Skip to main content
This guide shows how to transfer USDC crosschain using the Circle Forwarding Service. This example shows a transfer from Base Sepolia to Avalanche Fuji, but you can use the same steps to transfer to any supported destination chain. When you use the Forwarding Service, Circle handles the mint transaction on the destination chain, eliminating the need for you to hold native tokens for gas on the destination chain or run multichain infrastructure.

Prerequisites

Before you start, ensure you have:
  • Installed Node.js v22+
  • Created a TypeScript project and installed the viem package.
  • Created a wallet with the private key available on the source chain.
  • Funded the wallet with testnet USDC and native tokens for gas fees on the source chain.
  • Created a .env file with your private key and recipient address.

Steps

Use the following steps to transfer USDC with the Forwarding Service.

Step 1. Get CCTP fees from the API

Query the CCTP API for the fees for transferring USDC from Base Sepolia to Avalanche Fuji. This value is passed to the maxFee parameter in the depositForBurnWithHook transaction. The following is an example request using source domain 6 (Base Sepolia) and destination domain 1 (Avalanche Fuji):
const response = await fetch(
  "https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/6/1?forward=true",
  {
    method: "GET",
    headers: { "Content-Type": "application/json" },
  },
);

const fees = await response.json();
console.log(fees);
Example response:
[
  {
    "finalityThreshold": 1000, // Fast transfer
    "minimumFee": 1.3, // Cents of USDC (0.013 USDC flat fee)
    "forwardFee": {
      // Gas-based, fluctuates based on destination chain gas prices
      "low": 206035, // 0.206035 USDC
      "med": 207543, // 0.207543 USDC
      "high": 209052 // 0.209052 USDC
    }
  },
  {
    "finalityThreshold": 2000, // Standard transfer
    "minimumFee": 0, // No fee
    "forwardFee": {
      "low": 206035, // 0.206035 USDC
      "med": 207543, // 0.207543 USDC
      "high": 209052 // 0.209052 USDC
    }
  }
]
The forwardFee is the fee charged by the Forwarding Service. The minimumFee is the CCTP protocol fee (a flat fee in cents of USDC).
Circle recommends selecting the med fee level or higher from the forwardFee object in the API response. Note that forwardFee values fluctuate based on destination chain gas prices. Make the query immediately before initiating your transfer.

Step 2. Calculate the USDC amounts and fees

Calculate the total fee by combining the protocol fee and the Forwarding Service fee. The maxFee parameter must cover both fees for the transfer to succeed.
// Amount to transfer (10 USDC in subunits)
const transferAmount = 10_000_000n;

// Parse fees from API response
const feeData = fees[0]; // Use fast transfer fees (finalityThreshold: 1000)
const forwardFee = BigInt(feeData.forwardFee.med);

// Calculate protocol fee (minimumFee is in cents of USDC)
const minimumFeeCents = feeData.minimumFee;
const protocolFee = BigInt(Math.floor(minimumFeeCents * 10_000)); // Convert cents to subunits

// Total max fee should cover both fees
const maxFee = forwardFee + protocolFee;
const totalAmount = transferAmount + maxFee; // Total to burn

console.log("Transfer amount:", Number(transferAmount) / 1_000_000, "USDC");
console.log("Forward fee:", Number(forwardFee) / 1_000_000, "USDC");
console.log("Protocol fee:", Number(protocolFee) / 1_000_000, "USDC");
console.log("Max fee:", Number(maxFee) / 1_000_000, "USDC");
console.log("Total to burn:", Number(totalAmount) / 1_000_000, "USDC");
In this example, for a 10 USDC transfer with forwarding, the total fee is 0.220543 USDC (0.207543 USDC Forwarding Service fee + 0.013 USDC CCTP protocol fee). For the recipient to receive 10 USDC, you must burn 10.220543 USDC in total.
If the maxFee parameter is insufficient to cover the both Fast Transfer protocol fee and the Forwarding Service fee, CCTP will prioritize forwarding execution over Fast Transfer. This means that the transfer will execute as a Standard Transfer with the Forwarding Service.

Step 3. Approve the USDC transfer

Grant approval for the TokenMessengerV2 contract deployed on Base to transfer USDC from your wallet. Approve at least totalAmount (including fees) calculated in Step 2.
import { createWalletClient, http, encodeFunctionData } from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";

// Configuration
const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const BASE_SEPOLIA_TOKEN_MESSENGER =
  "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA";

// Set up wallet client
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const client = createWalletClient({
  chain: baseSepolia,
  transport: http(),
  account,
});

