Skip to main content
This guide walks you through the process of creating Unified Crosschain USDC Balances on supported EVM chains using Circle Gateway, and performing transfers from EVM to EVM and from Solana to EVM.

Prerequisites

Before you begin, ensure that you’ve: If you want to try the Solana to EVM transfer, ensure that you’ve:

Add testnet funds to your wallet

To interact with Gateway, you need test USDC and native tokens in your wallet on each chain you deposit from. You also need testnet native tokens on the destination chain to call the Gateway Minter contract. Use the Circle Faucet to get testnet USDC. If you have a Circle Developer Console account, you can use the Console Faucet to get testnet native tokens. In addition, the following faucets can also be used to fund your wallet with testnet native tokens:
Faucet: Arc Testnet (USDC + native tokens)
PropertyValue
Chain namearcTestnet
USDC address0x3600000000000000000000000000000000000000
Domain ID26

Step 1: Set up your project

This step shows you how to prepare your project and environment.

1.1. Create a new project

Create a new directory and install the required dependencies:
# Set up your directory and initialize a Node.js project
mkdir unified-gateway-balance-evm
cd unified-gateway-balance-evm
npm init -y

# Set up module type and run scripts
npm pkg set type=module
npm pkg set scripts.deposit="tsx --env-file=.env deposit.ts"
npm pkg set scripts.transfer-from-evm="tsx --env-file=.env transfer-from-evm.ts"
npm pkg set scripts.balances="tsx --env-file=.env balances.ts"

# Install dependencies
npm install viem tsx typescript
npm install --save-dev @types/node
If you want to try the Solana to EVM transfer, add the run script and install these additional dependencies:
npm pkg set scripts.transfer-from-sol="tsx --env-file=.env transfer-from-sol.ts"
npm pkg set overrides.bigint-buffer=npm:@trufflesuite/bigint-buffer@1.1.10
npm install @coral-xyz/anchor @solana/buffer-layout @solana/spl-token @solana/web3.js bs58

1.2. Initialize and configure the project

This command creates a tsconfig.json file:
Shell
npx tsc --init
Then, edit the tsconfig.json file:
Shell
# Replace the contents of the generated file
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF

1.3 Configure environment variables

Create a .env file in the project directory and add your wallet private key, replacing with the private key from your EVM wallet.
echo "EVM_PRIVATE_KEY={YOUR_PRIVATE_KEY}" > .env
If you want to try the Solana to EVM transfer, add your Solana keypair, replacing with the your actual keypair as a JSON array.
echo "SOLANA_PRIVATE_KEYPAIR={YOUR_SOLANA_KEYPAIR_ARRAY}" >> .env
If your wallet exports a private key hash instead, you can use bs58 to convert it:
TypeScript
const bytes = bs58.decode({ YOUR_PRIVATE_KEY_HASH });
console.log(JSON.stringify(Array.from(bytes)));
Important: These are sensitive credentials. Do not commit them to version control or share them publicly.

Step 2: Set up the configuration file

This section covers the shared configuration file will be used by both the deposit and transfer scripts.

2.1. Create the configuration file

touch config.ts

2.2. Configure wallet account and chain settings

Add the account setup, Gateway contract addresses, and chain-specific configuration to your config.ts file. This includes RPC endpoints, USDC addresses, and domain IDs for all supported testnet chains.
config.ts
import { type Address } from "viem";
import {
  sepolia,
  baseSepolia,
  avalancheFuji,
  arcTestnet,
  hyperliquidEvmTestnet,
  seiTestnet,
  sonicTestnet,
  worldchainSepolia,
} from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";

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

/* Gateway Contract Addresses */
export const GATEWAY_WALLET_ADDRESS: Address =
  "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
export const GATEWAY_MINTER_ADDRESS: Address =
  "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";

