// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//! CCTP v2 message format types
//!
//! Circle's CCTP v2 introduces a structured message format with headers and
//! typed body formats for different message types (burn messages, etc.).
//!
//! Reference: <https://developers.circle.com/cctp/technical-guide>
use alloy_primitives::{Address, Bytes, FixedBytes, U256};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::DomainId;
use crate::FinalityThreshold;
fn push_address_word(bytes: &mut Vec<u8>, address: Address) {
bytes.extend_from_slice(&[0u8; 12]);
bytes.extend_from_slice(address.as_slice());
}
fn decode_address_word(bytes: &[u8]) -> Option<Address> {
(bytes.len() == 32).then(|| Address::from_slice(&bytes[12..32]))
}
fn bytes_is_empty(bytes: &Bytes) -> bool {
bytes.is_empty()
}
/// Error returned when parsing a canonical CCTP v2 message fails.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("invalid CCTP v2 message: {reason}")]
pub struct ParseMessageError {
reason: String,
}
impl ParseMessageError {
fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
/// CCTP v2 Message Header
///
/// The message header contains metadata about cross-chain messages,
/// including source/destination domains, finality requirements, and routing.
///
/// # Format
///
/// - version: uint32 (4 bytes)
/// - sourceDomain: uint32 (4 bytes)
/// - destinationDomain: uint32 (4 bytes)
/// - nonce: bytes32 (32 bytes) - unique identifier assigned by Circle
/// - sender: bytes32 (32 bytes) - message sender address
/// - recipient: bytes32 (32 bytes) - message recipient address
/// - destinationCaller: bytes32 (32 bytes) - authorized caller on destination
/// - minFinalityThreshold: uint32 (4 bytes) - minimum required finality
/// - finalityThresholdExecuted: uint32 (4 bytes) - actual finality level
///
/// Total fixed size: 4 + 4 + 4 + 32 + 32 + 32 + 32 + 4 + 4 = 148 bytes
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageHeader {
/// Message format version
pub version: u32,
/// Source blockchain domain ID
pub source_domain: DomainId,
/// Destination blockchain domain ID
pub destination_domain: DomainId,
/// Unique message nonce assigned by Circle
pub nonce: FixedBytes<32>,
/// Address that sent the message (padded to 32 bytes)
pub sender: FixedBytes<32>,
/// Address that will receive the message (padded to 32 bytes)
pub recipient: FixedBytes<32>,
/// Address authorized to call receiveMessage on destination (0 = anyone)
pub destination_caller: FixedBytes<32>,
/// Minimum finality threshold required (1000 = Fast, 2000 = Standard)
pub min_finality_threshold: u32,
/// Actual finality threshold when message was attested
pub finality_threshold_executed: u32,
}
impl MessageHeader {
/// Size of the message header in bytes
pub const SIZE: usize = 148;
/// Creates a new message header
#[allow(clippy::too_many_arguments)]
pub fn new(
version: u32,
source_domain: DomainId,
destination_domain: DomainId,
nonce: FixedBytes<32>,
sender: FixedBytes<32>,
recipient: FixedBytes<32>,
destination_caller: FixedBytes<32>,
min_finality_threshold: u32,
finality_threshold_executed: u32,
) -> Self {
Self {
version,
source_domain,
destination_domain,
nonce,
sender,
recipient,
destination_caller,
min_finality_threshold,
finality_threshold_executed,
}
}
/// Encodes the message header to bytes
///
/// The encoding follows Circle's v2 message format specification.
pub fn encode(&self) -> Bytes {
let mut bytes = Vec::with_capacity(Self::SIZE);
// version (4 bytes)
bytes.extend_from_slice(&self.version.to_be_bytes());
// sourceDomain (4 bytes)
bytes.extend_from_slice(&self.source_domain.as_u32().to_be_bytes());
// destinationDomain (4 bytes)
bytes.extend_from_slice(&self.destination_domain.as_u32().to_be_bytes());
// nonce (32 bytes)
bytes.extend_from_slice(self.nonce.as_slice());
// sender (32 bytes)
bytes.extend_from_slice(self.sender.as_slice());
// recipient (32 bytes)
bytes.extend_from_slice(self.recipient.as_slice());
// destinationCaller (32 bytes)
bytes.extend_from_slice(self.destination_caller.as_slice());
// minFinalityThreshold (4 bytes)
bytes.extend_from_slice(&self.min_finality_threshold.to_be_bytes());
// finalityThresholdExecuted (4 bytes)
bytes.extend_from_slice(&self.finality_threshold_executed.to_be_bytes());
Bytes::from(bytes)
}
/// Decodes a message header from bytes
///
/// Returns `None` if the bytes are not at least [`MessageHeader::SIZE`] bytes long
/// or if domain IDs are invalid.
pub fn decode(bytes: &[u8]) -> Option<Self> {
if bytes.len() < Self::SIZE {
return None;
}
let version = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let source_domain = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let source_domain = DomainId::from_u32(source_domain)?;
let destination_domain = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let destination_domain = DomainId::from_u32(destination_domain)?;
let nonce = FixedBytes::from_slice(&bytes[12..44]);
let sender = FixedBytes::from_slice(&bytes[44..76]);
let recipient = FixedBytes::from_slice(&bytes[76..108]);
let destination_caller = FixedBytes::from_slice(&bytes[108..140]);
let min_finality_threshold =
u32::from_be_bytes([bytes[140], bytes[141], bytes[142], bytes[143]]);
let finality_threshold_executed =
u32::from_be_bytes([bytes[144], bytes[145], bytes[146], bytes[147]]);
Some(Self {
version,
source_domain,
destination_domain,
nonce,
sender,
recipient,
destination_caller,
min_finality_threshold,
finality_threshold_executed,
})
}
/// Parses a message header and returns a descriptive error on failure.
pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
if bytes.len() < Self::SIZE {
return Err(ParseMessageError::new(format!(
"header requires at least {} bytes, got {}",
Self::SIZE,
bytes.len()
)));
}
Self::decode(bytes).ok_or_else(|| ParseMessageError::new("failed to decode header"))
}
/// Returns true when the nonce is still the placeholder zero value from the on-chain event.
pub fn has_placeholder_nonce(&self) -> bool {
self.nonce.as_slice().iter().all(|byte| *byte == 0)
}
/// Returns the EVM sender address encoded in the 32-byte sender field.
///
/// This helper assumes the source domain uses the EVM trailing-20-byte
/// convention for `bytes32` addresses. For non-EVM domains, the raw
/// [`Self::sender`] field is authoritative.
#[must_use]
pub fn sender_address(&self) -> Address {
Address::from_slice(&self.sender.as_slice()[12..32])
}
/// Returns the EVM recipient address encoded in the 32-byte recipient field.
///
/// This helper assumes the destination domain uses the EVM trailing-20-byte
/// convention for `bytes32` addresses. For non-EVM domains, the raw
/// [`Self::recipient`] field is authoritative.
#[must_use]
pub fn recipient_address(&self) -> Address {
Address::from_slice(&self.recipient.as_slice()[12..32])
}
/// Returns the destination caller as an EVM address if the message is not permissionless.
///
/// This helper assumes the destination domain uses the EVM trailing-20-byte
/// convention for `bytes32` addresses. For non-EVM domains, the raw
/// [`Self::destination_caller`] field is authoritative.
#[must_use]
pub fn destination_caller_address(&self) -> Option<Address> {
(!self.is_permissionless())
.then(|| Address::from_slice(&self.destination_caller.as_slice()[12..32]))
}
/// Returns true when the message can be relayed by anyone.
pub fn is_permissionless(&self) -> bool {
self.destination_caller
.as_slice()
.iter()
.all(|byte| *byte == 0)
}
/// Returns the requested finality threshold when it matches a known CCTP mode.
#[must_use]
pub fn requested_finality(&self) -> Option<FinalityThreshold> {
FinalityThreshold::from_u32(self.min_finality_threshold)
}
/// Returns the finality threshold that Circle actually used for the attestation.
#[must_use]
pub fn attested_finality(&self) -> Option<FinalityThreshold> {
FinalityThreshold::from_u32(self.finality_threshold_executed)
}
}
/// CCTP v2 Burn Message Body
///
/// The burn message body contains information about a token burn operation
/// for cross-chain USDC transfers.
///
/// # Format
///
/// - version: uint32 (4 bytes)
/// - burnToken: bytes32 (32 bytes) - address of token being burned
/// - mintRecipient: bytes32 (32 bytes) - address to receive minted tokens
/// - amount: uint256 (32 bytes) - amount being transferred
/// - messageSender: bytes32 (32 bytes) - original sender address
/// - maxFee: uint256 (32 bytes) - maximum fee willing to pay
/// - feeExecuted: uint256 (32 bytes) - actual fee charged
/// - expirationBlock: uint256 (32 bytes) - block number when message expires
/// - hookData: dynamic bytes - arbitrary data for destination chain hooks
///
/// Total fixed size: 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 = 228 bytes + dynamic hookData
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BurnMessageV2 {
/// Message body version
pub version: u32,
/// Address of the token being burned (USDC contract)
pub burn_token: Address,
/// Address that will receive minted tokens on destination chain
pub mint_recipient: Address,
/// Amount of tokens being transferred (in wei/smallest unit)
pub amount: U256,
/// Address of the original message sender
pub message_sender: Address,
/// Maximum fee the sender is willing to pay (for Fast Transfers)
pub max_fee: U256,
/// Actual fee that was charged
pub fee_executed: U256,
/// Block number after which the message expires (anti-replay protection)
pub expiration_block: U256,
/// Optional hook data for programmable transfers
pub hook_data: Bytes,
}
impl BurnMessageV2 {
/// Minimum size of the burn message body in bytes (without hookData)
pub const MIN_SIZE: usize = 228;
/// Creates a new burn message with standard settings (no fast transfer, no hooks)
pub fn new(
burn_token: Address,
mint_recipient: Address,
amount: U256,
message_sender: Address,
) -> Self {
Self {
version: 1,
burn_token,
mint_recipient,
amount,
message_sender,
max_fee: U256::ZERO,
fee_executed: U256::ZERO,
expiration_block: U256::ZERO,
hook_data: Bytes::new(),
}
}
/// Creates a new burn message with fast transfer settings
pub fn new_with_fast_transfer(
burn_token: Address,
mint_recipient: Address,
amount: U256,
message_sender: Address,
max_fee: U256,
) -> Self {
Self {
version: 1,
burn_token,
mint_recipient,
amount,
message_sender,
max_fee,
fee_executed: U256::ZERO,
expiration_block: U256::ZERO,
hook_data: Bytes::new(),
}
}
/// Creates a new burn message with hook data
pub fn new_with_hooks(
burn_token: Address,
mint_recipient: Address,
amount: U256,
message_sender: Address,
hook_data: Bytes,
) -> Self {
Self {
version: 1,
burn_token,
mint_recipient,
amount,
message_sender,
max_fee: U256::ZERO,
fee_executed: U256::ZERO,
expiration_block: U256::ZERO,
hook_data,
}
}
/// Sets the hook data for this message
pub fn with_hook_data(mut self, hook_data: Bytes) -> Self {
self.hook_data = hook_data;
self
}
/// Sets the maximum fee for fast transfer
pub fn with_max_fee(mut self, max_fee: U256) -> Self {
self.max_fee = max_fee;
self
}
/// Sets the expiration block
pub fn with_expiration_block(mut self, expiration_block: U256) -> Self {
self.expiration_block = expiration_block;
self
}
/// Encodes the burn message body to bytes.
pub fn encode(&self) -> Bytes {
let mut bytes = Vec::with_capacity(Self::MIN_SIZE + self.hook_data.len());
bytes.extend_from_slice(&self.version.to_be_bytes());
push_address_word(&mut bytes, self.burn_token);
push_address_word(&mut bytes, self.mint_recipient);
bytes.extend_from_slice(&self.amount.to_be_bytes::<32>());
push_address_word(&mut bytes, self.message_sender);
bytes.extend_from_slice(&self.max_fee.to_be_bytes::<32>());
bytes.extend_from_slice(&self.fee_executed.to_be_bytes::<32>());
bytes.extend_from_slice(&self.expiration_block.to_be_bytes::<32>());
bytes.extend_from_slice(&self.hook_data);
Bytes::from(bytes)
}
/// Decodes a burn message body from bytes.
pub fn decode(bytes: &[u8]) -> Option<Self> {
if bytes.len() < Self::MIN_SIZE {
return None;
}
Some(Self {
version: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
burn_token: decode_address_word(&bytes[4..36])?,
mint_recipient: decode_address_word(&bytes[36..68])?,
amount: U256::from_be_slice(&bytes[68..100]),
message_sender: decode_address_word(&bytes[100..132])?,
max_fee: U256::from_be_slice(&bytes[132..164]),
fee_executed: U256::from_be_slice(&bytes[164..196]),
expiration_block: U256::from_be_slice(&bytes[196..228]),
hook_data: Bytes::copy_from_slice(&bytes[228..]),
})
}
/// Parses a burn message body and returns a descriptive error on failure.
pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
if bytes.len() < Self::MIN_SIZE {
return Err(ParseMessageError::new(format!(
"burn message body requires at least {} bytes, got {}",
Self::MIN_SIZE,
bytes.len()
)));
}
Self::decode(bytes)
.ok_or_else(|| ParseMessageError::new("failed to decode burn message body"))
}
/// Returns true if this message has hook data
pub fn has_hooks(&self) -> bool {
!self.hook_data.is_empty()
}
/// Returns true if this message is configured for fast transfer (`max_fee` > 0)
pub fn is_fast_transfer(&self) -> bool {
self.max_fee > U256::ZERO
}
}
/// Parsed representation of a canonical CCTP v2 transfer message.
///
/// This combines the fixed-size message header with the burn message body and
/// can be serialized directly for agent or tool responses.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedV2Message {
pub header: MessageHeader,
pub body: BurnMessageV2,
}
impl ParsedV2Message {
/// Encodes the full CCTP v2 message.
pub fn encode(&self) -> Bytes {
let mut bytes = self.header.encode().to_vec();
bytes.extend_from_slice(&self.body.encode());
Bytes::from(bytes)
}
/// Decodes a full CCTP v2 message.
pub fn decode(bytes: &[u8]) -> Option<Self> {
let header = MessageHeader::decode(bytes)?;
let body = BurnMessageV2::decode(&bytes[MessageHeader::SIZE..])?;
Some(Self { header, body })
}
/// Parses a full CCTP v2 message and returns a descriptive error on failure.
pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
let header = MessageHeader::parse(bytes)?;
let body = BurnMessageV2::parse(&bytes[MessageHeader::SIZE..])?;
Ok(Self { header, body })
}
/// Returns the keccak256 message hash used by the destination contract.
#[must_use]
pub fn message_hash(&self) -> FixedBytes<32> {
alloy_primitives::keccak256(self.encode())
}
/// Returns a compact summary that is convenient to serialize from tools.
///
/// Address-like fields in the summary use the SDK's current EVM-oriented
/// interpretation of `bytes32` address words. For non-EVM domains, use the
/// raw header fields in [`Self::header`] as the canonical source of truth.
#[must_use]
pub fn summary(&self) -> ParsedV2MessageSummary {
let encoded = self.encode();
let message_hash = alloy_primitives::keccak256(&encoded);
let message_len_bytes = encoded.len();
ParsedV2MessageSummary {
message_hash,
message_len_bytes,
source_domain: self.header.source_domain,
destination_domain: self.header.destination_domain,
message_version: self.header.version,
body_version: self.body.version,
nonce: self.header.nonce,
has_placeholder_nonce: self.header.has_placeholder_nonce(),
sender: self.header.sender_address(),
recipient: self.header.recipient_address(),
destination_caller: self.header.destination_caller_address(),
permissionless_relay: self.header.is_permissionless(),
requested_finality: self.header.requested_finality(),
attested_finality: self.header.attested_finality(),
burn_token: self.body.burn_token,
mint_recipient: self.body.mint_recipient,
amount: self.body.amount,
message_sender: self.body.message_sender,
max_fee: self.body.max_fee,
fee_executed: self.body.fee_executed,
expiration_block: self.body.expiration_block,
hook_data: self.body.hook_data.clone(),
hook_data_len_bytes: self.body.hook_data.len(),
has_hooks: self.body.has_hooks(),
is_fast_transfer: self.body.is_fast_transfer(),
}
}
}
/// JSON-friendly summary of a canonical CCTP v2 transfer message.
///
/// `DomainId` values serialize as `snake_case` strings. Future crate releases may
/// add new domain variants, so older versions of the crate may reject summaries
/// containing unknown domain names.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedV2MessageSummary {
pub message_hash: FixedBytes<32>,
pub message_len_bytes: usize,
pub source_domain: DomainId,
pub destination_domain: DomainId,
pub message_version: u32,
pub body_version: u32,
pub nonce: FixedBytes<32>,
pub has_placeholder_nonce: bool,
pub sender: Address,
pub recipient: Address,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destination_caller: Option<Address>,
pub permissionless_relay: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_finality: Option<FinalityThreshold>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attested_finality: Option<FinalityThreshold>,
pub burn_token: Address,
pub mint_recipient: Address,
pub amount: U256,
pub message_sender: Address,
pub max_fee: U256,
pub fee_executed: U256,
pub expiration_block: U256,
#[serde(default, skip_serializing_if = "bytes_is_empty")]
pub hook_data: Bytes,
pub hook_data_len_bytes: usize,
pub has_hooks: bool,
pub is_fast_transfer: bool,
}
impl ParsedV2MessageSummary {
/// Parses and summarizes a canonical CCTP v2 transfer message.
pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
ParsedV2Message::parse(bytes).map(|message| message.summary())
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::{address, hex};
#[test]
fn test_message_header_size() {
assert_eq!(MessageHeader::SIZE, 148);
}
#[test]
fn test_message_header_encode_decode() {
let header = MessageHeader::new(
1,
DomainId::Ethereum,
DomainId::Arbitrum,
FixedBytes::from([1u8; 32]),
FixedBytes::from([2u8; 32]),
FixedBytes::from([3u8; 32]),
FixedBytes::from([0u8; 32]),
1000,
1000,
);
let encoded = header.encode();
assert_eq!(encoded.len(), MessageHeader::SIZE);
let decoded = MessageHeader::decode(&encoded).expect("should decode");
assert_eq!(header, decoded);
}
#[test]
fn test_message_header_decode_too_short() {
let short_bytes = vec![0u8; 100];
assert!(MessageHeader::decode(&short_bytes).is_none());
}
#[test]
fn test_message_header_decode_invalid_domain() {
let mut bytes = vec![0u8; MessageHeader::SIZE];
// Set invalid source domain ID (999)
bytes[4..8].copy_from_slice(&999u32.to_be_bytes());
assert!(MessageHeader::decode(&bytes).is_none());
}
#[test]
fn test_burn_message_v2_new() {
let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
let amount = U256::from(1000000u64);
let sender = address!("1234567890abcdef1234567890abcdef12345678");
let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender);
assert_eq!(msg.version, 1);
assert_eq!(msg.burn_token, burn_token);
assert_eq!(msg.mint_recipient, mint_recipient);
assert_eq!(msg.amount, amount);
assert_eq!(msg.message_sender, sender);
assert_eq!(msg.max_fee, U256::ZERO);
assert_eq!(msg.fee_executed, U256::ZERO);
assert_eq!(msg.expiration_block, U256::ZERO);
assert!(msg.hook_data.is_empty());
assert!(!msg.has_hooks());
assert!(!msg.is_fast_transfer());
}
#[test]
fn test_burn_message_v2_fast_transfer() {
let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
let amount = U256::from(1000000u64);
let sender = address!("1234567890abcdef1234567890abcdef12345678");
let max_fee = U256::from(100u64);
let msg = BurnMessageV2::new_with_fast_transfer(
burn_token,
mint_recipient,
amount,
sender,
max_fee,
);
assert_eq!(msg.max_fee, max_fee);
assert!(msg.is_fast_transfer());
assert!(!msg.has_hooks());
}
#[test]
fn test_burn_message_v2_with_hooks() {
let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
let amount = U256::from(1000000u64);
let sender = address!("1234567890abcdef1234567890abcdef12345678");
let hook_data = Bytes::from(vec![1, 2, 3, 4]);
let msg = BurnMessageV2::new_with_hooks(
burn_token,
mint_recipient,
amount,
sender,
hook_data.clone(),
);
assert_eq!(msg.hook_data, hook_data);
assert!(msg.has_hooks());
assert!(!msg.is_fast_transfer());
}
#[test]
fn test_burn_message_v2_builder() {
let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
let amount = U256::from(1000000u64);
let sender = address!("1234567890abcdef1234567890abcdef12345678");
let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender)
.with_max_fee(U256::from(100u64))
.with_hook_data(Bytes::from(vec![1, 2, 3]))
.with_expiration_block(U256::from(1000u64));
assert!(msg.is_fast_transfer());
assert!(msg.has_hooks());
assert_eq!(msg.expiration_block, U256::from(1000u64));
}
#[test]
fn test_burn_message_v2_encode_decode_roundtrip() {
let message = BurnMessageV2::new_with_fast_transfer(
address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
U256::from(1_000_000u64),
address!("1234567890abcdef1234567890abcdef12345678"),
U256::from(100u64),
)
.with_hook_data(Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]))
.with_expiration_block(U256::from(12345u64));
let encoded = message.encode();
let decoded = BurnMessageV2::decode(&encoded).expect("burn message should decode");
assert_eq!(decoded, message);
}
#[test]
fn test_message_header_permissionless_helpers() {
let header = MessageHeader::new(
1,
DomainId::Ethereum,
DomainId::Base,
FixedBytes::from([0u8; 32]),
address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word(),
address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
FixedBytes::ZERO,
FinalityThreshold::Fast.as_u32(),
FinalityThreshold::Standard.as_u32(),
);
assert!(header.has_placeholder_nonce());
assert!(header.is_permissionless());
assert_eq!(
header.sender_address(),
address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D")
);
assert_eq!(
header.recipient_address(),
address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")
);
assert_eq!(header.requested_finality(), Some(FinalityThreshold::Fast));
assert_eq!(
header.attested_finality(),
Some(FinalityThreshold::Standard)
);
assert_eq!(header.destination_caller_address(), None);
}
#[test]
fn test_parsed_v2_message_from_real_circle_message() {
let raw_message = hex::decode("0000000100000003000000062f3cb13cf4a6103f9e3b256495b08c4e05630fcba639565d199ed420a5f2be010000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000000000000007d0000007d00000000100000000000000000000000075faf114eafb1bdbe2f0316df893fd58ce46aa4d0000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a019200000000000000000000000000000000000000000000000000000000000f42400000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a0192000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap();
let parsed = ParsedV2Message::parse(&raw_message).expect("message should parse");
let summary = parsed.summary();
assert_eq!(parsed.header.source_domain, DomainId::Arbitrum);
assert_eq!(parsed.header.destination_domain, DomainId::Base);
assert!(!parsed.header.has_placeholder_nonce());
assert_eq!(
parsed.header.requested_finality(),
Some(FinalityThreshold::Standard)
);
assert_eq!(
parsed.header.attested_finality(),
Some(FinalityThreshold::Standard)
);
assert_eq!(
parsed.body.burn_token,
address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D")
);
assert_eq!(
parsed.body.mint_recipient,
address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")
);
assert_eq!(parsed.body.amount, U256::from(1_000_000u64));
assert_eq!(
parsed.body.message_sender,
address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")
);
assert_eq!(parsed.body.max_fee, U256::ZERO);
assert_eq!(parsed.body.fee_executed, U256::ZERO);
assert_eq!(parsed.body.expiration_block, U256::ZERO);
assert!(parsed.body.hook_data.is_empty());
assert_eq!(parsed.encode().as_ref(), raw_message.as_slice());
assert_eq!(
parsed.message_hash(),
alloy_primitives::keccak256(&raw_message)
);
assert_eq!(
summary.message_hash,
alloy_primitives::keccak256(&raw_message)
);
assert!(summary.permissionless_relay);
assert!(!summary.has_hooks);
assert!(!summary.is_fast_transfer);
}
#[test]
fn test_parsed_v2_message_summary_omits_empty_optionals() {
let summary = ParsedV2MessageSummary {
message_hash: FixedBytes::from([0x11; 32]),
message_len_bytes: 376,
source_domain: DomainId::Ethereum,
destination_domain: DomainId::Base,
message_version: 1,
body_version: 1,
nonce: FixedBytes::from([0x22; 32]),
has_placeholder_nonce: false,
sender: address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
recipient: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
destination_caller: None,
permissionless_relay: true,
requested_finality: Some(FinalityThreshold::Standard),
attested_finality: Some(FinalityThreshold::Standard),
burn_token: address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
mint_recipient: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
amount: U256::from(1_000_000u64),
message_sender: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
max_fee: U256::ZERO,
fee_executed: U256::ZERO,
expiration_block: U256::ZERO,
hook_data: Bytes::new(),
hook_data_len_bytes: 0,
has_hooks: false,
is_fast_transfer: false,
};
let json = serde_json::to_value(summary).expect("summary should serialize");
assert!(json.get("destination_caller").is_none());
assert!(json.get("hook_data").is_none());
}
}