Overview
This guide demonstrates how to integrate CCTP into your smart contracts using hooks, message handlers, and relayers.Core Interfaces
CCTP provides several interfaces for integration:IMessageHandler
ImplementIMessageHandler to receive cross-chain messages:
interface IMessageHandler {
function handleReceiveMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) external returns (bool);
}
sourceDomain: The source chain’s domain IDsender: The message sender address as bytes32messageBody: The message payload
trueif message handling succeeded
IReceiver
TheIReceiver interface is implemented by MessageTransmitter:
interface IReceiver {
function receiveMessage(
bytes calldata message,
bytes calldata signature
) external returns (bool success);
}
IRelayer
TheIRelayer interface is implemented by MessageTransmitter for sending messages:
interface IRelayer {
function sendMessage(
uint32 destinationDomain,
bytes32 recipient,
bytes calldata messageBody
) external returns (uint64);
function sendMessageWithCaller(
uint32 destinationDomain,
bytes32 recipient,
bytes32 destinationCaller,
bytes calldata messageBody
) external returns (uint64);
}
Implementation Examples
Basic Message Handler
Implement a simple contract that receives cross-chain messages:pragma solidity 0.7.6;
import "../interfaces/IMessageHandler.sol";
import "../roles/Ownable2Step.sol";
contract MyMessageHandler is IMessageHandler, Ownable2Step {
// Address of the MessageTransmitter
address public immutable messageTransmitter;
// Mapping of authorized senders by domain
mapping(uint32 => bytes32) public authorizedSenders;
event MessageReceived(
uint32 sourceDomain,
bytes32 sender,
bytes messageBody
);
constructor(address _messageTransmitter) Ownable2Step() {
require(
_messageTransmitter != address(0),
"Invalid transmitter address"
);
messageTransmitter = _messageTransmitter;
}
function handleReceiveMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) external override returns (bool) {
// Only allow calls from MessageTransmitter
require(
msg.sender == messageTransmitter,
"Unauthorized caller"
);
// Verify sender is authorized
require(
authorizedSenders[sourceDomain] == sender,
"Unauthorized sender"
);
// Process message
_processMessage(sourceDomain, sender, messageBody);
emit MessageReceived(sourceDomain, sender, messageBody);
return true;
}
function _processMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) internal virtual {
// Implement your message processing logic
}
function setAuthorizedSender(
uint32 domain,
bytes32 sender
) external onlyOwner {
authorizedSenders[domain] = sender;
}
}
Cross-Chain Token Bridge
Build a custom token bridge on top of CCTP:pragma solidity 0.7.6;
import "../interfaces/IRelayer.sol";
import "../interfaces/IMessageHandler.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TokenBridge is IMessageHandler {
IRelayer public immutable messageTransmitter;
address public immutable messageHandler;
IERC20 public immutable token;
mapping(uint32 => bytes32) public remoteBridges;
mapping(address => mapping(uint32 => uint256)) public lockedBalances;
event TokensLocked(
address indexed sender,
uint32 destinationDomain,
uint256 amount
);
event TokensUnlocked(
address indexed recipient,
uint32 sourceDomain,
uint256 amount
);
constructor(
address _messageTransmitter,
address _token
) {
messageTransmitter = IRelayer(_messageTransmitter);
messageHandler = address(this);
token = IERC20(_token);
}
function bridgeTokens(
uint32 destinationDomain,
address recipient,
uint256 amount
) external returns (uint64) {
// Transfer tokens from sender
require(
token.transferFrom(msg.sender, address(this), amount),
"Transfer failed"
);
// Update locked balance
lockedBalances[msg.sender][destinationDomain] += amount;
// Encode message
bytes memory messageBody = abi.encode(
recipient,
amount
);
// Send cross-chain message
uint64 nonce = messageTransmitter.sendMessage(
destinationDomain,
remoteBridges[destinationDomain],
messageBody
);
emit TokensLocked(msg.sender, destinationDomain, amount);
return nonce;
}
function handleReceiveMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) external override returns (bool) {
require(
msg.sender == address(messageTransmitter),
"Unauthorized"
);
require(
sender == remoteBridges[sourceDomain],
"Invalid sender"
);
// Decode message
(address recipient, uint256 amount) = abi.decode(
messageBody,
(address, uint256)
);
// Transfer tokens to recipient
require(
token.transfer(recipient, amount),
"Transfer failed"
);
emit TokensUnlocked(recipient, sourceDomain, amount);
return true;
}
function setRemoteBridge(
uint32 domain,
bytes32 bridge
) external {
remoteBridges[domain] = bridge;
}
}
Hook Execution Pattern
CCTP V2 supports hooks that execute custom logic after message receipt.Hook Data Format
Field Bytes Type Index
target 20 address 0
hookCallData dynamic bytes 20
CCTPHookWrapper Example
TheCCTPHookWrapper demonstrates hook integration:
pragma solidity 0.7.6;
import {IReceiverV2} from "../interfaces/v2/IReceiverV2.sol";
import {Ownable2Step} from "../roles/Ownable2Step.sol";
contract CCTPHookWrapper is Ownable2Step {
IReceiverV2 public immutable messageTransmitter;
constructor(address _messageTransmitter) Ownable2Step() {
require(
_messageTransmitter != address(0),
"Invalid address"
);
messageTransmitter = IReceiverV2(_messageTransmitter);
}
function relay(
bytes calldata message,
bytes calldata attestation
)
external
returns (
bool relaySuccess,
bool hookSuccess,
bytes memory hookReturnData
)
{
_checkOwner();
// Relay message to MessageTransmitter
relaySuccess = messageTransmitter.receiveMessage(
message,
attestation
);
require(relaySuccess, "Receive failed");
// Extract and execute hook if present
bytes memory hookData = _extractHookData(message);
if (hookData.length >= 20) {
address target = _bytesToAddress(hookData);
bytes memory callData = _slice(hookData, 20);
(hookSuccess, hookReturnData) = target.call(callData);
}
}
function _extractHookData(
bytes calldata message
) internal pure returns (bytes memory) {
// Parse message and extract hook data
// Implementation details...
}
}
Custom Hook Handler
Implement a contract that receives hook callbacks:pragma solidity 0.7.6;
contract MyHookHandler {
event HookExecuted(
address indexed caller,
uint256 amount,
bytes data
);
function handleHook(
uint256 amount,
address recipient,
bytes calldata data
) external {
// Verify caller
require(
msg.sender == TRUSTED_HOOK_WRAPPER,
"Unauthorized"
);
// Execute custom logic
_processHook(amount, recipient, data);
emit HookExecuted(msg.sender, amount, data);
}
function _processHook(
uint256 amount,
address recipient,
bytes calldata data
) internal {
// Your custom hook logic
}
}
Integration Best Practices
1. Access Control
Always verify the message sender:function handleReceiveMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) external override returns (bool) {
// Only accept calls from MessageTransmitter
require(
msg.sender == address(messageTransmitter),
"Unauthorized caller"
);
// Only accept messages from authorized remote contracts
require(
authorizedSenders[sourceDomain] == sender,
"Unauthorized sender"
);
// Process message
}
2. Message Validation
Validate message format and data:function _validateMessage(
bytes calldata messageBody
) internal pure {
require(messageBody.length >= 32, "Invalid length");
(address recipient, uint256 amount) = abi.decode(
messageBody,
(address, uint256)
);
require(recipient != address(0), "Invalid recipient");
require(amount > 0, "Invalid amount");
}
3. Reentrancy Protection
Protect against reentrancy attacks:import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract MyHandler is IMessageHandler, ReentrancyGuard {
function handleReceiveMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) external override nonReentrant returns (bool) {
// Safe from reentrancy
}
}
4. Error Handling
Handle errors gracefully:function handleReceiveMessage(
uint32 sourceDomain,
bytes32 sender,
bytes calldata messageBody
) external override returns (bool) {
try this._processMessage(messageBody) {
return true;
} catch Error(string memory reason) {
emit MessageFailed(sourceDomain, sender, reason);
return false;
} catch {
emit MessageFailed(sourceDomain, sender, "Unknown error");
return false;
}
}
5. Gas Optimization
Optimize gas usage:// Use immutable for constants
address public immutable messageTransmitter;
// Pack structs efficiently
struct Transfer {
uint64 nonce; // 8 bytes
uint32 domain; // 4 bytes
uint96 amount; // 12 bytes
address recipient; // 20 bytes
} // Total: 44 bytes (fits in 2 slots)
// Use unchecked for safe operations
unchecked {
counter += 1;
}
Automated Relaying
Implement automated message relaying:const { Web3 } = require('web3');
class CCTPRelayer {
constructor(sourceRpc, destRpc) {
this.sourceWeb3 = new Web3(sourceRpc);
this.destWeb3 = new Web3(destRpc);
}
async monitorAndRelay() {
// Subscribe to MessageSent events
this.sourceWeb3.eth.subscribe('logs', {
address: MESSAGE_TRANSMITTER_ADDRESS,
topics: [web3.utils.keccak256('MessageSent(bytes)')]
}, async (error, log) => {
if (error) {
console.error('Error:', error);
return;
}
await this.relayMessage(log);
});
}
async relayMessage(log) {
// Extract message bytes
const messageBytes = this.sourceWeb3.eth.abi
.decodeParameters(['bytes'], log.data)[0];
const messageHash = this.sourceWeb3.utils
.keccak256(messageBytes);
// Fetch attestation
const attestation = await this.fetchAttestation(messageHash);
// Relay to destination
await this.destMessageTransmitter.methods
.receiveMessage(messageBytes, attestation)
.send();
console.log(`Relayed message: ${messageHash}`);
}
async fetchAttestation(messageHash) {
let response = { status: 'pending' };
while (response.status !== 'complete') {
const res = await fetch(
`https://iris-api-sandbox.circle.com/attestations/${messageHash}`
);
response = await res.json();
await new Promise(r => setTimeout(r, 2000));
}
return response.attestation;
}
}
// Usage
const relayer = new CCTPRelayer(ETH_RPC, AVAX_RPC);
relayer.monitorAndRelay();
Testing Integration
Test your integration thoroughly:contract MyHandlerTest is Test {
MyMessageHandler handler;
MockMessageTransmitter transmitter;
function setUp() public {
transmitter = new MockMessageTransmitter();
handler = new MyMessageHandler(address(transmitter));
}
function testHandleMessage() public {
uint32 sourceDomain = 0;
bytes32 sender = bytes32(uint256(uint160(address(this))));
bytes memory messageBody = abi.encode(
address(0x123),
1000e6
);
// Set authorized sender
handler.setAuthorizedSender(sourceDomain, sender);
// Simulate message receipt
vm.prank(address(transmitter));
bool success = handler.handleReceiveMessage(
sourceDomain,
sender,
messageBody
);
assertTrue(success);
}
}