Skip to main content
This guide demonstrates how to transfer USDC from Ethereum Sepolia to Arc testnet using CCTP. You use the viem framework to interact with CCTP contracts and the CCTP API to retrieve attestations.
Tip: Use Bridge Kit to simplify crosschain transfers with CCTP.This guide demonstrates how to transfer USDC from to using a manual integration with CCTP. This example is provided for understanding and for developers who may need to implement a manual integration.For a more streamlined experience, use Bridge Kit to transfer USDC between blockchains in just a few lines of code.

Prerequisites

Before you start building the sample app to perform a USDC transfer, ensure you have:
  • Node.js and npm installed on your development machine. You can download and install Node.js directly, or use a version manager like nvm. The npm binary comes with Node.js.
  • Created an Ethereum wallet and have the private key available.
  • Funded your wallet with the following testnet tokens:

Part 1: Project setup

Set up your project environment and install the required dependencies.

1.1. Create a new project

Create a new directory and initialize a new Node.js project with default settings:
Shell
mkdir cctp-evm-transfer
cd cctp-evm-transfer
npm init -y
This also creates a default package.json file.

1.2. Install dependencies

In your project directory, install the required dependencies, including viem:
Shell
npm install axios dotenv viem
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.3. Add module type

Use the npm pkg command to set the module type to module:
Shell
npm pkg set type=module

1.4. 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
Warning: This is strictly for testing purposes. Never share your private key.

Part 2: Configure the script

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

2.1. Define configuration constants

The script predefines the contract addresses, transfer amount, and maximum fee. Update the DESTINATION_ADDRESS with your wallet address.
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 ARC_TESTNET_MESSAGE_TRANSMITTER =
  "0xe737e5cebeeba77efe34d4aa090756590b1ce275";

// Transfer Parameters
const DESTINATION_ADDRESS = account.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
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc testnet

2.2. Set up wallet clients

The wallet client configures the appropriate network settings using viem. In this example, the script connects to Ethereum Sepolia and Arc testnet.
JavaScript
// Set up the wallet clients
const sepoliaClient = createWalletClient({
  chain: sepolia,
  transport: http(),
  account,
});

const arcClient = createWalletClient({
  chain: arcTestnet,
  transport: http(),
  account,
});

Part 3: Implement the transfer logic

The following sections outline the core transfer logic.

3.1. Approve USDC

Grant approval for the TokenMessengerV2 contract deployed on Ethereum Sepolia to withdraw USDC from your wallet. This allows the contract to burn USDC when you initiate the transfer.
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}`);
}

3.2. Burn USDC

Call the depositForBurn function from the TokenMessengerV2 contract deployed on Ethereum Sepolia 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 (see supported chains and domains)
  • 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 (1000 or less) or a Standard Transfer (2000 or more)
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,
        ARC_TESTNET_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;
}

3.3. Retrieve attestation

Retrieve the attestation required to complete the CCTP transfer by calling Circle’s attestation API.
  • 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.
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));
    }
  }
}

3.4. Mint USDC

Call the receiveMessage function from the MessageTransmitterV2 contract deployed on the Arc 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 Arc testnet wallet address.
JavaScript
async function mintUSDC(attestation) {
  console.log("Minting USDC on Arc testnet...");
  const mintTx = await arcClient.sendTransaction({
    to: ARC_TESTNET_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}`);
}

Part 4: Complete script

Create a transfer.js file in your project directory and populate it with the complete code below.
transfer.js
// Import environment variables
import "dotenv/config";
import { createWalletClient, http, encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet, sepolia } 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 ARC_TESTNET_MESSAGE_TRANSMITTER =
  "0xe737e5cebeeba77efe34d4aa090756590b1ce275";

// Transfer Parameters
const DESTINATION_ADDRESS = account.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
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc testnet

// Set up wallet clients
const sepoliaClient = createWalletClient({
  chain: sepolia,
  transport: http(),
  account,
});
const arcClient = createWalletClient({
  chain: arcTestnet,
  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,
        ARC_TESTNET_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 Arc testnet...");
  const mintTx = await arcClient.sendTransaction({
    to: ARC_TESTNET_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);

Part 5: Test the script

Run the following command to execute the script:
Shell
node transfer.js
Once the script runs and the transfer is finalized, a confirmation receipt is logged in the console.
Rate limit:The attestation service rate limit is 35 requests per second. If you exceed this limit, the service blocks all API requests for the next 5 minutes and returns an HTTP 429 (Too Many Requests) response.