CCTP

Transfer USDC on testnet from Ethereum to Avalanche using CCTP V2

Explore this script to transfer USDC on testnet between two EVM-compatible chains via CCTP V2

This guide demonstrates how to use the viem framework and the CCTP V2 API in a simple script that enables a user to transfer USDC from a wallet address on the Ethereum Sepolia testnet to another wallet address on the Avalanche Fuji testnet.

Before you start building the sample app to perform a USDC transfer, ensure you have met the following prerequisites:

  1. Install Node.js and npm

    • Download and install Node.js directly or use a version manager like nvm.
    • npm is included with Node.js.
  2. Set up a non-custodial wallet (for example, MetaMask)

    • You can download, install, and create a MetaMask wallet from its official website.
    • During setup, create a wallet on the Ethereum Sepolia testnet.
    • Retrieve the private key for your wallet, as it will be required in the script below.
  3. Fund your wallet with testnet tokens

To build the script, first set up your project environment and install the required dependencies.

  1. Set up a new project

Create a new directory and initialize a new Node.js project with default settings:

Shell
mkdir cctp-v2-transfer
cd cctp-v2-transfer
npm init -y

This also creates a default package.json file.

  1. Install dependencies

In your project directory, install the required dependencies, including viem:

Shell
npm install axios@^1.7.9 dotenv@^16.4.7 viem@^2.23.4

This sets up your development environment with the necessary libraries for building the script. It also updates the package.json file with the dependencies.

  1. Add module type

Add "type": "module" to the package.json file:

package.json

JSON
{
  "name": "cctp-v2-transfer",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node transfer.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "axios": "^1.7.9",
    "dotenv": "^16.4.7",
    "viem": "^2.23.4"
  }
}
  1. Configure environment variables

Create a .env file in your project directory and add your wallet private key:

Shell
echo "PRIVATE_KEY=your-private-key-here" > .env

This section covers the necessary setup for the transfer.js script, including defining keys and addresses, and configuring the wallet client for interacting with the source and destination chains.

  1. Replace with your private key and wallet address

Ensure that this section of the file includes your private key and associated wallet address. The script also predefines the contract addresses, the transfer amount, and the max fee. These definitions are critical for successfully transferring USDC between the intended wallets.

JavaScript
// ============ Configuration Constants ============

// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(`0x${PRIVATE_KEY}`);

// Contract Addresses
const ETHEREUM_SEPOLIA_USDC = "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238";
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
  "0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa";
const AVALANCHE_FUJI_MESSAGE_TRANSMITTER =
  "0xe737e5cebeeba77efe34d4aa090756590b1ce275";

// Transfer Parameters
const DESTINATION_ADDRESS = "your-wallet-address"; // Address to receive minted tokens on destination chain
const AMOUNT = 1_000_000n; // Set transfer amount in 10^6 subunits (1 USDC; change as needed)
const maxFee = 500n; // Set fast transfer max fee in 10^6 subunits (0.0005 USDC; change as needed)

// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(2)}`; // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
  "0x0000000000000000000000000000000000000000000000000000000000000000"; // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()

// Chain-specific Parameters
const ETHEREUM_SEPOLIA_DOMAIN = 0; // Source domain ID for Ethereum Sepolia testnet
const AVALANCHE_FUJI_DOMAIN = 1; // Destination domain ID for Avalanche Fuji testnet
  1. Set up wallet clients

The wallet client configures the appropriate network settings using viem. In this example, the script connects to the Ethereum Sepolia testnet and the Avalanche Fuji testnet.

JavaScript
// Set up the wallet clients
const sepoliaClient = createWalletClient({
  chain: sepolia,
  transport: http(),
  account,
});

const avalancheClient = createWalletClient({
  chain: avalancheFuji,
  transport: http(),
  account,
});

The following sections outline the relevant transfer logic of the sample script. You can view the full source code in the Build the script section below. To perform the actual transfer of USDC from Ethereum Sepolia to Avalanche Fuji using CCTP V2, follow the steps below:

The first step is to grant approval for the TokenMessengerV2 contract deployed on the Ethereum Sepolia testnet to withdraw USDC from your wallet on that source chain. This allows the contract to withdraw USDC from the specified wallet address.

JavaScript
async function approveUSDC() {
  console.log("Approving USDC transfer...");
  const approveTx = await sepoliaClient.sendTransaction({
    to: ETHEREUM_SEPOLIA_USDC,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "approve",
          stateMutability: "nonpayable",
          inputs: [
            { name: "spender", type: "address" },
            { name: "amount", type: "uint256" },
          ],
          outputs: [{ name: "", type: "bool" }],
        },
      ],
      functionName: "approve",
      args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, 10_000_000_000n], // Set max allowance in 10^6 subunits (10,000 USDC; change as needed)
    }),
  });
  console.log(`USDC Approval Tx: ${approveTx}`);
}

