diff --git a/CHANGELOG.md b/CHANGELOG.md index 314a609d..c571e13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - SDK/CLI: Add `sourceTokenAddress` search filter to `searchMessages` and `--source-token` CLI option for filtering messages by source token +- SDK: Add `getMessagesInRange()` to the abstract Chain class — range-based CCIP message discovery using `getLogs` + `decodeMessage`, returns `AsyncIterableIterator` ## [1.4.0] - 2026-03-26 diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 514203db..e612a743 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -11,7 +11,7 @@ import { Format } from './commands/index.ts' util.inspect.defaultOptions.depth = 6 // print down to tokenAmounts in requests // generate:nofail // `const VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -const VERSION = '1.4.2-7f8e132' +const VERSION = '1.4.2-f874d03' // generate:end const globalOpts = { diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index 17957bc9..92f1ee9d 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -60,7 +60,7 @@ export const DEFAULT_TIMEOUT_MS = 30000 /** SDK version string for telemetry header */ // generate:nofail // `export const SDK_VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -export const SDK_VERSION = '1.4.2-7f8e132' +export const SDK_VERSION = '1.4.2-f874d03' // generate:end /** SDK telemetry header name */ diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index a709679f..0e51bc52 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -26,7 +26,7 @@ import type { import type { LeafHasher } from './hasher/common.ts' import { decodeMessageV1 } from './messages.ts' import { getOffchainTokenData } from './offchain.ts' -import { getMessagesInTx } from './requests.ts' +import { getMessagesInRange, getMessagesInTx } from './requests.ts' import { DEFAULT_GAS_LIMIT } from './shared/constants.ts' import type { UnsignedSolanaTx } from './solana/types.ts' import type { UnsignedSuiTx } from './sui/types.ts' @@ -715,6 +715,43 @@ export abstract class Chain { } } + /** + * Discover and decode CCIP messages within a block/slot/checkpoint range. + * + * @remarks + * This is the range-scanning equivalent of {@link getMessagesInTx}. Results are + * yielded in native log order (block number + log index for EVM, slot order for Solana). + * Non-CCIP logs in the range are silently skipped. + * + * On Solana, the `address` parameter is required (router program address). + * On EVM, it is optional but recommended for public RPCs that require address filtering. + * + * @param opts - {@link LogFilter} options (startBlock, endBlock, address, topics, page) + * @returns Async iterator of {@link CCIPRequest} objects in native log order + * @throws {@link CCIPChainFamilyUnsupportedError} if a pre-v1.6 message is found on a non-EVM chain + * @throws {@link CCIPLogsAddressRequiredError} on Solana if `address` is not provided (thrown by underlying {@link Chain.getLogs}) + * + * @see {@link getMessagesInBatch} - Batch discovery by sequence number range + * + * @example Scan a block range on EVM + * ```typescript + * for await (const request of chain.getMessagesInRange({ + * startBlock: 1000000, + * endBlock: 1001000, + * address: '0xOnRampAddress...', // optional on EVM + * })) { + * console.log(`seqNr=${request.message.sequenceNumber} dest=${request.lane.destChainSelector}`) + * } + * ``` + * + * @see {@link getMessagesInTx} - Per-transaction message discovery + */ + async *getMessagesInRange( + opts: Parameters[0], + ): AsyncIterableIterator { + yield* getMessagesInRange(this, opts) + } + /** * Fetch a CCIP message by its unique message ID. * diff --git a/ccip-sdk/src/evm/errors.ts b/ccip-sdk/src/evm/errors.ts index 4875d089..08a177f2 100644 --- a/ccip-sdk/src/evm/errors.ts +++ b/ccip-sdk/src/evm/errors.ts @@ -140,7 +140,7 @@ export function recursiveParseError( try { const obj = data.toObject() const keys = Object.keys(obj) - // eslint-disable-next-line no-restricted-syntax + if (keys.length > 0 && keys.every((k) => k.startsWith('_'))) throw new Error('not an obj') kv = Object.entries(obj).map(([k, v]) => [j(key, k), v]) } catch (_) { diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index d47942f0..78bec402 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -57,7 +57,7 @@ export { } from './extra-args.ts' export { estimateReceiveExecution } from './gas.ts' export { CCTP_FINALITY_FAST, CCTP_FINALITY_STANDARD, getOffchainTokenData } from './offchain.ts' -export { decodeMessage, sourceToDestTokenAddresses } from './requests.ts' +export { decodeMessage, getMessagesInRange, sourceToDestTokenAddresses } from './requests.ts' export { type CCIPExecution, type CCIPMessage, diff --git a/ccip-sdk/src/requests.test.ts b/ccip-sdk/src/requests.test.ts index eb13521e..9365bcfa 100644 --- a/ccip-sdk/src/requests.test.ts +++ b/ccip-sdk/src/requests.test.ts @@ -8,7 +8,13 @@ import type { Chain, LogFilter } from './chain.ts' import { CCIPAddressInvalidError } from './errors/specialized.ts' import type { GenericExtraArgsV3, SVMExtraArgsV1 } from './extra-args.ts' import { EVMChain } from './index.ts' -import { decodeMessage, getMessageById, getMessagesInBatch, getMessagesInTx } from './requests.ts' +import { + decodeMessage, + getMessageById, + getMessagesInBatch, + getMessagesInRange, + getMessagesInTx, +} from './requests.ts' import { SolanaChain } from './solana/index.ts' import { SuiChain } from './sui/index.ts' import { @@ -415,6 +421,223 @@ describe('getMessagesInBatch', () => { }) }) +describe('getMessagesInRange', () => { + it('should yield CCIP requests from logs', async () => { + // Use a fresh local mock chain to avoid state issues from other test suites + const localChain = new MockChain() + localChain.getLogs.mock.mockImplementation((_opts: LogFilter) => + (async function* () { + yield { + address: rampAddress, + index: 1, + topics: [topic0], + data: mockedMessage(1), + blockNumber: 12000, + transactionHash: '0x111', + tx: { + hash: '0x111', + logs: [], + blockNumber: 12000, + timestamp: 1234567890, + from: '0x0000000000000000000000000000000000000001', + }, + } as ChainLog + yield { + address: rampAddress, + index: 2, + topics: [topic0], + data: { notAMessage: true }, // non-CCIP log + blockNumber: 12000, + transactionHash: '0x222', + } as unknown as ChainLog + yield { + address: rampAddress, + index: 3, + topics: [topic0], + data: mockedMessage(2), + blockNumber: 12001, + transactionHash: '0x333', + tx: { + hash: '0x333', + logs: [], + blockNumber: 12001, + timestamp: 1234567891, + from: '0x0000000000000000000000000000000000000002', + }, + } as ChainLog + })(), + ) + + const results: CCIPRequest[] = [] + for await (const request of getMessagesInRange(localChain as unknown as Chain, { + startBlock: 12000, + endBlock: 12001, + address: rampAddress, + })) { + results.push(request) + } + + // Should yield 2 messages (non-CCIP log skipped) + assert.equal(results.length, 2) + assert.equal(results[0]!.message.sequenceNumber, 1n) + assert.equal(results[1]!.message.sequenceNumber, 2n) + // Lane should be constructed + assert.equal(results[0]!.lane.onRamp, rampAddress) + assert.equal(results[0]!.lane.version, CCIPVersion.V1_2) + // getLaneForOnRamp should be called (no destChainSelector in mocked messages) + assert.equal(localChain.getLaneForOnRamp.mock.calls.length, 2) + }) + + it('should resolve lane via typeAndVersion for v1.6+ messages with destChainSelector', async () => { + const localChain = new MockChain() + localChain.typeAndVersion.mock.mockImplementation(() => + Promise.resolve(['OnRamp', CCIPVersion.V1_6, `OnRamp ${CCIPVersion.V1_6}`]), + ) + // Override decodeMessage to return a message with destChainSelector (v1.6+) + MockChain.decodeMessage.mock.mockImplementation( + (log: { topics: readonly string[]; data: unknown }): CCIPMessage | undefined => { + if (typeof log.data === 'object' && log.data && 'messageId' in log.data) { + const d = log.data as Record + return { + messageId: d.messageId, + sourceChainSelector: + d.sourceChainSelector != null + ? (d.sourceChainSelector as bigint) + : 16015286601757825753n, + destChainSelector: d.destChainSelector as bigint, // v1.6+ field + sequenceNumber: d.sequenceNumber != null ? (d.sequenceNumber as bigint) : 1n, + nonce: 0n, + sender: '0x0000000000000000000000000000000000000045', + receiver: toBeHex(456, 32), + data: '0x', + tokenAmounts: [], + sourceTokenData: [], + gasLimit: 100n, + strict: false, + feeToken: '0x0000000000000000000000000000000000008916', + feeTokenAmount: 0n, + } as CCIPMessage + } + return undefined + }, + ) + const v16Message = { + ...mockedMessage(1), + destChainSelector: 4949039107694359620n, // v1.6+ includes destChainSelector + } + localChain.getLogs.mock.mockImplementation((_opts: LogFilter) => + (async function* () { + yield { + address: rampAddress, + index: 1, + topics: [topic0], + data: v16Message, + blockNumber: 15000, + transactionHash: '0xV16Tx', + tx: { + hash: '0xV16Tx', + logs: [], + blockNumber: 15000, + timestamp: 1234567890, + from: '0x0000000000000000000000000000000000000001', + }, + } as ChainLog + })(), + ) + + const results: CCIPRequest[] = [] + for await (const request of getMessagesInRange(localChain as unknown as Chain, { + startBlock: 15000, + endBlock: 15000, + })) { + results.push(request) + } + + assert.equal(results.length, 1) + // Lane should use typeAndVersion path (not getLaneForOnRamp) + assert.equal(localChain.typeAndVersion.mock.calls.length, 1) + assert.equal(localChain.getLaneForOnRamp.mock.calls.length, 0) + // Lane should have destChainSelector from the message + assert.equal(results[0]!.lane.destChainSelector, 4949039107694359620n) + assert.equal(results[0]!.lane.version, CCIPVersion.V1_6) + assert.equal(results[0]!.lane.onRamp, rampAddress) + }) + + it('should yield nothing for range with no CCIP messages', async () => { + mockedChain.getLogs.mock.mockImplementation((_opts: LogFilter) => + (async function* () { + // empty + })(), + ) + + const results: CCIPRequest[] = [] + for await (const request of getMessagesInRange(mockedChain as unknown as Chain, { + startBlock: 12000, + endBlock: 12001, + })) { + results.push(request) + } + + assert.equal(results.length, 0) + }) + + it('should pass default topics and address to getLogs', async () => { + mockedChain.getLogs.mock.mockImplementation((_opts: LogFilter) => + (async function* () { + // empty + })(), + ) + + for await (const _ of getMessagesInRange(mockedChain as unknown as Chain, { + startBlock: 100, + endBlock: 200, + address: '0xMyOnRamp', + })) { + // consume + } + + assert.equal(mockedChain.getLogs.mock.calls.length, 1) + const callArgs = (mockedChain.getLogs.mock.calls[0] as unknown as { arguments: [LogFilter] }) + .arguments[0] + assert.equal(callArgs.startBlock, 100) + assert.equal(callArgs.endBlock, 200) + assert.equal(callArgs.address, '0xMyOnRamp') + assert.deepEqual(callArgs.topics, ['CCIPSendRequested', 'CCIPMessageSent']) + }) + + it('should fetch transaction when log.tx is not available', async () => { + const localChain = new MockChain() + localChain.getLogs.mock.mockImplementation((_opts: LogFilter) => + (async function* () { + yield { + address: rampAddress, + index: 1, + topics: [topic0], + data: mockedMessage(1), + blockNumber: 12000, + transactionHash: '0xNoTxLog', + // no tx field + } as ChainLog + })(), + ) + + const results: CCIPRequest[] = [] + for await (const request of getMessagesInRange(localChain as unknown as Chain, { + startBlock: 12000, + endBlock: 12000, + })) { + results.push(request) + } + + assert.equal(results.length, 1) + // getTransaction should have been called as fallback + assert.equal(localChain.getTransaction.mock.calls.length, 1) + const txHash = (localChain.getTransaction.mock.calls[0] as unknown as { arguments: [string] }) + .arguments[0] + assert.equal(txHash, '0xNoTxLog') + }) +}) + describe('decodeMessage', () => { it('should decode 1.5 message with tokenAmounts', () => { const msgInfoString = diff --git a/ccip-sdk/src/requests.ts b/ccip-sdk/src/requests.ts index 3251fe5a..e0507c4b 100644 --- a/ccip-sdk/src/requests.ts +++ b/ccip-sdk/src/requests.ts @@ -1,7 +1,7 @@ import { type BytesLike, hexlify, isBytesLike, toBigInt } from 'ethers' import type { PickDeep } from 'type-fest' -import type { Chain, ChainStatic } from './chain.ts' +import type { Chain, ChainStatic, LogFilter } from './chain.ts' import { CCIPChainFamilyUnsupportedError, CCIPMessageBatchIncompleteError, @@ -21,6 +21,7 @@ import { type CCIPVersion, type ChainLog, type ChainTransaction, + type Lane, type MessageInput, ChainFamily, } from './types.ts' @@ -200,6 +201,30 @@ export function buildMessageForDest(message: MessageInput, dest: ChainFamily): A return supportedChains[dest]!.buildMessageForDest(message) } +/** + * Resolve the lane for a decoded CCIP message. + * + * Shared helper used by {@link getMessagesInTx}, {@link getMessageById}, and + * {@link getMessagesInRange} to build the {@link Lane} from a decoded message and log. + * + * @internal + */ +async function resolveLane(source: Chain, message: CCIPMessage, log: ChainLog): Promise { + if ('destChainSelector' in message) { + const [_, version] = await source.typeAndVersion(log.address) + return { + sourceChainSelector: message.sourceChainSelector, + destChainSelector: message.destChainSelector, + onRamp: log.address, + version: version as CCIPVersion, + } + } else if (source.network.family !== ChainFamily.EVM) { + throw new CCIPChainFamilyUnsupportedError(source.network.family) + } else { + return await (source as EVMChain).getLaneForOnRamp(log.address) + } +} + /** * Fetch all CCIP messages in a transaction. * @param source - Source chain instance @@ -211,25 +236,11 @@ export function buildMessageForDest(message: MessageInput, dest: ChainFamily): A * @see {@link getMessageById} - Search by messageId when tx hash unknown */ export async function getMessagesInTx(source: Chain, tx: ChainTransaction): Promise { - // RPC fallback const requests: CCIPRequest[] = [] for (const log of tx.logs) { - let lane const message = (source.constructor as ChainStatic).decodeMessage(log) if (!message) continue - if ('destChainSelector' in message) { - const [_, version] = await source.typeAndVersion(log.address) - lane = { - sourceChainSelector: message.sourceChainSelector, - destChainSelector: message.destChainSelector, - onRamp: log.address, - version: version as CCIPVersion, - } - } else if (source.network.family !== ChainFamily.EVM) { - throw new CCIPChainFamilyUnsupportedError(source.network.family) - } else { - lane = await (source as EVMChain).getLaneForOnRamp(log.address) - } + const lane = await resolveLane(source, message, log) requests.push({ lane, message, log, tx }) } if (!requests.length) @@ -276,25 +287,9 @@ export async function getMessageById( })) { const message = (source.constructor as ChainStatic).decodeMessage(log) if (message?.messageId !== messageId) continue - let destChainSelector, version - if ('destChainSelector' in message) { - destChainSelector = message.destChainSelector - ;[, version] = await source.typeAndVersion(log.address) - } else { - ;({ destChainSelector, version } = await (source as EVMChain).getLaneForOnRamp(log.address)) - } + const lane = await resolveLane(source, message, log) const tx = log.tx ?? (await source.getTransaction(log.transactionHash)) - return { - lane: { - sourceChainSelector: message.sourceChainSelector, - destChainSelector, - onRamp: log.address, - version: version as CCIPVersion, - }, - message, - log, - tx, - } + return { lane, message, log, tx } } throw new CCIPMessageIdNotFoundError(messageId) } @@ -393,6 +388,72 @@ export async function getMessagesInBatch< return messages } +/** + * Discover and decode CCIP messages within a block/slot/checkpoint range. + * + * This is the range-scanning equivalent of {@link getMessagesInTx}. It composes + * {@link Chain.getLogs} and {@link ChainStatic.decodeMessage} to yield CCIP requests + * in discovery order without requiring transaction hashes upfront. + * + * Results are yielded in native log order: (blockNumber, logIndex) ascending for EVM, + * slot order for Solana. Non-CCIP logs in the range are silently skipped. + * + * @param source - Source chain to scan logs from + * @param opts - {@link LogFilter} options. Key fields: + * - `startBlock` / `endBlock` — block/slot range (endBlock supports `'finalized'` and `'latest'`) + * - `address` — onRamp/router address (optional on EVM, required on Solana) + * - `topics` — defaults to both CCIP message event names + * - `page` — batch size for log pagination + * @returns Async iterator of {@link CCIPRequest} objects in native log order + * + * @throws {@link CCIPChainFamilyUnsupportedError} if a pre-v1.6 message is found on a non-EVM chain + * @throws {@link CCIPLogsAddressRequiredError} on Solana if `address` is not provided + * + * @example EVM — scan a block range for all CCIP messages + * + * ```typescript + * const chain = await EVMChain.fromUrl('https://rpc.sepolia.org') + * for await (const request of getMessagesInRange(chain, { + * startBlock: 1000000, + * endBlock: 1001000, + * address: '0xOnRampAddress...', // optional on EVM, but recommended for public RPCs + * })) { + * console.log(`seqNr=${request.message.sequenceNumber} dest=${request.lane.destChainSelector}`) + * } + * ``` + * + * @example Solana — scan a slot range (address required) + * + * ```typescript + * const chain = await SolanaChain.fromUrl('https://api.devnet.solana.com') + * for await (const request of getMessagesInRange(chain, { + * startBlock: 450000000, + * endBlock: 450100000, + * address: 'Ccip842gzYHh...', // router program address (required on Solana) + * })) { + * console.log(`seqNr=${request.message.sequenceNumber}`) + * } + * ``` + * + * @see {@link getMessagesInTx} - Per-transaction message discovery + * @see {@link getMessagesInBatch} - Batch discovery by sequence number range + */ +export async function* getMessagesInRange( + source: Chain, + opts: LogFilter, +): AsyncIterableIterator { + for await (const log of source.getLogs({ + ...opts, + topics: opts.topics ?? ['CCIPSendRequested', 'CCIPMessageSent'], + })) { + const message = (source.constructor as ChainStatic).decodeMessage(log) + if (!message) continue + const lane = await resolveLane(source, message, log) + const tx = log.tx ?? (await source.getTransaction(log.transactionHash)) + yield { lane, message, log, tx } + } +} + /** * Map source token to its pool address and destination token address. * diff --git a/ccip-sdk/src/selectors.ts b/ccip-sdk/src/selectors.ts index bda3f611..39e4926c 100644 --- a/ccip-sdk/src/selectors.ts +++ b/ccip-sdk/src/selectors.ts @@ -1301,6 +1301,18 @@ const selectors: Selectors = { network_type: 'TESTNET', family: 'EVM', }, + '102030': { + selector: 18240105181246962294n, + name: 'creditcoin-mainnet', + network_type: 'MAINNET', + family: 'EVM', + }, + '102031': { + selector: 16960985330067274105n, + name: 'creditcoin-testnet', + network_type: 'TESTNET', + family: 'EVM', + }, '128123': { selector: 1910019406958449359n, name: 'etherlink-testnet',