Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: search block by timestamp with binsearch #11

Merged
merged 20 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,8 @@
"*": "prettier --write --ignore-unknown",
"*.js,*.ts": "eslint --fix"
},
"packageManager": "[email protected]+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903"
"packageManager": "[email protected]+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903",
"dependencies": {
"winston": "3.13.1"
}
}
5 changes: 4 additions & 1 deletion packages/blocknumber/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@
},
"keywords": [],
"author": "",
"license": "ISC"
"license": "ISC",
"dependencies": {
"viem": "2.17.10"
}
}
6 changes: 6 additions & 0 deletions packages/blocknumber/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export * from "./invalidChain.js";
export * from "./invalidTimestamp.js";
export * from "./lastBlockEpoch.js";
export * from "./timestampNotFound.js";
export * from "./unexpectedSearchRange.js";
export * from "./unsupportedBlockNumber.js";
export * from "./unsupportedBlockTimestamps.js";
7 changes: 7 additions & 0 deletions packages/blocknumber/src/exceptions/invalidTimestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class InvalidTimestamp extends Error {
constructor(timestamp: number | bigint) {
super(`Timestamp ${timestamp} is prior the timestamp of the first block.`);

this.name = "InvalidTimestamp";
}
}
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";
}
}
7 changes: 7 additions & 0 deletions packages/blocknumber/src/exceptions/timestampNotFound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class TimestampNotFound extends Error {
constructor(timestamp: number | bigint) {
super(`No block was processed during ${timestamp}.`);

this.name = "TimestampNotFound";
}
}
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";
}
}
7 changes: 7 additions & 0 deletions packages/blocknumber/src/exceptions/unsupportedBlockNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class UnsupportedBlockNumber extends Error {
constructor(timestamp: bigint) {
super(`Block with null block number at ${timestamp}`);

this.name = "UnsupportedBlockNumber";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class UnsupportedBlockTimestamps extends Error {
constructor(timestamp: number | bigint) {
super(`Found multiple blocks at ${timestamp}.`);

this.name = "UnsupportedBlockTimestamps";
}
}
13 changes: 13 additions & 0 deletions packages/blocknumber/src/providers/blockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface BlockNumberProvider {
/**
* 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
*
* @returns the corresponding block number of a chain at a specific timestamp
*/
getEpochBlockNumber(timestamp: number): Promise<bigint>;
}
236 changes: 236 additions & 0 deletions packages/blocknumber/src/providers/evmBlockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { Block, PublicClient } from "viem";

import {
InvalidTimestamp,
LastBlockEpoch,
TimestampNotFound,
UnexpectedSearchRange,
UnsupportedBlockNumber,
UnsupportedBlockTimestamps,
} from "../exceptions/index.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 };

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;
}

export class EvmBlockNumberProvider implements BlockNumberProvider {
private client: PublicClient;
private searchConfig: SearchConfig;
private firstBlock: Block | null;

/**
* 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,
};
this.firstBlock = null;
}

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);

logger.info(
`Working with latest block (number: ${upperBoundBlock.number}, timestamp: ${upperBoundBlock.timestamp})...`,
);

const firstBlock = await this.getFirstBlock();

if (_timestamp < firstBlock.timestamp) throw new InvalidTimestamp(_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 !== null) return this.firstBlock;

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

return this.firstBlock;
}

Comment on lines +88 to +100
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIceeeeee 💯

/**
* 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;
}

/**
* 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) * deltaMultiplier;

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

let searchCount = 0n;
while (candidateBlockNumber >= 0) {
const candidate = await this.client.getBlock({ blockNumber: candidateBlockNumber });

if (candidate.timestamp < timestamp) {
logger.info(`Estimated lower bound at block ${candidate.number}.`);

return candidate;
}

searchCount++;
candidateBlockNumber = lastBlock.number - baseStep * 2n ** searchCount;
}

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

if (firstBlock.timestamp <= timestamp) {
return firstBlock;
}

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...");

const pastBlock = await this.client.getBlock({
blockNumber: lastBlock.number - BigInt(blocksLookback),
});

const estimatedBlockTime = (lastBlock.timestamp - pastBlock.timestamp) / blocksLookback;

logger.info(`Estimated block time: ${estimatedBlockTime}.`);

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) {
currentBlockNumber = (high + low) / 2n;

const currentBlock = await this.client.getBlock({ blockNumber: currentBlockNumber });
const nextBlock = await this.client.getBlock({ blockNumber: currentBlockNumber + 1n });

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)
throw new UnsupportedBlockTimestamps(timestamp);

const blockContainsTimestamp =
currentBlock.timestamp <= timestamp && nextBlock.timestamp > timestamp;

if (blockContainsTimestamp) {
logger.debug(`Block #${currentBlock.number} contains timestamp.`);

return currentBlock.number;
} else if (currentBlock.timestamp <= timestamp) {
low = currentBlockNumber + 1n;
} else {
high = currentBlockNumber - 1n;
}
}

throw new TimestampNotFound(timestamp);
}
}
1 change: 1 addition & 0 deletions packages/blocknumber/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./chainId.js";
export * from "./logger.js";
15 changes: 15 additions & 0 deletions packages/blocknumber/src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import winston from "winston";

const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
defaultMeta: { service: "blocknumber" },
transports: [
new winston.transports.Console({
format: winston.format.simple(),
silent: process.env.NODE_ENV == "test",
}),
],
});

export default logger;
Loading