In this step, you call the depositForBurn function from the TokenMessengerV2 contract deployed on the Ethereum Sepolia testnet to burn USDC on that source chain. You specify the following parameters:

  • Burn amount: The amount of USDC to burn
  • Destination domain: the target blockchain for minting USDC
  • Mint recipient: The wallet address that will receive the minted USDC
  • Burn token: The contract address of the USDC token being burned on the source chain
  • Destination caller: The address on the target chain to call receiveMessage
  • Max fee: The maximum fee allowed for the transfer
  • Finality threshold: Determines whether it's a Fast Transfer or a Standard Transfer
JavaScript
async function burnUSDC() {
  console.log("Burning USDC on Ethereum Sepolia...");
  const burnTx = await sepoliaClient.sendTransaction({
    to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "depositForBurn",
          stateMutability: "nonpayable",
          inputs: [
            { name: "amount", type: "uint256" },
            { name: "destinationDomain", type: "uint32" },
            { name: "mintRecipient", type: "bytes32" },
            { name: "burnToken", type: "address" },
            { name: "destinationCaller", type: "bytes32" },
            { name: "maxFee", type: "uint256" },
            { name: "minFinalityThreshold", type: "uint32" },
          ],
          outputs: [],
        },
      ],
      functionName: "depositForBurn",
      args: [
        AMOUNT,
        AVALANCHE_FUJI_DOMAIN,
        DESTINATION_ADDRESS_BYTES32,
        ETHEREUM_SEPOLIA_USDC,
        DESTINATION_CALLER_BYTES32,
        maxFee,
        1000, // minFinalityThreshold (1000 or less for Fast Transfer)
      ],
    }),
  });
  console.log(`Burn Tx: ${burnTx}`);
  return burnTx;
}

In this step, you retrieve the attestation required to complete the CCTP transfer.

  • Call Circle's GET /v2/messages API endpoint to retrieve the attestation.
    • Pass the srcDomain argument from the CCTP Domain for your source chain.
    • Pass transactionHash from the value returned by sendTransaction within the burnUSDC function above.

This step is essential for verifying the burn event before proceeding with the transfer.

JavaScript
async function retrieveAttestation(transactionHash) {
  console.log("Retrieving attestation...");
  const url = `https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`;
  while (true) {
    try {
      const response = await axios.get(url);
      if (response.status === 404) {
        console.log("Waiting for attestation...");
      }
      if (response.data?.messages?.[0]?.status === "complete") {
        console.log("Attestation retrieved successfully!");
        return response.data.messages[0];
      }
      console.log("Waiting for attestation...");
      await new Promise((resolve) => setTimeout(resolve, 5000));
    } catch (error) {
      console.error("Error fetching attestation:", error.message);
      await new Promise((resolve) => setTimeout(resolve, 5000));
    }
  }
}

In this final step, you call the receiveMessage function from the MessageTransmitterV2 contract deployed on the Avalanche Fuji testnet to mint USDC on that destination chain.

  • Pass the signed attestation and the message data as parameters.
  • The function processes the attestation and mints USDC to the specified Avalanche Fuji wallet address.

This step finalizes the CCTP transfer, making the USDC available on the destination chain.

JavaScript
async function mintUSDC(attestation) {
  console.log("Minting USDC on Avalanche Fuji...");
  const mintTx = await avalancheClient.sendTransaction({
    to: AVALANCHE_FUJI_MESSAGE_TRANSMITTER,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "receiveMessage",
          stateMutability: "nonpayable",
          inputs: [
            { name: "message", type: "bytes" },
            { name: "attestation", type: "bytes" },
          ],
          outputs: [],
        },
      ],
      functionName: "receiveMessage",
      args: [attestation.message, attestation.attestation],
    }),
  });
  console.log(`Mint Tx: ${mintTx}`);
}

Now that you understand the core steps for programmatically transferring USDC from Ethereum Sepolia to Avalanche Fuji using CCTP V2, create a transfer.js in your project directory and populate it with the sample code below.

JavaScript
// Import environment variables
import "dotenv/config";
import { createWalletClient, http, encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia, avalancheFuji } from "viem/chains";
import axios from "axios";

// ============ Configuration Constants ============

// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(`0x${PRIVATE_KEY}`);

// Contract Addresses
const ETHEREUM_SEPOLIA_USDC = "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238";
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
  "0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa";
const AVALANCHE_FUJI_MESSAGE_TRANSMITTER =
  "0xe737e5cebeeba77efe34d4aa090756590b1ce275";

