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

# Cross-chain USDC

> Fund a Chipi wallet from Base or another EVM chain, and withdraw to one, through Circle's CCTP V2: one hook each, the recipient always checked, Chipi minting on Starknet.

A Chipi wallet lives on Starknet, but a user's USDC often doesn't. With Circle's Cross-Chain Transfer Protocol (CCTP V2) they can bring native USDC from Base, Arbitrum, Optimism, Polygon or Ethereum straight into their Chipi wallet, and send it back out, with no bridge token and no custodian in between: USDC is burned on one chain and minted on the other by Circle.

> [!NOTE]
>
> **Who pays what.** Depositing, the user pays gas on the source chain (cents on an L2) and chooses the speed: **standard** is free and takes about 15–20 minutes from an L2 (Circle waits for Ethereum finality); **fast** takes about a minute for Circle's fee (1.3 bps from Base, so $0.0325 on $25), taken from the amount. Chipi pays the Starknet mint. Withdrawing, the user pays no gas (Chipi's paymaster), Circle takes a fast fee (12 bps out of Starknet), and Chipi pays it back to the recipient under its sponsorship policy (`quoteCrossChainWithdrawal` says whether it does for this user).

## How a deposit works

1. The user's EVM wallet signs `approve` and `depositForBurn` on the source chain (one prompt when the wallet batches them with EIP-5792, two otherwise). The burn names the Chipi wallet as the 32-byte `mintRecipient`.
2. The SDK registers the burn with Chipi as soon as its hash exists. From here the deposit completes even if the page closes: Chipi's delivery service polls Circle and mints from its own account.
3. Circle attests the burn once the source chain reaches the chosen finality.
4. Chipi calls `receive_message` on Starknet's MessageTransmitter; the USDC appears in the Chipi wallet.
5. The SDK records the deposit so it shows in the wallet's history with its source chain.

A wrong `mintRecipient` would mint USDC to an address nobody controls, so the recipient is never typed: it is the Chipi wallet, and `assertMintRecipient` decodes the encoded value back and compares it before anything is signed. Every other failure leaves the money in flight, not lost: a burn with `destinationCaller = 0` can be minted by anyone at any time, and a fast attestation that expires is simply requested again.

## Fund from Base in one hook

`useCrossChainDeposit` quotes both speeds, burns through any EIP-1193 wallet, registers, waits for the mint and records it. `discoverEvmWallets` lists the wallets installed in the browser (EIP-6963).

```tsx theme={null}
import { useState } from "react";
import { useCrossChainDeposit, useCctpDepositStatus } from "@chipi-stack/nextjs";
import {
  CCTP_EVM_CHAINS,
  discoverEvmWallets,
  formatCctpEta,
  type CctpFinality,
  type CctpQuote,
} from "@chipi-stack/backend";

const BASE = CCTP_EVM_CHAINS.find((c) => c.key === "base")!;

export function FundFromBase() {
  const { quote, depositAsync, isLoading, error } = useCrossChainDeposit();
  const [quotes, setQuotes] = useState<Record<CctpFinality, CctpQuote> | null>(null);
  const [status, setStatus] = useState("");
  const [burnTxHash, setBurnTxHash] = useState<string | null>(null);
  // Resumes on any device after the page closed: keep the burn hash.
  const followed = useCctpDepositStatus({ burnTxHash, getBearerToken: getToken });

  const showQuotes = async () => setQuotes(await quote({ chain: BASE, amount: "25" }));

  const fund = async (finality: CctpFinality) => {
    const [evmWallet] = await discoverEvmWallets();
    if (!evmWallet) throw new Error("No EVM wallet is installed");
    const result = await depositAsync({
      provider: evmWallet.provider,
      chain: BASE,
      amount: "25",
      walletAddress: wallet.address, // the Chipi wallet: the only possible recipient
      finality,
      getBearerToken: getToken, // asked fresh at every step; tokens expire during the wait
      onStatus: ({ step, eta, burnTxHash }) => {
        if (burnTxHash) setBurnTxHash(burnTxHash);
        setStatus(`${step}: ${formatCctpEta(eta)}`);
      },
    });
    console.log("minted on Starknet:", result.mintTxHash);
  };

  return (
    <div>
      <button onClick={showQuotes}>Quote 25 USDC from Base</button>
      {quotes && (
        <>
          <button onClick={() => fund("standard")} disabled={isLoading}>
            Free, {formatCctpEta(quotes.standard.eta)}: receive {quotes.standard.receivesUsdc} USDC
          </button>
          <button onClick={() => fund("fast")} disabled={isLoading}>
            {formatCctpEta(quotes.fast.eta)} for {quotes.fast.feeUsdc} USDC: receive {quotes.fast.receivesUsdc}
          </button>
        </>
      )}
      <p>{status}</p>
      {followed.data?.status === "MINTED" && <p>Arrived: {followed.data.mintTx}</p>}
      {error && <p>{error.message}</p>}
    </div>
  );
}
```

