Repository Public
1// Circle Cross-Chain Transfer Protocol (CCTP) — quote adapter. Price discovery only: nothing here builds, signs or sends.2//3// What it quotes: Circle-issued stablecoins burned on one of the wallet's networks and minted 1:1 on another.4// USDC — CCTP V2 (TokenMessengerV2.depositForBurn -> Iris attestation -> MessageTransmitterV2.receiveMessage)5// EURC — "CCTP for non-USDC" (CrossChainTokenService.crossChainTransfer, same attestation service), EVM only6// Two quotes per pair where the source chain has both speeds: Standard (attested at finality) and Fast (attested at7// confirmation, charged in basis points). The destination mint is a separate transaction that the wallet itself must8// submit and pay in the destination chain's coin; that cost is a FeeItem and is named in the step.9//10// Every address, domain id, duration and size below is pinned from Circle's documentation (read 2026-11-10) and is11// re-checked on-chain by scripts/smoke-swap-cctp.mts. Sources and the execution path: docs/swap/providers/cctp.md.12import { z } from 'zod';13import { isValidAddress } from '@spw/core';14import { ProviderError, resolveAsset, type AssetRef, type FeeItem, type ProviderContext, type RouteStep, type SwapProvider, type SwapQuote, type SwapQuoteRequest } from '../types.ts';15import type { ProviderEnv } from './index.ts';1617const ID = 'cctp';18/** Circle's attestation service ("Iris"), mainnet. Keyless; 40 requests/s, a breach blocks the caller for five minutes. */19export const CCTP_API = 'https://iris-api.circle.com';20export const CCTP_API_VERSION = 'v2';21export const CCTP_DOCS = 'https://developers.circle.com/cctp';2223/** minFinalityThreshold values defined by CCTP V2 (anything <= 1000 is Fast, anything above is Standard). */24export const FINALITY_FAST = 1000;25export const FINALITY_STANDARD = 2000;2627/** CCTP V2 on EVM: the same addresses on every chain (https://developers.circle.com/cctp/references/contract-addresses). */28export const CCTP_EVM = {29 tokenMessenger: '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d',30 messageTransmitter: '0x81D40F21F12A8F0E3252Bccb954D722d4c464B64',31 tokenMinter: '0xfd78EE919681417d192449715b2594ab58f5D002',32} as const;33/** CCTP V2 on Solana (https://developers.circle.com/cctp/references/solana-programs). */34export const CCTP_SOLANA = {35 messageTransmitter: 'CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC',36 tokenMessengerMinter: 'CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe',37} as const;38/** CCTP for non-USDC assets (EURC): one service address on every EVM chain, one TokenManager per token. */39export const CCTPX = {40 service: '0x431871229103b780868f8C6BB820cd16ECf942BC',41 eurcTokenId: '0x6ca9e29fa53becc29becaf4a90b9ca7a995ad4d2234880da13ca38c657fb241c',42 eurcTokenManager: '0x8c27579e24f9f19d96724e19fc059dacd1469e10',43} as const;4445export interface CctpChain {46 /** Circle's domain id — not a chain id */47 domain: number;48 /** can this chain be the SOURCE of a Fast Transfer (Circle disables it where finality is already fast) */49 fastSource: boolean;50 /** documented time for the attestation to become available, seconds (upper bound of the documented range) */51 standardSeconds: number; fastSeconds: number | null;52 /** EURC is deployed here and enabled in CCTP for non-USDC */53 eurc: boolean;54}55/**56 * The wallet's networks that CCTP V2 serves with native USDC. Domains: /cctp/concepts/supported-chains-and-domains;57 * times: /cctp/concepts/finality-and-block-confirmations. BNB Smart Chain has a domain (17) but carries USYC only, and58 * Stellar/Sui/Aptos are CCTP chains whose USDC is not in the wallet's registry: none of them is listed.59 */60export const CCTP_CHAINS: Record<string, CctpChain> = {61 ethereum: { domain: 0, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 20, eurc: true },62 avalanche: { domain: 1, fastSource: false, standardSeconds: 8, fastSeconds: null, eurc: true },63 optimism: { domain: 2, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 8, eurc: false },64 arbitrum: { domain: 3, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 8, eurc: false },65 solana: { domain: 5, fastSource: true, standardSeconds: 17, fastSeconds: 1, eurc: false },66 base: { domain: 6, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 8, eurc: true },67 polygon: { domain: 7, fastSource: false, standardSeconds: 8, fastSeconds: null, eurc: false },68 hyperevm: { domain: 19, fastSource: false, standardSeconds: 5, fastSeconds: null, eurc: false },69};7071/** "There is a $10 million limit on the amount of USDC that can be burned in a single CCTP transaction." */72export const USDC_BURN_LIMIT = 10_000_000n * 1_000_000n;73/** A fee schedule above this is treated as a corrupted answer, not as a price (Circle's Fast fees are 0–14 bps). */74const MAX_SANE_FEE_BPS = 100n;75/** Gas units, pinned above what real mainnet transactions used on 2026-11-10 (samples in the dossier). Estimates. */76const GAS = {77 approve: 60_000n,78 usdcBurn: 120_000n, usdcMint: 200_000n,79 eurcBurn: 380_000n, eurcMint: 240_000n,80} as const;81/** Solana: lamports per signature (protocol constant) and the sizes of the accounts a transfer creates. */82const SOL = { lamportsPerSignature: 5_000n, burnSignatures: 2n, mintSignatures: 1n, messageSentBytes: 428, usedNonceBytes: 9, tokenAccountBytes: 165 } as const;8384const ZERO32 = `0x${'0'.repeat(64)}`;85const ZERO_ADDRESS = `0x${'0'.repeat(40)}`;86const schemaError = (m: string) => new ProviderError(ID, 'schema', m);8788// ---------------------------------------------------------------------------------------------------------------------89// Pure helpers (exported for the tests and for the next phase)9091/** A JSON number as an exact decimal scaled by 10^decimals. Refuses exponents, signs and non-finite values. */92function scaled(n: number, decimals: number, what: string, truncate = false): bigint {93 const m = /^(\d+)(?:\.(\d+))?$/.exec(String(n));94 if (!m) throw schemaError(`${what}: not a plain non-negative decimal`);95 const frac = m[2] ?? '';96 if (frac.length > decimals && !truncate) throw schemaError(`${what}: more than ${decimals} decimals`);97 return BigInt(m[1]! + frac.slice(0, decimals).padEnd(decimals, '0'));98}99const BPS_DECIMALS = 6;100const BPS_DENOMINATOR = 10_000n * 10n ** BigInt(BPS_DECIMALS);101102/**103 * Fee for `amount` at `bps` basis points (Circle publishes fractional values such as 1.3), in base units.104 * `fee` is what Iris charges — amount × bps / 10 000 rounded DOWN (observed on mainnet burns, see the dossier);105 * `maxFee` is the same product rounded UP: the smallest `maxFee` argument that still covers it, and therefore the106 * most the mint can ever deduct.107 */108export function cctpFee(amount: bigint, bps: number): { fee: bigint; maxFee: bigint } {109 const s = scaled(bps, BPS_DECIMALS, 'minimumFee');110 if (s > MAX_SANE_FEE_BPS * 10n ** BigInt(BPS_DECIMALS)) throw schemaError(`minimumFee ${bps} bps is not a plausible CCTP fee`);111 const product = amount * s;112 return { fee: product / BPS_DENOMINATOR, maxFee: (product + BPS_DENOMINATOR - 1n) / BPS_DENOMINATOR };113}114115const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';116/** A Solana address (base58, 32 bytes) as the bytes32 CCTP uses for recipients. */117export function solanaAddressToBytes32(address: string): string {118 let v = 0n;119 for (const c of address) { const i = B58.indexOf(c); if (i < 0) throw new Error('invalid base58'); v = v * 58n + BigInt(i); }120 const hex = v.toString(16);121 if (hex.length > 64) throw new Error('not a 32-byte address');122 return `0x${hex.padStart(64, '0')}`;123}124/** An EVM address left-padded to bytes32, as `Message.addressToBytes32` does. */125export function evmAddressToBytes32(address: string): string {126 if (!/^0x[0-9a-fA-F]{40}$/.test(address)) throw new Error('invalid EVM address');127 return `0x${'0'.repeat(24)}${address.slice(2).toLowerCase()}`;128}129130export interface CctpMessageV2 {131 version: number; sourceDomain: number; destinationDomain: number; nonce: string; sender: string; recipient: string;132 destinationCaller: string; minFinalityThreshold: number; finalityThresholdExecuted: number;133 body: { version: number; burnToken: string; mintRecipient: string; amount: bigint; messageSender: string; maxFee: bigint; feeExecuted: bigint; expirationBlock: bigint; hookData: string };134}135/**136 * Decode an attested CCTP V2 burn message (148-byte header + BurnMessageV2 body) by its documented offsets. This is the137 * parse the signer needs before it may sign `receiveMessage(message, attestation)`: every field that decides who is138 * minted how much is in here. Throws on any length or range problem.139 */140export function decodeCctpMessageV2(message: string): CctpMessageV2 {141 if (!/^0x([0-9a-fA-F]{2})*$/.test(message)) throw new Error('message is not hex');142 const h = message.slice(2).toLowerCase();143 if (h.length < (148 + 228) * 2) throw new Error('message shorter than a V2 burn message');144 const at = (off: number, len: number) => h.slice(off * 2, (off + len) * 2);145 const u32 = (off: number) => Number.parseInt(at(off, 4), 16);146 const b32 = (off: number) => `0x${at(off, 32)}`;147 const B = 148; // body offset148 return {149 version: u32(0), sourceDomain: u32(4), destinationDomain: u32(8), nonce: b32(12), sender: b32(44), recipient: b32(76),150 destinationCaller: b32(108), minFinalityThreshold: u32(140), finalityThresholdExecuted: u32(144),151 body: {152 version: u32(B), burnToken: b32(B + 4), mintRecipient: b32(B + 36), amount: BigInt(b32(B + 68)), messageSender: b32(B + 100),153 maxFee: BigInt(b32(B + 132)), feeExecuted: BigInt(b32(B + 164)), expirationBlock: BigInt(b32(B + 196)), hookData: `0x${h.slice((B + 228) * 2)}`,154 },155 };156}157158// ---------------------------------------------------------------------------------------------------------------------159// Provider payload schemas160161const uint = z.number().int().nonnegative();162const FeesSchema = z.array(z.object({163 finalityThreshold: z.union([z.literal(FINALITY_FAST), z.literal(FINALITY_STANDARD)]),164 minimumFee: z.number().finite().nonnegative(),165 /** Circle Forwarding Service fee for this route, USDC base units (returned because we ask with forward=true) */166 forwardFee: z.object({ low: uint, med: uint, high: uint }).optional(),167})).min(1).max(2);168const AllowanceSchema = z.object({ allowance: z.number().finite().nonnegative(), lastUpdated: z.string() });169const numeric = z.string().regex(/^\d{1,40}$/);170const hex = z.string().regex(/^0x([0-9a-fA-F]{2})+$/);171const CctpxQuoteSchema = z.object({172 signedQuote: hex,173 issuedAt: uint,174 expiry: z.object({ mode: z.literal('TIMESTAMP'), expiresAt: uint }),175 feeTotalAmount: numeric,176 feeToken: z.string().regex(/^0x0{40}$/),177 items: z.array(z.object({ type: z.enum(['PROTOCOL', 'PRE_FINALITY']), amount: numeric, args: z.array(z.string()).min(1), argsHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/) })).min(1).max(2),178 nonce: z.string(),179});180const EvmQuantity = z.object({ result: z.string().regex(/^0x[0-9a-fA-F]{1,24}$/) });181182function parse<T>(schema: z.ZodType<T>, data: unknown, what: string): T {183 const r = schema.safeParse(data);184 if (!r.success) throw schemaError(`${what}: ${r.error.issues[0]?.path.join('.') ?? ''} ${r.error.issues[0]?.message ?? 'invalid'}`.trim());185 return r.data;186}187const httpStatus = (e: unknown): number | null => (e instanceof ProviderError && e.code === 'http' ? ((e as { status?: number }).status ?? null) : null);188189// ---------------------------------------------------------------------------------------------------------------------190// Costs on each side (estimates, live)191192type Token = 'USDC' | 'EURC';193interface Leg { ref: AssetRef; chain: CctpChain; family: 'evm' | 'sol'; rpc: string[]; nativeSymbol: string }194interface Cost { fees: FeeItem[]; note: string; detail: Record<string, string> }195196/** Classify strictly through the registry: only the allowlisted USDC / EURC deployment on a CCTP network qualifies. */197function leg(a: AssetRef): { token: Token; leg: Leg } | null {198 const chain = CCTP_CHAINS[a.network];199 if (!chain || a.asset === 'native') return null;200 let r: ReturnType<typeof resolveAsset>;201 try { r = resolveAsset(a); } catch { return null; }202 if (r.net.family !== 'evm' && r.net.family !== 'sol') return null;203 const token: Token | null = r.def.symbol === 'USDC' ? 'USDC' : r.def.symbol === 'EURC' && chain.eurc && r.net.family === 'evm' ? 'EURC' : null;204 if (!token || r.def.decimals !== 6) return null;205 return { token, leg: { ref: { network: a.network, asset: r.def.id }, chain, family: r.net.family, rpc: r.net.rpc, nativeSymbol: r.net.native.symbol } };206}207function pair(from: AssetRef, to: AssetRef): { token: Token; src: Leg; dst: Leg } | null {208 const a = leg(from), b = leg(to);209 if (!a || !b || a.token !== b.token || from.network === to.network) return null;210 return { token: a.token, src: a.leg, dst: b.leg };211}212213/** One JSON-RPC call against the registry's endpoints, in order; the first well-formed answer wins. */214async function rpcCall(l: Leg, body: unknown, ctx: ProviderContext): Promise<unknown> {215 let last: unknown = new ProviderError(ID, 'unavailable', `no RPC endpoint for ${l.ref.network}`);216 for (const url of l.rpc) {217 if (ctx.signal.aborted) break;218 try { return await ctx.http.postJson(url, body, { signal: ctx.signal }); } catch (e) { last = e; }219 }220 throw last;221}222async function evmGasPrice(l: Leg, ctx: ProviderContext): Promise<bigint> {223 const r = parse(EvmQuantity, await rpcCall(l, { jsonrpc: '2.0', id: 1, method: 'eth_gasPrice', params: [] }, ctx), `${l.ref.network} eth_gasPrice`);224 const v = BigInt(r.result);225 if (v <= 0n || v > 10n ** 13n) throw schemaError(`${l.ref.network} eth_gasPrice out of range`);226 return v;227}228const native = (l: Leg): AssetRef => ({ network: l.ref.network, asset: 'native' });229const networkFee = (l: Leg, amount: bigint): FeeItem => ({ kind: 'network', asset: native(l), amount, includedInOutput: false, estimated: true });230231/** Solana: rent-exempt minimum for each size, plus (optionally) whether `owner` already holds a token account of `mint`. */232async function solanaRent(l: Leg, sizes: number[], ctx: ProviderContext, holder?: { owner: string; mint: string }): Promise<{ rent: bigint[]; hasTokenAccount: boolean | null }> {233 const calls: unknown[] = sizes.map((s, i) => ({ jsonrpc: '2.0', id: i, method: 'getMinimumBalanceForRentExemption', params: [s] }));234 if (holder) calls.push({ jsonrpc: '2.0', id: sizes.length, method: 'getTokenAccountsByOwner', params: [holder.owner, { mint: holder.mint }, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }] });235 const Batch = z.array(z.object({ id: uint, result: z.unknown() })).length(calls.length);236 const answers = parse(Batch, await rpcCall(l, calls, ctx), 'solana rpc batch');237 const byId = (i: number) => { const a = answers.find((x) => x.id === i); if (!a) throw schemaError('solana rpc batch: missing answer'); return a.result; };238 const rent = sizes.map((_s, i) => { const v = parse(z.number().int().positive().max(1e9), byId(i), 'solana rent'); return BigInt(v); });239 const hasTokenAccount = holder ? parse(z.object({ value: z.array(z.unknown()) }), byId(sizes.length), 'solana token accounts').value.length > 0 : null;240 return { rent, hasTokenAccount };241}242243async function sourceCost(token: Token, l: Leg, ctx: ProviderContext): Promise<Cost> {244 if (l.family === 'evm') {245 const price = await evmGasPrice(l, ctx), units = GAS.approve + (token === 'USDC' ? GAS.usdcBurn : GAS.eurcBurn);246 return { fees: [networkFee(l, units * price)], note: `source gas ≈ ${units} units (exact-amount approve + burn), paid in ${l.nativeSymbol}`, detail: { gasPrice: price.toString(), gasUnits: units.toString() } };247 }248 const { rent } = await solanaRent(l, [SOL.messageSentBytes], ctx);249 const tx = SOL.lamportsPerSignature * SOL.burnSignatures;250 return {251 fees: [networkFee(l, tx), networkFee(l, rent[0]!)],252 note: `source cost in SOL: transaction fee (2 signatures, priority fee excluded) + ${rent[0]} lamports rent deposit for the MessageSent account, reclaimable by the wallet 5 days later (reclaim_event_account)`,253 detail: { transactionFee: tx.toString(), messageSentRent: rent[0]!.toString() },254 };255}256async function destinationCost(token: Token, l: Leg, recipient: string, ctx: ProviderContext): Promise<Cost> {257 if (l.family === 'evm') {258 const price = await evmGasPrice(l, ctx), units = token === 'USDC' ? GAS.usdcMint : GAS.eurcMint;259 return { fees: [networkFee(l, units * price)], note: `the wallet submits the mint itself and needs ${l.nativeSymbol} on ${l.ref.network} for it (≈ ${units} gas units)`, detail: { gasPrice: price.toString(), gasUnits: units.toString() } };260 }261 const known = isValidAddress(l.ref.network, recipient);262 const { rent, hasTokenAccount } = await solanaRent(l, [SOL.usedNonceBytes, SOL.tokenAccountBytes], ctx, known ? { owner: recipient, mint: l.ref.asset } : undefined);263 const needsAccount = hasTokenAccount !== true;264 const total = SOL.lamportsPerSignature * SOL.mintSignatures + rent[0]! + (needsAccount ? rent[1]! : 0n);265 return {266 fees: [networkFee(l, total)],267 note: `the wallet submits the mint itself and needs SOL for it: transaction fee + ${rent[0]} lamports for the used-nonce record${needsAccount ? ` + ${rent[1]} lamports to create the recipient's USDC token account (${hasTokenAccount === null ? 'existence not checked' : 'it does not exist yet'}; the mint reverts without it)` : ' (the recipient already has a USDC token account)'}`,268 detail: { transactionFee: (SOL.lamportsPerSignature * SOL.mintSignatures).toString(), usedNonceRent: rent[0]!.toString(), tokenAccountRent: rent[1]!.toString(), recipientTokenAccount: hasTokenAccount === null ? 'unknown' : hasTokenAccount ? 'exists' : 'missing' },269 };270}271272// ---------------------------------------------------------------------------------------------------------------------273274const QUOTE_ONLY_USDC = 'CCTP is quote-only in this phase: the wallet has no burn/mint builder yet. Every value-moving argument of depositForBurn (amount, destination domain, mintRecipient, burnToken, destinationCaller = 0, maxFee, minFinalityThreshold) is derivable by the signer from the approved intent and the pinned TokenMessengerV2 address — see docs/swap/providers/cctp.md for the path to "verified".';275const QUOTE_ONLY_EURC = 'CCTP for non-USDC is quote-only in this phase: crossChainTransfer carries a Circle-signed fee quote the signer cannot rebuild (it can only bound the attached value); see docs/swap/providers/cctp.md.';276let seq = 0;277const quoteId = (variant: string, now: number) => `${ID}-${variant}-${now.toString(36)}-${(++seq).toString(36)}`;278279function settled<T>(r: PromiseSettledResult<T>): T { if (r.status === 'rejected') throw r.reason; return r.value; }280281async function quoteUsdc(req: SwapQuoteRequest, src: Leg, dst: Leg, ctx: ProviderContext): Promise<SwapQuote[]> {282 if (req.amountIn > USDC_BURN_LIMIT) return [];283 const feesUrl = `${CCTP_API}/${CCTP_API_VERSION}/burn/USDC/fees/${src.chain.domain}/${dst.chain.domain}?forward=true`;284 const [feesR, allowanceR, srcR, dstR] = await Promise.allSettled([285 ctx.http.getJson(feesUrl, { signal: ctx.signal }),286 src.chain.fastSource ? ctx.http.getJson(`${CCTP_API}/${CCTP_API_VERSION}/fastBurn/USDC/allowance`, { signal: ctx.signal }) : Promise.resolve(null),287 sourceCost('USDC', src, ctx),288 destinationCost('USDC', dst, req.recipient, ctx),289 ]);290 // Iris answers 400 for a domain pair it does not serve: that is "no route", not a failure291 if (feesR.status === 'rejected' && httpStatus(feesR.reason) === 400) return [];292 const fees = parse(FeesSchema, settled(feesR), 'fees');293 if (new Set(fees.map((f) => f.finalityThreshold)).size !== fees.length) throw schemaError('fees: duplicate finality threshold');294 const standard = fees.find((f) => f.finalityThreshold === FINALITY_STANDARD);295 if (!standard) throw schemaError('fees: no Standard Transfer entry');296 const fast = src.chain.fastSource ? fees.find((f) => f.finalityThreshold === FINALITY_FAST) : undefined;297 const allowanceRaw = settled(allowanceR);298 const allowance = src.chain.fastSource ? parse(AllowanceSchema, allowanceRaw, 'allowance') : null;299 const fastCapacity = allowance ? scaled(allowance.allowance, 6, 'allowance', true) : 0n;300 const srcCost = settled(srcR), dstCost = settled(dstR);301 const now = ctx.now();302303 const evmSrc = src.family === 'evm', evmDst = dst.family === 'evm';304 const mintRecipient = !isValidAddress(dst.ref.network, req.recipient) ? null : evmDst ? evmAddressToBytes32(req.recipient) : null; // Solana: the recipient's USDC associated token account, derived when the transaction is built305 const contracts: RouteStep['contracts'] = [306 ...(evmSrc307 ? [{ network: src.ref.network, address: CCTP_EVM.tokenMessenger, role: 'spender' as const }, { network: src.ref.network, address: CCTP_EVM.tokenMessenger, role: 'bridge' as const }]308 : [{ network: src.ref.network, address: CCTP_SOLANA.tokenMessengerMinter, role: 'program' as const }, { network: src.ref.network, address: CCTP_SOLANA.messageTransmitter, role: 'program' as const }]),309 ...(evmDst310 ? [{ network: dst.ref.network, address: CCTP_EVM.messageTransmitter, role: 'bridge' as const }, { network: dst.ref.network, address: CCTP_EVM.tokenMessenger, role: 'bridge' as const }]311 : [{ network: dst.ref.network, address: CCTP_SOLANA.messageTransmitter, role: 'program' as const }, { network: dst.ref.network, address: CCTP_SOLANA.tokenMessengerMinter, role: 'program' as const }]),312 ];313314 const build = (variant: 'standard' | 'fast', entry: z.infer<typeof FeesSchema>[number], seconds: number): SwapQuote | null => {315 const { fee, maxFee } = cctpFee(req.amountIn, entry.minimumFee);316 if (maxFee >= req.amountIn) return null; // the contract requires maxFee < amount317 const threshold = variant === 'fast' ? FINALITY_FAST : FINALITY_STANDARD;318 const label = variant === 'fast' ? 'CCTP V2 Fast Transfer' : 'CCTP V2 Standard Transfer';319 return {320 id: quoteId(variant, now), provider: ID, request: req,321 steps: [{322 kind: 'bridge', provider: ID, from: req.from, to: req.to,323 via: [324 label,325 `burn on ${src.ref.network}: ${evmSrc ? 'TokenMessengerV2.depositForBurn' : 'TokenMessengerMinterV2 deposit_for_burn'} (minFinalityThreshold ${threshold}, maxFee ${maxFee}); ${srcCost.note}`,326 `attestation by Circle (Iris) about ${seconds} s after the burn`,327 `mint on ${dst.ref.network}: ${evmDst ? 'MessageTransmitterV2.receiveMessage' : 'MessageTransmitterV2 receive_message'}; ${dstCost.note}`,328 ],329 contracts,330 }],331 amountOut: req.amountIn - fee, minAmountOut: req.amountIn - maxFee,332 fees: [{ kind: 'protocol', asset: req.to, amount: fee, includedInOutput: true, estimated: false }, ...srcCost.fees, ...dstCost.fees],333 estimatedSeconds: seconds, priceImpactBps: 0, expiresAt: now + 30_000, firmness: 'indicative',334 executability: { class: 'quote_only', reason: QUOTE_ONLY_USDC },335 // burned, not escrowed: nothing ever returns to the sender. If the mint is not submitted the funds stay336 // mintable — by anyone holding the attestation, only ever to mintRecipient — with no deadline.337 refundable: false, recoverable: true, atomic: false,338 raw: {339 product: 'cctp-v2', token: 'USDC', transfer: variant, sourceDomain: src.chain.domain, destinationDomain: dst.chain.domain,340 fees, allowance, feeBps: String(entry.minimumFee), protocolFee: fee.toString(), maxFee: maxFee.toString(),341 burn: {342 network: src.ref.network, contract: evmSrc ? CCTP_EVM.tokenMessenger : CCTP_SOLANA.tokenMessengerMinter,343 approval: evmSrc ? { token: src.ref.asset, spender: CCTP_EVM.tokenMessenger, amount: req.amountIn.toString() } : null,344 args: { amount: req.amountIn.toString(), destinationDomain: dst.chain.domain, mintRecipient, burnToken: src.ref.asset, destinationCaller: ZERO32, maxFee: maxFee.toString(), minFinalityThreshold: threshold },345 cost: srcCost.detail,346 },347 mint: { network: dst.ref.network, contract: evmDst ? CCTP_EVM.messageTransmitter : CCTP_SOLANA.messageTransmitter, submittedBy: 'wallet', cost: dstCost.detail },348 // the alternative the wallet does not use yet: Circle submits the mint and deducts this from the minted amount349 forwardingService: entry.forwardFee ?? null,350 ifMintNeverSubmitted: 'USDC is burned on the source chain and stays mintable to mintRecipient by anyone holding the attestation; no expiry for Standard, re-attestation for an expired Fast attestation; never refunded to the sender',351 },352 };353 };354355 const out: SwapQuote[] = [];356 const s = build('standard', standard, src.chain.standardSeconds); if (s) out.push(s);357 // Fast only where Circle offers it from this source and the global Fast allowance can absorb the burn right now358 if (fast && src.chain.fastSeconds !== null && fastCapacity >= req.amountIn) { const f = build('fast', fast, src.chain.fastSeconds); if (f) out.push(f); }359 return out;360}361362async function quoteEurc(req: SwapQuoteRequest, src: Leg, dst: Leg, ctx: ProviderContext): Promise<SwapQuote[]> {363 const url = `${CCTP_API}/${CCTP_API_VERSION}/quote/cctpx/${CCTPX.eurcTokenId}/${src.chain.domain}/${dst.chain.domain}`;364 const body = (requests: { type: string }[]) => ({ amount: req.amountIn.toString(), feeToken: ZERO_ADDRESS, requests });365 const [stdR, fastR, srcR, dstR] = await Promise.allSettled([366 ctx.http.postJson(url, body([]), { signal: ctx.signal }),367 src.chain.fastSource ? ctx.http.postJson(url, body([{ type: 'PRE_FINALITY' }]), { signal: ctx.signal }) : Promise.resolve(null),368 sourceCost('EURC', src, ctx),369 destinationCost('EURC', dst, req.recipient, ctx),370 ]);371 if (stdR.status === 'rejected' && httpStatus(stdR.reason) === 400) return []; // UNSUPPORTED_ROUTE, TOKEN_NOT_SUPPORTED_ON_ROUTE, amount refused372 const srcCost = settled(srcR), dstCost = settled(dstR);373 const now = ctx.now();374 // Iris refuses a fast quote outright when the per-token allowance or its FX pricing is unavailable: no Fast quote then375 const fastRefused = fastR.status === 'rejected' && httpStatus(fastR.reason) === 400;376 const fastRaw = fastRefused ? null : settled(fastR);377378 const build = (variant: 'standard' | 'fast', payload: unknown, seconds: number): SwapQuote => {379 const q = parse(CctpxQuoteSchema, payload, `${variant} quote`);380 const types = q.items.map((i) => i.type);381 const want = variant === 'fast' ? ['PRE_FINALITY', 'PROTOCOL'] : ['PROTOCOL'];382 if ([...types].sort().join() !== want.join()) throw schemaError(`${variant} quote: unexpected fee items ${types.join('+')}`);383 for (const i of q.items) {384 if (i.args[0]!.toLowerCase() !== CCTPX.eurcTokenId) throw schemaError('quote is for another token');385 if (i.type === 'PRE_FINALITY' && i.args[1] !== req.amountIn.toString()) throw schemaError('quote is for another amount');386 }387 const total = BigInt(q.feeTotalAmount);388 if (q.items.reduce((a, i) => a + BigInt(i.amount), 0n) !== total) throw schemaError('quote items do not add up to feeTotalAmount');389 if (total > 10n ** 18n) throw schemaError('quote fee is not plausible');390 if (q.expiry.expiresAt * 1000 <= now) throw schemaError('quote already expired');391 const threshold = variant === 'fast' ? FINALITY_FAST : FINALITY_STANDARD;392 return {393 id: quoteId(`eurc-${variant}`, now), provider: ID, request: req,394 steps: [{395 kind: 'bridge', provider: ID, from: req.from, to: req.to,396 via: [397 variant === 'fast' ? 'CCTP for non-USDC, Fast Transfer' : 'CCTP for non-USDC, Standard Transfer',398 `burn on ${src.ref.network}: CrossChainTokenService.crossChainTransfer (minFinalityThreshold ${threshold}) with ${total} wei of ${src.nativeSymbol} attached for Circle's fee; ${srcCost.note}`,399 `attestation by Circle (Iris) about ${seconds} s after the burn`,400 `mint on ${dst.ref.network}: MessageTransmitterV2.receiveMessage; ${dstCost.note}`,401 ],402 contracts: [403 { network: src.ref.network, address: CCTPX.eurcTokenManager, role: 'spender' },404 { network: src.ref.network, address: CCTPX.service, role: 'bridge' },405 { network: dst.ref.network, address: CCTP_EVM.messageTransmitter, role: 'bridge' },406 { network: dst.ref.network, address: CCTPX.service, role: 'bridge' },407 ],408 }],409 // fees are paid upfront in the source chain's coin: the full amount is minted410 amountOut: req.amountIn, minAmountOut: req.amountIn,411 fees: [...q.items.map((i): FeeItem => ({ kind: 'protocol', asset: native(src), amount: BigInt(i.amount), includedInOutput: false, estimated: false })), ...srcCost.fees, ...dstCost.fees],412 estimatedSeconds: seconds, priceImpactBps: 0, expiresAt: q.expiry.expiresAt * 1000, firmness: 'indicative',413 executability: { class: 'quote_only', reason: QUOTE_ONLY_EURC },414 refundable: false, recoverable: true, atomic: false,415 raw: {416 product: 'cctp-non-usdc', token: 'EURC', transfer: variant, sourceDomain: src.chain.domain, destinationDomain: dst.chain.domain, quote: q,417 burn: { network: src.ref.network, contract: CCTPX.service, approval: { token: src.ref.asset, spender: CCTPX.eurcTokenManager, amount: req.amountIn.toString() }, value: total.toString(), minFinalityThreshold: threshold, cost: srcCost.detail },418 mint: { network: dst.ref.network, contract: CCTP_EVM.messageTransmitter, submittedBy: 'wallet', cost: dstCost.detail },419 ifMintNeverSubmitted: 'EURC is burned on the source chain and stays mintable to the recipient by anyone holding the attestation; never refunded to the sender',420 },421 };422 };423 const out = [build('standard', settled(stdR), src.chain.standardSeconds)];424 if (src.chain.fastSource && src.chain.fastSeconds !== null && !fastRefused) out.push(build('fast', fastRaw, src.chain.fastSeconds));425 return out;426}427428export function createCctpProvider(_env: ProviderEnv = {}): SwapProvider {429 return {430 id: ID, name: 'Circle CCTP',431 capabilities: () => ({ networks: Object.keys(CCTP_CHAINS), crossChain: true, needsKey: false, configured: true, kinds: ['bridge'], docs: CCTP_DOCS }),432 supportsPair: (from, to) => pair(from, to) !== null,433 async quote(req, ctx) {434 const p = pair(req.from, req.to);435 if (!p || req.amountIn <= 0n) return [];436 // the request is echoed untouched (the aggregator compares it); the registry's own form of each asset is used for the legs437 return p.token === 'USDC' ? quoteUsdc(req, p.src, p.dst, ctx) : quoteEurc(req, p.src, p.dst, ctx);438 },439 };440}441