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. Select a tab below for EVM or Solana destination instructions.Documentation Index
Fetch the complete documentation index at: https://developers.circle.com/llms.txt
Use this file to discover all available pages before exploring further.
- EVM
- Solana
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
vieminstalled -
You’ve set up a
.envfile with the following variables:.envEVM_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 calledtransfer.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 totransfer.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.In forwarded flows, the
POST /v1/transfer response may omit top-level
attestation and signature fields. Use the returned transferId to poll
GET /v1/transfer/{id} for the full transfer record.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 totransfer.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
When the destination is Solana, you need to ensure that
destinationRecipient
is an initialized USDC token account. If the recipient wallet address does not
already have a USDC token account, you can request
automatic ATA creation
using the Forwarding Service.This guide demonstrates how to forward from an EVM source blockchain to a Solana
destination with automatic Associated Token Account (ATA) creation.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)
- 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,@solana/web3.js, and@solana/spl-tokeninstalled -
You’ve set up a
.envfile with the following variables:.envEVM_PRIVATE_KEY={YOUR_PRIVATE_KEY} SOLANA_RECIPIENT_WALLET={RECIPIENT_SOLANA_WALLET_ADDRESS}
Steps
Follow these steps to transfer a unified USDC balance to a Solana destination using the Forwarding Service with automatic ATA creation. This example transfers 10 USDC from Arc Testnet to Solana Devnet.Step 1. Create the transfer spec and estimate fees
Create a new file calledtransfer-solana.ts in the root of your project and
add the following code to it. This code derives the recipient’s ATA, creates a
transfer spec targeting Solana Devnet, and calls the
/estimate endpoint with
enableForwarder=true and recipientSetupOptions for automatic ATA creation.The forwarding fee for Solana destinations includes the rent cost for ATA
creation when
recipientSetupOptions is provided. If the recipient already
has an initialized USDC token account, omit recipientSetupOptions to avoid
the additional rent fee.transfer-solana.ts
import { randomBytes } from "node:crypto";
import { pad, zeroAddress, formatUnits } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
/* Constants */
const GATEWAY_API_BASE = "https://gateway-api-testnet.circle.com";
const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
const TRANSFER_VALUE = 10_000000n; // 10 USDC (6 decimals)
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 300_000; // 5 minutes
const SOLANA_USDC_MINT = new PublicKey(
"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
);
const SOLANA_GATEWAY_MINTER_ADDRESS = new PublicKey(
"GATEmKK2ECL1brEngQZWCgMWPbvrEYqsV6u29dAaHavr",
);
const sourceChain = {
name: "arcTestnet",
usdcAddress: "0x3600000000000000000000000000000000000000",
domainId: 26,
};
const destinationChain = {
name: "solanaDevnet",
minterAddress:
"0x" + Buffer.from(SOLANA_GATEWAY_MINTER_ADDRESS.toBytes()).toString("hex"),
usdcAddress: "0x" + Buffer.from(SOLANA_USDC_MINT.toBytes()).toString("hex"),
domainId: 5,
};
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");
if (!process.env.SOLANA_RECIPIENT_WALLET)
throw new Error("SOLANA_RECIPIENT_WALLET not set");
const account = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
// Derive the recipient's USDC ATA from their wallet address
const recipientWallet = new PublicKey(process.env.SOLANA_RECIPIENT_WALLET);
const recipientAta = getAssociatedTokenAddressSync(
SOLANA_USDC_MINT,
recipientWallet,
);
const recipientAtaHex =
"0x" + Buffer.from(recipientAta.toBytes()).toString("hex");
const recipientWalletHex =
"0x" + Buffer.from(recipientWallet.toBytes()).toString("hex");
console.log(`Using account: ${account.address}`);
console.log(`Transfer: ${sourceChain.name} → ${destinationChain.name}`);
console.log(`Recipient wallet: ${recipientWallet.toBase58()}`);
console.log(`Recipient ATA: ${recipientAta.toBase58()}`);
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: destinationChain.minterAddress,
sourceToken: sourceChain.usdcAddress,
destinationToken: destinationChain.usdcAddress,
sourceDepositor: account.address,
destinationRecipient: recipientAtaHex,
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 with recipientSetupOptions for automatic ATA creation
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,
recipientSetupOptions: {
includeRecipientSetup: true,
recipientOwnerAddress: recipientWalletHex,
},
},
],
(_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}`);
Step 2. Sign and submit the burn intent to the Gateway API
Add the following code totransfer-solana.ts. The signing step is the same as
the EVM flow, but the request body includes recipientSetupOptions so the
Forwarding Service creates the ATA on Solana.The recipientOwnerAddress is the recipient’s Solana wallet address in bytes32
hex format. The API validates that the destinationRecipient in the transfer
spec matches the canonical ATA derived from this address and the USDC token
mint. If they don’t match, the request is rejected.In forwarded flows, the
POST /v1/transfer response may omit top-level
attestation and signature fields. Use the returned transferId to poll
GET /v1/transfer/{id} for the full transfer record.transfer-solana.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,
recipientSetupOptions: {
includeRecipientSetup: true,
recipientOwnerAddress: recipientWalletHex,
},
},
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 totransfer-solana.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-solana.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-solana.ts