Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 11 additions & 19 deletions packages/bitcore-node/src/modules/multiProvider/api/csp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PassThrough, Readable } from 'stream';
import { LRUCache } from 'lru-cache';
import { EthDater } from '../../../utils/ethDater';
import logger from '../../../logger';
import { CacheStorage } from '../../../models/cache';
import { WalletAddressStorage } from '../../../models/walletAddress';
Expand Down Expand Up @@ -40,6 +41,7 @@ export class MultiProviderEVMStateProvider extends BaseEVMStateProvider {
private providersByNetwork: Map<string, ProviderWithHealth[]> = new Map();
blockAtTimeCache: { [key: string]: LRUCache<string, IBlock> } = {};
private localTipCache: Map<string, { tip: IBlock; fetchedAtMs: number }> = new Map();
private daters: Map<string, EthDater> = new Map();
private static readonly LOCAL_TIP_TTL_MS = 5_000;

constructor(chain: string = 'ETH') {
Expand Down Expand Up @@ -435,8 +437,9 @@ export class MultiProviderEVMStateProvider extends BaseEVMStateProvider {
}

if (Number(block.timestamp) > targetTimestamp) {
logger.warn(`MultiProvider: block verification exceeded ${MAX_ADJUSTMENTS} adjustments, falling back to binary search`);
return this._binarySearchBlockByTimestamp(web3, targetTimestamp);
logger.warn(`MultiProvider: block verification exceeded ${MAX_ADJUSTMENTS} adjustments, falling back to EthDater`);
const result = await this._getDater(network, web3).getDate(date, false);
return result.block;
}

adjustments = 0;
Expand All @@ -450,24 +453,13 @@ export class MultiProviderEVMStateProvider extends BaseEVMStateProvider {
return blockNum;
}

private async _binarySearchBlockByTimestamp(web3: any, targetTimestamp: number): Promise<number> {
const latestBlock = await web3.eth.getBlock('latest');
let high = Number(latestBlock.number);
let low = 0;
const MAX_ITERATIONS = 64;
let iterations = 0;

while (low < high && iterations < MAX_ITERATIONS) {
const mid = Math.floor((low + high + 1) / 2);
const block = await web3.eth.getBlock(mid);
if (Number(block.timestamp) <= targetTimestamp) {
low = mid;
} else {
high = mid - 1;
}
iterations++;
private _getDater(network: string, web3: any): EthDater {
let dater = this.daters.get(network);
if (!dater) {
dater = new EthDater(web3);
this.daters.set(network, dater);
}
return low;
return dater;
}

// @override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import axios from 'axios';
import config from '../../../../config';
import logger from '../../../../logger';
import { EVMTransactionStorage } from '../../evm/models/transaction';
import { EthDater } from '../../../../utils/ethDater';
import { ExternalApiStream } from '../streams/apiStream';
import {
type AdapterBlockByDateParams,
Expand All @@ -26,6 +27,7 @@ export class AlchemyAdapter implements IIndexedAPIAdapter {

private apiKey: string;
private requestTimeout: number;
private daters = new Map<string, EthDater>();

constructor(providerConfig: IMultiProviderConfig) {
const apiKey = config.externalProviders?.alchemy?.apiKey;
Expand All @@ -34,6 +36,17 @@ export class AlchemyAdapter implements IIndexedAPIAdapter {
this.requestTimeout = providerConfig.requestTimeout ?? 30000;
}

private getDater(chain: string, network: string): EthDater {
const key = `${chain}:${network}`;
let dater = this.daters.get(key);
if (!dater) {
const web3 = new Web3(new Web3.providers.HttpProvider(this.getBaseUrl(chain, network)));
dater = new EthDater(web3);
this.daters.set(key, dater);
}
return dater;
}

private getNetworkUrlString(chain: string, network: string): string {
const normalizedChain = ALCHEMY_CHAIN_ALIASES[chain.toUpperCase()] || chain.toLowerCase();
return `${normalizedChain}-${network.toLowerCase()}`;
Expand Down Expand Up @@ -111,35 +124,13 @@ export class AlchemyAdapter implements IIndexedAPIAdapter {

async getBlockNumberByDate(params: AdapterBlockByDateParams): Promise<number> {
const { chain, network, date } = params;
const url = this.getBaseUrl(chain, network);
const targetTimestamp = Math.floor(new Date(date).getTime() / 1000);

const MAX_ITERATIONS = 64;
const latestBlockResp = await this._jsonRpc(url, 'eth_blockNumber', []);
let high = parseInt(latestBlockResp.result, 16);
let low = 0;

const latestBlock = await this._jsonRpc(url, 'eth_getBlockByNumber', [latestBlockResp.result, false]);
const latestTimestamp = parseInt(latestBlock.result.timestamp, 16);
if (targetTimestamp >= latestTimestamp) return high; // Target in the future
if (targetTimestamp <= 0) return 0; // Target before genesis

// Binary search: largest block with timestamp <= target
let iterations = 0;
while (low < high && iterations < MAX_ITERATIONS) {
const mid = Math.floor((low + high + 1) / 2);
const blockResp = await this._jsonRpc(url, 'eth_getBlockByNumber', [`0x${mid.toString(16)}`, false]);
const blockTimestamp = parseInt(blockResp.result.timestamp, 16);

if (blockTimestamp <= targetTimestamp) {
low = mid;
} else {
high = mid - 1;
}
iterations++;
try {
// `false` = "block before" semantics: largest block whose timestamp < target.
const result = await this.getDater(chain, network).getDate(new Date(date), false);
return result.block;
} catch (error) {
this._classifyError(error);
}

return low;
}

async healthCheck(): Promise<boolean> {
Expand Down
138 changes: 138 additions & 0 deletions packages/bitcore-node/src/utils/ethDater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Block-by-date resolution via average-block-time interpolation.
*
* Ported from monosux/ethereum-block-by-date (MIT) and trimmed to what we use:
* - getDate(date, after) only (no getEvery / period iteration)
* - web3 provider only (no ethers / viem)
* - no moment.js (native Date + unix-second arithmetic)
*
* Algorithm:
* 1. Probe genesis and latest to compute average block time across the chain.
* 2. Interpolate a first guess by dividing the date's offset from genesis by avg block time.
* 3. Refine recursively: re-estimate local block time between the guess and a neighbor,
* step toward the target, repeat until the guess straddles the target.
* 4. Cache every probed block within the call so the recursion doesn't re-fetch.
*
* "after" semantics mirror the upstream library:
* - true: smallest block whose timestamp >= target
* - false: largest block whose timestamp < target (a.k.a. "block before")
*/

export interface BlockResult {
block: number;
timestamp: number;
date: string;
}

interface SavedBlock {
number: number;
timestamp: number;
}

function toUnixSeconds(ts: bigint | number | string): number {
if (typeof ts === 'bigint') return Number(ts);
if (typeof ts === 'string') {
return ts.startsWith('0x') ? parseInt(ts, 16) : parseInt(ts, 10);
}
return Number(ts);
}

export class EthDater {
private web3: any;
private latestBlock?: SavedBlock;
private firstBlock?: SavedBlock;
private blockTime?: number;
private checkedBlocks: Record<number, number[]> = {};
private savedBlocks: Record<number, SavedBlock> = {};
public requests = 0;

constructor(web3: any) {
this.web3 = typeof web3?.eth !== 'undefined' ? web3 : { eth: web3 };
}

async getDate(input: Date | string | number, after: boolean = true, refresh: boolean = false): Promise<BlockResult> {
const targetUnix = Math.floor(new Date(input).getTime() / 1000);
const dateIso = new Date(targetUnix * 1000).toISOString();

if (!this.firstBlock || !this.latestBlock || this.blockTime === undefined || refresh) {
await this.getBoundaries();
}

if (targetUnix < this.firstBlock!.timestamp) {
return this.returnWrapper(dateIso, 1);
}
if (targetUnix >= this.latestBlock!.timestamp) {
return this.returnWrapper(dateIso, this.latestBlock!.number);
}

this.checkedBlocks[targetUnix] = [];
const guess = Math.ceil((targetUnix - this.firstBlock!.timestamp) / this.blockTime!);
const predictedBlock = await this.getBlockWrapper(guess);
const found = await this.findBetter(targetUnix, predictedBlock, after);
return this.returnWrapper(dateIso, found);
}

private async getBoundaries(): Promise<void> {
this.latestBlock = await this.getBlockWrapper('latest');
this.firstBlock = await this.getBlockWrapper(1);
const span = this.latestBlock.number - 1;
this.blockTime = span > 0 ? (this.latestBlock.timestamp - this.firstBlock.timestamp) / span : 0;
}

private async findBetter(targetUnix: number, predicted: SavedBlock, after: boolean, blockTime: number = this.blockTime!): Promise<number> {
if (await this.isBetterBlock(targetUnix, predicted, after)) return predicted.number;

const difference = targetUnix - predicted.timestamp;
let skip = Math.ceil(difference / (blockTime === 0 ? 1 : blockTime));
if (skip === 0) skip = difference < 0 ? -1 : 1;

const nextNum = this.getNextBlock(targetUnix, predicted.number, skip);
const next = await this.getBlockWrapper(nextNum);

const localBlockTime = Math.abs(
(predicted.timestamp - next.timestamp) /
(predicted.number - next.number || 1)
);
return this.findBetter(targetUnix, next, after, localBlockTime);
}

private async isBetterBlock(targetUnix: number, predicted: SavedBlock, after: boolean): Promise<boolean> {
const ts = predicted.timestamp;
if (after) {
if (ts < targetUnix) return false;
const previous = await this.getBlockWrapper(predicted.number - 1);
return ts >= targetUnix && previous.timestamp < targetUnix;
} else {
if (ts >= targetUnix) return false;
const next = await this.getBlockWrapper(predicted.number + 1);
return ts < targetUnix && next.timestamp >= targetUnix;
}
}

private getNextBlock(targetUnix: number, currentBlock: number, skip: number): number {
let next = currentBlock + skip;
if (next > this.latestBlock!.number) next = this.latestBlock!.number;
if (this.checkedBlocks[targetUnix].includes(next)) {
return this.getNextBlock(targetUnix, currentBlock, skip < 0 ? --skip : ++skip);
}
this.checkedBlocks[targetUnix].push(next);
return next < 1 ? 1 : next;
}

private returnWrapper(dateIso: string, block: number): BlockResult {
return { date: dateIso, block, timestamp: this.savedBlocks[block].timestamp };
}

private async getBlockWrapper(block: number | 'latest'): Promise<SavedBlock> {
if (typeof block === 'number' && this.savedBlocks[block]) return this.savedBlocks[block];

const raw = await this.web3.eth.getBlock(block);
const number = toUnixSeconds(raw.number);
const timestamp = toUnixSeconds(raw.timestamp);
this.savedBlocks[number] = { number, timestamp };
this.requests++;
return this.savedBlocks[number];
}
}

export default EthDater;
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ describe('Address Routes', function () {
this.timeout(10000);
const csp = new BaseEVMStateProvider('BASE');
sandbox.stub(ChainStateProvider, 'get').returns(csp);
request.get('/api/BASE/testnet/address/0x9bb6f7fdf81afbd8876d37f3e5e37df416bf8da1/txs?tokenAddress=0x036CbD53842c5426634e7929541eC2318f3dCF7e&startBlock=14035000&endBlock=14045000')
request.get('/api/BASE/testnet/address/0x9bb6f7fdf81afbd8876d37f3e5e37df416bf8da1/txs?tokenAddress=0x036CbD53842c5426634e7929541eC2318f3dCF7e&startBlock=14035000&endBlock=14035600')
.expect(200, (err, res) => {
if (err) return done(err);
expect(res.body).to.deep.equal([{
Expand Down
39 changes: 25 additions & 14 deletions packages/bitcore-node/test/unit/adapters/alchemy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import axios from 'axios';
import { AdapterError, AdapterErrorCode } from '../../../src/providers/chain-state/external/adapters/errors';
import { AlchemyAdapter, AlchemyAssetTransferStream } from '../../../src/providers/chain-state/external/adapters/alchemy';
import { EVMTransactionStorage } from '../../../src/providers/chain-state/evm/models/transaction';
import { EthDater } from '../../../src/utils/ethDater';
import config from '../../../src/config';

// --- Mock data ---
Expand Down Expand Up @@ -336,26 +337,36 @@ describe('AlchemyAdapter', function() {

// --- getBlockNumberByDate ---
describe('getBlockNumberByDate', function() {
it('should binary search for block closest to target date', async function() {
axiosPostStub.callsFake(async (_url: string, body: any) => {
if (body.method === 'eth_blockNumber') return rpcOk('0x64');
if (body.method === 'eth_getBlockByNumber') {
const num = parseInt(body.params[0], 16);
return rpcOk({ timestamp: `0x${num.toString(16)}`, number: body.params[0] });
}
return rpcOk(null);
});
it('should delegate to EthDater and return its block', async function() {
const getDateStub = sandbox.stub(EthDater.prototype, 'getDate')
.resolves({ block: 50, timestamp: 50, date: '1970-01-01T00:00:50Z' });

const result = await adapter.getBlockNumberByDate({ chain: 'ETH', network: 'mainnet', chainId: '1', date: new Date(50000) });

expect(result).to.equal(50);
// `false` = "block before" semantics: largest block whose timestamp < target
expect(getDateStub.calledOnce).to.be.true;
expect(getDateStub.firstCall.args[1]).to.equal(false);
});

it('should cache the dater per chain:network across calls', async function() {
sandbox.stub(EthDater.prototype, 'getDate')
.resolves({ block: 50, timestamp: 50, date: '1970-01-01T00:00:50Z' });

await adapter.getBlockNumberByDate({ chain: 'ETH', network: 'mainnet', chainId: '1', date: new Date(50000) });
await adapter.getBlockNumberByDate({ chain: 'ETH', network: 'mainnet', chainId: '1', date: new Date(60000) });

expect((adapter as any).daters.size).to.equal(1);
});

it('should return latest block if target is in the future', async function() {
axiosPostStub.onCall(0).resolves(rpcOk('0x64'));
axiosPostStub.onCall(1).resolves(rpcOk({ timestamp: '0x64', number: '0x64' }));
it('should build separate daters for different chain:network combinations', async function() {
sandbox.stub(EthDater.prototype, 'getDate')
.resolves({ block: 50, timestamp: 50, date: '1970-01-01T00:00:50Z' });

await adapter.getBlockNumberByDate({ chain: 'ETH', network: 'mainnet', chainId: '1', date: new Date(50000) });
await adapter.getBlockNumberByDate({ chain: 'MATIC', network: 'mainnet', chainId: '137', date: new Date(50000) });

const result = await adapter.getBlockNumberByDate({ chain: 'ETH', network: 'mainnet', chainId: '1', date: new Date(200000) });
expect(result).to.equal(100);
expect((adapter as any).daters.size).to.equal(2);
});
});

Expand Down
44 changes: 44 additions & 0 deletions packages/bitcore-node/test/unit/modules/base/csp.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import { CryptoRpc } from '@bitpay-labs/crypto-rpc';
import { EthDater } from '../../../../src/utils/ethDater';
import { MultiProviderEVMStateProvider } from '../../../../src/modules/multiProvider/api/csp';
import { MoralisStateProvider } from '../../../../src/modules/moralis/api/csp';
import { CacheStorage } from '../../../../src/models/cache';
Expand Down Expand Up @@ -530,4 +531,47 @@ describe('MultiProviderEVMStateProvider: _buildWalletTransactionsStream tokenAdd
expect(adapter.streamAddressTransactions.callCount).to.equal(1);
expect(adapter.streamERC20Transfers.callCount).to.equal(0);
});
});

describe('MultiProviderEVMStateProvider: _verifyBlockBeforeDate EthDater fallback', function() {
let cfgStub: sinon.SinonStub;
let sandbox: sinon.SinonSandbox;

before(function() {
cfgStub = sinon.stub(Config, 'get').returns({ chains: { ETH: {} } } as any);
(BaseEVMStateProvider as any).rpcInitialized = { ETH: true };
});
after(function() { cfgStub.restore(); });
beforeEach(function() { sandbox = sinon.createSandbox(); });
afterEach(function() { sandbox.restore(); });

const ALWAYS_AHEAD_TS = '0xffffffff';
const targetDate = new Date(1700000000 * 1000);

it('falls back to EthDater when the bounded walk exceeds MAX_ADJUSTMENTS', async function() {
const provider = new MultiProviderEVMStateProvider('ETH');
const fakeWeb3 = { eth: { getBlock: sandbox.stub().resolves({ timestamp: ALWAYS_AHEAD_TS }) } };
(provider as any).getWeb3 = async () => ({ web3: fakeWeb3 });
const getDateStub = sandbox.stub(EthDater.prototype, 'getDate')
.resolves({ block: 12345, timestamp: 1700000000, date: '2023-11-14T22:13:20Z' });

const result = await (provider as any)._verifyBlockBeforeDate('mainnet', 100, targetDate);

expect(result).to.equal(12345);
expect(getDateStub.calledOnce).to.be.true;
expect(getDateStub.firstCall.args[1]).to.equal(false);
});

it('caches the dater per network across calls', async function() {
const provider = new MultiProviderEVMStateProvider('ETH');
const fakeWeb3 = { eth: { getBlock: sandbox.stub().resolves({ timestamp: ALWAYS_AHEAD_TS }) } };
(provider as any).getWeb3 = async () => ({ web3: fakeWeb3 });
sandbox.stub(EthDater.prototype, 'getDate')
.resolves({ block: 1, timestamp: 1, date: '1970-01-01T00:00:01Z' });

await (provider as any)._verifyBlockBeforeDate('mainnet', 100, targetDate);
await (provider as any)._verifyBlockBeforeDate('mainnet', 100, targetDate);

expect((provider as any).daters.size).to.equal(1);
});
});
Loading