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..7751c06 100644 --- a/packages/blocknumber/src/providers/evmBlockNumberProvider.ts +++ b/packages/blocknumber/src/providers/evmBlockNumberProvider.ts @@ -1,5 +1,5 @@ import { ILogger, UnixTimestamp } from "@ebo-agent/shared"; -import { Block, FallbackTransport, HttpTransport, PublicClient } from "viem"; +import { Block, BlockNotFoundError, FallbackTransport, HttpTransport, PublicClient } from "viem"; import { InvalidTimestamp, @@ -131,13 +131,15 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { const estimatedBlockTime = await this.estimateBlockTime(lastBlock, blocksLookback); const timestampDelta = lastBlock.timestamp - timestamp; - let candidateBlockNumber = lastBlock.number - timestampDelta / estimatedBlockTime; + let candidateBlockNumber = BigInt( + Math.floor(Number(lastBlock.number) - Number(timestampDelta) / estimatedBlockTime), + ); - const baseStep = (lastBlock.number - candidateBlockNumber) * deltaMultiplier; + const baseStep = Number(lastBlock.number - candidateBlockNumber) * Number(deltaMultiplier); this.logger.info("Calculating lower bound for binary search..."); - let searchCount = 0n; + let searchCount = 0; while (candidateBlockNumber >= 0) { const candidate = await this.client.getBlock({ blockNumber: candidateBlockNumber }); @@ -148,7 +150,7 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { } searchCount++; - candidateBlockNumber = lastBlock.number - baseStep * 2n ** searchCount; + candidateBlockNumber = BigInt(Number(lastBlock.number) - baseStep * 2 ** searchCount); } const firstBlock = await this.client.getBlock({ blockNumber: 0n }); @@ -171,10 +173,11 @@ 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 = + Number(lastBlock.timestamp - pastBlock.timestamp) / Number(blocksLookback); this.logger.info(`Estimated block time: ${estimatedBlockTime}.`); @@ -186,8 +189,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 +208,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.getNextBlockWithDifferentTimestamp(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 +245,55 @@ export class EvmBlockNumberProvider implements BlockNumberProvider { throw new TimestampNotFound(timestamp); } + + /** + * Get the next block searched moving sequentially and forward which has a different + * timestamp from `block`'s timestamp. + * + * @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 getNextBlockWithDifferentTimestamp( + 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");