import { EvmBlock, EvmLogFilter, EvmLog, EvmTag } from "../../domain/entities"; import { EvmBlockRepository } from "../../domain/repositories"; import winston from "../log"; import { HttpClient } from "../http/HttpClient"; import { HttpClientError } from "../../domain/errors"; import { Config } from "../config"; import { DynamicStrategy } from "./strategies/DynamicStrategy"; /** * EvmJsonRPCBlockRepository is a repository that uses a JSON RPC endpoint to fetch blocks. * On the reliability side, only knows how to timeout. */ const HEXADECIMAL_PREFIX = "0x"; const CHAIN = "ethereum"; export class EvmJsonRPCBlockRepository implements EvmBlockRepository, DynamicStrategy { private readonly logger = winston.child({ module: "EvmJsonRPCBlockRepository" }); private httpClient: HttpClient; private cfg: Config; private rpc: URL; constructor(cfg: Config) { const repoCfg: EvmJsonRPCBlockRepositoryCfg = { chain: CHAIN, rpc: cfg.platforms[CHAIN].rpcs[0], timeout: cfg.platforms[CHAIN].timeout, }; this.logger = winston.child({ module: "EvmJsonRPCBlockRepository", chain: repoCfg.chain }); this.httpClient = new HttpClient({ retries: 3, timeout: cfg.platforms[CHAIN].timeout ?? 5_000, initialDelay: 1_000, maxDelay: 30_000, }); this.rpc = new URL(repoCfg.rpc); this.cfg = cfg; } apply(chain: string): boolean { return chain === CHAIN; } getName(): string { return `${CHAIN}-evmRepo`; } createInstance(): EvmJsonRPCBlockRepository { return this; } async getBlockHeight(finality: EvmTag): Promise { const block: EvmBlock = await this.getBlock(finality); return block.number; } /** * Get blocks by block number. * @param blockNumbers * @returns a record of block hash -> EvmBlock */ async getBlocks(blockNumbers: Set): Promise> { if (!blockNumbers.size) return {}; const reqs: any[] = []; for (let blockNumber of blockNumbers) { const blockNumberStrParam = `${HEXADECIMAL_PREFIX}${blockNumber.toString(16)}`; const blockNumberStrId = blockNumber.toString(); reqs.push({ jsonrpc: "2.0", id: blockNumberStrId, method: "eth_getBlockByNumber", params: [blockNumberStrParam, false], }); } let results: (undefined | { id: string; result?: EvmBlock; error?: ErrorBlock })[]; try { results = await this.httpClient.post(this.rpc.href, reqs); } catch (e: HttpClientError | any) { this.handleError(e, "eth_getBlockByNumber"); throw e; } if (results && results.length) { return results .map( ( response: undefined | { id: string; result?: EvmBlock; error?: ErrorBlock }, idx: number ) => { // Karura is getting 6969 errors for some blocks, so we'll just return empty blocks for those instead of throwing an error. // We take the timestamp from the previous block, which is not ideal but should be fine. if ( (response && response.result === null) || (response?.error && response.error?.code && response.error.code === 6969) ) { this.logger.warn; return { hash: "", number: BigInt(response.id), timestamp: Date.now(), }; } if ( response?.result && response.result?.hash && response.result.number && response.result.timestamp ) { return { hash: response.result.hash, number: BigInt(response.result.number), timestamp: Number(response.result.timestamp), }; } const msg = `Got error ${response?.error?.message} for eth_getBlockByNumber for ${ response?.id ?? reqs[idx].id } on ${this.rpc.hostname}`; this.logger.error(msg); throw new Error( `Unable to parse result of eth_getBlockByNumber for ${ response?.id ?? reqs[idx].id }: ${msg}` ); } ) .reduce((acc: Record, block: EvmBlock) => { acc[block.hash] = block; return acc; }, {}); } throw new Error( `Unable to parse ${ results?.length ?? 0 } blocks for eth_getBlockByNumber for numbers ${blockNumbers} on ${this.rpc.hostname}` ); } async getFilteredLogs(filter: EvmLogFilter): Promise { const parsedFilters = { topics: filter.topics, address: filter.addresses, fromBlock: `${HEXADECIMAL_PREFIX}${filter.fromBlock.toString(16)}`, toBlock: `${HEXADECIMAL_PREFIX}${filter.toBlock.toString(16)}`, }; let response: { result: Log[]; error?: ErrorBlock }; try { response = await this.httpClient.post(this.rpc.href, { jsonrpc: "2.0", method: "eth_getLogs", params: [parsedFilters], id: 1, }); } catch (e: HttpClientError | any) { this.handleError(e, "eth_getLogs"); throw e; } const logs = response?.result; this.logger.info( `Got ${logs?.length} logs for ${this.describeFilter(filter)} from ${this.rpc.hostname}` ); return logs.map((log) => ({ ...log, blockNumber: BigInt(log.blockNumber), transactionIndex: log.transactionIndex.toString(), })); } private describeFilter(filter: EvmLogFilter): string { return `[addresses:${filter.addresses}][topics:${filter.topics}][blocks:${filter.fromBlock} - ${filter.toBlock}]`; } /** * Loosely based on the wormhole-dashboard implementation (minus some specially crafted blocks when null result is obtained) */ private async getBlock(blockNumberOrTag: EvmTag): Promise { let response: { result?: EvmBlock; error?: ErrorBlock }; try { response = await this.httpClient.post(this.rpc.href, { jsonrpc: "2.0", method: "eth_getBlockByNumber", params: [blockNumberOrTag, false], // this means we'll get a light block (no txs) id: 1, }); } catch (e: HttpClientError | any) { this.handleError(e, "eth_getBlockByNumber"); throw e; } const result = response?.result; if (result && result.hash && result.number && result.timestamp) { // Convert to our domain compatible type return { number: BigInt(result.number), timestamp: Number(result.timestamp), hash: result.hash, }; } throw new Error( `Unable to parse result of eth_getBlockByNumber for ${blockNumberOrTag} on ${this.rpc}` ); } private handleError(e: any, method: string) { if (e instanceof HttpClientError) { this.logger.error( `Got ${e.status} from ${this.rpc.hostname}/${method}. ${e?.message ?? `${e?.message}`}` ); } else { this.logger.error(`Got error ${e} from ${this.rpc.hostname}/${method}`); } } } export type EvmJsonRPCBlockRepositoryCfg = { rpc: string; timeout?: number; chain: string; }; type ErrorBlock = { code: number; //6969, message: string; //'Error: No response received from RPC endpoint in 60s' }; type Log = { blockNumber: string; blockHash: string; transactionIndex: number; removed: boolean; address: string; data: string; topics: Array; transactionHash: string; logIndex: number; };