-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: search block by timestamp with binsearch (#11)
# 🤖 Linear Closes GRT-39 ## Description Implements the `EvmProvider.getEpochBlockNumber` using a binary search with an optimization that takes into account that the current epoch begins near the last block.
- Loading branch information
Showing
15 changed files
with
896 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,5 +15,8 @@ | |
}, | ||
"keywords": [], | ||
"author": "", | ||
"license": "ISC" | ||
"license": "ISC", | ||
"dependencies": { | ||
"viem": "2.17.10" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
7
packages/blocknumber/src/exceptions/unsupportedBlockNumber.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} | ||
} |
7 changes: 7 additions & 0 deletions
7
packages/blocknumber/src/exceptions/unsupportedBlockTimestamps.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
236
packages/blocknumber/src/providers/evmBlockNumberProvider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
/** | ||
* 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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from "./chainId.js"; | ||
export * from "./logger.js"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.