Skip to main content

Overview

This guide demonstrates how to transfer USDC from Ethereum Sepolia testnet to Avalanche Fuji testnet using Circle’s Cross-Chain Transfer Protocol (CCTP).

Prerequisites

  • Node.js installed
  • Web3.js library
  • Private keys for source and destination addresses
  • USDC on the source chain (Sepolia testnet)

Testnet Contract Addresses

Ethereum Sepolia

const ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS = '0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5';
const USDC_ETH_CONTRACT_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
const ETH_MESSAGE_CONTRACT_ADDRESS = '0x80537e4e8bab73d21096baa3a8c813b45ca0b7c9';

Avalanche Fuji

const AVAX_MESSAGE_TRANSMITTER_CONTRACT_ADDRESS = '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79';
const AVAX_DESTINATION_DOMAIN = 1;

Setup

1

Install Dependencies

npm install web3 dotenv
2

Configure Environment

Create a .env file with the following variables:
ETH_TESTNET_RPC=<ETH_TESTNET_RPC_URL>
AVAX_TESTNET_RPC=<AVAX_TESTNET_RPC_URL>
ETH_PRIVATE_KEY=<ORIGINATING_ADDRESS_PRIVATE_KEY>
AVAX_PRIVATE_KEY=<RECIPIENT_ADDRESS_PRIVATE_KEY>
RECIPIENT_ADDRESS=<RECIPIENT_ADDRESS_FOR_AVAX>
AMOUNT=<AMOUNT_TO_TRANSFER>

Transfer Process

The cross-chain transfer involves 5 main steps:

Step 1: Approve Token Messenger

Approve the TokenMessenger contract to withdraw USDC from your Ethereum address:
const { Web3 } = require('web3');
const web3 = new Web3(process.env.ETH_TESTNET_RPC);

// Initialize signer
const ethSigner = web3.eth.accounts.privateKeyToAccount(process.env.ETH_PRIVATE_KEY);
web3.eth.accounts.wallet.add(ethSigner);

// Initialize USDC contract
const usdcEthContract = new web3.eth.Contract(
  usdcAbi, 
  USDC_ETH_CONTRACT_ADDRESS, 
  { from: ethSigner.address }
);

// Approve
const approveTxGas = await usdcEthContract.methods
  .approve(ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS, amount)
  .estimateGas();

const approveTx = await usdcEthContract.methods
  .approve(ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS, amount)
  .send({ gas: approveTxGas });

Step 2: Burn USDC on Source Chain

Call depositForBurn on the TokenMessenger contract:
// Initialize TokenMessenger contract
const ethTokenMessengerContract = new web3.eth.Contract(
  tokenMessengerAbi,
  ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS,
  { from: ethSigner.address }
);

// Convert recipient address to bytes32
const ethMessageContract = new web3.eth.Contract(
  messageAbi,
  ETH_MESSAGE_CONTRACT_ADDRESS,
  { from: ethSigner.address }
);

const destinationAddressInBytes32 = await ethMessageContract.methods
  .addressToBytes32(mintRecipient)
  .call();

// Burn USDC
const burnTxGas = await ethTokenMessengerContract.methods
  .depositForBurn(
    amount,
    AVAX_DESTINATION_DOMAIN,
    destinationAddressInBytes32,
    USDC_ETH_CONTRACT_ADDRESS
  )
  .estimateGas();

const burnTx = await ethTokenMessengerContract.methods
  .depositForBurn(
    amount,
    AVAX_DESTINATION_DOMAIN,
    destinationAddressInBytes32,
    USDC_ETH_CONTRACT_ADDRESS
  )
  .send({ gas: burnTxGas });

Step 3: Retrieve Message Bytes

Extract the messageBytes from the MessageSent event:
const transactionReceipt = await web3.eth.getTransactionReceipt(
  burnTx.transactionHash
);

// Find MessageSent event
const eventTopic = web3.utils.keccak256('MessageSent(bytes)');
const log = transactionReceipt.logs.find((l) => l.topics[0] === eventTopic);

// Decode message bytes
const messageBytes = web3.eth.abi.decodeParameters(['bytes'], log.data)[0];
const messageHash = web3.utils.keccak256(messageBytes);

console.log(`MessageHash: ${messageHash}`);

Step 4: Poll for Attestation

Request attestation from Circle’s attestation service:
let attestationResponse = { status: 'pending' };

while (attestationResponse.status !== 'complete') {
  const response = await fetch(
    `https://iris-api-sandbox.circle.com/attestations/${messageHash}`
  );
  attestationResponse = await response.json();
  
  await new Promise(r => setTimeout(r, 2000));
}