/* Chain Configuration */
export const chainConfigs = {
  sepolia: {
    chain: sepolia,
    usdcAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" as Address,
    domainId: 0,
  },
  baseSepolia: {
    chain: baseSepolia,
    usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address,
    domainId: 6,
  },
  avalancheFuji: {
    chain: avalancheFuji,
    usdcAddress: "0x5425890298aed601595a70ab815c96711a31bc65" as Address,
    domainId: 1,
  },
  arcTestnet: {
    chain: arcTestnet,
    usdcAddress: "0x3600000000000000000000000000000000000000" as Address,
    domainId: 26,
  },
  hyperliquidEvmTestnet: {
    chain: hyperliquidEvmTestnet,
    usdcAddress: "0x2B3370eE501B4a559b57D449569354196457D8Ab" as Address,
    domainId: 19,
  },
  seiTestnet: {
    chain: seiTestnet,
    usdcAddress: "0x4fCF1784B31630811181f670Aea7A7bEF803eaED" as Address,
    domainId: 16,
  },
  sonicTestnet: {
    chain: sonicTestnet,
    usdcAddress: "0x0BA304580ee7c9a980CF72e55f5Ed2E9fd30Bc51" as Address,
    domainId: 13,
  },
  worldchainSepolia: {
    chain: worldchainSepolia,
    usdcAddress: "0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88" as Address,
    domainId: 14,
  },
} as const;

export type ChainKey = keyof typeof chainConfigs;

/* CLI Argument Parsing Helper */
export function parseSelectedChains(): ChainKey[] {
  const args = process.argv.slice(2);
  const validChains = Object.keys(chainConfigs) as ChainKey[];

  if (args.length === 0) {
    throw new Error(
      "No chains specified. Usage: npm run <script> <chain1> [chain2...] or 'all'",
    );
  }

  if (args.length === 1 && args[0] === "all") {
    return validChains;
  }

  const invalid = args.filter((c) => !validChains.includes(c as ChainKey));
  if (invalid.length > 0) {
    console.error(`Unsupported chain: ${invalid.join(", ")}`);
    console.error(`Valid chains: ${validChains.join(", ")}, all`);
    process.exit(1);
  }

  return [...new Set(args)] as ChainKey[];
}

Step 3: Deposit into a unified crosschain balance

This section explains parts of the deposit script that allows you to deposit USDC into the Gateway Wallet contracts. The script accepts chain names as CLI arguments. Specify one or more chains (for example, arcTestnet baseSepolia) or use all for all supported testnets. You can skip to the full deposit script if you prefer.

3.1. Create the script file

touch deposit.ts

3.2. Define constants and ABI

deposit.ts
const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)

// Gateway Wallet ABI (minimal - only deposit function)
const gatewayWalletAbi = [
  {
    type: "function",
    name: "deposit",
    inputs: [
      { name: "token", type: "address" },
      { name: "value", type: "uint256" },
    ],
    outputs: [],
    stateMutability: "nonpayable",
  },
] as const;

3.3. Setup clients and check balances

Set up the client and contracts for the chain, then verify sufficient USDC balance before depositing.
deposit.ts
const config = chainConfigs[chainName];

// Create client for current chain
const client = createPublicClient({
  chain: config.chain,
  transport: http(),
});

// Get contract instances
const usdcContract = getContract({
  address: config.usdcAddress,
  abi: erc20Abi,
  client,
});

const gatewayWallet = getContract({
  address: GATEWAY_WALLET_ADDRESS,
  abi: gatewayWalletAbi,
  client,
});

// Check USDC balance
const balance = await usdcContract.read.balanceOf([account.address]);
console.log(`Current balance: ${formatUnits(balance, 6)} USDC`);

if (balance < DEPOSIT_AMOUNT) {
  throw new Error(
    "Insufficient USDC balance. Please top up at https://faucet.circle.com",
  );
}

3.4. Approve and deposit USDC

The main logic performs two key actions:
  • Approve USDC transfers: It calls the approve method on the USDC contract to allow the Gateway Wallet contract to transfer USDC from your wallet.
  • Deposit USDC into Gateway: After receiving the approval transaction hash, it calls the deposit method on the Gateway Wallet contract.
