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
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
Parameter V1 V2 Notes amount✅ ✅ Same destinationDomain✅ ✅ Same mintRecipient✅ ✅ Same burnToken✅ ✅ Same destinationCaller❌ ✅ New - use bytes32(0) for any caller maxFee❌ ✅ New - must be < amount and >= minFee minFinalityThreshold❌ ✅ New - minimum 500, use 1000 for most cases Return value uint64 noncenone V2 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:
Alternative 1 : Send a new message with updated parameters
Alternative 2 : Implement application-level message invalidation logic
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 Event Listener
V2 Event Listener
// 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 >
# V2 .env configuration (all V1 roles plus:)
# Existing V1 roles
MESSAGE_TRANSMITTER_V2_PAUSER_ADDRESS =< address >
TOKEN_MINTER_V2_PAUSER_ADDRESS =< address >
MESSAGE_TRANSMITTER_V2_RESCUER_ADDRESS =< address >
TOKEN_MESSENGER_V2_RESCUER_ADDRESS =< address >
TOKEN_MINTER_V2_RESCUER_ADDRESS =< address >
TOKEN_CONTROLLER_ADDRESS =< address >
# NEW V2 roles
TOKEN_MESSENGER_V2_FEE_RECIPIENT_ADDRESS =< address >
TOKEN_MESSENGER_V2_DENYLISTER_ADDRESS =< address >
TOKEN_MESSENGER_V2_MIN_FEE_CONTROLLER_ADDRESS =< address >
Role Responsibilities
Role Description When to Update feeRecipient Receives collected fees Set during initialization denylister Manages protocol denylist Set during initialization minFeeController Sets minimum fee requirements Set 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:
V1 Integration
V2 Integration
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
Test Fee Calculation
// Test minimum fee calculation
uint256 amount = 1000000 ; // 1 USDC
uint256 minFee = tokenMessenger. getMinFeeAmount (amount);
assert (minFee > 0 && minFee < amount);
Test Basic Transfer
// Test basic V2 transfer
usdc. approve ( address (tokenMessenger), amount);
tokenMessenger. depositForBurn (
amount,
destinationDomain,
recipientBytes32,
address (usdc),
bytes32 ( 0 ),
minFee,
1000
);
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
);
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" );
}
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