@bronlabs/intents-sdk
Version:
SDK for Intents DeFi smart contracts
253 lines • 10.1 kB
JavaScript
import { ethers, FetchRequest } from 'ethers';
import WebSocket from 'ws';
import { sleep, log } from './utils.js';
import { EventQueue } from './eventQueue.js';
import { initOrderEngine } from './contracts.js';
export class OrderIndexer {
constructor(config) {
this.debugTrace = (prefix) => (info) => {
if (info.action !== 'receiveRpcResult') {
log.debug(`${prefix}: ${info.action} -> ${info.payload?.method}`);
}
};
this.config = config;
const req = new FetchRequest(config.rpcUrl);
if (config.rpcAuthToken) {
req.setHeader('x-api-key', config.rpcAuthToken);
}
this.httpProvider = new ethers.JsonRpcProvider(req, undefined, { staticNetwork: true });
void this.httpProvider.on('debug', this.debugTrace('RPC'));
this.wsProvider = null;
this.wsOrderEngine = null;
this.orderEngine = initOrderEngine(config.orderEngineAddress, this.httpProvider);
this.eventQueue = new EventQueue();
this.processors = [];
this.isRunning = false;
this.lastProcessedBlock = 0;
this.isProcessingQueue = false;
this.reconnectTimeout = null;
this.rpcTimeoutMs = 15_000;
this.healthCheckInterval = null;
}
addProcessor(processor) {
this.processors.push(processor);
}
async start() {
if (this.isRunning) {
log.warn('Indexer is already running');
return;
}
this.isRunning = true;
log.info(`Starting indexer with offset -${this.config.startBlockOffset} blocks`);
try {
await this.startWebSocketListener();
}
catch (error) {
log.error('Error starting indexer:', error);
this.isRunning = false;
}
}
async stop() {
if (!this.isRunning) {
log.warn('Indexer is not running');
return;
}
log.info('Stopping indexer...');
this.isRunning = false;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.wsOrderEngine) {
void this.wsOrderEngine.removeAllListeners();
this.wsOrderEngine = null;
}
if (this.wsProvider) {
void this.wsProvider.removeAllListeners();
void this.wsProvider.destroy();
this.wsProvider = null;
}
const maxWaitTime = 30000;
const startTime = Date.now();
while (!this.eventQueue.isEmpty() && (Date.now() - startTime) < maxWaitTime) {
log.info(`Waiting for ${this.eventQueue.size()} events to complete before stopping`);
await sleep(1000);
}
if (!this.eventQueue.isEmpty()) {
log.warn(`Stopping indexer with ${this.eventQueue.size()} unprocessed events after timeout`);
}
log.info('Indexer stopped');
}
async processHistoricalEvents() {
const currentBlock = await this.withTimeout(this.httpProvider.getBlockNumber(), 'httpProvider.getBlockNumber');
if (!this.lastProcessedBlock) {
this.lastProcessedBlock = currentBlock - this.config.startBlockOffset;
}
if (currentBlock > this.lastProcessedBlock) {
log.info(`Processing historical events from ${this.lastProcessedBlock + 1} to ${currentBlock}`);
await this.processBlockRange(this.lastProcessedBlock + 1, currentBlock);
}
}
async startWebSocketListener() {
await this.processHistoricalEvents(); // Process historical events first
try {
const ws = new WebSocket(this.config.rpcUrl.replace(/^https?:\/\//, 'wss://'));
ws.on('error', error => {
if (!this.isRunning)
return;
log.error('WebSocket transport error:', error);
this.reconnectWebSocket();
});
ws.on('close', (code, reason) => {
if (!this.isRunning)
return;
log.warn(`WebSocket transport closed: ${code} ${reason?.toString?.() ?? ''}`);
this.reconnectWebSocket();
});
this.wsProvider = new ethers.WebSocketProvider(ws, undefined, { staticNetwork: true });
void this.wsProvider.on('debug', this.debugTrace('WS'));
this.wsOrderEngine = new ethers.Contract(this.config.orderEngineAddress, this.orderEngine.interface, this.wsProvider);
void this.wsOrderEngine.on('OrderStatusChanged', async (orderId, status, event) => {
if (!this.isRunning)
return;
this.eventQueue.add({
type: 'OrderStatusChanged',
data: {
orderId,
status: Number(status)
},
event,
retries: 0
});
this.lastProcessedBlock = event.blockNumber;
if (this.isRunning && !this.eventQueue.isEmpty()) {
await this.processEventQueue();
}
});
this.monitorWebSocketHealth();
log.info('WebSocket listener started');
}
catch (error) {
log.error('Error starting WebSocket:', error);
this.reconnectWebSocket();
}
}
monitorWebSocketHealth() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
if (!this.isRunning || !this.wsProvider) {
return;
}
try {
await this.withTimeout(this.wsProvider.getBlockNumber(), 'wsProvider.getBlockNumber (health check)');
}
catch (error) {
log.warn('WebSocket health check failed:', error);
this.reconnectWebSocket();
}
}, 60_000);
}
reconnectWebSocket() {
if (!this.isRunning || this.reconnectTimeout)
return;
if (this.wsOrderEngine) {
void this.wsOrderEngine.removeAllListeners();
this.wsOrderEngine = null;
}
if (this.wsProvider) {
void this.wsProvider.removeAllListeners();
void this.wsProvider.destroy();
this.wsProvider = null;
}
log.info('Reconnecting WebSocket in 5 seconds...');
this.reconnectTimeout = setTimeout(async () => {
this.reconnectTimeout = null;
if (this.isRunning) {
try {
await this.startWebSocketListener();
}
catch (error) {
log.error('Error reconnecting WebSocket:', error);
this.reconnectWebSocket();
}
}
}, 5000);
}
async withTimeout(promise, label) {
return await Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`RPC timeout in ${label} after ${this.rpcTimeoutMs}ms`)), this.rpcTimeoutMs))
]);
}
async processBlockRange(fromBlock, toBlock) {
const chunkSize = 2000;
for (let from = fromBlock; from <= toBlock; from += chunkSize) {
if (!this.isRunning)
break;
const to = Math.min(from + chunkSize - 1, toBlock);
try {
const events = await this.withTimeout(this.orderEngine.queryFilter(this.orderEngine.filters.OrderStatusChanged(), from, to), `orderEngine.queryFilter ${from}-${to}`);
for (const event of events) {
const { args: { orderId, status } } = this.orderEngine.interface.parseLog({
topics: event.topics,
data: event.data
});
this.eventQueue.add({
type: 'OrderStatusChanged',
data: {
orderId,
status: Number(status)
},
event: event,
retries: 0
});
}
this.lastProcessedBlock = to;
if (this.isRunning && !this.eventQueue.isEmpty()) {
await this.processEventQueue();
}
}
catch (error) {
log.error(`Error processing block range ${from}-${to}:`, error);
throw error;
}
}
}
async processEventQueue() {
if (this.isProcessingQueue || this.eventQueue.isEmpty() || this.processors.length === 0) {
return;
}
this.isProcessingQueue = true;
try {
while (!this.eventQueue.isEmpty()) {
const event = this.eventQueue.peek();
if (!event)
continue;
try {
for (const processor of this.processors) {
await processor(event);
}
this.eventQueue.remove();
}
catch (error) {
log.error(`Error processing event:`, error);
if (event.retries < this.config.maxRetries) {
event.retries++;
log.info(`Retrying event processing (${event.retries}/${this.config.maxRetries})`);
await sleep(this.config.retryDelay);
}
else {
log.error(`Max retries reached for event, removing from queue:`, event);
this.eventQueue.remove();
}
}
}
}
finally {
this.isProcessingQueue = false;
}
}
}
//# sourceMappingURL=orderIndexer.js.map