deposit.ts
// [1] Approve Gateway Wallet to spend USDC
console.log(
  `Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${chainName}...`,
);
const approvalTx = await usdcContract.write.approve(
  [GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
  { account },
);
await client.waitForTransactionReceipt({ hash: approvalTx });
console.log(`Approved on ${chainName}: ${approvalTx}`);

// [2] Deposit USDC into Gateway Wallet
console.log(
  `Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
);
const depositTx = await gatewayWallet.write.deposit(
  [config.usdcAddress, DEPOSIT_AMOUNT],
  { account },
);
await client.waitForTransactionReceipt({ hash: depositTx });
console.log(`Done on ${chainName}. Deposit tx: ${depositTx}`);

3.5. Full deposit script

The complete deposit script loops through selected chains, validates USDC balances, and deposits funds into the Gateway Wallet contract on each chain. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.
deposit.ts
import {
  createPublicClient,
  getContract,
  http,
  erc20Abi,
  formatUnits,
} from "viem";
import {
  account,
  chainConfigs,
  parseSelectedChains,
  GATEWAY_WALLET_ADDRESS,
} from "./config.js";

const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)

// Gateway Wallet ABI (minimal - only deposit function)
const gatewayWalletAbi = [
  {
    type: "function",
    name: "deposit",
    inputs: [
      { name: "token", type: "address" },
      { name: "value", type: "uint256" },
    ],
    outputs: [],
    stateMutability: "nonpayable",
  },
] as const;

async function main() {
  console.log(`Using account: ${account.address}\n`);

  const selectedChains = parseSelectedChains();
  console.log(`Depositing on: ${selectedChains.join(", ")}\n`);

  for (const chainName of selectedChains) {
    const config = chainConfigs[chainName];

    // Create client for current chain
    const client = createPublicClient({
      chain: config.chain,
      transport: http(),
    });

    // Get contract instances
    const usdcContract = getContract({
      address: config.usdcAddress,
      abi: erc20Abi,
      client,
    });

    const gatewayWallet = getContract({
      address: GATEWAY_WALLET_ADDRESS,
      abi: gatewayWalletAbi,
      client,
    });

    console.log(`\n=== Processing ${chainName} ===`);

    // Check USDC balance
    const balance = await usdcContract.read.balanceOf([account.address]);
    console.log(`Current balance: ${formatUnits(balance, 6)} USDC`);

    if (balance < DEPOSIT_AMOUNT) {
      throw new Error(
        "Insufficient USDC balance. Please top up at https://faucet.circle.com",
      );
    }

    try {
      // [1] Approve Gateway Wallet to spend USDC
      console.log(
        `Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${chainName}...`,
      );
      const approvalTx = await usdcContract.write.approve(
        [GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
        { account },
      );
      await client.waitForTransactionReceipt({ hash: approvalTx });
      console.log(`Approved on ${chainName}: ${approvalTx}`);

      // [2] Deposit USDC into Gateway Wallet
      console.log(
        `Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
      );
      const depositTx = await gatewayWallet.write.deposit(
        [config.usdcAddress, DEPOSIT_AMOUNT],
        { account },
      );
      await client.waitForTransactionReceipt({ hash: depositTx });
      console.log(`Done on ${chainName}. Deposit tx: ${depositTx}`);
    } catch (err) {
      console.error(`Error on ${chainName}:`, err);
    }
  }
}

main().catch((error) => {
  console.error("\nError:", error);
  process.exit(1);
});

3.6. Run the script to create a crosschain balance

Run the deposit script to make the deposits. You must specify at least one chain using command-line arguments.
# Deposit to all supported chains
npm run deposit -- all

# Deposit to a single chain
npm run deposit -- sepolia

# Deposit to multiple chains
npm run deposit -- baseSepolia avalancheFuji
Wait for the required number of block confirmations. Once the deposit transactions are final, the total balance is the sum of all the USDC from deposit transactions across all supported chains that have reached finality. Note that for certain chains, finality may take up to 20 minutes to be reached.

3.7. Check the balances on the Gateway Wallet

Create a new file called balances.ts, and add the following code. This script retrieves the USDC balances available from your Gateway Wallet on each supported chain.
balances.ts
import { privateKeyToAccount } from "viem/accounts";

if (!process.env.EVM_PRIVATE_KEY) {
  throw new Error("Missing EVM_PRIVATE_KEY in environment");
}

