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
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
Domain IDs
| Chain | Domain ID |
|---|
| Ethereum | 0 |
| Avalanche | 1 |
| Arbitrum | 3 |
| Optimism | 2 |
| Base | 6 |
| Polygon | 7 |
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