Skip to content

Commit

Permalink
refactor: client and search params in evm provider constructor
Browse files Browse the repository at this point in the history
  • Loading branch information
0xyaco committed Jul 25, 2024
1 parent 711216d commit c7a9035
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 58 deletions.
11 changes: 11 additions & 0 deletions packages/blocknumber/src/exceptions/lastBlockEpoch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Block } from "viem";

export class LastBlockEpoch extends Error {
constructor(block: Block) {
super(
`Cannot specify the start of the epoch with the last block only (number: ${block.number}), wait for it to be finalized.`,
);

this.name = "LastBlockEpoch";
}
}
9 changes: 9 additions & 0 deletions packages/blocknumber/src/exceptions/unexpectedSearchRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class UnexpectedSearchRange extends Error {
constructor(low: bigint, high: bigint) {
super(
`Lower bound of search range (${low}) must be less than or equal to upper bound (${high})`,
);

this.name = "UnexpectedSearchRange";
}
}
8 changes: 5 additions & 3 deletions packages/blocknumber/src/providers/blockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export interface BlockNumberProvider {
/**
* Get the epoch block number on a chain at a specific timestamp.
* Get the block number corresponding to the beginning of the epoch.
*
* The input timestamp falls between the timestamps of the found block and
* the immediately following block.
*
* @param timestamp UTC timestamp in ms since UNIX epoch
* @param url url of the chain data provider
*
* @returns the corresponding block number of a chain at a specific timestamp
*/
getEpochBlockNumber(timestamp: number, searchParams: unknown): Promise<bigint>;
getEpochBlockNumber(timestamp: number): Promise<bigint>;
}
128 changes: 108 additions & 20 deletions packages/blocknumber/src/providers/evmBlockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,63 @@
import { Block, PublicClient } from "viem";
import { Block, fromBlobs, PublicClient } from "viem";

import {
TimestampNotFound,
UnsupportedBlockNumber,
UnsupportedBlockTimestamps,
} from "../exceptions/index.js";
import { LastBlockEpoch } from "../exceptions/lastBlockEpoch.js";
import { UnexpectedSearchRange } from "../exceptions/unexpectedSearchRange.js";
import logger from "../utils/logger.js";
import { BlockNumberProvider } from "./blockNumberProvider.js";

const BINARY_SEARCH_BLOCKS_LOOKBACK = 10_000n;
const BINARY_SEARCH_DELTA_MULTIPLIER = 2n;

type BlockWithNumber = Omit<Block, "number"> & { number: bigint };

