diff --git a/ccip-cli/src/commands/manual-exec.ts b/ccip-cli/src/commands/manual-exec.ts index e0e59360..4ae59af0 100644 --- a/ccip-cli/src/commands/manual-exec.ts +++ b/ccip-cli/src/commands/manual-exec.ts @@ -31,7 +31,7 @@ import { estimateReceiveExecution, isSupportedTxHash, } from '@chainlink/ccip-sdk/src/index.ts' -import { isHexString } from 'ethers' +import { hexlify, isHexString } from 'ethers' import type { Argv } from 'yargs' import type { GlobalOpts } from '../index.ts' @@ -44,7 +44,7 @@ import { selectRequest, withDateTimestamp, } from './utils.ts' -import { fetchChainsFromRpcs, loadChainWallet } from '../providers/index.ts' +import { fetchChainsFromRpcs, loadChainWallet, resolveIndexer } from '../providers/index.ts' // const MAX_QUEUE = 1000 // const MAX_EXECS_IN_BATCH = 1 @@ -118,6 +118,11 @@ export const builder = (yargs: Argv) => string: true, example: '--receiver-object-ids 0xabc... 0xdef...', }, + receiver: { + type: 'string', + describe: + 'Canton destination: CCIPReceiver contract ID, party ID (hint::1220…), or keccak256(party) from the message receiver field. Defaults to the message receiver when executing on Canton.', + }, }) /** @@ -205,8 +210,10 @@ async function manualExec( let inputs if (source) { offRamp ??= await discoverOffRamp(source, dest, request.lane.onRamp, source) + const indexer = resolveIndexer(argv, dest, logger, source) const verifications = await dest.getVerifications({ ...argv, + indexer, offRamp, request, }) @@ -277,10 +284,16 @@ async function manualExec( 'on network', dest.network.name, ) + const messageReceiver = + typeof request.message.receiver === 'string' + ? request.message.receiver + : hexlify(request.message.receiver) + const receipt = await dest.execute({ ...argv, wallet, ...(inputs ?? { messageId: request.message.messageId }), + receiver: argv.receiver ?? messageReceiver, }) switch (argv.format) { diff --git a/ccip-cli/src/commands/send.ts b/ccip-cli/src/commands/send.ts index 6b2ba1ad..0533474a 100644 --- a/ccip-cli/src/commands/send.ts +++ b/ccip-cli/src/commands/send.ts @@ -41,7 +41,7 @@ import type { GlobalOpts } from '../index.ts' import { showRequests } from './show.ts' import { type Ctx, Format } from './types.ts' import { getCtx, logParsedError, parseTokenAmounts } from './utils.ts' -import { fetchChainsFromRpcs, loadChainWallet } from '../providers/index.ts' +import { fetchChainsFromRpcs, loadChainWallet, resolveRouter } from '../providers/index.ts' export const command = 'send' export const describe = 'Send a CCIP message from source to destination chain' @@ -68,8 +68,8 @@ export const builder = (yargs: Argv) => .option('router', { alias: 'r', type: 'string', - demandOption: true, - describe: 'Router contract address on source', + describe: + 'Router contract address on EVM source, or CCIPSender instance id on Canton source (defaults to canton-config senderInstanceId)', }) .options({ receiver: { @@ -227,9 +227,19 @@ async function sendMessage( const { output, logger } = ctx const sourceNetwork = networkInfo(argv.source) const destNetwork = networkInfo(argv.dest) + const router = resolveRouter(argv, sourceNetwork, logger) + if (!router) { + throw new CCIPArgumentInvalidError( + 'router', + sourceNetwork.family === ChainFamily.Canton + ? 'required on Canton source: pass -r or set senderInstanceId in canton-config' + : 'required: pass -r with the source router contract address', + ) + } + const getChain = fetchChainsFromRpcs(ctx, argv) const source = await getChain(sourceNetwork.name) - decodeAddress(argv.router, sourceNetwork.family) + decodeAddress(router, sourceNetwork.family) let data: BytesLike | undefined if (argv.data) { @@ -319,7 +329,7 @@ async function sendMessage( const estimated = await estimateReceiveExecution({ source, dest, - routerOrRamp: argv.router, + routerOrRamp: router, message: { sender: walletAddress, receiver, @@ -378,7 +388,7 @@ async function sendMessage( feeToken = (source.constructor as ChainStatic).getAddress(argv.feeToken) feeTokenInfo = await source.getTokenInfo(feeToken) } catch (_) { - const feeTokens = await source.getFeeTokens(argv.router) + const feeTokens = await source.getFeeTokens(router) logger.debug('supported feeTokens:', feeTokens) for (const [token, info] of Object.entries(feeTokens)) { if (info.symbol === 'UNKNOWN' || info.symbol !== argv.feeToken) continue @@ -389,7 +399,7 @@ async function sendMessage( if (!feeTokenInfo) throw new CCIPTokenNotFoundError(argv.feeToken) } } else { - const nativeToken = await source.getNativeTokenForRouter(argv.router) + const nativeToken = await source.getNativeTokenForRouter(router) feeTokenInfo = await source.getTokenInfo(nativeToken) } @@ -404,6 +414,7 @@ async function sendMessage( // calculate fee const fee = await source.getFee({ ...argv, + router, destChainSelector: destNetwork.chainSelector, message, }) @@ -460,6 +471,7 @@ async function sendMessage( const request = await source.sendMessage({ ...argv, + router, destChainSelector: destNetwork.chainSelector, message: { ...message, fee }, wallet, @@ -474,11 +486,15 @@ async function sendMessage( ', messageId =>', request.message.messageId, ) - await showRequests(ctx, { - ...argv, - txHashOrId: request.tx.hash, - 'tx-hash-or-id': request.tx.hash, - 'log-index': undefined, - logIndex: undefined, - }) + await showRequests( + ctx, + { + ...argv, + txHashOrId: request.tx.hash, + 'tx-hash-or-id': request.tx.hash, + 'log-index': undefined, + logIndex: undefined, + }, + { request }, + ) } diff --git a/ccip-cli/src/commands/show.ts b/ccip-cli/src/commands/show.ts index 81e41fe6..20148170 100644 --- a/ccip-cli/src/commands/show.ts +++ b/ccip-cli/src/commands/show.ts @@ -20,7 +20,10 @@ */ import { + type CCIPRequest, type Chain, + type ChainGetter, + type ChainTransaction, CCIPAPIClient, CCIPExecTxRevertedError, CCIPMessageIdNotFoundError, @@ -46,7 +49,7 @@ import { selectRequest, withDateTimestamp, } from './utils.ts' -import { fetchChainsFromRpcs } from '../providers/index.ts' +import { fetchChainsFromRpcs, resolveIndexer } from '../providers/index.ts' export const command = ['show ', '* '] export const describe = 'Show details of a CCIP request' @@ -92,8 +95,13 @@ export async function handler(argv: Awaited['argv']> /** * Show details of a request. + * When `opts.request` is set (e.g. after `send`), skip re-fetching the source tx from RPC. */ -export async function showRequests(ctx: Ctx, argv: Parameters[0]) { +export async function showRequests( + ctx: Ctx, + argv: Parameters[0], + opts?: { request?: CCIPRequest }, +) { const { output, logger } = ctx // In JSON mode, accumulate all output into a single envelope so JSON.parse(stdout) works. @@ -116,41 +124,58 @@ export async function showRequests(ctx: Ctx, argv: Parameters[0] } } - const [getChain, tx$] = fetchChainsFromRpcs(ctx, argv, argv.txHashOrId) + let getChain: ChainGetter + let tx$: Promise<[Chain, ChainTransaction]> | undefined + if (opts?.request) { + getChain = fetchChainsFromRpcs(ctx, argv) + } else { + const chainsResult = fetchChainsFromRpcs(ctx, argv, argv.txHashOrId) + getChain = chainsResult[0] + tx$ = chainsResult[1] + } let source: Chain | undefined, offRamp // Track if we displayed all messages in non-interactive multi-message path let displayedAllMessages = false as boolean - let request$ = (async () => { - const [source_, tx] = await tx$ - source = source_ - const messages = await source_.getMessagesInTx(tx) + let request$ = opts?.request + ? (async () => { + try { + source = await getChain(opts.request!.lane.sourceChainSelector) + } catch (err) { + logger.debug('Failed to resolve source chain for prebuilt request:', err) + } + return opts.request! + })() + : (async () => { + const [source_, tx] = await tx$! + source = source_ + const messages = await source_.getMessagesInTx(tx) - // Non-interactive multi-message path: display all messages and signal early return - if (argv.interactive === false && argv.logIndex == null && messages.length > 1) { - switch (argv.format) { - case Format.log: - for (const req of messages) { - output.write(`message ${req.log.index} =`, withDateTimestamp(req)) - } - break - case Format.pretty: - for (const req of messages) { - await prettyRequest.call(ctx, req, source) + // Non-interactive multi-message path: display all messages and signal early return + if (argv.interactive === false && argv.logIndex == null && messages.length > 1) { + switch (argv.format) { + case Format.log: + for (const req of messages) { + output.write(`message ${req.log.index} =`, withDateTimestamp(req)) + } + break + case Format.pretty: + for (const req of messages) { + await prettyRequest.call(ctx, req, source) + } + break + case Format.json: + output.write(JSON.stringify({ requests: messages }, bigIntReplacer, 2)) + break } - break - case Format.json: - output.write(JSON.stringify({ requests: messages }, bigIntReplacer, 2)) - break - } - logger.info('Use --log-index N for full details on a specific message.') - displayedAllMessages = true - return messages[0]! // return first to satisfy type; caller checks displayedAllMessages - } + logger.info('Use --log-index N for full details on a specific message.') + displayedAllMessages = true + return messages[0]! // return first to satisfy type; caller checks displayedAllMessages + } - return selectRequest(messages, 'to know more', argv) - })() + return selectRequest(messages, 'to know more', argv) + })() if (argv.api !== false && isHexString(argv.txHashOrId, 32)) { const apiClient = CCIPAPIClient.fromUrl( @@ -258,7 +283,7 @@ export async function showRequests(ctx: Ctx, argv: Parameters[0] output.write('Commit (dest):') })() - let dest: Chain | undefined + let dest!: Chain try { dest = await getChain(request.lane.destChainSelector) } catch (err) { @@ -293,10 +318,12 @@ export async function showRequests(ctx: Ctx, argv: Parameters[0] cancelWaitVerifications = ac.abort.bind(ac) } verifications$ = (async () => { + const indexer = resolveIndexer(argv, dest, logger, source) const verifications = await dest.getVerifications({ offRamp, request, ...argv, + indexer, watch, }) cancelWaitFinalized?.() diff --git a/ccip-cli/src/commands/utils.ts b/ccip-cli/src/commands/utils.ts index 36d94338..91d27fe5 100644 --- a/ccip-cli/src/commands/utils.ts +++ b/ccip-cli/src/commands/utils.ts @@ -156,6 +156,9 @@ async function formatToken( | { sourceTokenAddress?: string; sourcePoolAddress: string } ), ): Promise { + if ('token' in ta && !ta.token) { + return ta.amount === 0n ? '0 (paid on-ledger)' : `${ta.amount} (on-ledger)` + } if (!source) return `${ta.amount} ${'sourcePoolAddress' in ta ? ta.sourcePoolAddress : ta.token}` let token if ('token' in ta) token = ta.token diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 3e1bc4de..90f1403a 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -28,7 +28,7 @@ Error.stackTraceLimit = 50 // show more stack frames for better debugging // generate:nofail // `const VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -const VERSION = '1.9.2-ae443e7' +const VERSION = '1.9.2-9e31827' // generate:end const require = createRequire(import.meta.url) diff --git a/ccip-cli/src/providers/canton.ts b/ccip-cli/src/providers/canton.ts index fa30e1ef..8746746a 100644 --- a/ccip-cli/src/providers/canton.ts +++ b/ccip-cli/src/providers/canton.ts @@ -189,10 +189,52 @@ export function loadCantonConfig( } } + if (parsed['chainId'] != null) { + if (typeof parsed['chainId'] !== 'string' || !parsed['chainId'].length) { + throw new Error('Canton config: "chainId" must be a non-empty string if provided') + } + } + logger?.debug('Loaded Canton config from', configPath, 'for party', parsed['party']) return parsed as unknown as CantonConfig } +/** + * CCIP v2 indexer URLs for verification lookups. + * CLI `--indexer` wins when provided; otherwise uses canton-config `indexerUrl` + * only when the lane involves Canton (EVM-only lanes keep default indexer behavior). + * Prefer {@link resolveIndexer} from `./index.ts` in CLI commands. + */ +export function resolveCliIndexer( + cliIndexer: readonly string[] | undefined, + cantonConfig: Partial | undefined, + laneInvolvesCanton: boolean, +): readonly string[] | undefined { + if (cliIndexer?.length) return cliIndexer + if (!laneInvolvesCanton) return undefined + const url = cantonConfig?.indexerUrl?.trim() + return url ? [url] : undefined +} + +/** + * Router / sender instance id for `ccip-cli send -r`. + * On Canton source lanes this is the CCIPSender instance id (e.g. `prod-ccipsender`); + * on EVM it must be the router contract address. CLI `-r` wins when set. + * Prefer {@link resolveRouter} from `./index.ts` in CLI commands. + */ +export function resolveCliRouter( + cliRouter: string | undefined, + cantonConfig: Partial | undefined, + sourceIsCanton: boolean, +): string | undefined { + if (cliRouter?.trim()) return cliRouter.trim() + if (sourceIsCanton) { + const fromConfig = cantonConfig?.senderInstanceId?.trim() + if (fromConfig) return fromConfig + } + return cliRouter +} + /** * Resolve a Canton wallet from CLI argv. * diff --git a/ccip-cli/src/providers/index.test.ts b/ccip-cli/src/providers/index.test.ts index 1b6fd682..1fe7682b 100644 --- a/ccip-cli/src/providers/index.test.ts +++ b/ccip-cli/src/providers/index.test.ts @@ -10,7 +10,13 @@ import { supportedChains, } from '@chainlink/ccip-sdk/src/index.ts' -import { fetchChainsFromRpcs } from './index.ts' +import { resolveCliIndexer, resolveCliRouter } from './canton.ts' +import { + fetchChainsFromRpcs, + filterEndpointsForFamily, + isCantonLedgerUrl, + resolveRouter, +} from './index.ts' import type { Ctx } from '../commands/index.ts' // --------------------------------------------------------------------------- @@ -561,3 +567,101 @@ describe('fetchChainsFromRpcs', () => { } }) }) + +describe('isCantonLedgerUrl', () => { + it('matches Canton JSON Ledger API paths', () => { + assert.ok(isCantonLedgerUrl('https://testnet.cv1.bcy-v.metalhosts.com/api/json')) + assert.ok(isCantonLedgerUrl('http://localhost:7575')) + }) + + it('does not match EVM JSON-RPC URLs', () => { + assert.ok(!isCantonLedgerUrl('https://ethereum-sepolia-rpc.publicnode.com')) + assert.ok(!isCantonLedgerUrl('https://rpcs.cldev.sh/ethereum/sepolia')) + }) +}) + +describe('filterEndpointsForFamily', () => { + const endpoints = new Set([ + 'https://testnet.cv1.bcy-v.metalhosts.com/api/json', + 'https://ethereum-sepolia-rpc.publicnode.com', + ]) + + it('gives Canton only ledger URLs', () => { + const filtered = filterEndpointsForFamily(endpoints, ChainFamily.Canton) + assert.deepEqual([...filtered], ['https://testnet.cv1.bcy-v.metalhosts.com/api/json']) + }) + + it('gives EVM only non-ledger URLs', () => { + const filtered = filterEndpointsForFamily(endpoints, ChainFamily.EVM) + assert.deepEqual([...filtered], ['https://ethereum-sepolia-rpc.publicnode.com']) + }) +}) + +describe('resolveRouter', () => { + const cantonSource = networkInfo('canton-testnet') + const evmSource = networkInfo('ethereum-testnet-sepolia') + + it('returns explicit CLI -r for any source family', () => { + assert.equal(resolveRouter({ router: '0xRouterAddress' }, evmSource), '0xRouterAddress') + assert.equal(resolveRouter({ router: 'prod-ccipsender' }, cantonSource), 'prod-ccipsender') + }) + + it('returns undefined for EVM source without -r', () => { + assert.equal(resolveRouter({}, evmSource), undefined) + }) +}) + +describe('resolveCliIndexer', () => { + const configIndexer = 'https://indexer-1.testnet.ccip.chain.link' + + it('prefers explicit CLI --indexer values', () => { + assert.deepEqual( + resolveCliIndexer(['https://cli-indexer'], { indexerUrl: configIndexer }, true), + ['https://cli-indexer'], + ) + }) + + it('falls back to canton-config indexerUrl when the lane involves Canton', () => { + assert.deepEqual(resolveCliIndexer(undefined, { indexerUrl: configIndexer }, true), [ + configIndexer, + ]) + }) + + it('does not fall back to canton-config indexerUrl for EVM-only lanes', () => { + assert.equal(resolveCliIndexer(undefined, { indexerUrl: configIndexer }, false), undefined) + }) + + it('returns undefined when neither CLI nor config provides an indexer', () => { + assert.equal(resolveCliIndexer(undefined, undefined, true), undefined) + }) +}) + +describe('resolveCliRouter', () => { + it('prefers explicit CLI -r on Canton source', () => { + assert.equal( + resolveCliRouter('cli-sender', { senderInstanceId: 'prod-ccipsender' }, true), + 'cli-sender', + ) + }) + + it('prefers explicit CLI -r on EVM source even when canton-config has senderInstanceId', () => { + assert.equal( + resolveCliRouter('0xRouterAddress', { senderInstanceId: 'prod-ccipsender' }, false), + '0xRouterAddress', + ) + }) + + it('falls back to canton-config senderInstanceId for Canton source', () => { + assert.equal( + resolveCliRouter(undefined, { senderInstanceId: 'prod-ccipsender' }, true), + 'prod-ccipsender', + ) + }) + + it('does not fall back to senderInstanceId for EVM source', () => { + assert.equal( + resolveCliRouter(undefined, { senderInstanceId: 'prod-ccipsender' }, false), + undefined, + ) + }) +}) diff --git a/ccip-cli/src/providers/index.ts b/ccip-cli/src/providers/index.ts index a365ae7e..350cafdb 100644 --- a/ccip-cli/src/providers/index.ts +++ b/ccip-cli/src/providers/index.ts @@ -7,6 +7,7 @@ import { type ChainTransaction, type EVMChain, type Logger, + type NetworkInfo, type TONChain, CCIPChainFamilyUnsupportedError, CCIPRpcNotFoundError, @@ -19,7 +20,12 @@ import { } from '@chainlink/ccip-sdk/src/index.ts' import { loadAptosWallet } from './aptos.ts' -import { loadCantonConfig, loadCantonWallet } from './canton.ts' +import { + loadCantonConfig, + loadCantonWallet, + resolveCliIndexer, + resolveCliRouter, +} from './canton.ts' import { loadEvmWallet } from './evm.ts' import { loadSolanaWallet } from './solana.ts' import { loadSuiWallet } from './sui.ts' @@ -30,6 +36,69 @@ import type { GlobalOpts } from '../index.ts' const RPCS_RE = /\b(?:http|ws)s?:\/\/[\w/\\@&?%~#.,;:=+-]+/ type FetchGlobalArgs = Partial> +/** + * True for Canton JSON Ledger API base URLs (not EVM/JSON-RPC endpoints). + * Used to avoid racing every `--rpc` against every chain family. + */ +export function isCantonLedgerUrl(url: string): boolean { + try { + const { pathname, port } = new URL(url) + if (/\/api\/json\/?$/i.test(pathname)) return true + if (/\/api\/ledger\/?$/i.test(pathname)) return true + if (port === '7575') return true + } catch { + return false + } + return false +} + +/** Keep only endpoints that plausibly belong to the requested chain family. */ +export function filterEndpointsForFamily(endpoints: Set, family: ChainFamily): Set { + const urls = [...endpoints] + if (family === ChainFamily.Canton) { + return new Set(urls.filter(isCantonLedgerUrl)) + } + return new Set(urls.filter((url) => !isCantonLedgerUrl(url))) +} + +type CantonCliArgs = Partial> + +/** CLI argv fields used by {@link resolveRouter}. */ +export type ResolveRouterArgs = CantonCliArgs & { router?: string } + +/** CLI argv fields used by {@link resolveIndexer}. */ +export type ResolveIndexerArgs = CantonCliArgs & { indexer?: string[] } + +/** + * Resolve `ccip-cli send -r` for the source chain. + * Canton source: CCIPSender instance id (CLI or canton-config `senderInstanceId`). + * EVM source: router contract address (CLI only). + */ +export function resolveRouter( + argv: ResolveRouterArgs, + sourceNetwork: NetworkInfo, + logger?: Logger, +): string | undefined { + const cantonConfig = loadCantonConfig(argv.cantonConfig, logger) + return resolveCliRouter(argv.router, cantonConfig, sourceNetwork.family === ChainFamily.Canton) +} + +/** + * Resolve CCIP v2 indexer URLs for verification lookups on manual-exec / show. + * CLI `--indexer` wins; canton-config `indexerUrl` applies only when the lane involves Canton. + */ +export function resolveIndexer( + argv: ResolveIndexerArgs, + dest: Chain, + logger?: Logger, + source?: Chain, +): readonly string[] | undefined { + const cantonConfig = loadCantonConfig(argv.cantonConfig, logger) + const laneInvolvesCanton = + dest.network.family === ChainFamily.Canton || source?.network.family === ChainFamily.Canton + return resolveCliIndexer(argv.indexer, cantonConfig, laneInvolvesCanton) +} + /** * Collects RPC endpoints URLs in rpcs array, rpcsFile an `RPC_` env vars, and returns a Set of unique endpoints * @param this - Context object containing abort signal and logger properties @@ -97,11 +166,18 @@ export function fetchChainsFromRpcs(ctx: Ctx, argv: FetchGlobalArgs, txHash?: st const C = supportedChains[F] if (!C) throw new CCIPChainFamilyUnsupportedError(F) ctx.abort.throwIfAborted() - ctx.logger.debug('Racing', endpoints.size, 'RPC endpoints for', F) + const familyEndpoints = filterEndpointsForFamily(endpoints, F) + ctx.logger.debug( + 'Racing', + familyEndpoints.size, + 'RPC endpoints for', + F, + familyEndpoints.size < endpoints.size ? `(filtered from ${endpoints.size})` : '', + ) const chains$: Promise[] = [] const txOnlyRacers = new WeakSet() - for (const url of endpoints) { + for (const url of familyEndpoints) { const chain$ = C.fromUrl(url, { ...ctx, abort: ctx.abort, diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index 80f1b9d1..d95443e6 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -61,7 +61,7 @@ export const DEFAULT_TIMEOUT_MS = 30000 /** SDK version string for telemetry header */ // generate:nofail // `export const SDK_VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -export const SDK_VERSION = '1.9.2-ae443e7' +export const SDK_VERSION = '1.9.2-9e31827' // generate:end /** SDK telemetry header name */ diff --git a/ccip-sdk/src/canton/ccv-addresses.test.ts b/ccip-sdk/src/canton/ccv-addresses.test.ts new file mode 100644 index 00000000..ef512d17 --- /dev/null +++ b/ccip-sdk/src/canton/ccv-addresses.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { id as keccak256Utf8 } from 'ethers' + +import { + ccvAddressesMatch, + missingTokenPoolRequiredCcvs, + normalizeCantonCcvList, + receiverRequiresConfiguredCcvs, + resolveEdsCcvAddress, + resolveSenderRequiredCcvs, +} from './ccv-addresses.ts' + +const EXECUTE_CCV_RAW = + 'committeeverifier-tqkny@ccvOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551' +const EXECUTE_CCV_HEX = keccak256Utf8(EXECUTE_CCV_RAW) + +describe('canton/ccv-addresses', () => { + it('normalizeCantonCcvList trims and drops empty entries', () => { + assert.deepEqual(normalizeCantonCcvList([' 0xabc ', '', '0xdef']), ['0xabc', '0xdef']) + }) + + it('ccvAddressesMatch links raw unpack form to hashed InstanceAddress', () => { + assert.equal(ccvAddressesMatch(EXECUTE_CCV_RAW, EXECUTE_CCV_HEX), true) + }) + + it('receiverRequiresConfiguredCcvs matches when any configured CCV overlaps', () => { + assert.equal(receiverRequiresConfiguredCcvs([EXECUTE_CCV_RAW], [EXECUTE_CCV_HEX]), true) + assert.equal( + receiverRequiresConfiguredCcvs( + [EXECUTE_CCV_RAW], + ['0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'], + ), + false, + ) + }) + + it('resolveEdsCcvAddress keeps indexer dest when it matches configured ccvs', () => { + assert.equal(resolveEdsCcvAddress(EXECUTE_CCV_HEX, [EXECUTE_CCV_HEX]), EXECUTE_CCV_HEX) + }) + + it('resolveEdsCcvAddress falls back to first configured CCV when indexer dest differs', () => { + assert.equal( + resolveEdsCcvAddress('committeeverifier-tqkny@ccvOwner::1220e382…', [EXECUTE_CCV_HEX]), + EXECUTE_CCV_HEX, + ) + }) + + it('missingTokenPoolRequiredCcvs accepts configured execute CCV for token pool requirement', () => { + const sepoliaResolver = '0x8f3ee3c77D2B27c32306a89D367654F959Db223D' + assert.deepEqual( + missingTokenPoolRequiredCcvs([EXECUTE_CCV_RAW], [sepoliaResolver], [EXECUTE_CCV_HEX]), + [], + ) + }) + + it('missingTokenPoolRequiredCcvs reports uncovered required CCVs', () => { + assert.deepEqual( + missingTokenPoolRequiredCcvs( + [EXECUTE_CCV_RAW], + ['0x8f3ee3c77D2B27c32306a89D367654F959Db223D'], + [], + ), + [EXECUTE_CCV_RAW], + ) + }) + + it('resolveSenderRequiredCcvs prefers explicit CLI ccvRawAddresses', () => { + const cli = ['0xcli'] + assert.deepEqual(resolveSenderRequiredCcvs(cli, [EXECUTE_CCV_HEX]), cli) + }) + + it('resolveSenderRequiredCcvs falls back to configured ccvs when CLI omits ccvRawAddresses', () => { + assert.deepEqual(resolveSenderRequiredCcvs(undefined, [EXECUTE_CCV_HEX]), [EXECUTE_CCV_HEX]) + }) + + it('resolveSenderRequiredCcvs honors explicit empty CLI ccvRawAddresses', () => { + assert.deepEqual(resolveSenderRequiredCcvs([], [EXECUTE_CCV_HEX]), []) + }) +}) diff --git a/ccip-sdk/src/canton/ccv-addresses.ts b/ccip-sdk/src/canton/ccv-addresses.ts new file mode 100644 index 00000000..dedb554a --- /dev/null +++ b/ccip-sdk/src/canton/ccv-addresses.ts @@ -0,0 +1,87 @@ +import { hashedUtf8Hex, normalizeHex } from '../utils.ts' + +/** keccak256 of a RawInstanceAddress.unpack string → InstanceAddress hex. */ +export function hashedRawInstanceAddress(raw: string): string { + return hashedUtf8Hex(raw) +} + +/** Normalize canton-config `ccvs` to a trimmed, non-empty list. */ +export function normalizeCantonCcvList(ccvs?: readonly string[]): string[] { + if (!ccvs?.length) return [] + return ccvs.map((ccv) => ccv.trim()).filter(Boolean) +} + +/** Whether two CCV references denote the same InstanceAddress (hex or raw unpack form). */ +export function ccvAddressesMatch(a: string, b: string): boolean { + const left = a.trim() + const right = b.trim() + if (!left || !right) return false + if (normalizeHex(left) === normalizeHex(right)) return true + if (left.includes('@') && normalizeHex(right) === hashedRawInstanceAddress(left)) return true + if (right.includes('@') && normalizeHex(left) === hashedRawInstanceAddress(right)) return true + return false +} + +/** True when any receiver required CCV matches any configured execute CCV. */ +export function receiverRequiresConfiguredCcvs( + requiredCCVs: readonly string[], + configuredCcvs: readonly string[], +): boolean { + if (!configuredCcvs.length) return false + return requiredCCVs.some((required) => + configuredCcvs.some((configured) => ccvAddressesMatch(required, configured)), + ) +} + +/** + * Resolve which CCV address to pass to EDS for execute disclosures. + * Uses the indexer dest address when it already matches a configured CCV; + * otherwise falls back to the first configured CCV (indexer raw addresses often 404 on EDS). + */ +export function resolveEdsCcvAddress( + indexerDestAddress: string, + configuredCcvs: readonly string[], +): string { + if (!configuredCcvs.length) return indexerDestAddress + for (const configured of configuredCcvs) { + if (ccvAddressesMatch(indexerDestAddress, configured)) return configured + } + return configuredCcvs[0]! +} + +/** + * CCV addresses for Canton send EDS (`senderRequiredCCVs`). + * Explicit `extraArgs.ccvRawAddresses` (e.g. CLI `-x ccvRawAddresses=…`) wins; + * otherwise falls back to canton-config `ccvs`. + */ +export function resolveSenderRequiredCcvs( + cliCcvRawAddresses: readonly string[] | undefined, + configuredCcvs: readonly string[], +): string[] { + if (cliCcvRawAddresses !== undefined) return [...cliCcvRawAddresses] + return [...configuredCcvs] +} + +/** + * Token pool execute disclosures declare required CCVs (often the Canton CommitteeVerifier). + * Message verifications carry send-side CCV results (e.g. Sepolia resolver); canton-config + * `ccvs` overrides which CCV EDS address to use at execute — same as Go ccvOverride. + * Returns required CCV addresses not satisfied by verifications or configured execute CCVs. + */ +export function missingTokenPoolRequiredCcvs( + required: readonly string[], + verificationDestAddresses: readonly string[], + configuredCcvs: readonly string[], +): string[] { + const isCovered = (requiredCcv: string): boolean => { + if (configuredCcvs.some((configured) => ccvAddressesMatch(requiredCcv, configured))) { + return true + } + return verificationDestAddresses.some( + (dest) => + ccvAddressesMatch(requiredCcv, dest) || + ccvAddressesMatch(requiredCcv, resolveEdsCcvAddress(dest, configuredCcvs)), + ) + } + return required.filter((address) => !isCovered(address)) +} diff --git a/ccip-sdk/src/canton/client/client.ts b/ccip-sdk/src/canton/client/client.ts index 620ef551..f18d850a 100644 --- a/ccip-sdk/src/canton/client/client.ts +++ b/ccip-sdk/src/canton/client/client.ts @@ -225,6 +225,80 @@ export function createCantonClient(config: CantonClientConfig) { return data.connectedSynchronizers ?? [] }, + /** List package IDs supported by this participant (for prepare submission). */ + async listPackageIds(): Promise { + const data = await get2<{ packageIds?: string[] }>('/v2/packages') + return data.packageIds ?? [] + }, + + /** List vetted packages for a synchronizer (maps package IDs to names). */ + async listVettedPackages( + synchronizerId: string, + ): Promise> { + const packages: Array<{ packageId: string; packageName: string; packageVersion: string }> = [] + let pageToken = '' + do { + const data = await post2<{ + vettedPackages?: Array<{ + packages?: Array<{ + packageId?: string + packageName?: string + packageVersion?: string + }> + }> + nextPageToken?: string + }>( + '/v2/package-vetting', + { + topologyStateFilter: { synchronizerIds: [synchronizerId] }, + pageToken, + pageSize: 500, + }, + undefined, + 1, + ) + for (const group of data.vettedPackages ?? []) { + for (const pkg of group.packages ?? []) { + if (pkg.packageId && pkg.packageName) { + packages.push({ + packageId: pkg.packageId, + packageName: pkg.packageName, + packageVersion: pkg.packageVersion ?? '', + }) + } + } + } + pageToken = data.nextPageToken ?? '' + } while (pageToken) + return packages + }, + + /** Resolve one vetted package ID per package name for interactive submission. */ + async getPreferredPackageIds( + actAs: string[], + packageNames: string[], + synchronizerId: string, + ): Promise { + if (packageNames.length === 0) return [] + const data = await post2<{ + packageReferences?: Array<{ packageId?: string }> + }>( + '/v2/interactive-submission/preferred-packages', + { + packageVettingRequirements: packageNames.map((packageName) => ({ + parties: actAs, + packageName, + })), + synchronizerId, + }, + undefined, + 1, + ) + return (data.packageReferences ?? []) + .map((ref) => ref.packageId) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + }, + /** * Check if the ledger API is alive */ @@ -252,20 +326,21 @@ export function createCantonClient(config: CantonClientConfig) { }, /** - * Fetch a transaction by its update ID without requiring a known party. - * Uses `filtersForAnyParty` with a wildcard so all visible events are returned. + * Fetch a transaction by its update ID. + * + * When `party` is provided, events are scoped via `filtersByParty` (works on + * restricted ledgers). Without a party, falls back to `filtersForAnyParty` + * which may be denied with HTTP 403 on production participant nodes. + * * @param updateId - The update ID (Canton transaction hash) + * @param party - Optional party ID to scope visible events * @returns The full `JsTransaction` including all events */ - async getTransactionById(updateId: string): Promise { - const response = await post2<{ transaction: JsTransaction }>( - '/v2/updates/transaction-by-id', - { - updateId, - transactionFormat: { - eventFormat: { - filtersByParty: {}, - filtersForAnyParty: { + async getTransactionById(updateId: string, party?: string): Promise { + const eventFormat = party + ? { + filtersByParty: { + [party]: { cumulative: [ { identifierFilter: { @@ -276,8 +351,31 @@ export function createCantonClient(config: CantonClientConfig) { }, ], }, - verbose: true, }, + verbose: true, + } + : { + filtersByParty: {}, + filtersForAnyParty: { + cumulative: [ + { + identifierFilter: { + WildcardFilter: { + value: { includeCreatedEventBlob: false }, + }, + }, + }, + ], + }, + verbose: true, + } + + const response = await post2<{ transaction: JsTransaction }>( + '/v2/updates/transaction-by-id', + { + updateId, + transactionFormat: { + eventFormat, transactionShape: 'TRANSACTION_SHAPE_LEDGER_EFFECTS', }, }, @@ -572,15 +670,17 @@ async function request( if (response.status < 200 || response.status >= 300) { const errorBody = response.data ?? `HTTP ${response.status}` - if (attempt < retries) { - console.log( - `[canton/client] ${method} ${path} failed with status ${response.status} (attempt ${attempt}/${retries}), retrying in ${retryDelayMs}ms:`, - errorBody, - ) - await new Promise((r) => setTimeout(r, retryDelayMs)) - continue + const isClientError = + response.status >= 400 && response.status < 500 && response.status !== 429 + if (isClientError || attempt >= retries) { + throw new CantonApiError(`${method} ${path} failed`, errorBody, response.status) } - throw new CantonApiError(`${method} ${path} failed`, errorBody, response.status) + console.log( + `[canton/client] ${method} ${path} failed with status ${response.status} (attempt ${attempt}/${retries}), retrying in ${retryDelayMs}ms:`, + errorBody, + ) + await new Promise((r) => setTimeout(r, retryDelayMs)) + continue } const contentLength = response.headers['content-length'] diff --git a/ccip-sdk/src/canton/events.ts b/ccip-sdk/src/canton/events.ts index 2a579a01..16f1c3bd 100644 --- a/ccip-sdk/src/canton/events.ts +++ b/ccip-sdk/src/canton/events.ts @@ -28,6 +28,16 @@ export interface CantonSendResultFields { onRampAddress?: string } +/** + * Normalize a Canton `BytesHex` message ID to CCIP canonical form (`0x` + 64 hex). + * Daml stores message IDs without the prefix; EVM-facing CCIP tooling expects it. + */ +export function normalizeCantonMessageId(messageId: string): string { + if (/^0x[0-9a-fA-F]{64}$/.test(messageId)) return messageId + if (/^[0-9a-fA-F]{64}$/.test(messageId)) return `0x${messageId}` + return messageId +} + // --------------------------------------------------------------------------- // Top-level parsers // --------------------------------------------------------------------------- @@ -70,7 +80,9 @@ export function parseCantonSendResult( if (sentEvent) { return { - messageId: typeof sentEvent.messageId === 'string' ? sentEvent.messageId : updateId, + messageId: normalizeCantonMessageId( + typeof sentEvent.messageId === 'string' ? sentEvent.messageId : updateId, + ), encodedMessage: typeof sentEvent.encodedMessage === 'string' ? sentEvent.encodedMessage : '', sequenceNumber: toBigIntSafe(sentEvent.sequenceNumber), @@ -84,7 +96,9 @@ export function parseCantonSendResult( if (createArgs) { const flat = flattenCantonRecord(createArgs) return { - messageId: typeof flat.messageId === 'string' ? flat.messageId : updateId, + messageId: normalizeCantonMessageId( + typeof flat.messageId === 'string' ? flat.messageId : updateId, + ), encodedMessage: typeof flat.encodedMessage === 'string' ? flat.encodedMessage : '', sequenceNumber: toBigIntSafe(flat.sequenceNumber), nonce: flat.nonce != null ? toBigIntSafe(flat.nonce) : undefined, @@ -99,6 +113,86 @@ export function parseCantonSendResult( ) } +/** + * Like {@link parseCantonSendResult} but returns `undefined` instead of throwing + * when no `CCIPMessageSent` event is found. Also accepts a single created event + * (as returned per-log by {@link CantonChain.getTransaction}). + */ +export function tryParseCantonSendResult( + transaction: unknown, + updateId: string, +): CantonSendResultFields | undefined { + try { + return parseCantonSendResult(transaction, updateId) + } catch { + if (!transaction || typeof transaction !== 'object') return undefined + const rec = transaction as Record + if (getTemplateEntityName(rec) !== 'CCIPMessageSent') return undefined + + const createArgs = (rec.create_arguments ?? rec.createArgument) as + | Record + | undefined + const sentEvent = extractCCIPMessageSentEvent(createArgs) + + if (sentEvent) { + return { + messageId: normalizeCantonMessageId( + typeof sentEvent.messageId === 'string' ? sentEvent.messageId : updateId, + ), + encodedMessage: + typeof sentEvent.encodedMessage === 'string' ? sentEvent.encodedMessage : '', + sequenceNumber: toBigIntSafe(sentEvent.sequenceNumber), + nonce: sentEvent.nonce != null ? toBigIntSafe(sentEvent.nonce) : undefined, + onRampAddress: + typeof sentEvent.onRampAddress === 'string' ? sentEvent.onRampAddress : undefined, + } + } + + if (createArgs) { + const flat = flattenCantonRecord(createArgs) + return { + messageId: normalizeCantonMessageId( + typeof flat.messageId === 'string' ? flat.messageId : updateId, + ), + encodedMessage: typeof flat.encodedMessage === 'string' ? flat.encodedMessage : '', + sequenceNumber: toBigIntSafe(flat.sequenceNumber), + nonce: flat.nonce != null ? toBigIntSafe(flat.nonce) : undefined, + onRampAddress: typeof flat.onRampAddress === 'string' ? flat.onRampAddress : undefined, + } + } + + return undefined + } +} + +/** + * Extract the flat `CCIPMessageSentEvent` fields from a log's Canton event data. + */ +export function extractCantonSentEventFields(data: unknown): Record | undefined { + if (!data || typeof data !== 'object') return undefined + const rec = data as Record + if (getTemplateEntityName(rec) !== 'CCIPMessageSent') return undefined + const createArgs = (rec.create_arguments ?? rec.createArgument) as + | Record + | undefined + return extractCCIPMessageSentEvent(createArgs) +} + +/** Find `CCIPMessageSentEvent` fields in a log payload or full transaction tree. */ +export function extractCantonSentEventFieldsFromLogData( + data: unknown, +): Record | undefined { + const direct = extractCantonSentEventFields(data) + if (direct) return direct + for (const event of extractEventsFromTransaction(data)) { + if (event && typeof event === 'object') { + const fields = extractCantonSentEventFields(event) + if (fields) return fields + } + } + return undefined +} + /** * Walk a Canton transaction response and extract an {@link ExecutionReceipt}. * @@ -128,7 +222,7 @@ export function parseCantonExecutionReceipt( const srcChain = payload['sourceChainSelector'] const retData = payload['returnData'] return { - messageId: typeof msgId === 'string' ? msgId : updateId, + messageId: normalizeCantonMessageId(typeof msgId === 'string' ? msgId : updateId), sequenceNumber: toBigIntSafe(seqNum), state: mapExecutionState(payload['state']), sourceChainSelector: srcChain != null ? toBigIntSafe(srcChain) : undefined, @@ -139,7 +233,7 @@ export function parseCantonExecutionReceipt( // Fallback — the command completed successfully but we couldn't locate the // specific ExecutionStateChanged event (e.g. different event format). return { - messageId: updateId, + messageId: normalizeCantonMessageId(updateId), sequenceNumber: 0n, state: ExecutionState.Success, } diff --git a/ccip-sdk/src/canton/explicit-disclosures/acs.test.ts b/ccip-sdk/src/canton/explicit-disclosures/acs.test.ts index aa87ac91..61b8a96e 100644 --- a/ccip-sdk/src/canton/explicit-disclosures/acs.test.ts +++ b/ccip-sdk/src/canton/explicit-disclosures/acs.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' +import { id as keccak256Utf8 } from 'ethers' + import { AcsDisclosureProvider } from './acs.ts' import type { CantonClient, JsGetActiveContractsResponse } from '../client/index.ts' @@ -20,11 +22,24 @@ function makeAcsEntry( contractId: string, createdEventBlob: string, signatory: string, - opts?: { instanceId?: string; partyOwner?: string }, + opts?: { + instanceId?: string + partyOwner?: string + owner?: string + requiredCCVs?: string[] + receiverFinalityConfig?: { tag: string; value: unknown } + }, ): JsGetActiveContractsResponse { - const createArgument: Record = {} + const createArgument: Record = {} if (opts?.instanceId) createArgument['instanceId'] = opts.instanceId if (opts?.partyOwner) createArgument['partyOwner'] = opts.partyOwner + if (opts?.owner) createArgument['owner'] = opts.owner + if (opts?.requiredCCVs) { + createArgument['requiredCCVs'] = opts.requiredCCVs.map((unpack) => ({ unpack })) + } + if (opts?.receiverFinalityConfig) { + createArgument['receiverFinalityConfig'] = opts.receiverFinalityConfig + } return { contractEntry: { JsActiveContract: { @@ -61,12 +76,18 @@ const SENDER_CONTRACT_ID = 'sender-cid-003' const SENDER_BLOB = 'sender-blob' const SENDER_TEMPLATE_ID = 'pkg-sender:CCIP.CCIPSender:CCIPSender' +const EXECUTE_CCV_RAW = + 'committeeverifier-tqkny@ccvOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551' +const EXECUTE_CCV_HEX = keccak256Utf8(EXECUTE_CCV_RAW) +const OTHER_CCV_RAW = + 'other-ccv@ccvOwner::1220e382f4e57b0815e6be737006e381e6b7de448e06bd033ece6df498017879f551' const EXECUTOR_TEMPLATE_ID = 'pkg-executor:CCIP.Executor:Executor' - -/** ACS snapshot for the default execute scenario (router + receiver) */ const DEFAULT_ACS: JsGetActiveContractsResponse[] = [ makeAcsEntry(ROUTER_TEMPLATE_ID, ROUTER_CONTRACT_ID, ROUTER_BLOB, PARTY, { partyOwner: PARTY }), - makeAcsEntry(RECEIVER_TEMPLATE_ID, RECEIVER_CONTRACT_ID, RECEIVER_BLOB, PARTY), + makeAcsEntry(RECEIVER_TEMPLATE_ID, RECEIVER_CONTRACT_ID, RECEIVER_BLOB, PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + }), ] /** ACS snapshot for the default send scenario (router + sender, plus unrelated executor noise) */ @@ -209,4 +230,99 @@ describe('canton/acs', () => { 'should throw mentioning PerPartyRouter', ) }) + + it('resolveReceiverForExecute prefers receivers whose requiredCCVs match configured ccvs', async () => { + const matchingCid = 'matching-ccv-receiver-cid' + const entries = [ + makeAcsEntry(ROUTER_TEMPLATE_ID, ROUTER_CONTRACT_ID, ROUTER_BLOB, PARTY, { + partyOwner: PARTY, + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, 'empty-receiver-cid', 'empty-blob', PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, 'wrong-ccv-receiver-cid', 'wrong-blob', PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + requiredCCVs: [OTHER_CCV_RAW, OTHER_CCV_RAW], + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, matchingCid, 'matching-blob', PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + requiredCCVs: [EXECUTE_CCV_RAW], + }), + ] + + const provider = new AcsDisclosureProvider(makeStubClient(entries), { + party: PARTY, + ccvs: [EXECUTE_CCV_HEX], + }) + const resolved = await provider.resolveReceiverForExecute(1) + + assert.equal(resolved?.contractId, matchingCid) + }) + + it('resolveReceiverForExecute falls back to non-empty requiredCCVs when ccvs is unset', async () => { + const configuredCid = 'configured-receiver-cid' + const entries = [ + makeAcsEntry(ROUTER_TEMPLATE_ID, ROUTER_CONTRACT_ID, ROUTER_BLOB, PARTY, { + partyOwner: PARTY, + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, 'empty-receiver-cid', 'empty-blob', PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, configuredCid, 'configured-blob', PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + requiredCCVs: [OTHER_CCV_RAW], + }), + ] + + const provider = new AcsDisclosureProvider(makeStubClient(entries), { party: PARTY }) + const resolved = await provider.resolveReceiverForExecute(1) + + assert.equal(resolved?.contractId, configuredCid) + }) + + it('resolveReceiverForExecute resolves keccak256(party) message receiver hints', async () => { + const hashedParty = keccak256Utf8(PARTY) + const entries = [ + makeAcsEntry(ROUTER_TEMPLATE_ID, ROUTER_CONTRACT_ID, ROUTER_BLOB, PARTY, { + partyOwner: PARTY, + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, RECEIVER_CONTRACT_ID, RECEIVER_BLOB, PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + requiredCCVs: [EXECUTE_CCV_RAW], + }), + ] + + const provider = new AcsDisclosureProvider(makeStubClient(entries), { + party: PARTY, + ccvs: [EXECUTE_CCV_HEX], + }) + const resolved = await provider.resolveReceiverForExecute(1, hashedParty) + + assert.equal(resolved?.contractId, RECEIVER_CONTRACT_ID) + }) + + it('resolveReceiverForExecute resolves explicit contract IDs with 0x prefix', async () => { + const longReceiverCid = + '009d6a63b316ebffe5c495009c7dd9debf3a81cc05796f815b964e4ea09855d328ca1212205be120866d73817cef0ff776f558e0f6c3c14159567c0bc893484ad9b24375f5' + const entries = [ + makeAcsEntry(ROUTER_TEMPLATE_ID, ROUTER_CONTRACT_ID, ROUTER_BLOB, PARTY, { + partyOwner: PARTY, + }), + makeAcsEntry(RECEIVER_TEMPLATE_ID, longReceiverCid, RECEIVER_BLOB, PARTY, { + owner: PARTY, + receiverFinalityConfig: { tag: 'BlockDepth', value: 1 }, + }), + ] + + const provider = new AcsDisclosureProvider(makeStubClient(entries), { party: PARTY }) + const resolved = await provider.resolveReceiverForExecute(1, `0x${longReceiverCid}`) + + assert.equal(resolved?.contractId, longReceiverCid) + }) }) diff --git a/ccip-sdk/src/canton/explicit-disclosures/acs.ts b/ccip-sdk/src/canton/explicit-disclosures/acs.ts index 8fa53d03..315bd916 100644 --- a/ccip-sdk/src/canton/explicit-disclosures/acs.ts +++ b/ccip-sdk/src/canton/explicit-disclosures/acs.ts @@ -1,5 +1,7 @@ +import { normalizeCantonCcvList, receiverRequiresConfiguredCcvs } from '../ccv-addresses.ts' import type { DisclosedContract } from './types.ts' import { CCIPError, CCIPErrorCode } from '../../errors/index.ts' +import { hashedUtf8Hex, isCantonPartyId, normalizeHex } from '../../utils.ts' import { type CantonClient, type EventFormat, @@ -37,6 +39,57 @@ function extractInstanceId(createArgument: unknown): string | null { return extractStringField(createArgument, 'instanceId') } +/** Read a field from ledger `createArgument` (flat object or `{ fields: [{ label, value }] }`). */ +function readCreateArgumentField(createArgument: unknown, fieldName: string): unknown { + if (!createArgument || typeof createArgument !== 'object') return undefined + const arg = createArgument as Record + if (fieldName in arg) return arg[fieldName] + if (Array.isArray(arg['fields'])) { + for (const field of arg['fields'] as Array>) { + if (field['label'] === fieldName) return field['value'] + } + } + return undefined +} + +/** Ledger list fields may be a bare array or wrapped as `{ list }` / `{ elements }`. */ +function asLedgerList(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (value && typeof value === 'object') { + const record = value as Record + if (Array.isArray(record['list'])) return record['list'] + if (Array.isArray(record['elements'])) return record['elements'] + } + return [] +} + +/** One `RawInstanceAddress` entry from ledger JSON → its `unpack` hex string, if present. */ +function extractRawInstanceAddressUnpack(item: unknown): string | null { + if (typeof item === 'string') return item + if (!item || typeof item !== 'object') return null + const record = item as Record + if (typeof record['unpack'] === 'string') return record['unpack'] + const value = record['value'] + if ( + value && + typeof value === 'object' && + typeof (value as Record)['unpack'] === 'string' + ) { + return (value as Record)['unpack'] as string + } + return null +} + +/** + * CCIPReceiver `requiredCCVs`: CCV addresses the receiver expects at execute time. + * The ledger returns a list of `RawInstanceAddress`; we collect each `unpack` value. + */ +function extractRequiredCCVs(createArgument: unknown): string[] { + return asLedgerList(readCreateArgumentField(createArgument, 'requiredCCVs')) + .map(extractRawInstanceAddressUnpack) + .filter((address): address is string => address != null) +} + /** * Extract the `receiverFinalityConfig` Daml variant from a contract's `createArgument`. * The Canton JSON Ledger API v2 represents variants as `{ tag: string, value: unknown }`. @@ -97,22 +150,45 @@ function _extractNumberField(createArgument: unknown, fieldName: string): number * - `moduleEntity`: the `ModuleName:EntityName` suffix extracted from the full template ID * string returned by the ledger, used to key the result map. */ -const CCIP_TEMPLATES = { - perPartyRouter: { - templateId: '#ccip-perpartyrouter:CCIP.PerPartyRouter:PerPartyRouter', - moduleEntity: 'CCIP.PerPartyRouter:PerPartyRouter', - }, - ccipReceiver: { - templateId: '#ccip-receiver:CCIP.CCIPReceiver:CCIPReceiver', - moduleEntity: 'CCIP.CCIPReceiver:CCIPReceiver', - }, - ccipSender: { - templateId: '#ccip-sender:CCIP.CCIPSender:CCIPSender', - moduleEntity: 'CCIP.CCIPSender:CCIPSender', - }, +const DEFAULT_CCIP_PACKAGE_NAMES = { + perPartyRouter: 'ccip-perpartyrouter', + ccipReceiver: 'ccip-receiver', + ccipSender: 'ccip-sender', +} as const + +/** DAR package names used to resolve CCIP template IDs from the ACS snapshot. */ +export type CcipPackageNames = { + [K in keyof typeof DEFAULT_CCIP_PACKAGE_NAMES]: string +} + +const CCIP_MODULE_ENTITIES = { + perPartyRouter: 'CCIP.PerPartyRouter:PerPartyRouter', + ccipReceiver: 'CCIP.CCIPReceiver:CCIPReceiver', + ccipSender: 'CCIP.CCIPSender:CCIPSender', } as const -type CcipContractType = keyof typeof CCIP_TEMPLATES +function resolveCcipPackageNames(overrides?: Partial): CcipPackageNames { + return { ...DEFAULT_CCIP_PACKAGE_NAMES, ...overrides } +} + +function buildCcipTemplates(packages: CcipPackageNames) { + return { + perPartyRouter: { + templateId: `#${packages.perPartyRouter}:${CCIP_MODULE_ENTITIES.perPartyRouter}`, + moduleEntity: CCIP_MODULE_ENTITIES.perPartyRouter, + }, + ccipReceiver: { + templateId: `#${packages.ccipReceiver}:${CCIP_MODULE_ENTITIES.ccipReceiver}`, + moduleEntity: CCIP_MODULE_ENTITIES.ccipReceiver, + }, + ccipSender: { + templateId: `#${packages.ccipSender}:${CCIP_MODULE_ENTITIES.ccipSender}`, + moduleEntity: CCIP_MODULE_ENTITIES.ccipSender, + }, + } as const +} + +type CcipContractType = keyof typeof CCIP_MODULE_ENTITIES /** * Build a targeted `EventFormat` that requests only the specific CCIP contract @@ -120,11 +196,12 @@ type CcipContractType = keyof typeof CCIP_TEMPLATES * Using explicit `TemplateFilter`s instead of a wildcard avoids pulling every * active contract for the party over the wire. */ -function buildTargetedEventFormat(party: string): EventFormat { +function buildTargetedEventFormat(party: string, packages: CcipPackageNames): EventFormat { + const templates = buildCcipTemplates(packages) return { filtersByParty: { [party]: { - cumulative: Object.values(CCIP_TEMPLATES).map(({ templateId }) => ({ + cumulative: Object.values(templates).map(({ templateId }) => ({ identifierFilter: { TemplateFilter: { value: { templateId, includeCreatedEventBlob: true }, @@ -148,10 +225,68 @@ interface RichContractMatch { synchronizerId: string instanceId: string | null signatory: string | null + /** owner field from createArgument (present on CCIPReceiver) */ + owner: string | null /** partyOwner field from createArgument (present on PerPartyRouter) */ partyOwner: string | null /** receiverFinalityConfig variant from createArgument (present on CCIPReceiver) */ receiverFinalityConfig: { tag: string; value: unknown } | null + /** RawInstanceAddress.unpack values from requiredCCVs (present on CCIPReceiver) */ + requiredCCVs: string[] +} + +function matchesReceiverFinality( + cfg: RichContractMatch['receiverFinalityConfig'], + finality: number, +): boolean { + if (!cfg) return false + return finality === 0 + ? cfg.tag === 'WaitForFinality' + : finality === 0x00010000 + ? cfg.tag === 'WaitForSafe' + : cfg.tag === 'BlockDepth' && Number(cfg.value) === finality +} + +/** Prefer receivers whose requiredCCVs include a CCV from canton-config `ccvs`. */ +function rankReceiverCandidates( + candidates: RichContractMatch[], + configuredCcvs: readonly string[], +): RichContractMatch[] { + const score = (candidate: RichContractMatch): number => { + if (receiverRequiresConfiguredCcvs(candidate.requiredCCVs, configuredCcvs)) return 2 + if (candidate.requiredCCVs.length > 0) return 1 + return 0 + } + return [...candidates].sort((a, b) => score(b) - score(a)) +} + +function toDisclosedContract(match: RichContractMatch): DisclosedContract { + return { + templateId: match.templateId, + contractId: match.contractId, + createdEventBlob: match.createdEventBlob, + synchronizerId: match.synchronizerId, + } +} + +type ReceiverHintKind = 'contractId' | 'hashedParty' | 'partyId' + +function classifyReceiverHint(hint: string): ReceiverHintKind { + const trimmed = hint.trim() + if (isCantonPartyId(trimmed)) return 'partyId' + const hex = normalizeHex(trimmed) + if (!/^[0-9a-f]+$/.test(hex)) { + throw new CCIPError( + CCIPErrorCode.CANTON_API_ERROR, + `Invalid Canton receiver hint "${hint}". Expected a contract ID, party ID (hint::1220…), or keccak256(party) hex.`, + ) + } + if (hex.length === 64) return 'hashedParty' + if (hex.length > 64) return 'contractId' + throw new CCIPError( + CCIPErrorCode.CANTON_API_ERROR, + `Invalid Canton receiver hint "${hint}". Hex value is too short to be a contract ID or keccak256(party).`, + ) } /** @@ -162,11 +297,12 @@ interface RichContractMatch { async function fetchRichSnapshot( client: CantonClient, party: string, + packages: CcipPackageNames, ): Promise> { const { offset } = await client.getLedgerEnd() const request: GetActiveContractsRequest = { - eventFormat: buildTargetedEventFormat(party), + eventFormat: buildTargetedEventFormat(party, packages), verbose: false, activeAtOffset: offset, } @@ -192,8 +328,10 @@ async function fetchRichSnapshot( synchronizerId: active.synchronizerId, instanceId: extractInstanceId(created.createArgument), signatory: signatories.length === 1 ? (signatories[0] ?? null) : null, + owner: extractStringField(created.createArgument, 'owner'), partyOwner: extractStringField(created.createArgument, 'partyOwner'), receiverFinalityConfig: extractFinalityConfig(created.createArgument), + requiredCCVs: extractRequiredCCVs(created.createArgument), } const list = byModuleEntity.get(moduleEntity) ?? [] @@ -217,15 +355,11 @@ function pickByContractId( snapshot: Map, cid: string, ): DisclosedContract { + const want = normalizeHex(cid) for (const contracts of snapshot.values()) { for (const c of contracts) { - if (c.contractId === cid) { - return { - templateId: c.templateId, - contractId: c.contractId, - createdEventBlob: c.createdEventBlob, - synchronizerId: c.synchronizerId, - } + if (normalizeHex(c.contractId) === want) { + return toDisclosedContract(c) } } } @@ -251,7 +385,7 @@ function pickBySignatory( contractType: CcipContractType, party: string, ): DisclosedContract { - const { moduleEntity } = CCIP_TEMPLATES[contractType] + const moduleEntity = CCIP_MODULE_ENTITIES[contractType] const candidates = snapshot.get(moduleEntity) ?? [] for (const c of candidates) { @@ -277,7 +411,7 @@ function pickByPartyOwner( contractType: CcipContractType, party: string, ): DisclosedContract { - const { moduleEntity } = CCIP_TEMPLATES[contractType] + const moduleEntity = CCIP_MODULE_ENTITIES[contractType] const candidates = snapshot.get(moduleEntity) ?? [] for (const c of candidates) { @@ -306,6 +440,14 @@ function pickByPartyOwner( export type AcsDisclosureConfig = { /** Canton party ID acting on behalf of the user */ party: string + /** Optional DAR package name overrides for ACS template filters */ + packages?: Partial + /** + * Optional execute CCV InstanceAddresses from canton-config (`ccvs`). + * Hex hashes and/or raw `instanceId@party` forms are accepted. + * Used to prefer CCIPReceivers whose `requiredCCVs` include these verifiers. + */ + ccvs?: string[] } /** @@ -335,6 +477,7 @@ export type AcsSendDisclosures = { export class AcsDisclosureProvider { private readonly client: CantonClient private readonly config: AcsDisclosureConfig + private readonly packages: CcipPackageNames /** * Create an `AcsDisclosureProvider` from a pre-built Canton Ledger API client. @@ -344,7 +487,11 @@ export class AcsDisclosureProvider { */ constructor(client: CantonClient, config: AcsDisclosureConfig) { this.client = client - this.config = config + this.config = { + ...config, + ccvs: normalizeCantonCcvList(config.ccvs), + } + this.packages = resolveCcipPackageNames(config.packages) } /** @@ -367,7 +514,7 @@ export class AcsDisclosureProvider { * the contract's template type. */ async fetchExecutionDisclosures(receiverCid?: string): Promise { - const snapshot = await fetchRichSnapshot(this.client, this.config.party) + const snapshot = await fetchRichSnapshot(this.client, this.config.party, this.packages) const existingRouter = pickByPartyOwner(snapshot, 'perPartyRouter', this.config.party) const ccipReceiver = receiverCid @@ -378,37 +525,60 @@ export class AcsDisclosureProvider { } /** - * Find the first `CCIPReceiver` in the party's ACS whose `receiverFinalityConfig` - * variant is compatible with `finality`, or `null` if none exists. + * Find the best `CCIPReceiver` for execute, optionally resolving a caller hint. + * + * Hint formats: + * - Ledger contract ID (long hex, optional `0x` prefix) + * - Canton party ID (`hint::1220…`) + * - keccak256(party) hex from the CCIP message `receiver` field (32 bytes) * - * Mirrors Go's `encodeReceiverFinalityConfig` mapping: - * 0 → WaitForFinality - * 0x00010000→ WaitForSafe - * N (other) → BlockDepth(N) + * When no hint is given, returns the best receiver whose finality config matches + * the message, preferring contracts whose `requiredCCVs` include a configured CCV. */ - async findReceiverForFinality(finality: number): Promise { - const snapshot = await fetchRichSnapshot(this.client, this.config.party) - const { moduleEntity } = CCIP_TEMPLATES.ccipReceiver + async resolveReceiverForExecute( + finality: number, + hint?: string, + ): Promise { + const snapshot = await fetchRichSnapshot(this.client, this.config.party, this.packages) + const moduleEntity = CCIP_MODULE_ENTITIES.ccipReceiver const candidates = snapshot.get(moduleEntity) ?? [] - for (const c of candidates) { - const cfg = c.receiverFinalityConfig - if (!cfg) continue - const matches = - finality === 0 - ? cfg.tag === 'WaitForFinality' - : finality === 0x00010000 - ? cfg.tag === 'WaitForSafe' - : cfg.tag === 'BlockDepth' && Number(cfg.value) === finality - if (matches) { - return { - templateId: c.templateId, - contractId: c.contractId, - createdEventBlob: c.createdEventBlob, - synchronizerId: c.synchronizerId, - } + + const ownedByParty = candidates.filter( + (c) => + c.owner === this.config.party || + c.partyOwner === this.config.party || + c.signatory === this.config.party, + ) + let pool = ownedByParty.length > 0 ? ownedByParty : candidates + + if (hint?.trim()) { + const trimmed = hint.trim() + const kind = classifyReceiverHint(trimmed) + if (kind === 'contractId') { + return pickByContractId(snapshot, trimmed) + } + if (kind === 'partyId') { + pool = pool.filter((c) => c.owner === trimmed) + } else { + const want = normalizeHex(trimmed) + pool = pool.filter((c) => c.owner != null && hashedUtf8Hex(c.owner) === want) } } - return null + + const matching = rankReceiverCandidates( + pool.filter((c) => matchesReceiverFinality(c.receiverFinalityConfig, finality)), + this.config.ccvs ?? [], + ) + const best = matching[0] + return best ? toDisclosedContract(best) : null + } + + /** + * Find a CCIPReceiver matching the requested finality. + * @deprecated Use {@link resolveReceiverForExecute} instead. + */ + async findReceiverForFinality(finality: number): Promise { + return this.resolveReceiverForExecute(finality) } /** @@ -419,7 +589,7 @@ export class AcsDisclosureProvider { * API when the global EDS selects one for the message. */ async fetchSendDisclosures(): Promise { - const snapshot = await fetchRichSnapshot(this.client, this.config.party) + const snapshot = await fetchRichSnapshot(this.client, this.config.party, this.packages) const existingRouter = pickByPartyOwner(snapshot, 'perPartyRouter', this.config.party) const existingSender = pickBySignatory(snapshot, 'ccipSender', this.config.party) diff --git a/ccip-sdk/src/canton/explicit-disclosures/eds.ts b/ccip-sdk/src/canton/explicit-disclosures/eds.ts index f8a528a2..fa317d23 100644 --- a/ccip-sdk/src/canton/explicit-disclosures/eds.ts +++ b/ccip-sdk/src/canton/explicit-disclosures/eds.ts @@ -60,6 +60,7 @@ export interface EdsSendResult { disclosedContracts: DisclosedContract[] ccvs: string[] executor?: string + feeTokenConfigCid: string } /** Result of `POST /ccip/v1/global/message/execute`. */ @@ -112,6 +113,7 @@ interface EdsGlobalSendResponse { disclosedContracts?: EdsApiDisclosedContract[] ccvs?: string[] executor?: string + feeTokenConfigCid?: string } interface EdsGlobalExecuteResponse { @@ -253,6 +255,7 @@ export class EdsDisclosureProvider { disclosedContracts: contractsOrEmpty(resp.disclosedContracts), ccvs: resp.ccvs ?? [], executor: resp.executor, + feeTokenConfigCid: resp.feeTokenConfigCid ?? '', } } diff --git a/ccip-sdk/src/canton/index.ts b/ccip-sdk/src/canton/index.ts index fadc65fe..ae65478c 100644 --- a/ccip-sdk/src/canton/index.ts +++ b/ccip-sdk/src/canton/index.ts @@ -41,6 +41,12 @@ import { CCIPVersion, } from '../types.ts' import { getDataBytes, sleep } from '../utils.ts' +import { + missingTokenPoolRequiredCcvs, + normalizeCantonCcvList, + resolveEdsCcvAddress, + resolveSenderRequiredCcvs, +} from './ccv-addresses.ts' import { type CantonClient, type JsCommands, @@ -49,7 +55,15 @@ import { type JsTransaction, createCantonClient, } from './client/index.ts' -import { parseCantonExecutionReceipt, parseCantonSendResult, resolveTimestamp } from './events.ts' +import { + extractCantonSentEventFieldsFromLogData, + normalizeCantonMessageId, + parseCantonExecutionReceipt, + parseCantonSendResult, + resolveTimestamp, + toBigIntSafe, + tryParseCantonSendResult, +} from './events.ts' import { AcsDisclosureProvider } from './explicit-disclosures/acs.ts' import { type EdsMessage, EdsDisclosureProvider } from './explicit-disclosures/eds.ts' import type { DisclosedContract } from './explicit-disclosures/types.ts' @@ -107,9 +121,14 @@ export class CantonChain extends Chain { readonly tokenMetadataClient: TokenMetadataClient readonly indexerUrl: string readonly ccipParty: string + /** Ledger party used for actAs / ACS queries (may differ from ccipParty). */ + readonly ledgerParty: string /** Custom fetch function supplied via ctx, used for indexer requests. Falls back to globalThis.fetch. */ private readonly fetchFn: typeof fetch + /** When set, used for CCV execute EDS lookups and receiver matching instead of indexer-only addresses. */ + private readonly ccvs: readonly string[] + /** * Creates a new CantonChain instance. * @param client - Canton Ledger API client. @@ -120,6 +139,7 @@ export class CantonChain extends Chain { * @param ccipParty - The party ID to use for CCIP operations * @param indexerUrl - Base URL of the CCV indexer service. * @param network - Network information for this chain. + * @param ledgerParty - User ledger party for actAs and transaction lookups (`canton-config.party`) * @param ctx - Context containing logger. */ constructor( @@ -131,6 +151,7 @@ export class CantonChain extends Chain { ccipParty: string, indexerUrl: string, network: NetworkInfo, + ledgerParty: string, ctx?: ChainContext, ) { super(network, ctx) @@ -141,8 +162,10 @@ export class CantonChain extends Chain { this.transferInstructionClient = transferInstructionClient this.tokenMetadataClient = tokenMetadataClient this.ccipParty = ccipParty + this.ledgerParty = ledgerParty this.indexerUrl = indexerUrl this.fetchFn = ctx?.fetch ?? globalThis.fetch.bind(globalThis) + this.ccvs = normalizeCantonCcvList(ctx?.cantonConfig?.ccvs) } /** @@ -186,11 +209,36 @@ export class CantonChain extends Chain { transferInstructionClient: TransferInstructionClient, tokenMetadataClient: TokenMetadataClient, ccipParty: string, - indexerUrl = MAINNET_INDEXER_URLS[0]!, + indexerUrl: string, + ledgerParty: string, ctx?: ChainContext, ): Promise { const synchronizers = await client.getConnectedSynchronizers() + if (!synchronizers.length) { + throw new CCIPChainNotFoundError('no connected synchronizers') + } + + const configChainId = ctx?.cantonConfig?.chainId?.trim() + if (configChainId) { + ctx?.logger?.debug( + 'Canton: using chainId from canton config (skipping synchronizer alias detection):', + configChainId, + ) + return new CantonChain( + client, + acsDisclosureProvider, + edsDisclosureProvider, + transferInstructionClient, + tokenMetadataClient, + ccipParty, + indexerUrl, + networkInfo(configChainId) as NetworkInfo, + ledgerParty, + ctx, + ) + } + // TODO: Check synchronizer returned aliases against known Canton chain names to determine the network. for (const { synchronizerAlias } of synchronizers) { const chainId = CantonChain.SYNCHRONIZER_ALIAS_TO_CHAIN_ID.get( @@ -206,6 +254,7 @@ export class CantonChain extends Chain { ccipParty, indexerUrl, networkInfo(chainId) as NetworkInfo, + ledgerParty, ctx, ) } @@ -228,14 +277,13 @@ export class CantonChain extends Chain { ccipParty, indexerUrl, networkInfo(CantonChain.DEFAULT_CANTON_CHAIN_ID) as NetworkInfo, + ledgerParty, ctx, ) } throw new CCIPChainNotFoundError( - synchronizers.length - ? `canton:${synchronizers.map((s) => s.synchronizerAlias).join(', ')}` - : 'no connected synchronizers', + `canton:${synchronizers.map((s) => s.synchronizerAlias).join(', ')}`, ) } @@ -258,6 +306,13 @@ export class CantonChain extends Chain { ) } + if (!ctx.cantonConfig.party.trim()) { + throw new CCIPError( + CCIPErrorCode.METHOD_UNSUPPORTED, + 'CantonChain.fromUrl: ctx.cantonConfig.party is required (ledger actAs party; distinct from ccipParty)', + ) + } + const fetchFn = ctx.fetch const client = createCantonClient({ baseUrl: url, @@ -277,6 +332,8 @@ export class CantonChain extends Chain { } const acsDisclosureProvider = new AcsDisclosureProvider(client, { party: ctx.cantonConfig.party, + packages: ctx.cantonConfig.packages, + ccvs: ctx.cantonConfig.ccvs, }) const edsDisclosureProvider = new EdsDisclosureProvider({ edsBaseUrl: ctx.cantonConfig.edsUrl, @@ -297,7 +354,8 @@ export class CantonChain extends Chain { transferInstructionClient, tokenMetadataClient, ctx.cantonConfig.ccipParty, - ctx.cantonConfig.indexerUrl ?? '', + ctx.cantonConfig.indexerUrl ?? MAINNET_INDEXER_URLS[0]!, + ctx.cantonConfig.party.trim(), ctx, ) } @@ -315,9 +373,8 @@ export class CantonChain extends Chain { /** * Fetches a Canton transaction (update) by its update ID. * - * The ledger is queried via `/v2/updates/transaction-by-id` with a wildcard - * party filter so that all visible events are returned without requiring a - * known party ID. + * The ledger is queried via `/v2/updates/transaction-by-id` scoped to + * {@link ledgerParty} so restricted participant nodes allow the lookup. * * Canton concepts are mapped to {@link ChainTransaction} fields as follows: * - `hash` — the Canton `updateId` @@ -330,7 +387,7 @@ export class CantonChain extends Chain { * @returns A {@link ChainTransaction} with events mapped to logs. */ async getTransaction(hash: string): Promise { - const tx: JsTransaction = await this.provider.getTransactionById(hash) + const tx: JsTransaction = await this.provider.getTransactionById(hash, this.ledgerParty) const timestamp = tx.effectiveAt ? Math.floor(new Date(tx.effectiveAt).getTime() / 1000) @@ -449,8 +506,17 @@ export class CantonChain extends Chain { */ async getTokenInfo(token: string): Promise<{ symbol: string; decimals: number }> { const { id } = parseInstrumentId(token) - const instrument = await this.tokenMetadataClient.getInstrument(id) - return { symbol: instrument.symbol, decimals: instrument.decimals } + try { + const instrument = await this.tokenMetadataClient.getInstrument(id) + return { symbol: instrument.symbol, decimals: instrument.decimals } + } catch (error) { + // scan-proxy only lists Amulet-registry instruments; CCIP-owned tokens (e.g. link-token) + // are absent but still use Canton 10-decimal holding amounts. + if (CCIPError.isCCIPError(error) && error.context['statusCode'] === 404) { + return { symbol: id, decimals: CantonChain.decimals } + } + throw error + } } /** @@ -521,7 +587,13 @@ export class CantonChain extends Chain { : '' const gasLimit = cantonArgs.gasLimit ?? 200_000n const feeTokenHoldingCids = cantonArgs.feeTokenHoldingCids - const senderRequiredCCVs = cantonArgs.ccvRawAddresses ?? [] + const senderRequiredCCVs = resolveSenderRequiredCcvs(cantonArgs.ccvRawAddresses, this.ccvs) + if (cantonArgs.ccvRawAddresses === undefined && this.ccvs.length) { + this.logger.debug( + 'CantonChain.generateUnsignedSendMessage: using ccvs from canton config for senderRequiredCCVs', + this.ccvs, + ) + } this.logger.debug('CantonChain.generateUnsignedSendMessage: fetching ACS disclosures') @@ -677,6 +749,14 @@ export class CantonChain extends Chain { ccvArgs: '', })) + if (!edsResult.feeTokenConfigCid) { + throw new CCIPError( + CCIPErrorCode.CANTON_API_ERROR, + 'CantonChain.generateUnsignedSendMessage: EDS did not return feeTokenConfigCid; ' + + 'ensure the fee token is registered in TokenAdminRegistry', + ) + } + const choiceArgument: Record = { // top-level Send fields (from CCIPSender.Send Daml struct) destinationChainSelector: destChainSelector.toString(), @@ -691,7 +771,7 @@ export class CantonChain extends Chain { extraArgs: { tag: 'V3', value: { - gasLimit: Number(gasLimit), + gasLimit: encodeDamlInt64(gasLimit), ccvs: ccvExtraArgs, executor: { tag: 'Executor_UseDefault', value: { executorArgs: '' } }, tokenReceiver: '', @@ -702,6 +782,7 @@ export class CantonChain extends Chain { feeTokenInput: { senderInputCids: feeTokenHoldingCids, feeTokenTransferFactory: feeTransferFactory.factoryId, + feeTokenConfigCid: edsResult.feeTokenConfigCid, feeTokenExtraArgs: { context: { values: feeTransferFactory.contextValues }, meta: { values: {} }, @@ -828,7 +909,7 @@ export class CantonChain extends Chain { token: string amount: bigint }[], - feeToken: opts.message.feeToken ?? '', + feeToken: message.feeToken ?? '', feeTokenAmount: 0n, } as unknown as CCIPMessage @@ -906,9 +987,16 @@ export class CantonChain extends Chain { ) const ccvExecuteResults = await Promise.all( - verifications.map((v) => - this.edsDisclosureProvider.fetchCcvExecuteDisclosure(v.destAddress, encodedMessageHex), - ), + verifications.map((v) => { + const ccvAddress = resolveEdsCcvAddress(v.destAddress, this.ccvs) + if (this.ccvs.length && ccvAddress !== v.destAddress) { + this.logger.debug( + 'CantonChain.generateUnsignedExecute: using ccvs config override for EDS', + { override: ccvAddress, verifierDestAddress: v.destAddress }, + ) + } + return this.edsDisclosureProvider.fetchCcvExecuteDisclosure(ccvAddress, encodedMessageHex) + }), ) const ccvInputs = verifications.map((v, index) => { @@ -933,6 +1021,7 @@ export class CantonChain extends Chain { assertRequiredCcvsCovered( tokenPoolExecute.requiredCCVs, verifications.map((v) => v.destAddress), + this.ccvs, ) tokenTransferInput = { @@ -1011,17 +1100,25 @@ export class CantonChain extends Chain { const inputOpts = opts as { input?: { encodedMessage?: string } } const encodedMessageHex = inputOpts.input?.encodedMessage ?? '' const finality = decodeFinalityFromEncodedMessage(encodedMessageHex) + const receiverHint = typeof opts.receiver === 'string' ? opts.receiver.trim() : '' this.logger.debug( - `CantonChain.execute: message finality=${finality}, looking for compatible CCIPReceiver...`, + `CantonChain.execute: message finality=${finality}, resolving CCIPReceiver` + + (receiverHint ? ` (hint=${receiverHint})` : '') + + '...', ) - let receiverCid = (await this.acsDisclosureProvider.findReceiverForFinality(finality)) - ?.contractId + const resolvedReceiver = await this.acsDisclosureProvider.resolveReceiverForExecute( + finality, + receiverHint || undefined, + ) + let receiverCid = resolvedReceiver?.contractId if (!receiverCid) { this.logger.debug( `CantonChain.execute: no CCIPReceiver with minBlockConfirmations=${finality} found in ACS — creating one`, ) receiverCid = await this.createReceiverForFinality(wallet.party, finality, wallet.signer) + } else { + this.logger.debug(`CantonChain.execute: using CCIPReceiver contractId=${receiverCid}`) } // Build the unsigned command, passing the resolved receiver CID via Canton-specific opts. @@ -1059,6 +1156,66 @@ export class CantonChain extends Chain { // ─── Internal submission helper ───────────────────────────────────────── + /** + * Build a prepare-submission request with synchronizer and package preferences + * required by prod Canton participants for interactive signing. + */ + private async buildPrepareRequest(commands: JsCommands): Promise { + const synchronizerId = await this.resolveSubmissionSynchronizerId(commands) + const packageNames = this.resolvePackageNamesForCommands(commands) + const packageIdSelectionPreference = await this.provider.getPreferredPackageIds( + commands.actAs, + packageNames, + synchronizerId, + ) + if (packageIdSelectionPreference.length === 0) { + throw new CCIPError( + CCIPErrorCode.CANTON_API_ERROR, + 'CantonChain: unable to resolve packageIdSelectionPreference for prepare submission', + ) + } + + return { + commandId: commands.commandId, + commands: commands.commands, + actAs: commands.actAs, + readAs: commands.readAs, + disclosedContracts: commands.disclosedContracts, + synchronizerId, + packageIdSelectionPreference, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + } + } + + /** Resolve synchronizerId for interactive prepare when commands omit it explicitly. */ + private async resolveSubmissionSynchronizerId(commands: JsCommands): Promise { + if (commands.synchronizerId) return commands.synchronizerId + + const fromDisclosed = commands.disclosedContracts + ?.map((dc) => dc.synchronizerId) + .find((id) => typeof id === 'string' && id.length > 0) + if (fromDisclosed) return fromDisclosed + + const synchronizers = await this.provider.getConnectedSynchronizers() + const synchronizerId = synchronizers[0]?.synchronizerId + if (!synchronizerId) { + throw new CCIPError( + CCIPErrorCode.CANTON_API_ERROR, + 'CantonChain: unable to resolve synchronizerId for prepare submission', + ) + } + return synchronizerId + } + + /** Collect DAR package names referenced by command template IDs for prepare submission. */ + private resolvePackageNamesForCommands(commands: JsCommands): string[] { + const names = new Set([ + ...packageNamesFromTemplateRefs(commands), + ...CANTON_SEND_PACKAGE_NAMES, + ]) + return [...names] + } + /** * Submit a command to the ledger, using external signing when a * {@link TransactionSigner} is provided. @@ -1078,15 +1235,7 @@ export class CantonChain extends Chain { } // Step 1 — Prepare the transaction - const prepareRequest: JsPrepareSubmissionRequest = { - commandId: commands.commandId, - commands: commands.commands, - actAs: commands.actAs, - readAs: commands.readAs, - disclosedContracts: commands.disclosedContracts, - synchronizerId: commands.synchronizerId, - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', - } + const prepareRequest = await this.buildPrepareRequest(commands) const prepareResponse = await this.provider.prepareSubmission(prepareRequest) @@ -1111,6 +1260,7 @@ export class CantonChain extends Chain { const executeResponse = await this.provider.executeSubmissionAndWaitForTransaction({ preparedTransaction: prepareResponse.preparedTransaction, partySignatures, + deduplicationPeriod: { Empty: {} }, hashingSchemeVersion, submissionId: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, }) @@ -1206,8 +1356,16 @@ export class CantonChain extends Chain { ) } - const indexerMessageId = normalizeIndexerMessageId(request.message.messageId) - const url = `${this.indexerUrl}/v1/verifierresults/${indexerMessageId}` + const indexerMessageId = normalizeCantonMessageId(request.message.messageId) + const cliIndexer = Array.isArray(opts.indexer) ? opts.indexer : undefined + const indexerBase = resolveIndexerBaseUrl(cliIndexer, this.indexerUrl) + if (!indexerBase) { + throw new CCIPError( + CCIPErrorCode.CANTON_API_ERROR, + 'CantonChain.getVerifications: indexer URL is required; set canton-config indexerUrl or pass indexer option', + ) + } + const url = `${indexerBase.replace(/\/$/, '')}/v1/verifierresults/${indexerMessageId}` const res = await this.fetchFn(url) if (!res.ok) { const body = await res.text() @@ -1455,9 +1613,35 @@ export class CantonChain extends Chain { * Try to decode a CCIP message from a Canton log/event. * @returns undefined (Canton message format not yet supported) */ - static decodeMessage(_log: Pick): CCIPMessage | undefined { - // TODO: implement Canton message decoding - return undefined + static decodeMessage(log: Pick): CCIPMessage | undefined { + const updateId = log.transactionHash + const sendResult = tryParseCantonSendResult(log.data, updateId) + if (!sendResult) return undefined + + const sentEvent = extractCantonSentEventFieldsFromLogData(log.data) + + const destRaw = + sentEvent?.destChainSelector ?? + sentEvent?.destinationChainSelector ?? + sentEvent?.dest_chain_selector + const srcRaw = sentEvent?.sourceChainSelector ?? sentEvent?.source_chain_selector + + if (destRaw == null || srcRaw == null) return undefined + + return { + messageId: sendResult.messageId, + encodedMessage: sendResult.encodedMessage, + sourceChainSelector: toBigIntSafe(srcRaw), + destChainSelector: toBigIntSafe(destRaw), + sequenceNumber: sendResult.sequenceNumber, + nonce: sendResult.nonce ?? 0n, + sender: typeof sentEvent?.sender === 'string' ? sentEvent.sender : '', + receiver: typeof sentEvent?.receiver === 'string' ? sentEvent.receiver : '', + data: sendResult.encodedMessage, + tokenAmounts: [], + feeToken: '', + feeTokenAmount: 0n, + } as unknown as CCIPMessage } /** @@ -1793,9 +1977,12 @@ function dedupeDisclosedContracts(contracts: readonly DisclosedContract[]): Disc }) } -function assertRequiredCcvsCovered(required: readonly string[], provided: readonly string[]): void { - const providedAddresses = new Set(provided.map(instanceAddressFor)) - const missing = required.filter((address) => !providedAddresses.has(instanceAddressFor(address))) +function assertRequiredCcvsCovered( + required: readonly string[], + verificationDestAddresses: readonly string[], + configuredCcvs: readonly string[], +): void { + const missing = missingTokenPoolRequiredCcvs(required, verificationDestAddresses, configuredCcvs) if (missing.length) { throw new CCIPError( CCIPErrorCode.CANTON_API_ERROR, @@ -1807,6 +1994,17 @@ function assertRequiredCcvsCovered(required: readonly string[], provided: readon const CANTON_DECIMALS = 10n const CANTON_DECIMAL_SCALE = BigInt(10) ** CANTON_DECIMALS +/** Package names commonly involved in CCIP Canton send (fee + token pool paths). */ +const CANTON_SEND_PACKAGE_NAMES = [ + 'ccip-core', + 'ccip-executor', + 'ccip-burn-mint-token-pool', + 'splice-amulet', + 'splice-api-token-holding-v1', + 'splice-api-token-transfer-instruction-v1', + 'link', +] as const + function formatCantonDecimal(amount: bigint): string { if (amount < 0n) { throw new CCIPError(CCIPErrorCode.METHOD_UNSUPPORTED, 'Canton token amounts cannot be negative') @@ -1816,6 +2014,46 @@ function formatCantonDecimal(amount: bigint): string { return `${whole}.${fraction}` } +function packageNamesFromTemplateRefs(commands: JsCommands): string[] { + const names = new Set() + for (const templateId of templateIdsFromCommands(commands)) { + if (!templateId.startsWith('#')) continue + const trimmed = templateId.slice(1) + const sep = trimmed.indexOf(':') + if (sep > 0) names.add(trimmed.slice(0, sep)) + } + return [...names] +} + +function templateIdsFromCommands(commands: JsCommands): string[] { + const ids: string[] = [] + for (const disclosed of commands.disclosedContracts ?? []) { + if (disclosed.templateId) ids.push(disclosed.templateId) + } + for (const command of commands.commands) { + const record = command as Record + for (const key of ['ExerciseCommand', 'CreateCommand'] as const) { + const nested = record[key] + if (!nested || typeof nested !== 'object') continue + const templateId = (nested as Record)['templateId'] + if (typeof templateId === 'string') ids.push(templateId) + } + } + return ids +} + +function resolveIndexerBaseUrl( + cliIndexer: readonly string[] | undefined, + configuredIndexerUrl: string, +): string { + for (const entry of cliIndexer ?? []) { + if (typeof entry !== 'string') continue + const trimmed = entry.trim() + if (trimmed) return trimmed + } + return configuredIndexerUrl.trim() +} + function decimalStringToCantonUnits(raw: string): bigint { const value = raw.trim().replace(/\.$/, '') if (!/^\d+(\.\d+)?$/.test(value)) return 0n @@ -1834,12 +2072,6 @@ function stripHexPrefix(hex: string): string { return hex.startsWith('0x') ? hex.slice(2) : hex } -function normalizeIndexerMessageId(messageId: string): string { - if (/^0x[0-9a-fA-F]{64}$/.test(messageId)) return messageId - if (/^[0-9a-fA-F]{64}$/.test(messageId)) return `0x${messageId}` - return messageId -} - function isRetryableCantonSubmitError(err: unknown): boolean { return CCIPError.isCCIPError(err) && err.isTransient } @@ -1875,5 +2107,10 @@ function decodeFinalityFromEncodedMessage(encodedHex: string): number { function encodeFinalityConfig(finality: number): Record { if (finality === 0) return { tag: 'WaitForFinality', value: {} } if (finality === 0x00010000) return { tag: 'WaitForSafe', value: {} } - return { tag: 'BlockDepth', value: finality } + return { tag: 'BlockDepth', value: encodeDamlInt64(finality) } +} + +/** Encode a Daml INT64 for the JSON Ledger API (string, not JSON number). */ +function encodeDamlInt64(value: bigint | number): string { + return value.toString() } diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index ff92ca66..628e6af2 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -196,10 +196,10 @@ export type WithCantonConfig = { * Configuration for connecting to a Canton Ledger API and fetch CCIP disclosures. */ export type CantonConfig = { - /** Party identifier for the Canton Ledger API. */ + /** User ledger party for actAs, ACS queries, and transaction visibility (often differs from ccipParty). */ party: string - /** CCIP party identifier */ + /** CCIP operator party (CCIPSender signatory / fee recipient on ledger). */ ccipParty: string /** JSON Web Token for authentication with the Canton Ledger API. */ @@ -218,8 +218,43 @@ export type CantonConfig = { /** Base URL for the Transfer Instruction API. */ transferInstructionUrl: string - /** Optional base URL for a transaction indexer to fetch CCV verifications; if not provided, default URL will be used. */ + /** + * Optional base URL for a transaction indexer to fetch CCV verifications. + * Used when `--indexer` is omitted on ccip-cli (CLI `--indexer` overrides this). + */ indexerUrl?: string + + /** + * Optional CCIP Canton chain ID (e.g. `canton:TestNet`). When set, skips + * synchronizer-alias detection — required when the ledger reports a generic + * alias such as `global`. + */ + chainId?: string + + /** + * Optional DAR package names for ACS template filters. Prod testnet bundles + * PerPartyRouter in `ccip-runtime` instead of the dev default `ccip-perpartyrouter`. + */ + packages?: Partial<{ + perPartyRouter: string + ccipReceiver: string + ccipSender: string + }> + + /** + * Optional CCIPSender instance id for Canton-source sends (ccip-cli `-r` on Canton lanes). + * Used only for CLI/SDK routing; on-ledger Send resolves CCIPSender from `party` via ACS. + * CLI `-r` overrides this value. + */ + senderInstanceId?: string + + /** + * Optional Canton CCV instance addresses (hex hashes and/or raw `instanceId@party`). + * Used for execute (EDS disclosures + receiver matching) and as the default for + * Canton send `senderRequiredCCVs` when `extraArgs.ccvRawAddresses` is omitted. + * CLI `-x ccvRawAddresses=…` overrides this list for send. + */ + ccvs?: string[] } /** @@ -647,6 +682,13 @@ export type ExecuteOpts = ( forceBuffer?: boolean /** For Solana, create and extend addresses in a lookup table before executing */ forceLookupTable?: boolean + /** + * Canton destination only: select the `CCIPReceiver` for execute. + * Accepts a ledger contract ID, a Canton party ID (`hint::1220…`), or the + * keccak256(party) hex used as the CCIP message `receiver` field. + * When omitted on Canton manual exec, the message receiver hash is used automatically. + */ + receiver?: string } /** diff --git a/ccip-sdk/src/requests.ts b/ccip-sdk/src/requests.ts index da342181..fce926f1 100644 --- a/ccip-sdk/src/requests.ts +++ b/ccip-sdk/src/requests.ts @@ -18,16 +18,16 @@ import type { EVMChain } from './evm/index.ts' import { decodeExtraArgs, decodeFinalityRequested } from './extra-args.ts' import { ChainFamily, networkInfo } from './networks.ts' import { supportedChains } from './supported-chains.ts' -import type { - AnyMessage, - CCIPMessage, - CCIPRequest, +import { + type AnyMessage, + type CCIPMessage, + type CCIPRequest, + type ChainLog, + type ChainTransaction, + type Lane, + type LeanNumbers, + type MessageInput, CCIPVersion, - ChainLog, - ChainTransaction, - Lane, - LeanNumbers, - MessageInput, } from './types.ts' import { convertKeysToCamelCase, @@ -259,6 +259,14 @@ export async function resolveLane( log: ChainLog, ): Promise { if ('destChainSelector' in message) { + if (source.network.family === ChainFamily.Canton) { + return { + sourceChainSelector: message.sourceChainSelector, + destChainSelector: message.destChainSelector, + onRamp: log.address || '', + version: CCIPVersion.V2_0, + } + } const [_, version] = await source.typeAndVersion(log.address) return { sourceChainSelector: message.sourceChainSelector, diff --git a/ccip-sdk/src/utils.ts b/ccip-sdk/src/utils.ts index 3627d78c..75c49007 100644 --- a/ccip-sdk/src/utils.ts +++ b/ccip-sdk/src/utils.ts @@ -7,6 +7,7 @@ import { type Numeric, decodeBase64, getBytes, + id as keccak256Utf8, isBytesLike, toBeArray, toBigInt, @@ -351,6 +352,9 @@ export function getAddressBytes(address: BytesLike | readonly number[]): Uint8Ar ? '0x' + address : address, ) + } else if (typeof address === 'string' && isCantonPartyId(address)) { + // Canton CCIP receivers use keccak256(partyId) as a 32-byte address (see HashedPartyFromString in chainlink-canton). + bytes = getBytes(`0x${hashedUtf8Hex(address)}`) } else if (typeof address === 'string' && /^-?\d+:[0-9a-f]{64}$/i.test(address)) { // TON raw format: "workchain:hash" → 36-byte CCIP format (4-byte BE workchain + 32-byte hash) const [workchain, hash] = address.split(':') @@ -371,6 +375,22 @@ export function getAddressBytes(address: BytesLike | readonly number[]): Uint8Ar return bytes } +/** Strip optional `0x` prefix and lowercase for stable hex string comparison. */ +export function normalizeHex(value: string): string { + const trimmed = value.trim() + return (trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed).toLowerCase() +} + +/** keccak256(utf8 string) as normalized hex (no `0x`). Used for Canton party / InstanceAddress hashes. */ +export function hashedUtf8Hex(value: string): string { + return normalizeHex(keccak256Utf8(value)) +} + +/** Daml party ID: `hint::1220<64-hex-fingerprint>` (not a 3-part instrument id). */ +export function isCantonPartyId(address: string): boolean { + return /^[\w.-]+::1220[0-9a-fA-F]{64}$/.test(address) +} + /** * Encodes remote/alien addresses for Any SRC *