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. Select a tab below for Circle Wallets or permissionless instructions.

Prerequisites

Before you begin, ensure that you’ve:If you want to try the Solana to EVM transfer, ensure that you’ve:
  • Created a Solana Devnet Developer-Controlled Wallet to act as the source depositor
  • Created an EVM Developer-Controlled Wallet on the destination chain to submit the mint transaction
  • Completed the deposit flow from the Solana quickstart first

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 native gas tokens on the destination chain to call the Gateway Minter contract.Use the Circle Faucet to get test 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

1.1. Create the project and install dependencies

mkdir unified-gateway-balance-evm-circle-wallets
cd unified-gateway-balance-evm-circle-wallets

npm init -y
npm pkg set type=module
npm pkg set scripts.deposit="tsx --env-file=.env deposit.ts --"
npm pkg set scripts.balances="tsx --env-file=.env balances.ts"
npm pkg set scripts.transfer-from-evm="tsx --env-file=.env transfer-from-evm.ts --"
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 @circle-fin/developer-controlled-wallets @coral-xyz/anchor @solana/buffer-layout @solana/web3.js bs58 tsx typescript
npm install --save-dev @types/node

1.2. Configure TypeScript (optional)

This step is optional. It helps prevent missing types in your IDE or editor.
Create a tsconfig.json file:
npx tsc --init
Then, update the tsconfig.json file:
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF

1.3. Set environment variables

Create a .env file in the project directory:
.env
CIRCLE_API_KEY=YOUR_API_KEY
CIRCLE_ENTITY_SECRET=YOUR_ENTITY_SECRET
DEPOSITOR_ADDRESS=YOUR_SOURCE_WALLET_ADDRESS
RECIPIENT_ADDRESS=YOUR_DESTINATION_WALLET_ADDRESS
  • CIRCLE_API_KEY is your Circle API key.
  • CIRCLE_ENTITY_SECRET is your Circle entity secret.
  • DEPOSITOR_ADDRESS is the source depositor wallet for the script you are running.
  • RECIPIENT_ADDRESS is the destination wallet. It is only required for transfer scripts.
For transfer-from-evm.ts, both DEPOSITOR_ADDRESS and RECIPIENT_ADDRESS are EVM addresses.For transfer-from-sol.ts, DEPOSITOR_ADDRESS is a Solana address and RECIPIENT_ADDRESS is an EVM address.
Prefer editing .env files in your IDE or editor so credentials are not leaked to your shell history.

Step 2. Set up the configuration file

The shared configuration file is used by the deposit and transfer scripts.

2.1. Create the configuration file

touch config.ts

2.2. Configure wallet account and chain settings

Add the chain metadata, Gateway contract addresses, Circle Wallets client, and Command-line helpers to your config.ts file.
config.ts
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

/* Chain configuration */
export type WalletChain =
  | "ETH-SEPOLIA"
  | "BASE-SEPOLIA"
  | "AVAX-FUJI"
  | "ARC-TESTNET"
  | "ARB-SEPOLIA"
  | "OP-SEPOLIA"
  | "MATIC-AMOY"
  | "UNI-SEPOLIA";

export type Chain =
  | "ethereum"
  | "base"
  | "avalanche"
  | "arc"
  | "arbitrum"
  | "optimism"
  | "polygon"
  | "unichain";

export type ChainConfig = {
  chainName: string;
  usdc: string;
  domain: number;
  walletChain: WalletChain;
};