// Transfer Parameters
const DESTINATION_ADDRESS = "your-wallet-address"; // Address to receive minted tokens on destination chain
const AMOUNT = 1_000_000n; // Set transfer amount in 10^6 subunits (1 USDC; change as needed)
const maxFee = 500n; // Set fast transfer max fee in 10^6 subunits (0.0005 USDC; change as needed)

// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(2)}`; // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
  "0x0000000000000000000000000000000000000000000000000000000000000000"; // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()

// Chain-specific Parameters
const ETHEREUM_SEPOLIA_DOMAIN = 0; // Source domain ID for Ethereum Sepolia testnet
const AVALANCHE_FUJI_DOMAIN = 1; // Destination domain ID for Avalanche Fuji testnet

// Set up wallet clients
const sepoliaClient = createWalletClient({
  chain: sepolia,
  transport: http(),
  account,
});
const avalancheClient = createWalletClient({
  chain: avalancheFuji,
  transport: http(),
  account,
});

async function approveUSDC() {
  console.log("Approving USDC transfer...");
  const approveTx = await sepoliaClient.sendTransaction({
    to: ETHEREUM_SEPOLIA_USDC,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "approve",
          stateMutability: "nonpayable",
          inputs: [
            { name: "spender", type: "address" },
            { name: "amount", type: "uint256" },
          ],
          outputs: [{ name: "", type: "bool" }],
        },
      ],
      functionName: "approve",
      args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, 10_000_000_000n], // Set max allowance in 10^6 subunits (10,000 USDC; change as needed)
    }),
  });
  console.log(`USDC Approval Tx: ${approveTx}`);
}

async function burnUSDC() {
  console.log("Burning USDC on Ethereum Sepolia...");
  const burnTx = await sepoliaClient.sendTransaction({
    to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "depositForBurn",
          stateMutability: "nonpayable",
          inputs: [
            { name: "amount", type: "uint256" },
            { name: "destinationDomain", type: "uint32" },
            { name: "mintRecipient", type: "bytes32" },
            { name: "burnToken", type: "address" },
            { name: "destinationCaller", type: "bytes32" },
            { name: "maxFee", type: "uint256" },
            { name: "minFinalityThreshold", type: "uint32" },
          ],
          outputs: [],
        },
      ],
      functionName: "depositForBurn",
      args: [
        AMOUNT,
        AVALANCHE_FUJI_DOMAIN,
        DESTINATION_ADDRESS_BYTES32,
        ETHEREUM_SEPOLIA_USDC,
        DESTINATION_CALLER_BYTES32,
        maxFee,
        1000, // minFinalityThreshold (1000 or less for Fast Transfer)
      ],
    }),
  });
  console.log(`Burn Tx: ${burnTx}`);
  return burnTx;
}

async function retrieveAttestation(transactionHash) {
  console.log("Retrieving attestation...");
  const url = `https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`;
  while (true) {
    try {
      const response = await axios.get(url);
      if (response.status === 404) {
        console.log("Waiting for attestation...");
      }
      if (response.data?.messages?.[0]?.status === "complete") {
        console.log("Attestation retrieved successfully!");
        return response.data.messages[0];
      }
      console.log("Waiting for attestation...");
      await new Promise((resolve) => setTimeout(resolve, 5000));
    } catch (error) {
      console.error("Error fetching attestation:", error.message);
      await new Promise((resolve) => setTimeout(resolve, 5000));
    }
  }
}

async function mintUSDC(attestation) {
  console.log("Minting USDC on Avalanche Fuji...");
  const mintTx = await avalancheClient.sendTransaction({
    to: AVALANCHE_FUJI_MESSAGE_TRANSMITTER,
    data: encodeFunctionData({
      abi: [
        {
          type: "function",
          name: "receiveMessage",
          stateMutability: "nonpayable",
          inputs: [
            { name: "message", type: "bytes" },
            { name: "attestation", type: "bytes" },
          ],
          outputs: [],
        },
      ],
      functionName: "receiveMessage",
      args: [attestation.message, attestation.attestation],
    }),
  });
  console.log(`Mint Tx: ${mintTx}`);
}

async function main() {
  await approveUSDC();
  const burnTx = await burnUSDC();
  const attestation = await retrieveAttestation(burnTx);
  await mintUSDC(attestation);
  console.log("USDC transfer completed!");
}

main().catch(console.error);

The transfer.js script provides a complete end-to-end solution for transfering USDC in CCTP V2 with a non-custodial wallet. In the next section, you can test the script.

To test the script, run the following command:

Shell
node transfer.js

Once the script runs and the transfer is finalized, a confirmation receipt is logged in the console.

You have successfully transferred USDC between two EVM-compatible chains using CCTP end-to-end!


WHAT'S NEXT

Did this page help you?
© 2023-2025 Circle Technology Services, LLC. All rights reserved.