From df8048f36c425ebd382f157f836c71bd0d832cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 25 May 2026 11:19:53 -0400 Subject: [PATCH 01/12] solana: improve errors parsing --- ccip-sdk/src/solana/cleanup.ts | 4 ++-- ccip-sdk/src/solana/index.ts | 19 +++++++++++-------- ccip-sdk/src/solana/utils.ts | 21 ++++++++++----------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/ccip-sdk/src/solana/cleanup.ts b/ccip-sdk/src/solana/cleanup.ts index 8d7dc104..110db7d6 100644 --- a/ccip-sdk/src/solana/cleanup.ts +++ b/ccip-sdk/src/solana/cleanup.ts @@ -114,7 +114,7 @@ export async function cleanUpBuffers( const tx = log.tx switch (log.data) { case 'Instruction: BufferExecutionReport': { - const bufferIds = tx.tx.transaction.message.compiledInstructions + const bufferIds = tx!.tx.transaction.message.compiledInstructions .filter( // method discriminant plus 4B first param bytearray length of 32B=0x20 (bufferId) ({ data }) => dataSlice(data, 0, 8 + 4) === '0x23cafcdc0252bd1720000000', @@ -182,7 +182,7 @@ export async function cleanUpBuffers( } case 'Instruction: DeactivateLookupTable': case 'Instruction: CreateLookupTable': { - const lookupTable = tx.tx.transaction.message.staticAccountKeys[1]! + const lookupTable = tx!.tx.transaction.message.staticAccountKeys[1]! if (seenAccs.has(lookupTable.toBase58())) continue seenAccs.add(lookupTable.toBase58()) diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index 5bff0379..84c7abaa 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -165,7 +165,12 @@ const unknownTokens: { [mint: string]: string } = { } /** Solana-specific log structure with transaction reference and log level. */ -export type SolanaLog = ChainLog & { tx: SolanaTransaction; data: string; level: number } +export type SolanaLog = ChainLog & { + tx?: SolanaTransaction + data: string + level: number + type: 'log' | 'data' +} /** Solana-specific transaction structure with versioned transaction response. */ export type SolanaTransaction = MergeArrayElements< ChainTransaction, @@ -474,9 +479,7 @@ export class SolanaChain extends Chain { * @throws {@link CCIPLogsAddressRequiredError} if address is not provided * @throws {@link CCIPTopicsInvalidError} if topics contain invalid values */ - async *getLogs( - opts: LogFilter & { programs?: string[] | true }, - ): AsyncGenerator { + async *getLogs(opts: LogFilter & { programs?: string[] | true }): AsyncGenerator { let programs: true | string[] if (!opts.address) { throw new CCIPLogsAddressRequiredError() @@ -1072,7 +1075,7 @@ export class SolanaChain extends Chain { if (laterReceiptLog) { return // ignore intermediary state (InProgress=1) if we can find a later receipt } else if (state !== ExecutionState.Success) { - returnData = getErrorFromLogs(log.tx.logs) + returnData = getErrorFromLogs(log.tx.logs as SolanaLog[]) } else if (log.tx.error) { returnData = util.inspect(log.tx.error) state = ExecutionState.Failed @@ -1379,13 +1382,13 @@ export class SolanaChain extends Chain { if (Array.isArray(data)) { if (data.every((e) => typeof e === 'string')) return getErrorFromLogs(data) else if (data.every((e) => typeof e === 'object' && 'data' in e && 'address' in e)) - return getErrorFromLogs(data as ChainLog[]) + return getErrorFromLogs(data as SolanaLog[]) } else if (typeof data === 'object') { if ('transactionLogs' in data && 'transactionMessage' in data) { - const parsed = getErrorFromLogs(data.transactionLogs as ChainLog[] | string[]) + const parsed = getErrorFromLogs(data.transactionLogs as SolanaLog[] | string[]) if (parsed) return { message: data.transactionMessage, ...parsed } } - if ('logs' in data) return getErrorFromLogs(data.logs as ChainLog[] | string[]) + if ('logs' in data) return getErrorFromLogs(data.logs as SolanaLog[] | string[]) } else if (typeof data === 'string') { const parsedExtraArgs = this.decodeExtraArgs(getDataBytes(data)) if (parsedExtraArgs) return parsedExtraArgs diff --git a/ccip-sdk/src/solana/utils.ts b/ccip-sdk/src/solana/utils.ts index c1863c45..66bbb180 100644 --- a/ccip-sdk/src/solana/utils.ts +++ b/ccip-sdk/src/solana/utils.ts @@ -26,11 +26,12 @@ import { CCIPTokenMintNotFoundError, CCIPTransactionNotFinalizedError, } from '../errors/index.ts' -import type { ChainLog, WithLogger } from '../types.ts' -import { bigIntReplacer, getDataBytes, isBase64, sleep } from '../utils.ts' +import type { WithLogger } from '../types.ts' +import { bigIntReplacer, getDataBytes, sleep } from '../utils.ts' import type { IDL as BASE_TOKEN_POOL_IDL } from './idl/1.6.0/BASE_TOKEN_POOL.ts' import type { UnsignedSolanaTx, Wallet } from './types.ts' import type { RateLimiterState } from '../chain.ts' +import type { SolanaLog } from './index.ts' /** * Result of resolving an Associated Token Account for a given mint and owner. @@ -143,10 +144,7 @@ export function camelToSnakeCase(str: string): string { .replace(/^_/, '') } -type ParsedLog = Pick & { - data: string - level: number -} +type ParsedLog = Pick /** * Utility function to parse Solana logs with proper address and topic extraction. @@ -181,6 +179,7 @@ export function parseSolanaLogs(logs: readonly string[]): ParsedLog[] { // Pop from stack when program returns programStack.pop() } else if (matchLog) { + const type = matchLog[1]! as 'log' | 'data' // Extract the actual log data const logData = log.slice(matchLog[0].length) const currentProgram = programStack[programStack.length - 1]! @@ -204,6 +203,7 @@ export function parseSolanaLogs(logs: readonly string[]): ParsedLog[] { address: currentProgram, data: logData, level: programStack.length, + type, }) } } @@ -219,7 +219,7 @@ export function parseSolanaLogs(logs: readonly string[]): ParsedLog[] { export function getErrorFromLogs( logs_: | readonly string[] - | readonly Pick[] + | readonly Pick[] | null, ): { program: string; [k: string]: string } | undefined { if (!logs_?.length) return @@ -234,11 +234,10 @@ export function getErrorFromLogs( (acc, l) => // if acc is empty (i.e. on last log), or it is emitted by the same program and not a Program data: !acc.length || (l.address === acc[0]!.address && !l.topics.length) ? [l, ...acc] : acc, - [] as Pick[], + [] as typeof logs, ) - .filter(({ data }) => !isBase64(data)) - .map(({ data }) => data as string) - .reduceRight((acc, l) => { + .filter(({ type }) => type !== 'data') + .reduceRight((acc, { data: l }) => { l = l.replace(/ (with message|thrown in) /, ' $1: ') if (l.endsWith(':') && acc.length) l = `${l} ${acc.shift()!}` // cosmetic: join lines ending in ':' with next try { From df97e999ddb4b915e47a0ec82054785c3dbedd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 25 May 2026 20:35:09 -0400 Subject: [PATCH 02/12] error: deduplicate some classes --- ccip-sdk/src/aptos/hasher.ts | 9 +- ccip-sdk/src/aptos/index.ts | 27 +++--- ccip-sdk/src/errors/codes.ts | 2 + ccip-sdk/src/errors/index.ts | 53 ++++++++--- ccip-sdk/src/errors/specialized.ts | 147 +++++++++++++++++++++++++++-- ccip-sdk/src/solana/hasher.ts | 8 +- ccip-sdk/src/solana/index.ts | 5 +- ccip-sdk/src/sui/hasher.ts | 5 +- 8 files changed, 208 insertions(+), 48 deletions(-) diff --git a/ccip-sdk/src/aptos/hasher.ts b/ccip-sdk/src/aptos/hasher.ts index 0af4a829..9508fc89 100644 --- a/ccip-sdk/src/aptos/hasher.ts +++ b/ccip-sdk/src/aptos/hasher.ts @@ -1,12 +1,9 @@ import { concat, id, keccak256, zeroPadValue } from 'ethers' -import { - CCIPAptosHasherVersionUnsupportedError, - CCIPExtraArgsInvalidError, -} from '../errors/index.ts' +import { CCIPExtraArgsInvalidError, CCIPHasherVersionUnsupportedError } from '../errors/index.ts' import { decodeExtraArgs } from '../extra-args.ts' import { type LeafHasher, LEAF_DOMAIN_SEPARATOR } from '../hasher/common.ts' -import { networkInfo } from '../networks.ts' +import { ChainFamily, networkInfo } from '../networks.ts' import { encodeNumber, encodeRawBytes } from '../shared/bcs-codecs.ts' import { type CCIPMessage, type CCIPMessage_V1_6, CCIPVersion } from '../types.ts' import { getAddressBytes } from '../utils.ts' @@ -34,7 +31,7 @@ export function getAptosLeafHasher({ return ((message: CCIPMessage): string => hashV16AptosMessage(message, metadataHash)) as LeafHasher default: - throw new CCIPAptosHasherVersionUnsupportedError(version) + throw new CCIPHasherVersionUnsupportedError(ChainFamily.Aptos, version) } } diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index 0a11a136..766496f5 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -26,17 +26,17 @@ import { getAptosLeafHasher } from './hasher.ts' import { getUserTxByVersion, getVersionTimestamp, streamAptosLogs } from './logs.ts' import { generateUnsignedCcipSend, getFee } from './send.ts' import { - CCIPAptosExtraArgsEncodingError, CCIPAptosExtraArgsV2RequiredError, - CCIPAptosLogInvalidError, CCIPAptosNetworkUnknownError, CCIPAptosRegistryTypeInvalidError, - CCIPAptosTokenNotRegisteredError, CCIPAptosTransactionInvalidError, CCIPAptosTransactionTypeInvalidError, - CCIPAptosWalletInvalidError, CCIPError, + CCIPExtraArgsEncodingUnsupportedError, + CCIPLogDataInvalidError, + CCIPTokenNotRegisteredError, CCIPTokenPoolChainConfigNotFoundError, + CCIPWalletInvalidError, } from '../errors/index.ts' import { type EVMExtraArgsV2, @@ -427,7 +427,7 @@ export class AptosChain extends Chain { (typeof data !== 'string' || !data.startsWith('{')) && (typeof data !== 'object' || isBytesLike(data)) ) - throw new CCIPAptosLogInvalidError(util.inspect(log)) + throw new CCIPLogDataInvalidError(util.inspect(log), { chain: ChainFamily.Aptos }) // offload massaging to generic decodeJsonMessage try { return decodeMessage(data) @@ -469,7 +469,10 @@ export class AptosChain extends Chain { accounts: extraArgs.accounts.map(getAddressBytes), }).toBytes(), ]) - throw new CCIPAptosExtraArgsEncodingError() + throw new CCIPExtraArgsEncodingUnsupportedError( + ChainFamily.Aptos, + 'EVMExtraArgsV2 & SVMExtraArgsV1', + ) } /** @@ -480,7 +483,8 @@ export class AptosChain extends Chain { * @throws {@link CCIPAptosLogInvalidError} if log data format is invalid */ static decodeCommits({ data }: Pick, lane?: Lane): CommitReport[] | undefined { - if (!data || typeof data != 'object') throw new CCIPAptosLogInvalidError(data) + if (!data || typeof data != 'object') + throw new CCIPLogDataInvalidError(data, { chain: ChainFamily.Aptos }) const data_ = data as { blessed_merkle_roots: unknown[] | undefined unblessed_merkle_roots: unknown[] @@ -514,7 +518,8 @@ export class AptosChain extends Chain { * @throws {@link CCIPAptosLogInvalidError} if log data format is invalid */ static decodeReceipt({ data }: Pick): ExecutionReceipt | undefined { - if (!data || typeof data != 'object') throw new CCIPAptosLogInvalidError(data) + if (!data || typeof data != 'object') + throw new CCIPLogDataInvalidError(data, { chain: ChainFamily.Aptos }) const data_ = data as { message_id: string; state: number } if (!data_.message_id || !data_.state) return return convertKeysToCamelCase(data_, (v) => @@ -592,7 +597,7 @@ export class AptosChain extends Chain { async sendMessage(opts: Parameters[0]): Promise { const account = opts.wallet if (!isAptosAccount(account)) { - throw new CCIPAptosWalletInvalidError(this.constructor.name, util.inspect(opts.wallet)) + throw new CCIPWalletInvalidError(opts.wallet, { className: this.constructor.name }) } const unsignedTx = await this.generateUnsignedSendMessage({ @@ -654,7 +659,7 @@ export class AptosChain extends Chain { async execute(opts: Parameters[0]): Promise { const account = opts.wallet if (!isAptosAccount(account)) { - throw new CCIPAptosWalletInvalidError(this.constructor.name, util.inspect(opts.wallet)) + throw new CCIPWalletInvalidError(opts.wallet, { className: this.constructor.name }) } const unsignedTx = await this.generateUnsignedExecute({ @@ -734,7 +739,7 @@ export class AptosChain extends Chain { functionArguments: [token], }, }) - if (administrator.match(/^0x0*$/)) throw new CCIPAptosTokenNotRegisteredError(token, registry) + if (administrator.match(/^0x0*$/)) throw new CCIPTokenNotRegisteredError(token, registry) return { administrator, ...(!pendingAdministrator.match(/^0x0*$/) && { pendingAdministrator }), diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index fab5a0a6..5a50c22a 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -113,6 +113,7 @@ export const CCIPErrorCode = { // Log & Event LOG_DATA_INVALID: 'LOG_DATA_INVALID', + /** @deprecated Deprecated in v1.7 (2026-05-25). Use LOG_DATA_INVALID instead. */ LOG_APTOS_INVALID: 'LOG_APTOS_INVALID', LOG_DATA_MISSING: 'LOG_DATA_MISSING', LOGS_NOT_FOUND: 'LOGS_NOT_FOUND', @@ -139,6 +140,7 @@ export const CCIPErrorCode = { APTOS_TX_TYPE_INVALID: 'APTOS_TX_TYPE_INVALID', APTOS_TX_TYPE_UNEXPECTED: 'APTOS_TX_TYPE_UNEXPECTED', APTOS_ADDRESS_MODULE_REQUIRED: 'APTOS_ADDRESS_MODULE_REQUIRED', + /** @deprecated Deprecated in v1.7 (2026-05-25). Use HASHER_VERSION_UNSUPPORTED instead. */ APTOS_HASHER_VERSION_UNSUPPORTED: 'APTOS_HASHER_VERSION_UNSUPPORTED', APTOS_TOPIC_INVALID: 'APTOS_TOPIC_INVALID', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index 489798be..95aeea78 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -39,6 +39,7 @@ export { // Specialized errors - Lane & Routing export { CCIPLaneNotFoundError, + CCIPLaneVersionUnsupportedError, CCIPOffRampNotFoundError, CCIPOnRampRequiredError, } from './specialized.ts' @@ -66,7 +67,11 @@ export { } from './specialized.ts' // Specialized errors - ExtraArgs -export { CCIPExtraArgsInvalidError, CCIPExtraArgsParseError } from './specialized.ts' +export { + CCIPExtraArgsEncodingUnsupportedError, + CCIPExtraArgsInvalidError, + CCIPExtraArgsParseError, +} from './specialized.ts' // Specialized errors - Token & Registry export { @@ -75,6 +80,7 @@ export { CCIPTokenNotConfiguredError, CCIPTokenNotFoundError, CCIPTokenNotInRegistryError, + CCIPTokenNotRegisteredError, } from './specialized.ts' // Specialized errors - Contract Type @@ -108,9 +114,7 @@ export { CCIPExtraArgsLengthInvalidError, CCIPLogDataMissingError, CCIPSolanaComputeUnitsExceededError, - CCIPSolanaExtraArgsEncodingError, CCIPSolanaFeeResultInvalidError, - CCIPSolanaLaneVersionUnsupportedError, CCIPSolanaLookupTableNotFoundError, CCIPSolanaOffRampEventsNotFoundError, CCIPSolanaRefAddressesNotFoundError, @@ -131,26 +135,17 @@ export { // Specialized errors - Aptos-specific export { CCIPAptosAddressModuleRequiredError, - CCIPAptosExtraArgsEncodingError, CCIPAptosExtraArgsV2RequiredError, - CCIPAptosHasherVersionUnsupportedError, - CCIPAptosLogInvalidError, CCIPAptosNetworkUnknownError, CCIPAptosRegistryTypeInvalidError, - CCIPAptosTokenNotRegisteredError, CCIPAptosTopicInvalidError, CCIPAptosTransactionInvalidError, CCIPAptosTransactionTypeInvalidError, CCIPAptosTransactionTypeUnexpectedError, - CCIPAptosWalletInvalidError, } from './specialized.ts' // Specialized errors - Sui-specific -export { - CCIPSuiHasherVersionUnsupportedError, - CCIPSuiLogInvalidError, - CCIPSuiMessageVersionInvalidError, -} from './specialized.ts' +export { CCIPSuiMessageVersionInvalidError } from './specialized.ts' // Specialized errors - Borsh export { CCIPBorshMethodUnknownError, CCIPBorshTypeUnknownError } from './specialized.ts' @@ -191,6 +186,38 @@ export { CCIPInteractiveRequiredError, } from './specialized.ts' +// --------------------------------------------------------------------------- +// Deprecated in v1.7 (2026-05-25) — prefer the generic equivalents above. +// These re-exports will be removed in a future major version. +// --------------------------------------------------------------------------- + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPHasherVersionUnsupportedError} with chain='Sui'. */ +export { CCIPSuiHasherVersionUnsupportedError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPHasherVersionUnsupportedError} with chain='Aptos'. */ +export { CCIPAptosHasherVersionUnsupportedError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPLogDataInvalidError} with chain option. */ +export { CCIPSuiLogInvalidError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPLogDataInvalidError} with chain option. */ +export { CCIPAptosLogInvalidError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPWalletInvalidError} with className option. */ +export { CCIPAptosWalletInvalidError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPExtraArgsEncodingUnsupportedError} with chainFamily='SVM'. */ +export { CCIPSolanaExtraArgsEncodingError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPExtraArgsEncodingUnsupportedError} with chainFamily='Aptos'. */ +export { CCIPAptosExtraArgsEncodingError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPLaneVersionUnsupportedError}. */ +export { CCIPSolanaLaneVersionUnsupportedError } from './specialized.ts' + +/** @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPTokenNotRegisteredError}. */ +export { CCIPAptosTokenNotRegisteredError } from './specialized.ts' + // HTTP Status codes (re-exported from root) export { HttpStatus, isServerError, isTransientHttpStatus } from '../http-status.ts' diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index df39e5ce..668c013f 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -1077,6 +1077,109 @@ export class CCIPLbtcAttestationNotApprovedError extends CCIPError { } } +// Lane Version + +/** + * Thrown when lane version is not supported. + * + * @example + * ```typescript + * try { + * const lane = await chain.getLane(onRamp, offRamp) + * } catch (error) { + * if (error instanceof CCIPLaneVersionUnsupportedError) { + * console.log(`Unsupported version: ${error.context.version}`) + * } + * } + * ``` + */ +export class CCIPLaneVersionUnsupportedError extends CCIPError { + override readonly name = 'CCIPLaneVersionUnsupportedError' + /** Creates a lane version unsupported error. */ + constructor(version: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.LANE_VERSION_UNSUPPORTED, `Unsupported lane version: ${version}`, { + ...options, + isTransient: false, + context: { ...options?.context, version }, + }) + } +} + +// Token Registration + +/** + * Thrown when token is not registered in a chain's token admin registry. + * + * @example + * ```typescript + * try { + * await chain.getRegistryTokenConfig(registry, token) + * } catch (error) { + * if (error instanceof CCIPTokenNotRegisteredError) { + * console.log(`Token ${error.context.token} not in registry`) + * } + * } + * ``` + */ +export class CCIPTokenNotRegisteredError extends CCIPError { + override readonly name = 'CCIPTokenNotRegisteredError' + /** Creates a token not registered error. */ + constructor(token: string, registry: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.TOKEN_NOT_REGISTERED, + `Token=${token} not registered in registry=${registry}`, + { + ...options, + isTransient: false, + context: { ...options?.context, token, registry }, + }, + ) + } +} + +// ExtraArgs Encoding + +/** + * Thrown when a chain family cannot encode the given extraArgs format. + * + * @example + * ```typescript + * try { + * chain.encodeExtraArgs(unsupportedArgs) + * } catch (error) { + * if (error instanceof CCIPExtraArgsEncodingUnsupportedError) { + * console.log(`${error.context.chainFamily} only supports: ${error.context.supportedFormats}`) + * } + * } + * ``` + */ +export class CCIPExtraArgsEncodingUnsupportedError extends CCIPError { + override readonly name = 'CCIPExtraArgsEncodingUnsupportedError' + /** Creates an extraArgs encoding unsupported error. */ + constructor( + chainFamily: typeof ChainFamily.Solana | typeof ChainFamily.Aptos, + supportedFormats: string, + options?: CCIPErrorOptions, + ) { + const ERROR_CODE_MAP: Record< + typeof ChainFamily.Solana | typeof ChainFamily.Aptos, + CCIPErrorCode + > = { + SVM: CCIPErrorCode.EXTRA_ARGS_SOLANA_EVM_ONLY, + APTOS: CCIPErrorCode.EXTRA_ARGS_APTOS_RESTRICTION, + } + super( + ERROR_CODE_MAP[chainFamily], + `${chainFamily} extraArgs encoding only supports ${supportedFormats}`, + { + ...options, + isTransient: false, + context: { ...options?.context, chainFamily, supportedFormats }, + }, + ) + } +} + // Solana /** @@ -1985,12 +2088,17 @@ export class CCIPContractNotRouterError extends CCIPError { export class CCIPLogDataInvalidError extends CCIPError { override readonly name = 'CCIPLogDataInvalidError' /** Creates a log data invalid error. */ - constructor(data: unknown, options?: CCIPErrorOptions) { - super(CCIPErrorCode.LOG_DATA_INVALID, `Invalid log data: ${String(data)}`, { - ...options, - isTransient: false, - context: { ...options?.context, data }, - }) + constructor(data: unknown, options?: CCIPErrorOptions & { chain?: ChainFamily }) { + const chain = options?.chain + super( + CCIPErrorCode.LOG_DATA_INVALID, + `Invalid ${chain ? chain + ' ' : ''}log data: ${String(data)}`, + { + ...options, + isTransient: false, + context: { ...options?.context, data, ...(chain && { chain }) }, + }, + ) } } @@ -2013,10 +2121,15 @@ export class CCIPLogDataInvalidError extends CCIPError { export class CCIPWalletInvalidError extends CCIPError { override readonly name = 'CCIPWalletInvalidError' /** Creates a wallet invalid error. */ - constructor(wallet: unknown, options?: CCIPErrorOptions) { - super(CCIPErrorCode.WALLET_INVALID, `Wallet must be a Signer, got ${String(wallet)}`, { + constructor(wallet: unknown, options?: CCIPErrorOptions & { className?: string }) { + const className = options?.className + const msg = className + ? `${className} requires a valid wallet, got ${String(wallet)}` + : `Wallet must be a Signer, got ${String(wallet)}` + super(CCIPErrorCode.WALLET_INVALID, msg, { ...options, isTransient: false, + context: { ...options?.context, ...(className && { className }) }, }) } } @@ -2281,6 +2394,8 @@ export class CCIPExtraArgsLengthInvalidError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPExtraArgsEncodingUnsupportedError} with chainFamily='SVM' instead. + * * Thrown when Solana can only encode EVMExtraArgsV2 but got different args. * * @example @@ -2539,6 +2654,8 @@ export class CCIPAptosRegistryTypeInvalidError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPLogDataInvalidError} with chain='Aptos' instead. + * * Thrown when Aptos log data is invalid. * * @example @@ -2565,6 +2682,8 @@ export class CCIPAptosLogInvalidError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPExtraArgsEncodingUnsupportedError} with chainFamily='Aptos' instead. + * * Thrown when Aptos can only encode specific extra args types. * * @example @@ -2594,6 +2713,8 @@ export class CCIPAptosExtraArgsEncodingError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPWalletInvalidError} with className option instead. + * * Thrown when Aptos wallet is invalid. * * @example @@ -2649,6 +2770,8 @@ export class CCIPAptosExtraArgsV2RequiredError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPTokenNotRegisteredError} instead. + * * Thrown when token is not registered in Aptos registry. * * @example @@ -3197,6 +3320,8 @@ export class CCIPCctpDecodeError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPHasherVersionUnsupportedError} with chain='Sui' instead. + * * Thrown when Sui hasher version is unsupported. * * @example @@ -3256,6 +3381,8 @@ export class CCIPSuiMessageVersionInvalidError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPLogDataInvalidError} with chain='Sui' instead. + * * Thrown when Sui log data is invalid. * * This error occurs when attempting to decode a Sui event log that doesn't @@ -3290,6 +3417,8 @@ export class CCIPSuiLogInvalidError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPLaneVersionUnsupportedError} instead. + * * Thrown when Solana lane version is unsupported. * * @example @@ -3346,6 +3475,8 @@ export class CCIPSolanaComputeUnitsExceededError extends CCIPError { } /** + * @deprecated Deprecated in v1.7 (2026-05-25). Use {@link CCIPHasherVersionUnsupportedError} with chain='Aptos' instead. + * * Thrown when Aptos hasher version is unsupported. * * @example diff --git a/ccip-sdk/src/solana/hasher.ts b/ccip-sdk/src/solana/hasher.ts index 00ecb672..92a145e0 100644 --- a/ccip-sdk/src/solana/hasher.ts +++ b/ccip-sdk/src/solana/hasher.ts @@ -12,10 +12,7 @@ import { } from 'ethers' import type { ReadonlyDeep } from 'type-fest' -import { - CCIPExtraArgsInvalidError, - CCIPSolanaLaneVersionUnsupportedError, -} from '../errors/index.ts' +import { CCIPExtraArgsInvalidError, CCIPLaneVersionUnsupportedError } from '../errors/index.ts' import { decodeExtraArgs } from '../extra-args.ts' import type { LeafHasher } from '../hasher/index.ts' import { networkInfo } from '../networks.ts' @@ -52,8 +49,7 @@ export function getV16SolanaLeafHasher( lane: Lane, { logger = console }: WithLogger = {}, ): LeafHasher { - if (lane.version !== CCIPVersion.V1_6) - throw new CCIPSolanaLaneVersionUnsupportedError(lane.version) + if (lane.version !== CCIPVersion.V1_6) throw new CCIPLaneVersionUnsupportedError(lane.version) return (message: ReadonlyDeep>): string => { let parsedArgs diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index 84c7abaa..c0b9b709 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -50,11 +50,11 @@ import { CCIPDataFormatUnsupportedError, CCIPExecutionReportChainMismatchError, CCIPExecutionStateInvalidError, + CCIPExtraArgsEncodingUnsupportedError, CCIPExtraArgsInvalidError, CCIPExtraArgsLengthInvalidError, CCIPLogDataMissingError, CCIPLogsAddressRequiredError, - CCIPSolanaExtraArgsEncodingError, CCIPSolanaOffRampEventsNotFoundError, CCIPSplTokenInvalidError, CCIPTokenAccountNotFoundError, @@ -959,7 +959,8 @@ export class SolanaChain extends Chain { * @throws {@link CCIPSolanaExtraArgsEncodingError} if SVMExtraArgsV1 encoding is attempted */ static encodeExtraArgs(args: ExtraArgs): string { - if ('computeUnits' in args) throw new CCIPSolanaExtraArgsEncodingError() + if ('computeUnits' in args) + throw new CCIPExtraArgsEncodingUnsupportedError(ChainFamily.Solana, 'EVMExtraArgsV2 format') const gasLimitUint128Le = toLeArray(args.gasLimit ?? 0n, 16) return concat([ EVMExtraArgsV2Tag, diff --git a/ccip-sdk/src/sui/hasher.ts b/ccip-sdk/src/sui/hasher.ts index 32329dc0..29418dbb 100644 --- a/ccip-sdk/src/sui/hasher.ts +++ b/ccip-sdk/src/sui/hasher.ts @@ -1,10 +1,11 @@ import { concat, id, keccak256, zeroPadValue } from 'ethers' -import { CCIPExtraArgsInvalidError, CCIPSuiHasherVersionUnsupportedError } from '../errors/index.ts' +import { CCIPExtraArgsInvalidError, CCIPHasherVersionUnsupportedError } from '../errors/index.ts' import { decodeExtraArgs } from '../extra-args.ts' import { type LeafHasher, LEAF_DOMAIN_SEPARATOR } from '../hasher/common.ts' import { type CCIPMessage, type CCIPMessage_V1_6, CCIPVersion } from '../types.ts' import type { CCIPMessage_V1_6_Sui } from './types.ts' +import { ChainFamily } from '../networks.ts' import { encodeNumber, encodeRawBytes } from '../shared/bcs-codecs.ts' /** @@ -30,7 +31,7 @@ export function getSuiLeafHasher({ return ((message: CCIPMessage): string => hashV16SuiMessage(message, metadataHash)) as LeafHasher default: - throw new CCIPSuiHasherVersionUnsupportedError(version) + throw new CCIPHasherVersionUnsupportedError(ChainFamily.Sui, version) } } From 7d22f0c25b820f86e0e482d12d34f2418d5b84e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 27 May 2026 23:53:35 -0400 Subject: [PATCH 03/12] validate token is connected to remote and has enough RL before send --- ccip-sdk/src/aptos/index.ts | 8 ++-- ccip-sdk/src/chain.ts | 36 +++++++++++++++ ccip-sdk/src/errors/codes.ts | 1 + ccip-sdk/src/errors/index.ts | 1 + ccip-sdk/src/errors/recovery.ts | 2 + ccip-sdk/src/errors/specialized.ts | 72 +++++++++++++++++++++++++++++- ccip-sdk/src/evm/index.ts | 9 ++-- ccip-sdk/src/solana/index.ts | 4 +- ccip-sdk/src/ton/index.ts | 16 +++---- 9 files changed, 126 insertions(+), 23 deletions(-) diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index 766496f5..f3787008 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -554,11 +554,9 @@ export class AptosChain extends Chain { } /** {@inheritDoc Chain.getFee} */ - async getFee({ - router, - destChainSelector, - message, - }: Parameters[0]): Promise { + async getFee(opts: Parameters[0]): Promise { + await this.checkSendMessage(opts) + const { router, destChainSelector, message } = opts const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family) return getFee(this.provider, router, destChainSelector, populatedMessage) } diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 1c372766..10cbd131 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -12,6 +12,7 @@ import { CCIPExecTxRevertedError, CCIPLogsRequiresStartError, CCIPNotImplementedError, + CCIPRateLimitExceededError, CCIPTokenPoolChainConfigNotFoundError, } from './errors/index.ts' import type { UnsignedEVMTx } from './evm/types.ts' @@ -1221,6 +1222,41 @@ export abstract class Chain { * ``` */ abstract getTokenAdminRegistryFor(address: string): Promise + + /** + * Pre-flight check if the token transfers in a message is supported for given lane, and have enough rate limit + * @throws {@link CCIPRateLimitExceededError} if amount exceeds the rate limit (capacity or available) for remote + * @throws {@link CCIPTokenPoolChainConfigNotFoundError} if tokenPool or remote config for the lane is not found + * @returns true if all token transfers are supported and within the rate limit + * @internal + */ + async checkSendMessage({ + router, + destChainSelector, + message, + }: PickDeep< + SendMessageOpts, + 'router' | 'destChainSelector' | 'message.tokenAmounts' + >): Promise { + let registry + for (const { token, amount } of message.tokenAmounts ?? []) { + registry ??= await this.getTokenAdminRegistryFor(router) + const { tokenPool } = await this.getRegistryTokenConfig(registry, token) + const remote = await this.getTokenPoolRemote(tokenPool!, destChainSelector) + if (!remote.outboundRateLimiterState) continue + if (amount > remote.outboundRateLimiterState.tokens) { + throw new CCIPRateLimitExceededError('OUTBOUND', remote.outboundRateLimiterState, { + token, + amount, + tokenPool: tokenPool!, + registry, + sourceChainSelector: this.network.chainSelector, + destChainSelector, + }) + } + } + return true + } /** * Fetch the current fee for a given intended message. * diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index 5a50c22a..c40ccba0 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -83,6 +83,7 @@ export const CCIPErrorCode = { TOKEN_NOT_REGISTERED: 'TOKEN_NOT_REGISTERED', TOKEN_REMOTE_NOT_CONFIGURED: 'TOKEN_REMOTE_NOT_CONFIGURED', TOKEN_DECIMALS_INSUFFICIENT: 'TOKEN_DECIMALS_INSUFFICIENT', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', TOKEN_INVALID_SPL: 'TOKEN_INVALID_SPL', TOKEN_DATA_PARSE_FAILED: 'TOKEN_DATA_PARSE_FAILED', TOKEN_MINT_NOT_FOUND: 'TOKEN_MINT_NOT_FOUND', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index 95aeea78..05e9aa6c 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -76,6 +76,7 @@ export { // Specialized errors - Token & Registry export { CCIPLegacyTokenPoolsUnsupportedError, + CCIPRateLimitExceededError, CCIPTokenDecimalsInsufficientError, CCIPTokenNotConfiguredError, CCIPTokenNotFoundError, diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index 2eff0421..62e47abd 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -100,6 +100,8 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { TOKEN_NOT_REGISTERED: 'Token is not registered in the TokenAdminRegistry.', TOKEN_REMOTE_NOT_CONFIGURED: 'Remote network is not registered in TokenPool.', TOKEN_DECIMALS_INSUFFICIENT: 'Destination token has insufficient decimals.', + RATE_LIMIT_EXCEEDED: + 'Rate limit amount not enough for token transfer. Check capacity and retry when refilled.', TOKEN_INVALID_SPL: 'Invalid SPL token or Token-2022.', TOKEN_DATA_PARSE_FAILED: 'Ensure the token address is valid and the token contract is deployed on this chain.', diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index 668c013f..8257d581 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -2,8 +2,9 @@ import type { BytesLike } from 'ethers' import { type CCIPErrorOptions, CCIPError } from './CCIPError.ts' import { CCIPErrorCode } from './codes.ts' +import type { RateLimiterState } from '../chain.ts' import { isTransientHttpStatus } from '../http-status.ts' -import type { ChainFamily } from '../networks.ts' +import { type ChainFamily, networkInfo } from '../networks.ts' import { bigIntReplacer, getAddressBytes, util } from '../utils.ts' // Chain/Network @@ -799,6 +800,75 @@ export class CCIPTokenDecimalsInsufficientError extends CCIPError { } } +/** + * Thrown when TokenPool's rate limit is not enough for the requested amount. + * Transient: rate limit may refill after some time, or user can reduce amount and retry immediately. + * Not transient: amount exceeds total capacity of the pool, so it will never be processable until pool configs change. + * + * @example + * ```typescript + * let fee + * do { + * try { + * fee = await chain.getFee({ + * router, + * destChainSelector, + * message: { receiver, tokenAmounts: [{ token, amount }] }, + * }) + * } catch (error) { + * if (!(error instanceof CCIPRateLimitExceededError) || !error.isTransient) { + * throw error + * } + * console.log(`Token ${error.context.token} exceeds rate limit, retrying after ${error.retryAfterMs}ms`) + * await sleep(error.retryAfterMs) + * } + * } while (fee === undefined) + * ``` + */ +export class CCIPRateLimitExceededError extends CCIPError { + override readonly name = 'CCIPRateLimitExceededError' + /** Creates a rate limit exceeded error. */ + constructor( + direction: 'OUTBOUND' | 'INBOUND', + rateLimiterState: NonNullable, + tokenInfo: { + token: string + amount: bigint + registry?: string + tokenPool: string + sourceChainSelector: bigint + destChainSelector: bigint + }, + options?: CCIPErrorOptions, + ) { + const isTransient = tokenInfo.amount <= rateLimiterState.capacity + let retryAfterMs + if (isTransient) { + retryAfterMs = Number( + ((tokenInfo.amount - rateLimiterState.tokens) * 1000n) / rateLimiterState.rate, + ) + } + const localNetwork = + direction === 'INBOUND' + ? networkInfo(tokenInfo.destChainSelector) + : networkInfo(tokenInfo.sourceChainSelector) + const remoteNetwork = + direction === 'INBOUND' + ? networkInfo(tokenInfo.sourceChainSelector) + : networkInfo(tokenInfo.destChainSelector) + super( + CCIPErrorCode.RATE_LIMIT_EXCEEDED, + `Requested token transfer amount=${tokenInfo.amount} on tokenPool=${tokenInfo.tokenPool} for token=${tokenInfo.token} at "${localNetwork.name}" ${direction === 'INBOUND' ? 'from' : 'to'} "${remoteNetwork.name}" exceeds rate limiter's ${isTransient ? 'available tokens' : 'capacity'}`, + { + ...options, + isTransient, + ...(isTransient && { retryAfterMs }), + context: { ...options?.context, direction, ...tokenInfo, ...rateLimiterState }, + }, + ) + } +} + // Contract Type /** diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 9c5435a8..48601880 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -52,6 +52,7 @@ import { CCIPExecTxRevertedError, CCIPHasherVersionUnsupportedError, CCIPLogDataInvalidError, + CCIPRateLimitExceededError, CCIPSourceChainUnsupportedError, CCIPTokenDecimalsInsufficientError, CCIPTokenNotConfiguredError, @@ -1157,11 +1158,9 @@ export class EVMChain extends Chain { } /** {@inheritDoc Chain.getFee} */ - async getFee({ - router, - destChainSelector, - message, - }: Parameters[0]): Promise { + async getFee(opts: Parameters[0]): Promise { + await this.checkSendMessage(opts) + const { router, destChainSelector, message } = opts const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family) const contract = new Contract( router, diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index c0b9b709..20db81f1 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -1148,7 +1148,9 @@ export class SolanaChain extends Chain { } /** {@inheritDoc Chain.getFee} */ - getFee({ router, destChainSelector, message }: Parameters[0]): Promise { + async getFee(opts: Parameters[0]): Promise { + await this.checkSendMessage(opts) + const { router, destChainSelector, message } = opts const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family) return getFee(this, router, destChainSelector, populatedMessage) } diff --git a/ccip-sdk/src/ton/index.ts b/ccip-sdk/src/ton/index.ts index a362db15..65da3fa2 100644 --- a/ccip-sdk/src/ton/index.ts +++ b/ccip-sdk/src/ton/index.ts @@ -1119,17 +1119,11 @@ export class TONChain extends Chain { } /** {@inheritDoc Chain.getFee} */ - async getFee({ - router, - destChainSelector, - message, - }: Parameters[0]): Promise { - return getFeeImpl( - this, - router, - destChainSelector, - buildMessageForDest(message, networkInfo(destChainSelector).family), - ) + async getFee(opts: Parameters[0]): Promise { + await this.checkSendMessage(opts) + const { router, destChainSelector, message } = opts + const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family) + return getFeeImpl(this, router, destChainSelector, populatedMessage) } /** {@inheritDoc Chain.generateUnsignedSendMessage} */ From d0ebe4222f050c49a617e1514e5ec6042d3e856f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 27 May 2026 23:55:32 -0400 Subject: [PATCH 04/12] better report and detect v1.5.0 TP proxys --- ccip-sdk/src/evm/const.ts | 2 + ccip-sdk/src/evm/index.ts | 82 +++++++++++++++++++++++++++------------ 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/ccip-sdk/src/evm/const.ts b/ccip-sdk/src/evm/const.ts index 28ff688e..2468e48d 100644 --- a/ccip-sdk/src/evm/const.ts +++ b/ccip-sdk/src/evm/const.ts @@ -40,6 +40,7 @@ const customErrors = [ ] as const export const VersionedContractABI = parseAbi(['function typeAndVersion() view returns (string)']) +export const TokenPoolAndProxyABI = parseAbi(['function getPreviousPool() view returns (address)']) export const interfaces = { Router: new Interface(Router_ABI), @@ -51,6 +52,7 @@ export const interfaces = { TokenPool_v1_6: new Interface(TokenPool_1_6_ABI), TokenPool_v1_5_1: new Interface(TokenPool_1_5_1_ABI), TokenPool_v1_5: new Interface(TokenPool_1_5_ABI), + TokenPoolAndProxy: new Interface(TokenPoolAndProxyABI), CommitStore_v1_5: new Interface(CommitStore_1_5_ABI), CommitStore_v1_2: new Interface(CommitStore_1_2_ABI), OffRamp_v2_0: new Interface(OffRamp_2_0_ABI), diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 48601880..c6d5a192 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -35,6 +35,7 @@ import { type GetBalanceOpts, type LogFilter, type RateLimiterState, + type TokenPoolConfig, type TokenPoolRemote, type TokenPrice, type TokenTransferFeeOpts, @@ -118,6 +119,7 @@ import type TokenPool_2_0_ABI from './abi/TokenPool_2_0.ts' 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, @@ -1852,14 +1854,16 @@ export class EVMChain extends Chain { async getTokenPoolConfig( tokenPool: string, feeOpts?: TokenTransferFeeOpts, - ): Promise>, 'typeAndVersion'>> { + ): Promise< + TokenPoolConfig & { + typeAndVersion: string + previousPool?: string + previousTypeAndVersion?: string + } + > { const [type, version, typeAndVersion] = await this.typeAndVersion(tokenPool) - let token, - router, - allowedFinality, - tokenTransferFeeConfig, - originalTokenPool: string | undefined + let token, router, allowedFinality, tokenTransferFeeConfig, previousPool: string | undefined if (version < CCIPVersion.V2_0) { const contract = new Contract( tokenPool, @@ -1868,6 +1872,16 @@ export class EVMChain extends Chain { ) as unknown as TypedContract token = contract.getToken() router = contract.getRouter() + if (type.endsWith('AndProxy')) { + const proxy = new Contract( + tokenPool, + interfaces.TokenPoolAndProxy, + this.provider, + ) as unknown as TypedContract + const previousPool_ = await proxy.getPreviousPool().catch(() => null) + if (previousPool_ && previousPool_ !== ZeroAddress) + previousPool = previousPool_ as CleanAddressable + } } else { if (type === 'USDCTokenPoolProxy') { const proxy = new Contract( @@ -1875,11 +1889,12 @@ export class EVMChain extends Chain { interfaces.USDCTokenPoolProxy_v2_0, this.provider, ) as unknown as TypedContract - originalTokenPool = tokenPool - tokenPool = (await proxy.getPools())['cctpV2PoolWithCCV'] as string + previousPool = (await proxy.getPools())['cctpV2PoolWithCCV'] as CleanAddressable< + Awaited> + >['cctpV2PoolWithCCV'] } const contract = new Contract( - tokenPool, + previousPool ?? tokenPool, interfaces.TokenPool_v2_0, this.provider, ) as unknown as TypedContract @@ -1919,19 +1934,28 @@ export class EVMChain extends Chain { ) } } + let previousTypeAndVersion + if (previousPool) previousTypeAndVersion = this.typeAndVersion(previousPool) - return Promise.all([token, router, allowedFinality, tokenTransferFeeConfig]).then( - ([token, router, allowedFinality, tokenTransferFeeConfig]) => { - return { - token: token as CleanAddressable, - router: router as CleanAddressable, - typeAndVersion, - ...(allowedFinality != null && decodeFinalityAllowed(allowedFinality)), - ...(tokenTransferFeeConfig != null && { tokenTransferFeeConfig }), - ...(originalTokenPool != null && { effectiveTokenPool: tokenPool }), - } - }, - ) + return Promise.all([ + token, + router, + allowedFinality, + tokenTransferFeeConfig, + previousTypeAndVersion, + ]).then(([token, router, allowedFinality, tokenTransferFeeConfig, previousTypeAndVersion]) => { + return { + token: token as CleanAddressable, + router: router as CleanAddressable, + typeAndVersion, + ...(allowedFinality != null && decodeFinalityAllowed(allowedFinality)), + ...(tokenTransferFeeConfig != null && { tokenTransferFeeConfig }), + ...(previousPool != null && { + previousPool, + previousTypeAndVersion: previousTypeAndVersion![2], + }), + } + }) } /** @@ -1954,7 +1978,8 @@ export class EVMChain extends Chain { tokenPool: string, remoteChainSelector?: bigint, ): Promise> { - const [type, version] = await this.typeAndVersion(tokenPool) + const { typeAndVersion, previousPool } = await this.getTokenPoolConfig(tokenPool) + const [type, version] = parseTypeAndVersion(typeAndVersion) let supportedChains: Promise | undefined if (remoteChainSelector) supportedChains = Promise.resolve([networkInfo(remoteChainSelector)]) @@ -1977,13 +2002,22 @@ export class EVMChain extends Chain { ), ), ) + let rlContract = contract + // v1.5.0 proxys to v1.4 TPs need to query rate limits from the previous TP + if (previousPool) { + rlContract = new Contract( + previousPool, + interfaces.TokenPool_v1_5, + this.provider, + ) as unknown as TypedContract + } remoteInfo = supportedChains.then((chains) => Promise.all( chains.map((chain) => Promise.all([ contract.getRemoteToken(chain.chainSelector), - resultToObject(contract.getCurrentOutboundRateLimiterState(chain.chainSelector)), - resultToObject(contract.getCurrentInboundRateLimiterState(chain.chainSelector)), + resultToObject(rlContract.getCurrentOutboundRateLimiterState(chain.chainSelector)), + resultToObject(rlContract.getCurrentInboundRateLimiterState(chain.chainSelector)), ] as const), ), ), From f01858fef6b7ebd931b07ad26ceadc735c2108cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 27 May 2026 23:56:41 -0400 Subject: [PATCH 05/12] validate dest TP RL at estimateReceiveExecution time --- ccip-sdk/src/evm/index.ts | 89 ++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 35 deletions(-) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index c6d5a192..5e15fc7d 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -2236,50 +2236,66 @@ export class EVMChain extends Chain { override async estimateReceiveExecution( opts: Parameters>[0], ): Promise { + let sourceChainSelector: bigint const convertAmounts = ( - tokenAmounts?: readonly (( + tokenAmounts: readonly (( | { token: string } | { destTokenAddress: string; extraData?: string } ) & { amount: bigint })[], + registry: string, ) => - !tokenAmounts - ? undefined - : Promise.all( - tokenAmounts.map(async (ta) => { - if (!('destTokenAddress' in ta)) return ta - let amount = ta.amount - if (isHexString(ta.extraData, 32)) { - // extraData is source token decimals in most pools derived from standard TP contracts; - // we can identify for it being exactly 32B and being a small integer; otherwise, assume same decimals - const sourceDecimals = toBigInt(ta.extraData) - if (0 < sourceDecimals && sourceDecimals <= 36) { - const { decimals: destDecimals } = await this.getTokenInfo(ta.destTokenAddress) - amount = - (amount * BigInt(10) ** BigInt(destDecimals)) / - BigInt(10) ** BigInt(sourceDecimals) - if (amount === 0n) - throw new CCIPTokenDecimalsInsufficientError( - ta.destTokenAddress, - destDecimals, - this.network.name, - formatUnits(amount, sourceDecimals), - ) - } - } - return { token: ta.destTokenAddress, amount } - }), - ) + Promise.all( + tokenAmounts.map(async (ta) => { + if (!('destTokenAddress' in ta)) return ta + let amount = ta.amount + if (isHexString(ta.extraData, 32)) { + // extraData is source token decimals in most pools derived from standard TP contracts; + // we can identify for it being exactly 32B and being a small integer; otherwise, assume same decimals + const sourceDecimals = toBigInt(ta.extraData) + if (0 < sourceDecimals && sourceDecimals <= 36) { + const { decimals: destDecimals } = await this.getTokenInfo(ta.destTokenAddress) + amount = + (amount * BigInt(10) ** BigInt(destDecimals)) / BigInt(10) ** BigInt(sourceDecimals) + if (amount === 0n) + throw new CCIPTokenDecimalsInsufficientError( + ta.destTokenAddress, + destDecimals, + this.network.name, + formatUnits(amount, sourceDecimals), + ) + } + } + const { tokenPool } = await this.getRegistryTokenConfig(registry, ta.destTokenAddress) + const remote = await this.getTokenPoolRemote(tokenPool!, sourceChainSelector) + if (remote.inboundRateLimiterState && amount > remote.inboundRateLimiterState.tokens) { + throw new CCIPRateLimitExceededError('INBOUND', remote.inboundRateLimiterState, { + token: ta.destTokenAddress, + amount, + sourceChainSelector, + destChainSelector: this.network.chainSelector, + tokenPool: tokenPool!, + registry, + }) + } + return { token: ta.destTokenAddress, amount } + }), + ) - let opts_ + let opts_, destRouter, destRegistry if (!('offRamp' in opts)) { const { lane, message, metadata } = await this.getMessageById(opts.messageId) + sourceChainSelector = lane.sourceChainSelector const offRamp = ('offRampAddress' in message && message.offRampAddress) || metadata?.offRamp || (await this.apiClient!.getExecutionInput(opts.messageId)).offRamp + ;[destRouter, destRegistry] = await Promise.all([ + this.getRouterForOffRamp(offRamp, message.sourceChainSelector), + this.getTokenAdminRegistryFor(offRamp), + ]) opts_ = { offRamp, @@ -2289,24 +2305,27 @@ export class EVMChain extends Chain { receiver: message.receiver, sender: message.sender, data: message.data, - destTokenAmounts: await convertAmounts(message.tokenAmounts), + destTokenAmounts: await convertAmounts(message.tokenAmounts, destRegistry), }, } } else { + sourceChainSelector = opts.message.sourceChainSelector + ;[destRouter, destRegistry] = await Promise.all([ + this.getRouterForOffRamp(opts.offRamp, opts.message.sourceChainSelector), + this.getTokenAdminRegistryFor(opts.offRamp), + ]) opts_ = { ...opts, message: { messageId: hexlify(randomBytes(32)), ...opts.message, - destTokenAmounts: await convertAmounts(opts.message.tokenAmounts), + destTokenAmounts: opts.message.tokenAmounts?.length + ? await convertAmounts(opts.message.tokenAmounts, destRegistry) + : undefined, }, } } - const destRouter = await this.getRouterForOffRamp( - opts_.offRamp, - opts_.message.sourceChainSelector, - ) return estimateExecGas({ provider: this.provider, router: destRouter, ...opts_ }) } } From 91b8d7e147909ead210ab0b37b34d66cfaf1ded4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 27 May 2026 23:56:54 -0400 Subject: [PATCH 06/12] chore: exclude dist from tsgo import suggestions --- ccip-sdk/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/ccip-sdk/tsconfig.json b/ccip-sdk/tsconfig.json index 21c9dfc4..5cc957ce 100644 --- a/ccip-sdk/tsconfig.json +++ b/ccip-sdk/tsconfig.json @@ -19,4 +19,5 @@ "noUncheckedIndexedAccess": true, "stableTypeOrdering": true, }, + "exclude": ["dist", "node_modules"], } From 69b8ab4dcf77a284ce25546c53aec2e41ec20d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 29 May 2026 06:54:39 -0400 Subject: [PATCH 07/12] validate dest RL, receiver finality before estimateReceiveExecution --- ccip-sdk/src/aptos/index.ts | 21 -- ccip-sdk/src/canton/index.ts | 15 -- ccip-sdk/src/chain.ts | 44 +++- ccip-sdk/src/errors/codes.ts | 3 + ccip-sdk/src/errors/index.ts | 3 + ccip-sdk/src/errors/specialized.ts | 46 ++++ ccip-sdk/src/evm/abi/CCIPReceiver_2_0.ts | 319 +++++++++++++++++++++++ ccip-sdk/src/evm/const.ts | 2 + ccip-sdk/src/evm/index.ts | 147 +++++------ ccip-sdk/src/gas.ts | 218 +++++++++++----- ccip-sdk/src/index.ts | 4 +- ccip-sdk/src/requests.ts | 65 ----- ccip-sdk/src/solana/index.ts | 66 +---- ccip-sdk/src/sui/index.ts | 6 +- ccip-sdk/src/ton/index.ts | 8 - 15 files changed, 657 insertions(+), 310 deletions(-) create mode 100644 ccip-sdk/src/evm/abi/CCIPReceiver_2_0.ts diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index f3787008..cdd41075 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -367,27 +367,6 @@ export class AptosChain extends Chain { return Promise.resolve(router.split('::')[0] + '::onramp') } - /** {@inheritDoc Chain.getTokenForTokenPool} */ - async getTokenForTokenPool(tokenPool: string): Promise { - const modulesNames = (await this._getAccountModulesNames(tokenPool)) - .reverse() - .filter((name) => name.endsWith('token_pool')) - let firstErr - for (const name of modulesNames) { - try { - const res = await this.provider.view<[string]>({ - payload: { - function: `${tokenPool}::${name}::get_token`, - }, - }) - return res[0] - } catch (err) { - firstErr ??= err as Error - } - } - throw CCIPError.from(firstErr ?? `Could not view 'get_token' in ${tokenPool}`, 'UNKNOWN') - } - /** {@inheritDoc Chain.getBalance} */ async getBalance(opts: GetBalanceOpts): Promise { const { holder, token } = opts diff --git a/ccip-sdk/src/canton/index.ts b/ccip-sdk/src/canton/index.ts index 54910b90..b10880a1 100644 --- a/ccip-sdk/src/canton/index.ts +++ b/ccip-sdk/src/canton/index.ts @@ -434,21 +434,6 @@ export class CantonChain extends Chain { throw new CCIPNotImplementedError('CantonChain.getOnRampForRouter') } - /** - * {@inheritDoc Chain.getCommitStoreForOffRamp} - */ - async getCommitStoreForOffRamp(offRamp: string): Promise { - return Promise.resolve(offRamp) - } - - /** - * {@inheritDoc Chain.getTokenForTokenPool} - * @throws {@link CCIPNotImplementedError} always (not yet implemented for Canton) - */ - getTokenForTokenPool(_tokenPool: string): Promise { - throw new CCIPNotImplementedError('CantonChain.getTokenForTokenPool') - } - /** * Returns token symbol and decimals for the given Canton fee token. * diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 10cbd131..91161999 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -1171,7 +1171,10 @@ export abstract class Chain { * console.log(`Token: ${token}`) * ``` */ - abstract getTokenForTokenPool(tokenPool: string): Promise + async getTokenForTokenPool(tokenPool: string): Promise { + return (await this.getTokenPoolConfig(tokenPool)).token + } + /** * Fetch token metadata. * @@ -1185,6 +1188,7 @@ export abstract class Chain { * ``` */ abstract getTokenInfo(token: string): Promise + /** * Query token balance for an address. * @@ -1257,6 +1261,44 @@ export abstract class Chain { } return true } + + /** + * Pre-flight check if the token transfers in a message is supported for dest given lane, and have enough rate limit + * @param opts - Execution options + * @throws {@link CCIPRateLimitExceededError} if amount exceeds the rate limit (capacity or available) for remote + * @throws {@link CCIPTokenPoolChainConfigNotFoundError} if tokenPool or remote config for the lane is not found + * @returns true if all token transfers are supported and within the rate limit + * @internal + */ + async checkExecute({ + offRamp, + message, + }: { + offRamp: string + message: Pick + }): Promise { + let registry + for (const ta of message.tokenAmounts ?? []) { + const amount = ta.amount + const token = 'destTokenAddress' in ta ? ta.destTokenAddress : ta.token + registry ??= await this.getTokenAdminRegistryFor(offRamp) + const { tokenPool } = await this.getRegistryTokenConfig(registry, token) + const remote = await this.getTokenPoolRemote(tokenPool!, message.sourceChainSelector) + if (!remote.inboundRateLimiterState) continue + if (amount > remote.inboundRateLimiterState.tokens) { + throw new CCIPRateLimitExceededError('INBOUND', remote.inboundRateLimiterState, { + token, + amount, + tokenPool: tokenPool!, + registry, + sourceChainSelector: message.sourceChainSelector, + destChainSelector: this.network.chainSelector, + }) + } + } + return true + } + /** * Fetch the current fee for a given intended message. * diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index c40ccba0..83750bdc 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -104,6 +104,9 @@ export const CCIPErrorCode = { EXECUTION_STATE_INVALID: 'EXECUTION_STATE_INVALID', RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND', + // Finality + FINALITY_NOT_ALLOWED: 'FINALITY_NOT_ALLOWED', + // Attestation (USDC/LBTC) USDC_ATTESTATION_FAILED: 'USDC_ATTESTATION_FAILED', USDC_BURN_FEES_FAILED: 'USDC_BURN_FEES_FAILED', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index 05e9aa6c..d1a14787 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -174,6 +174,9 @@ export { CCIPApiClientNotAvailableError, CCIPUnexpectedPaginationError } from '. // Specialized errors - Viem Adapter export { CCIPViemAdapterError } from './specialized.ts' +// Specialized errors - Finality +export { CCIPFinalityNotAllowedError } from './specialized.ts' + // Specialized errors - Address Validation export { CCIPAddressInvalidError } from './specialized.ts' diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index 8257d581..34154fd2 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -3,6 +3,7 @@ import type { BytesLike } from 'ethers' import { type CCIPErrorOptions, CCIPError } from './CCIPError.ts' import { CCIPErrorCode } from './codes.ts' import type { RateLimiterState } from '../chain.ts' +import type { FinalityAllowed, FinalityRequested } from '../extra-args.ts' import { isTransientHttpStatus } from '../http-status.ts' import { type ChainFamily, networkInfo } from '../networks.ts' import { bigIntReplacer, getAddressBytes, util } from '../utils.ts' @@ -3676,3 +3677,48 @@ export class CCIPViemAdapterError extends CCIPError { }) } } + +// Finality + +/** + * Thrown when a receiver contract rejects the requested finality. + * + * The receiver either does not support `"safe"` (FCR) finality, or requires a higher + * minimum confirmation depth than what was requested. Inspect `context.requested` and + * `context.allowed` for the exact mismatch. + * + * @example + * ```typescript + * try { + * await chain.estimateReceiveExecution({ offRamp, message }) + * } catch (error) { + * if (error instanceof CCIPFinalityNotAllowedError) { + * console.log( + * `Receiver ${error.context.receiver} rejected finality`, + * `requested=${error.context.requested}`, + * `allowed=${JSON.stringify(error.context.allowed)}`, + * ) + * } + * } + * ``` + */ +export class CCIPFinalityNotAllowedError extends CCIPError { + override readonly name = 'CCIPFinalityNotAllowedError' + /** + * Creates a finality not allowed error. + * @param requested - The finality value that was requested (`"safe"` or a block depth number). + * @param allowed - The finality config the receiver actually accepts. + * @param options - Optional error options. + */ + constructor(requested: FinalityRequested, allowed: FinalityAllowed, options?: CCIPErrorOptions) { + const detail = + requested === 'safe' + ? `Receiver does not support "safe" finality` + : `Receiver requires minimum finality depth of ${allowed.finalityDepth}, but ${requested} was requested` + super(CCIPErrorCode.FINALITY_NOT_ALLOWED, detail, { + ...options, + isTransient: false, + context: { ...options?.context, requested, allowed }, + }) + } +} diff --git a/ccip-sdk/src/evm/abi/CCIPReceiver_2_0.ts b/ccip-sdk/src/evm/abi/CCIPReceiver_2_0.ts new file mode 100644 index 00000000..0ad20147 --- /dev/null +++ b/ccip-sdk/src/evm/abi/CCIPReceiver_2_0.ts @@ -0,0 +1,319 @@ +// TODO: track a v2 release tag and the v2.0.0 folder instead of a commit + latest/ folder, once 2.0.0 is released in `chainlink-ccip` +export default [ + // generate: + // fetch('https://github.com/smartcontractkit/chainlink-ccip/raw/refs/heads/main/chains/evm/gobindings/generated/v2_0_0/ping_pong_demo/ping_pong_demo.go') + // .then((res) => res.text()) + // .then((body) => body.match(/^\s*ABI: "(.*?)",$/m)?.[1]) + // .then((abi) => JSON.parse(abi.replace(/\\"/g, '"'))) + // .then((obj) => require('util').inspect(obj, {depth:99}).split('\n').slice(1, -1)) + { + type: 'constructor', + inputs: [ + { name: 'router', type: 'address', internalType: 'address' }, + { + name: 'feeToken', + type: 'address', + internalType: 'contract IERC20', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'acceptOwnership', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'ccipReceive', + inputs: [ + { + name: 'message', + type: 'tuple', + internalType: 'struct Client.Any2EVMMessage', + components: [ + { + name: 'messageId', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'sourceChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { name: 'sender', type: 'bytes', internalType: 'bytes' }, + { name: 'data', type: 'bytes', internalType: 'bytes' }, + { + name: 'destTokenAmounts', + type: 'tuple[]', + internalType: 'struct Client.EVMTokenAmount[]', + components: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getCCVsAndFinalityConfig', + inputs: [ + { name: '', type: 'uint64', internalType: 'uint64' }, + { name: '', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [ + { + name: 'requiredCCVs', + type: 'address[]', + internalType: 'address[]', + }, + { + name: 'optionalCCVs', + type: 'address[]', + internalType: 'address[]', + }, + { + name: 'optionalThreshold', + type: 'uint8', + internalType: 'uint8', + }, + { + name: 'allowedFinalityConfig', + type: 'bytes4', + internalType: 'bytes4', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getCounterpartAddress', + inputs: [], + outputs: [{ name: '', type: 'bytes', internalType: 'bytes' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getCounterpartChainSelector', + inputs: [], + outputs: [{ name: '', type: 'uint64', internalType: 'uint64' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getFeeToken', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'contract IERC20' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getOutOfOrderExecution', + inputs: [], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getRouter', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isPaused', + inputs: [], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'owner', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'setCounterpart', + inputs: [ + { + name: 'counterpartChainSelector', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'counterpartAddress', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setCounterpartAddress', + inputs: [{ name: 'addr', type: 'bytes', internalType: 'bytes' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setCounterpartChainSelector', + inputs: [{ name: 'chainSelector', type: 'uint64', internalType: 'uint64' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setOutOfOrderExecution', + inputs: [ + { + name: 'outOfOrderExecution', + type: 'bool', + internalType: 'bool', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setPaused', + inputs: [{ name: 'pause', type: 'bool', internalType: 'bool' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'startPingPong', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [{ name: 'interfaceId', type: 'bytes4', internalType: 'bytes4' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'pure', + }, + { + type: 'function', + name: 'transferOwnership', + inputs: [{ name: 'to', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'typeAndVersion', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'pure', + }, + { + type: 'event', + name: 'OutOfOrderExecutionChange', + inputs: [ + { + name: 'isOutOfOrder', + type: 'bool', + indexed: false, + internalType: 'bool', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferRequested', + inputs: [ + { + name: 'from', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'to', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferred', + inputs: [ + { + name: 'from', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'to', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Ping', + inputs: [ + { + name: 'pingPongCount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Pong', + inputs: [ + { + name: 'pingPongCount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { type: 'error', name: 'CannotTransferToSelf', inputs: [] }, + { + type: 'error', + name: 'InvalidRouter', + inputs: [{ name: 'router', type: 'address', internalType: 'address' }], + }, + { type: 'error', name: 'MustBeProposedOwner', inputs: [] }, + { type: 'error', name: 'OnlyCallableByOwner', inputs: [] }, + { type: 'error', name: 'OwnerCannotBeZero', inputs: [] }, + // generate:end +] as const diff --git a/ccip-sdk/src/evm/const.ts b/ccip-sdk/src/evm/const.ts index 2468e48d..f6cc970e 100644 --- a/ccip-sdk/src/evm/const.ts +++ b/ccip-sdk/src/evm/const.ts @@ -2,6 +2,7 @@ import { parseAbi } from 'abitype' import { type EventFragment, AbiCoder, Interface } from 'ethers' import Token_ABI from './abi/BurnMintERC677Token.ts' +import CCIPReceiver_2_0_ABI from './abi/CCIPReceiver_2_0.ts' import CCTPVerifier_2_0_ABI from './abi/CCTPVerifier_2_0.ts' import CommitStore_1_2_ABI from './abi/CommitStore_1_2.ts' import CommitStore_1_5_ABI from './abi/CommitStore_1_5.ts' @@ -55,6 +56,7 @@ export const interfaces = { TokenPoolAndProxy: new Interface(TokenPoolAndProxyABI), CommitStore_v1_5: new Interface(CommitStore_1_5_ABI), CommitStore_v1_2: new Interface(CommitStore_1_2_ABI), + Receiver_v2_0: new Interface(CCIPReceiver_2_0_ABI), OffRamp_v2_0: new Interface(OffRamp_2_0_ABI), OffRamp_v1_6: new Interface(OffRamp_1_6_ABI), EVM2EVMOffRamp_v1_5: new Interface(EVM2EVMOffRamp_1_5_ABI), diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 5e15fc7d..beefb4a6 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -14,7 +14,7 @@ import { JsonRpcProvider, WebSocketProvider, ZeroAddress, - formatUnits, + ZeroHash, getAddress, getNumber, hexlify, @@ -23,7 +23,6 @@ import { isHexString, randomBytes, toBeHex, - toBigInt, } from 'ethers' import type { TypedContract } from 'ethers-abitype' import { memoize } from 'micro-memoize' @@ -51,11 +50,10 @@ import { CCIPError, CCIPExecTxNotConfirmedError, CCIPExecTxRevertedError, + CCIPFinalityNotAllowedError, CCIPHasherVersionUnsupportedError, CCIPLogDataInvalidError, - CCIPRateLimitExceededError, CCIPSourceChainUnsupportedError, - CCIPTokenDecimalsInsufficientError, CCIPTokenNotConfiguredError, CCIPTokenPoolChainConfigNotFoundError, CCIPTransactionNotFoundError, @@ -65,12 +63,15 @@ import { } from '../errors/index.ts' import { type ExtraArgs, + type FinalityAllowed, type FinalityRequested, decodeFinalityAllowed, encodeFinality, } from '../extra-args.ts' +import { getDestTokenAmount } from '../gas.ts' import type { LeafHasher } from '../hasher/common.ts' import { decodeMessageV1 } from '../messages.ts' +import { type NetworkInfo, ChainFamily, NetworkType, networkInfo } from '../networks.ts' import { CCTP_FINALITY_FAST, getUsdcBurnFees } from '../offchain.ts' import { buildMessageForDest, decodeMessage } from '../requests.ts' import { supportedChains } from '../supported-chains.ts' @@ -98,6 +99,7 @@ import { parseTypeAndVersion, } from '../utils.ts' import type Token_ABI from './abi/BurnMintERC677Token.ts' +import type Receiver_2_0_ABI from './abi/CCIPReceiver_2_0.ts' import type CCTPVerifier_2_0_ABI from './abi/CCTPVerifier_2_0.ts' import CommitStore_1_2_ABI from './abi/CommitStore_1_2.ts' import CommitStore_1_5_ABI from './abi/CommitStore_1_5.ts' @@ -113,6 +115,7 @@ import EVM2EVMOnRamp_1_2_ABI from './abi/OnRamp_1_2.ts' import EVM2EVMOnRamp_1_5_ABI from './abi/OnRamp_1_5.ts' import OnRamp_1_6_ABI from './abi/OnRamp_1_6.ts' import OnRamp_2_0_ABI from './abi/OnRamp_2_0.ts' +import type PriceRegistry_1_2 from './abi/PriceRegistry_1_2.ts' import type Router_ABI from './abi/Router.ts' import type TokenAdminRegistry_1_5_ABI from './abi/TokenAdminRegistry_1_5.ts' import type TokenPool_2_0_ABI from './abi/TokenPool_2_0.ts' @@ -138,8 +141,6 @@ 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 { type UnsignedEVMTx, resultToObject } from './types.ts' -import { type NetworkInfo, ChainFamily, NetworkType, networkInfo } from '../networks.ts' -import type PriceRegistry_1_2 from './abi/PriceRegistry_1_2.ts' export type { UnsignedEVMTx } /** Raw on-chain TokenBucket struct returned by TokenPool rate limiter queries. */ @@ -982,16 +983,6 @@ export class EVMChain extends Chain { } } - /** {@inheritDoc Chain.getTokenForTokenPool} */ - async getTokenForTokenPool(tokenPool: string): Promise { - const contract = new Contract( - tokenPool, - interfaces.TokenPool_v1_6, - this.provider, - ) as unknown as TypedContract - return contract.getToken() as Promise - } - /** {@inheritDoc Chain.getTokenInfo} */ async getTokenInfo(token: string): Promise<{ decimals: number; symbol: string; name: string }> { const contract = new Contract( @@ -2236,67 +2227,15 @@ export class EVMChain extends Chain { override async estimateReceiveExecution( opts: Parameters>[0], ): Promise { - let sourceChainSelector: bigint - const convertAmounts = ( - tokenAmounts: readonly (( - | { token: string } - | { destTokenAddress: string; extraData?: string } - ) & { - amount: bigint - })[], - registry: string, - ) => - Promise.all( - tokenAmounts.map(async (ta) => { - if (!('destTokenAddress' in ta)) return ta - let amount = ta.amount - if (isHexString(ta.extraData, 32)) { - // extraData is source token decimals in most pools derived from standard TP contracts; - // we can identify for it being exactly 32B and being a small integer; otherwise, assume same decimals - const sourceDecimals = toBigInt(ta.extraData) - if (0 < sourceDecimals && sourceDecimals <= 36) { - const { decimals: destDecimals } = await this.getTokenInfo(ta.destTokenAddress) - amount = - (amount * BigInt(10) ** BigInt(destDecimals)) / BigInt(10) ** BigInt(sourceDecimals) - if (amount === 0n) - throw new CCIPTokenDecimalsInsufficientError( - ta.destTokenAddress, - destDecimals, - this.network.name, - formatUnits(amount, sourceDecimals), - ) - } - } - const { tokenPool } = await this.getRegistryTokenConfig(registry, ta.destTokenAddress) - const remote = await this.getTokenPoolRemote(tokenPool!, sourceChainSelector) - if (remote.inboundRateLimiterState && amount > remote.inboundRateLimiterState.tokens) { - throw new CCIPRateLimitExceededError('INBOUND', remote.inboundRateLimiterState, { - token: ta.destTokenAddress, - amount, - sourceChainSelector, - destChainSelector: this.network.chainSelector, - tokenPool: tokenPool!, - registry, - }) - } - return { token: ta.destTokenAddress, amount } - }), - ) - - let opts_, destRouter, destRegistry + let opts_, destRouter if (!('offRamp' in opts)) { const { lane, message, metadata } = await this.getMessageById(opts.messageId) - sourceChainSelector = lane.sourceChainSelector const offRamp = ('offRampAddress' in message && message.offRampAddress) || metadata?.offRamp || (await this.apiClient!.getExecutionInput(opts.messageId)).offRamp - ;[destRouter, destRegistry] = await Promise.all([ - this.getRouterForOffRamp(offRamp, message.sourceChainSelector), - this.getTokenAdminRegistryFor(offRamp), - ]) - + destRouter = await this.getRouterForOffRamp(offRamp, message.sourceChainSelector) opts_ = { offRamp, message: { @@ -2305,27 +2244,77 @@ export class EVMChain extends Chain { receiver: message.receiver, sender: message.sender, data: message.data, - destTokenAmounts: await convertAmounts(message.tokenAmounts, destRegistry), + destTokenAmounts: await Promise.all( + message.tokenAmounts.map((tokenAmount) => + getDestTokenAmount({ dest: this, tokenAmount }), + ), + ), }, } } else { - sourceChainSelector = opts.message.sourceChainSelector - ;[destRouter, destRegistry] = await Promise.all([ - this.getRouterForOffRamp(opts.offRamp, opts.message.sourceChainSelector), - this.getTokenAdminRegistryFor(opts.offRamp), - ]) + destRouter = await this.getRouterForOffRamp(opts.offRamp, opts.message.sourceChainSelector) opts_ = { ...opts, message: { messageId: hexlify(randomBytes(32)), ...opts.message, - destTokenAmounts: opts.message.tokenAmounts?.length - ? await convertAmounts(opts.message.tokenAmounts, destRegistry) - : undefined, + destTokenAmounts: await Promise.all( + (opts.message.tokenAmounts ?? []).map((tokenAmount) => + getDestTokenAmount({ dest: this, tokenAmount }), + ), + ), }, } } + // v2: check allowed finality + if (opts_.message.finality && opts_.message.finality !== 'finalized') { + let allowedFinality: FinalityAllowed = { + finalityDepth: 1, + finalitySafe: true, + } // default=loose for non-receivers + try { + const receiver = new Contract( + opts_.message.receiver, + interfaces.Receiver_v2_0, + this.provider, + ) as unknown as TypedContract + if (await receiver.supportsInterface(receiver.ccipReceive.fragment.selector)) + allowedFinality = { finalityDepth: 0 } // default=finalized for legacy receivers + + const [, , , allowedFinality_] = await receiver.getCCVsAndFinalityConfig( + opts_.message.sourceChainSelector, + opts_.message.sender ?? ZeroHash, + ) + allowedFinality = decodeFinalityAllowed(allowedFinality_) + } catch (err) { + this.logger.debug( + `Failed to fetch allowed finality config from receiver="${opts_.message.receiver}", defaulting to: ${JSON.stringify(allowedFinality)}. Error:`, + err, + ) + } + if (opts_.message.finality === 'safe') { + if (!allowedFinality.finalitySafe) + throw new CCIPFinalityNotAllowedError(opts_.message.finality, allowedFinality, { + context: { + source: networkInfo(opts_.message.sourceChainSelector).name, + sender: opts_.message.sender, + dest: this.network.name, + receiver: opts_.message.receiver, + }, + }) + } else if (opts_.message.finality < allowedFinality.finalityDepth) { + throw new CCIPFinalityNotAllowedError(opts_.message.finality, allowedFinality, { + context: { + source: networkInfo(opts_.message.sourceChainSelector).name, + sender: opts_.message.sender, + dest: this.network.name, + receiver: opts_.message.receiver, + }, + }) + } + } + return estimateExecGas({ provider: this.provider, router: destRouter, ...opts_ }) } } diff --git a/ccip-sdk/src/gas.ts b/ccip-sdk/src/gas.ts index a96620e9..3b6262e1 100644 --- a/ccip-sdk/src/gas.ts +++ b/ccip-sdk/src/gas.ts @@ -7,10 +7,11 @@ import { CCIPMethodUnsupportedError, CCIPOnRampRequiredError, CCIPTokenDecimalsInsufficientError, + CCIPTokenNotInRegistryError, } from './errors/index.ts' import type { CCIPMessage_V2_0 } from './evm/messages.ts' import { discoverOffRamp } from './execution.ts' -import { sourceToDestTokenAddresses } from './requests.ts' +import { networkInfo } from './networks.ts' import type { CCIPMessage_V1_6_Solana } from './solana/types.ts' import type { CCIPMessage, MessageInput } from './types.ts' import { getDataBytes } from './utils.ts' @@ -21,7 +22,12 @@ import { getDataBytes } from './utils.ts' export type EstimateMessageInput = Simplify< Pick & Partial> & - Partial> & + Partial< + Pick< + CCIPMessage_V2_0, + 'messageId' | 'sender' | 'onRampAddress' | 'offRampAddress' | 'finality' + > + > & Partial< Pick > & { @@ -32,18 +38,96 @@ export type EstimateMessageInput = Simplify< */ tokenAmounts?: readonly ({ amount: bigint + extraData?: string } & ( | { token: string } | { sourceTokenAddress?: string sourcePoolAddress: string destTokenAddress: string - extraData?: string } ))[] } > +/** + * Options for {@link estimateReceiveExecution} function. + */ +export type EstimateReceiveExecutionOpts = { + /** Source chain instance (for token data retrieval) */ + source: Chain + /** Dest chain instance (for token and execution simulation) */ + dest: Chain + /** source router or onRamp, or dest offRamp contract address */ + routerOrRamp: string + /** message to be simulated */ + message: Omit +} + +/** + * Map source token to its pool address and destination token address. + * + * Resolves token routing by querying the TokenAdminRegistry and TokenPool + * to find the corresponding destination chain token. + * + * @param opts - options to convert source to dest token addresses + * @returns Extended token amount with `sourcePoolAddress`, `sourceTokenAddress`, and `destTokenAddress` + * + * @throws {@link CCIPTokenNotInRegistryError} if token is not registered in TokenAdminRegistry + * + * @example + * ```typescript + * import { sourceToDestTokenAddresses, EVMChain } from '@chainlink/ccip-sdk' + * + * const source = await EVMChain.fromUrl('https://rpc.sepolia.org') + * const tokenAmount = await sourceToDestTokenAddresses({ + * source, + * onRamp: '0xOnRamp...', + * destChainSelector: 14767482510784806043n, + * sourceTokenAmount: { token: '0xLINK...', amount: 1000000000000000000n }, + * }) + * console.log(`Pool: ${tokenAmount.sourcePoolAddress}`) + * console.log(`Dest token: ${tokenAmount.destTokenAddress}`) + * ``` + */ +export async function sourceToDestTokenAddresses({ + source, + onRamp, + destChainSelector, + sourceTokenAmount, +}: { + /** Source chain instance */ + source: Chain + /** OnRamp contract address */ + onRamp: string + /** Destination chain selector */ + destChainSelector: bigint + /** Token amount object containing `token` and `amount` */ + sourceTokenAmount: S +}): Promise< + S & { + sourcePoolAddress: string + sourceTokenAddress: string + destTokenAddress: string + } +> { + const tokenAdminRegistry = await source.getTokenAdminRegistryFor(onRamp) + const sourceTokenAddress = sourceTokenAmount.token + const { tokenPool: sourcePoolAddress } = await source.getRegistryTokenConfig( + tokenAdminRegistry, + sourceTokenAddress, + ) + if (!sourcePoolAddress) + throw new CCIPTokenNotInRegistryError(sourceTokenAddress, tokenAdminRegistry) + const remotes = await source.getTokenPoolRemotes(sourcePoolAddress, destChainSelector) + return { + ...sourceTokenAmount, + sourcePoolAddress, + sourceTokenAddress, + destTokenAddress: remotes[networkInfo(destChainSelector).name]!.remoteToken, + } +} + function getSourceDecimalsFromExtraData(extraData?: string): bigint | undefined { if (!extraData) return undefined try { @@ -57,17 +141,61 @@ function getSourceDecimalsFromExtraData(extraData?: string): bigint | undefined } /** - * Options for {@link estimateReceiveExecution} function. + * If given a `{token, amount}` and no `source` (e.g. when called from Chain.estimateReceiveExecution), + * assume it's already a dest tokenAmount and return as-is. + * Otherwise, if given a source tokenAmount, resolve the corresponding destTokenAddress and adjust + * the amount for decimals difference. + * @param opts - options to get destination token amount + * @returns dest `token` and adjusted `amount` for the given source token amount */ -export type EstimateReceiveExecutionOpts = { - /** Source chain instance (for token data retrieval) */ - source: Chain - /** Dest chain instance (for token and execution simulation) */ +export async function getDestTokenAmount({ + source, + onRamp, + dest, + tokenAmount, +}: { + source?: Chain + onRamp?: string dest: Chain - /** source router or onRamp, or dest offRamp contract address */ - routerOrRamp: string - /** message to be simulated */ - message: Omit + tokenAmount: NonNullable[number] +}): Promise<{ token: string; amount: bigint }> { + let sourceTokenAddress, sourcePoolAddress, destTokenAddress + if ('destTokenAddress' in tokenAmount) { + ;({ destTokenAddress, sourcePoolAddress, sourceTokenAddress } = tokenAmount) + } else if (!source) + return tokenAmount // if we don't have a source, assume we were already given a dest `{token, amount}` + else { + ;({ destTokenAddress, sourceTokenAddress, sourcePoolAddress } = + await sourceToDestTokenAddresses({ + source, + onRamp: onRamp!, + destChainSelector: dest.network.chainSelector, + sourceTokenAmount: tokenAmount, + })) + } + + const { decimals: destDecimals } = await dest.getTokenInfo(destTokenAddress) + const sourceDecimals = + getSourceDecimalsFromExtraData(tokenAmount.extraData) ?? + (source + ? ( + await source.getTokenInfo( + sourceTokenAddress ?? (await source.getTokenForTokenPool(sourcePoolAddress)), + ) + ).decimals + : destDecimals) + + const destAmount = + (tokenAmount.amount * BigInt(10) ** BigInt(destDecimals)) / BigInt(10) ** BigInt(sourceDecimals) + if (destAmount === 0n) + throw new CCIPTokenDecimalsInsufficientError( + destTokenAddress, + destDecimals, + dest.network.name, + formatUnits(tokenAmount.amount, sourceDecimals), + ) + + return { token: destTokenAddress, amount: destAmount } } /** @@ -108,24 +236,21 @@ export async function estimateReceiveExecution({ routerOrRamp, message, }: EstimateReceiveExecutionOpts) { - if (!dest.estimateReceiveExecution) - throw new CCIPMethodUnsupportedError(dest.constructor.name, 'estimateReceiveExecution') - let onRamp: string, offRamp: string if (message.onRampAddress) onRamp = message.onRampAddress if (message.offRampAddress) offRamp = message.offRampAddress if (!onRamp! || !offRamp!) try { - const tnv = await source.typeAndVersion(routerOrRamp) - if (!tnv[0].includes('OnRamp')) + const [type] = await source.typeAndVersion(routerOrRamp) + if (!type.includes('OnRamp')) onRamp = await source.getOnRampForRouter(routerOrRamp, dest.network.chainSelector) else onRamp = routerOrRamp - offRamp = await discoverOffRamp(source, dest, onRamp, source) + offRamp ||= await discoverOffRamp(source, dest, onRamp, source) } catch (sourceErr) { try { - const tnv = await dest.typeAndVersion(routerOrRamp) - if (!tnv[0].includes('OffRamp')) - throw new CCIPContractTypeInvalidError(routerOrRamp, tnv[2], ['OffRamp']) + const [type, , tnv] = await dest.typeAndVersion(routerOrRamp) + if (!type.includes('OffRamp')) + throw new CCIPContractTypeInvalidError(routerOrRamp, tnv, ['OffRamp']) offRamp = routerOrRamp const onRamps = await dest.getOnRampsForOffRamp(offRamp, source.network.chainSelector) if (!onRamps.length) throw new CCIPOnRampRequiredError() @@ -136,46 +261,11 @@ export async function estimateReceiveExecution({ } const destTokenAmounts = await Promise.all( - (message.tokenAmounts ?? []).map(async (ta) => { - const tokenAmount = - 'destTokenAddress' in ta - ? ta - : await sourceToDestTokenAddresses({ - source, - onRamp, - destChainSelector: dest.network.chainSelector, - sourceTokenAmount: ta, - }) - const sourceDecimalsFromExtraData = - 'extraData' in tokenAmount - ? getSourceDecimalsFromExtraData(tokenAmount.extraData) - : undefined - const { decimals: destDecimals } = await dest.getTokenInfo(tokenAmount.destTokenAddress) - const sourceDecimals = - sourceDecimalsFromExtraData ?? - ( - await source.getTokenInfo( - 'token' in ta - ? ta.token - : ta.sourceTokenAddress - ? ta.sourceTokenAddress - : await source.getTokenForTokenPool(tokenAmount.sourcePoolAddress), - ) - ).decimals - const destAmount = - (tokenAmount.amount * BigInt(10) ** BigInt(destDecimals)) / - BigInt(10) ** BigInt(sourceDecimals) - if (destAmount === 0n) - throw new CCIPTokenDecimalsInsufficientError( - tokenAmount.destTokenAddress, - destDecimals, - dest.network.name, - formatUnits(tokenAmount.amount, sourceDecimals), - ) - return { ...tokenAmount, token: tokenAmount.destTokenAddress, amount: destAmount } - }), + (message.tokenAmounts ?? []).map(async (tokenAmount) => + getDestTokenAmount({ source, dest, onRamp, tokenAmount }), + ), ) - return dest.estimateReceiveExecution({ + const payload = { offRamp, message: { messageId: message.messageId ?? hexlify(randomBytes(32)), @@ -190,5 +280,11 @@ export async function estimateReceiveExecution({ accountIsWritableBitmap: message.accountIsWritableBitmap, }), }, - }) + } + await dest.checkExecute(payload) + + if (!dest.estimateReceiveExecution) + throw new CCIPMethodUnsupportedError(dest.constructor.name, 'estimateReceiveExecution') + + return dest.estimateReceiveExecution(payload) } diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index cbc5a248..74391321 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -61,9 +61,9 @@ export { encodeExtraArgs, encodeFinality, } from './extra-args.ts' -export { estimateReceiveExecution } from './gas.ts' +export { estimateReceiveExecution, sourceToDestTokenAddresses } from './gas.ts' export { CCTP_FINALITY_FAST, CCTP_FINALITY_STANDARD, getOffchainTokenData } from './offchain.ts' -export { decodeMessage, getMessagesInRange, sourceToDestTokenAddresses } from './requests.ts' +export { decodeMessage, getMessagesInRange } from './requests.ts' export { type CCIPExecution, type CCIPMessage, diff --git a/ccip-sdk/src/requests.ts b/ccip-sdk/src/requests.ts index ed3bbdcf..14fdb46e 100644 --- a/ccip-sdk/src/requests.ts +++ b/ccip-sdk/src/requests.ts @@ -12,7 +12,6 @@ import { CCIPMessageIdNotFoundError, CCIPMessageInvalidError, CCIPMessageNotFoundInTxError, - CCIPTokenNotInRegistryError, CCIPTransactionNotFinalizedError, } from './errors/index.ts' import type { EVMChain } from './evm/index.ts' @@ -531,70 +530,6 @@ export async function* getMessagesInRange( } } -/** - * Map source token to its pool address and destination token address. - * - * Resolves token routing by querying the TokenAdminRegistry and TokenPool - * to find the corresponding destination chain token. - * - * @param opts - options to convert source to dest token addresses - * @returns Extended token amount with `sourcePoolAddress`, `sourceTokenAddress`, and `destTokenAddress` - * - * @throws {@link CCIPTokenNotInRegistryError} if token is not registered in TokenAdminRegistry - * - * @example - * ```typescript - * import { sourceToDestTokenAddresses, EVMChain } from '@chainlink/ccip-sdk' - * - * const source = await EVMChain.fromUrl('https://rpc.sepolia.org') - * const tokenAmount = await sourceToDestTokenAddresses({ - * source, - * onRamp: '0xOnRamp...', - * destChainSelector: 14767482510784806043n, - * sourceTokenAmount: { token: '0xLINK...', amount: 1000000000000000000n }, - * }) - * console.log(`Pool: ${tokenAmount.sourcePoolAddress}`) - * console.log(`Dest token: ${tokenAmount.destTokenAddress}`) - * ``` - */ -export async function sourceToDestTokenAddresses({ - source, - onRamp, - destChainSelector, - sourceTokenAmount, -}: { - /** Source chain instance */ - source: Chain - /** OnRamp contract address */ - onRamp: string - /** Destination chain selector */ - destChainSelector: bigint - /** Token amount object containing `token` and `amount` */ - sourceTokenAmount: S -}): Promise< - S & { - sourcePoolAddress: string - sourceTokenAddress: string - destTokenAddress: string - } -> { - const tokenAdminRegistry = await source.getTokenAdminRegistryFor(onRamp) - const sourceTokenAddress = sourceTokenAmount.token - const { tokenPool: sourcePoolAddress } = await source.getRegistryTokenConfig( - tokenAdminRegistry, - sourceTokenAddress, - ) - if (!sourcePoolAddress) - throw new CCIPTokenNotInRegistryError(sourceTokenAddress, tokenAdminRegistry) - const remotes = await source.getTokenPoolRemotes(sourcePoolAddress, destChainSelector) - return { - ...sourceTokenAmount, - sourcePoolAddress, - sourceTokenAddress, - destTokenAddress: remotes[networkInfo(destChainSelector).name]!.remoteToken, - } -} - /** * Confirm a log tx is finalized or wait for it to be finalized. * diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index 20db81f1..bfdf84dc 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -21,7 +21,6 @@ import { dataSlice, encodeBase58, encodeBase64, - formatUnits, hexlify, isHexString, randomBytes, @@ -59,10 +58,8 @@ import { CCIPSplTokenInvalidError, CCIPTokenAccountNotFoundError, CCIPTokenDataParseError, - CCIPTokenDecimalsInsufficientError, CCIPTokenNotConfiguredError, CCIPTokenPoolChainConfigNotFoundError, - CCIPTokenPoolInfoNotFoundError, CCIPTokenPoolStateNotFoundError, CCIPTopicsInvalidError, CCIPTransactionNotFoundError, @@ -74,6 +71,7 @@ import { type SVMExtraArgsV1, EVMExtraArgsV2Tag, } from '../extra-args.ts' +import { getDestTokenAmount } from '../gas.ts' import type { LeafHasher } from '../hasher/common.ts' import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' import SELECTORS from '../selectors.ts' @@ -687,20 +685,6 @@ export class SolanaChain extends Chain { return Promise.resolve(router) // solana's Router is also the OnRamp } - /** - * {@inheritDoc Chain.getTokenForTokenPool} - * @throws {@link CCIPTokenPoolInfoNotFoundError} if token pool info not found - */ - async getTokenForTokenPool(tokenPool: string): Promise { - const tokenPoolInfo = await this.connection.getAccountInfo(new PublicKey(tokenPool)) - if (!tokenPoolInfo) throw new CCIPTokenPoolInfoNotFoundError(tokenPool) - const { config }: { config: { mint: PublicKey } } = tokenPoolCoder.accounts.decode( - 'state', - tokenPoolInfo.data, - ) - return config.mint.toString() - } - /** * {@inheritDoc Chain.getTokenInfo} * @throws {@link CCIPSplTokenInvalidError} if token is not a valid SPL token @@ -1280,42 +1264,6 @@ export class SolanaChain extends Chain { override async estimateReceiveExecution( opts: Parameters>[0], ): Promise { - const convertAmounts = ( - tokenAmounts?: readonly (( - | { token: string } - | { destTokenAddress: string; extraData?: string } - ) & { - amount: bigint - })[], - ) => - !tokenAmounts - ? undefined - : Promise.all( - tokenAmounts.map(async (ta) => { - if (!('destTokenAddress' in ta)) return ta - let amount = ta.amount - if (isHexString(ta.extraData, 32)) { - // extraData is source token decimals in most pools derived from standard TP contracts; - // we can identify it by being exactly 32B and a small integer; otherwise, assume same decimals. - const sourceDecimals = toBigInt(ta.extraData) - if (0 < sourceDecimals && sourceDecimals <= 36) { - const { decimals: destDecimals } = await this.getTokenInfo(ta.destTokenAddress) - amount = - (amount * BigInt(10) ** BigInt(destDecimals)) / - BigInt(10) ** BigInt(sourceDecimals) - if (amount === 0n) - throw new CCIPTokenDecimalsInsufficientError( - ta.destTokenAddress, - destDecimals, - this.network.name, - formatUnits(amount, sourceDecimals), - ) - } - } - return { token: ta.destTokenAddress, amount } - }), - ) - let opts_ if (!('offRamp' in opts)) { const { lane, message, metadata } = await this.getMessageById(opts.messageId) @@ -1332,7 +1280,11 @@ export class SolanaChain extends Chain { receiver: message.receiver, sender: message.sender, data: message.data, - destTokenAmounts: await convertAmounts(message.tokenAmounts), + destTokenAmounts: await Promise.all( + message.tokenAmounts.map((tokenAmount) => + getDestTokenAmount({ dest: this, tokenAmount }), + ), + ), tokenReceiver: 'tokenReceiver' in message ? message.tokenReceiver : undefined, accounts: 'accounts' in message ? message.accounts : undefined, accountIsWritableBitmap: @@ -1345,7 +1297,11 @@ export class SolanaChain extends Chain { message: { messageId: hexlify(randomBytes(32)), ...opts.message, - destTokenAmounts: await convertAmounts(opts.message.tokenAmounts), + destTokenAmounts: await Promise.all( + (opts.message.tokenAmounts ?? []).map((tokenAmount) => + getDestTokenAmount({ dest: this, tokenAmount }), + ), + ), }, } } diff --git a/ccip-sdk/src/sui/index.ts b/ccip-sdk/src/sui/index.ts index a91e2864..cb793e6e 100644 --- a/ccip-sdk/src/sui/index.ts +++ b/ccip-sdk/src/sui/index.ts @@ -26,9 +26,9 @@ import { CCIPError, CCIPErrorCode, CCIPExecutionReportChainMismatchError, + CCIPLogDataInvalidError, CCIPLogsAddressRequiredError, CCIPNotImplementedError, - CCIPSuiLogInvalidError, CCIPTopicsInvalidError, } from '../errors/index.ts' import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' @@ -393,7 +393,7 @@ export class SuiChain extends Chain { * @throws {@link CCIPError} if token pool type is invalid or state not found * @throws {@link CCIPDataFormatUnsupportedError} if view call fails */ - async getTokenForTokenPool(tokenPool: string): Promise { + override async getTokenForTokenPool(tokenPool: string): Promise { const normalizedTokenPool = normalizeSuiAddress(tokenPool) // Get objects owned by this package (looking for state pointers) @@ -578,7 +578,7 @@ export class SuiChain extends Chain { (typeof data !== 'string' || !data.startsWith('{')) && (typeof data !== 'object' || isBytesLike(data)) ) - throw new CCIPSuiLogInvalidError(util.inspect(log)) + throw new CCIPLogDataInvalidError(util.inspect(log), { chain: this.family }) // offload massaging to generic decodeJsonMessage try { return decodeMessage(data) diff --git a/ccip-sdk/src/ton/index.ts b/ccip-sdk/src/ton/index.ts index 65da3fa2..edde294b 100644 --- a/ccip-sdk/src/ton/index.ts +++ b/ccip-sdk/src/ton/index.ts @@ -638,14 +638,6 @@ export class TONChain extends Chain { } } - /** - * {@inheritDoc Chain.getTokenForTokenPool} - * @throws {@link CCIPNotImplementedError} always (not implemented for TON) - */ - async getTokenForTokenPool(_tokenPool: string): Promise { - return Promise.reject(new CCIPNotImplementedError('getTokenForTokenPool')) - } - /** {@inheritDoc Chain.getTokenInfo} */ async getTokenInfo(token: string): Promise<{ symbol: string; decimals: number }> { const tokenAddress = Address.parse(token) From 6003a0ccfea071d64752046b1e8f517a32e0b55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 29 May 2026 06:55:31 -0400 Subject: [PATCH 08/12] send: --estimate-gas-limit by default when possible, to include pre-flight dest checks --- ccip-cli/src/commands/send.ts | 145 ++++++++++++++++++++-------------- ccip-cli/src/index.ts | 3 +- 2 files changed, 86 insertions(+), 62 deletions(-) diff --git a/ccip-cli/src/commands/send.ts b/ccip-cli/src/commands/send.ts index 10ac7be9..91ad330c 100644 --- a/ccip-cli/src/commands/send.ts +++ b/ccip-cli/src/commands/send.ts @@ -24,6 +24,7 @@ import { type MessageInput, CCIPArgumentInvalidError, CCIPInsufficientBalanceError, + CCIPMethodUnsupportedError, CCIPTokenNotFoundError, ChainFamily, bigIntReplacer, @@ -33,7 +34,7 @@ import { getDataBytes, networkInfo, } from '@chainlink/ccip-sdk/src/index.ts' -import { type BytesLike, AbiCoder, formatUnits, toUtf8Bytes } from 'ethers' +import { type BytesLike, AbiCoder, formatUnits, randomBytes, toUtf8Bytes } from 'ethers' import type { Argv } from 'yargs' import type { GlobalOpts } from '../index.ts' @@ -265,73 +266,95 @@ async function sendMessage( let walletAddress, wallet if (!receiver) { - if (sourceNetwork.family !== destNetwork.family) - throw new CCIPArgumentInvalidError('receiver', 'required for cross-family transfers') - ;[walletAddress, wallet] = await loadChainWallet(source, argv, logger) + try { + if (sourceNetwork.family !== destNetwork.family) + throw new CCIPArgumentInvalidError('receiver', 'required for cross-family transfers') + ;[walletAddress, wallet] = await loadChainWallet(source, argv, logger) + } catch (err) { + if (!argv.onlyGetFee && !argv.onlyEstimate) throw err + // if we can't load wallet for receiver, and it's only for estimation, generate a random one + walletAddress = decodeAddress( + randomBytes( + destNetwork.family === ChainFamily.EVM + ? 20 + : destNetwork.family === ChainFamily.TON + ? 36 + : 32, + ), + destNetwork.family, + ) + } receiver = walletAddress // send to self if same family } - if (argv.estimateGasLimit != null || argv.onlyEstimate) { - // TODO: implement for all chain families - const dest = await getChain(destNetwork.chainSelector) + if (argv.estimateGasLimit == null || argv.estimateGasLimit > -100) + try { + const dest = await getChain(destNetwork.chainSelector) - if (!walletAddress) { - try { - ;[walletAddress, wallet] = await loadChainWallet(source, argv, logger) - } catch { - // pass undefined sender for default + if (!walletAddress) { + try { + ;[walletAddress, wallet] = await loadChainWallet(source, argv, logger) + } catch { + // pass undefined sender as default + } } - } - const estimated = await estimateReceiveExecution({ - source, - dest, - routerOrRamp: argv.router, - message: { - sender: walletAddress, - receiver, - data, - tokenAmounts, - ...(!!argv.tokenReceiver && { tokenReceiver: argv.tokenReceiver }), - ...(accounts != null && accounts.length && { accounts, accountIsWritableBitmap }), - }, - }) - argv.gasLimit = Math.ceil(estimated * (1 + (argv.estimateGasLimit ?? 0) / 100)) - if (argv.onlyEstimate) { - // --only-estimate: the estimate IS the data output - if (argv.format === Format.json) { - output.write( - JSON.stringify( - { - estimated, - bufferPercent: argv.estimateGasLimit ?? 0, - withBuffer: argv.gasLimit, - }, - bigIntReplacer, - 2, - ), - ) - } else { - output.write( - 'Estimated', - destNetwork.family === ChainFamily.Solana ? 'computeUnits' : 'gasLimit', - 'for sender =', - walletAddress, - ':', - estimated, - ...(argv.estimateGasLimit ? ['+', argv.estimateGasLimit, '% =', argv.gasLimit] : []), - ) + + const estimated = await estimateReceiveExecution({ + source, + dest, + routerOrRamp: argv.router, + message: { + sender: walletAddress, + receiver, + data, + tokenAmounts, + ...(!!argv.tokenReceiver && { tokenReceiver: argv.tokenReceiver }), + ...(accounts != null && accounts.length && { accounts, accountIsWritableBitmap }), + }, + }) + argv.gasLimit = Math.ceil(estimated * (1 + (argv.estimateGasLimit ?? 10) / 100)) + if (argv.onlyEstimate) { + // --only-estimate: the estimate IS the data output + if (argv.format === Format.json) { + output.write( + JSON.stringify( + { + estimated, + bufferPercent: argv.estimateGasLimit ?? 0, + withBuffer: argv.gasLimit, + }, + bigIntReplacer, + 2, + ), + ) + } else { + output.write( + 'Estimated', + destNetwork.family === ChainFamily.Solana ? 'computeUnits' : 'gasLimit', + 'for sender =', + walletAddress, + ':', + estimated, + ...(argv.estimateGasLimit ? ['+', argv.estimateGasLimit, '% =', argv.gasLimit] : []), + ) + } + return } - return + // When continuing to send, the estimate is a status message + logger.info( + 'Estimated gasLimit for sender =', + walletAddress, + ':', + estimated, + ...(argv.estimateGasLimit ? ['+', argv.estimateGasLimit, '% =', argv.gasLimit] : []), + ) + } catch (err) { + // if user requested estimation explicitly, surface any error + if (argv.estimateGasLimit != null || argv.onlyEstimate) throw err + // otherwise, surface anything other than unimplemented error (e.g. CCIPRateLimitExceededError) + if (!(err instanceof CCIPMethodUnsupportedError)) throw err + logger.debug('estimateReceiveExecution not supported for', destNetwork.name, '—', err) } - // When continuing to send, the estimate is a status message - logger.info( - 'Estimated gasLimit for sender =', - walletAddress, - ':', - estimated, - ...(argv.estimateGasLimit ? ['+', argv.estimateGasLimit, '% =', argv.gasLimit] : []), - ) - } // builds a catch-all extraArgs object, which can be massaged by // [[Chain.buildMessageForDest]] to create suitable extraArgs with defaults if needed diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 97ba39eb..7ea70172 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -120,7 +120,8 @@ export type GlobalOpts = ArgumentsCamelCase { if (arg === '--no-api') return '--api=false' - if (arg === '--json') return ['--format', 'json'] + if (arg === '--json') return ['--format=json'] + if (arg === '--no-estimate-gas-limit') return '--estimate-gas-limit=-100' return arg }) if (!process.stdin.isTTY && !result.includes('--no-interactive')) result.push('--no-interactive') From 54fc9aa5486ef6f0d0f96f272b644e6337551efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 29 May 2026 07:05:23 -0400 Subject: [PATCH 09/12] add changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca19989e..ea5d7b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- SDK: `checkSendMessage` method called at `getFee` time, checks source rate limits before sending +- SDK: `checkExecute` method checks dest rate limits, called by standalone `estimateReceiveExecution` function +- SDK: `estimateReceiveExecution` method also checks requested finality is within `ccipReceiver` `allowedFinality` +- CLI: `send` performs all these `--estimate-gas-limit` checks by default before sending + ## [1.7.0] - 2026-05-25 - SDK: Add `getMessagesInRange()` to the abstract Chain class — range-based CCIP message discovery using `getLogs` + `decodeMessage`, returns `AsyncIterableIterator` From c0bbd5c201b34845d2d97a9e2b987c7bcd417a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 29 May 2026 10:56:46 -0400 Subject: [PATCH 10/12] fix tests, improve errors --- ccip-sdk/src/commits.test.ts | 4 ---- ccip-sdk/src/errors/recovery.ts | 3 +++ ccip-sdk/src/evm/errors.ts | 11 ++++++++++- ccip-sdk/src/evm/fork.test.ts | 4 ++++ ccip-sdk/src/execution.test.ts | 4 ---- ccip-sdk/src/gas.test.ts | 1 + ccip-sdk/src/waitFinalized.test.ts | 3 --- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/ccip-sdk/src/commits.test.ts b/ccip-sdk/src/commits.test.ts index 238e0dff..09feb350 100644 --- a/ccip-sdk/src/commits.test.ts +++ b/ccip-sdk/src/commits.test.ts @@ -160,10 +160,6 @@ class MockChain extends Chain { return { remoteToken: '0xRemoteToken', remotePools: [] } } - async getTokenForTokenPool(_tokenPool: string): Promise { - return '0xToken' - } - async getTokenInfo(_token: string): Promise<{ symbol: string; decimals: number; name?: string }> { return { symbol: 'TST', decimals: 18, name: 'Test Token' } } diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index 62e47abd..86174537 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -125,6 +125,9 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { EXECUTION_STATE_INVALID: 'Invalid execution state returned from contract.', RECEIPT_NOT_FOUND: 'Receipt not found in transaction logs. Wait and retry.', + FINALITY_NOT_ALLOWED: + "The receiver contract does not accept the requested finality. Check the receiver's getCCVsAndFinalityConfig() for the allowed depth or safe flag.", + USDC_ATTESTATION_FAILED: 'USDC attestation not ready. Wait and retry (10-30 min typical).', LBTC_ATTESTATION_ERROR: 'LBTC attestation fetch failed. Wait and retry.', LBTC_ATTESTATION_NOT_FOUND: 'LBTC attestation not found. Verify the payload hash.', diff --git a/ccip-sdk/src/evm/errors.ts b/ccip-sdk/src/evm/errors.ts index 7c0bad9c..86fa973b 100644 --- a/ccip-sdk/src/evm/errors.ts +++ b/ccip-sdk/src/evm/errors.ts @@ -215,7 +215,16 @@ export function parseData(data: unknown): Record | undefined { } const shortMessage = err_.shortMessage || err_.message const transaction = err_.transaction - if (!shortMessage || !transaction?.data) return + if (!shortMessage) return + if (!transaction?.data) { + // No transaction calldata context (e.g. error from contract.fn.populateTransaction); + // still try to surface decoded revert data if available. + const errorData = getErrorData(data) + if (!errorData) return + const reason = Object.fromEntries(recursiveParseError('revert', errorData)) + if (!Object.keys(reason).length) return + return { error: shortMessage, ...reason } + } let method, invocation const invocation_ = (data as { invocation: { method: string; args: Result } | null }).invocation diff --git a/ccip-sdk/src/evm/fork.test.ts b/ccip-sdk/src/evm/fork.test.ts index 26c067d9..a7a8e620 100644 --- a/ccip-sdk/src/evm/fork.test.ts +++ b/ccip-sdk/src/evm/fork.test.ts @@ -1612,6 +1612,8 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { apiClient: null, logger: testLogger, }) + // Bypass SDK preflight so the on-chain TokenMaxCapacityExceeded revert reaches EVMChain.parse. + viemChain.checkSendMessage = async () => true as const let caught: unknown try { @@ -1661,6 +1663,8 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => { apiClient: null, logger: testLogger, }) + // Bypass SDK preflight so the on-chain TokenMaxCapacityExceeded revert reaches EVMChain.parse. + ethersChainLocal.checkSendMessage = async () => true as const const ethersWalletLocal = new Wallet(ANVIL_PRIVATE_KEY, ethersChainLocal.provider) let caught: unknown diff --git a/ccip-sdk/src/execution.test.ts b/ccip-sdk/src/execution.test.ts index 12b6b05a..120671bc 100644 --- a/ccip-sdk/src/execution.test.ts +++ b/ccip-sdk/src/execution.test.ts @@ -172,10 +172,6 @@ class MockChain extends Chain { return { remoteToken: '0xRemoteToken', remotePools: [] } } - async getTokenForTokenPool(_tokenPool: string): Promise { - return '0xToken' - } - async getTokenInfo(_token: string): Promise<{ symbol: string; decimals: number; name?: string }> { return { symbol: 'TST', decimals: 18, name: 'Test Token' } } diff --git a/ccip-sdk/src/gas.test.ts b/ccip-sdk/src/gas.test.ts index 7d6fbde3..2d60033f 100644 --- a/ccip-sdk/src/gas.test.ts +++ b/ccip-sdk/src/gas.test.ts @@ -78,6 +78,7 @@ function createMockChains(onRamp: string, offRamp: string) { onRamp, ]), balanceOf: mock.fn(async () => 0n), + checkExecute: mock.fn(async () => true), estimateReceiveExecution: mock.fn(async (opts: any) => { const router = await mockDestChain.getRouterForOffRamp(opts.offRamp) const { tokenAmounts, ...message } = opts.message diff --git a/ccip-sdk/src/waitFinalized.test.ts b/ccip-sdk/src/waitFinalized.test.ts index 1891c823..35d557f8 100644 --- a/ccip-sdk/src/waitFinalized.test.ts +++ b/ccip-sdk/src/waitFinalized.test.ts @@ -126,9 +126,6 @@ class WaitFinalizedMockChain extends Chain { async getTokenPoolRemotes(): Promise { return {} } - async getTokenForTokenPool() { - return '0x' - } async getTokenInfo() { return { symbol: 'T', decimals: 18 } } From b816c754da6513f7ca286728f7c2ca9917bba329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 29 May 2026 11:53:00 -0400 Subject: [PATCH 11/12] bump selectors --- ccip-sdk/src/selectors.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ccip-sdk/src/selectors.ts b/ccip-sdk/src/selectors.ts index 0791fcdd..302ee4d5 100644 --- a/ccip-sdk/src/selectors.ts +++ b/ccip-sdk/src/selectors.ts @@ -805,6 +805,12 @@ const SELECTORS: Selectors = { network_type: 'TESTNET', family: 'EVM', }, + '5042': { + selector: 6370580034781731079n, + name: 'arc-mainnet', + network_type: 'MAINNET', + family: 'EVM', + }, '5330': { selector: 470401360549526817n, name: 'superseed-mainnet', From 3ce60dc632cb917f84d8d80a8dffeba131cead0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 29 May 2026 16:59:39 -0400 Subject: [PATCH 12/12] fix passing extraArgs to estimateReceiveExecution inputs --- ccip-cli/src/commands/send.ts | 37 +++++++++++++++++----------------- ccip-cli/src/commands/utils.ts | 3 ++- ccip-sdk/src/evm/const.ts | 1 + ccip-sdk/src/evm/index.ts | 14 ++++++------- ccip-sdk/src/gas.test.ts | 3 +++ ccip-sdk/src/gas.ts | 9 +-------- 6 files changed, 32 insertions(+), 35 deletions(-) diff --git a/ccip-cli/src/commands/send.ts b/ccip-cli/src/commands/send.ts index 91ad330c..6b2ba1ad 100644 --- a/ccip-cli/src/commands/send.ts +++ b/ccip-cli/src/commands/send.ts @@ -287,6 +287,23 @@ async function sendMessage( receiver = walletAddress // send to self if same family } + // builds a catch-all extraArgs object, which can be massaged by + // [[Chain.buildMessageForDest]] to create suitable extraArgs with defaults if needed + // --extra entries are spread last so they take priority over code-generated values + const extraArgs = { + ...(argv.allowOutOfOrderExec != null && { + allowOutOfOrderExecution: !!argv.allowOutOfOrderExec, + }), + ...(argv.gasLimit == null + ? {} + : destNetwork.family === ChainFamily.Solana + ? { computeUnits: BigInt(argv.gasLimit) } + : { gasLimit: BigInt(argv.gasLimit) }), + ...(!!argv.tokenReceiver && { tokenReceiver: argv.tokenReceiver }), + ...(!!accounts && { accounts, accountIsWritableBitmap }), // accounts also used as Sui receiverObjectIds + ...parseExtraArgs(argv.extra), + } + if (argv.estimateGasLimit == null || argv.estimateGasLimit > -100) try { const dest = await getChain(destNetwork.chainSelector) @@ -308,8 +325,7 @@ async function sendMessage( receiver, data, tokenAmounts, - ...(!!argv.tokenReceiver && { tokenReceiver: argv.tokenReceiver }), - ...(accounts != null && accounts.length && { accounts, accountIsWritableBitmap }), + ...extraArgs, }, }) argv.gasLimit = Math.ceil(estimated * (1 + (argv.estimateGasLimit ?? 10) / 100)) @@ -356,23 +372,6 @@ async function sendMessage( logger.debug('estimateReceiveExecution not supported for', destNetwork.name, '—', err) } - // builds a catch-all extraArgs object, which can be massaged by - // [[Chain.buildMessageForDest]] to create suitable extraArgs with defaults if needed - // --extra entries are spread last so they take priority over code-generated values - const extraArgs = { - ...(argv.allowOutOfOrderExec != null && { - allowOutOfOrderExecution: !!argv.allowOutOfOrderExec, - }), - ...(argv.gasLimit == null - ? {} - : destNetwork.family === ChainFamily.Solana - ? { computeUnits: BigInt(argv.gasLimit) } - : { gasLimit: BigInt(argv.gasLimit) }), - ...(!!argv.tokenReceiver && { tokenReceiver: argv.tokenReceiver }), - ...(!!accounts && { accounts, accountIsWritableBitmap }), // accounts also used as Sui receiverObjectIds - ...parseExtraArgs(argv.extra), - } - let feeToken, feeTokenInfo if (argv.feeToken) { try { diff --git a/ccip-cli/src/commands/utils.ts b/ccip-cli/src/commands/utils.ts index f57a6e3b..92a9b632 100644 --- a/ccip-cli/src/commands/utils.ts +++ b/ccip-cli/src/commands/utils.ts @@ -1,4 +1,5 @@ import { Console } from 'node:console' +import util from 'node:util' import { type CCIPExecution, @@ -564,7 +565,7 @@ export function formatCCIPError(err: unknown, verbose = false): string | null { if (Object.keys(err.context).length > 0) { lines.push(' context:') for (const [key, value] of Object.entries(err.context)) { - lines.push(` ${key}: ${value as string}`) + lines.push(` ${key}: ${util.inspect(value)}`) } } diff --git a/ccip-sdk/src/evm/const.ts b/ccip-sdk/src/evm/const.ts index f6cc970e..72365801 100644 --- a/ccip-sdk/src/evm/const.ts +++ b/ccip-sdk/src/evm/const.ts @@ -38,6 +38,7 @@ const customErrors = [ 'error BlacklistableBlacklistedAccount(address)', 'error WrongAsset(address expected, address received)', 'error FailedInnerCall()', + 'error SenderNotAllowed(uint64 sourceChainSelector, bytes sender)', ] as const export const VersionedContractABI = parseAbi(['function typeAndVersion() view returns (string)']) diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index beefb4a6..2a898a6a 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -2229,7 +2229,7 @@ export class EVMChain extends Chain { ): Promise { let opts_, destRouter if (!('offRamp' in opts)) { - const { lane, message, metadata } = await this.getMessageById(opts.messageId) + const { message, metadata } = await this.getMessageById(opts.messageId) const offRamp = ('offRampAddress' in message && message.offRampAddress) || @@ -2239,11 +2239,7 @@ export class EVMChain extends Chain { opts_ = { offRamp, message: { - sourceChainSelector: lane.sourceChainSelector, - messageId: message.messageId, - receiver: message.receiver, - sender: message.sender, - data: message.data, + ...message, destTokenAmounts: await Promise.all( message.tokenAmounts.map((tokenAmount) => getDestTokenAmount({ dest: this, tokenAmount }), @@ -2268,7 +2264,11 @@ export class EVMChain extends Chain { } // v2: check allowed finality - if (opts_.message.finality && opts_.message.finality !== 'finalized') { + if ( + 'finality' in opts_.message && + opts_.message.finality && + opts_.message.finality !== 'finalized' + ) { let allowedFinality: FinalityAllowed = { finalityDepth: 1, finalitySafe: true, diff --git a/ccip-sdk/src/gas.test.ts b/ccip-sdk/src/gas.test.ts index 2d60033f..8e867e59 100644 --- a/ccip-sdk/src/gas.test.ts +++ b/ccip-sdk/src/gas.test.ts @@ -487,6 +487,7 @@ describe('EVMChain.estimateReceiveExecution({ messageId })', () => { messageId, sender, receiver, + sourceChainSelector: 16015286601757825753n, data: '0xdaad', tokenAmounts: [], offRampAddress: offRamp, @@ -521,6 +522,7 @@ describe('EVMChain.estimateReceiveExecution({ messageId })', () => { messageId, sender, receiver, + sourceChainSelector: 16015286601757825753n, data: '0x', tokenAmounts: [], }, @@ -562,6 +564,7 @@ describe('EVMChain.estimateReceiveExecution({ messageId })', () => { messageId, sender, receiver, + sourceChainSelector: 16015286601757825753n, data: '0x', tokenAmounts: [ { diff --git a/ccip-sdk/src/gas.ts b/ccip-sdk/src/gas.ts index 3b6262e1..a3620e07 100644 --- a/ccip-sdk/src/gas.ts +++ b/ccip-sdk/src/gas.ts @@ -268,17 +268,10 @@ export async function estimateReceiveExecution({ const payload = { offRamp, message: { + ...message, messageId: message.messageId ?? hexlify(randomBytes(32)), - receiver: message.receiver, - sender: message.sender, - data: message.data, sourceChainSelector: source.network.chainSelector, tokenAmounts: destTokenAmounts, - ...(!!message.tokenReceiver && { tokenReceiver: message.tokenReceiver }), - ...(!!message.accounts?.length && { - accounts: message.accounts, - accountIsWritableBitmap: message.accountIsWritableBitmap, - }), }, } await dest.checkExecute(payload)