export const chainConfig: Record<Chain, ChainConfig> = {
  ethereum: {
    chainName: "Ethereum Sepolia",
    usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    domain: 0,
    walletChain: "ETH-SEPOLIA",
  },
  base: {
    chainName: "Base Sepolia",
    usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    domain: 6,
    walletChain: "BASE-SEPOLIA",
  },
  avalanche: {
    chainName: "Avalanche Fuji",
    usdc: "0x5425890298aed601595a70AB815c96711a31Bc65",
    domain: 1,
    walletChain: "AVAX-FUJI",
  },
  arc: {
    chainName: "Arc Testnet",
    usdc: "0x3600000000000000000000000000000000000000",
    domain: 26,
    walletChain: "ARC-TESTNET",
  },
  arbitrum: {
    chainName: "Arbitrum Sepolia",
    usdc: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
    domain: 3,
    walletChain: "ARB-SEPOLIA",
  },
  optimism: {
    chainName: "OP Sepolia",
    usdc: "0x5fd84259d66Cd46123540766Be93DFE6D43130D7",
    domain: 2,
    walletChain: "OP-SEPOLIA",
  },
  polygon: {
    chainName: "Polygon Amoy",
    usdc: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582",
    domain: 7,
    walletChain: "MATIC-AMOY",
  },
  unichain: {
    chainName: "Unichain Sepolia",
    usdc: "0x31d0220469e10c4E71834a79b1f276d740d3768F",
    domain: 10,
    walletChain: "UNI-SEPOLIA",
  },
};

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

/* API Credentials */
export const API_KEY = process.env.CIRCLE_API_KEY!;
export const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET!;
export const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;

if (!API_KEY || !ENTITY_SECRET || !DEPOSITOR_ADDRESS) {
  console.error(
    "Missing required env vars: CIRCLE_API_KEY, CIRCLE_ENTITY_SECRET, DEPOSITOR_ADDRESS",
  );
  process.exit(1);
}

/* Circle Wallets Client */
export const client = initiateDeveloperControlledWalletsClient({
  apiKey: API_KEY,
  entitySecret: ENTITY_SECRET,
});

/* Command-line argument parsing helper */
export function parseSelectedChains(): Chain[] {
  const args = process.argv
    .slice(2)
    .filter((arg) => arg !== "--")
    .map((chain) => chain.toLowerCase());
  const validChains = Object.keys(chainConfig);

  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 Object.keys(chainConfig) as Chain[];
  }

  const invalid = args.filter((arg) => !(arg in chainConfig));
  if (invalid.length > 0) {
    console.error(
      `Unsupported chain: ${invalid.join(", ")}\n` +
        `Valid chains: ${validChains.join(", ")}, all\n` +
        `Example: npm run <script> -- ethereum base`,
    );
    process.exit(1);
  }

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

/* Transaction Polling Helper */
export async function waitForTxCompletion(
  client: ReturnType<typeof initiateDeveloperControlledWalletsClient>,
  txId: string,
  label: string,
) {
  const terminalStates = new Set([
    "COMPLETE",
    "CONFIRMED",
    "FAILED",
    "DENIED",
    "CANCELLED",
  ]);

  process.stdout.write(`Waiting for ${label} (txId=${txId})\n`);

  while (true) {
    const { data } = await client.getTransaction({ id: txId });
    const state = data?.transaction?.state;

    process.stdout.write(".");

    if (state && terminalStates.has(state)) {
      process.stdout.write("\n");
      console.log(`${label} final state: ${state}`);

      if (state !== "COMPLETE" && state !== "CONFIRMED") {
        throw new Error(
          `${label} did not complete successfully (state=${state})`,
        );
      }
      return data.transaction;
    }
    await new Promise((resolve) => setTimeout(resolve, 3000));
  }
}

/* Balance Parsing Helper */
export function parseBalance(
  value: string | number | null | undefined,
): bigint {
  const str = String(value ?? "0");
  const [whole, decimal = ""] = str.split(".");
  const decimal6 = (decimal + "000000").slice(0, 6);
  return BigInt((whole || "0") + decimal6);
}

Step 3. Deposit into a unified crosschain balance (Circle Wallets)

The deposit script deposits USDC into the Gateway Wallet on selected EVM chains. Pass chain names as command-line arguments (for example, arc, base, or all). You can skip to the full deposit script if you prefer.
Do not transfer USDC directly to the Gateway Wallet contract with a standard ERC-20 transfer. You must call a Gateway deposit method or the USDC will not be credited to your unified balance.

