diff --git a/packages/blocknumber/package.json b/packages/blocknumber/package.json index baee2f5..58f3957 100644 --- a/packages/blocknumber/package.json +++ b/packages/blocknumber/package.json @@ -25,6 +25,7 @@ "dependencies": { "@ebo-agent/shared": "workspace:*", "axios": "1.7.7", + "bignumber.js": "9.1.2", "jwt-decode": "4.0.0", "viem": "2.17.10" }, diff --git a/packages/blocknumber/src/providers/blockNumberProviderFactory.ts b/packages/blocknumber/src/providers/blockNumberProviderFactory.ts index b630a71..3225486 100644 --- a/packages/blocknumber/src/providers/blockNumberProviderFactory.ts +++ b/packages/blocknumber/src/providers/blockNumberProviderFactory.ts @@ -10,7 +10,7 @@ import { EvmBlockNumberProvider } from "./evmBlockNumberProvider.js"; const DEFAULT_PROVIDER_CONFIG = { blocksLookback: 10_000n, - deltaMultiplier: 2n, + deltaMultiplier: 2, }; export class BlockNumberProviderFactory { diff --git a/packages/blocknumber/src/providers/evmBlockNumberProvider.ts b/packages/blocknumber/src/providers/evmBlockNumberProvider.ts index 73cebe6..3d835d9 100644 --- a/packages/blocknumber/src/providers/evmBlockNumberProvider.ts +++ b/packages/blocknumber/src/providers/evmBlockNumberProvider.ts @@ -1,5 +1,6 @@ import { ILogger, UnixTimestamp } from "@ebo-agent/shared"; -import { Block, FallbackTransport, HttpTransport, PublicClient } from "viem"; +import { BigNumber } from "bignumber.js"; +import { Block, BlockNotFoundError, FallbackTransport, HttpTransport, PublicClient } from "viem"; import { InvalidTimestamp, @@ -12,7 +13,7 @@ import { import { BlockNumberProvider } from "./blockNumberProvider.js"; const BINARY_SEARCH_BLOCKS_LOOKBACK = 10_000n; -const BINARY_SEARCH_DELTA_MULTIPLIER = 2n; +const BINARY_SEARCH_DELTA_MULTIPLIER = 2; type BlockWithNumber = Omit & { number: bigint }; @@ -26,7 +27,7 @@ interface SearchConfig { * Multiplier to apply to the step, used while scanning blocks backwards, to find a * lower bound block. */ - deltaMultiplier: bigint; + deltaMultiplier: number; } export class EvmBlockNumberProvider implements BlockNumberProvider { @@ -45,7 +46,7 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { */ constructor( client: PublicClient>, - searchConfig: { blocksLookback?: bigint; deltaMultiplier?: bigint }, + searchConfig: { blocksLookback?: bigint; deltaMultiplier?: number }, private logger: ILogger, ) { this.client = client; @@ -129,17 +130,24 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { private async calculateLowerBoundBlock(timestamp: UnixTimestamp, lastBlock: BlockWithNumber) { const { blocksLookback, deltaMultiplier } = this.searchConfig; - const estimatedBlockTime = await this.estimateBlockTime(lastBlock, blocksLookback); - const timestampDelta = lastBlock.timestamp - timestamp; - let candidateBlockNumber = lastBlock.number - timestampDelta / estimatedBlockTime; + const estimatedBlockTimeBN = await this.estimateBlockTime(lastBlock, blocksLookback); + const timestampDeltaBN = new BigNumber((lastBlock.timestamp - timestamp).toString()); - const baseStep = (lastBlock.number - candidateBlockNumber) * deltaMultiplier; + let candidateBlockNumberBN = new BigNumber(lastBlock.number.toString()) + .minus(timestampDeltaBN.dividedBy(estimatedBlockTimeBN)) + .integerValue(); + + const baseStepBN = new BigNumber(lastBlock.number.toString()) + .minus(candidateBlockNumberBN) + .multipliedBy(deltaMultiplier); this.logger.info("Calculating lower bound for binary search..."); - let searchCount = 0n; - while (candidateBlockNumber >= 0) { - const candidate = await this.client.getBlock({ blockNumber: candidateBlockNumber }); + let searchCount = 0; + while (candidateBlockNumberBN.isGreaterThanOrEqualTo(0)) { + const candidate = await this.client.getBlock({ + blockNumber: BigInt(candidateBlockNumberBN.toString()), + }); if (candidate.timestamp < timestamp) { this.logger.info(`Estimated lower bound at block ${candidate.number}.`); @@ -148,7 +156,10 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { } searchCount++; - candidateBlockNumber = lastBlock.number - baseStep * 2n ** searchCount; + + candidateBlockNumberBN = new BigNumber(lastBlock.number.toString()).minus( + baseStepBN.multipliedBy(2 ** searchCount), + ); } const firstBlock = await this.client.getBlock({ blockNumber: 0n }); @@ -171,10 +182,12 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { this.logger.info("Estimating block time..."); const pastBlock = await this.client.getBlock({ - blockNumber: lastBlock.number - BigInt(blocksLookback), + blockNumber: lastBlock.number - blocksLookback, }); - const estimatedBlockTime = (lastBlock.timestamp - pastBlock.timestamp) / blocksLookback; + const estimatedBlockTime = new BigNumber( + (lastBlock.timestamp - pastBlock.timestamp).toString(), + ).dividedBy(blocksLookback.toString()); this.logger.info(`Estimated block time: ${estimatedBlockTime}.`); @@ -186,8 +199,7 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { * * @param timestamp timestamp to find the block for * @param between blocks search space - * @throws {UnsupportedBlockTimestamps} when two consecutive blocks with the same timestamp are found - * during the search. These chains are not supported at the moment. + * @throws {UnsupportedBlockTimestamps} throw if a block has a smaller timestamp than a previous block. * @throws {TimestampNotFound} when the search is finished and no block includes the searched timestamp * @returns the block number */ @@ -206,25 +218,34 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { currentBlockNumber = (high + low) / 2n; const currentBlock = await this.client.getBlock({ blockNumber: currentBlockNumber }); - const nextBlock = await this.client.getBlock({ blockNumber: currentBlockNumber + 1n }); + const nextBlock = await this.searchNextBlockWithDifferentTimestamp(currentBlock); this.logger.debug( `Analyzing block number #${currentBlock.number} with timestamp ${currentBlock.timestamp}`, ); - // We do not support blocks with equal timestamps (nor non linear or non sequential chains). - // We could support same timestamps blocks by defining a criteria based on block height - // apart from their timestamps. - if (nextBlock.timestamp <= currentBlock.timestamp) + // If no next block with a different timestamp is defined to ensure that the + // searched timestamp is between two blocks, it won't be possible to answer. + // + // As an example, if the latest block has timestamp 1 and we are looking for timestamp 10, + // the next block could have timestamp 2. + if (!nextBlock) throw new TimestampNotFound(timestamp); + + // Non linear or non sequential chains are not supported. + if (nextBlock.timestamp < currentBlock.timestamp) throw new UnsupportedBlockTimestamps(timestamp); + const isCurrentBlockBeforeOrAtTimestamp = currentBlock.timestamp <= timestamp; + const isNextBlockAfterTimestamp = nextBlock.timestamp > timestamp; const blockContainsTimestamp = - currentBlock.timestamp <= timestamp && nextBlock.timestamp > timestamp; + isCurrentBlockBeforeOrAtTimestamp && isNextBlockAfterTimestamp; if (blockContainsTimestamp) { this.logger.debug(`Block #${currentBlock.number} contains timestamp.`); - return currentBlock.number; + const result = await this.searchFirstBlockWithEqualTimestamp(currentBlock); + + return result.number; } else if (currentBlock.timestamp <= timestamp) { low = currentBlockNumber + 1n; } else { @@ -234,4 +255,55 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { throw new TimestampNotFound(timestamp); } + + /** + * Find the next block with a different timestamp than `block`, moving sequentially forward + * through the blockchain. + * + * @param block a `Block` with a number and a timestamp. + * @returns a `Block` with a different timestamp, or `null` if no block with different timestamp was found. + */ + private async searchNextBlockWithDifferentTimestamp( + block: BlockWithNumber, + ): Promise { + let nextBlock: BlockWithNumber = block; + + try { + while (nextBlock.timestamp === block.timestamp) { + nextBlock = await this.client.getBlock({ blockNumber: nextBlock.number + 1n }); + } + + return nextBlock; + } catch (err) { + if (err instanceof BlockNotFoundError) { + // This covers the case where the search surpasses the latest block + // and no more blocks are found by block number. + return null; + } else { + throw err; + } + } + } + + /** + * Search the block with the lowest height that has the same timestamp as `block`. + * + * @param block the block to use in the search + * @returns a block with the same timestamp as `block` and with the lowest height. + */ + private async searchFirstBlockWithEqualTimestamp( + block: BlockWithNumber, + ): Promise { + let prevBlock: BlockWithNumber = block; + let candidateBlock: BlockWithNumber = block; + + do { + if (prevBlock.number === 0n) return prevBlock; + + candidateBlock = prevBlock; + prevBlock = await this.client.getBlock({ blockNumber: prevBlock.number - 1n }); + } while (prevBlock.timestamp === block.timestamp); + + return candidateBlock; + } } diff --git a/packages/blocknumber/test/providers/evmBlockNumberProvider.spec.ts b/packages/blocknumber/test/providers/evmBlockNumberProvider.spec.ts index 566f0d8..66eb0d7 100644 --- a/packages/blocknumber/test/providers/evmBlockNumberProvider.spec.ts +++ b/packages/blocknumber/test/providers/evmBlockNumberProvider.spec.ts @@ -1,5 +1,5 @@ import { ILogger, UnixTimestamp } from "@ebo-agent/shared"; -import { Block, createPublicClient, GetBlockParameters, http } from "viem"; +import { Block, BlockNotFoundError, createPublicClient, GetBlockParameters, http } from "viem"; import { mainnet } from "viem/chains"; import { describe, expect, it, vi } from "vitest"; @@ -7,7 +7,6 @@ import { InvalidTimestamp, LastBlockEpoch, UnsupportedBlockNumber, - UnsupportedBlockTimestamps, } from "../../src/exceptions/index.js"; import { EvmBlockNumberProvider } from "../../src/providers/evmBlockNumberProvider.js"; @@ -80,11 +79,12 @@ describe("EvmBlockNumberProvider", () => { ); }); - it("fails when finding multiple blocks with the same timestamp", () => { + it("returns the first one when finding multiple blocks with the same timestamp", async () => { const timestamp = BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)) as UnixTimestamp; - const afterTimestamp = BigInt(Date.UTC(2024, 1, 2, 0, 0, 0, 0)); + const prevTimestamp = timestamp - 1n; + const afterTimestamp = timestamp + 1n; const rpcProvider = mockRpcProviderBlocks([ - { number: 0n, timestamp: timestamp }, + { number: 0n, timestamp: prevTimestamp }, { number: 1n, timestamp: timestamp }, { number: 2n, timestamp: timestamp }, { number: 3n, timestamp: timestamp }, @@ -93,9 +93,9 @@ describe("EvmBlockNumberProvider", () => { evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig, logger); - expect(evmProvider.getEpochBlockNumber(timestamp)).rejects.toBeInstanceOf( - UnsupportedBlockTimestamps, - ); + const result = await evmProvider.getEpochBlockNumber(timestamp); + + expect(result).toEqual(1n); }); it("fails when finding a block with no number", () => { @@ -158,11 +158,17 @@ function mockRpcProviderBlocks(blocks: Pick[]) { .fn() .mockImplementation((args?: GetBlockParameters | undefined) => { if (args?.blockTag == "finalized") { - return Promise.resolve(blocks[blocks.length - 1]); + const block = blocks[blocks.length - 1]; + + return Promise.resolve(block); } else if (args?.blockNumber !== undefined) { const blockNumber = Number(args.blockNumber); + const block = blocks[blockNumber]; + + if (block === undefined) + throw new BlockNotFoundError({ blockNumber: BigInt(blockNumber) }); - return Promise.resolve(blocks[blockNumber]); + return Promise.resolve(block); } throw new Error("Unhandled getBlock mock case"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8810a4..ec19c87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: axios: specifier: 1.7.7 version: 1.7.7 + bignumber.js: + specifier: 9.1.2 + version: 9.1.2 jwt-decode: specifier: 4.0.0 version: 4.0.0 @@ -1991,6 +1994,12 @@ packages: integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, } + bignumber.js@9.1.2: + resolution: + { + integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==, + } + brace-expansion@1.1.11: resolution: { @@ -5931,6 +5940,8 @@ snapshots: base64-js@1.5.1: {} + bignumber.js@9.1.2: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2