export class EvmBlockNumberProvider implements BlockNumberProvider {
client: PublicClient;
interface SearchConfig {
/**
* Indicates how many blocks should be used for estimating the chain's block time
*/
blocksLookback: bigint;

/**
* Multiplier to apply to the step, used while scanning blocks backwards, to find a
* lower bound block.
*/
deltaMultiplier: bigint;
}

constructor(client: PublicClient) {
export class EvmBlockNumberProvider implements BlockNumberProvider {
private client: PublicClient;
private searchConfig: SearchConfig;
private firstBlock: Block;

/**
* Creates a new instance of PublicClient.
*
* @param client the viem client to use for EVM compatible RPC node calls.
* @param searchConfig.blocksLookback amount of blocks that should be used for
* estimating the chain's block time. Defaults to 10.000 blocks.
* @param searchConfig.deltaMultiplier multiplier to apply to the step, used
* while scanning blocks backwards during lower bound search. Defaults to 2.
*/
constructor(
client: PublicClient,
searchConfig: { blocksLookback?: bigint; deltaMultiplier?: bigint },
) {
this.client = client;
this.searchConfig = {
blocksLookback: searchConfig.blocksLookback ?? BINARY_SEARCH_BLOCKS_LOOKBACK,
deltaMultiplier: searchConfig.deltaMultiplier ?? BINARY_SEARCH_DELTA_MULTIPLIER,
};
}

async getEpochBlockNumber(
timestamp: number,
searchParams: { blocksLookback: bigint },
): Promise<bigint> {
async getEpochBlockNumber(timestamp: number): Promise<bigint> {
// An optimized binary search is used to look for the epoch block.
const _timestamp = BigInt(timestamp);

// The EBO agent looks only for finalized blocks to avoid handling reorgs
const upperBoundBlock = await this.client.getBlock({ blockTag: "finalized" });

this.validateBlockNumber(upperBoundBlock);
Expand All @@ -32,36 +66,71 @@ export class EvmBlockNumberProvider implements BlockNumberProvider {
`Working with latest block (number: ${upperBoundBlock.number}, timestamp: ${upperBoundBlock.timestamp})...`,
);

if (_timestamp >= upperBoundBlock.timestamp) return upperBoundBlock.number;
const firstBlock = await this.getFirstBlock();

const lowerBoundBlock = await this.calculateLowerBoundBlock(
_timestamp,
upperBoundBlock,
searchParams.blocksLookback,
);
if (_timestamp < firstBlock.timestamp) throw new TimestampNotFound(_timestamp);
if (_timestamp >= upperBoundBlock.timestamp) throw new LastBlockEpoch(upperBoundBlock);

// Reduces the search space by estimating a lower bound for the binary search.
//
// Performing a binary search between block 0 and last block is not efficient.
const lowerBoundBlock = await this.calculateLowerBoundBlock(_timestamp, upperBoundBlock);

// Searches for the timestamp with a binary search
return this.searchTimestamp(_timestamp, {
fromBlock: lowerBoundBlock.number,
toBlock: upperBoundBlock.number,
});
}

/**
* Fetches and caches the first block. Cached block will be returned if the cache is hit.
*
* @returns the chain's first block
*/
private async getFirstBlock(): Promise<Block> {
if (this.firstBlock !== undefined) return this.firstBlock;

this.firstBlock = await this.client.getBlock({ blockNumber: 0n });

return this.firstBlock;
}

/**
* Validates that a block contains a non-null number
*
* @param block viem block
* @throws {UnsupportedBlockNumber} when block contains a null number
* @returns true if the block contains a non-null number
*/
private validateBlockNumber(block: Block): block is BlockWithNumber {
if (block.number === null) throw new UnsupportedBlockNumber(block.timestamp);

return true;
}

private async calculateLowerBoundBlock(
timestamp: bigint,
lastBlock: BlockWithNumber,
blocksLookback: bigint = 10_000n,
) {
/**
* Searches for an efficient lower bound to run the binary search, leveraging that
* the epoch start tends to be relatively near the last block.
*
* The amount of blocks to look back from the last block is estimated, using an
* estimated block-time based on the last `searchConfig.blocksLookback` blocks.
*
* Until a block with a timestamp before the input timestamp is found, backward
* exponentially grown steps are performed.
*
* @param timestamp timestamp of the epoch start
* @param lastBlock last block of the chain
* @returns an optimized lower bound for a binary search space
*/
private async calculateLowerBoundBlock(timestamp: bigint, 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 baseStep = (lastBlock.number - candidateBlockNumber) * BINARY_SEARCH_DELTA_MULTIPLIER;
const baseStep = (lastBlock.number - candidateBlockNumber) * deltaMultiplier;

logger.info("Calculating lower bound for binary search...");

Expand All @@ -88,6 +157,13 @@ export class EvmBlockNumberProvider implements BlockNumberProvider {
throw new TimestampNotFound(timestamp);
}

/**
* Estimates the chain's block time based on the last `blocksLookback` blocks.
*
* @param lastBlock last chain block
* @param blocksLookback amount of blocks to look back
* @returns the estimated block time
*/
private async estimateBlockTime(lastBlock: BlockWithNumber, blocksLookback: bigint) {
logger.info("Estimating block time...");

Expand All @@ -102,13 +178,25 @@ export class EvmBlockNumberProvider implements BlockNumberProvider {
return estimatedBlockTime;
}

/**
* Performs a binary search in the specified block range to find the block corresponding to a timestamp.
*
* @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 {TimestampNotFound} when the search is finished and no block includes the searched timestamp
* @returns the block number
*/
private async searchTimestamp(
timestamp: bigint,
between: { fromBlock: bigint; toBlock: bigint },
) {
let currentBlockNumber: bigint;
let { fromBlock: low, toBlock: high } = between;

if (low > high) throw new UnexpectedSearchRange(low, high);

logger.debug(`Starting block binary search for timestamp ${timestamp}...`);

while (low <= high) {
Expand Down
60 changes: 25 additions & 35 deletions packages/blocknumber/test/providers/evmBlockNumberProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Block, createPublicClient, GetBlockParameters, http } from "viem";
import { mainnet } from "viem/chains";
import { describe, expect, it, vi } from "vitest";

import { LastBlockEpoch } from "../../src/exceptions/lastBlockEpoch.js";
import { TimestampNotFound } from "../../src/exceptions/timestampNotFound.js";
import { UnsupportedBlockNumber } from "../../src/exceptions/unsupportedBlockNumber.js";
import { UnsupportedBlockTimestamps } from "../../src/exceptions/unsupportedBlockTimestamps.js";
import { EvmBlockNumberProvider } from "../../src/providers/evmBlockNumberProvider.js";

describe("EvmBlockNumberProvider", () => {
describe("getEpochBlockNumber", () => {
const searchConfig = { blocksLookback: 2n, deltaMultiplier: 2n };
let evmProvider: EvmBlockNumberProvider;

it("returns the first of two consecutive blocks when their timestamp contains the searched timestamp", async () => {
Expand All @@ -17,12 +19,10 @@ describe("EvmBlockNumberProvider", () => {
const endTimestamp = Date.UTC(2024, 1, 11, 0, 0, 0, 0);
const rpcProvider = mockRpcProvider(blockNumber, startTimestamp, endTimestamp);

evmProvider = new EvmBlockNumberProvider(rpcProvider);
evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig);

const day5 = Date.UTC(2024, 1, 5, 2, 0, 0, 0);
const epochBlockNumber = await evmProvider.getEpochBlockNumber(day5, {
blocksLookback: 2n,
});
const epochBlockNumber = await evmProvider.getEpochBlockNumber(day5);

expect(epochBlockNumber).toEqual(4n);
});
Expand All @@ -33,31 +33,27 @@ describe("EvmBlockNumberProvider", () => {
const endTimestamp = Date.UTC(2024, 1, 1, 0, 0, 11, 0);
const rpcProvider = mockRpcProvider(lastBlockNumber, startTimestamp, endTimestamp);

evmProvider = new EvmBlockNumberProvider(rpcProvider);
evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig);

const exactDay5 = Date.UTC(2024, 1, 1, 0, 0, 5, 0);
const epochBlockNumber = await evmProvider.getEpochBlockNumber(exactDay5, {
blocksLookback: 2n,
});
const epochBlockNumber = await evmProvider.getEpochBlockNumber(exactDay5);

expect(epochBlockNumber).toEqual(4n);
});

it("returns the last block if timestamp is after the block's timestamp", async () => {
it("throws if the search timestamp is after the last block's timestamp", async () => {
const lastBlockNumber = 10n;
const startTimestamp = Date.UTC(2024, 1, 1, 0, 0, 0, 0);
const endTimestamp = Date.UTC(2024, 1, 1, 0, 0, 11, 0);
const rpcProvider = mockRpcProvider(lastBlockNumber, startTimestamp, endTimestamp);

evmProvider = new EvmBlockNumberProvider(rpcProvider);
evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig);

const futureTimestamp = Date.UTC(2025, 1, 1, 0, 0, 0, 0);

const blockNumber = await evmProvider.getEpochBlockNumber(futureTimestamp, {
blocksLookback: 2n,
});

expect(blockNumber).toEqual(lastBlockNumber);
expect(evmProvider.getEpochBlockNumber(futureTimestamp)).rejects.toBeInstanceOf(
LastBlockEpoch,
);
});

it("fails if the timestamp is before the first block", async () => {
Expand All @@ -66,15 +62,13 @@ describe("EvmBlockNumberProvider", () => {
const endTimestamp = Date.UTC(2024, 1, 1, 0, 0, 11, 0);
const rpcProvider = mockRpcProvider(lastBlockNumber, startTimestamp, endTimestamp);

evmProvider = new EvmBlockNumberProvider(rpcProvider);
evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig);

const futureTimestamp = Date.UTC(1970, 1, 1, 0, 0, 0, 0);

expect(
evmProvider.getEpochBlockNumber(futureTimestamp, {
blocksLookback: 2n,
}),
).rejects.toBeInstanceOf(TimestampNotFound);
expect(evmProvider.getEpochBlockNumber(futureTimestamp)).rejects.toBeInstanceOf(
TimestampNotFound,
);
});

it("fails when finding multiple blocks with the same timestamp", () => {
Expand All @@ -88,11 +82,11 @@ describe("EvmBlockNumberProvider", () => {
{ number: 4n, timestamp: afterTimestamp },
]);

evmProvider = new EvmBlockNumberProvider(rpcProvider);
evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig);

expect(
evmProvider.getEpochBlockNumber(Number(timestamp), { blocksLookback: 2n }),
).rejects.toBeInstanceOf(UnsupportedBlockTimestamps);
expect(evmProvider.getEpochBlockNumber(Number(timestamp))).rejects.toBeInstanceOf(
UnsupportedBlockTimestamps,
);
});

it("fails when finding a block with no number", () => {
Expand All @@ -101,27 +95,23 @@ describe("EvmBlockNumberProvider", () => {
{ number: null, timestamp: BigInt(timestamp) },
]);

evmProvider = new EvmBlockNumberProvider(rpcProvider);
evmProvider = new EvmBlockNumberProvider(rpcProvider, searchConfig);

expect(
evmProvider.getEpochBlockNumber(Number(timestamp), { blocksLookback: 2n }),
).rejects.toBeInstanceOf(UnsupportedBlockNumber);
expect(evmProvider.getEpochBlockNumber(Number(timestamp))).rejects.toBeInstanceOf(
UnsupportedBlockNumber,
);
});

it("fails when the data provider fails", () => {
const client = createPublicClient({ chain: mainnet, transport: http() });

client.getBlock = vi.fn().mockRejectedValue(null);

evmProvider = new EvmBlockNumberProvider(client);
evmProvider = new EvmBlockNumberProvider(client, searchConfig);
const timestamp = Date.UTC(2024, 1, 1, 0, 0, 0, 0);

expect(
evmProvider.getEpochBlockNumber(timestamp, { blocksLookback: 2n }),
).rejects.toBeDefined();
expect(evmProvider.getEpochBlockNumber(timestamp)).rejects.toBeDefined();
});

it("fails when the chain did not reach to the block yet", async () => {});
});
});

Expand Down

0 comments on commit c7a9035

Please sign in to comment.