Skip to main content
In this tutorial, you’ll use Circle Gateway to create a chain-abstracted USDC balance and Circle Dev-Controlled Wallets.

Prerequisites

Before you begin, make sure you have:
  1. Installed Node.js v22+ and npm.
  2. A Circle Developer Console account
  3. An API key created in the Console:
    Keys → Create a key → API key → Standard Key
  4. Your Entity Secret registered for your wallet (you need it for the script below)

Step 1: Set up your project

In this step, you prepare your project and environment.

1.1. Create a new project

Create a new directory, navigate to it and initialize a new project.
mkdir unified-gateway-balance
cd unified-gateway-balance
npm init -y
npm pkg set type=module
Install the Circle Dev-Controlled Wallets SDK and supporting tools. It is also possible to call the API directly if you can’t use the SDK in your project.
npm install @circle-fin/developer-controlled-wallets typescript tsx

1.2. Initialize and configure the project

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

1.3 Configure environment variables

Create a .env file in the project directory with your Circle credentials, replacing these placeholders with your own credentials:
  • CIRCLE_API_KEY: your API key should be either environment-prefixed (for example, TEST_API_KEY:abc123:def456 or LIVE_API_KEY:xyz:uvw) or base64-encoded strings.
  • CIRCLE_ENTITY_SECRET: your entity secret should be 64 lowercase alphanumeric characters.
echo "CIRCLE_API_KEY={YOUR_API_KEY}
CIRCLE_ENTITY_SECRET={YOUR_ENTITY_SECRET}" > .env
Important: These are sensitive credentials. Do not commit them to version control or share them publicly.

Step 2: Set up your wallets

In this step, you create dev-controlled wallets and fund them with USDC and native tokens to make a deposit into a unified Gateway balance. If you already have funded dev-controlled wallets, skip to Step 3.

2.1. Create wallets on supported chains

Import the Circle Wallets SDK and initialize the client using your API key and Entity Secret. Dev-controlled wallets are created in a wallet set, which serves as the source from which individual wallet keys are derived.
If you supply a refId, all EVM wallets sharing that refId in the function call will have the same address. Having the same address across all the blockchains makes it more straightforward when depositing funds with the deposit function.
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

const client = initiateDeveloperControlledWalletsClient({
  apiKey: "<YOUR_API_KEY>",
  entitySecret: "<YOUR_ENTITY_SECRET>",
});

// Create a wallet set
const walletSetResponse = await client.createWalletSet({
  name: "Gateway Source Wallets",
});

// Create wallets on supported chains
const walletsResponse = await client.createWallets({
  blockchains: ["ARC-TESTNET", "AVAX-FUJI", "BASE-SEPOLIA", "ETH-SEPOLIA"],
  count: 1,
  walletSetId: walletSetResponse.data?.walletSet?.id ?? "",
  metadata: [{ refId: "source-depositor" }],
});
If you’re calling the API directly, you’ll need to make two requests: one to create the wallet set; one to create the wallet.Be sure to replace the Entity Secret ciphertext and the idempotency key in your request. If you’re using the SDKs, this is handled automatically for you.
You should now have four new Externally Owned Account (EOA) developer-controlled wallets with the same wallet address that you can also see from the Developer Console. The API response will look similar to the following:
[
  {
    id: "d646ea32-818d-50e2-85d6-473f809c9f24",
    state: "LIVE",
    walletSetId: "116e0d3e-4f6d-5f2f-8f34-b119732fce0b",
    custodyType: "DEVELOPER",
    refId: "source-depositor",
    name: "",
    address: "0x7f6f2263e451756456a87e9d6911db22617becf5",
    blockchain: "ARC-TESTNET",
    accountType: "EOA",
    updateDate: "2026-01-07T10:07:50Z",
    createDate: "2026-01-07T10:07:50Z",
  }, {
    id: "4be20092-b475-551a-a24f-b698e73c078d",
    state: "LIVE",
    walletSetId: "116e0d3e-4f6d-5f2f-8f34-b119732fce0b",
    custodyType: "DEVELOPER",
    refId: "source-depositor",
    name: "",
    address: "0x7f6f2263e451756456a87e9d6911db22617becf5",
    blockchain: "AVAX-FUJI",
    accountType: "EOA",
    updateDate: "2026-01-07T10:07:50Z",
    createDate: "2026-01-07T10:07:50Z",
  }, {
    id: "ec2cd72c-e799-5fa7-95f1-08e1a62a1b1a",
    state: "LIVE",
    walletSetId: "116e0d3e-4f6d-5f2f-8f34-b119732fce0b",
    custodyType: "DEVELOPER",
    refId: "source-depositor",
    name: "",
    address: "0x7f6f2263e451756456a87e9d6911db22617becf5",
    blockchain: "BASE-SEPOLIA",
    accountType: "EOA",
    updateDate: "2026-01-07T10:07:50Z",
    createDate: "2026-01-07T10:07:50Z",
  }, {
    id: "1b93227b-7721-5f27-a825-ffbc46cf92fc",
    state: "LIVE",
    walletSetId: "116e0d3e-4f6d-5f2f-8f34-b119732fce0b",
    custodyType: "DEVELOPER",
    refId: "source-depositor",
    name: "",
    address: "0x7f6f2263e451756456a87e9d6911db22617becf5",
    blockchain: "ETH-SEPOLIA",
    accountType: "EOA",
    updateDate: "2026-01-07T10:07:50Z",
    createDate: "2026-01-07T10:07:50Z",
  }
]

