Overview
A cross-chain USDC transfer via CCTP involves three main phases:
- Source Chain: User initiates burn and message emission
- Attestation: Off-chain service validates and signs the message
- Destination Chain: User or relayer completes the transfer with minting
The examples below show a transfer from Ethereum (Domain 0) to Avalanche (Domain 1), but the flow is identical for any supported chain pair.
Phase 1: Source Chain - depositForBurn
Step 1: User Approves USDC
Before calling depositForBurn(), users must approve the TokenMessenger contract to spend their USDC:
const usdcContract = new web3.eth.Contract(usdcAbi, USDC_ETH_ADDRESS);
const amount = web3.utils.toWei('100', 'mwei'); // 100 USDC (6 decimals)
await usdcContract.methods
.approve(TOKEN_MESSENGER_ETH_ADDRESS, amount)
.send({ from: userAddress });
Step 2: Call depositForBurn
User initiates the cross-chain transfer:
const tokenMessengerContract = new web3.eth.Contract(
tokenMessengerAbi,
TOKEN_MESSENGER_ETH_ADDRESS
);
const AVAX_DESTINATION_DOMAIN = 1; // Avalanche domain ID
const mintRecipient = '0x...';
const mintRecipientBytes32 = web3.utils.padLeft(mintRecipient, 64);
const txReceipt = await tokenMessengerContract.methods
.depositForBurn(
amount,
AVAX_DESTINATION_DOMAIN,
mintRecipientBytes32,
USDC_ETH_ADDRESS
)
.send({ from: userAddress });
Step 3: Contract Execution Flow
Inside TokenMessenger.depositForBurn(), the following occurs:
function _depositForBurn(
uint256 _amount,
uint32 _destinationDomain,
bytes32 _mintRecipient,
address _burnToken,
bytes32 _destinationCaller
) internal returns (uint64 nonce) {
// 1. Validation
require(_amount > 0, "Amount must be nonzero");
require(_mintRecipient != bytes32(0), "Mint recipient must be nonzero");
// 2. Get destination TokenMessenger
bytes32 _destinationTokenMessenger = _getRemoteTokenMessenger(_destinationDomain);
// 3. Transfer USDC from user to TokenMinter
ITokenMinter _localMinter = _getLocalMinter();
IMintBurnToken(_burnToken).transferFrom(
msg.sender,
address(_localMinter),
_amount
);
// 4. Burn the tokens
_localMinter.burn(_burnToken, _amount);
// 5. Format burn message
bytes memory _burnMessage = BurnMessage._formatMessage(
messageBodyVersion,
Message.addressToBytes32(_burnToken),
_mintRecipient,
_amount,
Message.addressToBytes32(msg.sender)
);
// 6. Send message via MessageTransmitter
uint64 _nonce = localMessageTransmitter.sendMessage(
_destinationDomain,
_destinationTokenMessenger,
_burnMessage
);
// 7. Emit event
emit DepositForBurn(
_nonce,
_burnToken,
_amount,
msg.sender,
_mintRecipient,
_destinationDomain,
_destinationTokenMessenger,
_destinationCaller
);
return _nonce;
}
Step 4: MessageTransmitter Sends Message
MessageTransmitter.sendMessage() formats and emits the cross-chain message:
function _sendMessage(
uint32 _destinationDomain,
bytes32 _recipient,
bytes32 _destinationCaller,
bytes32 _sender,
uint64 _nonce,
bytes calldata _messageBody
) internal {
// Validate message size
require(
_messageBody.length <= maxMessageBodySize,
"Message body exceeds max size"
);
require(_recipient != bytes32(0), "Recipient must be nonzero");
// Format complete message
bytes memory _message = Message._formatMessage(
version, // Message format version
localDomain, // Source domain (e.g., 0 for Ethereum)
_destinationDomain, // Destination domain (e.g., 1 for Avalanche)
_nonce, // Unique nonce
_sender, // TokenMessenger address on source chain
_recipient, // TokenMessenger address on destination chain
_destinationCaller, // Optional: specific caller on destination
_messageBody // Burn message with amount/recipient details
);
// Emit event for attestation service to observe
emit MessageSent(_message);
}
The emitted MessageSent event contains the complete message bytes needed for attestation:
const transactionReceipt = await web3.eth.getTransactionReceipt(
txReceipt.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];
// Hash the message for attestation lookup
const messageHash = web3.utils.keccak256(messageBytes);
console.log('Message Hash:', messageHash);
Message Hash: This hash uniquely identifies the transfer and is used to retrieve the attestation.
Phase 2: Attestation Service
Step 6: Attestation Service Observes Event
Circle’s attestation service continuously monitors all supported chains for MessageSent events:
- Event Detection: Service detects new
MessageSent event
- Message Validation:
- Verifies transaction is confirmed
- Checks burn amount is within limits
- Validates destination domain is supported
- Confirms source contract is legitimate
- Signature Generation:
- Hashes the complete message bytes
- Signs the hash with authorized attester private keys
- Combines signatures into attestation format
Step 7: Poll for Attestation
Users fetch the attestation via Circle’s API:
let attestationResponse = { status: 'pending' };
while (attestationResponse.status !== 'complete') {
const response = await fetch(
`https://iris-api.circle.com/attestations/${messageHash}`
);
attestationResponse = await response.json();
if (attestationResponse.status === 'pending') {
console.log('Waiting for attestation...');
await new Promise(r => setTimeout(r, 2000)); // Wait 2 seconds
}
}
const attestation = attestationResponse.attestation;
console.log('Attestation received:', attestation);
Rate Limiting: The attestation service is rate-limited. Limit requests to less than 1 per second to avoid being blocked.
Attestation Format:
- Concatenated 65-byte ECDSA signatures (v + r + s)
- Number of signatures equals
signatureThreshold
- Signatures must be in increasing order of attester address
Response Format:
{
"status": "complete",
"attestation": "0x1234567890abcdef..."
}
Phase 3: Destination Chain - receiveMessage
Step 8: Call receiveMessage
With the attestation, anyone can complete the transfer on the destination chain:
// Connect to Avalanche
const avaxWeb3 = new Web3(AVAX_RPC_URL);
const messageTransmitterContract = new avaxWeb3.eth.Contract(
messageTransmitterAbi,
MESSAGE_TRANSMITTER_AVAX_ADDRESS
);
// Submit message with attestation
const receiveTx = await messageTransmitterContract.methods
.receiveMessage(messageBytes, attestation)
.send({
from: relayerAddress, // Can be any address
gas: 500000
});
console.log('Transfer completed!', receiveTx.transactionHash);
Step 9: MessageTransmitter Validates Message
MessageTransmitter.receiveMessage() performs extensive validation:
function receiveMessage(
bytes calldata message,
bytes calldata attestation
) external override whenNotPaused returns (bool) {
// 1. Verify attestation signatures (m-of-n multisig)
_verifyAttestationSignatures(message, attestation);
bytes29 _msg = message.ref(0);
// 2. Validate message format
_msg._validateMessageFormat();
// 3. Validate destination domain matches this chain
require(
_msg._destinationDomain() == localDomain,
"Invalid destination domain"
);
// 4. Validate destination caller (if specified)
if (_msg._destinationCaller() != bytes32(0)) {
require(
_msg._destinationCaller() == Message.addressToBytes32(msg.sender),
"Invalid caller for message"
);
}
// 5. Validate message version
require(_msg._version() == version, "Invalid message version");
// 6. Check nonce hasn't been used (replay protection)
uint32 _sourceDomain = _msg._sourceDomain();
uint64 _nonce = _msg._nonce();
bytes32 _sourceAndNonce = _hashSourceAndNonce(_sourceDomain, _nonce);
require(usedNonces[_sourceAndNonce] == 0, "Nonce already used");
// 7. Mark nonce as used
usedNonces[_sourceAndNonce] = 1;
// 8. Forward to message handler (TokenMessenger)
bytes32 _sender = _msg._sender();
bytes memory _messageBody = _msg._messageBody().clone();
require(
IMessageHandler(Message.bytes32ToAddress(_msg._recipient()))
.handleReceiveMessage(_sourceDomain, _sender, _messageBody),
"handleReceiveMessage() failed"
);
// 9. Emit success event
emit MessageReceived(
msg.sender,
_sourceDomain,
_nonce,
_sender,
_messageBody
);
return true;
}
Step 10: TokenMessenger Handles Message
TokenMessenger.handleReceiveMessage() processes the burn message:
function handleReceiveMessage(
uint32 remoteDomain,
bytes32 sender,
bytes calldata messageBody
)
external
override
onlyLocalMessageTransmitter // Must be called by MessageTransmitter
onlyRemoteTokenMessenger(remoteDomain, sender) // Sender must be registered
returns (bool)
{
// 1. Parse burn message
bytes29 _msg = messageBody.ref(0);
_msg._validateBurnMessageFormat();
require(
_msg._getVersion() == messageBodyVersion,
"Invalid message body version"
);
// 2. Extract burn details
bytes32 _mintRecipient = _msg._getMintRecipient();
bytes32 _burnToken = _msg._getBurnToken();
uint256 _amount = _msg._getAmount();
// 3. Get local minter
ITokenMinter _localMinter = _getLocalMinter();
// 4. Mint tokens to recipient
_mintAndWithdraw(
address(_localMinter),
remoteDomain,
_burnToken,
Message.bytes32ToAddress(_mintRecipient),
_amount
);
return true;
}
Step 11: TokenMinter Mints USDC
Finally, TokenMinter.mint() creates new USDC on the destination chain:
function mint(
uint32 sourceDomain,
bytes32 burnToken,
address to,
uint256 amount
)
external
override
whenNotPaused
onlyLocalTokenMessenger // Only TokenMessenger can call
returns (address mintToken)
{
// 1. Lookup local token from remote token mapping
address _mintToken = _getLocalToken(sourceDomain, burnToken);
require(_mintToken != address(0), "Mint token not supported");
// 2. Mint USDC to recipient
IMintBurnToken _token = IMintBurnToken(_mintToken);
require(_token.mint(to, amount), "Mint operation failed");
return _mintToken;
}
Result: The recipient now has USDC on Avalanche, completing the cross-chain transfer!
Complete Example
Here’s a full working example combining all steps:
const Web3 = require('web3');
// Configure chains
const ethWeb3 = new Web3(process.env.ETH_RPC);
const avaxWeb3 = new Web3(process.env.AVAX_RPC);
async function transferUSDC() {
// Step 1-2: Approve and deposit
const amount = ethWeb3.utils.toWei('100', 'mwei');
const usdcEth = new ethWeb3.eth.Contract(usdcAbi, USDC_ETH_ADDRESS);
await usdcEth.methods
.approve(TOKEN_MESSENGER_ETH, amount)
.send({ from: userAddress });
const tmEth = new ethWeb3.eth.Contract(tmAbi, TOKEN_MESSENGER_ETH);
const burnTx = await tmEth.methods
.depositForBurn(amount, 1, recipientBytes32, USDC_ETH_ADDRESS)
.send({ from: userAddress });
// Step 3-5: Extract message
const receipt = await ethWeb3.eth.getTransactionReceipt(burnTx.transactionHash);
const eventTopic = ethWeb3.utils.keccak256('MessageSent(bytes)');
const log = receipt.logs.find(l => l.topics[0] === eventTopic);
const messageBytes = ethWeb3.eth.abi.decodeParameters(['bytes'], log.data)[0];
const messageHash = ethWeb3.utils.keccak256(messageBytes);
console.log('Message hash:', messageHash);
// Step 6-7: Get attestation
let attestationResponse = { status: 'pending' };
while (attestationResponse.status !== 'complete') {
const response = await fetch(
`https://iris-api.circle.com/attestations/${messageHash}`
);
attestationResponse = await response.json();
if (attestationResponse.status === 'pending') {
await new Promise(r => setTimeout(r, 2000));
}
}
const attestation = attestationResponse.attestation;
console.log('Attestation received');
// Step 8-11: Complete on destination
const mtAvax = new avaxWeb3.eth.Contract(mtAbi, MESSAGE_TRANSMITTER_AVAX);
const receiveTx = await mtAvax.methods
.receiveMessage(messageBytes, attestation)
.send({ from: relayerAddress, gas: 500000 });
console.log('Transfer complete!', receiveTx.transactionHash);
}
transferUSDC();
Advanced: depositForBurnWithCaller
For permissioned receiving, use depositForBurnWithCaller():
function depositForBurnWithCaller(
uint256 amount,
uint32 destinationDomain,
bytes32 mintRecipient,
address burnToken,
bytes32 destinationCaller // Only this address can call receiveMessage
) external returns (uint64 nonce)
Use Cases:
- Relayer-exclusive completion (prevent front-running)
- Smart contract-only receivers
- Conditional minting logic
If destinationCaller is invalid or unable to call receiveMessage(), the funds will be permanently stuck. Only use this for advanced scenarios.
Timing and Finality
Attestation Delay:
- Typical: 10-20 minutes after source chain confirmation
- Depends on: Chain finality requirements, attestation service processing
Destination Confirmation:
- Immediate once
receiveMessage() transaction confirms
- Subject to destination chain’s block time
Total Time: Usually 15-30 minutes for complete cross-chain transfer
Error Handling
Common failure scenarios:
| Error | Cause | Solution |
|---|
| ”Transfer operation failed” | Insufficient approval or balance | Increase approval amount |
| ”No TokenMessenger for domain” | Destination domain not supported | Check supported domains |
| ”Nonce already used” | Message already processed | Transfer already completed |
| ”Invalid attestation length” | Wrong number of signatures | Fetch attestation again |
| ”Invalid signature order or dupe” | Malformed attestation | Use official attestation API |
| ”Mint token not supported” | Token not configured on destination | Contact Circle support |
Next Steps
Attestation
Understand signature verification in detail
Quickstart
Try the example integration yourself