Documentation Index
Fetch the complete documentation index at: https://mintlify.com/circlefin/evm-cctp-contracts/llms.txt
Use this file to discover all available pages before exploring further.
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