2.2. 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 and 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
To request more testnet USDC, you can fill out this Testnet USDC Faucet Survey.

2.3. Check wallet balances

You can check your wallet balances from the Circle Developer Console or programmatically by making a request to GET /wallets/{id}/balances with the specified wallet ID.
const response = await client.getWalletTokenBalance({
  id: "<WALLET_ID>",
});

Step 3: Deposit into a unified crosschain balance

In this step, you review each part of the script to deposit USDC into the Gateway Wallet contracts. You can skip to the full deposit script if you prefer.

3.1. Create the script file

touch deposit.ts

3.2. Define chain configuration

type Chain = "ethereum" | "base" | "avalanche" | "arc";

type ChainConfig = {
  chainName: string;
  usdc: string;
  walletId: string;
};

const CHAIN_CONFIG: Record<Chain, ChainConfig> = {
  ethereum: {
    chainName: "Ethereum Sepolia",
    usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    walletId: "80ed23f3-bb10-58f8-920d-71e7d58d0706",
  },
  base: {
    chainName: "Base Sepolia",
    usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    walletId: "44814fb3-c2f8-50fa-9505-6b11998b72f4",
  },
  avalanche: {
    chainName: "Avalanche Fuji",
    usdc: "0x5425890298aed601595a70AB815c96711a31Bc65",
    walletId: "2546e083-f1b2-5e6b-b052-d16a5b17f52f",
  },
  arc: {
    chainName: "Arc Testnet",
    usdc: "0x3600000000000000000000000000000000000000",
    walletId: "9ca0ff79-04e6-5353-8aff-b013ab7fce58",
  },
};

3.3. Define constants

const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";

const API_KEY = process.env.CIRCLE_API_KEY;
const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET;

if (!API_KEY || !ENTITY_SECRET) {
  console.error("Missing CIRCLE_API_KEY or CIRCLE_ENTITY_SECRET in .env");
  process.exit(1);
}

const DEPOSIT_AMOUNT_USDC = "2";

3.4. Add helper functions

// Convert human-readable USDC (e.g., "10.5") to uint256 (6 decimals) string
function usdcToUint256Str(usdcStr: string) {
  const [whole, decimal = ""] = String(usdcStr).split(".");
  const decimal6 = (decimal + "000000").slice(0, 6);
  const uint256Amt = BigInt(whole + decimal6).toString();
  return uint256Amt;
}

// Sets time interval to poll for transaction status
function sleep(ms: number) {
  return new Promise((res) => setTimeout(res, ms));
}

// Polls for transaction status
async function waitForTxCompletion(client: any, txId: string, label: string) {
  const terminalStates = new Set([
    "COMPLETE",
    "CONFIRMED",
    "FAILED",
    "DENIED",
    "CANCELLED",
  ]);

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

  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 sleep(3000);
  }
}

// Dedupe CLI arguments
function dedupe<T>(array: T[]) {
  const chains = new Set<T>();
  return array.filter((chain) =>
    chains.has(chain) ? false : (chains.add(chain), true),
  );
}

// Parse chains from CLI arguments
function parseSelectedChains(): Chain[] {
  const args = process.argv.slice(2).map((chain) => chain.toLowerCase());
  if (args.length === 0) return Object.keys(CHAIN_CONFIG) as Chain[];

  const selected: Chain[] = [];
  for (const arg of args) {
    if (!(arg in CHAIN_CONFIG)) {
      console.error(
        `Unsupported chain: ${arg}\n` +
          `Usage: npx tsx --env-file=.env deposit.ts <${Object.keys(
            CHAIN_CONFIG,
          ).join("|")}>\n` +
          `Example: npx tsx --env-file=.env deposit.ts base`,
      );
      process.exit(1);
    }
    selected.push(arg as Chain);
  }
  return dedupe(selected);
}