const attestationSignature = attestationResponse.attestation;
console.log(`Attestation: ${attestationSignature}`);
The attestation service is rate-limited. Please limit your requests to less than 1 per second.

Step 5: Receive Message on Destination Chain

Call receiveMessage on the destination chain’s MessageTransmitter:
// Switch to Avalanche network
web3.setProvider(process.env.AVAX_TESTNET_RPC);

// Initialize AVAX signer
const avaxSigner = web3.eth.accounts.privateKeyToAccount(
  process.env.AVAX_PRIVATE_KEY
);
web3.eth.accounts.wallet.add(avaxSigner);

// Initialize MessageTransmitter contract
const avaxMessageTransmitterContract = new web3.eth.Contract(
  messageTransmitterAbi,
  AVAX_MESSAGE_TRANSMITTER_CONTRACT_ADDRESS,
  { from: avaxSigner.address }
);

// Receive message
const receiveTxGas = await avaxMessageTransmitterContract.methods
  .receiveMessage(messageBytes, attestationSignature)
  .estimateGas();

const receiveTx = await avaxMessageTransmitterContract.methods
  .receiveMessage(messageBytes, attestationSignature)
  .send({ gas: receiveTxGas });

const receiveTxReceipt = await waitForTransaction(web3, receiveTx.transactionHash);
console.log('Transfer complete!');

Complete Example

Here’s the complete transfer script:
require('dotenv').config();
const { Web3 } = require('web3');

const main = async () => {
  const web3 = new Web3(process.env.ETH_TESTNET_RPC);
  
  // Setup signers
  const ethSigner = web3.eth.accounts.privateKeyToAccount(
    process.env.ETH_PRIVATE_KEY
  );
  web3.eth.accounts.wallet.add(ethSigner);
  
  const avaxSigner = web3.eth.accounts.privateKeyToAccount(
    process.env.AVAX_PRIVATE_KEY
  );
  web3.eth.accounts.wallet.add(avaxSigner);
  
  // Contract addresses and configuration
  const ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS = 
    '0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5';
  const USDC_ETH_CONTRACT_ADDRESS = 
    '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
  const AVAX_MESSAGE_TRANSMITTER_CONTRACT_ADDRESS = 
    '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79';
  const AVAX_DESTINATION_DOMAIN = 1;
  
  // Initialize contracts
  const ethTokenMessengerContract = new web3.eth.Contract(
    tokenMessengerAbi,
    ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS,
    { from: ethSigner.address }
  );
  
  const usdcEthContract = new web3.eth.Contract(
    usdcAbi,
    USDC_ETH_CONTRACT_ADDRESS,
    { from: ethSigner.address }
  );
  
  // Transfer amount
  const amount = process.env.AMOUNT;
  
  // Step 1: Approve
  console.log('Step 1: Approving USDC...');
  const approveTx = await usdcEthContract.methods
    .approve(ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS, amount)
    .send();
  
  // Step 2: Burn
  console.log('Step 2: Burning USDC...');
  const burnTx = await ethTokenMessengerContract.methods
    .depositForBurn(
      amount,
      AVAX_DESTINATION_DOMAIN,
      destinationAddressInBytes32,
      USDC_ETH_CONTRACT_ADDRESS
    )
    .send();
  
  // Step 3: Get message hash
  console.log('Step 3: Retrieving message hash...');
  const messageHash = getMessageHashFromTx(burnTx);
  
  // Step 4: Fetch attestation
  console.log('Step 4: Polling for attestation...');
  const attestation = await pollForAttestation(messageHash);
  
  // Step 5: Receive on destination
  console.log('Step 5: Receiving USDC on destination chain...');
  web3.setProvider(process.env.AVAX_TESTNET_RPC);
  const receiveTx = await avaxMessageTransmitterContract.methods
    .receiveMessage(messageBytes, attestation)
    .send();
  
  console.log('Transfer complete!');
};

main();

Running the Script

node index.js

Domain IDs

ChainDomain ID
Ethereum0
Avalanche1
Arbitrum3
Optimism2
Base6
Polygon7

Troubleshooting

Approval Fails

  • Check that you have sufficient USDC balance
  • Verify the token messenger contract address
  • Ensure your wallet has enough ETH for gas

Burn Transaction Fails

  • Verify approval was successful
  • Check that amount is within burn limits
  • Ensure destination domain is valid

Attestation Not Available

  • Wait longer (attestations typically take 10-20 seconds)
  • Verify the message hash is correct
  • Check that the burn transaction was successful

Receive Message Fails

  • Ensure attestation is complete
  • Verify message hasn’t already been received (check nonce)
  • Check that destination contract address is correct

Next Steps

Build docs developers (and LLMs) love