const DOMAINS = {
  sepolia: 0,
  avalancheFuji: 1,
  baseSepolia: 6,
  arcTestnet: 26,
  hyperliquidEvmTestnet: 19,
  seiTestnet: 16,
  sonicTestnet: 13,
  worldchainSepolia: 14,
};

async function main() {
  const account = privateKeyToAccount(
    process.env.EVM_PRIVATE_KEY as `0x${string}`,
  );
  const depositor = account.address;

  console.log(`Depositor address: ${depositor}\n`);

  const body = {
    token: "USDC",
    sources: Object.entries(DOMAINS).map(([_, domain]) => ({
      domain,
      depositor,
    })),
  };

  const res = await fetch(
    "https://gateway-api-testnet.circle.com/v1/balances",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    },
  );

  const result = await res.json();

  let total = 0;
  for (const balance of result.balances) {
    const chain =
      Object.keys(DOMAINS).find(
        (key) => DOMAINS[key as keyof typeof DOMAINS] === balance.domain,
      ) || `Domain ${balance.domain}`;
    const amount = parseFloat(balance.balance);
    console.log(`${chain}: ${amount.toFixed(6)} USDC`);
    total += amount;
  }

  console.log(`\nTotal: ${total.toFixed(6)} USDC`);
}

main().catch((error) => {
  console.error("\nError:", error);
  process.exit(1);
});
You can run it to check whether finality has been reached for recent transactions.
npm run balances

Step 4: Transfer USDC from the crosschain balance

This section explains parts of the transfer script that burns USDC from source chains and mints on a destination chain via Gateway. The script accepts chain names as CLI arguments. Specify one or more source chains (for example, seiTestnet or arcTestnet) or use all for all supported testnets. You can skip to the full transfer script if you prefer.

4.1. Create the script file

touch transfer-from-evm.ts

4.2. Define constants and types

You can set which chain to deposit to by modifying the DESTINATION_CHAIN value. This example sets it to seiTestnet. You can also set the amount to be transferred from each source chain by changing the TRANSFER_VALUE.
transfer-from-evm.ts
const DESTINATION_CHAIN: ChainKey = "seiTestnet";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;

// EIP-712 Domain and Types for Gateway burn intents
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;

const gatewayMinterAbi = [
  {
    type: "function",
    name: "gatewayMint",
    inputs: [
      { name: "attestationPayload", type: "bytes" },
      { name: "signature", type: "bytes" },
    ],
    outputs: [],
    stateMutability: "nonpayable",
  },
] as const;

4.3. Add helper functions

transfer-from-evm.ts
// Create a burn intent for cross-chain transfer
function createBurnIntent(params: {
  sourceChain: ChainKey;
  depositorAddress: string;
  recipientAddress?: string;
}) {
  const {
    sourceChain,
    depositorAddress,
    recipientAddress = depositorAddress,
  } = params;
  const sourceConfig = chainConfigs[sourceChain];
  const destConfig = chainConfigs[DESTINATION_CHAIN];

  return {
    maxBlockHeight: maxUint256,
    maxFee: MAX_FEE,
    spec: {
      version: 1,
      sourceDomain: sourceConfig.domainId,
      destinationDomain: destConfig.domainId,
      sourceContract: GATEWAY_WALLET_ADDRESS,
      destinationContract: GATEWAY_MINTER_ADDRESS,
      sourceToken: sourceConfig.usdcAddress,
      destinationToken: destConfig.usdcAddress,
      sourceDepositor: depositorAddress,
      destinationRecipient: recipientAddress,
      sourceSigner: depositorAddress,
      destinationCaller: zeroAddress,
      value: TRANSFER_VALUE,
      salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
      hookData: "0x" as Hex,
    },
  };
}

// Create EIP-712 typed data for signing
function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
  return {
    types: { EIP712Domain, TransferSpec, BurnIntent },
    domain,
    primaryType: "BurnIntent" as const,
    message: {
      ...burnIntent,
      spec: {
        ...burnIntent.spec,
        sourceContract: addressToBytes32(burnIntent.spec.sourceContract),
        destinationContract: addressToBytes32(
          burnIntent.spec.destinationContract,
        ),
        sourceToken: addressToBytes32(burnIntent.spec.sourceToken),
        destinationToken: addressToBytes32(burnIntent.spec.destinationToken),
        sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor),
        destinationRecipient: addressToBytes32(
          burnIntent.spec.destinationRecipient,
        ),
        sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner),
        destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller),
      },
    },
  };
}