`onStatus` reports each step (`switching-chain`, `approving`, `burning`, `registering`, `attesting`, `minting`, `recording`, `done`) with how long it usually takes, so you can show a countdown instead of a spinner. The attestation is the long step and follows the route: `cctpStepEta("attesting", quote)`.

If `depositAsync` rejects with a `CrossChainDepositError`, the burn already happened: the error carries `burnTxHash` and `sourceDomain`. Keep them, and either call `registerCctpDeposit` again or follow the status with `useCctpDepositStatus`.

## Withdraw to Base

`useCrossChainWithdraw` signs the Starknet burn with the wallet's own key (passkey or `encryptKey`, as `useCallAnyContract`), waits for it, records it and asks Chipi's relayer to deliver on the destination chain. Withdrawals are always fast: a standard transfer out of Starknet waits hours for the L1 proof.

```tsx theme={null}
import { useCrossChainWithdraw } from "@chipi-stack/nextjs";
import { CCTP_EVM_CHAINS, formatCctpEta } from "@chipi-stack/backend";
import type { WalletData } from "@chipi-stack/types";

const BASE = CCTP_EVM_CHAINS.find((c) => c.key === "base")!;

export function WithdrawToBase({ chipiWallet, recipient }: { chipiWallet: WalletData; recipient: string }) {
  const { withdrawAsync, isLoading, error } = useCrossChainWithdraw();

  const withdraw = async () => {
    const result = await withdrawAsync({
      wallet: chipiWallet,
      usePasskey: true,
      externalUserId,
      chain: BASE,
      recipient, // checked: hex, not zero, EIP-55 when it carries mixed case
      amount: "7.81",
      getBearerToken: getToken,
      onStatus: ({ step, eta }) => console.log(step, formatCctpEta(eta)),
    });
    console.log("delivered on Base:", result.destTxHash, "sponsored:", result.quote.sponsored);
  };

  return (
    <button onClick={withdraw} disabled={isLoading}>
      {error ? error.message : "Withdraw 7.81 USDC to Base"}
    </button>
  );
}
```

The burn transaction carries `approve` and `deposit_for_burn` with `maxFee` set to the quoted fee (`cctpMaxFee`), so Circle can attest fast; Circle only ever takes the real fee. If the relayer step fails after the burn, the error is a `CrossChainWithdrawError` with the `burnTxHash`: call `finalizeCrossChainWithdrawal` again, the relayer is idempotent.

## From a server

The same calls exist on `ChipiSDK`, for a backend that drives deposits itself (a treasury topping up users, a job that resumes a deposit whose browser is gone).

```ts theme={null}
import { ChipiServerSDK, CCTP_EVM_CHAINS, cctpQuote } from "@chipi-stack/backend";

export async function resumeDeposit(burnTxHash: string, chipiWallet: string) {
  const sdk = new ChipiServerSDK({ apiPublicKey: "pk_prod_...", apiSecretKey: "sk_prod_..." });
  const base = CCTP_EVM_CHAINS.find((c) => c.key === "base")!;

  // Both speeds for 25 USDC from Base: fee in bps and base units, net amount, ETA.
  const quotes = await cctpQuote({ sourceDomain: base.domain, destinationDomain: 25, amount: "25" });

  // Register a burn the browser made but never registered (idempotent).
  await sdk.registerCctpDeposit({
    burnTxHash,
    sourceDomain: base.domain,
    mintRecipient: chipiWallet,
    amountUsdc: quotes.standard.amountUsdc,
  });
  // PENDING | MINTED (with mintTx) | FAILED | UNREGISTERED
  return sdk.getCctpDepositStatus(burnTxHash);
}
```

Withdrawals: `quoteCrossChainWithdrawal`, `recordCrossChainWithdrawal`, `finalizeCrossChainWithdrawal`. Chipi reads the burn's own `DepositForBurn` event for the amount, destination and recipient, so the values a client records only need to agree with the chain.

## Building blocks

Everything the hooks use is exported from `@chipi-stack/backend`, for apps that need a different flow:

| | |
| - | - |
| `CCTP_EVM_CHAINS`, `cctpEvmChainByKey` | Base, Arbitrum, Optimism, Polygon, Ethereum: chain id, Circle domain, native USDC, explorer. `verified` marks the chains Chipi has run a real transfer on (Base). |
| `CCTP_STARKNET` | Circle's `TokenMessengerMinterV2`, `MessageTransmitterV2` and native USDC on Starknet; domain 25. |
| `cctpQuote`, `cctpRoute`, `cctpStepEta`, `formatCctpEta` | Fee (Circle's live fee API) and attestation time per source chain and finality. |
| `depositFromEvm`, `discoverEvmWallets` | The EIP-1193 deposit (chain switch and check, balance and allowance, EIP-5792 batch or approve + burn, receipt check). |
| `buildCctpWithdrawToEvmCalls`, `cctpMaxFee` | The Starknet `approve` + `deposit_for_burn` calls of a withdrawal. |
| `buildCctpReceiveMessageCall` | The Starknet mint of a deposit, for a wallet that wants to deliver it itself. |
| `starknetAddressToMintRecipient`, `assertMintRecipient`, `assertEvmAddress` | Recipient encodings and the gates before signing. |
| `decodeCctpMessage`, `isCctpMessageExpired` | Circle's attested message, field by field. |

## Solana (optional entry point)

Solana is served by `@chipi-stack/backend/solana`, a separate entry point that needs `@solana/web3.js` (an optional peer dependency): apps that never touch Solana install nothing new. Standard finality on Solana is about 25 seconds and free, so a deposit from Solana has no fast fee to weigh. `@solana/web3.js` v1 wants a global `Buffer` in the browser; polyfill it before importing this entry point.

**Deposit.** The owner's wallet (Phantom, Solflare, Backpack…) signs Circle's `depositForBurn`; the recipient is the Chipi wallet, checked before the transaction exists. `depositFromSolana` waits until the burn has landed and names a failure instead of leaving the UI polling for a burn that never happened: `solana_insufficient_sol` (the burn creates a message account whose rent the owner pays, about 0.004 SOL), `solana_insufficient_usdc`, `solana_burn_failed`, `solana_burn_not_landed`. Then register the burn as for an EVM deposit.

```ts theme={null}
import { Connection } from "@solana/web3.js";
import { ChipiBrowserSDK } from "@chipi-stack/backend";
import { depositFromSolana, solanaUsdcBalance, type SolanaWalletSigner } from "@chipi-stack/backend/solana";

const connection = new Connection("https://api.mainnet-beta.solana.com", "confirmed");
const phantom = (window as unknown as { solana: SolanaWalletSigner }).solana;
const sdk = new ChipiBrowserSDK({ apiPublicKey: "pk_prod_..." });

export async function fundFromSolana(owner: string) {
  const { usdc, hasRentForBurn } = await solanaUsdcBalance(connection, owner);
  if (!hasRentForBurn) throw new Error("add about 0.005 SOL for the fee and the message rent");
  if (usdc < 1_000_000n) throw new Error("not enough USDC");

  const burn = await depositFromSolana({
    connection,
    wallet: phantom,
    owner,
    amount: 1_000_000n, // 1 USDC, base units
    starknetRecipient: wallet.address, // the Chipi wallet: the only possible recipient
  });
  // Chipi mints on Starknet from here on, even if the page closes.
  await sdk.registerCctpDeposit(
    { burnTxHash: burn.burnTxHash, sourceDomain: burn.sourceDomain, mintRecipient: wallet.address, amountUsdc: "1" },
    bearerToken
  );
  return burn.burnTxHash;
}
```

**Withdraw.** The burn on Starknet must name the owner's USDC **token account**, never the owner's key: `receiveMessage` mints to the account the message names, and a wrong one strands the USDC. `buildCctpWithdrawToSolanaCalls` derives it, `isSolanaWalletAddress` refuses a token account or program address typed as the owner, and `solanaUsdcAtaExists` checks the account exists (Circle does not create it). Record and finalize with `recordCrossChainWithdrawal` (`recipientOwner` = the owner) and `finalizeCrossChainWithdrawal`, as for an EVM destination.

```ts theme={null}
import { Connection } from "@solana/web3.js";
import { cctpFees, cctpMaxFee, CCTP_STARKNET_DOMAIN, parseUsdc } from "@chipi-stack/backend";
import {
  SOLANA_CCTP,
  buildCctpWithdrawToSolanaCalls,
  isSolanaWalletAddress,
  solanaUsdcAtaExists,
} from "@chipi-stack/backend/solana";

export async function withdrawCalls(owner: string, humanAmount: string) {
  if (!isSolanaWalletAddress(owner)) throw new Error("that is not a Solana wallet address");
  const connection = new Connection(SOLANA_CCTP.defaultRpc, "confirmed");
  if (!(await solanaUsdcAtaExists(connection, owner))) {
    throw new Error("this wallet has no USDC account yet; receive any USDC on Solana once first");
  }
  const amount = parseUsdc(humanAmount);
  const fees = await cctpFees(CCTP_STARKNET_DOMAIN, SOLANA_CCTP.domain);
  return buildCctpWithdrawToSolanaCalls({ amount, owner, maxFee: cctpMaxFee(amount, fees.fast) });
}
```

For a relayer, `buildSolanaReceiveMessageTx` is Circle's `receiveMessage` with the source domain, nonce and burn token read from the attested message, and `isSolanaCctpMessageUsed` says whether a message was already received (the used-nonce account).

## What is not here yet

* **A React hook for Solana deposits.** `useCrossChainDeposit` takes EVM wallets; from Solana, call `depositFromSolana` and `registerCctpDeposit` as above, then follow the burn with `useCctpDepositStatus`.
* **Expo**: `depositFromEvm` needs a browser EIP-1193 provider.
* **Arbitrum, Optimism, Polygon and Ethereum** are wired and listed, but Chipi has not run a real transfer on them yet (`verified: false`). Ethereum deposits cost mainnet gas.

> ✅ Verified against the live API on **2026-10-24**.


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