Skip to main content
Ctrl+ArcZ funds and settles in USDC on Arc, but the USDC often starts somewhere else. The SDK brings it over with Circle CCTP (burn and mint) and Circle Gateway (one balance, spendable anywhere), and both run in the browser, from the user’s own wallet.
Non-custodial, and that is the design, not a detail. The USDC being burned is whatever the connected wallet holds, and the mint goes back to that same wallet by default. No operator balance stands behind anyone’s transfer, so there is no liquidity to run out, no key on a server, and nothing to trust between the user and Circle. Nothing here can move funds belonging to anyone else.
The bridge screen, CCTP engine

Two engines

CCTP

Burn on the source chain, Circle attests, Circle mints on the destination. Widest chain coverage. One source-chain transaction per transfer. Steps: quote, approve, burn, attest, forward.

Gateway

Deposit once into a unified balance, then every spend is a signature: no source-chain transaction and no gas. Fewer chains, and the deposit has to confirm first. Steps: approve, deposit, quote, sign, transfer, mint.
The honest trade: CCTP costs a transaction every time but is ready immediately. Gateway costs a wait once and is nearly free every time after. Move money often between the same chains and Gateway wins; move it once and CCTP does. Choosing between the engines

CCTP: burn and mint

Circle submits the destination mint itself through its forwarder (FORWARDING_HOOK), so the user needs no gas on the destination chain. That is what makes arriving on Arc with an empty wallet work at all.
walletClient must be connected to from. recipient defaults to the sending wallet, which is what a bridge means; pass another address only when you deliberately intend to pay someone.
Quote immediately before the burn, and never reuse a quote. The forwarding fee is dynamic. A stale quote produces a maxFee too small for Circle to accept, which strands the transfer at exactly the step that cannot be undone. bridgeFromWallet quotes internally for this reason; quoteBridge is for showing a number, not for pre-computing one to pass in.

A slow transfer is not a lost transfer

The wait for Circle’s attestation is the longest part of a CCTP transfer by far. bridgeFromWallet reports attest the moment the burn confirms so a UI is not silent through it, and it returns after timeoutMs whether or not the mint landed. Returning early costs nothing, because the burn is on chain and the attestation does not expire. The burn hash is the receipt, and anyone holding it can ask again later:
undefined means “not yet”, never “gone”. Persist the burn hash the moment you have it: a browser that reloaded, a phone that was closed or a support ticket can all recover the transfer from that one value.

Gateway: one balance, spendable anywhere

Depositing into the Gateway balance
A plain ERC-20 transfer to the Gateway wallet does not deposit, and it does not fail loudly either: the USDC just sits at an address that will never credit it. depositToGateway calls the deposit method. Use it.

Drawing on several chains at once

from above is the single-chain shorthand. A Gateway balance is one figure spread over several chains and a transfer spends it per chain, so a wallet with enough in total can still be unable to pay from any one of them. Circle accepts up to 16 intents in a single request and EVM signs them together as a BurnIntentSet, so sources draws on as many chains as it takes and the user still signs once.
allocate is a pure function over a snapshot, so the same balances always give the same split and a client can price a route without asking anyone. Its order is not arbitrary: one leg beats two because an extra leg is an extra base fee for nothing, the cheaper chain wins among equals because base fees run from 0.001 to 1.00 USDC, and the roomiest leg goes first because Circle deducts the forwarding fee from the intents in the order they are passed, so a small leg placed first can end up owing more than it holds. It returns two figures and they are not interchangeable. fee is what Circle charges and what a user should be shown. ceiling is what the signature authorises, close to twice that, and it is what feasibility has to be judged against; maxDeliverable reserves at the ceiling for the same reason, so a Max button never fills in an amount the next check refuses.
Every one of these figures is USDC. Circle takes its fee out of the amount being sent rather than out of the source chain’s gas token, which is why a spend needs no gas on any chain. The deposit is the only step that costs gas, and only there does the chain’s own token matter.

The deposit wait is the whole cost, and it is uneven

gatewayBalance counts only deposits that have reached the confirmations Circle requires, because reporting a pending deposit as spendable produces an intent the API rejects. How long that takes depends entirely on the chain: Read it from DEPOSIT_CONFIRMATION_SECONDS rather than showing one number for all of them. Half a second and nineteen minutes are not the same product, and a caller that averages them is lying about ten of the eleven chains.

Getting money back out

from === to is not a mistake to refuse. Same-chain means Circle debits the balance and mints straight back to the wallet on that chain, which is the only quick exit a depositor has. isGatewayWithdrawal({ from, to }) recognises it, so a UI can label it “withdraw” instead of “bridge”.

Supported chains

CCTP covers 20 testnets; Gateway covers 11 of them. Both lists live in the SDK (CCTP_CHAINS, GATEWAY_CHAIN_NAMES), so read them rather than copying this table.
A CCTP domain is not a chain id. They are unrelated numbers and Circle’s API speaks domains, which is why CCTP_CHAINS carries both. Sonic Testnet and Morph Hoodi have no viem chain definition, so chainExplorerTxUrl returns undefined for them rather than guessing a URL that quietly goes somewhere else.
Picking a Gateway chain

Refusing before the user signs

The most useful thing a bridge UI does is refuse early, with the fix attached. bridge/refusal answers “can this transfer happen” and returns what to do about it, not just that it cannot.
Three things here are worth copying rather than reinventing:
  • Check the source chain’s balance, not the total. Circle reads the balance as one figure but spends it per chain: an intent carries a single source domain and draws only on what was deposited there. Checking the sum lets the check pass, the user sign, and Circle refuse afterwards, which is the exact opposite of what this exists to prevent. When another chain does have enough, the refusal comes back with a switchSource fix naming it.
  • The fee comes off the top. maxGatewaySpendable(here, feeCeiling) is the real ceiling. Offering the raw balance as “max” produces an amount that cannot pay its own fee.
  • On Arc, gas is USDC. The transfer and its gas come out of one balance, so cctpShortfall checks them together (gasInUsdc: true) and maxDepositable keeps a gas reserve behind. Checking them separately waves through a wallet that can afford the transfer and then cannot afford to send it.
refusal.code is a key into your own strings and refusal.params are already formatted for reading, so the SDK ships no user-facing English and your app keeps one voice.

Gas-sponsored claim

Bridging is keyless, but the gasless claim is not: it is the one cross-chain flow that still needs a server, because Circle Gas Station sponsors the user-operation through a Circle Smart Account owned by a relayer key.
The contract releases funds to the transfer’s recorded recipient, never to msg.sender, so the sponsoring account is only a relayer: it pays the gas and cannot redirect the money. The endpoint returns a serializable result rather than throwing across HTTP, encoding the same outcomes as claim() in error.kind. This route is relayer-funded, so it is authenticated and quota-limited. See The API for the signed-request headers, and Errors and result shapes for the taxonomy.

Next