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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CCIPRequest>`
Expand Down
154 changes: 88 additions & 66 deletions ccip-cli/src/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type MessageInput,
CCIPArgumentInvalidError,
CCIPInsufficientBalanceError,
CCIPMethodUnsupportedError,
CCIPTokenNotFoundError,
ChainFamily,
bigIntReplacer,
Expand All @@ -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'
Expand Down Expand Up @@ -265,72 +266,25 @@ 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)
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 (!walletAddress) {
try {
;[walletAddress, wallet] = await loadChainWallet(source, argv, logger)
} catch {
// pass undefined sender for 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] : []),
)
}
return
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,
)
}
// When continuing to send, the estimate is a status message
logger.info(
'Estimated gasLimit for sender =',
walletAddress,
':',
estimated,
...(argv.estimateGasLimit ? ['+', argv.estimateGasLimit, '% =', argv.gasLimit] : []),
)
receiver = walletAddress // send to self if same family
}

// builds a catch-all extraArgs object, which can be massaged by
Expand All @@ -350,6 +304,74 @@ async function sendMessage(
...parseExtraArgs(argv.extra),
}

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 as default
}
}

const estimated = await estimateReceiveExecution({
source,
dest,
routerOrRamp: argv.router,
message: {
sender: walletAddress,
receiver,
data,
tokenAmounts,
...extraArgs,
},
})
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
}
// 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)
}

let feeToken, feeTokenInfo
if (argv.feeToken) {
try {
Expand Down
3 changes: 2 additions & 1 deletion ccip-cli/src/commands/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Console } from 'node:console'
import util from 'node:util'

import {
type CCIPExecution,
Expand Down Expand Up @@ -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)}`)
}
}

Expand Down
3 changes: 2 additions & 1 deletion ccip-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ export type GlobalOpts = ArgumentsCamelCase<InferredOptionTypes<typeof globalOpt
function preprocessArgv(argv: string[]): string[] {
const result = argv.flatMap((arg) => {
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')
Expand Down
9 changes: 3 additions & 6 deletions ccip-sdk/src/aptos/hasher.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -34,7 +31,7 @@ export function getAptosLeafHasher<V extends CCIPVersion = CCIPVersion>({
return ((message: CCIPMessage<typeof CCIPVersion.V1_6>): string =>
hashV16AptosMessage(message, metadataHash)) as LeafHasher<V>
default:
throw new CCIPAptosHasherVersionUnsupportedError(version)
throw new CCIPHasherVersionUnsupportedError(ChainFamily.Aptos, version)
}
}

Expand Down
56 changes: 19 additions & 37 deletions ccip-sdk/src/aptos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -367,27 +367,6 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
return Promise.resolve(router.split('::')[0] + '::onramp')
}

/** {@inheritDoc Chain.getTokenForTokenPool} */
async getTokenForTokenPool(tokenPool: string): Promise<string> {
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<bigint> {
const { holder, token } = opts
Expand Down Expand Up @@ -427,7 +406,7 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
(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)
Expand Down Expand Up @@ -469,7 +448,10 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
accounts: extraArgs.accounts.map(getAddressBytes),
}).toBytes(),
])
throw new CCIPAptosExtraArgsEncodingError()
throw new CCIPExtraArgsEncodingUnsupportedError(
ChainFamily.Aptos,
'EVMExtraArgsV2 & SVMExtraArgsV1',
)
}

/**
Expand All @@ -480,7 +462,8 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
* @throws {@link CCIPAptosLogInvalidError} if log data format is invalid
*/
static decodeCommits({ data }: Pick<ChainLog, 'data'>, 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[]
Expand Down Expand Up @@ -514,7 +497,8 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
* @throws {@link CCIPAptosLogInvalidError} if log data format is invalid
*/
static decodeReceipt({ data }: Pick<ChainLog, 'data'>): 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) =>
Expand Down Expand Up @@ -549,11 +533,9 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
}

/** {@inheritDoc Chain.getFee} */
async getFee({
router,
destChainSelector,
message,
}: Parameters<Chain['getFee']>[0]): Promise<bigint> {
async getFee(opts: Parameters<Chain['getFee']>[0]): Promise<bigint> {
await this.checkSendMessage(opts)
const { router, destChainSelector, message } = opts
const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family)
return getFee(this.provider, router, destChainSelector, populatedMessage)
}
Expand Down Expand Up @@ -592,7 +574,7 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
async sendMessage(opts: Parameters<Chain['sendMessage']>[0]): Promise<CCIPRequest> {
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({
Expand Down Expand Up @@ -654,7 +636,7 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
async execute(opts: Parameters<Chain['execute']>[0]): Promise<CCIPExecution> {
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({
Expand Down Expand Up @@ -734,7 +716,7 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
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 }),
Expand Down
15 changes: 0 additions & 15 deletions ccip-sdk/src/canton/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,21 +434,6 @@ export class CantonChain extends Chain<typeof ChainFamily.Canton> {
throw new CCIPNotImplementedError('CantonChain.getOnRampForRouter')
}

/**
* {@inheritDoc Chain.getCommitStoreForOffRamp}
*/
async getCommitStoreForOffRamp(offRamp: string): Promise<string> {
return Promise.resolve(offRamp)
}

/**
* {@inheritDoc Chain.getTokenForTokenPool}
* @throws {@link CCIPNotImplementedError} always (not yet implemented for Canton)
*/
getTokenForTokenPool(_tokenPool: string): Promise<string> {
throw new CCIPNotImplementedError('CantonChain.getTokenForTokenPool')
}

/**
* Returns token symbol and decimals for the given Canton fee token.
*
Expand Down
Loading
Loading