3.5. 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.
Important: You must call the deposit method, not the standard transfer function on the USDC contract. Deposits into the Gateway Wallet only work through the designated deposit function.
async function main() {
  // Allows for chain selection via CLI arguments
  const selectedChains = parseSelectedChains();
  if (selectedChains.length !== 1) {
    console.error(
      `Usage: npx tsx --env-file=.env deposit.ts <${Object.keys(
        CHAIN_CONFIG,
      ).join("|")}> (pick exactly one)\n` +
        `Example: npx tsx --env-file=.env deposit.ts base`,
    );
    process.exit(1);
  }

  const CHAIN = selectedChains[0];
  const config = CHAIN_CONFIG[CHAIN];

  const USDC_ADDRESS = config.usdc;
  const WALLET_ID = config.walletId;

  console.log(`Using chain: ${config.chainName}`);
  console.log(`USDC address: ${USDC_ADDRESS}`);
  console.log(`Wallet ID: ${WALLET_ID}`);

  // Initiate wallets client
  const client = initiateDeveloperControlledWalletsClient({
    apiKey: API_KEY!,
    entitySecret: ENTITY_SECRET!,
  });

  // Approve USDC for the Gateway Wallet to transfer USDC from your address
  console.log(
    `Approving ${DEPOSIT_AMOUNT_USDC} USDC for spender ${GATEWAY_WALLET_ADDRESS}`,
  );

  const approveTx = await client.createContractExecutionTransaction({
    walletId: WALLET_ID!,
    contractAddress: USDC_ADDRESS,
    abiFunctionSignature: "approve(address,uint256)",
    abiParameters: [
      GATEWAY_WALLET_ADDRESS,
      usdcToUint256Str(DEPOSIT_AMOUNT_USDC),
    ],
    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");

  // Call deposit method on the Gateway Wallet contract
  console.log(`Depositing ${DEPOSIT_AMOUNT_USDC} USDC to Gateway Wallet`);

  const depositTx = await client.createContractExecutionTransaction({
    walletId: WALLET_ID!,
    contractAddress: GATEWAY_WALLET_ADDRESS,
    abiFunctionSignature: "deposit(address,uint256)",
    abiParameters: [USDC_ADDRESS, usdcToUint256Str(DEPOSIT_AMOUNT_USDC)],
    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");

  console.log(
    "Transaction complete. Once finality is reached, Gateway credits your unified USDC balance.",
  );
}

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

3.6. Full deposit script

Now that you’ve completed the setup and core steps, this full script brings everything together. It deposits 2 USDC from the specified chain into your Gateway balance. The script includes inline comments to explain what each function does, making it easier to follow and modify if needed.
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

/* Chain configuration */
type Chain = "ethereum" | "base" | "avalanche" | "arc";

type ChainConfig = {
  chainName: string;
  usdc: string;
  walletId: string;
};

const CHAIN_CONFIG: Record<Chain, ChainConfig> = {
  ethereum: {
    chainName: "Ethereum Sepolia",
    usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    walletId: "80ed23f3-bb10-58f8-920d-71e7d58d0706",
  },
  base: {
    chainName: "Base Sepolia",
    usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    walletId: "44814fb3-c2f8-50fa-9505-6b11998b72f4",
  },
  avalanche: {
    chainName: "Avalanche Fuji",
    usdc: "0x5425890298aed601595a70AB815c96711a31Bc65",
    walletId: "2546e083-f1b2-5e6b-b052-d16a5b17f52f",
  },
  arc: {
    chainName: "Arc Testnet",
    usdc: "0x3600000000000000000000000000000000000000",
    walletId: "9ca0ff79-04e6-5353-8aff-b013ab7fce58",
  },
};

/* Constants */
const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";

const API_KEY = process.env.CIRCLE_API_KEY;
const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET;

if (!API_KEY || !ENTITY_SECRET) {
  console.error("Missing CIRCLE_API_KEY or CIRCLE_ENTITY_SECRET in .env");
  process.exit(1);
}

const DEPOSIT_AMOUNT_USDC = "1";

/* Helpers */
// Convert human-readable USDC (e.g., "10.5") to uint256 (6 decimals) string
function usdcToUint256Str(usdcStr: string) {
  const [whole, decimal = ""] = String(usdcStr).split(".");
  const decimal6 = (decimal + "000000").slice(0, 6);
  const uint256Amt = BigInt(whole + decimal6).toString();
  return uint256Amt;
}

// Sets time interval to poll for transaction status
function sleep(ms: number) {
  return new Promise((res) => setTimeout(res, ms));
}

// Polls for transaction status
async function waitForTxCompletion(client: any, txId: string, label: string) {
  const terminalStates = new Set([
    "COMPLETE",
    "CONFIRMED",
    "FAILED",
    "DENIED",
    "CANCELLED",
  ]);

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

  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 sleep(3000);
  }
}

// Dedupe CLI arguments
function dedupe<T>(array: T[]) {
  const chains = new Set<T>();
  return array.filter((chain) =>
    chains.has(chain) ? false : (chains.add(chain), true),
  );
}

// Parse chains from CLI arguments
function parseSelectedChains(): Chain[] {
  const args = process.argv.slice(2).map((chain) => chain.toLowerCase());
  if (args.length === 0) return Object.keys(CHAIN_CONFIG) as Chain[];

  const selected: Chain[] = [];
  for (const arg of args) {
    if (!(arg in CHAIN_CONFIG)) {
      console.error(
        `Unsupported chain: ${arg}\n` +
          `Usage: npx tsx --env-file=.env deposit.ts <${Object.keys(
            CHAIN_CONFIG,
          ).join("|")}>\n` +
          `Example: npx tsx --env-file=.env deposit.ts base`,
      );
      process.exit(1);
    }
    selected.push(arg as Chain);
  }
  return dedupe(selected);
}

/* Main logic */
async function main() {
  // Allows for chain selection via CLI arguments
  const selectedChains = parseSelectedChains();
  if (selectedChains.length !== 1) {
    console.error(
      `Usage: npx tsx --env-file=.env deposit.ts <${Object.keys(
        CHAIN_CONFIG,
      ).join("|")}> (pick exactly one)\n` +
        `Example: npx tsx --env-file=.env deposit.ts base`,
    );
    process.exit(1);
  }

  const CHAIN = selectedChains[0];
  const config = CHAIN_CONFIG[CHAIN];

  const USDC_ADDRESS = config.usdc;
  const WALLET_ID = config.walletId;

  console.log(`Using chain: ${config.chainName}`);
  console.log(`USDC address: ${USDC_ADDRESS}`);
  console.log(`Wallet ID: ${WALLET_ID}`);

  // Initiate wallets client
  const client = initiateDeveloperControlledWalletsClient({
    apiKey: API_KEY!,
    entitySecret: ENTITY_SECRET!,
  });

  // Approve USDC for the Gateway Wallet to transfer USDC from your address
  console.log(
    `Approving ${DEPOSIT_AMOUNT_USDC} USDC for spender ${GATEWAY_WALLET_ADDRESS}`,
  );

  const approveTx = await client.createContractExecutionTransaction({
    walletId: WALLET_ID!,
    contractAddress: USDC_ADDRESS,
    abiFunctionSignature: "approve(address,uint256)",
    abiParameters: [
      GATEWAY_WALLET_ADDRESS,
      usdcToUint256Str(DEPOSIT_AMOUNT_USDC),
    ],
    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");

  // Call deposit method on the Gateway Wallet contract
  console.log(`Depositing ${DEPOSIT_AMOUNT_USDC} USDC to Gateway Wallet`);

  const depositTx = await client.createContractExecutionTransaction({
    walletId: WALLET_ID!,
    contractAddress: GATEWAY_WALLET_ADDRESS,
    abiFunctionSignature: "deposit(address,uint256)",
    abiParameters: [USDC_ADDRESS, usdcToUint256Str(DEPOSIT_AMOUNT_USDC)],
    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");

  console.log(
    "Transaction complete. Once finality is reached, Gateway credits your unified USDC balance.",
  );
}

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

3.7. Run the script to create a crosschain balance

Run the deposit.ts script to make the deposits. You can select which chain to deposit from using command-line arguments.
# Deposit from Arc wallet
npx tsx --env-file=.env deposit.ts arc

# Deposit from Ethereum wallet
npx tsx --env-file=.env deposit.ts ethereum

# Deposit from Base wallet
npx tsx --env-file=.env deposit.ts base

# Deposit from Avalanche wallet
npx tsx --env-file=.env deposit.ts avalanche
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.8. 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. You can run it to check whether finality has been reached for recent transactions.
touch balances.ts
Replace the placeholder <WALLET_ID> with any of the wallet IDs from the created wallets. The code derives the address from the wallet ID provided.
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

const GATEWAY_API = "https://gateway-api-testnet.circle.com/v1";

const API_KEY = process.env.CIRCLE_API_KEY;
const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET;

if (!API_KEY || !ENTITY_SECRET) {
  console.error("Missing CIRCLE_API_KEY or CIRCLE_ENTITY_SECRET in .env");
  process.exit(1);
}

const CHAINS = {
  ethereum: { domain: 0, name: "Ethereum Sepolia" },
  avalanche: { domain: 1, name: "Avalanche Fuji" },
  base: { domain: 6, name: "Base Sepolia" },
  arc: { domain: 26, name: "Arc Testnet" },
};
const chainList = Object.values(CHAINS);
const domainNames = Object.fromEntries(
  chainList.map((c) => [c.domain, c.name]),
);

const toBigInt = (value: string | number | null | undefined): bigint => {
  const balanceString = String(value ?? "0");
  if (balanceString.includes(".")) {
    const [whole, decimal = ""] = balanceString.split(".");
    const decimal6 = (decimal + "000000").slice(0, 6);
    return BigInt((whole || "0") + decimal6);
  }
  return BigInt(balanceString || "0");
};

async function showUnifiedAvailableBalance(client: any, walletId: string) {
  const { data } = await client.getWallet({ id: walletId });
  const depositor = data?.wallet?.address;
  if (!depositor) throw new Error("Could not resolve wallet address");
  console.log(`Depositor address: ${depositor}`);

  // Query Gateway for the available balance recorded by the system
  const response = await fetch(`${GATEWAY_API}/balances`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      token: "USDC",
      sources: chainList.map(({ domain }) => ({ domain, depositor })),
    }),
  });
  const { balances = [] } = await response.json();

  let totalBalances = 0n;
  for (const balance of balances) {
    const amount = toBigInt(balance?.balance);
    const chain =
      domainNames[balance!.domain as number] ??
      `Domain ${balance!.domain as number}`;
    console.log(
      `  - ${chain}: ${amount / 1_000_000n}.${(amount % 1_000_000n)
        .toString()
        .padStart(6, "0")} USDC`,
    );
    totalBalances += amount;
  }
  const whole = totalBalances / 1_000_000n;
  const decimal = totalBalances % 1_000_000n;
  const totalUsdc = `${whole}.${decimal.toString().padStart(6, "0")}`;
  console.log(`Unified USDC available: ${totalUsdc} USDC`);
}

async function main() {
  const client = initiateDeveloperControlledWalletsClient({
    apiKey: API_KEY!,
    entitySecret: ENTITY_SECRET!,
  });
  const walletId = "<WALLET_ID>";
  await showUnifiedAvailableBalance(client, walletId);
}

main().catch((error) => {
  console.error("\n Error:", error?.response?.data ?? error);
  process.exit(1);
});
Run the script to check the unified balance.
npx tsx --env-file=.env balances.ts

Step 4: Transfer USDC from the crosschain balance to Arc

In this step, you can review the transfer script, covering configuration, approval, and minting USDC on Arc from your crosschain balance. You can skip to the full transfer script if you prefer.

4.1. Create the script file

touch transfer.ts

4.2. Define chain configuration

import { randomBytes } from "node:crypto";
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

/* Chain configuration */
type WalletChain = "ETH-SEPOLIA" | "BASE-SEPOLIA" | "AVAX-FUJI" | "ARC-TESTNET";

type Chain = "ethereum" | "base" | "avalanche" | "arc";

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

const CHAIN_CONFIG: Record<Chain, ChainConfig> = {
  ethereum: {
    chainName: "Ethereum Sepolia",
    usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    walletId: "80ed23f3-bb10-58f8-920d-71e7d58d0706",
    domain: 0,
    walletChain: "ETH-SEPOLIA",
  },
  base: {
    chainName: "Base Sepolia",
    usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    walletId: "44814fb3-c2f8-50fa-9505-6b11998b72f4",
    domain: 6,
    walletChain: "BASE-SEPOLIA",
  },
  avalanche: {
    chainName: "Avalanche Fuji",
    usdc: "0x5425890298aed601595a70AB815c96711a31Bc65",
    walletId: "2546e083-f1b2-5e6b-b052-d16a5b17f52f",
    domain: 1,
    walletChain: "AVAX-FUJI",
  },
  arc: {
    chainName: "Arc Testnet",
    usdc: "0x3600000000000000000000000000000000000000",
    walletId: "9ca0ff79-04e6-5353-8aff-b013ab7fce58",
    domain: 26,
    walletChain: "ARC-TESTNET",
  },
};

4.3. Define constants and EIP-712 typed data types

These constants define the parameters for transferring funds from your Gateway balance to a wallet on the destination chain.
  • Replace the <WALLET_ADDRESS> placeholder with the unified wallet address of your created dev-controlled wallets. You can run the balances.ts script to check the balance.
  • The DESTINATION_CHAIN is currently set to BASE-SEPOLIA but you can change it to a chain of your choice. Note that gas fees differ between chains.
  • You can set the amount of USDC to be transfered from each chain within the unified balance, which is currently set to 1 USDC.
const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
const GATEWAY_MINTER_ADDRESS = "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";
const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();

const API_KEY = process.env.CIRCLE_API_KEY;
const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET;

if (!API_KEY || !ENTITY_SECRET) {
  console.error("Missing CIRCLE_API_KEY or CIRCLE_ENTITY_SECRET in .env");
  process.exit(1);
}

const DEPOSITOR_ADDRESS = "<WALLET_ADDRESS>";
const DESTINATION_CHAIN: WalletChain = "BASE-SEPOLIA";
const TRANSFER_AMOUNT_USDC = 1;

// EIP-712 typing
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" },
];

4.4. Add helper functions

function burnIntentTypedData(burnIntent: any, domain: any) {
  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 ??
            addressToBytes32("0x0000000000000000000000000000000000000000"),
        ),
      },
    },
  };
}

