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
6 changes: 5 additions & 1 deletion ccip-cli/src/commands/manual-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,11 @@ async function manualExec(
let inputs
if (source) {
offRamp ??= await discoverOffRamp(source, dest, request.lane.onRamp, source)
const verifications = await dest.getVerifications({ ...argv, offRamp, request })
const verifications = await dest.getVerifications({
...argv,
offRamp,
request,
})

if (argv.estimateGasLimit != null) {
const estimated = await estimateReceiveExecution({
Expand Down
6 changes: 6 additions & 0 deletions ccip-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ const globalOpts = {
describe:
'Path to Canton config JSON file (party, ccipParty, jwt, edsUrl, transferInstructionUrl, etc.)',
},
indexer: {
type: 'array',
string: true,
describe:
'Additional CCIP v2 indexer base URLs to query for CCV verifications (e.g. https://indexer-1.ccip.chain.link)',
},
} as const

/** Type for global CLI options. */
Expand Down
49 changes: 49 additions & 0 deletions ccip-sdk/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CCIPLaneNotFoundError,
CCIPMessageIdNotFoundError,
CCIPMessageNotFoundInTxError,
CCIPMessageNotVerifiedYetError,
CCIPUnexpectedPaginationError,
} from '../errors/index.ts'
import { HttpStatus } from '../http-status.ts'
Expand All @@ -20,6 +21,7 @@ import {
type Lane,
type Logger,
type OffchainTokenData,
type VerifierResult,
type WithLogger,
CCIPVersion,
MessageStatus,
Expand Down Expand Up @@ -409,6 +411,53 @@ export class CCIPAPIClient {
return this._transformMessageResponse(raw)
}

/**
* Fetches CCV verification results for a CCIP v2.0 message from the API.
*
* Validates that all `requiredCCVs` addresses have a matching verification in the
* response. Throws {@link CCIPMessageNotVerifiedYetError} if the `verifiers` field
* is absent or any required CCV is missing.
*
* @param messageId - The CCIP message ID
* @param options - Optional request options (signal for cancellation)
* @returns CCIPVerifications with policy and verifier results
* @throws {@link CCIPMessageNotVerifiedYetError} if verifications are not yet available
*/
async getVerifications(
messageId: string,
options?: { signal?: AbortSignal },
): Promise<VerifierResult[]> {
const apiRes = await this.getMessageById(messageId, options)

if (!('verifiers' in apiRes.message)) {
throw new CCIPMessageNotVerifiedYetError(messageId)
}

const verifiers = apiRes.message.verifiers as {
items?: {
destAddress: string
sourceAddress: string
isRequired: boolean
verification?: { data: string; timestamp: string }
}[]
}
if (!verifiers.items?.every((f) => f.verification?.data || !f.isRequired))
throw new CCIPMessageNotVerifiedYetError(messageId)

const verifications: VerifierResult[] = (verifiers.items ?? [])
.filter((item) => item.verification?.data)
.map((item) => ({
destAddress: item.destAddress,
sourceAddress: item.sourceAddress,
ccvData: item.verification!.data,
...(item.verification?.timestamp && {
timestamp: new Date(item.verification.timestamp).getTime() / 1e3,
}),
}))

return verifications
}

/**
* Searches CCIP messages using filters with cursor-based pagination.
*
Expand Down
4 changes: 2 additions & 2 deletions ccip-sdk/src/canton/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import {
type TokenPoolRemote,
Chain,
} from '../chain.ts'
import { MAINNET_INDEXER_URLS } from '../commits.ts'
import {
CCIPChainNotFoundError,
CCIPError,
CCIPErrorCode,
CCIPNotImplementedError,
CCIPWalletInvalidError,
} from '../errors/index.ts'
import { CCV_INDEXER_URL } from '../evm/const.ts'
import type { ExtraArgs } from '../extra-args.ts'
import type { LeafHasher } from '../hasher/common.ts'
import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts'
Expand Down Expand Up @@ -182,7 +182,7 @@ export class CantonChain extends Chain<typeof ChainFamily.Canton> {
transferInstructionClient: TransferInstructionClient,
tokenMetadataClient: TokenMetadataClient,
ccipParty: string,
indexerUrl = CCV_INDEXER_URL,
indexerUrl = MAINNET_INDEXER_URLS[0]!,
ctx?: ChainContext,
): Promise<CantonChain> {
const synchronizers = await client.getConnectedSynchronizers()
Expand Down
4 changes: 3 additions & 1 deletion ccip-sdk/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type {
import type { EstimateMessageInput } from './gas.ts'
import type { LeafHasher } from './hasher/common.ts'
import { decodeMessageV1 } from './messages.ts'
import { type ChainFamily, type NetworkInfo, networkInfo } from './networks.ts'
import { type ChainFamily, type NetworkInfo, type NetworkType, networkInfo } from './networks.ts'
import { getOffchainTokenData } from './offchain.ts'
import {
getMessagesInBatch,
Expand Down Expand Up @@ -1562,6 +1562,8 @@ export abstract class Chain<F extends ChainFamily = ChainFamily> {
CCIPRequest,
'lane' | `message.${'sequenceNumber' | 'messageId'}` | 'log.blockTimestamp'
>
/** Optional list of CCIP v2 indexer base URLs to query for CCV verifications, or a NetworkType to use default URLs */
indexer?: readonly string[] | NetworkType
} & Pick<LogFilter, 'page' | 'watch' | 'startBlock'>): Promise<CCIPVerifications> {
return getOnchainCommitReport(this, offRamp, request, hints)
}
Expand Down
122 changes: 120 additions & 2 deletions ccip-sdk/src/commits.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,126 @@
import type { PickDeep } from 'type-fest'

import type { CCIPAPIClient } from './api/index.ts'
import type { Chain, ChainStatic, LogFilter } from './chain.ts'
import { CCIPCommitNotFoundError } from './errors/index.ts'
import { type CCIPRequest, type CCIPVerifications, CCIPVersion } from './types.ts'
import {
CCIPCommitNotFoundError,
CCIPHttpError,
CCIPMessageNotVerifiedYetError,
} from './errors/index.ts'
import { NetworkType } from './networks.ts'
import {
type CCIPRequest,
type CCIPVerifications,
type VerifierResult,
CCIPVersion,
} from './types.ts'
import { signalToPromise } from './utils.ts'

/** Default CCIP v2 indexer base URLs for mainnet. */
export const MAINNET_INDEXER_URLS: readonly string[] = [
'https://indexer-1.ccip.chain.link',
'https://indexer-2.ccip.chain.link',
]

/** Default CCIP v2 indexer base URLs for testnet. */
export const TESTNET_INDEXER_URLS: readonly string[] = [
'https://indexer-1.testnet.ccip.chain.link',
'https://indexer-2.testnet.ccip.chain.link',
]

/** Shape of the indexer `/v1/verifierresults/:messageId` JSON response. */
type IndexerResponse = {
success: boolean
results: Array<{
verifierResult: {
message_id: string
message_ccv_addresses: string[]
ccv_data: string
timestamp: string
verifier_source_address: string
verifier_dest_address: string
}
}>
messageID: string
}

/** Options for {@link fetchVerifications}. */
export type FetchVerificationsOpts = {
/** Indexer base URLs, or a {@link NetworkType} to use the built-in defaults. */
indexer?: readonly string[] | NetworkType
/** CCIP API client to race against the indexers; omit or pass `null` to skip. */
apiClient?: CCIPAPIClient | null
/** AbortSignal that cancels in-flight requests and terminates the poll loop. */
watch?: AbortSignal
/** Milliseconds between poll retries when `watch` is set (default: 5000). */
pollInterval?: number
}

/**
* Fetch CCV verifications for a CCIP v2.0 message.
*
* Races the optional API client against all provided indexer URLs via
* {@link Promise.any}, returning the first successful response.
*
* When `opts.watch` is supplied the function retries on
* {@link CCIPMessageNotVerifiedYetError} at `opts.pollInterval` ms intervals
* until the signal fires.
*
* @param messageId - The CCIP message ID (hex string)
* @param opts - See {@link FetchVerificationsOpts}
* @returns CCIPVerifications with verificationPolicy and verifier results
* @throws {@link CCIPMessageNotVerifiedYetError} if all sources fail or signal fires
*/
export async function fetchVerifications(
messageId: string,
{
indexer = [...MAINNET_INDEXER_URLS, ...TESTNET_INDEXER_URLS],
apiClient,
watch,
pollInterval = 5_000,
}: FetchVerificationsOpts = {},
): Promise<VerifierResult[]> {
if (indexer === NetworkType.Mainnet) indexer = MAINNET_INDEXER_URLS
else if (indexer === NetworkType.Testnet) indexer = TESTNET_INDEXER_URLS

// Polling loop: retry on CCIPMessageNotVerifiedYetError until watch fires
let lastErr
do {
try {
return await Promise.any([
...(apiClient != null ? [apiClient.getVerifications(messageId, { signal: watch })] : []),
...indexer.map(async (baseUrl) => {
const url = `${baseUrl.replace(/\/+$/, '')}/v1/verifierresults/${messageId}`

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
Comment thread
andrevmatos marked this conversation as resolved.
Dismissed
const res = await fetch(url, { signal: watch })
if (!res.ok) throw new CCIPHttpError(res.status, res.statusText, { context: { url } })
const json = (await res.json()) as IndexerResponse
if (!json.success) throw new CCIPMessageNotVerifiedYetError(messageId)
const verifications: VerifierResult[] = json.results.map(({ verifierResult: vr }) => ({
ccvData: vr.ccv_data,
sourceAddress: vr.verifier_source_address,
destAddress: vr.verifier_dest_address,
timestamp: vr.timestamp
? Math.floor(new Date(vr.timestamp).getTime() / 1000)
: undefined,
}))
return verifications
}),
]).catch((err: AggregateError) => {
if (watch?.aborted) throw err.errors[0] ?? err
throw new CCIPMessageNotVerifiedYetError(messageId, { cause: err })
})
} catch (err) {
lastErr = err
if (!(err instanceof CCIPMessageNotVerifiedYetError)) throw err
await signalToPromise(
watch
? AbortSignal.any([watch, AbortSignal.timeout(pollInterval)])
: AbortSignal.timeout(pollInterval),
).catch(() => {})
}
} while (!watch?.aborted)
throw lastErr
}

/**
* Look for a CommitReport at dest for given CCIPRequest
Expand Down
2 changes: 0 additions & 2 deletions ccip-sdk/src/evm/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,3 @@ export const commitsFragments = getAllFragmentsMatchingEvents([
'CommitReportAccepted',
])
export const receiptsFragments = getAllFragmentsMatchingEvents(['ExecutionStateChanged'])

export const CCV_INDEXER_URL = 'https://chainlink-ccv-indexer.ccip.stage.external.griddle.sh/all'
42 changes: 11 additions & 31 deletions ccip-sdk/src/evm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
type TotalFeesEstimate,
Chain,
} from '../chain.ts'
import { fetchVerifications } from '../commits.ts'
import {
CCIPAddressInvalidError,
CCIPBlockNotFoundError,
Expand Down Expand Up @@ -123,7 +124,6 @@ import type USDCTokenPoolProxy_2_0_ABI from './abi/USDCTokenPoolProxy_2_0.ts'
import type VersionedVerifierResolver_2_0_ABI from './abi/VersionedVerifierResolver_2_0.ts'
import {
type TokenPoolAndProxyABI,
CCV_INDEXER_URL,
VersionedContractABI,
commitsFragments,
interfaces,
Expand Down Expand Up @@ -2147,36 +2147,16 @@ export class EVMChain extends Chain<typeof ChainFamily.EVM> {
optionalThreshold: Number(optionalThreshold),
}

if (this.apiClient) {
const apiRes = await this.apiClient.getMessageById(request.message.messageId)
if ('verifiers' in apiRes.message) {
const verifiers = apiRes.message.verifiers as {
items?: {
destAddress: string
sourceAddress: string
verification?: { data: string; timestamp: string }
}[]
}
return {
verificationPolicy,
verifications: (verifiers.items ?? [])
.filter((item) => item.verification?.data)
.map((item) => ({
destAddress: item.destAddress,
sourceAddress: item.sourceAddress,
ccvData: item.verification!.data,
...(!!item.verification?.timestamp && {
timestamp: new Date(item.verification.timestamp).getTime() / 1e3,
}),
})),
}
}
}

const url = `${CCV_INDEXER_URL}/v1/verifierresults/${request.message.messageId}`
const res = await fetch(url)
const json = await res.json()
return json as CCIPVerifications
// race API client + indexer URLs
const verifications = await fetchVerifications(request.message.messageId, {
apiClient: this.apiClient,
indexer: opts.indexer ?? this.network.networkType,
watch:
opts.watch instanceof AbortSignal
? AbortSignal.any([opts.watch, this.abort])
: this.abort,
})
return { verificationPolicy, verifications }
} else if (request.lane.version < CCIPVersion.V1_6) {
// v1.2..v1.5 EVM (only) have separate CommitStore
const { commitStore } = (await this.getOffRampConfig(
Expand Down
1 change: 1 addition & 0 deletions ccip-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type {
} from './chain.ts'
export { DEFAULT_API_RETRY_CONFIG, LaneFeature } from './chain.ts'
export { calculateManualExecProof, discoverOffRamp } from './execution.ts'
export { type FetchVerificationsOpts, fetchVerifications } from './commits.ts'
export {
type EVMExtraArgsV1,
type EVMExtraArgsV2,
Expand Down
Loading