Skip to main content

Overview

This guide helps you migrate your CCTP integration from V1 to V2 contracts. V2 introduces breaking changes in function signatures and event structures that require code updates.
V1 and V2 are separate protocol deployments. You cannot send V1 messages to V2 contracts or vice versa. Both versions can coexist on the same chain.

Migration Checklist

  • Review new V2 features and determine which to use
  • Update depositForBurn function calls with new parameters
  • Replace depositForBurnWithCaller usage
  • Update event listeners for new event signatures
  • Add fee calculation logic
  • Configure new role addresses
  • Update message handler interface (if applicable)
  • Deploy or connect to V2 contracts
  • Test integration thoroughly

API Changes

depositForBurn Function

The depositForBurn function signature has changed significantly.

V1 to V2 Comparison

// Approve USDC
usdc.approve(address(tokenMessenger), amount);

// Deposit for burn
uint64 nonce = tokenMessenger.depositForBurn(
    amount,
    destinationDomain,
    mintRecipient,
    burnToken
);

Parameter Mapping

ParameterV1V2Notes
amountSame
destinationDomainSame
mintRecipientSame
burnTokenSame
destinationCallerNew - use bytes32(0) for any caller
maxFeeNew - must be < amount and >= minFee
minFinalityThresholdNew - minimum 500, use 1000 for most cases
Return valueuint64 noncenoneV2 doesn’t return nonce

Migration Example

// V1 code
function bridgeUSDC(uint256 amount, uint32 destDomain, bytes32 recipient) external {
    usdc.approve(address(tokenMessengerV1), amount);
    
    uint64 nonce = tokenMessengerV1.depositForBurn(
        amount,
        destDomain,
        recipient,
        address(usdc)
    );
    
    emit BridgeInitiated(nonce, amount);
}

// V2 code
function bridgeUSDC(uint256 amount, uint32 destDomain, bytes32 recipient) external {
    usdc.approve(address(tokenMessengerV2), amount);
    
    // Calculate required fee
    uint256 minFeeAmount = tokenMessengerV2.getMinFeeAmount(amount);
    uint256 maxFee = minFeeAmount + 1000; // Add buffer
    
    tokenMessengerV2.depositForBurn(
        amount,
        destDomain,
        recipient,
        address(usdc),
        bytes32(0),                           // Any caller can relay
        maxFee,                               // Maximum fee willing to pay
        1000                                  // Confirmed finality
    );
    
    // Note: Cannot use nonce anymore, use event filtering instead
    emit BridgeInitiated(amount);
}

depositForBurnWithCaller Removal

depositForBurnWithCaller is removed in V2. Use depositForBurn with the destinationCaller parameter instead.
// Specify authorized caller
tokenMessenger.depositForBurnWithCaller(
    amount,
    destinationDomain,
    mintRecipient,
    burnToken,
    destinationCaller
);

replaceDepositForBurn

replaceDepositForBurn() is not available in V2. Message replacement functionality has been removed.
If you rely on message replacement:
  1. Alternative 1: Send a new message with updated parameters
  2. Alternative 2: Implement application-level message invalidation logic
  3. Alternative 3: Use hook data to encode conditional logic

Event Changes

DepositForBurn Event

The event signature has changed significantly.

Event Structure Comparison

event DepositForBurn(
    uint64 indexed nonce,
    address indexed burnToken,
    uint256 amount,
    address indexed depositor,
    bytes32 mintRecipient,
    uint32 destinationDomain,
    bytes32 destinationTokenMessenger,
    bytes32 destinationCaller
);

Migration Impact

Key Changes:
  • nonce removed (was indexed)
  • minFinalityThreshold added (indexed)
  • maxFee added
  • hookData added

Event Listener Migration

// V1 - Listen by nonce
const filter = tokenMessenger.filters.DepositForBurn(
    nonce,              // indexed
    null,               // burnToken
    null                // depositor
);

const events = await tokenMessenger.queryFilter(filter);
console.log('Amount:', events[0].args.amount);

