Skip to main content
Transfer your unified USDC balance to a destination chain without needing a wallet or gas on that chain. The Forwarding Service handles the destination chain mint automatically. This guide demonstrates how to estimate fees, create a burn intent, submit it with forwarding enabled, and poll for transfer completion.

Prerequisites

Before you begin, ensure that you’ve:
  • Installed Node.js v22+
  • Prepared an EVM testnet wallet with the private key available
    • Added the supported Testnets of your choice to your wallet (this guide uses Arc Testnet and Base Sepolia)
  • Funded your testnet wallet with native tokens on the source chain (this guide uses Arc Testnet). With the Forwarding Service, you do not need native tokens on the destination chain.
  • Deposited 10 USDC into the Gateway Wallet contract on Arc Testnet
  • Created a TypeScript project and have viem installed
  • You’ve set up a .env file with the following variables:
    .env
    EVM_PRIVATE_KEY={YOUR_PRIVATE_KEY}
    

Steps

Follow these steps to transfer a unified USDC balance using the Forwarding Service. This example transfers 10 USDC from Arc Testnet to Base Sepolia. You can adapt it for another supported chain.

Step 1. Create the transfer spec and estimate fees

Create a new file called transfer.ts in the root of your project and add the following code to it. This code creates a transfer spec for 10 USDC on Arc Testnet, then calls the /estimate endpoint with enableForwarder=true to determine the maxFee and maxBlockHeight values. Using the estimate endpoint ensures the maxFee covers the gas fee, transfer fee, and forwarding fee.
transfer.ts
import { randomBytes } from "node:crypto";
import { pad, zeroAddress, formatUnits } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet, baseSepolia } from "viem/chains";

/* Constants */
const GATEWAY_API_BASE = "https://gateway-api-testnet.circle.com";
const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
const GATEWAY_MINTER_ADDRESS = "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";

const TRANSFER_VALUE = 10_000000n; // 10 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000; // 5 minutes

const sourceChain = {
  name: "arcTestnet",
  chain: arcTestnet,
  usdcAddress: "0x3600000000000000000000000000000000000000",
  domainId: 26,
};

const destinationChain = {
  name: "baseSepolia",
  chain: baseSepolia,
  usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  domainId: 6,
};

const domain = { name: "GatewayWallet", version: "1" };

const EIP712Domain = [
  { name: "name", type: "string" },
  { name: "version", type: "string" },
] as const;

const TransferSpec = [
  { name: "version", type: "uint32" },
  { name: "sourceDomain", type: "uint32" },
  { name: "destinationDomain", type: "uint32" },
  { name: "sourceContract", type: "bytes32" },
  { name: "destinationContract", type: "bytes32" },
  { name: "sourceToken", type: "bytes32" },
  { name: "destinationToken", type: "bytes32" },
  { name: "sourceDepositor", type: "bytes32" },
  { name: "destinationRecipient", type: "bytes32" },
  { name: "sourceSigner", type: "bytes32" },
  { name: "destinationCaller", type: "bytes32" },
  { name: "value", type: "uint256" },
  { name: "salt", type: "bytes32" },
  { name: "hookData", type: "bytes" },
] as const;

const BurnIntent = [
  { name: "maxBlockHeight", type: "uint256" },
  { name: "maxFee", type: "uint256" },
  { name: "spec", type: "TransferSpec" },
] as const;

if (!process.env.EVM_PRIVATE_KEY) throw new Error("EVM_PRIVATE_KEY not set");
const account = privateKeyToAccount(
  process.env.EVM_PRIVATE_KEY as `0x${string}`,
);

console.log(`Using account: ${account.address}`);
console.log(`Transfer: ${sourceChain.name}${destinationChain.name}`);
console.log(`Amount: ${formatUnits(TRANSFER_VALUE, 6)} USDC`);
console.log(`Forwarding: enabled\n`);

// Create the transfer spec
const spec = {
  version: 1,
  sourceDomain: sourceChain.domainId,
  destinationDomain: destinationChain.domainId,
  sourceContract: GATEWAY_WALLET_ADDRESS,
  destinationContract: GATEWAY_MINTER_ADDRESS,
  sourceToken: sourceChain.usdcAddress,
  destinationToken: destinationChain.usdcAddress,
  sourceDepositor: account.address,
  destinationRecipient: account.address,
  sourceSigner: account.address,
  destinationCaller: zeroAddress,
  value: TRANSFER_VALUE,
  salt: "0x" + randomBytes(32).toString("hex"),
  hookData: "0x",
};