// Burn intent factory using CHAIN_CONFIG
function makeBurnIntent(sourceChain: Chain) {
  const src = CHAIN_CONFIG[sourceChain];
  const dst = getConfigByWalletChain(DESTINATION_CHAIN);
  const value = BigInt(usdcToUint256Str(String(TRANSFER_AMOUNT_USDC)));

  return {
    maxBlockHeight: MAX_UINT256_DEC,
    maxFee: 2_010000n,
    spec: {
      version: 1,
      sourceDomain: src.domain,
      destinationDomain: dst.domain,
      sourceContract: GATEWAY_WALLET_ADDRESS,
      destinationContract: GATEWAY_MINTER_ADDRESS,
      sourceToken: src.usdc,
      destinationToken: dst.usdc,
      sourceDepositor: DEPOSITOR_ADDRESS,
      destinationRecipient: "0x8b4ac46d9267e25f4d40092b33aedc150ebe5cdf",
      sourceSigner: DEPOSITOR_ADDRESS,
      destinationCaller: addressToBytes32(
        "0x0000000000000000000000000000000000000000",
      ),
      value: value,
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x",
    },
  };
}

function getConfigByWalletChain(walletChain: WalletChain): ChainConfig {
  const entry = Object.values(CHAIN_CONFIG).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}`;
}

// Convert human-readable USDC (e.g., "10.5") to uint256 (6 decimals) string
function usdcToUint256Str(usdcStr: string) {
  const [whole, decimal = ""] = String(usdcStr).split(".");
  const decimal6 = (decimal + "000000").slice(0, 6);
  const uint256Amt = BigInt(whole + decimal6).toString();
  return uint256Amt;
}

function stringifyTypedData(obj: any) {
  return JSON.stringify(obj, (_key, value) =>
    typeof value === "bigint" ? value.toString() : value,
  );
}

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}` : ""
  }`;
}

// Sets time interval to poll for transaction status
function sleep(ms: number) {
  return new Promise((res) => setTimeout(res, ms));
}

// Polls for transaction status
async function waitForTxCompletion(client: any, 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("Mint tx state:", state);

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

// Dedupe CLI arguments
function dedupe<T>(array: T[]) {
  const chains = new Set<T>();
  return array.filter((chain) =>
    chains.has(chain) ? false : (chains.add(chain), true),
  );
}

// Parse chains from CLI arguments
function parseSelectedChains(): Chain[] {
  const args = process.argv.slice(2).map((chain) => chain.toLowerCase());
  if (args.length === 0) return Object.keys(CHAIN_CONFIG) as Chain[];

  const selected: Chain[] = [];
  for (const arg of args) {
    if (!(arg in CHAIN_CONFIG)) {
      console.error(
        `Unsupported chain: ${arg}\n` +
          `Usage: npx tsx --env-file=.env transfer.ts [${Object.keys(
            CHAIN_CONFIG,
          ).join("] [")}]\n` +
          `Example: npx tsx --env-file=.env transfer.ts base avalanche`,
      );
      process.exit(1);
    }
    selected.push(arg as Chain);
  }
  return dedupe(selected);
}

4.5. Construct and sign the burn intents

First, within the main logic, you need to build the requests and sign them before they can be submitted to the Gateway API.
// Build requests only for selected chains
const requests: any[] = [];
const burnIntentsForTotal: any[] = [];

for (const chain of selectedChains) {
  const config = CHAIN_CONFIG[chain];

  const burnIntent = makeBurnIntent(chain);
  const typedData = burnIntentTypedData(burnIntent, domain);

  const sigResp = await client.signTypedData({
    walletId: config.walletId,
    data: stringifyTypedData(typedData),
  });

  requests.push({
    burnIntent: typedData.message,
    signature: sigResp.data?.signature,
  });

  burnIntentsForTotal.push(burnIntent);
}

4.6. Submit the burn intents to the API

Then, you use the signed burn intents to request an attestation from the 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,
    ),
  },
);

const json = await response.json();
const attestation = json?.attestation;
const operatorSig = json?.signature;

if (!attestation || !operatorSig) {
  console.error("Gateway /transfer error:", json);
  process.exit(1);
}

4.7. Mint USDC on destination chain

Finally, you need to pass the attestation to mint USDC to the destination chain by calling gatewayMint() on the Gateway Minter contract.
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 totalMintBaseUnits = burnIntentsForTotal.reduce(
  (sum, i) => sum + (i.spec.value ?? 0n),
  0n,
);
console.log(`Minted ${formatUnits(totalMintBaseUnits, 6)} USDC`);

4.8. Full transfer script

Now that you’ve covered the setup and core steps, this full script puts everything together and transfers out 1 USDC from each specified Gateway balances to the destination address. It includes comments that describe what each function does as well for reference.
import { randomBytes } from "node:crypto";
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

/* Chain configuration */
type WalletChain = "ETH-SEPOLIA" | "BASE-SEPOLIA" | "AVAX-FUJI" | "ARC-TESTNET";

type Chain = "ethereum" | "base" | "avalanche" | "arc";

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

const CHAIN_CONFIG: Record<Chain, ChainConfig> = {
  ethereum: {
    chainName: "Ethereum Sepolia",
    usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    walletId: "80ed23f3-bb10-58f8-920d-71e7d58d0706",
    domain: 0,
    walletChain: "ETH-SEPOLIA",
  },
  base: {
    chainName: "Base Sepolia",
    usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    walletId: "44814fb3-c2f8-50fa-9505-6b11998b72f4",
    domain: 6,
    walletChain: "BASE-SEPOLIA",
  },
  avalanche: {
    chainName: "Avalanche Fuji",
    usdc: "0x5425890298aed601595a70AB815c96711a31Bc65",
    walletId: "2546e083-f1b2-5e6b-b052-d16a5b17f52f",
    domain: 1,
    walletChain: "AVAX-FUJI",
  },
  arc: {
    chainName: "Arc Testnet",
    usdc: "0x3600000000000000000000000000000000000000",
    walletId: "9ca0ff79-04e6-5353-8aff-b013ab7fce58",
    domain: 26,
    walletChain: "ARC-TESTNET",
  },
};

/* Constants */
const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
const GATEWAY_MINTER_ADDRESS = "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";
const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();

const API_KEY = process.env.CIRCLE_API_KEY;
const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET;

if (!API_KEY || !ENTITY_SECRET) {
  console.error("Missing CIRCLE_API_KEY or CIRCLE_ENTITY_SECRET in .env");
  process.exit(1);
}

const DEPOSITOR_ADDRESS = "0xd02419296ef08a575e6dea178950e9c9c25126b8";
const DESTINATION_CHAIN: WalletChain = "AVAX-FUJI";
const TRANSFER_AMOUNT_USDC = 1;

// EIP-712 typing
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" },
];

/* Helpers */
function burnIntentTypedData(burnIntent: any, domain: any) {
  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 ??
            addressToBytes32("0x0000000000000000000000000000000000000000"),
        ),
      },
    },
  };
}

// Burn intent factory using CHAIN_CONFIG
function makeBurnIntent(sourceChain: Chain) {
  const src = CHAIN_CONFIG[sourceChain];
  const dst = getConfigByWalletChain(DESTINATION_CHAIN);
  const value = BigInt(usdcToUint256Str(String(TRANSFER_AMOUNT_USDC)));

  return {
    maxBlockHeight: MAX_UINT256_DEC,
    maxFee: 2_010000n,
    spec: {
      version: 1,
      sourceDomain: src.domain,
      destinationDomain: dst.domain,
      sourceContract: GATEWAY_WALLET_ADDRESS,
      destinationContract: GATEWAY_MINTER_ADDRESS,
      sourceToken: src.usdc,
      destinationToken: dst.usdc,
      sourceDepositor: DEPOSITOR_ADDRESS,
      destinationRecipient: "0x8b4ac46d9267e25f4d40092b33aedc150ebe5cdf",
      sourceSigner: DEPOSITOR_ADDRESS,
      destinationCaller: addressToBytes32(
        "0x0000000000000000000000000000000000000000",
      ),
      value: value,
      salt: "0x" + randomBytes(32).toString("hex"),
      hookData: "0x",
    },
  };
}

function getConfigByWalletChain(walletChain: WalletChain): ChainConfig {
  const entry = Object.values(CHAIN_CONFIG).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}`;
}

