> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlarcz.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Bridge USDC

> Move native USDC across chains from the user's own wallet with Circle CCTP and Gateway. No relayer, no custody, no server.

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

<Note>
  **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.
</Note>

<img src="https://mintcdn.com/ctrlarcz/pKCJPq_LT-Wjc1hI/screens/10-bridge-cctp.png?fit=max&auto=format&n=pKCJPq_LT-Wjc1hI&q=85&s=f06a949419d73c1e5d1400ed1514e834" alt="The bridge screen, CCTP engine" width="620" height="991" data-path="screens/10-bridge-cctp.png" />

## Two engines

<CardGroup cols={2}>
  <Card title="CCTP" icon="fire">
    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`.
  </Card>

  <Card title="Gateway" icon="wallet">
    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`.
  </Card>
</CardGroup>

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.

<img src="https://mintcdn.com/ctrlarcz/pKCJPq_LT-Wjc1hI/screens/11-bridge-engines.png?fit=max&auto=format&n=pKCJPq_LT-Wjc1hI&q=85&s=37711d453ec4d1addc6e8b963fc7056c" alt="Choosing between the engines" width="620" height="672" data-path="screens/11-bridge-engines.png" />

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

```ts theme={null}
import { quoteBridge, bridgeFromWallet } from '@ctrl-arcz/sdk';
import { parseUnits } from 'viem';

const amount = parseUnits('5', 6);

// Price it first if you want to show the fee before anything is signed.
const quote = await quoteBridge({ from: 'Base_Sepolia', to: 'Arc_Testnet', amount });
// { amount, maxFee, total } - total is what leaves the wallet

const result = await bridgeFromWallet(
  { publicClient, walletClient },
  {
    from: 'Base_Sepolia',
    to: 'Arc_Testnet',
    amount,
    onQuote: (q) => showFee(q.maxFee),
    onStep: (step, txHash) => showProgress(step, txHash),
  },
);
// { approveTxHash?, burnTxHash, forwardTxHash?, quote }
```

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

<Warning>
  **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.
</Warning>

### 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:

```ts theme={null}
import { findForwardedMint, waitForForwardedMint, CCTP_CHAINS } from '@ctrl-arcz/sdk';

// One shot: has Circle minted yet?
const mint = await findForwardedMint({
  sourceDomain: CCTP_CHAINS.Base_Sepolia.domain,
  burnTxHash,
});

// Or keep watching, up to timeoutMs (default 180000).
const mint2 = await waitForForwardedMint({ sourceDomain, burnTxHash, timeoutMs: 60_000 });
```

`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

<img src="https://mintcdn.com/ctrlarcz/pe0Rvqjn7AnGz2-H/screens/12-bridge-gateway.png?fit=max&auto=format&n=pe0Rvqjn7AnGz2-H&q=85&s=b55474bc91a23304cd279e88d628a064" alt="Depositing into the Gateway balance" width="620" height="1600" data-path="screens/12-bridge-gateway.png" />

```ts theme={null}
import {
  depositToGateway,
  gatewayBalance,
  quoteGatewaySpend,
  spendFromGateway,
} from '@ctrl-arcz/sdk';

// 1. Deposit, once, on whichever chain the USDC is on today.
await depositToGateway({ publicClient, walletClient }, {
  chain: 'Base_Sepolia',
  amount: parseUnits('20', 6),
  onStep: (step, txHash) => showProgress(step, txHash),
});

// 2. Read what is actually spendable.
const balance = await gatewayBalance({ depositor: address });
// { total, byChain: { Base_Sepolia: 20000000n, ... } }

// 3. Spend it anywhere. A signature, no source-chain transaction, no gas.
const spend = await spendFromGateway({ walletClient }, {
  from: 'Base_Sepolia',
  to: 'Arc_Testnet',
  amount: parseUnits('5', 6),
  onTransferId: (id) => persist(id),
});
// { transferId, mintTxHash?, quote }
```

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

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

```ts theme={null}
import { allocate, maxDeliverable, spendFromGateway, gatewayBalance } from '@ctrl-arcz/sdk';

const { byChain } = await gatewayBalance({ depositor: address });
const balances = Object.entries(byChain).map(([chain, balance]) => ({ chain, balance }));