const specBytes32 = {
  ...spec,
  sourceContract: pad(spec.sourceContract.toLowerCase() as `0x${string}`, {
    size: 32,
  }),
  destinationContract: pad(
    spec.destinationContract.toLowerCase() as `0x${string}`,
    { size: 32 },
  ),
  sourceToken: pad(spec.sourceToken.toLowerCase() as `0x${string}`, {
    size: 32,
  }),
  destinationToken: pad(spec.destinationToken.toLowerCase() as `0x${string}`, {
    size: 32,
  }),
  sourceDepositor: pad(spec.sourceDepositor.toLowerCase() as `0x${string}`, {
    size: 32,
  }),
  destinationRecipient: pad(
    spec.destinationRecipient.toLowerCase() as `0x${string}`,
    { size: 32 },
  ),
  sourceSigner: pad(spec.sourceSigner.toLowerCase() as `0x${string}`, {
    size: 32,
  }),
  destinationCaller: pad(
    spec.destinationCaller.toLowerCase() as `0x${string}`,
    { size: 32 },
  ),
};

// Estimate fees
console.log("Estimating fees...");
const estimateResponse = await fetch(
  `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify([{ spec: specBytes32 }], (_key, value) =>
      typeof value === "bigint" ? value.toString() : value,
    ),
  },
);

if (!estimateResponse.ok) {
  const text = await estimateResponse.text();
  throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
}

const estimateResult = await estimateResponse.json();
const estimated = estimateResult.body[0].burnIntent;
const maxFee = BigInt(estimated.maxFee);
const maxBlockHeight = BigInt(estimated.maxBlockHeight);
const { fees } = estimateResult;

if (fees.forwardingFee) {
  console.log(`  Forwarding fee: ${fees.forwardingFee} ${fees.token}`);
}
console.log(`  Estimated maxFee: ${formatUnits(maxFee, 6)} ${fees.token}`);
Note: For production apps, verifying the balance on each chain before creating burn intents is best practice. For this how-to, it’s assumed that the balance is created per the prerequisites. For a complete end-to-end example that includes checking and error handling, see the Gateway quickstarts (EVM).

Step 2. Sign and submit the burn intent to the Gateway API

Add the following code to transfer.ts. This code constructs the EIP-712 typed data, signs the burn intent using the estimated maxFee and maxBlockHeight, and submits it to the /transfer endpoint with enableForwarder=true.
transfer.ts
const typedData = {
  types: { EIP712Domain, TransferSpec, BurnIntent },
  domain,
  primaryType: "BurnIntent" as const,
  message: { maxBlockHeight, maxFee, spec: specBytes32 },
};

const signature = await account.signTypedData(
  typedData as Parameters<typeof account.signTypedData>[0],
);
console.log("\nSigned burn intent.");

console.log("Submitting to Gateway API...");
const response = await fetch(
  `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(
      [{ burnIntent: typedData.message, signature }],
      (_key, value) => (typeof value === "bigint" ? value.toString() : value),
    ),
  },
);

if (!response.ok) {
  const text = await response.text();
  throw new Error(`Gateway API error: ${response.status} ${text}`);
}

const json = await response.json();
const transferId = json.transferId;
if (!transferId) throw new Error("Missing transferId in response");
console.log(`Transfer ID: ${transferId}`);

Step 3. Poll for transfer completion

Add the following code to transfer.ts. Because the Forwarding Service handles the destination chain mint, you don’t need to call the minter contract. Instead, poll the /transfer/{id} endpoint until the status reaches confirmed or finalized.
transfer.ts
console.log(`\nPolling for transfer completion...`);
const pollStart = Date.now();
let completed = false;

while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
  const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);

  if (!pollRes.ok) {
    console.error(`Poll error: ${pollRes.status}`);
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
    continue;
  }

  const details = await pollRes.json();
  console.log(`  Status: ${details.status}`);

  if (details.status === "finalized" || details.status === "confirmed") {
    completed = true;
    break;
  }

  if (details.status === "failed") {
    const reason = details.forwardingDetails?.failureReason ?? "unknown";
    throw new Error(`Transfer failed: ${reason}`);
  }

  if (details.status === "expired") {
    throw new Error("Transfer attestation expired before forwarding");
  }

  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}

if (!completed) {
  throw new Error("Polling timed out waiting for transfer completion");
}

console.log(
  `\nTransfer complete. ${formatUnits(TRANSFER_VALUE, 6)} USDC forwarded to ${destinationChain.name}.`,
);

Step 4. Run the script

Run the script with the following command:
npx tsx --env-file=.env transfer.ts