-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
06ef8af
build: separate tsconfig for build stage
0xyaco 9802a89
feat: set up blocknumber module tests
0xyaco 546d76e
chore: set up blocknumber module linting
0xyaco 5b57a2d
Merge remote-tracking branch 'origin/dev' into chore/blocknumber-setup
0xyaco f291b45
style: remove total-typescript on non-base tsconfig
0xyaco 0e8fc5a
build: set up build script
0xyaco baa144a
fix: blocknumber module package config
0xyaco 9965c35
fix: dummy class import module
0xyaco 6215697
chore: add coverage script and tasks
0xyaco 2748ca2
chore: remove dummy classes
0xyaco ab869a4
feat: implement caip-2 compliant chain ids
0xyaco 212960a
refactor: create exceptions/ folder
0xyaco 21f025b
Merge remote-tracking branch 'origin/dev' into feat/bn-service
0xyaco ecd91e9
feat: search block by timestamp with binsearch
0xyaco 4a38c03
fix: fixed winston dependency version
0xyaco 711216d
refactor: rename evmProvider to evmBlockNumberProvider
0xyaco c7a9035
refactor: client and search params in evm provider constructor
0xyaco 18d537a
Merge remote-tracking branch 'origin/dev' into feat/evm-block-search
0xyaco 5ad1369
fix: pnpm dependecies mismatch
0xyaco 6875aea
fix: throw InvalidTimestamp for timestamps prior first block
0xyaco File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIceeeeee 💯