Skip to content

Commit

Permalink
fix: handle chains with blocks with same timestamp
Browse files Browse the repository at this point in the history
  • Loading branch information
0xyaco committed Oct 23, 2024
1 parent 65ff4c3 commit 30857c1
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { EvmBlockNumberProvider } from "./evmBlockNumberProvider.js";

const DEFAULT_PROVIDER_CONFIG = {
blocksLookback: 10_000n,
deltaMultiplier: 2n,
deltaMultiplier: 2,
};

export class BlockNumberProviderFactory {
Expand Down
94 changes: 78 additions & 16 deletions packages/blocknumber/src/providers/evmBlockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });

Expand All @@ -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 });
Expand All @@ -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}.`);

Expand All @@ -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
*/
Expand All @@ -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 {
Expand All @@ -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<BlockWithNumber | null> {
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<BlockWithNumber> {
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;
}
}
26 changes: 16 additions & 10 deletions packages/blocknumber/test/providers/evmBlockNumberProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
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";

import {
InvalidTimestamp,
LastBlockEpoch,
UnsupportedBlockNumber,
UnsupportedBlockTimestamps,
} from "../../src/exceptions/index.js";
import { EvmBlockNumberProvider } from "../../src/providers/evmBlockNumberProvider.js";

Expand Down Expand Up @@ -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 },
Expand All @@ -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", () => {
Expand Down Expand Up @@ -158,11 +158,17 @@ function mockRpcProviderBlocks(blocks: Pick<Block, "timestamp" | "number">[]) {
.fn()
.mockImplementation((args?: GetBlockParameters<false, "finalized"> | 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");
Expand Down

0 comments on commit 30857c1

Please sign in to comment.