Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CCIPRequest>`

## [1.4.0] - 2026-03-26

Expand Down
2 changes: 1 addition & 1 deletion ccip-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion ccip-sdk/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
39 changes: 38 additions & 1 deletion ccip-sdk/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -715,6 +715,43 @@ export abstract class Chain<F extends ChainFamily = ChainFamily> {
}
}

/**
* 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<Chain['getLogs']>[0],
): AsyncIterableIterator<CCIPRequest> {
yield* getMessagesInRange(this, opts)
}

/**
* Fetch a CCIP message by its unique message ID.
*
Expand Down
2 changes: 1 addition & 1 deletion ccip-sdk/src/evm/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (_) {
Expand Down
2 changes: 1 addition & 1 deletion ccip-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
225 changes: 224 additions & 1 deletion ccip-sdk/src/requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>
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 =
Expand Down
Loading
Loading