async function approveUSDC(amount: bigint) {
  console.log("Approving USDC transfer...");
  const approveTx = await client.sendTransaction({
    to: BASE_SEPOLIA_USDC,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "approve",
          stateMutability: "nonpayable",
          inputs: [
            { name: "spender", type: "address" },
            { name: "amount", type: "uint256" },
          ],
          outputs: [{ name: "", type: "bool" }],
        },
      ],
      functionName: "approve",
      args: [BASE_SEPOLIA_TOKEN_MESSENGER, amount],
    }),
  });
  console.log("USDC Approval Tx:", approveTx);
  return approveTx;
}

// Approve the total amount (from Step 2)
await approveUSDC(totalAmount);

Step 4. Sign and broadcast a depositForBurnWithHook transaction on the TokenMessengerV2 contract

Create and send a depositForBurnWithHook transaction with the Forwarding Service hook data. The hook data tells the CCTP Forwarding Service to automatically forward the mint transaction on the destination chain. The Forwarding Service hook data is a static 32-byte value containing the magic bytes cctp-forward, version 0, and length 0. For details on the hook format, see Forwarding Service hook format.
// Forwarding Service hook data: magic bytes ("cctp-forward") + version (0) + additional data length (0)
const FORWARDING_SERVICE_HOOK_DATA =
  "0x636374702d666f72776172640000000000000000000000000000000000000000";
Then, send the depositForBurnWithHook transaction:
Use totalAmount (transfer amount + fees) for the amount parameter. The recipient receives only the transfer amount after fees are deducted.
import { pad, encodeFunctionData } from "viem";

// Configuration
const AVALANCHE_FUJI_DOMAIN = 1;
const DESTINATION_ADDRESS = "0xYOUR_DESTINATION_ADDRESS" as `0x${string}`;

// Convert address to bytes32 format
const mintRecipientBytes32 = pad(DESTINATION_ADDRESS, { size: 32 });

async function depositForBurnWithHook() {
  console.log("Burning USDC on Base with Forwarding Service hook...");

  const burnTx = await client.sendTransaction({
    to: BASE_SEPOLIA_TOKEN_MESSENGER,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "depositForBurnWithHook",
          stateMutability: "nonpayable",
          inputs: [
            { name: "amount", type: "uint256" },
            { name: "destinationDomain", type: "uint32" },
            { name: "mintRecipient", type: "bytes32" },
            { name: "burnToken", type: "address" },
            { name: "destinationCaller", type: "bytes32" },
            { name: "maxFee", type: "uint256" },
            { name: "minFinalityThreshold", type: "uint32" },
            { name: "hookData", type: "bytes" },
          ],
          outputs: [],
        },
      ],
      functionName: "depositForBurnWithHook",
      args: [
        totalAmount, // Total to burn (recipient receives transferAmount after fees)
        AVALANCHE_FUJI_DOMAIN,
        mintRecipientBytes32,
        BASE_SEPOLIA_USDC,
        pad("0x", { size: 32 }), // destinationCaller (empty = any caller)
        maxFee,
        1000,
        FORWARDING_SERVICE_HOOK_DATA,
      ],
    }),
  });
  console.log("Burn Tx:", burnTx);
  return burnTx;
}
Once the burn transaction is confirmed on Base, the Circle Forwarding Service automatically handles the attestation and mint transaction on Avalanche. The USDC is minted directly to the mintRecipient address on the destination chain. The recipient receives transferAmount USDC (fees are automatically deducted from the burned amount).

Step 5. Verify the mint transaction

After the burn transaction is confirmed, query the Circle Iris API to retrieve the forwarding details. The API returns the forwardTxHash, which is the mint transaction hash on the destination chain. The attestation may take time to become available, depending on the destination chain. Poll the API until the message is ready:
// Configuration
const BASE_SEPOLIA_DOMAIN = 6;

process.stdout.write("Waiting for attestation...");