MintAndWithdraw Event

event MintAndWithdraw(
    address indexed mintRecipient,
    uint256 amount,
    address indexed mintToken
);
Migration: Update event listeners to include feeCollected field.

Fee Management

Calculating Fees

V2 requires fee calculation before calling depositForBurn.
// Get minimum fee amount
uint256 minFeeAmount = tokenMessenger.getMinFeeAmount(amount);

// Add buffer for fee fluctuation (optional)
uint256 maxFee = minFeeAmount.mul(110).div(100); // 10% buffer

// Ensure maxFee < amount
require(maxFee < amount, "Fee exceeds amount");

tokenMessenger.depositForBurn(
    amount,
    destinationDomain,
    mintRecipient,
    address(usdc),
    bytes32(0),
    maxFee,
    minFinalityThreshold
);

Fee Recipient

Set up fee collection:
// Owner sets fee recipient
tokenMessenger.setFeeRecipient(treasuryAddress);

// Fees are automatically minted to feeRecipient
// when messages are processed as unfinalized

Zero Fee Transfers

To avoid fees entirely, use finalized messages:
// Use finalized threshold (2000) for zero-fee transfers
tokenMessenger.depositForBurn(
    amount,
    destinationDomain,
    mintRecipient,
    address(usdc),
    bytes32(0),
    0,      // maxFee can be 0 for finalized
    2000    // FINALITY_THRESHOLD_FINALIZED
);
Finalized messages take longer to process but don’t incur fees.

Message Handler Interface

If you implement custom message handlers, update your interface.

Interface Changes

contract MyHandler is IMessageHandler {
    function handleReceiveMessage(
        uint32 sourceDomain,
        bytes32 sender,
        bytes calldata messageBody
    ) external override returns (bool) {
        // Process message
        return true;
    }
}

Role Configuration

V2 introduces new administrative roles.

Required Role Addresses

# V1 .env configuration
MESSAGE_TRANSMITTER_PAUSER_ADDRESS=<address>
TOKEN_MINTER_PAUSER_ADDRESS=<address>
MESSAGE_TRANSMITTER_RESCUER_ADDRESS=<address>
TOKEN_MESSENGER_RESCUER_ADDRESS=<address>
TOKEN_MINTER_RESCUER_ADDRESS=<address>
TOKEN_CONTROLLER_ADDRESS=<address>

Role Responsibilities

RoleDescriptionWhen to Update
feeRecipientReceives collected feesSet during initialization
denylisterManages protocol denylistSet during initialization
minFeeControllerSets minimum fee requirementsSet during initialization

Contract Interface Differences

New Functions in V2

// Fee management
function setFeeRecipient(address _feeRecipient) external;
function setMinFeeController(address _minFeeController) external;
function setMinFee(uint256 _minFee) external;
function getMinFeeAmount(uint256 amount) external view returns (uint256);

// Denylist management
function addToDenylist(address account) external;
function removeFromDenylist(address account) external;
function isDenylisted(address account) external view returns (bool);

// Hook support
function depositForBurnWithHook(
    uint256 amount,
    uint32 destinationDomain,
    bytes32 mintRecipient,
    address burnToken,
    bytes32 destinationCaller,
    uint256 maxFee,
    uint32 minFinalityThreshold,
    bytes calldata hookData
) external;

Removed Functions in V2

// ❌ Removed - Use depositForBurn with destinationCaller parameter
function depositForBurnWithCaller(...) external returns (uint64 nonce);

// ❌ Removed - Message replacement not supported
function replaceDepositForBurn(...) external;

Smart Contract Migration Example

