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 begin, ensure that you’ve:
  • Installed Node.js v22+
  • Prepared an EVM testnet wallet with the private key available
  • Funded your wallet with the following testnet tokens:

Step 1. Set up the project

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

1.1. Set up your development environment

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

# Set up module type and start command
npm pkg set type=module
npm pkg set scripts.start="npx tsx --env-file=.env index.ts"

# Install runtime dependencies
npm install viem tsx

# Install dev dependencies
npm install --save-dev typescript @types/node

1.2. Initialize and configure the project

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

1.3. Configure environment variables

Create a .env file in the project directory and add your wallet private key, replacing {YOUR_PRIVATE_KEY} with the private key from your EVM wallet.
Shell
echo "PRIVATE_KEY={YOUR_PRIVATE_KEY}" > .env
Warning: This is strictly for testing purposes. Never share your private key.

Step 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.
TypeScript
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);

// 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; // 1 USDC (1 USDC = 1,000,000 subunits)
const maxFee = 500n; // 0.0005 USDC (500 subunits)

// 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.
TypeScript
// Set up the wallet clients
const sepoliaClient = createWalletClient({
  chain: sepolia,
  transport: http(),
  account,
});

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

Step 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.
TypeScript
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], // 10 USDC allowance
    }),
  });
  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)
TypeScript
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.
TypeScript
async function retrieveAttestation(transactionHash: string) {
  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 fetch(url, { method: "GET" });

      if (!response.ok) {
        if (response.status !== 404) {
          const text = await response.text().catch(() => "");
          console.error(
            "Error fetching attestation:",
            `${response.status} ${response.statusText}${
              text ? ` - ${text}` : ""
            }`,
          );
        }
        await new Promise((resolve) => setTimeout(resolve, 5000));
        continue;
      }

      const data = (await response.json()) as AttestationResponse;

      if (data?.messages?.[0]?.status === "complete") {
        console.log("Attestation retrieved successfully!");
        return data.messages[0];
      }
      console.log("Waiting for attestation...");
      await new Promise((resolve) => setTimeout(resolve, 5000));
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      console.error("Error fetching attestation:", 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.
TypeScript
async function mintUSDC(attestation: AttestationMessage) {
  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 as `0x${string}`,
        attestation.attestation as `0x${string}`,
      ],
    }),
  });
  console.log(`Mint Tx: ${mintTx}`);
}

Step 4: Complete script

Create a index.ts file in your project directory and populate it with the complete code below.
index.ts
import { createWalletClient, http, encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet, sepolia } from "viem/chains";

interface AttestationMessage {
  message: string;
  attestation: string;
  status: string;
}

interface AttestationResponse {
  messages: AttestationMessage[];
}

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

// 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; // 1 USDC (1 USDC = 1,000,000 subunits)
const maxFee = 500n; // 0.0005 USDC (500 subunits)

// 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,
});

// ============ CCTP Flow Functions ============
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_000n], // 10 USDC allowance
    }),
  });
  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 as `0x${string}`,
        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: string) {
  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 fetch(url, { method: "GET" });

      if (!response.ok) {
        if (response.status !== 404) {
          const text = await response.text().catch(() => "");
          console.error(
            "Error fetching attestation:",
            `${response.status} ${response.statusText}${
              text ? ` - ${text}` : ""
            }`,
          );
        }
        await new Promise((resolve) => setTimeout(resolve, 5000));
        continue;
      }

      const data = (await response.json()) as AttestationResponse;

      if (data?.messages?.[0]?.status === "complete") {
        console.log("Attestation retrieved successfully!");
        return data.messages[0];
      }
      console.log("Waiting for attestation...");
      await new Promise((resolve) => setTimeout(resolve, 5000));
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      console.error("Error fetching attestation:", message);
      await new Promise((resolve) => setTimeout(resolve, 5000));
    }
  }
}

async function mintUSDC(attestation: AttestationMessage) {
  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 as `0x${string}`,
        attestation.attestation as `0x${string}`,
      ],
    }),
  });
  console.log(`Mint Tx: ${mintTx}`);
}

// ============ Main Execution ============
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);

Step 5: Test the script

Run the following command to execute the script:
Shell
npm run start
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.