let mintTx;
while (!mintTx) {
  const messageResponse = await fetch(
    `https://iris-api-sandbox.circle.com/v2/messages/${BASE_SEPOLIA_DOMAIN}?transactionHash=${burnTx}`,
  );
  const data = await messageResponse.json();

  if (data.messages?.[0]?.forwardTxHash) {
    mintTx = data.messages[0].forwardTxHash;
    console.log(); // New line after dots
  } else {
    process.stdout.write(".");
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

console.log("Mint Tx:", mintTx);

Full example code

The following is a complete example of how to transfer USDC from Base Sepolia to Avalanche Fuji using the Forwarding Service. Remember to set the PRIVATE_KEY and DESTINATION_ADDRESS environment variables.
import { createWalletClient, http, encodeFunctionData, pad } from "viem";
import { baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";

// Validate environment variables
if (!process.env.PRIVATE_KEY || !process.env.DESTINATION_ADDRESS) {
  throw new Error(
    "PRIVATE_KEY and DESTINATION_ADDRESS environment variables are required",
  );
}

// Configuration
const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const BASE_SEPOLIA_TOKEN_MESSENGER =
  "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA";
const BASE_SEPOLIA_DOMAIN = 6;
const AVALANCHE_FUJI_DOMAIN = 1;
const DESTINATION_ADDRESS = process.env.DESTINATION_ADDRESS as `0x${string}`;

// Forwarding Service hook data
const FORWARDING_SERVICE_HOOK_DATA =
  "0x636374702d666f72776172640000000000000000000000000000000000000000" as `0x${string}`;

// Set up wallet client
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const client = createWalletClient({
  chain: baseSepolia,
  transport: http(),
  account,
});

async function main() {
  console.log("Wallet address:", account.address);
  console.log("Destination address:", DESTINATION_ADDRESS);

  // Step 1: Get fees from API
  console.log("\nStep 1: Getting CCTP fees...");
  const feeResponse = await fetch(
    `https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/${BASE_SEPOLIA_DOMAIN}/${AVALANCHE_FUJI_DOMAIN}?forward=true`,
    {
      method: "GET",
      headers: { "Content-Type": "application/json" },
    },
  );
  const fees = await feeResponse.json();
  console.log("Fees:", JSON.stringify(fees, null, 2));

  // Step 2: Calculate amounts
  console.log("\nStep 2: Calculating amounts...");
  const transferAmount = 10_000_000n; // 10 USDC
  const feeData = fees[0]; // Fast transfer
  const forwardFee = BigInt(feeData.forwardFee.med);
  const minimumFeeCents = feeData.minimumFee;
  const protocolFee = BigInt(Math.floor(minimumFeeCents * 10_000)); // Convert cents to subunits
  const maxFee = forwardFee + protocolFee;
  const totalAmount = transferAmount + maxFee; // Total to burn

  console.log("Transfer amount:", Number(transferAmount) / 1_000_000, "USDC");
  console.log("Forward fee:", Number(forwardFee) / 1_000_000, "USDC");
  console.log("Protocol fee:", Number(protocolFee) / 1_000_000, "USDC");
  console.log("Max fee:", Number(maxFee) / 1_000_000, "USDC");
  console.log("Total to burn:", Number(totalAmount) / 1_000_000, "USDC");

  // Step 3: Approve USDC
  console.log("\nStep 3: Approving USDC transfer...");
  const approveTx = await client.sendTransaction({
    to: BASE_SEPOLIA_USDC,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "approve",
          stateMutability: "nonpayable",
          inputs: [
            { name: "spender", type: "address" },
            { name: "amount", type: "uint256" },
          ],
          outputs: [{ name: "", type: "bool" }],
        },
      ],
      functionName: "approve",
      args: [BASE_SEPOLIA_TOKEN_MESSENGER, totalAmount],
    }),
  });
  console.log("Approval Tx:", approveTx);

  // Step 4: Burn USDC with Forwarding Service hook
  console.log("\nStep 4: Burning USDC with Forwarding Service hook...");

  const burnTx = await client.sendTransaction({
    to: BASE_SEPOLIA_TOKEN_MESSENGER,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "depositForBurnWithHook",
          stateMutability: "nonpayable",
          inputs: [
            { name: "amount", type: "uint256" },
            { name: "destinationDomain", type: "uint32" },
            { name: "mintRecipient", type: "bytes32" },
            { name: "burnToken", type: "address" },
            { name: "destinationCaller", type: "bytes32" },
            { name: "maxFee", type: "uint256" },
            { name: "minFinalityThreshold", type: "uint32" },
            { name: "hookData", type: "bytes" },
          ],
          outputs: [],
        },
      ],
      functionName: "depositForBurnWithHook",
      args: [
        totalAmount,
        AVALANCHE_FUJI_DOMAIN,
        pad(DESTINATION_ADDRESS as `0x${string}`, { size: 32 }),
        BASE_SEPOLIA_USDC,
        pad("0x", { size: 32 }),
        maxFee,
        1000, // Fast Transfer
        FORWARDING_SERVICE_HOOK_DATA,
      ],
    }),
  });
  console.log("Burn Tx:", burnTx);

  console.log(
    "\nTransfer initiated. The Forwarding Service will automatically mint USDC on Avalanche.",
  );

  // Step 5: Verify the mint transaction
  console.log("\nStep 5: Verifying mint transaction...");
  process.stdout.write("Waiting for attestation...");

  let mintTx;
  while (!mintTx) {
    const messageResponse = await fetch(
      `https://iris-api-sandbox.circle.com/v2/messages/${BASE_SEPOLIA_DOMAIN}?transactionHash=${burnTx}`,
    );
    const data = await messageResponse.json();

    if (data.messages?.[0]?.forwardTxHash) {
      mintTx = data.messages[0].forwardTxHash;
      console.log(); // New line after dots
    } else {
      process.stdout.write(".");
      await new Promise((resolve) => setTimeout(resolve, 2000));
    }
  }

  console.log("Mint Tx:", mintTx);
}

main().catch(console.error);