// Convert human-readable USDC (e.g., "10.5") to uint256 (6 decimals) string
function usdcToUint256Str(usdcStr: string) {
  const [whole, decimal = ""] = String(usdcStr).split(".");
  const decimal6 = (decimal + "000000").slice(0, 6);
  const uint256Amt = BigInt(whole + decimal6).toString();
  return uint256Amt;
}

function stringifyTypedData(obj: any) {
  return JSON.stringify(obj, (_key, value) =>
    typeof value === "bigint" ? value.toString() : value,
  );
}

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}` : ""}`;
}

// Sets time interval to poll for transaction status
function sleep(ms: number) {
  return new Promise((res) => setTimeout(res, ms));
}

// Polls for transaction status
async function waitForTxCompletion(client: any, 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("Mint tx state:", state);

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

// Dedupe CLI arguments
function dedupe<T>(array: T[]) {
  const chains = new Set<T>();
  return array.filter((chain) =>
    chains.has(chain) ? false : (chains.add(chain), true),
  );
}

// Parse chains from CLI arguments
function parseSelectedChains(): Chain[] {
  const args = process.argv.slice(2).map((chain) => chain.toLowerCase());
  if (args.length === 0) return Object.keys(CHAIN_CONFIG) as Chain[];

  const selected: Chain[] = [];
  for (const arg of args) {
    if (!(arg in CHAIN_CONFIG)) {
      console.error(
        `Unsupported chain: ${arg}\n` +
          `Usage: npx tsx --env-file=.env transfer.ts [${Object.keys(CHAIN_CONFIG).join("] [")}]\n` +
          `Example: npx tsx --env-file=.env transfer.ts base avalanche`,
      );
      process.exit(1);
    }
    selected.push(arg as Chain);
  }
  return dedupe(selected);
}

/* Main logic */
async function main() {
  // Allows for chain selection via CLI arguments
  const selectedChains = parseSelectedChains();
  console.log(
    `Transfering balances from: ${selectedChains.map((c) => CHAIN_CONFIG[c].chainName).join(", ")}`,
  );

  // Initiate wallets client
  const client = initiateDeveloperControlledWalletsClient({
    apiKey: API_KEY!,
    entitySecret: ENTITY_SECRET!,
  });

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

  // Build requests only for selected chains
  const requests: any[] = [];
  const burnIntentsForTotal: any[] = [];

  for (const chain of selectedChains) {
    const config = CHAIN_CONFIG[chain];

    const burnIntent = makeBurnIntent(chain);
    const typedData = burnIntentTypedData(burnIntent, domain);

    const sigResp = await client.signTypedData({
      walletId: config.walletId,
      data: stringifyTypedData(typedData),
    });

    requests.push({
      burnIntent: typedData.message,
      signature: sigResp.data?.signature,
    });

    burnIntentsForTotal.push(burnIntent);
  }

  // Submit burn intents to Gateway API to obtain an attestation
  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,
      ),
    },
  );

  const json = await response.json();
  const attestation = json?.attestation;
  const operatorSig = json?.signature;

  if (!attestation || !operatorSig) {
    console.error("Gateway /transfer error:", json);
    process.exit(1);
  }

  // Mint on the destination chain
  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 totalMintBaseUnits = burnIntentsForTotal.reduce(
    (sum, i) => sum + (i.spec.value ?? 0n),
    0n,
  );
  console.log(`Minted ${formatUnits(totalMintBaseUnits, 6)} USDC`);
}

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

4.9. Run the script to transfer USDC to destination chain

Run the transfer.ts 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.
npx tsx --env-file=.env transfer.ts [chain1] [chain2]

Summary

After completing this tutorial, you’ve successfully:
  • Created dev-controlled wallets
  • Funded your wallet with testnet USDC
  • Created a unified USDC balance
  • Transferred USDC out from your unified USDC balance