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.

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.
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.
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

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.
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.
- 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
switchSourcefix 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
cctpShortfallchecks them together (gasInUsdc: true) andmaxDepositablekeeps 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.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
- Handle the outcomes: Errors and result shapes.
- The server-side routes: The API.
- Run it yourself: Local development.