// Convert address to bytes32
function addressToBytes32(address: string): Hex {
  return pad(address.toLowerCase() as Hex, { size: 32 });
}

4.4. Create and sign burn intents

transfer-from-evm.ts
const requests = [];

for (const chainName of selectedChains) {
  console.log(
    `Creating burn intent from ${chainName}${DESTINATION_CHAIN}...`,
  );

  const intent = createBurnIntent({
    sourceChain: chainName,
    depositorAddress: account.address,
  });

  const typedData = burnIntentTypedData(intent);
  const signature = await account.signTypedData(typedData);

  requests.push({ burnIntent: typedData.message, signature });
}
console.log("Signed burn intents.");

4.5. Request attestation from Gateway API

transfer-from-evm.ts
const response = await fetch(
  "https://gateway-api-testnet.circle.com/v1/transfer",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(requests, (_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();
console.log("Gateway API response:", JSON.stringify(json, null, 2));

const attestation = json?.attestation;
const operatorSig = json?.signature;

if (!attestation || !operatorSig) {
  throw new Error("Missing attestation or signature in response");
}

4.6. Mint on destination chain

transfer-from-evm.ts
const destConfig = chainConfigs[DESTINATION_CHAIN];

const destClient = createPublicClient({
  chain: destConfig.chain,
  transport: http(),
});

const walletClient = createWalletClient({
  account,
  chain: destConfig.chain,
  transport: http(),
});

const destinationGatewayMinterContract = getContract({
  address: GATEWAY_MINTER_ADDRESS,
  abi: gatewayMinterAbi,
  client: { public: destClient, wallet: walletClient },
});

console.log(`Minting funds on ${destConfig.chain.name}...`);
const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
  [attestation, operatorSig],
  { account },
);

await destClient.waitForTransactionReceipt({ hash: mintTx });

const totalMinted = BigInt(requests.length) * TRANSFER_VALUE;
console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
console.log(`Mint transaction hash (${DESTINATION_CHAIN}):`, mintTx);

4.7. Full EVM transfer script

The complete transfer script loops through selected source chains, creates and signs burn intents for each chain, submits them to the Gateway API for attestation, and mints USDC on the destination chain. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.
transfer-from-evm.ts
import {
  createPublicClient,
  createWalletClient,
  getContract,
  http,
  pad,
  zeroAddress,
  maxUint256,
  formatUnits,
  type Hex,
} from "viem";
import { randomBytes } from "node:crypto";
import {
  account,
  chainConfigs,
  parseSelectedChains,
  GATEWAY_WALLET_ADDRESS,
  GATEWAY_MINTER_ADDRESS,
  type ChainKey,
} from "./config.js";

const DESTINATION_CHAIN: ChainKey = "seiTestnet";
const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
const MAX_FEE = 2_010000n;

// EIP-712 Domain and Types for Gateway burn intents
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;

const gatewayMinterAbi = [
  {
    type: "function",
    name: "gatewayMint",
    inputs: [
      { name: "attestationPayload", type: "bytes" },
      { name: "signature", type: "bytes" },
    ],
    outputs: [],
    stateMutability: "nonpayable",
  },
] as const;

// Create a burn intent for cross-chain transfer
function createBurnIntent(params: {
  sourceChain: ChainKey;
  depositorAddress: string;
  recipientAddress?: string;
}) {
  const {
    sourceChain,
    depositorAddress,
    recipientAddress = depositorAddress,
  } = params;
  const sourceConfig = chainConfigs[sourceChain];
  const destConfig = chainConfigs[DESTINATION_CHAIN];

  return {
    maxBlockHeight: maxUint256,
    maxFee: MAX_FEE,
    spec: {
      version: 1,
      sourceDomain: sourceConfig.domainId,
      destinationDomain: destConfig.domainId,
      sourceContract: GATEWAY_WALLET_ADDRESS,
      destinationContract: GATEWAY_MINTER_ADDRESS,
      sourceToken: sourceConfig.usdcAddress,
      destinationToken: destConfig.usdcAddress,
      sourceDepositor: depositorAddress,
      destinationRecipient: recipientAddress,
      sourceSigner: depositorAddress,
      destinationCaller: zeroAddress,
      value: TRANSFER_VALUE,
      salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
      hookData: "0x" as Hex,
    },
  };
}

// Create EIP-712 typed data for signing
function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
  return {
    types: { EIP712Domain, TransferSpec, BurnIntent },
    domain,
    primaryType: "BurnIntent" as const,
    message: {
      ...burnIntent,
      spec: {
        ...burnIntent.spec,
        sourceContract: addressToBytes32(burnIntent.spec.sourceContract),
        destinationContract: addressToBytes32(
          burnIntent.spec.destinationContract,
        ),
        sourceToken: addressToBytes32(burnIntent.spec.sourceToken),
        destinationToken: addressToBytes32(burnIntent.spec.destinationToken),
        sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor),
        destinationRecipient: addressToBytes32(
          burnIntent.spec.destinationRecipient,
        ),
        sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner),
        destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller),
      },
    },
  };
}