Complete example showing V1 to V2 migration:
contract MyBridgeV1 {
    ITokenMessenger public tokenMessenger;
    IERC20 public usdc;
    
    constructor(address _tokenMessenger, address _usdc) {
        tokenMessenger = ITokenMessenger(_tokenMessenger);
        usdc = IERC20(_usdc);
    }
    
    function bridge(
        uint256 amount,
        uint32 destDomain,
        bytes32 recipient
    ) external {
        usdc.transferFrom(msg.sender, address(this), amount);
        usdc.approve(address(tokenMessenger), amount);
        
        uint64 nonce = tokenMessenger.depositForBurn(
            amount,
            destDomain,
            recipient,
            address(usdc)
        );
        
        emit Bridged(nonce, msg.sender, amount);
    }
    
    event Bridged(uint64 indexed nonce, address indexed user, uint256 amount);
}

Frontend Integration Changes

ethers.js v6 Example

import { ethers } from 'ethers';

// V1 deposit
async function depositForBurnV1(
  tokenMessenger: Contract,
  amount: bigint,
  destinationDomain: number,
  mintRecipient: string,
  burnToken: string
) {
  const tx = await tokenMessenger.depositForBurn(
    amount,
    destinationDomain,
    mintRecipient,
    burnToken
  );
  
  const receipt = await tx.wait();
  const event = receipt.logs
    .map(log => tokenMessenger.interface.parseLog(log))
    .find(e => e?.name === 'DepositForBurn');
  
  return event?.args.nonce;  // Get nonce
}

Testing Your Migration

Test Checklist

1

Test Fee Calculation

// Test minimum fee calculation
uint256 amount = 1000000; // 1 USDC
uint256 minFee = tokenMessenger.getMinFeeAmount(amount);
assert(minFee > 0 && minFee < amount);
2

Test Basic Transfer

// Test basic V2 transfer
usdc.approve(address(tokenMessenger), amount);
tokenMessenger.depositForBurn(
    amount,
    destinationDomain,
    recipientBytes32,
    address(usdc),
    bytes32(0),
    minFee,
    1000
);
3

Test Hook Transfer

// Test transfer with hook data
bytes memory hookData = abi.encode("TEST", address(this));
tokenMessenger.depositForBurnWithHook(
    amount,
    destinationDomain,
    recipientBytes32,
    address(usdc),
    bytes32(0),
    minFee,
    1000,
    hookData
);
4

Test Event Parsing

// Verify event structure
const events = await tokenMessenger.queryFilter(
  tokenMessenger.filters.DepositForBurn()
);
const event = events[0];
assert(event.args.maxFee !== undefined);
assert(event.args.minFinalityThreshold === 1000);

Common Migration Issues

Issue: “Insufficient max fee” Error

Cause: maxFee is less than the minimum required fee. Solution: Call getMinFeeAmount() before depositForBurn():
uint256 minFee = tokenMessenger.getMinFeeAmount(amount);
uint256 maxFee = minFee + buffer;
tokenMessenger.depositForBurn(..., maxFee, ...);

Issue: Missing Nonce in V2

Cause: V2 doesn’t return nonce from depositForBurn(). Solution: Use transaction hash or event filtering instead:
const tx = await tokenMessenger.depositForBurn(...);
const receipt = await tx.wait();
const identifier = receipt.hash;  // Use tx hash instead of nonce

Issue: “Caller is denylisted” Error

Cause: Calling address is on the protocol denylist. Solution: Check denylist status:
bool denylisted = tokenMessenger.isDenylisted(msg.sender);
if (denylisted) {
    revert("Address is denylisted");
}

Issue: Wrong destinationCaller Format

Cause: Using address instead of bytes32. Solution: Convert address to bytes32:
// Wrong
tokenMessenger.depositForBurn(..., destinationCallerAddress, ...);

// Correct
bytes32 destinationCaller = bytes32(uint256(uint160(destinationCallerAddress)));
tokenMessenger.depositForBurn(..., destinationCaller, ...);

// Or use any caller
tokenMessenger.depositForBurn(..., bytes32(0), ...);

Next Steps

V2 Deployment

Deploy V2 contracts to your network

TokenMessengerV2 API

Complete V2 API reference

Integration Guide

Build on CCTP V2

Testing Guide

Test your V2 integration

Build docs developers (and LLMs) love