// Which chains to draw from, and how much from each. Pure, no network calls.
const plan = allocate({ amount: parseUnits('12', 6), balances, forwarding: quote.forwardingFee });
if (plan.shortfall > 0n) return tellTheUser(plan.shortfall);

await spendFromGateway({ walletClient }, {
  sources: plan.legs,        // [{ chain, value }, ...]
  to: 'Arc_Testnet',
  onTransferId: (id) => persist(id),
});
```

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

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

### 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:

| Deposit chain | Confirmation wait |
| - | - |
| Arc Testnet | \~1s |
| Sei Testnet | \~5s |
| Avalanche Fuji, Polygon Amoy, Sonic Testnet | \~8s |
| Ethereum, Base, OP, Arbitrum, Unichain, World Chain Sepolia | up to \~19 min |

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.

| Chain | CCTP | Gateway |
| - | :-: | :-: |
| Arc Testnet | yes | yes |
| Ethereum Sepolia | yes | yes |
| Base Sepolia | yes | yes |
| Avalanche Fuji | yes | yes |
| OP Sepolia | yes | yes |
| Arbitrum Sepolia | yes | yes |
| Polygon Amoy | yes | yes |
| Unichain Sepolia | yes | yes |
| Sonic Testnet | yes | yes |
| World Chain Sepolia | yes | yes |
| Sei Testnet | yes | yes |
| Linea Sepolia | yes | no |
| Codex Testnet | yes | no |
| Monad Testnet | yes | no |
| XDC Apothem | yes | no |
| Ink Testnet | yes | no |
| Plume Testnet | yes | no |
| Injective Testnet | yes | no |
| Morph Hoodi | yes | no |
| Cronos Testnet | yes | no |

```ts theme={null}
import {
  CCTP_CHAINS,
  GATEWAY_CHAIN_NAMES,
  cctpChainByChainId,
  chainExplorerTxUrl,
  chainLabel,
} from '@ctrl-arcz/sdk';

CCTP_CHAINS.Arc_Testnet;              // { domain: 26, chainId: 5042002, usdc, gasToken: 'usdc' }
cctpChainByChainId(await walletClient.getChainId()); // which chain the wallet is on
chainLabel('Base_Sepolia');           // 'Base Sepolia'
chainExplorerTxUrl('Base_Sepolia', burnTxHash); // undefined where no explorer is known
```

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

<img src="https://mintcdn.com/ctrlarcz/pe0Rvqjn7AnGz2-H/screens/13-gateway-chains.png?fit=max&auto=format&n=pe0Rvqjn7AnGz2-H&q=85&s=c8516ec57f40d6db88d4065d008c6db0" alt="Picking a Gateway chain" width="620" height="1600" data-path="screens/13-gateway-chains.png" />

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

```ts theme={null}
import {
  gatewayShortfall,
  cctpShortfall,
  maxGatewaySpendable,
  maxDepositable,
  usdc,
} from '@ctrl-arcz/sdk';

const refusal = gatewayShortfall({
  here: balance.byChain[from] ?? 0n,  // the balance ON THE SOURCE CHAIN
  byChain: balance.byChain,
  from,
  fromLabel: chainLabel(from),
  committed: amount + quote.maxFee,
  labelOf: chainLabel,
});

if (refusal) {
  // refusal.code is a string key, so the SDK holds no English.
  // refusal.fix is the action: switchSource | deposit | useMax
  render(t(refusal.code, refusal.params), refusal.fix);
  return;
}
```

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.

```ts theme={null}
const res = await fetch('https://api.ctrlarcz.xyz/api/gasless-claim', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ transferId: '42', code: 'A4K79QMX2PR6TH8D', salt: '0x...' }),
});
const result = await res.json();
// { ok: true, txHash } | { ok: false, error: { kind, attemptsRemaining?, reason?, message? } }
```

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](/reference/api) for the signed-request headers, and
[Errors and result shapes](/guides/errors-and-results) for the taxonomy.

## Next

* Handle the outcomes: [Errors and result shapes](/guides/errors-and-results).
* The server-side routes: [The API](/reference/api).
* Run it yourself: [Local development](/guides/local-development).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.