// Convert address to bytes32
function addressToBytes32(address: string): Hex {
  return pad(address.toLowerCase() as Hex, { size: 32 });
}

async function main() {
  console.log(`Using account: ${account.address}`);

  const selectedChains = parseSelectedChains();
  console.log(`Transfering balances from: ${selectedChains.join(", ")}`);

  // [1] Create and sign burn intents for each source chain
  const requests = [];

  for (const chainName of selectedChains) {
    console.log(
      `Creating burn intent from ${chainName}${DESTINATION_CHAIN}...`,
    );

    const intent = createBurnIntent({
      sourceChain: chainName,
      depositorAddress: account.address,
    });

    const typedData = burnIntentTypedData(intent);
    const signature = await account.signTypedData(typedData);

    requests.push({ burnIntent: typedData.message, signature });
  }
  console.log("Signed burn intents.");

  // [2] Request attestation from Gateway API
  const response = await fetch(
    "https://gateway-api-testnet.circle.com/v1/transfer",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(requests, (_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();
  console.log("Gateway API response:", JSON.stringify(json, null, 2));

  const attestation = json?.attestation;
  const operatorSig = json?.signature;

  if (!attestation || !operatorSig) {
    throw new Error("Missing attestation or signature in response");
  }

  // [3] Mint on destination chain
  const destConfig = chainConfigs[DESTINATION_CHAIN];

  const destClient = createPublicClient({
    chain: destConfig.chain,
    transport: http(),
  });

  const walletClient = createWalletClient({
    account,
    chain: destConfig.chain,
    transport: http(),
  });

  const destinationGatewayMinterContract = getContract({
    address: GATEWAY_MINTER_ADDRESS,
    abi: gatewayMinterAbi,
    client: { public: destClient, wallet: walletClient },
  });

  console.log(`Minting funds on ${destConfig.chain.name}...`);
  const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
    [attestation, operatorSig],
    { account },
  );

  await destClient.waitForTransactionReceipt({ hash: mintTx });

  const totalMinted = BigInt(requests.length) * TRANSFER_VALUE;
  console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
  console.log(`Mint transaction hash (${DESTINATION_CHAIN}):`, mintTx);
}

main().catch((error) => {
  console.error("\nError:", error);
  process.exit(1);
});

4.8. Run the script to transfer USDC to destination chain

Run the transfer script to transfer 1 USDC from each selected Gateway balance to the destination chain.
Gateway gas fees are charged per burn intent. To reduce overall gas costs, consider keeping most Gateway funds on low-cost chains, where Circle’s base fee for burns is cheaper.
# Transfer from all chains
npm run transfer-from-evm -- all

# Transfer from a single chain
npm run transfer-from-evm -- arcTestnet

# Transfer from multiple chains
npm run transfer-from-evm -- baseSepolia avalancheFuji