diff --git a/ccip-sdk/src/aptos/logs.ts b/ccip-sdk/src/aptos/logs.ts index 9cf7f2f7..5e7f5476 100644 --- a/ccip-sdk/src/aptos/logs.ts +++ b/ccip-sdk/src/aptos/logs.ts @@ -11,8 +11,8 @@ import type { LogFilter } from '../chain.ts' import { CCIPAptosAddressModuleRequiredError, CCIPAptosTransactionTypeUnexpectedError, + CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, - CCIPLogsWatchRequiresStartError, CCIPTopicsInvalidError, } from '../errors/index.ts' import type { ChainLog } from '../types.ts' @@ -97,7 +97,7 @@ async function* fetchEventsForward( ): AsyncGenerator { if (opts.watch && typeof opts.endBlock === 'number' && opts.endBlock > 0) throw new CCIPLogsWatchRequiresFinalityError(opts.endBlock) - opts.endBlock ||= 'latest' + opts.endBlock ??= 'latest' const fetchBatch = memoize( async (start?: number) => { @@ -119,18 +119,24 @@ async function* fetchEventsForward( let start if ( - (!opts.startBlock || opts.startBlock < +initialBatch[0]!.version) && - (!opts.startTime || - opts.startTime < (await getVersionTimestamp(provider, +initialBatch[0]!.version))) + opts.startTime != null && + (opts.startBlock == null || opts.startBlock < +initialBatch[0]!.version) && + opts.startTime < (await getVersionTimestamp(provider, +initialBatch[0]!.version)) ) { const i = await binarySearchFirst(0, Math.floor(end / limit) - 1, async (i) => { const batch = await fetchBatch(end - (i + 1) * limit + 1) const firstTimestamp = await getVersionTimestamp(provider, +batch[0]!.version) return firstTimestamp > opts.startTime! }) - start = end - (i + 1) * limit + 1 + start = Math.max(end - (i + 1) * limit + 1, 0) + } else if ( + opts.startTime == null && + opts.startBlock != null && + opts.startBlock <= +initialBatch[0]!.version + ) { + start = 0 } else { - start = end - limit + 1 + start = Math.max(end - limit + 1, 0) } let notAfter = @@ -155,7 +161,7 @@ async function* fetchEventsForward( const data = await fetchBatch(start) if ( first && - opts.startTime && + opts.startTime != null && (await getVersionTimestamp(provider, +data[0]!.version)) < opts.startTime ) { // the first batch may have some head which is not in the range @@ -172,10 +178,10 @@ async function* fetchEventsForward( first = false for (const ev of data) { - if (opts.startBlock && +ev.version < opts.startBlock) continue + if (opts.startBlock != null && +ev.version < opts.startBlock) continue // there may be an unknown interval between yields, so we support memoized negative finality if ( - notAfter && + notAfter != null && +ev.version > (typeof notAfter === 'function' ? await notAfter() : notAfter) ) { catchedUp = true @@ -197,41 +203,6 @@ async function* fetchEventsForward( } } -async function* fetchEventsBackward( - { provider }: { provider: Aptos }, - opts: LogFilter, - eventHandlerField: string, - stateAddr: string, - limit = 100, -): AsyncGenerator { - let start - let cont = true - const notAfter = - typeof opts.endBlock !== 'number' - ? undefined - : opts.endBlock < 0 - ? +(await provider.getLedgerInfo()).ledger_version + opts.endBlock - : opts.endBlock - do { - const { data } = await getAptosFullNode({ - aptosConfig: provider.config, - originMethod: 'getEventsByEventHandle', - path: `accounts/${stateAddr}/events/${opts.address}::${eventHandlerField}`, - params: { start, limit }, - }) - - if (!data.length) break - else if (start === 1) cont = false - else start = Math.max(+data[0]!.sequence_number - limit, 1) - - for (const ev of data.reverse()) { - if (notAfter && +ev.version > notAfter) continue - if (+ev.sequence_number <= 1) cont = false - yield ev - } - } while (cont) -} - /** * Streams logs from the Aptos blockchain based on filter options. * @param provider - Aptos provider instance. @@ -246,6 +217,9 @@ export async function* streamAptosLogs( if (!opts.address || !opts.address.includes('::')) throw new CCIPAptosAddressModuleRequiredError() if (opts.topics?.length !== 1 || typeof opts.topics[0] !== 'string') throw new CCIPTopicsInvalidError(opts.topics!) + const hasStart = opts.startBlock != null || opts.startTime != null + if (!hasStart) throw new CCIPLogsRequiresStartError() + let eventHandlerField = opts.topics[0] if (!eventHandlerField.includes('/')) { eventHandlerField = (eventToHandler as Record)[eventHandlerField]! @@ -257,18 +231,8 @@ export async function* streamAptosLogs( }, }) - let eventsIter - if (opts.startBlock || opts.startTime) { - eventsIter = fetchEventsForward(ctx, opts, eventHandlerField, stateAddr, limit) - } else if (opts.watch) { - throw new CCIPLogsWatchRequiresStartError() - } else { - // backwards, just paginate down to lowest sequence number - eventsIter = fetchEventsBackward(ctx, opts, eventHandlerField, stateAddr, limit) - } - let topics - for await (const ev of eventsIter) { + for await (const ev of fetchEventsForward(ctx, opts, eventHandlerField, stateAddr, limit)) { topics ??= [ev.type.slice(ev.type.lastIndexOf('::') + 2)] yield { address: opts.address, diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index cef008c5..6c67a05d 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -9,6 +9,7 @@ import { CCIPArgumentInvalidError, CCIPChainFamilyMismatchError, CCIPExecTxRevertedError, + CCIPLogsRequiresStartError, CCIPNotImplementedError, CCIPTokenPoolChainConfigNotFoundError, CCIPTransactionNotFinalizedError, @@ -166,13 +167,13 @@ export const DEFAULT_API_RETRY_CONFIG: Required = { * Filter options for getLogs queries across chains. */ export type LogFilter = { - /** Starting block number (inclusive). */ + /** Starting block number (inclusive). Required unless startTime is provided; explicit 0 is allowed. */ startBlock?: number /** Starting Unix timestamp (inclusive). */ startTime?: number /** Ending block number (inclusive). */ endBlock?: number | 'finalized' | 'latest' - /** Solana: optional hint txHash for end of iteration. */ + /** Cursor hint for the exclusive upper end of iteration on chains that support it. */ endBefore?: string /** watch mode: polls for new logs after fetching since start (required), until endBlock finality tag * (e.g. endBlock=finalized polls only finalized logs); can be a promise to cancel loop @@ -671,13 +672,12 @@ export abstract class Chain { /** * An async generator that yields logs based on the provided options. * @param opts - Options object containing: - * - `startBlock`: if provided, fetch and generate logs forward starting from this block; - * otherwise, returns logs backwards in time from endBlock; - * optionally, startTime may be provided to fetch logs forward starting from this time + * - `startBlock`: fetch and generate logs forward starting from this block; + * required unless startTime is provided; explicit 0 is allowed * - `startTime`: instead of a startBlock, a start timestamp may be provided; - * if either is provided, fetch logs forward from this starting point; otherwise, backwards - * - `endBlock`: if omitted, use latest block; can be a block number, 'latest', 'finalized' or - * negative finality block depth + * if either is provided, fetch logs forward from this starting point + * - `endBlock`: a fixed block height, finality tag or negative finality depth to stop iteration + * at; defaults to `latest` * - `endBefore`: optional hint signature for end of iteration, instead of endBlock * - `address`: if provided, fetch logs for this address only (may be required in some * networks/implementations) @@ -686,10 +686,11 @@ export abstract class Chain { * some networks/implementations may not be able to filter topics other than topic0s, so one may * want to assume those are optimization hints, instead of hard filters, and verify results * - `page`: if provided, try to use this page/range for batches - * - `watch`: true or cancellation promise, getLogs continuously after initial fetch + * - `watch`: true or cancellation promise, stream logs after initial catch-up until endBlock + * finality tag (e.g. 'finalized'); requires endBlock to be a finality tag * @returns An async iterable iterator of logs. * @throws {@link CCIPLogsWatchRequiresFinalityError} if watch mode is used without a finality endBlock tag - * @throws {@link CCIPLogsWatchRequiresStartError} if watch mode is used without startBlock or startTime + * @throws {@link CCIPLogsRequiresStartError} if used without startBlock or startTime * @throws {@link CCIPLogsAddressRequiredError} if address is required but not provided (chain-specific) */ abstract getLogs(opts: LogFilter): AsyncIterableIterator @@ -1396,7 +1397,7 @@ export abstract class Chain { 'page' | 'watch' | 'startBlock' | 'startTime' >): AsyncIterableIterator { if (verifications && 'log' in verifications) hints.startBlock ??= verifications.log.blockNumber - const onlyLast = !hints.startTime && !hints.startBlock // backwards + if (hints.startTime == null && hints.startBlock == null) throw new CCIPLogsRequiresStartError() for await (const log of this.getLogs({ address: offRamp, topics: ['ExecutionStateChanged'], @@ -1415,7 +1416,7 @@ export abstract class Chain { const timestamp = log.tx?.timestamp ?? (await this.getBlockTimestamp(log.blockNumber)) yield { receipt, log, timestamp } - if (onlyLast || receipt.state === ExecutionState.Success) break + if (receipt.state === ExecutionState.Success) break } } diff --git a/ccip-sdk/src/commits.ts b/ccip-sdk/src/commits.ts index 25bc3fd9..582a9eca 100644 --- a/ccip-sdk/src/commits.ts +++ b/ccip-sdk/src/commits.ts @@ -27,7 +27,9 @@ export async function getOnchainCommitReport( ): Promise { for await (const log of dest.getLogs({ ...hints, - ...(!hints?.startBlock ? { startTime: requestTimestamp } : { startBlock: hints.startBlock }), + ...(hints?.startBlock == null + ? { startTime: requestTimestamp } + : { startBlock: hints.startBlock }), address: offRamp, topics: [lane.version < CCIPVersion.V1_6 ? 'ReportAccepted' : 'CommitReportAccepted'], })) { diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index a5e74a13..9e9aaccc 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -120,6 +120,7 @@ export const CCIPErrorCode = { LOG_EVENT_HANDLER_UNKNOWN: 'LOG_EVENT_HANDLER_UNKNOWN', LOGS_WATCH_REQUIRES_FINALITY: 'LOGS_WATCH_REQUIRES_FINALITY', LOGS_WATCH_REQUIRES_START: 'LOGS_WATCH_REQUIRES_START', + LOGS_REQUIRES_START: 'LOGS_REQUIRES_START', LOGS_ADDRESS_REQUIRED: 'LOGS_ADDRESS_REQUIRED', TOPICS_INVALID: 'TOPICS_INVALID', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index 5e378285..9fb5a6b2 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -19,6 +19,7 @@ export { CCIPBlockNotFoundError, CCIPTransactionNotFoundError } from './speciali // Specialized errors - Logs export { CCIPLogsAddressRequiredError, + CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, CCIPLogsWatchRequiresStartError, } from './specialized.ts' diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index f734f4be..215e45fc 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -144,6 +144,7 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { LOGS_WATCH_REQUIRES_FINALITY: 'Logs watch requires endBlock to be a `finalized`, `latest` or finality block depth (negative).', LOGS_WATCH_REQUIRES_START: 'Logs watch requires either startBlock or startTime (forward mode).', + LOGS_REQUIRES_START: 'Logs queries require either startBlock or startTime.', LOGS_ADDRESS_REQUIRED: 'Provide address for logs filtering.', TOPICS_INVALID: 'Topics must be strings for event filtering.', diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index 5a851834..d8a82085 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -1493,7 +1493,7 @@ export class CCIPLogsNotFoundError extends CCIPError { * @example * ```typescript * try { - * const logs = await chain.getLogs({ topics: ['0xunknown'] }) + * const logs = await chain.getLogs({ startBlock: 0, topics: ['0xunknown'] }) * } catch (error) { * if (error instanceof CCIPLogTopicsNotFoundError) { * console.log(`Topics not matched: ${error.context.topics}`) @@ -1519,7 +1519,7 @@ export class CCIPLogTopicsNotFoundError extends CCIPError { * @example * ```typescript * try { - * await chain.watchLogs({ endBlock: 1000 }) // Fixed endBlock not allowed + * await chain.watchLogs({ startBlock: 0, endBlock: 1000 }) // Fixed endBlock not allowed * } catch (error) { * if (error instanceof CCIPLogsWatchRequiresFinalityError) { * console.log('Use "latest" or "finalized" for endBlock in watch mode') @@ -1542,22 +1542,39 @@ export class CCIPLogsWatchRequiresFinalityError extends CCIPError { /** * Thrown when trying to `watch` logs but no start position provided. * + * @deprecated Log queries now require a start position for all modes and throw + * {@link CCIPLogsRequiresStartError}; this class remains exported for compatibility. + */ +export class CCIPLogsWatchRequiresStartError extends CCIPError { + override readonly name = 'CCIPLogsWatchRequiresStartError' + /** Creates a logs watch requires start error. */ + constructor(options?: CCIPErrorOptions) { + super(CCIPErrorCode.LOGS_WATCH_REQUIRES_START, `Watch mode requires startBlock or startTime`, { + ...options, + isTransient: false, + }) + } +} + +/** + * Thrown when querying logs without an explicit start position. + * * @example * ```typescript * try { - * await chain.watchLogs({}) // Missing startBlock or startTime + * await chain.getLogs({ address: '0x...', topics: ['CCIPMessageSent'] }) * } catch (error) { - * if (error instanceof CCIPLogsWatchRequiresStartError) { - * console.log('Provide startBlock or startTime for watch mode') + * if (error instanceof CCIPLogsRequiresStartError) { + * console.log('Provide startBlock or startTime') * } * } * ``` */ -export class CCIPLogsWatchRequiresStartError extends CCIPError { - override readonly name = 'CCIPLogsWatchRequiresStartError' - /** Creates a logs watch requires start error. */ +export class CCIPLogsRequiresStartError extends CCIPError { + override readonly name = 'CCIPLogsRequiresStartError' + /** Creates a logs requires start error. */ constructor(options?: CCIPErrorOptions) { - super(CCIPErrorCode.LOGS_WATCH_REQUIRES_START, `Watch mode requires startBlock or startTime`, { + super(CCIPErrorCode.LOGS_REQUIRES_START, `Logs query requires startBlock or startTime`, { ...options, isTransient: false, }) @@ -1570,7 +1587,7 @@ export class CCIPLogsWatchRequiresStartError extends CCIPError { * @example * ```typescript * try { - * await chain.getLogs({ topics: [...] }) // Missing address + * await chain.getLogs({ startBlock: 0, topics: [...] }) // Missing address * } catch (error) { * if (error instanceof CCIPLogsAddressRequiredError) { * console.log('Contract address is required for this chain') @@ -2102,7 +2119,7 @@ export class CCIPBlockTimeNotFoundError extends CCIPError { * @example * ```typescript * try { - * await chain.getLogs({ topics: [123] }) // Invalid topic type + * await chain.getLogs({ startBlock: 0, topics: [123] }) // Invalid topic type * } catch (error) { * if (error instanceof CCIPTopicsInvalidError) { * console.log('Topics must be string values') @@ -2722,7 +2739,7 @@ export class CCIPAptosTransactionTypeUnexpectedError extends CCIPError { * @example * ```typescript * try { - * await aptosChain.getLogs({ address: '0x1' }) // Missing module + * await aptosChain.getLogs({ address: '0x1', startBlock: 0 }) // Missing module * } catch (error) { * if (error instanceof CCIPAptosAddressModuleRequiredError) { * console.log('Provide address with module name') @@ -2751,7 +2768,7 @@ export class CCIPAptosAddressModuleRequiredError extends CCIPError { * @example * ```typescript * try { - * await aptosChain.getLogs({ topics: ['invalid'] }) + * await aptosChain.getLogs({ startBlock: 0, topics: ['invalid'] }) * } catch (error) { * if (error instanceof CCIPAptosTopicInvalidError) { * console.log(`Invalid topic: ${error.context.topic}`) diff --git a/ccip-sdk/src/evm/fork.test.ts b/ccip-sdk/src/evm/fork.test.ts index e55acf83..35ee3ed2 100644 --- a/ccip-sdk/src/evm/fork.test.ts +++ b/ccip-sdk/src/evm/fork.test.ts @@ -494,6 +494,8 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { for await (const exec of sepoliaChain.getExecutionReceipts({ offRamp, messageId: successMsg.messageId, + sourceChainSelector: request.message.sourceChainSelector, + startTime: request.tx.timestamp, })) { if (exec.receipt.state === ExecutionState.Success) { foundSuccess = true @@ -538,6 +540,8 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { for await (const exec of sepoliaChain.getExecutionReceipts({ offRamp, messageId: failedMsg.messageId, + sourceChainSelector: request.message.sourceChainSelector, + startTime: request.tx.timestamp, })) { assert.notEqual( exec.receipt.state, diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 6d39a44c..c9492eea 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -23,7 +23,7 @@ import { } from 'ethers' import type { TypedContract } from 'ethers-abitype' import { memoize } from 'micro-memoize' -import type { PickDeep, SetRequired } from 'type-fest' +import type { PickDeep, SetFieldType, SetRequired } from 'type-fest' import { type ChainContext, @@ -127,7 +127,7 @@ import { } from './extra-args.ts' import { estimateExecGas } from './gas.ts' import { getV12LeafHasher, getV16LeafHasher } from './hasher.ts' -import { getEvmLogs } from './logs.ts' +import { type EVMEndBlockTag, getEvmLogs } from './logs.ts' import type { CCIPMessage_V1_6_EVM, CCIPMessage_V2_0, CleanAddressable } from './messages.ts' import { encodeEVMOffchainTokenData } from './offchain.ts' import { buildMessageForDest, decodeMessage, getMessagesInBatch } from '../requests.ts' @@ -352,7 +352,7 @@ export class EVMChain extends Chain { } /** {@inheritDoc Chain.getBlockTimestamp} */ - async getBlockTimestamp(block: number | 'finalized'): Promise { + async getBlockTimestamp(block: EVMEndBlockTag): Promise { const res = await this.provider.getBlock(block) // cached if (!res) throw new CCIPBlockNotFoundError(block) return res.timestamp @@ -374,7 +374,9 @@ export class EVMChain extends Chain { } /** {@inheritDoc Chain.getLogs} */ - async *getLogs(filter: LogFilter & { onlyFallback?: boolean }): AsyncIterableIterator { + async *getLogs( + filter: SetFieldType & { onlyFallback?: boolean }, + ): AsyncIterableIterator { if (filter.watch instanceof Promise) filter = { ...filter, watch: Promise.race([filter.watch, this.destroy$]) } yield* getEvmLogs(filter, this) diff --git a/ccip-sdk/src/evm/logs.ts b/ccip-sdk/src/evm/logs.ts index 4ab042ce..249e5df9 100644 --- a/ccip-sdk/src/evm/logs.ts +++ b/ccip-sdk/src/evm/logs.ts @@ -6,22 +6,26 @@ import { isHexString, } from 'ethers' import { memoize } from 'micro-memoize' +import type { SetFieldType } from 'type-fest' import type { LogFilter } from '../chain.ts' import { CCIPLogTopicsNotFoundError, CCIPLogsAddressRequiredError, CCIPLogsNotFoundError, + CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, - CCIPLogsWatchRequiresStartError, CCIPRpcNotFoundError, } from '../errors/index.ts' +import type { FinalityRequested } from '../extra-args.ts' import { blockRangeGenerator, getSomeBlockNumberBefore } from '../utils.ts' import { getAllFragmentsMatchingEvents } from './const.ts' import type { WithLogger } from '../types.ts' const MAX_PARALLEL_JOBS = 24 const PER_REQUEST_TIMEOUT = 5000 +/** Tags or values which can be used as `endBlock` in {@link EVMChain.getLogs} filter */ +export type EVMEndBlockTag = FinalityRequested | 'latest' const getFallbackRpcsList = memoize( async () => { @@ -97,8 +101,8 @@ async function getFallbackArchiveLogs( filter: { address: string topics: (string | string[] | null)[] - startBlock?: number - endBlock?: number | 'latest' + startBlock: number + endBlock?: EVMEndBlockTag }, { logger = console, destroy$ }: { destroy$?: Promise } & WithLogger = {}, ) { @@ -106,8 +110,8 @@ async function getFallbackArchiveLogs( if (provider != null) { return (await provider).getLogs({ ...filter, - fromBlock: filter.startBlock ?? 1, - toBlock: filter.endBlock ?? 'latest', + fromBlock: filter.startBlock, + toBlock: filter.endBlock || 'latest', }) } let cancel!: (_?: unknown) => void @@ -143,8 +147,8 @@ async function getFallbackArchiveLogs( await provider .getLogs({ ...filter, - fromBlock: filter.startBlock ?? 1, - toBlock: filter.endBlock ?? 'latest', + fromBlock: filter.startBlock, + toBlock: filter.endBlock || 'latest', }) .then((logs) => { if (!logs.length) throw new CCIPLogsNotFoundError(filter) @@ -178,7 +182,7 @@ async function getFallbackArchiveLogs( /** * Implements Chain.getLogs for EVM. - * If !(filter.startBlock|startTime), walks backwards from endBlock, otherwise forward from then. + * Walks logs forward from `startBlock` or `startTime`; if neither is provided, throws. * @param filter - Chain LogFilter. The `onlyFallback` option controls pagination behavior: * - If undefined (default): paginate main provider only by filter.page * - If false: first try whole range with main provider, then fallback to archive provider @@ -187,17 +191,14 @@ async function getFallbackArchiveLogs( * @returns Async iterator of logs. */ export async function* getEvmLogs( - filter: LogFilter & { onlyFallback?: boolean }, + filter: SetFieldType & { onlyFallback?: boolean }, ctx: { provider: JsonRpcApiProvider; destroy$?: Promise } & WithLogger, ): AsyncIterableIterator { const { provider, logger = console } = ctx - if (filter.watch) { - if (typeof filter.endBlock === 'number' && filter.endBlock > 0) - throw new CCIPLogsWatchRequiresFinalityError(filter.endBlock) - else if (filter.startBlock == null && filter.startTime == null) - throw new CCIPLogsWatchRequiresStartError() - } + if (filter.startBlock == null && filter.startTime == null) throw new CCIPLogsRequiresStartError() + if (filter.watch && typeof filter.endBlock === 'number' && filter.endBlock > 0) + throw new CCIPLogsWatchRequiresFinalityError(filter.endBlock) if ( filter.topics?.length && @@ -217,7 +218,7 @@ export async function* getEvmLogs( const { number: endBlock } = (await provider.getBlock(filter.endBlock || 'latest'))! - if (filter.startBlock == null && filter.startTime) { + if (filter.startBlock == null && filter.startTime != null) { filter.startBlock = await getSomeBlockNumberBefore( async (block: number) => (await provider.getBlock(block))!.timestamp, // cached endBlock, @@ -225,13 +226,15 @@ export async function* getEvmLogs( ctx, ) } + filter.startBlock = Math.max(0, filter.startBlock!) + const startBlock = filter.startBlock if (filter.onlyFallback != null) { if (!filter.address || !filter.topics?.length) throw new CCIPLogsAddressRequiredError() let logs try { logs = await provider.getLogs({ ...filter, - fromBlock: filter.startBlock ?? 1, + fromBlock: startBlock, toBlock: endBlock, }) } catch (_) { @@ -241,7 +244,7 @@ export async function* getEvmLogs( { address: filter.address, topics: filter.topics, - startBlock: filter.startBlock ?? 1, + startBlock, endBlock, }, ctx, @@ -251,15 +254,14 @@ export async function* getEvmLogs( } } if (logs) { - if (!filter.startBlock) logs.reverse() yield* logs return } } - let latestLogBlockNumber = filter.startBlock ?? 1 + let latestLogBlockNumber = startBlock // paginate only if filter.onlyFallback isn't true - for (const blockRange of blockRangeGenerator({ ...filter, endBlock })) { + for (const blockRange of blockRangeGenerator({ ...filter, startBlock, endBlock })) { const filter_ = { ...blockRange, ...(filter.address ? { address: filter.address } : {}), @@ -269,7 +271,6 @@ export async function* getEvmLogs( const logs = await provider.getLogs(filter_) if (logs.length) latestLogBlockNumber = Math.max(latestLogBlockNumber, logs[logs.length - 1]!.blockNumber) - if (filter.startBlock == null) logs.reverse() yield* logs } diff --git a/ccip-sdk/src/logs.test.ts b/ccip-sdk/src/logs.test.ts new file mode 100644 index 00000000..511a20d1 --- /dev/null +++ b/ccip-sdk/src/logs.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { describe, it, mock } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' +import type { SuiGraphQLClient } from '@mysten/sui/graphql' +import type { SuiJsonRpcClient } from '@mysten/sui/jsonRpc' +import type { Connection } from '@solana/web3.js' +import type { TonClient } from '@ton/ton' +import type { JsonRpcApiProvider } from 'ethers' + +import { streamAptosLogs } from './aptos/logs.ts' +import { getEvmLogs } from './evm/logs.ts' +import { getTransactionsForAddress } from './solana/logs.ts' +import { streamSuiLogs } from './sui/events.ts' +import { streamTransactionsForAddress } from './ton/logs.ts' + +const silentLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} + +async function consume(iterable: AsyncIterable) { + for await (const _ of iterable) { + // drain + } +} + +describe('logs start position validation', () => { + it('requires startBlock or startTime for EVM logs', async () => { + await assert.rejects(() => consume(getEvmLogs({}, { provider: {} as JsonRpcApiProvider })), { + name: 'CCIPLogsRequiresStartError', + }) + }) + + it('requires startBlock or startTime for Solana logs', async () => { + await assert.rejects( + () => + consume( + getTransactionsForAddress( + { address: '11111111111111111111111111111111' }, + { + connection: {} as Connection, + getTransaction: mock.fn(), + }, + ), + ), + { name: 'CCIPLogsRequiresStartError' }, + ) + }) + + it('requires startBlock or startTime for TON logs', async () => { + await assert.rejects( + () => + consume( + streamTransactionsForAddress( + { address: `0:${'1'.repeat(64)}` }, + { + provider: {} as TonClient, + getTransaction: mock.fn(), + }, + ), + ), + { name: 'CCIPLogsRequiresStartError' }, + ) + }) + + it('requires startBlock or startTime for Aptos logs', async () => { + await assert.rejects( + () => + consume( + streamAptosLogs({ provider: {} as Aptos }, { address: '0x1::ccip', topics: ['Foo'] }), + ), + { name: 'CCIPLogsRequiresStartError' }, + ) + }) + + it('requires startBlock or startTime for Sui logs', async () => { + await assert.rejects( + () => + consume( + streamSuiLogs( + { client: {} as SuiJsonRpcClient, graphqlClient: {} as SuiGraphQLClient }, + { address: '0x1::ccip', topics: ['Foo'] }, + ), + ), + { name: 'CCIPLogsRequiresStartError' }, + ) + }) +}) + +describe('EVM logs block tags', () => { + it('accepts safe as an endBlock tag', async () => { + const getBlock = mock.fn(async (_block: unknown) => ({ number: 123, timestamp: 1000 })) + const getLogs = mock.fn(async (_filter: { toBlock?: number }) => []) + const provider = { getBlock, getLogs } as unknown as JsonRpcApiProvider + + await consume( + getEvmLogs({ startBlock: 100, endBlock: 'safe' }, { provider, logger: silentLogger }), + ) + + assert.equal(getBlock.mock.calls[0]!.arguments[0], 'safe') + assert.equal(getLogs.mock.calls[0]!.arguments[0].toBlock, 123) + }) +}) diff --git a/ccip-sdk/src/requests.test.ts b/ccip-sdk/src/requests.test.ts index e3f99a1f..02785671 100644 --- a/ccip-sdk/src/requests.test.ts +++ b/ccip-sdk/src/requests.test.ts @@ -84,7 +84,9 @@ class MockChain { from: '0x0000000000000000000000000000000000000001', } as ChainTransaction), ) - getBlockTimestamp = mock.fn(() => Promise.resolve(1234567890)) + getBlockTimestamp = mock.fn((_block?: number | 'latest' | 'finalized') => + Promise.resolve(1234567890), + ) static decodeMessage = mock.fn( (log: { topics: readonly string[]; data: unknown }): CCIPMessage | undefined => { if (typeof log.data === 'object' && log.data && 'messageId' in log.data) { @@ -265,7 +267,9 @@ describe('getMessageById', () => { })(), ) - const result = await getMessageById(mockedChain as unknown as Chain, '0xMessageId1') + const result = await getMessageById(mockedChain as unknown as Chain, '0xMessageId1', { + startBlock: 0, + }) assert.equal(result.log.index, 1) assert.ok(result.message) assert.equal(result.tx.timestamp, 1234567890) @@ -289,7 +293,8 @@ describe('getMessageById', () => { ) await assert.rejects( - async () => await getMessageById(mockedChain as unknown as Chain, '0xMessageId1'), + async () => + await getMessageById(mockedChain as unknown as Chain, '0xMessageId1', { startBlock: 0 }), /Could not find a CCIPSendRequested message with messageId: 0xMessageId1/, ) @@ -409,7 +414,15 @@ describe('getMessagesInBatch', () => { mockedChain.getLogs.mock.mockImplementation((_opts: LogFilter) => (async function* () { - // Return empty to trigger error + // Only yield the request's own log — not enough to fill batch + yield { + address: rampAddress, + index: 5, + topics: [topic0], + data: mockedMessage(5), + blockNumber: 1, + transactionHash: '0x123', + } })(), ) @@ -422,6 +435,81 @@ describe('getMessagesInBatch', () => { /Could not find all messages in batch/, ) }) + + it('should retry missing earlier batch messages with a forward time window', async () => { + const mockRequest: Omit = { + log: { + address: rampAddress, + topics: [topic0], + blockNumber: 500, + transactionHash: '0x500', + index: 0, + data: mockedMessage(5), + }, + message: { + messageId: '0xMessageId5', + sourceChainSelector: 16015286601757825753n, + sequenceNumber: 5n, + nonce: 0n, + sender: '0x0000000000000000000000000000000000000045', + receiver: toBeHex(456, 32), + data: '0x', + tokenAmounts: [], + sourceTokenData: [], + gasLimit: 100n, + strict: false, + feeToken: '0x0000000000000000000000000000000000008916', + feeTokenAmount: 0n, + }, + lane: { + sourceChainSelector: 16015286601757825753n, + destChainSelector: 10n, + onRamp: rampAddress, + version: CCIPVersion.V1_2, + }, + } + const calls: LogFilter[] = [] + + mockedChain.getBlockTimestamp.mock.mockImplementation( + (block?: number | 'latest' | 'finalized') => + Promise.resolve(typeof block === 'number' ? block * 10 : 0), + ) + mockedChain.getLogs.mock.mockImplementation((opts: LogFilter) => + (async function* () { + calls.push(opts) + const seqs = calls.length === 1 ? [3, 4, 5] : [1, 2, 3] + for (const seq of seqs) { + yield { + address: rampAddress, + index: seq, + topics: [topic0], + data: mockedMessage(seq), + blockNumber: seq * 100, + transactionHash: `0x${seq}`, + } + } + })(), + ) + + const result = await getMessagesInBatch(mockedChain as unknown as Chain, mockRequest, { + minSeqNr: 1n, + maxSeqNr: 5n, + }) + + assert.deepEqual( + result.map((message) => message.sequenceNumber), + [1n, 2n, 3n, 4n, 5n], + ) + assert.equal(calls.length, 2) + assert.equal(calls[0]!.startBlock, 0) + assert.equal(calls[0]!.endBlock, 500) + assert.equal(calls[0]!.startTime, undefined) + assert.equal(calls[0]!.endBefore, undefined) + assert.equal(calls[1]!.endBlock, 300) + assert.equal(calls[1]!.startTime, 0) + assert.equal(calls[1]!.startBlock, undefined) + assert.equal(calls[1]!.endBefore, undefined) + }) }) describe('decodeMessage', () => { diff --git a/ccip-sdk/src/requests.ts b/ccip-sdk/src/requests.ts index 43851bc0..c6f1d846 100644 --- a/ccip-sdk/src/requests.ts +++ b/ccip-sdk/src/requests.ts @@ -1,9 +1,11 @@ import { type BytesLike, hexlify, isBytesLike, toBigInt } from 'ethers' +import { memoize } from 'micro-memoize' import type { PickDeep } from 'type-fest' -import type { Chain, ChainStatic } from './chain.ts' +import type { Chain, ChainStatic, LogFilter } from './chain.ts' import { CCIPChainFamilyUnsupportedError, + CCIPLogsRequiresStartError, CCIPMessageBatchIncompleteError, CCIPMessageDecodeError, CCIPMessageIdNotFoundError, @@ -19,6 +21,7 @@ import { type CCIPMessage, type CCIPRequest, type CCIPVersion, + type ChainLog, type ChainTransaction, type MessageInput, ChainFamily, @@ -259,6 +262,7 @@ export async function getMessagesInTx(source: Chain, tx: ChainTransaction): Prom * const source = await EVMChain.fromUrl('https://rpc.sepolia.org') * const request = await getMessageById(source, '0xabc123...', { * onRamp: '0xOnRampAddress...', + * startTime: 1710000000, * }) * console.log(`Found: seqNr=${request.message.sequenceNumber}`) * ``` @@ -268,12 +272,14 @@ export async function getMessagesInTx(source: Chain, tx: ChainTransaction): Prom export async function getMessageById( source: Chain, messageId: string, - opts?: { page?: number; onRamp?: string }, + opts?: Pick & { onRamp?: string }, ): Promise { + if (opts?.startBlock == null && opts?.startTime == null) throw new CCIPLogsRequiresStartError() + const { onRamp, ...hints } = opts for await (const log of source.getLogs({ topics: ['CCIPSendRequested', 'CCIPMessageSent'], - address: opts?.onRamp, - ...opts, + address: onRamp, + ...hints, })) { const message = (source.constructor as ChainStatic).decodeMessage(log) if (message?.messageId !== messageId) continue @@ -302,6 +308,7 @@ export async function getMessageById( // Number of blocks to expand the search window for logs const BLOCK_LOG_WINDOW_SIZE = 5000 +const BATCH_LOG_LOOKBACK_SECONDS = 60 * 60 /** * Fetches all CCIP messages contained in a given commit batch. @@ -317,7 +324,9 @@ export async function getMessagesInBatch< C extends Chain, R extends PickDeep< CCIPRequest, - 'lane' | `log.${'topics' | 'address' | 'blockNumber'}` | 'message.sequenceNumber' + | 'lane' + | `log.${'topics' | 'address' | 'blockNumber' | 'tx.timestamp'}` + | 'message.sequenceNumber' >, >( source: C, @@ -325,31 +334,40 @@ export async function getMessagesInBatch< { minSeqNr, maxSeqNr }: { minSeqNr: bigint; maxSeqNr: bigint }, opts: Parameters[0] = { page: BLOCK_LOG_WINDOW_SIZE }, ): Promise { + // short-circuit trivial batchSize=1 if (minSeqNr === maxSeqNr) return [request.message] - const filter = { - page: BLOCK_LOG_WINDOW_SIZE, + type LogAnchor = PickDeep + type BatchEntry = { log: LogAnchor; message: R['message'] } + + const baseFilter = { + page: opts.page ?? BLOCK_LOG_WINDOW_SIZE, topics: [request.log.topics[0]], address: request.log.address, ...opts, } - if (request.message.sequenceNumber === maxSeqNr) filter.endBlock = request.log.blockNumber - else - // start proportionally before send request block, including case when seqNum==min => startBlock - filter.startBlock = - request.log.blockNumber - - Math.ceil( - (Number(request.message.sequenceNumber - minSeqNr) / Number(maxSeqNr - minSeqNr)) * - filter.page, - ) - const messages: R['message'][] = [] - if (filter.startBlock) { - // forward - let backwardsBefore, backwardsEndBlock + const entries: BatchEntry[] = [] + + const getLogTimestamp = memoize( + async (log: LogAnchor): Promise => { + if (log.tx?.timestamp != null) { + getLogTimestamp.cache.set([log], Promise.resolve(log.tx.timestamp)) + return log.tx.timestamp + } + const timestamp = source.getBlockTimestamp(log.blockNumber) + getLogTimestamp.cache.set([log], timestamp) + return timestamp + }, + { async: true, transformKey: ([log]) => [log.blockNumber] as const }, + ) + + const collectForward = async (filter: Parameters[0]): Promise => { + // on first, collect up to batch end; on subsequent, collect up to before earliest seen + const stopAtSeqNr = entries.length ? entries[0]!.message.sequenceNumber - 1n : maxSeqNr + let done = false + const head: BatchEntry[] = [] for await (const log of source.getLogs(filter)) { - backwardsBefore ??= log.transactionHash - backwardsEndBlock ??= log.blockNumber - 1 const message = (source.constructor as ChainStatic).decodeMessage(log) if ( !message || @@ -358,40 +376,62 @@ export async function getMessagesInBatch< message.destChainSelector !== request.lane.destChainSelector) ) continue - if (message.sequenceNumber < minSeqNr) continue - messages.push(message) - if (message.sequenceNumber >= maxSeqNr) break - } - if (messages.length && messages[0]!.sequenceNumber > minSeqNr) { - // still work to be done backwards - delete filter['startBlock'] - filter['endBlock'] = backwardsEndBlock - filter['endBefore'] = backwardsBefore + if (message.sequenceNumber <= minSeqNr) done = true // if we see anything before batch, we're sure there's nothing earlier + if (message.sequenceNumber < minSeqNr) continue // if before batch, ignore + if (message.sequenceNumber <= maxSeqNr) head.push({ log, message }) // inside batch, collect + if (message.sequenceNumber >= stopAtSeqNr) break } + entries.unshift(...head) + return done } - if (filter.endBlock) { - // backwards - for await (const log of source.getLogs(filter)) { - const message = (source.constructor as ChainStatic).decodeMessage(log) - if ( - !message || - !('sequenceNumber' in message) || - ('destChainSelector' in message && - message.destChainSelector !== request.lane.destChainSelector) - ) - continue - messages.unshift(message) - if (message.sequenceNumber <= minSeqNr) break + + // first, start proportionally before send request block; guaranteed to return at least 1 item (request's) + let done = await collectForward({ + ...baseFilter, + startBlock: Math.max( + 0, + // edge cases: our req first => [req..]; our req last => [req-page..req] + request.log.blockNumber - + Math.ceil( + (Number(request.message.sequenceNumber - minSeqNr) / Number(maxSeqNr - minSeqNr)) * + baseFilter.page, + ), + ), + // iff our request is maxSeqNr, we know we don't need to go past it + ...(request.message.sequenceNumber === maxSeqNr && { + endBlock: request.log.blockNumber, + }), + }) + + let retries = 0 + const batchSize = Number(maxSeqNr - minSeqNr) + 1 + while (!done && entries[0]!.message.sequenceNumber > minSeqNr) { + const earliest = entries[0]! + const earliestBefore = earliest.message.sequenceNumber + const earliestTimestamp = await getLogTimestamp(earliest.log) + + done = await collectForward({ + ...baseFilter, + startTime: Math.max(0, earliestTimestamp - BATCH_LOG_LOOKBACK_SECONDS * 2 ** retries), + endBlock: earliest.log.blockNumber, + }) + + const earliestAfter = entries[0]!.message.sequenceNumber + if (earliestAfter < earliestBefore) { + retries = 0 + } else { + retries++ + if (retries >= 6) break } } - if (messages.length != Number(maxSeqNr - minSeqNr) + 1) { + if (entries.length < batchSize) { throw new CCIPMessageBatchIncompleteError( { min: minSeqNr, max: maxSeqNr }, - messages.map((e) => e.sequenceNumber), + entries.map((e) => e.message.sequenceNumber), ) } - return messages + return entries.map((e) => e.message) } /** diff --git a/ccip-sdk/src/solana/cleanup.ts b/ccip-sdk/src/solana/cleanup.ts index ae20db6a..8d7dc104 100644 --- a/ccip-sdk/src/solana/cleanup.ts +++ b/ccip-sdk/src/solana/cleanup.ts @@ -103,6 +103,7 @@ export async function cleanUpBuffers( let alreadyClosed = 0 for await (const log of ctx.getLogs({ address: wallet.publicKey.toBase58(), + startBlock: 0, topics: [ 'Instruction: BufferExecutionReport', 'Instruction: CreateLookupTable', diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index c6181dd7..83f21087 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -419,11 +419,11 @@ export class SolanaChain extends Chain { * * Returns logs in chronological order (oldest first) * * - If opts.startBlock and opts.startTime are omitted: - * * Fetches signatures in reverse chronological order (newest first) - * * Returns logs in reverse chronological order (newest first) + * * Uses slot 0 as the forward start for non-watch queries * * @param opts - Log filter options containing: * - `startBlock`: Starting slot number (inclusive) + * Solana's special case: if startBlock=0, fetch only one page of getSignaturesForAddress * - `startTime`: Starting Unix timestamp (inclusive) * - `endBlock`: Ending slot number (inclusive) * - `endBefore`: Fetch signatures before this transaction @@ -460,9 +460,7 @@ export class SolanaChain extends Chain { // Process signatures and yield logs for await (const tx of this.getTransactionsForAddress(opts)) { - let logs = tx.logs - if (opts.startBlock == null && opts.startTime == null) logs = logs.toReversed() // backwards - for (const log of logs) { + for (const log of tx.logs) { // Filter and yield logs from the specified program, and which match event discriminant or log prefix if ( (programs !== true && !programs.includes(log.address)) || @@ -573,6 +571,7 @@ export class SolanaChain extends Chain { for await (const log of this.getLogs({ programs: true, address: feeQuoterDestChainStateAccountAddress.toBase58(), + startBlock: 0, // use getLogs special-case to do a single getSignaturesForAddress pass topics: ['ExecutionStateChanged', 'CommitReportAccepted', 'Transmitted'], })) { return [log.address] // assume single offramp per router/deployment on Solana diff --git a/ccip-sdk/src/solana/logs.ts b/ccip-sdk/src/solana/logs.ts index cdb58f8e..91fee57d 100644 --- a/ccip-sdk/src/solana/logs.ts +++ b/ccip-sdk/src/solana/logs.ts @@ -4,8 +4,8 @@ import type { LogFilter } from '../chain.ts' import type { SolanaTransaction } from './index.ts' import { CCIPLogsAddressRequiredError, + CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, - CCIPLogsWatchRequiresStartError, } from '../errors/index.ts' import { sleep } from '../utils.ts' @@ -32,14 +32,15 @@ async function* fetchSigsForward( while ( batch.length > 0 && - (batch[batch.length - 1]!.slot < (opts.startBlock || 0) || - (batch[batch.length - 1]!.blockTime || -1) < (opts.startTime || 0)) + (batch[batch.length - 1]!.slot < (opts.startBlock ?? 0) || + (batch[batch.length - 1]!.blockTime ?? -1) < (opts.startTime ?? 0)) ) { batch.length-- // truncate tail of txs which are older than requested start } allSigs.push(...batch) // concat in descending order - } while (batch.length >= limit) + // special case: if startBlock=0, do a single pass + } while (batch.length >= limit && (opts.startBlock || opts.startTime)) allSigs.reverse() // forward @@ -49,7 +50,7 @@ async function* fetchSigsForward( : opts.endBlock < 0 ? (await connection.getSlot('confirmed')) + opts.endBlock : opts.endBlock - while (notAfter && allSigs.length > 0 && allSigs[allSigs.length - 1]!.slot > notAfter) { + while (notAfter != null && allSigs.length > 0 && allSigs[allSigs.length - 1]!.slot > notAfter) { allSigs.length-- // truncate head (after reverse) of txs newer than requested end } yield* allSigs // all past logs @@ -81,45 +82,13 @@ async function* fetchSigsForward( : opts.endBlock for (const sig of batch) { - if (notAfter && sig.slot > notAfter) break + if (notAfter != null && sig.slot > notAfter) break until = sig.signature yield sig } } } -async function* fetchSigsBackwards( - opts: LogFilter & { pollInterval?: number }, - ctx: { connection: Connection }, -) { - const { connection } = ctx - const limit = Math.min(opts.page || 1000, 1000) - const commitment = opts.endBlock === 'finalized' ? 'finalized' : 'confirmed' - - if (typeof opts.endBlock === 'number' && opts.endBlock < 0) - opts.endBlock = (await connection.getSlot('confirmed')) + opts.endBlock - - let batch: Awaited> | undefined - do { - batch = await connection.getSignaturesForAddress( - new PublicKey(opts.address!), - { - limit, - before: batch?.length - ? batch[batch.length - 1]!.signature - : opts.endBefore - ? opts.endBefore - : undefined, - }, - commitment, - ) - for (const sig of batch) { - if (typeof opts.endBlock === 'number' && sig.slot > opts.endBlock) continue - yield sig - } - } while (batch.length >= limit) -} - /** * Internal method to get transactions for an address with pagination. * @param opts - Log filter options. @@ -134,19 +103,14 @@ export async function* getTransactionsForAddress( ): AsyncGenerator { if (!opts.address) throw new CCIPLogsAddressRequiredError() - opts.endBlock ||= 'latest' + opts.endBlock ??= 'latest' - let allSignatures - if (opts.startBlock != null || opts.startTime != null) { - if (opts.watch && ((typeof opts.endBlock === 'number' && opts.endBlock > 0) || opts.endBefore)) - throw new CCIPLogsWatchRequiresFinalityError(opts.endBlock) + const hasStart = opts.startBlock != null || opts.startTime != null + if (!hasStart) throw new CCIPLogsRequiresStartError() + if (opts.watch && ((typeof opts.endBlock === 'number' && opts.endBlock > 0) || opts.endBefore)) + throw new CCIPLogsWatchRequiresFinalityError(opts.endBlock) - allSignatures = fetchSigsForward(opts, ctx) - } else { - if (opts.watch) throw new CCIPLogsWatchRequiresStartError() - - allSignatures = fetchSigsBackwards(opts, ctx) // generate backwards until depleting getSignaturesForAddress - } + const allSignatures = fetchSigsForward(opts, ctx) // Process signatures for await (const signatureInfo of allSignatures) { diff --git a/ccip-sdk/src/sui/events.ts b/ccip-sdk/src/sui/events.ts index 4e77a797..fc77f9a0 100644 --- a/ccip-sdk/src/sui/events.ts +++ b/ccip-sdk/src/sui/events.ts @@ -4,8 +4,8 @@ import type { SuiEventFilter, SuiJsonRpcClient } from '@mysten/sui/jsonRpc' import type { LogFilter } from '../chain.ts' import { CCIPDataFormatUnsupportedError, + CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError, - CCIPLogsWatchRequiresStartError, CCIPTopicsInvalidError, } from '../errors/index.ts' import { sleep } from '../utils.ts' @@ -127,18 +127,18 @@ async function* fetchEventsForward( throw new CCIPLogsWatchRequiresFinalityError(opts.endBlock) // Determine starting checkpoint - let startCheckpoint - if (opts.startBlock) startCheckpoint = opts.startBlock - if (opts.startTime) { + let startCheckpoint: number | undefined + if (opts.startBlock != null) startCheckpoint = opts.startBlock + if (opts.startTime != null) { // Use getTransactionDigestsInTimeRange to find the checkpoint for startTime // Use a small time window to find transactions near startTime const startCheckpoint_ = await getCheckpointRightBefore(ctx.client, opts.startTime) - if (startCheckpoint_) { - if (startCheckpoint) startCheckpoint = Math.max(startCheckpoint, startCheckpoint_) + if (startCheckpoint_ != null) { + if (startCheckpoint != null) startCheckpoint = Math.max(startCheckpoint, startCheckpoint_) else startCheckpoint = startCheckpoint_ } } - if (!startCheckpoint) throw new CCIPLogsWatchRequiresStartError() + if (startCheckpoint == null) throw new CCIPLogsRequiresStartError() // Determine ending checkpoint let endCheckpoint: number | undefined @@ -237,7 +237,7 @@ async function* fetchEventsForward( for (const node of nodes) { // Filter by startTime if provided (timestamp is in ISO format) - if (opts.startTime) { + if (opts.startTime != null) { const eventTime = new Date(node.timestamp).getTime() / 1000 // Convert to seconds if (eventTime < opts.startTime) continue } @@ -274,128 +274,6 @@ async function* fetchEventsForward( } } -/** - * Fetches events in backward direction (descending checkpoint order). - */ -async function* fetchEventsBackward( - ctx: { client: SuiJsonRpcClient; graphqlClient: SuiGraphQLClient }, - opts: LogFilter, - type: string, - limit = 50, -): AsyncGenerator> { - // Determine ending checkpoint (where to stop going backwards) - let endCheckpoint: number | undefined - if (typeof opts.endBlock === 'number') { - if (opts.endBlock < 0) { - endCheckpoint = (await getLatestCheckpoint(ctx.graphqlClient)) + opts.endBlock - } else { - endCheckpoint = opts.endBlock - } - } - - // Start from the latest checkpoint and go backwards - const latestCheckpoint = await getLatestCheckpoint(ctx.graphqlClient) - let currentCheckpoint = latestCheckpoint - - const allEvents: EventNode[] = [] - - // Fetch all events going backwards - while (currentCheckpoint >= 0) { - let cursor: string | undefined = undefined - let hasNextPage = true - - const minCheckpoint = endCheckpoint !== undefined ? endCheckpoint : 0 - - while (hasNextPage) { - const query = ` - query FetchEvents($type: String!, $after: String, $afterCheckpoint: UInt53!, $beforeCheckpoint: UInt53!) { - events( - filter: { - type: $type - afterCheckpoint: $afterCheckpoint - beforeCheckpoint: $beforeCheckpoint - } - after: $after - last: ${limit} - ) { - nodes { - sequenceNumber - sender { - address - } - timestamp - contents { - json - } - transaction { - effects { - checkpoint { - sequenceNumber - } - } - digest - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - ` - - const batchStartCheckpoint = Math.max(currentCheckpoint - 1000, minCheckpoint) - - const result: { data?: EventsQueryResponse; errors?: unknown } = - await ctx.graphqlClient.query({ - query, - variables: { - type, - after: cursor, - afterCheckpoint: batchStartCheckpoint, - beforeCheckpoint: currentCheckpoint + 1, - }, - }) - - if (result.errors) { - throw new CCIPDataFormatUnsupportedError( - `GraphQL errors: ${JSON.stringify(result.errors, null, 2)}`, - ) - } - - if (!result.data) { - throw new CCIPDataFormatUnsupportedError('No data returned from GraphQL query') - } - - const { nodes, pageInfo } = result.data.events - - if (!nodes.length) { - break - } - - for (const node of nodes) { - allEvents.push(node as EventNode) - } - - hasNextPage = pageInfo.hasNextPage - cursor = pageInfo.endCursor ?? undefined - } - - currentCheckpoint = Math.max(currentCheckpoint - 1000, minCheckpoint) - 1 - if (currentCheckpoint < minCheckpoint) break - } - - // Yield events in descending order (most recent first) - for (const event of allEvents.reverse()) { - // Filter out events after endBlock if specified - if (endCheckpoint !== undefined && event.transaction) { - const checkpoint = event.transaction.effects.checkpoint.sequenceNumber - if (checkpoint > endCheckpoint) continue - } - yield event - } -} - /** * Streams logs from the Sui blockchain based on filter options. * @param ctx - Context containing Sui client and grraphqlClient instances. @@ -414,14 +292,8 @@ export async function* streamSuiLogs( // opts.topics[0] is the EventName const eventType = `${opts.address}::${opts.topics[0]}` - // Forward mode: if startTime or startBlock are provided, or if watch is enabled - if (opts.startBlock || opts.startTime || opts.watch) { - if (opts.watch && !opts.startBlock && !opts.startTime) { - throw new CCIPLogsWatchRequiresStartError() - } - yield* fetchEventsForward(ctx, opts, eventType) - } else { - // Backward mode: paginate backwards until depleting events - yield* fetchEventsBackward(ctx, opts, eventType) - } + const hasStart = opts.startBlock != null || opts.startTime != null + if (!hasStart) throw new CCIPLogsRequiresStartError() + + yield* fetchEventsForward(ctx, opts, eventType) } diff --git a/ccip-sdk/src/ton/index.integration.test.ts b/ccip-sdk/src/ton/index.integration.test.ts index 8ba18675..e154177b 100644 --- a/ccip-sdk/src/ton/index.integration.test.ts +++ b/ccip-sdk/src/ton/index.integration.test.ts @@ -141,6 +141,7 @@ describe.skip('TON index integration tests', () => { for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 10, })) { logs.push(log) @@ -169,12 +170,12 @@ describe.skip('TON index integration tests', () => { ) } - // Verify descending order if we have multiple logs + // Verify ascending order if we have multiple logs if (logs.length > 1) { for (let i = 1; i < logs.length; i++) { assert.ok( - logs[i - 1].blockNumber >= logs[i].blockNumber, - 'Logs should be in descending order by blockNumber', + logs[i - 1].blockNumber <= logs[i].blockNumber, + 'Logs should be in ascending order by blockNumber', ) } } @@ -185,6 +186,7 @@ describe.skip('TON index integration tests', () => { for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOffRamp, + startBlock: 0, page: 10, })) { logs.push(log) @@ -200,6 +202,7 @@ describe.skip('TON index integration tests', () => { for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 5, })) { logs.push(log) @@ -216,6 +219,7 @@ describe.skip('TON index integration tests', () => { const recentLogs: any[] = [] for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 5, })) { recentLogs.push(log) @@ -224,14 +228,14 @@ describe.skip('TON index integration tests', () => { assert.ok(recentLogs.length >= 2, 'Need at least 2 logs to test filtering') - // Use the highest block from fetched logs (logs are in descending order by lt) - const highBlock = recentLogs[0].blockNumber + // Use the highest block from fetched logs. + const highBlock = Math.max(...recentLogs.map((log) => log.blockNumber)) - // Test endBlock: should only get logs <= endBlock - // When endBlock is specified, iteration starts from that point going backwards + // Test endBlock: should only get logs <= endBlock. const logsWithEndBlock: any[] = [] for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, endBlock: highBlock, page: 10, })) { @@ -259,6 +263,7 @@ describe.skip('TON index integration tests', () => { // Fetch a real CCIPMessageSent log from OnRamp for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 10, })) { const decoded = TONChain.decodeMessage(log) @@ -428,6 +433,7 @@ describe.skip('TON index integration tests', () => { // Fetch a real CommitReportAccepted log from OffRamp for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOffRamp, + startBlock: 0, topics: ['CommitReportAccepted'], page: 20, })) { @@ -453,6 +459,7 @@ describe.skip('TON index integration tests', () => { it('should return undefined for non-commit BOC data', async () => { for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 1, })) { assert.equal( @@ -535,6 +542,7 @@ describe.skip('TON index integration tests', () => { // Fetch a real ExecutionStateChanged log from OffRamp for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOffRamp, + startBlock: 0, topics: ['ExecutionStateChanged'], page: 20, })) { @@ -561,6 +569,7 @@ describe.skip('TON index integration tests', () => { // Fetch CCIPMessageSent logs from OnRamp - these should NOT decode as receipts for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 1, })) { assert.equal( @@ -701,6 +710,7 @@ describe.skip('TON index integration tests', () => { // Get a known transaction hash from logs for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 1, })) { knownTxHash = log.transactionHash @@ -799,6 +809,7 @@ describe.skip('TON index integration tests', () => { let logLt: number | undefined for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 1, })) { logLt = log.blockNumber @@ -829,6 +840,7 @@ describe.skip('TON index integration tests', () => { let txHash: string | undefined for await (const log of tonChain.getLogs({ address: ADDRESSES_TO_ASSERT.tonOnRamp, + startBlock: 0, page: 1, })) { if (TONChain.decodeMessage(log)) { diff --git a/ccip-sdk/src/ton/index.ts b/ccip-sdk/src/ton/index.ts index 18e666b8..35840cd9 100644 --- a/ccip-sdk/src/ton/index.ts +++ b/ccip-sdk/src/ton/index.ts @@ -383,9 +383,7 @@ export class TONChain extends Chain { ]) } for await (const tx of streamTransactionsForAddress(opts, this)) { - const logs = - opts.startBlock == null && opts.startTime == null ? tx.logs.toReversed() : tx.logs - for (const log of logs) { + for (const log of tx.logs) { if (topics && !topics.has(log.topics[0]!)) continue yield log } diff --git a/ccip-sdk/src/ton/logs.test.ts b/ccip-sdk/src/ton/logs.test.ts index 5d346df9..965cce4d 100644 --- a/ccip-sdk/src/ton/logs.test.ts +++ b/ccip-sdk/src/ton/logs.test.ts @@ -92,7 +92,28 @@ describe('TON logs unit tests', () => { ) }) - it('should throw CCIPLogsWatchRequiresStartError when watch is true but no startBlock or startTime', async () => { + it('should throw CCIPLogsRequiresStartError when no startBlock or startTime is provided', async () => { + const mockProvider = {} as TonClient + const mockGetTransaction = mock.fn(async () => createMockChainTransaction('hash', 1)) + + await assert.rejects( + async () => { + for await (const _tx of streamTransactionsForAddress( + { + address: TEST_ADDRESS, + }, + { provider: mockProvider, getTransaction: mockGetTransaction }, + )) { + // Should not reach here + } + }, + { + name: 'CCIPLogsRequiresStartError', + }, + ) + }) + + it('should throw CCIPLogsRequiresStartError when watch is true but no startBlock or startTime', async () => { const mockProvider = {} as TonClient const mockGetTransaction = mock.fn(async () => createMockChainTransaction('hash', 1)) @@ -109,7 +130,7 @@ describe('TON logs unit tests', () => { } }, { - name: 'CCIPLogsWatchRequiresStartError', + name: 'CCIPLogsRequiresStartError', }, ) }) @@ -333,8 +354,8 @@ describe('TON logs unit tests', () => { }) }) - describe('backward fetching (without startBlock)', () => { - it('should fetch transactions backward when no startBlock or startTime', async () => { + describe('explicit origin start (startBlock=0)', () => { + it('should fetch transactions forward from the oldest available transaction', async () => { const tx1 = createMockTransaction({ lt: 1002n, now: 102 }) const tx2 = createMockTransaction({ lt: 1001n, now: 101 }) const tx3 = createMockTransaction({ lt: 1000n, now: 100 }) @@ -352,6 +373,7 @@ describe('TON logs unit tests', () => { for await (const tx of streamTransactionsForAddress( { address: TEST_ADDRESS, + startBlock: 0, }, { provider: mockProvider, getTransaction: mockGetTransaction }, )) { @@ -359,10 +381,14 @@ describe('TON logs unit tests', () => { } assert.equal(results.length, 3) + assert.deepEqual( + results.map((tx) => tx.blockNumber), + [1000, 1001, 1002], + ) assert.equal(mockGetTransaction.mock.calls.length, 3) }) - it('should filter transactions by endBlock in backward mode', async () => { + it('should filter transactions by endBlock', async () => { const tx1 = createMockTransaction({ lt: 1002n, now: 102 }) const tx2 = createMockTransaction({ lt: 1001n, now: 101 }) const tx3 = createMockTransaction({ lt: 1000n, now: 100 }) @@ -380,6 +406,7 @@ describe('TON logs unit tests', () => { for await (const tx of streamTransactionsForAddress( { address: TEST_ADDRESS, + startBlock: 0, endBlock: 1001, }, { provider: mockProvider, getTransaction: mockGetTransaction }, @@ -391,42 +418,7 @@ describe('TON logs unit tests', () => { assert.equal(results.length, 2) }) - it('should handle endBefore parameter in backward mode', async () => { - const tx1 = createMockTransaction({ lt: 1002n, now: 102 }) - const tx2 = createMockTransaction({ lt: 1001n, now: 101 }) - - const getTransactionsMock = mock.fn(async (addr, opts) => { - if (opts?.hash) { - assert.equal(opts.hash, 'testhash') - assert.equal(opts.lt, '1001') - return [tx2] - } - return [tx1, tx2] - }) - const mockProvider = { - getTransactions: getTransactionsMock, - } as unknown as TonClient - - const mockGetTransaction = mock.fn(async (tx: Transaction) => - createMockChainTransaction(tx.hash().toString('base64'), Number(tx.lt)), - ) - - const results: ChainTransaction[] = [] - for await (const tx of streamTransactionsForAddress( - { - address: TEST_ADDRESS, - endBlock: 1001, - endBefore: 'testhash', - }, - { provider: mockProvider, getTransaction: mockGetTransaction }, - )) { - results.push(tx) - } - - assert.ok(results.length >= 1) - }) - - it('should treat negative endBlock as latest in backward mode', async () => { + it('should treat negative endBlock as latest', async () => { const tx1 = createMockTransaction({ lt: 1000n, now: 100 }) const getTransactionsMock = mock.fn(async () => [tx1]) @@ -442,6 +434,7 @@ describe('TON logs unit tests', () => { for await (const tx of streamTransactionsForAddress( { address: TEST_ADDRESS, + startBlock: 0, endBlock: -5, }, { provider: mockProvider, getTransaction: mockGetTransaction }, @@ -452,11 +445,11 @@ describe('TON logs unit tests', () => { assert.equal(results.length, 1) }) - it('should handle pagination in backward mode', async () => { - const batch1 = Array.from({ length: 100 }, (_, i) => + it('should handle pagination', async () => { + const batch1 = Array.from({ length: 10 }, (_, i) => createMockTransaction({ lt: BigInt(1100 - i), now: 1100 - i }), ) - const batch2 = Array.from({ length: 50 }, (_, i) => + const batch2 = Array.from({ length: 5 }, (_, i) => createMockTransaction({ lt: BigInt(1000 - i), now: 1000 - i }), ) @@ -478,7 +471,8 @@ describe('TON logs unit tests', () => { for await (const tx of streamTransactionsForAddress( { address: TEST_ADDRESS, - page: 100, + startBlock: 0, + page: 10, }, { provider: mockProvider, getTransaction: mockGetTransaction }, )) { @@ -908,6 +902,7 @@ describe('TON logs unit tests', () => { for await (const tx of streamTransactionsForAddress( { address: TEST_ADDRESS, + startBlock: 0, endBlock: 0, }, { provider: mockProvider, getTransaction: mockGetTransaction }, diff --git a/ccip-sdk/src/ton/logs.ts b/ccip-sdk/src/ton/logs.ts index db5139e3..b807a89e 100644 --- a/ccip-sdk/src/ton/logs.ts +++ b/ccip-sdk/src/ton/logs.ts @@ -2,10 +2,7 @@ import { Address } from '@ton/core' import type { TonClient, Transaction } from '@ton/ton' import type { LogFilter } from '../chain.ts' -import { - CCIPLogsWatchRequiresFinalityError, - CCIPLogsWatchRequiresStartError, -} from '../errors/index.ts' +import { CCIPLogsRequiresStartError, CCIPLogsWatchRequiresFinalityError } from '../errors/index.ts' import { CCIPLogsAddressRequiredError } from '../errors/specialized.ts' import type { ChainTransaction } from '../types.ts' import { sleep } from '../utils.ts' @@ -32,7 +29,7 @@ async function* fetchTxsForward( }) until ??= batch[0]?.lt - while (batch.length > 0 && batch[batch.length - 1]!.now < (opts.startTime || 0)) { + while (batch.length > 0 && batch[batch.length - 1]!.now < (opts.startTime ?? 0)) { batch.length-- // truncate tail of txs which are older than requested start } @@ -43,7 +40,7 @@ async function* fetchTxsForward( const notAfter = typeof opts.endBlock !== 'number' || opts.endBlock < 0 ? undefined : BigInt(opts.endBlock) - while (notAfter && allTxs.length > 0 && allTxs[allTxs.length - 1]!.lt > notAfter) { + while (notAfter != null && allTxs.length > 0 && allTxs[allTxs.length - 1]!.lt > notAfter) { allTxs.length-- // truncate head (after reverse) of txs newer than requested end } yield* allTxs // all past logs @@ -73,38 +70,10 @@ async function* fetchTxsForward( } } -async function* fetchTxsBackwards( - opts: LogFilter & { pollInterval?: number }, - { provider }: { provider: TonClient }, -) { - const limit = Math.min(opts.page || 100, 100) - - if (typeof opts.endBlock === 'number' && opts.endBlock < 0) opts.endBlock = 'latest' - - let batch: Transaction[] | undefined - do { - batch = await provider.getTransactions(Address.parse(opts.address!), { - limit, - ...(batch?.length - ? { - lt: batch[batch.length - 1]!.lt.toString(), - hash: batch[batch.length - 1]!.hash().toString('base64'), - } - : opts.endBefore && opts.endBlock - ? { lt: opts.endBlock.toString(), hash: opts.endBefore } - : {}), - }) - for (const tx of batch) { - if (typeof opts.endBlock === 'number' && tx.lt > BigInt(opts.endBlock)) continue - yield tx - } - } while (batch.length >= limit) -} - /** * Internal method to get transactions for an address with pagination. * @param opts - Log filter options. - * @returns Async generator of Solana transactions. + * @returns Async generator of TON transactions. */ export async function* streamTransactionsForAddress( opts: Omit & { pollInterval?: number }, @@ -115,21 +84,16 @@ export async function* streamTransactionsForAddress( ): AsyncGenerator { if (!opts.address) throw new CCIPLogsAddressRequiredError() - opts.endBlock ||= 'latest' + opts.endBlock ??= 'latest' - let allTransactions - if (opts.startBlock != null || opts.startTime != null) { - if (opts.watch && ((typeof opts.endBlock === 'number' && opts.endBlock > 0) || opts.endBefore)) - throw new CCIPLogsWatchRequiresFinalityError(opts.endBlock) + const hasStart = opts.startBlock != null || opts.startTime != null + if (!hasStart) throw new CCIPLogsRequiresStartError() + if (opts.watch && ((typeof opts.endBlock === 'number' && opts.endBlock > 0) || opts.endBefore)) + throw new CCIPLogsWatchRequiresFinalityError(opts.endBlock) - allTransactions = fetchTxsForward(opts, ctx) - } else { - if (opts.watch) throw new CCIPLogsWatchRequiresStartError() - - allTransactions = fetchTxsBackwards(opts, ctx) // generate backwards until depleting getSignaturesForAddress - } + const allTransactions = fetchTxsForward(opts, ctx) - // Process signatures + // Process transactions for await (const tx of allTransactions) { yield await ctx.getTransaction(tx) } diff --git a/ccip-sdk/src/utils.test.ts b/ccip-sdk/src/utils.test.ts index 49a2109c..42cd89a3 100644 --- a/ccip-sdk/src/utils.test.ts +++ b/ccip-sdk/src/utils.test.ts @@ -46,6 +46,24 @@ describe('getSomeBlockNumberBefore', () => { assert.ok(blockNumber <= 800) assert.ok(blockNumber >= 790) }) + + it('should use the recent block timestamp instead of wall-clock time', async () => { + const baseTimestamp = 1_700_000_000 + const getBlockTimestamp = mock.fn(async (num) => baseTimestamp + num * 12) + + const blockNumber = await getSomeBlockNumberBefore( + getBlockTimestamp, + 10_000, + baseTimestamp + 9_500 * 12, + ) + + assert.ok(blockNumber <= 9500) + assert.ok(blockNumber >= 9490) + assert.ok( + getBlockTimestamp.mock.calls.length < 20, + `expected interpolation to converge quickly, got ${getBlockTimestamp.mock.calls.length} calls`, + ) + }) }) describe('decodeAddress', () => { @@ -453,20 +471,6 @@ describe('networkInfo', () => { }) describe('blockRangeGenerator', () => { - it('should generate block ranges backwards', () => { - const ranges = [...blockRangeGenerator({ endBlock: 100000 })] - assert.equal(ranges.length, 10) - assert.deepEqual(ranges[0], { fromBlock: 90001, toBlock: 100000 }) - assert.deepEqual(ranges[1], { fromBlock: 80001, toBlock: 90000 }) - assert.deepEqual(ranges[2], { fromBlock: 70001, toBlock: 80000 }) - assert.deepEqual(ranges[3], { fromBlock: 60001, toBlock: 70000 }) - assert.deepEqual(ranges[4], { fromBlock: 50001, toBlock: 60000 }) - assert.deepEqual(ranges[5], { fromBlock: 40001, toBlock: 50000 }) - assert.deepEqual(ranges[6], { fromBlock: 30001, toBlock: 40000 }) - assert.deepEqual(ranges[7], { fromBlock: 20001, toBlock: 30000 }) - assert.deepEqual(ranges[8], { fromBlock: 10001, toBlock: 20000 }) - assert.deepEqual(ranges[9], { fromBlock: 1, toBlock: 10000 }) - }) it('should generate block ranges forwards', () => { const ranges = [...blockRangeGenerator({ startBlock: 1000, endBlock: 50000 })] assert.equal(ranges.length, 5) @@ -495,7 +499,7 @@ describe('blockRangeGenerator', () => { it('should handle when endBlock equals startBlock', () => { const ranges = [...blockRangeGenerator({ startBlock: 100, endBlock: 100 })] - assert.equal(ranges.length, 0) + assert.deepEqual(ranges, [{ fromBlock: 100, toBlock: 100, progress: '0%' }]) }) }) diff --git a/ccip-sdk/src/utils.ts b/ccip-sdk/src/utils.ts index f2b8c522..ca95a2fa 100644 --- a/ccip-sdk/src/utils.ts +++ b/ccip-sdk/src/utils.ts @@ -48,13 +48,16 @@ export async function getSomeBlockNumberBefore( timestamp: number, { precision = 10, logger = console }: { precision?: number } & WithLogger = {}, ): Promise { + const recentTimestamp = await getBlockTimestamp(recentBlockNumber) + if (recentTimestamp <= timestamp) return recentBlockNumber + let beforeBlockNumber = Math.max(1, recentBlockNumber - precision * 1000) let beforeTimestamp = await getBlockTimestamp(beforeBlockNumber) - const now = Math.trunc(Date.now() / 1000) - let estimatedBlockTime = (now - beforeTimestamp) / (recentBlockNumber - beforeBlockNumber), + let estimatedBlockTime = + (recentTimestamp - beforeTimestamp) / (recentBlockNumber - beforeBlockNumber), afterBlockNumber = recentBlockNumber, - afterTimestamp = now + afterTimestamp = recentTimestamp // first, go back looking for a block prior to our target timestamp for (let iter = 0; beforeBlockNumber > 1 && beforeTimestamp > timestamp; iter++) { @@ -66,7 +69,8 @@ export async function getSomeBlockNumberBefore( 10 ** iter, ) beforeTimestamp = await getBlockTimestamp(beforeBlockNumber) - estimatedBlockTime = (now - beforeTimestamp) / (recentBlockNumber - beforeBlockNumber) + estimatedBlockTime = + (recentTimestamp - beforeTimestamp) / (recentBlockNumber - beforeBlockNumber) } if (beforeTimestamp > timestamp) { @@ -196,31 +200,23 @@ const BLOCK_RANGE = 10_000 * * @param params - Range parameters: * - `singleBlock` - yields a single `{ fromBlock, toBlock }` for that block. - * - `endBlock` + optional `startBlock` - if `startBlock` is given, moves forward - * from there up to `endBlock`; otherwise moves backward from `endBlock` to genesis. + * - `startBlock` + `endBlock` - moves forward from `startBlock` up to `endBlock`. * - `page` - step size per range (default 10 000). * @returns Generator of `{ fromBlock, toBlock }` pairs, optionally with a `progress` percentage * string when iterating forward. */ export function* blockRangeGenerator( - params: { page?: number } & ({ endBlock: number; startBlock?: number } | { singleBlock: number }), + params: { page?: number } & ({ endBlock: number; startBlock: number } | { singleBlock: number }), ) { const stepSize = params.page ?? BLOCK_RANGE if ('singleBlock' in params) { yield { fromBlock: params.singleBlock, toBlock: params.singleBlock } - } else if ('startBlock' in params && params.startBlock) { - for (let fromBlock = params.startBlock; fromBlock < params.endBlock; fromBlock += stepSize) { + } else { + for (let fromBlock = params.startBlock; fromBlock <= params.endBlock; fromBlock += stepSize) { yield { fromBlock, toBlock: Math.min(params.endBlock, fromBlock + stepSize - 1), - progress: `${Math.trunc(((fromBlock - params.startBlock) / (params.endBlock - params.startBlock)) * 10000) / 100}%`, - } - } - } else { - for (let toBlock = params.endBlock; toBlock > 1; toBlock -= stepSize) { - yield { - fromBlock: Math.max(1, toBlock - stepSize + 1), - toBlock, + progress: `${Math.trunc(((fromBlock - params.startBlock) / Math.max(params.endBlock - params.startBlock, 1)) * 10000) / 100}%`, } } } @@ -752,7 +748,7 @@ export function createRateLimitedFetch( ): typeof fetch { opts.maxRequests ??= 40 opts.maxRetries ??= 5 - opts.windowMs ??= 11e3 + opts.windowMs ??= 12e3 const opts_ = opts as RateLimitOpts const extractMethod = (init?: RequestInit): string | undefined => { @@ -809,6 +805,7 @@ export function createRateLimitedFetch( logger.debug( 'fetched', response.status, + response.headers, body, // ((await response.clone().json()) as { result: unknown })?.result, ) diff --git a/docs/adding-new-chain.md b/docs/adding-new-chain.md index 9e7aecbd..f79a98c1 100644 --- a/docs/adding-new-chain.md +++ b/docs/adding-new-chain.md @@ -30,13 +30,13 @@ Before starting, study these files: ### Reference Implementations -| Chain | File | Completeness | -| ------ | ------------------------------ | ------------------------ | -| EVM | `ccip-sdk/src/evm/index.ts` | Full implementation | -| Solana | `ccip-sdk/src/solana/index.ts` | Full implementation | -| Aptos | `ccip-sdk/src/aptos/index.ts` | Full implementation | +| Chain | File | Completeness | +| ------ | ------------------------------ | ---------------------------------------- | +| EVM | `ccip-sdk/src/evm/index.ts` | Full implementation | +| Solana | `ccip-sdk/src/solana/index.ts` | Full implementation | +| Aptos | `ccip-sdk/src/aptos/index.ts` | Full implementation | | TON | `ccip-sdk/src/ton/index.ts` | Partial (no token pool/registry queries) | -| Sui | `ccip-sdk/src/sui/index.ts` | Partial (manual exec) | +| Sui | `ccip-sdk/src/sui/index.ts` | Partial (manual exec) | --- @@ -54,13 +54,13 @@ All bytearray fields (addresses, data payloads) use the **destination chain's na ### Format by Chain Family -| Chain Family | Address Format | Data Payload | Explorer Example | -|--------------|----------------|--------------|------------------| -| EVM | Checksummed hex (`0x...`) | Hex string | Etherscan | -| Solana | Base58 | Base64 | Solana Explorer | -| Aptos | Full 32-byte hex + `::module` suffix | Hex string | Aptos Explorer | -| Sui | Full 32-byte hex + `::module` suffix | Hex string | SuiVision | -| TON | `workchain:hash` | Hex string | TONScan | +| Chain Family | Address Format | Data Payload | Explorer Example | +| ------------ | ------------------------------------ | ------------ | ---------------- | +| EVM | Checksummed hex (`0x...`) | Hex string | Etherscan | +| Solana | Base58 | Base64 | Solana Explorer | +| Aptos | Full 32-byte hex + `::module` suffix | Hex string | Aptos Explorer | +| Sui | Full 32-byte hex + `::module` suffix | Hex string | SuiVision | +| TON | `workchain:hash` | Hex string | TONScan | :::tip Aptos/Sui Module Suffixes Aptos and Sui addresses often include module suffixes (e.g., `0x123...abc::router`, `0x123...abc::onramp`). The `getAddress()` method preserves these suffixes. Different CCIP components share the same package address but differ by module: `::router`, `::onramp`, `::offramp`, `::fee_quoter`. @@ -78,11 +78,11 @@ Before implementing, check: The SDK provides utilities that handle format conversion: -| Utility | Purpose | File | -|---------|---------|------| -| `getDataBytes(data)` | Normalize any input format to bytes | `utils.ts` | -| `getAddressBytes(address)` | Extract address bytes (handles hex, base58, base64, strips `::module` suffixes) | `utils.ts` | -| `decodeAddress(bytes, family)` | Convert bytes to chain-native string | `utils.ts` | +| Utility | Purpose | File | +| ------------------------------ | ------------------------------------------------------------------------------- | ---------- | +| `getDataBytes(data)` | Normalize any input format to bytes | `utils.ts` | +| `getAddressBytes(address)` | Extract address bytes (handles hex, base58, base64, strips `::module` suffixes) | `utils.ts` | +| `decodeAddress(bytes, family)` | Convert bytes to chain-native string | `utils.ts` | ### Implementation Requirements @@ -185,6 +185,7 @@ Implement all static methods defined in the `ChainStatic` interface. **Reference:** See `ccip-sdk/src/chain.ts` for the complete `ChainStatic` type definition with all required static methods and their signatures. **Key concepts:** + - `fromUrl` - Async factory that creates a chain instance from an RPC URL - `decodeMessage` / `decodeCommits` / `decodeReceipt` - Parse chain-specific log formats; return `undefined` if log doesn't match (don't throw) - `decodeExtraArgs` / `encodeExtraArgs` - Handle your chain's extra args serialization; decoded args include a `_tag` discriminator (e.g., `{ ..., _tag: 'EVMExtraArgsV2' }`) @@ -211,6 +212,7 @@ Implement all abstract methods from the `Chain` base class. **Reference:** See `ccip-sdk/src/chain.ts` for the complete list of abstract methods with JSDoc descriptions. **Method categories:** + - **Block/Transaction** - `getBlockTimestamp`, `getTransaction`, `getLogs` - **Message operations** - `getMessagesInBatch` (note: `getMessagesInTx` has a default implementation) - **Contract queries** - `typeAndVersion`, router/ramp getters @@ -219,6 +221,7 @@ Implement all abstract methods from the `Chain` base class. - **Execution** - `sendMessage`, `execute`, `getOffchainTokenData` **Important patterns:** + - Methods use opts objects (e.g., `SendMessageOpts`, `ExecuteOpts`) - see type definitions in `chain.ts` - `getLogs` is an async generator - see Engineering Patterns section - Some methods have default implementations that can be overridden @@ -236,6 +239,7 @@ The hasher computes deterministic message hashes that must match the on-chain im **Reference:** See `ccip-sdk/src/evm/hasher.ts` or `ccip-sdk/src/solana/hasher.ts` for complete examples. **Key points:** + - Pre-compute lane metadata hash in the factory (done once per lane) - The returned hasher function encodes the message according to your chain's on-chain format - Implement `static getDestLeafHasher(lane, ctx)` in your chain class to return the appropriate hasher @@ -253,6 +257,7 @@ Message hash computation must match the on-chain implementation exactly. Test ag Define your chain-specific types, including the unsigned transaction type for `generateUnsignedSendMessage` and `generateUnsignedExecute`. **Then update `ccip-sdk/src/chain.ts`:** + - Add your `UnsignedYourChainTx` to the `UnsignedTx` type mapping **Reference:** See `ccip-sdk/src/solana/types.ts` or `ccip-sdk/src/evm/types.ts` for examples. @@ -273,10 +278,12 @@ Define your chain-specific types, including the unsigned transaction type for `g ## Step 7: CLI Wallet Provider **Files:** + - `ccip-cli/src/providers/yourchain.ts` - Wallet loading logic - `ccip-cli/src/providers/index.ts` - Add case to `loadChainWallet` switch **Wallet sources to support:** + - Environment variable (`PRIVATE_KEY`) - File path - Ledger (if applicable) @@ -313,6 +320,7 @@ The SDK uses `micro-memoize` to cache expensive RPC calls. Memoize methods in yo **Reference:** See `EVMChain` or `SolanaChain` constructors for memoization patterns. **Common `micro-memoize` options:** + - `maxSize` - Limit cache size - `maxArgs` - Only use first N args for cache key - `transformKey` - Normalize cache keys @@ -326,6 +334,7 @@ The SDK uses `micro-memoize` to cache expensive RPC calls. Memoize methods in yo Chain instances hold network connections that need cleanup. **Pattern:** + 1. Create `destroy$: Promise` that resolves when `destroy()` is called 2. Use `destroy$.finally()` to clean up the client connection 3. In `getLogs`, integrate `destroy$` with watch cancellation via `Promise.race` @@ -348,7 +357,8 @@ Chain instances hold network connections that need cleanup. **`typeAndVersion`:** Returns 4-tuple `[type, version, typeAndVersion, suffix?]`. Use `parseTypeAndVersion` utility from `utils.ts`. **`getLogs`:** Async generator that handles: -- Forward vs backward iteration (based on `startBlock`/`startTime`) + +- Forward iteration from required `startBlock`/`startTime` hints - Watch mode validation and polling - Integration with `destroy$` for cancellation @@ -363,6 +373,7 @@ CCIP message types vary by version (v1.2/v1.5 vs v1.6) and may contain extra arg Each chain family has 4-byte tag prefixes for their extra args encoding (see existing tags in `ccip-sdk/src/extra-args.ts`). **When adding a new chain with custom extra args:** + 1. Generate a tag: `id('CCIP YourChainExtraArgsV1').substring(0, 10)` (using ethers `id`) 2. Add the constant to `ccip-sdk/src/extra-args.ts` 3. Define your `YourChainExtraArgsV1` type in `extra-args.ts` @@ -375,6 +386,7 @@ Each chain family has 4-byte tag prefixes for their extra args encoding (see exi Before submitting your PR: **Core Implementation:** + - [ ] `ChainFamily` constant added to `types.ts` - [ ] Chain class extends `Chain` - [ ] Static registration block added (`static { supportedChains[...] = ... }`) @@ -383,17 +395,21 @@ Before submitting your PR: - [ ] Key methods memoized (see Engineering Patterns) **Types and Exports:** + - [ ] `UnsignedTx` type mapping added to `chain.ts` - [ ] Chain class exported from `index.ts` **Hasher:** + - [ ] `getDestLeafHasher` static method implemented - [ ] Hasher tests pass with real transaction data **CLI:** + - [ ] Wallet provider implemented in `ccip-cli/src/providers/` **Quality:** + - [ ] All quality gates pass (`npm run check && npm test`) - [ ] CHANGELOG.md updated