SPB Git

Repository Public

cctp.ts 30.0 KB 441 lines typescript

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
Files main
Menu

Theme