Skip to main content

Overview

A cross-chain USDC transfer via CCTP involves three main phases:
  1. Source Chain: User initiates burn and message emission
  2. Attestation: Off-chain service validates and signs the message
  3. 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);
}

Step 5: Extract Message from Event

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:
  1. Event Detection: Service detects new MessageSent event
  2. Message Validation:
    • Verifies transaction is confirmed
    • Checks burn amount is within limits
    • Validates destination domain is supported
    • Confirms source contract is legitimate
  3. 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:
ErrorCauseSolution
”Transfer operation failed”Insufficient approval or balanceIncrease approval amount
”No TokenMessenger for domain”Destination domain not supportedCheck supported domains
”Nonce already used”Message already processedTransfer already completed
”Invalid attestation length”Wrong number of signaturesFetch attestation again
”Invalid signature order or dupe”Malformed attestationUse official attestation API
”Mint token not supported”Token not configured on destinationContact Circle support

Next Steps

Attestation

Understand signature verification in detail

Quickstart

Try the example integration yourself

Build docs developers (and LLMs) love