3.1. Create the deposit script

touch deposit.ts

3.2. Define constants and deposit amount

Set the deposit amount once near the top of the script, then use parseSelectedChains() to let the reader choose one or more EVM source chains at runtime.
const DEPOSIT_AMOUNT_USDC = "2";

3.3. Approve USDC spending and submit the deposit

For each selected source chain, the script first approves the Gateway Wallet contract to spend USDC, then calls the Gateway deposit(address,uint256) method.
const approveTx = await client.createContractExecutionTransaction({
  walletAddress: DEPOSITOR_ADDRESS,
  blockchain: chainConfig[chain].walletChain,
  contractAddress: chainConfig[chain].usdc,
  abiFunctionSignature: "approve(address,uint256)",
  abiParameters: [
    GATEWAY_WALLET_ADDRESS,
    parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
  ],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

const depositTx = await client.createContractExecutionTransaction({
  walletAddress: DEPOSITOR_ADDRESS,
  blockchain: chainConfig[chain].walletChain,
  contractAddress: GATEWAY_WALLET_ADDRESS,
  abiFunctionSignature: "deposit(address,uint256)",
  abiParameters: [
    chainConfig[chain].usdc,
    parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
  ],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

3.4. Wait for Circle Wallet transaction to complete

Circle Wallet contract execution is asynchronous. After each submit step, wait for transaction completion before proceeding to the next phase.
await waitForTxCompletion(client, approveTxId, "USDC approve");
await waitForTxCompletion(client, depositTxId, "Gateway deposit");

3.5. Full deposit script (Circle Wallets)

The script loops through selected chains, approves USDC spending, and deposits into the Gateway Wallet on each chain. Inline comments explain each stage.
deposit.ts
import {
  chainConfig,
  GATEWAY_WALLET_ADDRESS,
  DEPOSITOR_ADDRESS,
  client,
  parseSelectedChains,
  waitForTxCompletion,
  parseBalance,
} from "./config.js";

const DEPOSIT_AMOUNT_USDC = "2";

async function main() {
  // Parse the selected source chains from the command-line arguments.
  const selectedChains = parseSelectedChains();
  console.log(`Using account: ${DEPOSITOR_ADDRESS}`);
  console.log(
    `Depositing on: ${selectedChains
      .map((chain) => chainConfig[chain].chainName)
      .join(", ")}`,
  );

  for (const chain of selectedChains) {
    try {
      console.log(`\n=== Processing ${chainConfig[chain].chainName} ===`);

      // [1] Approve the Gateway Wallet to spend USDC on the source chain.
      console.log(
        `Approving ${DEPOSIT_AMOUNT_USDC} USDC on ${chainConfig[chain].chainName}...`,
      );

      const approveTx = await client.createContractExecutionTransaction({
        walletAddress: DEPOSITOR_ADDRESS,
        blockchain: chainConfig[chain].walletChain,
        contractAddress: chainConfig[chain].usdc,
        abiFunctionSignature: "approve(address,uint256)",
        abiParameters: [
          GATEWAY_WALLET_ADDRESS,
          parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
        ],
        fee: { type: "level", config: { feeLevel: "MEDIUM" } },
      });

      const approveTxId = approveTx.data?.id;
      if (!approveTxId) throw new Error("Failed to create approve transaction");

      await waitForTxCompletion(client, approveTxId, "USDC approve");

      // [2] Call the Gateway deposit function for the current source chain.
      console.log(`Depositing ${DEPOSIT_AMOUNT_USDC} USDC to Gateway Wallet`);

      const depositTx = await client.createContractExecutionTransaction({
        walletAddress: DEPOSITOR_ADDRESS,
        blockchain: chainConfig[chain].walletChain,
        contractAddress: GATEWAY_WALLET_ADDRESS,
        abiFunctionSignature: "deposit(address,uint256)",
        abiParameters: [
          chainConfig[chain].usdc,
          parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
        ],
        fee: { type: "level", config: { feeLevel: "MEDIUM" } },
      });

      const depositTxId = depositTx.data?.id;
      if (!depositTxId) throw new Error("Failed to create deposit transaction");

      await waitForTxCompletion(client, depositTxId, "Gateway deposit");
    } catch (err) {
      console.error(`Error on ${chainConfig[chain].chainName}:`, err);
    }
  }

  console.log(
    "\n==| Block confirmation may take up to 19 minutes for some chains |==",
  );
}

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

3.6. Run the deposit script

Run the script with one or more supported chains:
npm run deposit -- ethereum
npm run deposit -- ethereum base arc
npm run deposit -- all
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.

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 for the DEPOSITOR_ADDRESS currently set in .env.
balances.ts
interface GatewayBalancesResponse {
  balances: Array<{
    domain: number;
    balance: string;
  }>;
}

const EVM_DOMAINS = {
  ethereum: 0,
  avalanche: 1,
  optimism: 2,
  arbitrum: 3,
  base: 6,
  polygon: 7,
  unichain: 10,
  arc: 26,
};

const SOLANA_DOMAINS = {
  solana: 5,
};

const DOMAINS = { ...EVM_DOMAINS, ...SOLANA_DOMAINS };

const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;

if (!DEPOSITOR_ADDRESS) {
  console.error("Missing required env var: DEPOSITOR_ADDRESS");
  process.exit(1);
}

const isEvmAddress = DEPOSITOR_ADDRESS.startsWith("0x");

async function main() {
  console.log(`Depositor address: ${DEPOSITOR_ADDRESS}`);
  console.log(`Address type: ${isEvmAddress ? "EVM" : "Solana"}\n`);

  const activeDomains = isEvmAddress ? EVM_DOMAINS : SOLANA_DOMAINS;
  const domainIds = Object.values(activeDomains);
  const body = {
    token: "USDC",
    sources: domainIds.map((domain) => ({
      domain,
      depositor: DEPOSITOR_ADDRESS,
    })),
  };

  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()) as GatewayBalancesResponse;

  let total = 0;
  for (const balance of result.balances) {
    const chain =
      Object.keys(DOMAINS).find(
        (k) => DOMAINS[k 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 EVM to EVM

The transfer script burns USDC on selected source chains and mints on a destination EVM chain via Gateway. Pass source chain names as command-line arguments (for example, ethereum, arc, or all). You can skip to the full transfer script if you prefer.

4.1. Create the EVM transfer script

touch transfer-from-evm.ts

4.2. Define constants and types

The validated script uses Arc Testnet as the destination chain. Update DESTINATION_CHAIN if you want to mint on a different supported EVM testnet.The script keeps the same typed burn intent structure as the standard Gateway quickstart, but routes signing and mint execution through Circle Wallets.
const DESTINATION_CHAIN: WalletChain = "ARC-TESTNET";
const TRANSFER_AMOUNT_USDC = 0.5;
const MAX_FEE = 2_010000n;
const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();

4.3. Add helper functions

These helpers derive destination chain config, convert EVM addresses to bytes32, and serialize typed data for the Gateway API request.
function getConfigByWalletChain(walletChain: WalletChain) {
  const entry = Object.values(chainConfig).find(
    (item) => item.walletChain === walletChain,
  );
  if (!entry) {
    throw new Error(`No config found for destination chain ${walletChain}`);
  }
  return entry;
}

function addressToBytes32(address: string) {
  return ("0x" +
    address
      .toLowerCase()
      .replace(/^0x/, "")
      .padStart(64, "0")) as `0x${string}`;
}

4.4. Create and sign burn intents

For each selected source chain, create an EIP-712 burn intent and sign it with the source Developer-Controlled Wallet.
const burnIntent = createBurnIntent({
  sourceChain: chain,
  depositorAddress: DEPOSITOR_ADDRESS,
  recipientAddress: RECIPIENT_ADDRESS,
});

const typedData = burnIntentTypedData(burnIntent);

const sigResp = await client.signTypedData({
  walletAddress: DEPOSITOR_ADDRESS,
  blockchain: chainConfig[chain].walletChain,
  data: stringifyTypedData(typedData),
});

4.5. Request attestation from Gateway API

Send the signed burn intents to the Gateway API and validate that the response includes both the attestation and the operator signature needed for minting.
const response = await fetch(
  "https://gateway-api-testnet.circle.com/v1/transfer",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: stringifyTypedData(requests),
  },
);

4.6. Mint on destination chain

Once the Gateway API returns the attestation set, call gatewayMint(bytes,bytes) on the destination EVM chain and wait for Circle Wallet transaction completion.
const tx = await client.createContractExecutionTransaction({
  walletAddress: DEPOSITOR_ADDRESS,
  blockchain: DESTINATION_CHAIN,
  contractAddress: GATEWAY_MINTER_ADDRESS,
  abiFunctionSignature: "gatewayMint(bytes,bytes)",
  abiParameters: [attestation, operatorSig],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

await waitForTxCompletion(client, txId, "USDC mint");

4.7. Full EVM transfer script (Circle Wallets)

The script builds and signs burn intents for the selected EVM source chains, requests a Gateway attestation, and mints on the destination chain. Inline comments explain each stage.
transfer-from-evm.ts
import { randomBytes } from "node:crypto";
import {
  chainConfig,
  GATEWAY_WALLET_ADDRESS,
  GATEWAY_MINTER_ADDRESS,
  DEPOSITOR_ADDRESS,
  client,
  parseSelectedChains,
  waitForTxCompletion,
  parseBalance,
  type Chain,
  type WalletChain,
} from "./config.js";

const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

if (!RECIPIENT_ADDRESS) {
  console.error("Missing required env var: RECIPIENT_ADDRESS");
  process.exit(1);
}

const DESTINATION_CHAIN: WalletChain = "ARC-TESTNET";
const TRANSFER_AMOUNT_USDC = 0.5;
const MAX_FEE = 2_010000n;
const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();

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

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

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" },
];

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

// Build a burn intent for an EVM source chain and destination EVM recipient.
function createBurnIntent(params: {
  sourceChain: Chain;
  depositorAddress: string;
  recipientAddress?: string;
}) {
  const {
    sourceChain,
    depositorAddress,
    recipientAddress = depositorAddress,
  } = params;
  const source = chainConfig[sourceChain];
  const destination = getConfigByWalletChain(DESTINATION_CHAIN);
  const value = parseBalance(String(TRANSFER_AMOUNT_USDC));

  return {
    maxBlockHeight: MAX_UINT256_DEC,
    maxFee: MAX_FEE,
    spec: {
      version: 1,
      sourceDomain: source.domain,
      destinationDomain: destination.domain,
      sourceContract: GATEWAY_WALLET_ADDRESS,
      destinationContract: GATEWAY_MINTER_ADDRESS,
      sourceToken: source.usdc,
      destinationToken: destination.usdc,
      sourceDepositor: depositorAddress,
      destinationRecipient: recipientAddress,
      sourceSigner: depositorAddress,
      destinationCaller: "0x0000000000000000000000000000000000000000",
      value,
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x",
    },
  };
}

// Format the burn intent as EIP-712 typed data for Circle Wallet signing.
function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
  return {
    types: { EIP712Domain, TransferSpec, BurnIntent },
    domain,
    primaryType: "BurnIntent",
    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 ??
            "0x0000000000000000000000000000000000000000",
        ),
      },
    },
  };
}

// Resolve the destination chain config from the selected wallet chain.
function getConfigByWalletChain(walletChain: WalletChain) {
  const entry = Object.values(chainConfig).find(
    (item) => item.walletChain === walletChain,
  );
  if (!entry) {
    throw new Error(`No config found for destination chain ${walletChain}`);
  }
  return entry;
}

// Convert an EVM address to a 32-byte hex string.
function addressToBytes32(address: string) {
  return ("0x" +
    address
      .toLowerCase()
      .replace(/^0x/, "")
      .padStart(64, "0")) as `0x${string}`;
}

function formatUnits(value: bigint, decimals: number) {
  let display = value.toString();
  const negative = display.startsWith("-");
  if (negative) display = display.slice(1);
  display = display.padStart(decimals, "0");
  const integer = display.slice(0, display.length - decimals);
  let fraction = display.slice(display.length - decimals);
  fraction = fraction.replace(/(0+)$/, "");
  return `${negative ? "-" : ""}${integer || "0"}${
    fraction ? `.${fraction}` : ""
  }`;
}

// Serialize typed data while converting bigint values to strings.
function stringifyTypedData<T>(obj: T) {
  return JSON.stringify(obj, (_key, value) =>
    typeof value === "bigint" ? value.toString() : value,
  );
}

async function main() {
  // Parse the selected source chains from the command-line arguments.
  const selectedChains = parseSelectedChains();
  console.log(`Sender (EVM): ${DEPOSITOR_ADDRESS}`);
  console.log(`Recipient (EVM): ${RECIPIENT_ADDRESS}`);
  console.log(
    `Transferring balances from: ${selectedChains
      .map((chain) => chainConfig[chain].chainName)
      .join(", ")}`,
  );

  const requests = [];

  // [1] Create and sign burn intents for each source chain.
  for (const chain of selectedChains) {
    console.log(
      `Creating burn intent from ${chain} -> ${DESTINATION_CHAIN}...`,
    );

    const burnIntent = createBurnIntent({
      sourceChain: chain,
      depositorAddress: DEPOSITOR_ADDRESS,
      recipientAddress: RECIPIENT_ADDRESS,
    });

    const typedData = burnIntentTypedData(burnIntent);

    const sigResp = await client.signTypedData({
      walletAddress: DEPOSITOR_ADDRESS,
      blockchain: chainConfig[chain].walletChain,
      data: stringifyTypedData(typedData),
    });

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

  // [2] Request the attestation set from Gateway API.
  const response = await fetch(
    "https://gateway-api-testnet.circle.com/v1/transfer",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: stringifyTypedData(requests),
    },
  );

  if (!response.ok) {
    console.error("Gateway API error status:", response.status);
    console.error(await response.text());
    throw new Error("Gateway API request failed");
  }

  const json = (await response.json()) as {
    attestation: string;
    signature: string;
  };
  console.log("Gateway API response:", JSON.stringify(json, null, 2));

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

  if (!attestation || !operatorSig) {
    console.error("Gateway /transfer error: missing attestation or signature");
    throw new Error("Invalid Gateway API response");
  }

  // [3] Mint on the destination EVM chain with the returned attestation.
  console.log(
    `Minting funds on ${getConfigByWalletChain(DESTINATION_CHAIN).chainName}...`,
  );

  const tx = await client.createContractExecutionTransaction({
    walletAddress: DEPOSITOR_ADDRESS,
    blockchain: DESTINATION_CHAIN,
    contractAddress: GATEWAY_MINTER_ADDRESS,
    abiFunctionSignature: "gatewayMint(bytes,bytes)",
    abiParameters: [attestation, operatorSig],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });

  console.log("Mint tx submitted:", tx.data?.id);

  const txId = tx.data?.id;
  if (!txId) throw new Error("Failed to submit mint transaction");
  await waitForTxCompletion(client, txId, "USDC mint");

  const totalMinted =
    BigInt(requests.length) * parseBalance(String(TRANSFER_AMOUNT_USDC));
  console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
  console.log(`Mint transaction ID (${DESTINATION_CHAIN}):`, txId);
}

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

4.8. Run the EVM to EVM transfer script

npm run transfer-from-evm -- arc
npm run transfer-from-evm -- ethereum base
npm run transfer-from-evm -- all