evm-watcher
Version:
Fault tolerant event watcher for topics on the ethereum virtual machine.
232 lines (231 loc) • 12.7 kB
JavaScript
"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;