The Message library defines the format for cross-chain messages in CCTP. Messages use a fixed-size header with a dynamic message body, leveraging TypedMemView for efficient memory operations.
Message Structure
Messages contain metadata about the cross-chain transfer and routing information:
Message format version (4 bytes)
Domain identifier of the source chain (4 bytes)
Domain identifier of the destination chain (4 bytes)
Destination-specific nonce for replay protection (8 bytes)In V2, this is expanded to bytes32 (32 bytes)
Address of sender on source chain as bytes32 (32 bytes, starting at index 20)
Address of recipient on destination chain as bytes32 (32 bytes, starting at index 52)
Address authorized to call receiveMessage on destination chain (32 bytes, starting at index 84)Set to bytes32(0) to allow any caller
Dynamic bytes payload containing the burn message or custom data (starting at index 116)
Memory Layout (V1)
Field Bytes Type Index
-----------------------------------------------
version 4 uint32 0
sourceDomain 4 uint32 4
destinationDomain 4 uint32 8
nonce 8 uint64 12
sender 32 bytes32 20
recipient 32 bytes32 52
destinationCaller 32 bytes32 84
messageBody dynamic bytes 116
Fields use specific padding: uintNN fields are left-padded, and bytesNN fields are right-padded. This ensures fixed-size encoding and prevents hash collisions.
Version 2 introduces additional fields for finality control:
Field Bytes Type Index
----------------------------------------------------------
version 4 uint32 0
sourceDomain 4 uint32 4
destinationDomain 4 uint32 8
nonce 32 bytes32 12
sender 32 bytes32 44
recipient 32 bytes32 76
destinationCaller 32 bytes32 108
minFinalityThreshold 4 uint32 140
finalityThresholdExecuted 4 uint32 144
messageBody dynamic bytes 148
V2 Key Differences
- nonce: Expanded from
uint64 (8 bytes) to bytes32 (32 bytes)
- minFinalityThreshold: Minimum finality level required for attestation
- finalityThresholdExecuted: Finality level at which message was executed
Encoding Functions
Source: ~/workspace/source/src/messages/Message.sol:66-87
function _formatMessage(
uint32 _msgVersion,
uint32 _msgSourceDomain,
uint32 _msgDestinationDomain,
uint64 _msgNonce,
bytes32 _msgSender,
bytes32 _msgRecipient,
bytes32 _msgDestinationCaller,
bytes memory _msgRawBody
) internal pure returns (bytes memory)
Packs all message fields into a single bytes array using abi.encodePacked.
Source: ~/workspace/source/src/messages/v2/MessageV2.sol:78-101
function _formatMessageForRelay(
uint32 _version,
uint32 _sourceDomain,
uint32 _destinationDomain,
bytes32 _sender,
bytes32 _recipient,
bytes32 _destinationCaller,
uint32 _minFinalityThreshold,
bytes calldata _messageBody
) internal pure returns (bytes memory)
Formats a V2 message with empty nonce and finalityThresholdExecuted (set during attestation).
Decoding Functions
All getter functions use TypedMemView for zero-copy operations:
V1 Getters
// Extract version
function _version(bytes29 _message) internal pure returns (uint32)
// Extract source domain
function _sourceDomain(bytes29 _message) internal pure returns (uint32)
// Extract destination domain
function _destinationDomain(bytes29 _message) internal pure returns (uint32)
// Extract nonce
function _nonce(bytes29 _message) internal pure returns (uint64)
// Extract sender address
function _sender(bytes29 _message) internal pure returns (bytes32)
// Extract recipient address
function _recipient(bytes29 _message) internal pure returns (bytes32)
// Extract destination caller
function _destinationCaller(bytes29 _message) internal pure returns (bytes32)
// Extract message body
function _messageBody(bytes29 _message) internal pure returns (bytes29)
V2 Getters
V2 uses _get prefix and includes additional fields:
// All V1 fields with _get prefix
function _getVersion(bytes29 _message) internal pure returns (uint32)
function _getSourceDomain(bytes29 _message) internal pure returns (uint32)
function _getDestinationDomain(bytes29 _message) internal pure returns (uint32)
function _getNonce(bytes29 _message) internal pure returns (bytes32) // Returns bytes32
function _getSender(bytes29 _message) internal pure returns (bytes32)
function _getRecipient(bytes29 _message) internal pure returns (bytes32)
function _getDestinationCaller(bytes29 _message) internal pure returns (bytes32)
function _getMessageBody(bytes29 _message) internal pure returns (bytes29)
// V2-specific fields
function _getMinFinalityThreshold(bytes29 _message) internal pure returns (uint32)
function _getFinalityThresholdExecuted(bytes29 _message) internal pure returns (uint32)
Address Conversion
Convert between EVM addresses and bytes32:
Address to Bytes32
Source: ~/workspace/source/src/messages/Message.sol:146-148
function addressToBytes32(address addr) external pure returns (bytes32) {
return bytes32(uint256(uint160(addr)));
}
Left-pads the address with zeros to create a bytes32 value.
Bytes32 to Address
Source: ~/workspace/source/src/messages/Message.sol:156-158
function bytes32ToAddress(bytes32 _buf) public pure returns (address) {
return address(uint160(uint256(_buf)));
}
Different bytes32 values can map to the same address due to truncation. If uniqueness is required, validate that the first 12 bytes are zero-padding.
Validation
V1 Validation
Source: ~/workspace/source/src/messages/Message.sol:164-170
function _validateMessageFormat(bytes29 _message) internal pure {
require(_message.isValid(), "Malformed message");
require(
_message.len() >= MESSAGE_BODY_INDEX,
"Invalid message: too short"
);
}
Ensures message is at least 116 bytes (minimum header size).
V2 Validation
Source: ~/workspace/source/src/messages/v2/MessageV2.sol:170-176
function _validateMessageFormat(bytes29 _message) internal pure {
require(_message.isValid(), "Malformed message");
require(
_message.len() >= MESSAGE_BODY_INDEX,
"Invalid message: too short"
);
}
Ensures message is at least 148 bytes (V2 minimum header size).
TypedMemView Usage
Messages use the TypedMemView library for efficient, zero-copy memory operations:
using TypedMemView for bytes;
using TypedMemView for bytes29;
bytes29: Efficient memory view type that avoids copying data
indexUint(): Reads uint values at specific byte offsets
index(): Reads bytes32 values at specific byte offsets
slice(): Creates sub-views without copying data
Example Usage
Encoding a Message
import {Message} from "./messages/Message.sol";
import {AddressUtils} from "./messages/v2/AddressUtils.sol";
// Convert addresses to bytes32
bytes32 senderBytes32 = AddressUtils.toBytes32(msg.sender);
bytes32 recipientBytes32 = AddressUtils.toBytes32(recipientAddress);
// Format message
bytes memory message = Message._formatMessage(
0, // version
0, // source domain
1, // destination domain
12345, // nonce
senderBytes32, // sender
recipientBytes32, // recipient
bytes32(0), // any destination caller
burnMessageBytes // message body
);
Decoding a Message
import {TypedMemView} from "@memview-sol/contracts/TypedMemView.sol";
import {Message} from "./messages/Message.sol";
using TypedMemView for bytes;
using Message for bytes29;
// Convert to TypedMemView
bytes29 messageView = receivedMessage.ref(0);
// Validate format
messageView._validateMessageFormat();
// Extract fields
uint32 version = messageView._version();
uint32 sourceDomain = messageView._sourceDomain();
bytes32 sender = messageView._sender();
bytes29 body = messageView._messageBody();
// Convert bytes32 back to address
address senderAddress = Message.bytes32ToAddress(sender);
See Also