UNPKG

evm-watcher

Version:

Fault tolerant event watcher for topics on the ethereum virtual machine.

232 lines (231 loc) 12.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.keepAlive = exports.EvmWatcher = exports.getProvider = void 0; const tslib_1 = require("tslib"); const ethers_1 = require("ethers"); const benchmark_1 = require("./util/benchmark"); const logger_1 = tslib_1.__importDefault(require("./util/logger")); /** * * @param {SupportedNetworks} network * @param {boolean} useWebsocket * @return {providers.WebSocketProvider|providers.JsonRpcProvider} */ const getProvider = (url, useWebsocket) => { logger_1.default.info(`NETWORK: ${url}`); if (useWebsocket) { return new ethers_1.providers.WebSocketProvider(url); } return new ethers_1.providers.JsonRpcProvider(url); }; exports.getProvider = getProvider; /** * EvmWatcher * Great for when you need to hunt thru the blockchain for a specific event, * Eth Watcher is a class which can be used to track an EVM blockchain's log events in a failsafe and serial way. * It's a "bring your own state" sort of module, where you can just pass in an object which implements a common interface to update * lastBlock, and other data. * * Check out the detailed documentation for examples, and deeper explanations. Happy hacking. */ class EvmWatcher { /** * * @param {DataAccessObject} param.dao (required) The data access object used for state management. * @param {WorkerState} param.initialParams (required) Initial params for this process. * @param {winston.Logger} param.customLogger (optional) default logger. Initial params for this process. * @param {number} param.sleepTime Optional. Default = 30000 (30 seconds). Number of Ms to sleep after we catch up to the latest block. * @param {SupportedNetwork} param.network Optional. Default = 1. aka: ETHEREUM_MAINNET. */ constructor({ dao, initialParams, customLogger, sleepTime = 30000, onError = () => { }, onComplete = () => { }, url, useWebSockets = false, buffer = 0, }) { var _a, _b; this.lastBlockProcessed = 0; if (((_a = this.initialParams) === null || _a === void 0 ? void 0 : _a.maxLogBatchSize) && ((_b = this.initialParams) === null || _b === void 0 ? void 0 : _b.maxLogBatchSize) < 1) throw new Error('ParameterError: maxLogBatchSize must be greater than 0.'); this.dao = dao; this.initialParams = initialParams; this.logger = customLogger || logger_1.default; this.sleepTime = sleepTime; this.nextStartBlock = undefined; this.provider = exports.getProvider(url, useWebSockets); this.onError = onError; this.onComplete = onComplete; this.useWebSockets = useWebSockets; if (buffer < 0) throw new Error('ParameterError: Buffer must be greater than 0.'); this.buffer = buffer; if (useWebSockets) { exports.keepAlive({ provider: this.provider, onDisconnect: (err) => tslib_1.__awaiter(this, void 0, void 0, function* () { this.logger.error('The ws connection was closed', JSON.stringify(err, null, 2)); this.provider.destroy(); process.exit(2); }), }); } } /** * @param {number} ms ms to sleep. * @return {void} void */ sleep(ms) { return tslib_1.__awaiter(this, void 0, void 0, function* () { return new Promise((res) => { this.logger.info(`Reached latestBlock. Sleeping for ${ms}ms.`); if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(res, ms); }); }); } /** * * @param {ethers.EventFilter} eventFilter * @param {Function} logProcessingFunction * @return {void} void */ onLogEvent(eventFilter, logProcessingFunction) { var _a; return tslib_1.__awaiter(this, void 0, void 0, function* () { try { while (true) { if (this.timer) clearTimeout(this.timer); yield this.loop(eventFilter, logProcessingFunction); if ((_a = this.initialParams) === null || _a === void 0 ? void 0 : _a.endBlock) { if (this.useWebSockets) yield this.provider.destroy(); this.logger.info('DONE'); yield this.onComplete(); return; } yield this.sleep(this.sleepTime); } } catch (e) { this.logger.error(`ERROR: ${e.message}`); if (this.useWebSockets) yield this.provider.destroy(); return yield this.onError(e); } }); } /** * * @param {ethers.EventFilter} eventFilter * @param {Function} logProcessingFunction * @return {void} void */ loop(eventFilter, logProcessingFunction) { var _a, _b, _c, _d, _e, _f; return tslib_1.__awaiter(this, void 0, void 0, function* () { logger_1.default.verbose('--- new loop ---'); const evmLastBlock = yield this.provider.getBlockNumber(); const evmLastBlockWithBuffer = evmLastBlock - this.buffer; this.logger.info(`Latest EVM Block: ${evmLastBlock}`); if (this.buffer) this.logger.info(`Latest EVM Block with buffer=${this.buffer}: ${evmLastBlockWithBuffer}`); let startBlock = Number(this.nextStartBlock || (((_a = this.initialParams) === null || _a === void 0 ? void 0 : _a.startBlock) === 0 ? '0' : (_b = this.initialParams) === null || _b === void 0 ? void 0 : _b.startBlock) || evmLastBlockWithBuffer); const workerState = yield this.dao.getWorkerState({ startBlock: startBlock, endBlock: (_c = this.initialParams) === null || _c === void 0 ? void 0 : _c.endBlock }); const endBlock = ((_d = this.initialParams) === null || _d === void 0 ? void 0 : _d.endBlock) !== undefined ? (_e = this.initialParams) === null || _e === void 0 ? void 0 : _e.endBlock : evmLastBlockWithBuffer; if (!this.nextStartBlock && workerState && workerState.lastBlockProcessed && workerState.lastBlockProcessed !== startBlock) { this.logger.info(`Fast-forwarding to last processed block: ${workerState.lastBlockProcessed}.`); startBlock = workerState.lastBlockProcessed + 1; } let logSpanEnd; if (this.lastBlockProcessed === evmLastBlockWithBuffer) { this.logger.info(`No new blocks.`); return; // Exit the loop if we are at the head of the blockchain. } while (startBlock <= endBlock) { // Calculating the end of the log-span is more complex than you might think. // Firstly, they are 0 based. So a log-span of 0 is possible, (aka: a log-span // of only 1 block.) Additionally, regardless of a log-span request, we may have // fewer than "block-span" blocks left to process. We need then to take the min // value between the zero-based `maxLogBatchSize` and `blocksLeft`. // To further complicate it, we need to ensure we never select a span larger // than the endBlock. So we need to take the min of `endBlock` and that previous // calculation. const numOfBlocksLeftOnTheBlockchain = evmLastBlockWithBuffer - startBlock; const zeroBasedLastBlockInitialParam = (((_f = this.initialParams) === null || _f === void 0 ? void 0 : _f.maxLogBatchSize) || 10) - 1; const maxRequestedBoundedByAvailableBlocks = Math.min(zeroBasedLastBlockInitialParam, numOfBlocksLeftOnTheBlockchain); logSpanEnd = startBlock + Math.min(maxRequestedBoundedByAvailableBlocks, zeroBasedLastBlockInitialParam); const zeroBasedLogSpanEnd = Math.max(startBlock, logSpanEnd); this.logger.info(`Requesting log span: ${startBlock}-${zeroBasedLogSpanEnd}. Span: ${zeroBasedLogSpanEnd - startBlock + 1}`); // removing until ethers supports a list of addresses for the address arg // const logs = await this.provider.getLogs({ // fromBlock: startBlock, // toBlock: zeroBasedLogSpanEnd, // ...eventFilter, // }); // we need to use send('eth_getLogs', ...) because we want to filter by a list of contract addresses which is allowed in the RPC method, // however, ethers.js does not support a list of addresses. // eth_getLogs RPC method: https://www.quicknode.com/docs/ethereum/eth_getLogs // associated GitHub issue: https://github.com/ethers-io/ethers.js/issues/473 const logs = yield this.provider.send('eth_getLogs', [Object.assign({ fromBlock: ethers_1.ethers.BigNumber.from(startBlock).toHexString(), toBlock: ethers_1.ethers.BigNumber.from(zeroBasedLogSpanEnd).toHexString() }, eventFilter)]); this.nextStartBlock = logSpanEnd + 1; this.logger.info(`Log count: ${logs.length}.`); for (let i = 0; i < logs.length; i += 1) { const log = logs[i]; log.blockNumber = ethers_1.ethers.BigNumber.from(log.blockNumber).toNumber(); log.logIndex = ethers_1.ethers.BigNumber.from(log.logIndex).toNumber(); log.transactionIndex = ethers_1.ethers.BigNumber.from(log.transactionIndex).toNumber(); yield benchmark_1.benchmarkGroupAverage(log.blockNumber, (isNewBlock, averageBlockTime, ms) => tslib_1.__awaiter(this, void 0, void 0, function* () { if (isNewBlock) { if (ms) this.logger.info(`Benchmark: ${ms}. Average: ${averageBlockTime}.`); const lastBlockProcessed = log.blockNumber - 1; yield this.dao.setLastBlockProcessed(lastBlockProcessed); // tell the caller. this.lastBlockProcessed = lastBlockProcessed; // local state. } yield logProcessingFunction(log, isNewBlock); })); } // This additional complete block is unfortunately the way to log/track the last block of the for loop. yield this.dao.setLastBlockProcessed(zeroBasedLogSpanEnd); // tell the caller this.lastBlockProcessed = zeroBasedLogSpanEnd; // local state. startBlock += zeroBasedLogSpanEnd - startBlock + 1; benchmark_1.resetBenchmark(); } }); } } exports.EvmWatcher = EvmWatcher; /** * This function is meant to address any intermittent socket closure events caused by * evil sexy saboteur clown ghosts that roam the internet. To ensure the websocket remains active, * we ping every 15 seconds, If we don't get a response, we let the caller re-connect, or * (in our case) choose to explode. */ const keepAlive = ({ provider, onDisconnect, expectedPongBack = 15000, checkInterval = 7500 }) => { let pingTimeout = null; let keepAliveInterval = null; provider._websocket.on('open', () => { keepAliveInterval = setInterval(() => { logger_1.default.verbose('Websocket PING sent.'); provider._websocket.ping(); // Use `WebSocket#terminate()`, which immediately destroys the connection, // instead of `WebSocket#close()`, which waits for the close timer. // Delay should be equal to the interval at which your server // sends out pings plus a conservative assumption of the latency. pingTimeout = setTimeout(() => { provider._websocket.terminate(); }, expectedPongBack); }, checkInterval); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any provider._websocket.on('close', (err) => { if (keepAliveInterval) clearInterval(keepAliveInterval); if (pingTimeout) clearTimeout(pingTimeout); onDisconnect(err); }); provider._websocket.on('pong', () => { logger_1.default.verbose('Websocket PONG received.'); if (pingTimeout) clearInterval(pingTimeout); }); }; exports.keepAlive = keepAlive;