Skip to main content

cctp_rs/protocol/
message.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4//! CCTP v2 message format types
5//!
6//! Circle's CCTP v2 introduces a structured message format with headers and
7//! typed body formats for different message types (burn messages, etc.).
8//!
9//! Reference: <https://developers.circle.com/cctp/technical-guide>
10
11use alloy_primitives::{Address, Bytes, FixedBytes, U256};
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use super::DomainId;
16use crate::FinalityThreshold;
17
18fn push_address_word(bytes: &mut Vec<u8>, address: Address) {
19    bytes.extend_from_slice(&[0u8; 12]);
20    bytes.extend_from_slice(address.as_slice());
21}
22
23fn decode_address_word(bytes: &[u8]) -> Option<Address> {
24    (bytes.len() == 32).then(|| Address::from_slice(&bytes[12..32]))
25}
26
27fn bytes_is_empty(bytes: &Bytes) -> bool {
28    bytes.is_empty()
29}
30
31/// Error returned when parsing a canonical CCTP v2 message fails.
32#[derive(Debug, Clone, PartialEq, Eq, Error)]
33#[error("invalid CCTP v2 message: {reason}")]
34pub struct ParseMessageError {
35    reason: String,
36}
37
38impl ParseMessageError {
39    fn new(reason: impl Into<String>) -> Self {
40        Self {
41            reason: reason.into(),
42        }
43    }
44}
45
46/// CCTP v2 Message Header
47///
48/// The message header contains metadata about cross-chain messages,
49/// including source/destination domains, finality requirements, and routing.
50///
51/// # Format
52///
53/// - version: uint32 (4 bytes)
54/// - sourceDomain: uint32 (4 bytes)
55/// - destinationDomain: uint32 (4 bytes)
56/// - nonce: bytes32 (32 bytes) - unique identifier assigned by Circle
57/// - sender: bytes32 (32 bytes) - message sender address
58/// - recipient: bytes32 (32 bytes) - message recipient address
59/// - destinationCaller: bytes32 (32 bytes) - authorized caller on destination
60/// - minFinalityThreshold: uint32 (4 bytes) - minimum required finality
61/// - finalityThresholdExecuted: uint32 (4 bytes) - actual finality level
62///
63/// Total fixed size: 4 + 4 + 4 + 32 + 32 + 32 + 32 + 4 + 4 = 148 bytes
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct MessageHeader {
66    /// Message format version
67    pub version: u32,
68    /// Source blockchain domain ID
69    pub source_domain: DomainId,
70    /// Destination blockchain domain ID
71    pub destination_domain: DomainId,
72    /// Unique message nonce assigned by Circle
73    pub nonce: FixedBytes<32>,
74    /// Address that sent the message (padded to 32 bytes)
75    pub sender: FixedBytes<32>,
76    /// Address that will receive the message (padded to 32 bytes)
77    pub recipient: FixedBytes<32>,
78    /// Address authorized to call receiveMessage on destination (0 = anyone)
79    pub destination_caller: FixedBytes<32>,
80    /// Minimum finality threshold required (1000 = Fast, 2000 = Standard)
81    pub min_finality_threshold: u32,
82    /// Actual finality threshold when message was attested
83    pub finality_threshold_executed: u32,
84}
85
86impl MessageHeader {
87    /// Size of the message header in bytes
88    pub const SIZE: usize = 148;
89
90    /// Creates a new message header
91    #[allow(clippy::too_many_arguments)]
92    pub fn new(
93        version: u32,
94        source_domain: DomainId,
95        destination_domain: DomainId,
96        nonce: FixedBytes<32>,
97        sender: FixedBytes<32>,
98        recipient: FixedBytes<32>,
99        destination_caller: FixedBytes<32>,
100        min_finality_threshold: u32,
101        finality_threshold_executed: u32,
102    ) -> Self {
103        Self {
104            version,
105            source_domain,
106            destination_domain,
107            nonce,
108            sender,
109            recipient,
110            destination_caller,
111            min_finality_threshold,
112            finality_threshold_executed,
113        }
114    }
115
116    /// Encodes the message header to bytes
117    ///
118    /// The encoding follows Circle's v2 message format specification.
119    pub fn encode(&self) -> Bytes {
120        let mut bytes = Vec::with_capacity(Self::SIZE);
121
122        // version (4 bytes)
123        bytes.extend_from_slice(&self.version.to_be_bytes());
124        // sourceDomain (4 bytes)
125        bytes.extend_from_slice(&self.source_domain.as_u32().to_be_bytes());
126        // destinationDomain (4 bytes)
127        bytes.extend_from_slice(&self.destination_domain.as_u32().to_be_bytes());
128        // nonce (32 bytes)
129        bytes.extend_from_slice(self.nonce.as_slice());
130        // sender (32 bytes)
131        bytes.extend_from_slice(self.sender.as_slice());
132        // recipient (32 bytes)
133        bytes.extend_from_slice(self.recipient.as_slice());
134        // destinationCaller (32 bytes)
135        bytes.extend_from_slice(self.destination_caller.as_slice());
136        // minFinalityThreshold (4 bytes)
137        bytes.extend_from_slice(&self.min_finality_threshold.to_be_bytes());
138        // finalityThresholdExecuted (4 bytes)
139        bytes.extend_from_slice(&self.finality_threshold_executed.to_be_bytes());
140
141        Bytes::from(bytes)
142    }
143
144    /// Decodes a message header from bytes
145    ///
146    /// Returns `None` if the bytes are not at least [`MessageHeader::SIZE`] bytes long
147    /// or if domain IDs are invalid.
148    pub fn decode(bytes: &[u8]) -> Option<Self> {
149        if bytes.len() < Self::SIZE {
150            return None;
151        }
152
153        let version = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
154
155        let source_domain = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
156        let source_domain = DomainId::from_u32(source_domain)?;
157
158        let destination_domain = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
159        let destination_domain = DomainId::from_u32(destination_domain)?;
160
161        let nonce = FixedBytes::from_slice(&bytes[12..44]);
162        let sender = FixedBytes::from_slice(&bytes[44..76]);
163        let recipient = FixedBytes::from_slice(&bytes[76..108]);
164        let destination_caller = FixedBytes::from_slice(&bytes[108..140]);
165
166        let min_finality_threshold =
167            u32::from_be_bytes([bytes[140], bytes[141], bytes[142], bytes[143]]);
168        let finality_threshold_executed =
169            u32::from_be_bytes([bytes[144], bytes[145], bytes[146], bytes[147]]);
170
171        Some(Self {
172            version,
173            source_domain,
174            destination_domain,
175            nonce,
176            sender,
177            recipient,
178            destination_caller,
179            min_finality_threshold,
180            finality_threshold_executed,
181        })
182    }
183
184    /// Parses a message header and returns a descriptive error on failure.
185    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
186        if bytes.len() < Self::SIZE {
187            return Err(ParseMessageError::new(format!(
188                "header requires at least {} bytes, got {}",
189                Self::SIZE,
190                bytes.len()
191            )));
192        }
193
194        Self::decode(bytes).ok_or_else(|| ParseMessageError::new("failed to decode header"))
195    }
196
197    /// Returns true when the nonce is still the placeholder zero value from the on-chain event.
198    pub fn has_placeholder_nonce(&self) -> bool {
199        self.nonce.as_slice().iter().all(|byte| *byte == 0)
200    }
201
202    /// Returns the EVM sender address encoded in the 32-byte sender field.
203    ///
204    /// This helper assumes the source domain uses the EVM trailing-20-byte
205    /// convention for `bytes32` addresses. For non-EVM domains, the raw
206    /// [`Self::sender`] field is authoritative.
207    #[must_use]
208    pub fn sender_address(&self) -> Address {
209        Address::from_slice(&self.sender.as_slice()[12..32])
210    }
211
212    /// Returns the EVM recipient address encoded in the 32-byte recipient field.
213    ///
214    /// This helper assumes the destination domain uses the EVM trailing-20-byte
215    /// convention for `bytes32` addresses. For non-EVM domains, the raw
216    /// [`Self::recipient`] field is authoritative.
217    #[must_use]
218    pub fn recipient_address(&self) -> Address {
219        Address::from_slice(&self.recipient.as_slice()[12..32])
220    }
221
222    /// Returns the destination caller as an EVM address if the message is not permissionless.
223    ///
224    /// This helper assumes the destination domain uses the EVM trailing-20-byte
225    /// convention for `bytes32` addresses. For non-EVM domains, the raw
226    /// [`Self::destination_caller`] field is authoritative.
227    #[must_use]
228    pub fn destination_caller_address(&self) -> Option<Address> {
229        (!self.is_permissionless())
230            .then(|| Address::from_slice(&self.destination_caller.as_slice()[12..32]))
231    }
232
233    /// Returns true when the message can be relayed by anyone.
234    pub fn is_permissionless(&self) -> bool {
235        self.destination_caller
236            .as_slice()
237            .iter()
238            .all(|byte| *byte == 0)
239    }
240
241    /// Returns the requested finality threshold when it matches a known CCTP mode.
242    #[must_use]
243    pub fn requested_finality(&self) -> Option<FinalityThreshold> {
244        FinalityThreshold::from_u32(self.min_finality_threshold)
245    }
246
247    /// Returns the finality threshold that Circle actually used for the attestation.
248    #[must_use]
249    pub fn attested_finality(&self) -> Option<FinalityThreshold> {
250        FinalityThreshold::from_u32(self.finality_threshold_executed)
251    }
252}
253
254/// CCTP v2 Burn Message Body
255///
256/// The burn message body contains information about a token burn operation
257/// for cross-chain USDC transfers.
258///
259/// # Format
260///
261/// - version: uint32 (4 bytes)
262/// - burnToken: bytes32 (32 bytes) - address of token being burned
263/// - mintRecipient: bytes32 (32 bytes) - address to receive minted tokens
264/// - amount: uint256 (32 bytes) - amount being transferred
265/// - messageSender: bytes32 (32 bytes) - original sender address
266/// - maxFee: uint256 (32 bytes) - maximum fee willing to pay
267/// - feeExecuted: uint256 (32 bytes) - actual fee charged
268/// - expirationBlock: uint256 (32 bytes) - block number when message expires
269/// - hookData: dynamic bytes - arbitrary data for destination chain hooks
270///
271/// Total fixed size: 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 = 228 bytes + dynamic hookData
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct BurnMessageV2 {
274    /// Message body version
275    pub version: u32,
276    /// Address of the token being burned (USDC contract)
277    pub burn_token: Address,
278    /// Address that will receive minted tokens on destination chain
279    pub mint_recipient: Address,
280    /// Amount of tokens being transferred (in wei/smallest unit)
281    pub amount: U256,
282    /// Address of the original message sender
283    pub message_sender: Address,
284    /// Maximum fee the sender is willing to pay (for Fast Transfers)
285    pub max_fee: U256,
286    /// Actual fee that was charged
287    pub fee_executed: U256,
288    /// Block number after which the message expires (anti-replay protection)
289    pub expiration_block: U256,
290    /// Optional hook data for programmable transfers
291    pub hook_data: Bytes,
292}
293
294impl BurnMessageV2 {
295    /// Minimum size of the burn message body in bytes (without hookData)
296    pub const MIN_SIZE: usize = 228;
297
298    /// Creates a new burn message with standard settings (no fast transfer, no hooks)
299    pub fn new(
300        burn_token: Address,
301        mint_recipient: Address,
302        amount: U256,
303        message_sender: Address,
304    ) -> Self {
305        Self {
306            version: 1,
307            burn_token,
308            mint_recipient,
309            amount,
310            message_sender,
311            max_fee: U256::ZERO,
312            fee_executed: U256::ZERO,
313            expiration_block: U256::ZERO,
314            hook_data: Bytes::new(),
315        }
316    }
317
318    /// Creates a new burn message with fast transfer settings
319    pub fn new_with_fast_transfer(
320        burn_token: Address,
321        mint_recipient: Address,
322        amount: U256,
323        message_sender: Address,
324        max_fee: U256,
325    ) -> Self {
326        Self {
327            version: 1,
328            burn_token,
329            mint_recipient,
330            amount,
331            message_sender,
332            max_fee,
333            fee_executed: U256::ZERO,
334            expiration_block: U256::ZERO,
335            hook_data: Bytes::new(),
336        }
337    }
338
339    /// Creates a new burn message with hook data
340    pub fn new_with_hooks(
341        burn_token: Address,
342        mint_recipient: Address,
343        amount: U256,
344        message_sender: Address,
345        hook_data: Bytes,
346    ) -> Self {
347        Self {
348            version: 1,
349            burn_token,
350            mint_recipient,
351            amount,
352            message_sender,
353            max_fee: U256::ZERO,
354            fee_executed: U256::ZERO,
355            expiration_block: U256::ZERO,
356            hook_data,
357        }
358    }
359
360    /// Sets the hook data for this message
361    pub fn with_hook_data(mut self, hook_data: Bytes) -> Self {
362        self.hook_data = hook_data;
363        self
364    }
365
366    /// Sets the maximum fee for fast transfer
367    pub fn with_max_fee(mut self, max_fee: U256) -> Self {
368        self.max_fee = max_fee;
369        self
370    }
371
372    /// Sets the expiration block
373    pub fn with_expiration_block(mut self, expiration_block: U256) -> Self {
374        self.expiration_block = expiration_block;
375        self
376    }
377
378    /// Encodes the burn message body to bytes.
379    pub fn encode(&self) -> Bytes {
380        let mut bytes = Vec::with_capacity(Self::MIN_SIZE + self.hook_data.len());
381
382        bytes.extend_from_slice(&self.version.to_be_bytes());
383        push_address_word(&mut bytes, self.burn_token);
384        push_address_word(&mut bytes, self.mint_recipient);
385        bytes.extend_from_slice(&self.amount.to_be_bytes::<32>());
386        push_address_word(&mut bytes, self.message_sender);
387        bytes.extend_from_slice(&self.max_fee.to_be_bytes::<32>());
388        bytes.extend_from_slice(&self.fee_executed.to_be_bytes::<32>());
389        bytes.extend_from_slice(&self.expiration_block.to_be_bytes::<32>());
390        bytes.extend_from_slice(&self.hook_data);
391
392        Bytes::from(bytes)
393    }
394
395    /// Decodes a burn message body from bytes.
396    pub fn decode(bytes: &[u8]) -> Option<Self> {
397        if bytes.len() < Self::MIN_SIZE {
398            return None;
399        }
400
401        Some(Self {
402            version: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
403            burn_token: decode_address_word(&bytes[4..36])?,
404            mint_recipient: decode_address_word(&bytes[36..68])?,
405            amount: U256::from_be_slice(&bytes[68..100]),
406            message_sender: decode_address_word(&bytes[100..132])?,
407            max_fee: U256::from_be_slice(&bytes[132..164]),
408            fee_executed: U256::from_be_slice(&bytes[164..196]),
409            expiration_block: U256::from_be_slice(&bytes[196..228]),
410            hook_data: Bytes::copy_from_slice(&bytes[228..]),
411        })
412    }
413
414    /// Parses a burn message body and returns a descriptive error on failure.
415    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
416        if bytes.len() < Self::MIN_SIZE {
417            return Err(ParseMessageError::new(format!(
418                "burn message body requires at least {} bytes, got {}",
419                Self::MIN_SIZE,
420                bytes.len()
421            )));
422        }
423
424        Self::decode(bytes)
425            .ok_or_else(|| ParseMessageError::new("failed to decode burn message body"))
426    }
427
428    /// Returns true if this message has hook data
429    pub fn has_hooks(&self) -> bool {
430        !self.hook_data.is_empty()
431    }
432
433    /// Returns true if this message is configured for fast transfer (max_fee > 0)
434    pub fn is_fast_transfer(&self) -> bool {
435        self.max_fee > U256::ZERO
436    }
437}
438
439/// Parsed representation of a canonical CCTP v2 transfer message.
440///
441/// This combines the fixed-size message header with the burn message body and
442/// can be serialized directly for agent or tool responses.
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444pub struct ParsedV2Message {
445    pub header: MessageHeader,
446    pub body: BurnMessageV2,
447}
448
449impl ParsedV2Message {
450    /// Encodes the full CCTP v2 message.
451    pub fn encode(&self) -> Bytes {
452        let mut bytes = self.header.encode().to_vec();
453        bytes.extend_from_slice(&self.body.encode());
454        Bytes::from(bytes)
455    }
456
457    /// Decodes a full CCTP v2 message.
458    pub fn decode(bytes: &[u8]) -> Option<Self> {
459        let header = MessageHeader::decode(bytes)?;
460        let body = BurnMessageV2::decode(&bytes[MessageHeader::SIZE..])?;
461        Some(Self { header, body })
462    }
463
464    /// Parses a full CCTP v2 message and returns a descriptive error on failure.
465    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
466        let header = MessageHeader::parse(bytes)?;
467        let body = BurnMessageV2::parse(&bytes[MessageHeader::SIZE..])?;
468        Ok(Self { header, body })
469    }
470
471    /// Returns the keccak256 message hash used by the destination contract.
472    #[must_use]
473    pub fn message_hash(&self) -> FixedBytes<32> {
474        alloy_primitives::keccak256(self.encode())
475    }
476
477    /// Returns a compact summary that is convenient to serialize from tools.
478    ///
479    /// Address-like fields in the summary use the SDK's current EVM-oriented
480    /// interpretation of `bytes32` address words. For non-EVM domains, use the
481    /// raw header fields in [`Self::header`] as the canonical source of truth.
482    #[must_use]
483    pub fn summary(&self) -> ParsedV2MessageSummary {
484        let encoded = self.encode();
485        let message_hash = alloy_primitives::keccak256(&encoded);
486        let message_len_bytes = encoded.len();
487
488        ParsedV2MessageSummary {
489            message_hash,
490            message_len_bytes,
491            source_domain: self.header.source_domain,
492            destination_domain: self.header.destination_domain,
493            message_version: self.header.version,
494            body_version: self.body.version,
495            nonce: self.header.nonce,
496            has_placeholder_nonce: self.header.has_placeholder_nonce(),
497            sender: self.header.sender_address(),
498            recipient: self.header.recipient_address(),
499            destination_caller: self.header.destination_caller_address(),
500            permissionless_relay: self.header.is_permissionless(),
501            requested_finality: self.header.requested_finality(),
502            attested_finality: self.header.attested_finality(),
503            burn_token: self.body.burn_token,
504            mint_recipient: self.body.mint_recipient,
505            amount: self.body.amount,
506            message_sender: self.body.message_sender,
507            max_fee: self.body.max_fee,
508            fee_executed: self.body.fee_executed,
509            expiration_block: self.body.expiration_block,
510            hook_data: self.body.hook_data.clone(),
511            hook_data_len_bytes: self.body.hook_data.len(),
512            has_hooks: self.body.has_hooks(),
513            is_fast_transfer: self.body.is_fast_transfer(),
514        }
515    }
516}
517
518/// JSON-friendly summary of a canonical CCTP v2 transfer message.
519///
520/// `DomainId` values serialize as `snake_case` strings. Future crate releases may
521/// add new domain variants, so older versions of the crate may reject summaries
522/// containing unknown domain names.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524pub struct ParsedV2MessageSummary {
525    pub message_hash: FixedBytes<32>,
526    pub message_len_bytes: usize,
527    pub source_domain: DomainId,
528    pub destination_domain: DomainId,
529    pub message_version: u32,
530    pub body_version: u32,
531    pub nonce: FixedBytes<32>,
532    pub has_placeholder_nonce: bool,
533    pub sender: Address,
534    pub recipient: Address,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub destination_caller: Option<Address>,
537    pub permissionless_relay: bool,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub requested_finality: Option<FinalityThreshold>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub attested_finality: Option<FinalityThreshold>,
542    pub burn_token: Address,
543    pub mint_recipient: Address,
544    pub amount: U256,
545    pub message_sender: Address,
546    pub max_fee: U256,
547    pub fee_executed: U256,
548    pub expiration_block: U256,
549    #[serde(default, skip_serializing_if = "bytes_is_empty")]
550    pub hook_data: Bytes,
551    pub hook_data_len_bytes: usize,
552    pub has_hooks: bool,
553    pub is_fast_transfer: bool,
554}
555
556impl ParsedV2MessageSummary {
557    /// Parses and summarizes a canonical CCTP v2 transfer message.
558    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
559        ParsedV2Message::parse(bytes).map(|message| message.summary())
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use alloy_primitives::{address, hex};
567
568    #[test]
569    fn test_message_header_size() {
570        assert_eq!(MessageHeader::SIZE, 148);
571    }
572
573    #[test]
574    fn test_message_header_encode_decode() {
575        let header = MessageHeader::new(
576            1,
577            DomainId::Ethereum,
578            DomainId::Arbitrum,
579            FixedBytes::from([1u8; 32]),
580            FixedBytes::from([2u8; 32]),
581            FixedBytes::from([3u8; 32]),
582            FixedBytes::from([0u8; 32]),
583            1000,
584            1000,
585        );
586
587        let encoded = header.encode();
588        assert_eq!(encoded.len(), MessageHeader::SIZE);
589
590        let decoded = MessageHeader::decode(&encoded).expect("should decode");
591        assert_eq!(header, decoded);
592    }
593
594    #[test]
595    fn test_message_header_decode_too_short() {
596        let short_bytes = vec![0u8; 100];
597        assert!(MessageHeader::decode(&short_bytes).is_none());
598    }
599
600    #[test]
601    fn test_message_header_decode_invalid_domain() {
602        let mut bytes = vec![0u8; MessageHeader::SIZE];
603        // Set invalid source domain ID (999)
604        bytes[4..8].copy_from_slice(&999u32.to_be_bytes());
605        assert!(MessageHeader::decode(&bytes).is_none());
606    }
607
608    #[test]
609    fn test_burn_message_v2_new() {
610        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
611        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
612        let amount = U256::from(1000000u64);
613        let sender = address!("1234567890abcdef1234567890abcdef12345678");
614
615        let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender);
616
617        assert_eq!(msg.version, 1);
618        assert_eq!(msg.burn_token, burn_token);
619        assert_eq!(msg.mint_recipient, mint_recipient);
620        assert_eq!(msg.amount, amount);
621        assert_eq!(msg.message_sender, sender);
622        assert_eq!(msg.max_fee, U256::ZERO);
623        assert_eq!(msg.fee_executed, U256::ZERO);
624        assert_eq!(msg.expiration_block, U256::ZERO);
625        assert!(msg.hook_data.is_empty());
626        assert!(!msg.has_hooks());
627        assert!(!msg.is_fast_transfer());
628    }
629
630    #[test]
631    fn test_burn_message_v2_fast_transfer() {
632        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
633        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
634        let amount = U256::from(1000000u64);
635        let sender = address!("1234567890abcdef1234567890abcdef12345678");
636        let max_fee = U256::from(100u64);
637
638        let msg = BurnMessageV2::new_with_fast_transfer(
639            burn_token,
640            mint_recipient,
641            amount,
642            sender,
643            max_fee,
644        );
645
646        assert_eq!(msg.max_fee, max_fee);
647        assert!(msg.is_fast_transfer());
648        assert!(!msg.has_hooks());
649    }
650
651    #[test]
652    fn test_burn_message_v2_with_hooks() {
653        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
654        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
655        let amount = U256::from(1000000u64);
656        let sender = address!("1234567890abcdef1234567890abcdef12345678");
657        let hook_data = Bytes::from(vec![1, 2, 3, 4]);
658
659        let msg = BurnMessageV2::new_with_hooks(
660            burn_token,
661            mint_recipient,
662            amount,
663            sender,
664            hook_data.clone(),
665        );
666
667        assert_eq!(msg.hook_data, hook_data);
668        assert!(msg.has_hooks());
669        assert!(!msg.is_fast_transfer());
670    }
671
672    #[test]
673    fn test_burn_message_v2_builder() {
674        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
675        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
676        let amount = U256::from(1000000u64);
677        let sender = address!("1234567890abcdef1234567890abcdef12345678");
678
679        let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender)
680            .with_max_fee(U256::from(100u64))
681            .with_hook_data(Bytes::from(vec![1, 2, 3]))
682            .with_expiration_block(U256::from(1000u64));
683
684        assert!(msg.is_fast_transfer());
685        assert!(msg.has_hooks());
686        assert_eq!(msg.expiration_block, U256::from(1000u64));
687    }
688
689    #[test]
690    fn test_burn_message_v2_encode_decode_roundtrip() {
691        let message = BurnMessageV2::new_with_fast_transfer(
692            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
693            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
694            U256::from(1_000_000u64),
695            address!("1234567890abcdef1234567890abcdef12345678"),
696            U256::from(100u64),
697        )
698        .with_hook_data(Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]))
699        .with_expiration_block(U256::from(12345u64));
700
701        let encoded = message.encode();
702        let decoded = BurnMessageV2::decode(&encoded).expect("burn message should decode");
703
704        assert_eq!(decoded, message);
705    }
706
707    #[test]
708    fn test_message_header_permissionless_helpers() {
709        let header = MessageHeader::new(
710            1,
711            DomainId::Ethereum,
712            DomainId::Base,
713            FixedBytes::from([0u8; 32]),
714            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word(),
715            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
716            FixedBytes::ZERO,
717            FinalityThreshold::Fast.as_u32(),
718            FinalityThreshold::Standard.as_u32(),
719        );
720
721        assert!(header.has_placeholder_nonce());
722        assert!(header.is_permissionless());
723        assert_eq!(
724            header.sender_address(),
725            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D")
726        );
727        assert_eq!(
728            header.recipient_address(),
729            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")
730        );
731        assert_eq!(header.requested_finality(), Some(FinalityThreshold::Fast));
732        assert_eq!(
733            header.attested_finality(),
734            Some(FinalityThreshold::Standard)
735        );
736        assert_eq!(header.destination_caller_address(), None);
737    }
738
739    #[test]
740    fn test_parsed_v2_message_from_real_circle_message() {
741        let raw_message = hex::decode("0000000100000003000000062f3cb13cf4a6103f9e3b256495b08c4e05630fcba639565d199ed420a5f2be010000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000000000000007d0000007d00000000100000000000000000000000075faf114eafb1bdbe2f0316df893fd58ce46aa4d0000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a019200000000000000000000000000000000000000000000000000000000000f42400000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a0192000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap();
742
743        let parsed = ParsedV2Message::parse(&raw_message).expect("message should parse");
744        let summary = parsed.summary();
745
746        assert_eq!(parsed.header.source_domain, DomainId::Arbitrum);
747        assert_eq!(parsed.header.destination_domain, DomainId::Base);
748        assert!(!parsed.header.has_placeholder_nonce());
749        assert_eq!(
750            parsed.header.requested_finality(),
751            Some(FinalityThreshold::Standard)
752        );
753        assert_eq!(
754            parsed.header.attested_finality(),
755            Some(FinalityThreshold::Standard)
756        );
757        assert_eq!(
758            parsed.body.burn_token,
759            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D")
760        );
761        assert_eq!(
762            parsed.body.mint_recipient,
763            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")
764        );
765        assert_eq!(parsed.body.amount, U256::from(1_000_000u64));
766        assert_eq!(
767            parsed.body.message_sender,
768            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")
769        );
770        assert_eq!(parsed.body.max_fee, U256::ZERO);
771        assert_eq!(parsed.body.fee_executed, U256::ZERO);
772        assert_eq!(parsed.body.expiration_block, U256::ZERO);
773        assert!(parsed.body.hook_data.is_empty());
774        assert_eq!(parsed.encode().as_ref(), raw_message.as_slice());
775        assert_eq!(
776            parsed.message_hash(),
777            alloy_primitives::keccak256(&raw_message)
778        );
779        assert_eq!(
780            summary.message_hash,
781            alloy_primitives::keccak256(&raw_message)
782        );
783        assert!(summary.permissionless_relay);
784        assert!(!summary.has_hooks);
785        assert!(!summary.is_fast_transfer);
786    }
787
788    #[test]
789    fn test_parsed_v2_message_summary_omits_empty_optionals() {
790        let summary = ParsedV2MessageSummary {
791            message_hash: FixedBytes::from([0x11; 32]),
792            message_len_bytes: 376,
793            source_domain: DomainId::Ethereum,
794            destination_domain: DomainId::Base,
795            message_version: 1,
796            body_version: 1,
797            nonce: FixedBytes::from([0x22; 32]),
798            has_placeholder_nonce: false,
799            sender: address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
800            recipient: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
801            destination_caller: None,
802            permissionless_relay: true,
803            requested_finality: Some(FinalityThreshold::Standard),
804            attested_finality: Some(FinalityThreshold::Standard),
805            burn_token: address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
806            mint_recipient: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
807            amount: U256::from(1_000_000u64),
808            message_sender: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
809            max_fee: U256::ZERO,
810            fee_executed: U256::ZERO,
811            expiration_block: U256::ZERO,
812            hook_data: Bytes::new(),
813            hook_data_len_bytes: 0,
814            has_hooks: false,
815            is_fast_transfer: false,
816        };
817
818        let json = serde_json::to_value(summary).expect("summary should serialize");
819        assert!(json.get("destination_caller").is_none());
820        assert!(json.get("hook_data").is_none());
821    }
822}