Skip to content

0014 · CCTP, both directions

USDC reaches the hub from Base and Solana over CCTP, which burns on the source chain and mints canonical USDC on the destination. Two directions were specified: inflows to the hub, and one outflow for the naira corridor, whose licensed partner settles on Solana.

Circle’s references give Stellar’s domain (27), the Soroban contract ids, the CctpForwarder.mint_and_forward(message, attestation) signature, the hook-data layout and the attestation API. They do not publish the argument order for the burn entry points — deposit_for_burn on Stellar, depositForBurnWithHook on an EVM chain — in a form that could be verified from the documentation alone.

Build the receiving half and the deposit instruction. Do not write the burn.

  • depositInstruction returns what a sender’s wallet needs to construct the burn: destination domain, mintRecipient, destinationCaller and hookData.
  • Iris waits for Circle’s attestation.
  • mintAndForward replays the attested message on Stellar through the forwarder.
  • cctpTransferLeg runs those as a saga step and books both sides.

This matches who owns the money. On the way in the funds are the sender’s, on the sender’s own chain: in Ramp’s flow an app’s user burns from their own wallet, and Corridor’s job is to say where to send it and to complete the mint. Corridor only needs to burn for its own outflows, which is the direction left unbuilt.

  • Inflows work; outflows do not. Mainnet naira payouts still wait on a burn, so testnet keeps using bridge.simulated.
  • Guessing an argument order in money-moving code would risk funds that cannot be recovered — a burn whose mintRecipient is wrong mints nowhere. Reading the contract source, or Circle’s SDK, is the work that unblocks it.
  • The mint record is keyed by the burn transaction rather than the saga step, because one burn may only ever be minted once however many sagas see it.
  • A mint is a contract call, so Horizon’s payment stream does not show it. Corridor books it from the transaction it submitted. Detecting an inbound CCTP mint on an account Corridor does not control would need Soroban RPC getEvents, which read-only mode does not have yet.
  • Writing the burn from the documentation’s prose. The argument order is the whole risk, and it is the one thing the prose does not give.
  • Burning on the user’s behalf in Ramp. It would mean holding the user’s funds on the source chain, which is the custody position the plan rules out (ADR 0013).
  • Accepting a wrapped USDC inflow instead. A second representation of the dollar to reconcile, against a ledger whose claim is that it matches the chain.

The burn is built on Solana. What unblocked it was reading Circle’s own Solana quickstart and its Solana programs reference instead of its prose: those give the Anchor discriminators, the eight arguments in order and the full twenty-account list with every PDA seed, which is exactly what was missing.

encodeDepositForBurn writes the instruction data and depositForBurnAccounts the account list, both tested byte for byte and position by position, because argument order and account order are the ABI. buildBurnToStellar composes them through depositInstruction — the same function that tells an external sender what to put in a burn — so the two halves of the lane cannot disagree about where the money goes.

What this changes:

  • Corridor can bring USDC from Solana to Stellar under its own key, where before only an external sender could start a transfer.
  • The Tron lane becomes possible as two hops: Tron USDT → Solana USDC by intent, then Solana → Stellar by burn-and-mint. NEAR Intents’ $100 minimum still applies, and it still does not route Stellar directly.

What it does not change: the Soroban deposit_for_burn on Stellar is still unwritten, so float cannot be moved off the hub. That direction needs the same treatment — the contract source read for its argument order.

The Soroban burn is built, so CCTP now runs in both directions and float can move off the hub.

Circle still publishes the function names without their arguments. The signature came from the deployed contract’s own interface spec, fetched off the network — a Soroban contract carries its spec in its WASM, so the authority was there the whole time:

deposit_for_burn(caller: Address, amount: i128, destination_domain: u32,
mint_recipient: BytesN<32>, burn_token: Address,
destination_caller: BytesN<32>, max_fee: i128,
min_finality_threshold: u32)
deposit_for_burn_with_hook(... the same ..., hook_data: Bytes)

Two details vindicate having refused to guess. burn_token sits between mint_recipient and destination_caller, which is not where the EVM or Solana entry points put it — an order copied from those chains would have compiled and spent from the wrong place. And amount is an i128, not the u64 Solana takes, so a large balance would have truncated silently.

The lesson generalises: when a protocol will not document its ABI, the deployed artefact usually declares it. Ask the chain rather than the docs.