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
18const CCTP_V2_MESSAGE_VERSION: u32 = 1;
19const CCTP_V2_BURN_BODY_VERSION: u32 = 1;
20
21fn read_u32(bytes: &[u8], offset: usize) -> u32 {
22    u32::from_be_bytes([
23        bytes[offset],
24        bytes[offset + 1],
25        bytes[offset + 2],
26        bytes[offset + 3],
27    ])
28}
29
30fn check_supported_version(
31    layer: &str,
32    version: u32,
33    supported: u32,
34) -> Result<(), ParseMessageError> {
35    if version == supported {
36        Ok(())
37    } else {
38        Err(ParseMessageError::new(format!(
39            "unsupported CCTP v2 {layer} version {version}; supported version is {supported}"
40        )))
41    }
42}
43
44fn address_word(address: Address) -> FixedBytes<32> {
45    address.into_word()
46}
47
48fn push_word(bytes: &mut Vec<u8>, word: FixedBytes<32>) {
49    bytes.extend_from_slice(word.as_slice());
50}
51
52/// Decodes a canonical EVM address word.
53///
54/// Returns `None` unless `bytes` is exactly 32 bytes long and the leading 12
55/// bytes are zero (the CCTP `bytes32` padding convention for EVM addresses).
56/// Rejecting non-canonical words preserves the `decode(raw).encode() == raw`
57/// invariant — otherwise stray leading bytes would be silently truncated on
58/// decode and reintroduced as zeros on re-encode.
59fn decode_address_word(bytes: &[u8]) -> Option<Address> {
60    if bytes.len() != 32 {
61        return None;
62    }
63    if !is_canonical_evm_address_word(bytes) {
64        return None;
65    }
66    Some(Address::from_slice(&bytes[12..32]))
67}
68
69fn is_canonical_evm_address_word(bytes: &[u8]) -> bool {
70    bytes.len() == 32 && bytes[..12].iter().all(|byte| *byte == 0)
71}
72
73fn check_canonical_address_word(bytes: &[u8], field: &str) -> Result<(), ParseMessageError> {
74    if bytes.len() != 32 {
75        return Err(ParseMessageError::new(format!(
76            "{field} word requires 32 bytes, got {len}",
77            len = bytes.len()
78        )));
79    }
80    if !is_canonical_evm_address_word(bytes) {
81        return Err(ParseMessageError::new(format!(
82            "{field} word has non-zero leading bytes; canonical CCTP v2 address \
83             words must be zero-padded in the first 12 bytes"
84        )));
85    }
86    Ok(())
87}
88
89fn check_word_for_domain(
90    domain: DomainId,
91    bytes: &[u8],
92    field: &str,
93) -> Result<(), ParseMessageError> {
94    if domain.is_evm() {
95        check_canonical_address_word(bytes, field)
96    } else if bytes.len() != 32 {
97        Err(ParseMessageError::new(format!(
98            "{field} word requires 32 bytes, got {len}",
99            len = bytes.len()
100        )))
101    } else {
102        Ok(())
103    }
104}
105
106fn bytes_is_empty(bytes: &Bytes) -> bool {
107    bytes.is_empty()
108}
109
110/// Error returned when parsing a canonical CCTP v2 message fails.
111#[derive(Debug, Clone, PartialEq, Eq, Error)]
112#[error("invalid CCTP v2 message: {reason}")]
113pub struct ParseMessageError {
114    reason: String,
115}
116
117impl ParseMessageError {
118    fn new(reason: impl Into<String>) -> Self {
119        Self {
120            reason: reason.into(),
121        }
122    }
123}
124
125/// CCTP v2 Message Header
126///
127/// The message header contains metadata about cross-chain messages,
128/// including source/destination domains, finality requirements, and routing.
129///
130/// # Format
131///
132/// - version: uint32 (4 bytes)
133/// - sourceDomain: uint32 (4 bytes)
134/// - destinationDomain: uint32 (4 bytes)
135/// - nonce: bytes32 (32 bytes) - unique identifier assigned by Circle
136/// - sender: bytes32 (32 bytes) - message sender address
137/// - recipient: bytes32 (32 bytes) - message recipient address
138/// - destinationCaller: bytes32 (32 bytes) - authorized caller on destination
139/// - minFinalityThreshold: uint32 (4 bytes) - minimum required finality
140/// - finalityThresholdExecuted: uint32 (4 bytes) - actual finality level
141///
142/// Total fixed size: 4 + 4 + 4 + 32 + 32 + 32 + 32 + 4 + 4 = 148 bytes
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct MessageHeader {
145    /// Message format version
146    pub version: u32,
147    /// Source blockchain domain ID
148    pub source_domain: DomainId,
149    /// Destination blockchain domain ID
150    pub destination_domain: DomainId,
151    /// Unique message nonce assigned by Circle
152    pub nonce: FixedBytes<32>,
153    /// Address that sent the message (padded to 32 bytes)
154    pub sender: FixedBytes<32>,
155    /// Address that will receive the message (padded to 32 bytes)
156    pub recipient: FixedBytes<32>,
157    /// Address authorized to call receiveMessage on destination (0 = anyone)
158    pub destination_caller: FixedBytes<32>,
159    /// Minimum finality threshold required (1000 = Fast, 2000 = Standard)
160    pub min_finality_threshold: u32,
161    /// Actual finality threshold when message was attested
162    pub finality_threshold_executed: u32,
163}
164
165impl MessageHeader {
166    /// Size of the message header in bytes
167    pub const SIZE: usize = 148;
168    /// The only CCTP v2 message header version this parser understands.
169    pub const SUPPORTED_VERSION: u32 = CCTP_V2_MESSAGE_VERSION;
170
171    /// Creates a new message header
172    #[allow(clippy::too_many_arguments)]
173    pub fn new(
174        version: u32,
175        source_domain: DomainId,
176        destination_domain: DomainId,
177        nonce: FixedBytes<32>,
178        sender: FixedBytes<32>,
179        recipient: FixedBytes<32>,
180        destination_caller: FixedBytes<32>,
181        min_finality_threshold: u32,
182        finality_threshold_executed: u32,
183    ) -> Self {
184        Self {
185            version,
186            source_domain,
187            destination_domain,
188            nonce,
189            sender,
190            recipient,
191            destination_caller,
192            min_finality_threshold,
193            finality_threshold_executed,
194        }
195    }
196
197    /// Encodes the message header to bytes
198    ///
199    /// The encoding follows Circle's v2 message format specification.
200    pub fn encode(&self) -> Bytes {
201        let mut bytes = Vec::with_capacity(Self::SIZE);
202
203        // version (4 bytes)
204        bytes.extend_from_slice(&self.version.to_be_bytes());
205        // sourceDomain (4 bytes)
206        bytes.extend_from_slice(&self.source_domain.as_u32().to_be_bytes());
207        // destinationDomain (4 bytes)
208        bytes.extend_from_slice(&self.destination_domain.as_u32().to_be_bytes());
209        // nonce (32 bytes)
210        bytes.extend_from_slice(self.nonce.as_slice());
211        // sender (32 bytes)
212        bytes.extend_from_slice(self.sender.as_slice());
213        // recipient (32 bytes)
214        bytes.extend_from_slice(self.recipient.as_slice());
215        // destinationCaller (32 bytes)
216        bytes.extend_from_slice(self.destination_caller.as_slice());
217        // minFinalityThreshold (4 bytes)
218        bytes.extend_from_slice(&self.min_finality_threshold.to_be_bytes());
219        // finalityThresholdExecuted (4 bytes)
220        bytes.extend_from_slice(&self.finality_threshold_executed.to_be_bytes());
221
222        Bytes::from(bytes)
223    }
224
225    /// Decodes a message header from bytes
226    ///
227    /// Returns `None` if the bytes are not at least [`MessageHeader::SIZE`] bytes long
228    /// or if domain IDs are invalid.
229    pub fn decode(bytes: &[u8]) -> Option<Self> {
230        if bytes.len() < Self::SIZE {
231            return None;
232        }
233
234        let version = read_u32(bytes, 0);
235        if version != Self::SUPPORTED_VERSION {
236            return None;
237        }
238
239        let source_domain = read_u32(bytes, 4);
240        let source_domain = DomainId::from_u32(source_domain)?;
241
242        let destination_domain = read_u32(bytes, 8);
243        let destination_domain = DomainId::from_u32(destination_domain)?;
244
245        let nonce = FixedBytes::from_slice(&bytes[12..44]);
246        let sender = FixedBytes::from_slice(&bytes[44..76]);
247        let recipient = FixedBytes::from_slice(&bytes[76..108]);
248        let destination_caller = FixedBytes::from_slice(&bytes[108..140]);
249
250        let min_finality_threshold = read_u32(bytes, 140);
251        let finality_threshold_executed = read_u32(bytes, 144);
252
253        Some(Self {
254            version,
255            source_domain,
256            destination_domain,
257            nonce,
258            sender,
259            recipient,
260            destination_caller,
261            min_finality_threshold,
262            finality_threshold_executed,
263        })
264    }
265
266    /// Parses a message header and returns a descriptive error on failure.
267    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
268        if bytes.len() < Self::SIZE {
269            return Err(ParseMessageError::new(format!(
270                "header requires at least {} bytes, got {}",
271                Self::SIZE,
272                bytes.len()
273            )));
274        }
275
276        let version = read_u32(bytes, 0);
277        check_supported_version("message header", version, Self::SUPPORTED_VERSION)?;
278
279        Self::decode(bytes).ok_or_else(|| ParseMessageError::new("failed to decode header"))
280    }
281
282    /// Returns true when the nonce is still the placeholder zero value from the on-chain event.
283    pub fn has_placeholder_nonce(&self) -> bool {
284        self.nonce.as_slice().iter().all(|byte| *byte == 0)
285    }
286
287    /// Returns the sender as an EVM `Address` when the source domain is EVM.
288    ///
289    /// Returns `None` for non-EVM domains such as [`DomainId::Solana`] or
290    /// [`DomainId::StarknetTestnet`], whose `bytes32` sender words do not use
291    /// the EVM trailing-20-byte convention. For those domains, the raw
292    /// [`Self::sender`] field is the canonical source of truth.
293    #[must_use]
294    pub fn sender_address(&self) -> Option<Address> {
295        self.source_domain
296            .is_evm()
297            .then(|| Address::from_slice(&self.sender.as_slice()[12..32]))
298    }
299
300    /// Returns the recipient as an EVM `Address` when the destination domain is EVM.
301    ///
302    /// Returns `None` for non-EVM destination domains. For those, the raw
303    /// [`Self::recipient`] field is the canonical source of truth.
304    #[must_use]
305    pub fn recipient_address(&self) -> Option<Address> {
306        self.destination_domain
307            .is_evm()
308            .then(|| Address::from_slice(&self.recipient.as_slice()[12..32]))
309    }
310
311    /// Returns the destination caller as an EVM `Address` when one is set and
312    /// the destination domain is EVM.
313    ///
314    /// Returns `None` when the message is permissionless or the destination
315    /// domain is non-EVM. The raw [`Self::destination_caller`] field is
316    /// authoritative in the non-EVM case.
317    #[must_use]
318    pub fn destination_caller_address(&self) -> Option<Address> {
319        if self.is_permissionless() || !self.destination_domain.is_evm() {
320            return None;
321        }
322        Some(Address::from_slice(
323            &self.destination_caller.as_slice()[12..32],
324        ))
325    }
326
327    /// Returns true when the message can be relayed by anyone.
328    pub fn is_permissionless(&self) -> bool {
329        self.destination_caller
330            .as_slice()
331            .iter()
332            .all(|byte| *byte == 0)
333    }
334
335    /// Returns the requested finality threshold when it matches a known CCTP mode.
336    #[must_use]
337    pub fn requested_finality(&self) -> Option<FinalityThreshold> {
338        FinalityThreshold::from_u32(self.min_finality_threshold)
339    }
340
341    /// Returns the finality threshold that Circle actually used for the attestation.
342    #[must_use]
343    pub fn attested_finality(&self) -> Option<FinalityThreshold> {
344        FinalityThreshold::from_u32(self.finality_threshold_executed)
345    }
346}
347
348/// CCTP v2 Burn Message Body
349///
350/// The burn message body contains information about a token burn operation
351/// for cross-chain USDC transfers.
352///
353/// # Format
354///
355/// - version: uint32 (4 bytes)
356/// - burnToken: bytes32 (32 bytes) - address of token being burned
357/// - mintRecipient: bytes32 (32 bytes) - address to receive minted tokens
358/// - amount: uint256 (32 bytes) - amount being transferred
359/// - messageSender: bytes32 (32 bytes) - original sender address
360/// - maxFee: uint256 (32 bytes) - maximum fee willing to pay
361/// - feeExecuted: uint256 (32 bytes) - actual fee charged
362/// - expirationBlock: uint256 (32 bytes) - block number when message expires
363/// - hookData: dynamic bytes - arbitrary data for destination chain hooks
364///
365/// Total fixed size: 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 = 228 bytes + dynamic hookData
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct BurnMessageV2 {
368    /// Message body version
369    pub version: u32,
370    /// Canonical 32-byte token word from the source domain.
371    pub burn_token: FixedBytes<32>,
372    /// Canonical 32-byte recipient word for the destination domain.
373    pub mint_recipient: FixedBytes<32>,
374    /// Amount of tokens being transferred (in wei/smallest unit)
375    pub amount: U256,
376    /// Canonical 32-byte sender word from the source domain.
377    pub message_sender: FixedBytes<32>,
378    /// Maximum fee the sender is willing to pay (for Fast Transfers)
379    pub max_fee: U256,
380    /// Actual fee that was charged
381    pub fee_executed: U256,
382    /// Block number after which the message expires (anti-replay protection)
383    pub expiration_block: U256,
384    /// Optional hook data for programmable transfers
385    pub hook_data: Bytes,
386}
387
388impl BurnMessageV2 {
389    /// Minimum size of the burn message body in bytes (without hookData)
390    pub const MIN_SIZE: usize = 228;
391    /// The only CCTP v2 burn body version this parser understands.
392    pub const SUPPORTED_VERSION: u32 = CCTP_V2_BURN_BODY_VERSION;
393
394    /// Creates a new burn message with standard settings (no fast transfer, no hooks)
395    pub fn new(
396        burn_token: Address,
397        mint_recipient: Address,
398        amount: U256,
399        message_sender: Address,
400    ) -> Self {
401        Self {
402            version: 1,
403            burn_token: address_word(burn_token),
404            mint_recipient: address_word(mint_recipient),
405            amount,
406            message_sender: address_word(message_sender),
407            max_fee: U256::ZERO,
408            fee_executed: U256::ZERO,
409            expiration_block: U256::ZERO,
410            hook_data: Bytes::new(),
411        }
412    }
413
414    /// Creates a new burn message with fast transfer settings
415    pub fn new_with_fast_transfer(
416        burn_token: Address,
417        mint_recipient: Address,
418        amount: U256,
419        message_sender: Address,
420        max_fee: U256,
421    ) -> Self {
422        Self {
423            version: 1,
424            burn_token: address_word(burn_token),
425            mint_recipient: address_word(mint_recipient),
426            amount,
427            message_sender: address_word(message_sender),
428            max_fee,
429            fee_executed: U256::ZERO,
430            expiration_block: U256::ZERO,
431            hook_data: Bytes::new(),
432        }
433    }
434
435    /// Creates a new burn message with hook data
436    pub fn new_with_hooks(
437        burn_token: Address,
438        mint_recipient: Address,
439        amount: U256,
440        message_sender: Address,
441        hook_data: Bytes,
442    ) -> Self {
443        Self {
444            version: 1,
445            burn_token: address_word(burn_token),
446            mint_recipient: address_word(mint_recipient),
447            amount,
448            message_sender: address_word(message_sender),
449            max_fee: U256::ZERO,
450            fee_executed: U256::ZERO,
451            expiration_block: U256::ZERO,
452            hook_data,
453        }
454    }
455
456    /// Sets the hook data for this message
457    pub fn with_hook_data(mut self, hook_data: Bytes) -> Self {
458        self.hook_data = hook_data;
459        self
460    }
461
462    /// Sets the maximum fee for fast transfer
463    pub fn with_max_fee(mut self, max_fee: U256) -> Self {
464        self.max_fee = max_fee;
465        self
466    }
467
468    /// Sets the expiration block
469    pub fn with_expiration_block(mut self, expiration_block: U256) -> Self {
470        self.expiration_block = expiration_block;
471        self
472    }
473
474    /// Returns `burn_token` as an EVM address when its word is canonically padded.
475    #[must_use]
476    pub fn burn_token_address(&self) -> Option<Address> {
477        decode_address_word(self.burn_token.as_slice())
478    }
479
480    /// Returns `mint_recipient` as an EVM address when its word is canonically padded.
481    #[must_use]
482    pub fn mint_recipient_address(&self) -> Option<Address> {
483        decode_address_word(self.mint_recipient.as_slice())
484    }
485
486    /// Returns `message_sender` as an EVM address when its word is canonically padded.
487    #[must_use]
488    pub fn message_sender_address(&self) -> Option<Address> {
489        decode_address_word(self.message_sender.as_slice())
490    }
491
492    /// Encodes the burn message body to bytes.
493    pub fn encode(&self) -> Bytes {
494        let mut bytes = Vec::with_capacity(Self::MIN_SIZE + self.hook_data.len());
495
496        bytes.extend_from_slice(&self.version.to_be_bytes());
497        push_word(&mut bytes, self.burn_token);
498        push_word(&mut bytes, self.mint_recipient);
499        bytes.extend_from_slice(&self.amount.to_be_bytes::<32>());
500        push_word(&mut bytes, self.message_sender);
501        bytes.extend_from_slice(&self.max_fee.to_be_bytes::<32>());
502        bytes.extend_from_slice(&self.fee_executed.to_be_bytes::<32>());
503        bytes.extend_from_slice(&self.expiration_block.to_be_bytes::<32>());
504        bytes.extend_from_slice(&self.hook_data);
505
506        Bytes::from(bytes)
507    }
508
509    /// Decodes a burn message body from bytes.
510    ///
511    /// Returns `None` for bytes shorter than [`Self::MIN_SIZE`]. The three
512    /// address-like fields are preserved as raw 32-byte words because their
513    /// canonical shape is domain-dependent. For any accepted input,
514    /// `decode(raw).unwrap().encode() == raw`.
515    pub fn decode(bytes: &[u8]) -> Option<Self> {
516        if bytes.len() < Self::MIN_SIZE {
517            return None;
518        }
519
520        let version = read_u32(bytes, 0);
521        if version != Self::SUPPORTED_VERSION {
522            return None;
523        }
524
525        Some(Self {
526            version,
527            burn_token: FixedBytes::from_slice(&bytes[4..36]),
528            mint_recipient: FixedBytes::from_slice(&bytes[36..68]),
529            amount: U256::from_be_slice(&bytes[68..100]),
530            message_sender: FixedBytes::from_slice(&bytes[100..132]),
531            max_fee: U256::from_be_slice(&bytes[132..164]),
532            fee_executed: U256::from_be_slice(&bytes[164..196]),
533            expiration_block: U256::from_be_slice(&bytes[196..228]),
534            hook_data: Bytes::copy_from_slice(&bytes[228..]),
535        })
536    }
537
538    /// Parses a burn message body and returns a descriptive error on failure.
539    ///
540    /// Body-only parsing has no source/destination domain context, so it
541    /// preserves the three address-like fields as raw words. Use
542    /// [`ParsedV2Message::parse`] when domain-aware EVM padding validation is
543    /// required.
544    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
545        if bytes.len() < Self::MIN_SIZE {
546            return Err(ParseMessageError::new(format!(
547                "burn message body requires at least {} bytes, got {}",
548                Self::MIN_SIZE,
549                bytes.len()
550            )));
551        }
552
553        let version = read_u32(bytes, 0);
554        check_supported_version("burn message body", version, Self::SUPPORTED_VERSION)?;
555
556        Self::decode(bytes)
557            .ok_or_else(|| ParseMessageError::new("failed to decode burn message body"))
558    }
559
560    fn parse_for_domains(
561        bytes: &[u8],
562        source_domain: DomainId,
563        destination_domain: DomainId,
564    ) -> std::result::Result<Self, ParseMessageError> {
565        if bytes.len() < Self::MIN_SIZE {
566            return Err(ParseMessageError::new(format!(
567                "burn message body requires at least {} bytes, got {}",
568                Self::MIN_SIZE,
569                bytes.len()
570            )));
571        }
572
573        let version = read_u32(bytes, 0);
574        check_supported_version("burn message body", version, Self::SUPPORTED_VERSION)?;
575
576        check_word_for_domain(source_domain, &bytes[4..36], "burn_token")?;
577        check_word_for_domain(destination_domain, &bytes[36..68], "mint_recipient")?;
578        check_word_for_domain(source_domain, &bytes[100..132], "message_sender")?;
579
580        Self::decode(bytes)
581            .ok_or_else(|| ParseMessageError::new("failed to decode burn message body"))
582    }
583
584    fn decode_for_domains(
585        bytes: &[u8],
586        source_domain: DomainId,
587        destination_domain: DomainId,
588    ) -> Option<Self> {
589        Self::parse_for_domains(bytes, source_domain, destination_domain).ok()
590    }
591
592    /// Returns true if this message has hook data
593    pub fn has_hooks(&self) -> bool {
594        !self.hook_data.is_empty()
595    }
596
597    /// Returns true if this message is configured for fast transfer (`max_fee` > 0)
598    pub fn is_fast_transfer(&self) -> bool {
599        self.max_fee > U256::ZERO
600    }
601}
602
603/// Parsed representation of a canonical CCTP v2 transfer message.
604///
605/// This combines the fixed-size message header with the burn message body and
606/// can be serialized directly for agent or tool responses.
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608pub struct ParsedV2Message {
609    pub header: MessageHeader,
610    pub body: BurnMessageV2,
611}
612
613impl ParsedV2Message {
614    /// Encodes the full CCTP v2 message.
615    pub fn encode(&self) -> Bytes {
616        let mut bytes = self.header.encode().to_vec();
617        bytes.extend_from_slice(&self.body.encode());
618        Bytes::from(bytes)
619    }
620
621    /// Decodes a canonical CCTP v2 burn-transfer message.
622    ///
623    /// Returns `None` for inputs that are too short, carry an unknown domain
624    /// ID, or contain non-canonical EVM `bytes32` address words for the
625    /// message's source or destination domains. Non-EVM-domain body words are
626    /// preserved raw. For any accepted input, `decode(raw).unwrap().encode()
627    /// == raw` and `decode(raw).unwrap().message_hash() == keccak256(raw)`.
628    /// Use [`Self::summary`] to obtain the JSON-friendly projection used in
629    /// agent and tool responses.
630    pub fn decode(bytes: &[u8]) -> Option<Self> {
631        let header = MessageHeader::decode(bytes)?;
632        let body = BurnMessageV2::decode_for_domains(
633            &bytes[MessageHeader::SIZE..],
634            header.source_domain,
635            header.destination_domain,
636        )?;
637        Some(Self { header, body })
638    }
639
640    /// Parses a canonical CCTP v2 burn-transfer message and returns a
641    /// descriptive error on failure.
642    ///
643    /// Strict parser: every accepted input round-trips byte-for-byte through
644    /// [`Self::encode`] and hashes to `keccak256(raw)` via [`Self::message_hash`].
645    /// EVM-domain body address words must use the canonical 12-zero-byte
646    /// padding convention; non-EVM-domain body words are preserved as raw
647    /// `bytes32` values. For a JSON-friendly view of the parsed message, call
648    /// [`Self::summary`].
649    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
650        let header = MessageHeader::parse(bytes)?;
651        let body = BurnMessageV2::parse_for_domains(
652            &bytes[MessageHeader::SIZE..],
653            header.source_domain,
654            header.destination_domain,
655        )?;
656        Ok(Self { header, body })
657    }
658
659    /// Returns the keccak256 message hash used by the destination contract.
660    #[must_use]
661    pub fn message_hash(&self) -> FixedBytes<32> {
662        alloy_primitives::keccak256(self.encode())
663    }
664
665    /// Returns a compact summary that is convenient to serialize from tools.
666    ///
667    /// The canonical `bytes32` header fields are exposed as `sender_bytes`,
668    /// `recipient_bytes`, and `destination_caller_bytes` and are always
669    /// populated. The EVM-interpreted `sender`, `recipient`, and
670    /// `destination_caller` fields are populated only when the corresponding
671    /// domain is EVM ([`DomainId::is_evm`]); for non-EVM domains they are
672    /// `None` so consumers do not mistake a misleading trailing-20-byte
673    /// projection for the authoritative value.
674    #[must_use]
675    pub fn summary(&self) -> ParsedV2MessageSummary {
676        let encoded = self.encode();
677        let message_hash = alloy_primitives::keccak256(&encoded);
678        let message_len_bytes = encoded.len();
679
680        ParsedV2MessageSummary {
681            message_hash,
682            message_len_bytes,
683            source_domain: self.header.source_domain,
684            destination_domain: self.header.destination_domain,
685            message_version: self.header.version,
686            body_version: self.body.version,
687            nonce: self.header.nonce,
688            has_placeholder_nonce: self.header.has_placeholder_nonce(),
689            sender_bytes: self.header.sender,
690            sender: self.header.sender_address(),
691            recipient_bytes: self.header.recipient,
692            recipient: self.header.recipient_address(),
693            destination_caller_bytes: self.header.destination_caller,
694            destination_caller: self.header.destination_caller_address(),
695            permissionless_relay: self.header.is_permissionless(),
696            requested_finality: self.header.requested_finality(),
697            attested_finality: self.header.attested_finality(),
698            burn_token_bytes: self.body.burn_token,
699            burn_token: self
700                .header
701                .source_domain
702                .is_evm()
703                .then(|| self.body.burn_token_address())
704                .flatten(),
705            mint_recipient_bytes: self.body.mint_recipient,
706            mint_recipient: self
707                .header
708                .destination_domain
709                .is_evm()
710                .then(|| self.body.mint_recipient_address())
711                .flatten(),
712            amount: self.body.amount,
713            message_sender_bytes: self.body.message_sender,
714            message_sender: self
715                .header
716                .source_domain
717                .is_evm()
718                .then(|| self.body.message_sender_address())
719                .flatten(),
720            max_fee: self.body.max_fee,
721            fee_executed: self.body.fee_executed,
722            expiration_block: self.body.expiration_block,
723            hook_data: self.body.hook_data.clone(),
724            hook_data_len_bytes: self.body.hook_data.len(),
725            has_hooks: self.body.has_hooks(),
726            is_fast_transfer: self.body.is_fast_transfer(),
727        }
728    }
729}
730
731/// JSON-friendly summary of a canonical CCTP v2 transfer message.
732///
733/// `DomainId` values serialize as `snake_case` strings. Future crate releases may
734/// add new domain variants, so older versions of the crate may reject summaries
735/// containing unknown domain names.
736///
737/// # Address fields and non-EVM domains
738///
739/// The `*_bytes` fields (`sender_bytes`, `recipient_bytes`,
740/// `destination_caller_bytes`) carry the canonical 32-byte header words and
741/// are always populated. The EVM-shaped fields (`sender`, `recipient`,
742/// `destination_caller`) are populated only when the corresponding domain is
743/// EVM ([`DomainId::is_evm`]); for non-EVM domains such as
744/// [`DomainId::Solana`] or [`DomainId::StarknetTestnet`], they are `None`
745/// because a trailing-20-byte projection would be misleading.
746///
747/// The same pattern applies to burn-body words: `burn_token_bytes`,
748/// `mint_recipient_bytes`, and `message_sender_bytes` always carry the
749/// canonical 32-byte wire values, while the EVM-shaped address projections are
750/// populated only for the source or destination domains where that projection
751/// is meaningful.
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
753pub struct ParsedV2MessageSummary {
754    pub message_hash: FixedBytes<32>,
755    pub message_len_bytes: usize,
756    pub source_domain: DomainId,
757    pub destination_domain: DomainId,
758    pub message_version: u32,
759    pub body_version: u32,
760    pub nonce: FixedBytes<32>,
761    pub has_placeholder_nonce: bool,
762    /// Canonical 32-byte sender word from the header. Always populated.
763    pub sender_bytes: FixedBytes<32>,
764    /// EVM sender address, populated only when `source_domain.is_evm()`.
765    #[serde(default, skip_serializing_if = "Option::is_none")]
766    pub sender: Option<Address>,
767    /// Canonical 32-byte recipient word from the header. Always populated.
768    pub recipient_bytes: FixedBytes<32>,
769    /// EVM recipient address, populated only when `destination_domain.is_evm()`.
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub recipient: Option<Address>,
772    /// Canonical 32-byte destination caller word. Zero for permissionless messages.
773    pub destination_caller_bytes: FixedBytes<32>,
774    /// EVM destination caller address, populated only when the message is not
775    /// permissionless and `destination_domain.is_evm()`.
776    ///
777    /// A `None` here is therefore ambiguous on its own — use
778    /// `permissionless_relay` to disambiguate. A `None` with
779    /// `permissionless_relay == true` means the message is open to any relayer;
780    /// a `None` with `permissionless_relay == false` means a caller is set but
781    /// the destination is non-EVM, and `destination_caller_bytes` carries the
782    /// canonical value.
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub destination_caller: Option<Address>,
785    pub permissionless_relay: bool,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub requested_finality: Option<FinalityThreshold>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub attested_finality: Option<FinalityThreshold>,
790    /// Canonical 32-byte burn token word from the body. Always populated.
791    pub burn_token_bytes: FixedBytes<32>,
792    /// EVM burn token address, populated only when `source_domain.is_evm()`.
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub burn_token: Option<Address>,
795    /// Canonical 32-byte mint recipient word from the body. Always populated.
796    pub mint_recipient_bytes: FixedBytes<32>,
797    /// EVM mint recipient address, populated only when `destination_domain.is_evm()`.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub mint_recipient: Option<Address>,
800    pub amount: U256,
801    /// Canonical 32-byte message sender word from the body. Always populated.
802    pub message_sender_bytes: FixedBytes<32>,
803    /// EVM message sender address, populated only when `source_domain.is_evm()`.
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub message_sender: Option<Address>,
806    pub max_fee: U256,
807    pub fee_executed: U256,
808    pub expiration_block: U256,
809    #[serde(default, skip_serializing_if = "bytes_is_empty")]
810    pub hook_data: Bytes,
811    pub hook_data_len_bytes: usize,
812    pub has_hooks: bool,
813    pub is_fast_transfer: bool,
814}
815
816impl ParsedV2MessageSummary {
817    /// Parses and summarizes a canonical CCTP v2 transfer message.
818    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseMessageError> {
819        ParsedV2Message::parse(bytes).map(|message| message.summary())
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use alloy_primitives::{address, hex};
827
828    #[test]
829    fn test_message_header_size() {
830        assert_eq!(MessageHeader::SIZE, 148);
831    }
832
833    #[test]
834    fn test_message_header_encode_decode() {
835        let header = MessageHeader::new(
836            1,
837            DomainId::Ethereum,
838            DomainId::Arbitrum,
839            FixedBytes::from([1u8; 32]),
840            FixedBytes::from([2u8; 32]),
841            FixedBytes::from([3u8; 32]),
842            FixedBytes::from([0u8; 32]),
843            1000,
844            1000,
845        );
846
847        let encoded = header.encode();
848        assert_eq!(encoded.len(), MessageHeader::SIZE);
849
850        let decoded = MessageHeader::decode(&encoded).expect("should decode");
851        assert_eq!(header, decoded);
852    }
853
854    #[test]
855    fn test_message_header_decode_too_short() {
856        let short_bytes = vec![0u8; 100];
857        assert!(MessageHeader::decode(&short_bytes).is_none());
858    }
859
860    #[test]
861    fn test_message_header_decode_invalid_domain() {
862        let mut bytes = vec![0u8; MessageHeader::SIZE];
863        bytes[0..4].copy_from_slice(&MessageHeader::SUPPORTED_VERSION.to_be_bytes());
864        // Set invalid source domain ID (999)
865        bytes[4..8].copy_from_slice(&999u32.to_be_bytes());
866        assert!(MessageHeader::decode(&bytes).is_none());
867    }
868
869    #[test]
870    fn test_message_header_rejects_unsupported_version() {
871        let header = MessageHeader::new(
872            MessageHeader::SUPPORTED_VERSION,
873            DomainId::Ethereum,
874            DomainId::Arbitrum,
875            FixedBytes::from([1u8; 32]),
876            FixedBytes::from([2u8; 32]),
877            FixedBytes::from([3u8; 32]),
878            FixedBytes::ZERO,
879            1000,
880            1000,
881        );
882        let mut encoded = header.encode().to_vec();
883        encoded[0..4].copy_from_slice(&2u32.to_be_bytes());
884
885        assert!(
886            MessageHeader::decode(&encoded).is_none(),
887            "decode must reject unsupported message header versions"
888        );
889        let err = MessageHeader::parse(&encoded)
890            .expect_err("parse must reject unsupported message header versions");
891        assert!(
892            err.to_string()
893                .contains("unsupported CCTP v2 message header version 2"),
894            "parse error should name the unsupported header version: {err}"
895        );
896    }
897
898    #[test]
899    fn test_burn_message_v2_new() {
900        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
901        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
902        let amount = U256::from(1000000u64);
903        let sender = address!("1234567890abcdef1234567890abcdef12345678");
904
905        let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender);
906
907        assert_eq!(msg.version, 1);
908        assert_eq!(msg.burn_token, burn_token.into_word());
909        assert_eq!(msg.burn_token_address(), Some(burn_token));
910        assert_eq!(msg.mint_recipient, mint_recipient.into_word());
911        assert_eq!(msg.mint_recipient_address(), Some(mint_recipient));
912        assert_eq!(msg.amount, amount);
913        assert_eq!(msg.message_sender, sender.into_word());
914        assert_eq!(msg.message_sender_address(), Some(sender));
915        assert_eq!(msg.max_fee, U256::ZERO);
916        assert_eq!(msg.fee_executed, U256::ZERO);
917        assert_eq!(msg.expiration_block, U256::ZERO);
918        assert!(msg.hook_data.is_empty());
919        assert!(!msg.has_hooks());
920        assert!(!msg.is_fast_transfer());
921    }
922
923    #[test]
924    fn test_burn_message_v2_fast_transfer() {
925        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
926        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
927        let amount = U256::from(1000000u64);
928        let sender = address!("1234567890abcdef1234567890abcdef12345678");
929        let max_fee = U256::from(100u64);
930
931        let msg = BurnMessageV2::new_with_fast_transfer(
932            burn_token,
933            mint_recipient,
934            amount,
935            sender,
936            max_fee,
937        );
938
939        assert_eq!(msg.max_fee, max_fee);
940        assert!(msg.is_fast_transfer());
941        assert!(!msg.has_hooks());
942    }
943
944    #[test]
945    fn test_burn_message_v2_with_hooks() {
946        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
947        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
948        let amount = U256::from(1000000u64);
949        let sender = address!("1234567890abcdef1234567890abcdef12345678");
950        let hook_data = Bytes::from(vec![1, 2, 3, 4]);
951
952        let msg = BurnMessageV2::new_with_hooks(
953            burn_token,
954            mint_recipient,
955            amount,
956            sender,
957            hook_data.clone(),
958        );
959
960        assert_eq!(msg.hook_data, hook_data);
961        assert!(msg.has_hooks());
962        assert!(!msg.is_fast_transfer());
963    }
964
965    #[test]
966    fn test_burn_message_v2_builder() {
967        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
968        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
969        let amount = U256::from(1000000u64);
970        let sender = address!("1234567890abcdef1234567890abcdef12345678");
971
972        let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender)
973            .with_max_fee(U256::from(100u64))
974            .with_hook_data(Bytes::from(vec![1, 2, 3]))
975            .with_expiration_block(U256::from(1000u64));
976
977        assert!(msg.is_fast_transfer());
978        assert!(msg.has_hooks());
979        assert_eq!(msg.expiration_block, U256::from(1000u64));
980    }
981
982    #[test]
983    fn test_burn_message_v2_encode_decode_roundtrip() {
984        let message = BurnMessageV2::new_with_fast_transfer(
985            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
986            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
987            U256::from(1_000_000u64),
988            address!("1234567890abcdef1234567890abcdef12345678"),
989            U256::from(100u64),
990        )
991        .with_hook_data(Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]))
992        .with_expiration_block(U256::from(12345u64));
993
994        let encoded = message.encode();
995        let decoded = BurnMessageV2::decode(&encoded).expect("burn message should decode");
996
997        assert_eq!(decoded, message);
998        assert_eq!(
999            decoded.encode(),
1000            encoded,
1001            "decode then encode must reproduce the canonical bytes"
1002        );
1003    }
1004
1005    #[test]
1006    fn test_burn_message_v2_rejects_unsupported_version() {
1007        let mut encoded = canonical_burn_body_bytes();
1008        encoded[0..4].copy_from_slice(&9u32.to_be_bytes());
1009
1010        assert!(
1011            BurnMessageV2::decode(&encoded).is_none(),
1012            "decode must reject unsupported burn body versions"
1013        );
1014        let err = BurnMessageV2::parse(&encoded)
1015            .expect_err("parse must reject unsupported burn body versions");
1016        assert!(
1017            err.to_string()
1018                .contains("unsupported CCTP v2 burn message body version 9"),
1019            "parse error should name the unsupported body version: {err}"
1020        );
1021    }
1022
1023    fn canonical_burn_body_bytes() -> Vec<u8> {
1024        BurnMessageV2::new_with_fast_transfer(
1025            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"),
1026            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"),
1027            U256::from(1_000_000u64),
1028            address!("1234567890abcdef1234567890abcdef12345678"),
1029            U256::from(100u64),
1030        )
1031        .encode()
1032        .to_vec()
1033    }
1034
1035    #[test]
1036    fn test_burn_message_v2_preserves_raw_body_words_without_domain_context() {
1037        let mut bytes = canonical_burn_body_bytes();
1038        bytes[4] = 0xff;
1039        bytes[36] = 0xee;
1040        bytes[100] = 0xdd;
1041
1042        let decoded = BurnMessageV2::decode(&bytes)
1043            .expect("body-only decode preserves raw words without domain context");
1044        assert_eq!(decoded.encode().as_ref(), bytes.as_slice());
1045        assert_eq!(decoded.burn_token.as_slice(), &bytes[4..36]);
1046        assert_eq!(decoded.mint_recipient.as_slice(), &bytes[36..68]);
1047        assert_eq!(decoded.message_sender.as_slice(), &bytes[100..132]);
1048        assert_eq!(decoded.burn_token_address(), None);
1049        assert_eq!(decoded.mint_recipient_address(), None);
1050        assert_eq!(decoded.message_sender_address(), None);
1051
1052        let parsed = BurnMessageV2::parse(&bytes)
1053            .expect("body-only parse preserves raw words without domain context");
1054        assert_eq!(parsed, decoded);
1055    }
1056
1057    #[test]
1058    fn test_domain_aware_body_parse_rejects_non_canonical_evm_words() {
1059        // Witness the boundary of the canonical-padding loop: the *last* of
1060        // the 12 leading zero bytes (byte 11 of each address word). The
1061        // first-byte variants are also covered below; this pins the iteration
1062        // bound at 12 (not 11) so a regression that shortened the slice would
1063        // surface here.
1064        for (offset, field, source_domain, destination_domain) in [
1065            (4, "burn_token", DomainId::Ethereum, DomainId::Base),
1066            (36, "mint_recipient", DomainId::Ethereum, DomainId::Base),
1067            (100, "message_sender", DomainId::Ethereum, DomainId::Base),
1068        ] {
1069            for padding_offset in [0, 11] {
1070                let mut bytes = canonical_burn_body_bytes();
1071                bytes[offset + padding_offset] = 0xff;
1072
1073                assert!(
1074                    BurnMessageV2::decode_for_domains(&bytes, source_domain, destination_domain)
1075                        .is_none(),
1076                    "domain-aware decode must reject non-canonical {field} word"
1077                );
1078                let err =
1079                    BurnMessageV2::parse_for_domains(&bytes, source_domain, destination_domain)
1080                        .expect_err("domain-aware parse must reject non-canonical body words");
1081                assert!(
1082                    err.to_string().contains(field),
1083                    "parse error should name the offending field {field}: {err}"
1084                );
1085            }
1086        }
1087    }
1088
1089    #[test]
1090    fn test_parsed_v2_message_rejects_non_canonical_body() {
1091        // Real Circle message reused from the round-trip test; valid as-is, then mutated.
1092        let mut bytes = hex::decode("0000000100000003000000062f3cb13cf4a6103f9e3b256495b08c4e05630fcba639565d199ed420a5f2be010000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000000000000007d0000007d00000000100000000000000000000000075faf114eafb1bdbe2f0316df893fd58ce46aa4d0000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a019200000000000000000000000000000000000000000000000000000000000f42400000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a0192000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap();
1093
1094        // Sanity-check: the canonical form parses round-trip cleanly today.
1095        let canonical = ParsedV2Message::parse(&bytes).expect("canonical message parses");
1096        assert_eq!(canonical.encode().as_ref(), bytes.as_slice());
1097
1098        // Body burn_token word lives at MessageHeader::SIZE + 4; flip a padding byte.
1099        bytes[MessageHeader::SIZE + 4] = 0xff;
1100
1101        assert!(
1102            ParsedV2Message::decode(&bytes).is_none(),
1103            "ParsedV2Message::decode must reject non-canonical body address words"
1104        );
1105        let err = ParsedV2Message::parse(&bytes)
1106            .expect_err("ParsedV2Message::parse must reject non-canonical body address words");
1107        assert!(
1108            err.to_string().contains("burn_token"),
1109            "parse error should name the offending field: {err}"
1110        );
1111
1112        // ParsedV2MessageSummary::parse is the agent/tool-facing entry point;
1113        // it must surface the same per-field rejection.
1114        let summary_err = ParsedV2MessageSummary::parse(&bytes).expect_err(
1115            "ParsedV2MessageSummary::parse must reject non-canonical body address words",
1116        );
1117        assert!(
1118            summary_err.to_string().contains("burn_token"),
1119            "summary parse error should name the offending field: {summary_err}"
1120        );
1121    }
1122
1123    #[test]
1124    fn test_parsed_v2_message_rejects_unsupported_body_version() {
1125        let header = MessageHeader::new(
1126            MessageHeader::SUPPORTED_VERSION,
1127            DomainId::Ethereum,
1128            DomainId::Base,
1129            FixedBytes::from([1u8; 32]),
1130            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word(),
1131            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
1132            FixedBytes::ZERO,
1133            1000,
1134            1000,
1135        );
1136        let mut bytes = header.encode().to_vec();
1137        let mut body = canonical_burn_body_bytes();
1138        body[0..4].copy_from_slice(&9u32.to_be_bytes());
1139        bytes.extend_from_slice(&body);
1140
1141        assert!(
1142            ParsedV2Message::decode(&bytes).is_none(),
1143            "full decode must reject unsupported burn body versions"
1144        );
1145        let err = ParsedV2Message::parse(&bytes)
1146            .expect_err("full parse must reject unsupported burn body versions");
1147        assert!(
1148            err.to_string()
1149                .contains("unsupported CCTP v2 burn message body version 9"),
1150            "parse error should name the unsupported body version: {err}"
1151        );
1152        let summary_err = ParsedV2MessageSummary::parse(&bytes)
1153            .expect_err("summary parse must reject unsupported burn body versions");
1154        assert!(
1155            summary_err
1156                .to_string()
1157                .contains("unsupported CCTP v2 burn message body version 9"),
1158            "summary parse error should name the unsupported body version: {summary_err}"
1159        );
1160    }
1161
1162    #[test]
1163    fn test_message_header_permissionless_helpers() {
1164        let header = MessageHeader::new(
1165            1,
1166            DomainId::Ethereum,
1167            DomainId::Base,
1168            FixedBytes::from([0u8; 32]),
1169            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word(),
1170            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
1171            FixedBytes::ZERO,
1172            FinalityThreshold::Fast.as_u32(),
1173            FinalityThreshold::Standard.as_u32(),
1174        );
1175
1176        assert!(header.has_placeholder_nonce());
1177        assert!(header.is_permissionless());
1178        assert_eq!(
1179            header.sender_address(),
1180            Some(address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"))
1181        );
1182        assert_eq!(
1183            header.recipient_address(),
1184            Some(address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"))
1185        );
1186        assert_eq!(header.requested_finality(), Some(FinalityThreshold::Fast));
1187        assert_eq!(
1188            header.attested_finality(),
1189            Some(FinalityThreshold::Standard)
1190        );
1191        assert_eq!(header.destination_caller_address(), None);
1192    }
1193
1194    #[test]
1195    fn test_parsed_v2_message_from_real_circle_message() {
1196        let raw_message = hex::decode("0000000100000003000000062f3cb13cf4a6103f9e3b256495b08c4e05630fcba639565d199ed420a5f2be010000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000000000000007d0000007d00000000100000000000000000000000075faf114eafb1bdbe2f0316df893fd58ce46aa4d0000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a019200000000000000000000000000000000000000000000000000000000000f42400000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a0192000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap();
1197
1198        let parsed = ParsedV2Message::parse(&raw_message).expect("message should parse");
1199        let summary = parsed.summary();
1200
1201        assert_eq!(parsed.header.source_domain, DomainId::Arbitrum);
1202        assert_eq!(parsed.header.destination_domain, DomainId::Base);
1203        assert!(!parsed.header.has_placeholder_nonce());
1204        assert_eq!(
1205            parsed.header.requested_finality(),
1206            Some(FinalityThreshold::Standard)
1207        );
1208        assert_eq!(
1209            parsed.header.attested_finality(),
1210            Some(FinalityThreshold::Standard)
1211        );
1212        assert_eq!(
1213            parsed.body.burn_token,
1214            address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word()
1215        );
1216        assert_eq!(
1217            parsed.body.burn_token_address(),
1218            Some(address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D"))
1219        );
1220        assert_eq!(
1221            parsed.body.mint_recipient,
1222            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word()
1223        );
1224        assert_eq!(
1225            parsed.body.mint_recipient_address(),
1226            Some(address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"))
1227        );
1228        assert_eq!(parsed.body.amount, U256::from(1_000_000u64));
1229        assert_eq!(
1230            parsed.body.message_sender,
1231            address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word()
1232        );
1233        assert_eq!(
1234            parsed.body.message_sender_address(),
1235            Some(address!("7F7D081724F0240c64C9E01CDe4626602f9a0192"))
1236        );
1237        assert_eq!(parsed.body.max_fee, U256::ZERO);
1238        assert_eq!(parsed.body.fee_executed, U256::ZERO);
1239        assert_eq!(parsed.body.expiration_block, U256::ZERO);
1240        assert!(parsed.body.hook_data.is_empty());
1241        assert_eq!(parsed.encode().as_ref(), raw_message.as_slice());
1242        assert_eq!(
1243            parsed.message_hash(),
1244            alloy_primitives::keccak256(&raw_message)
1245        );
1246        assert_eq!(
1247            summary.message_hash,
1248            alloy_primitives::keccak256(&raw_message)
1249        );
1250        assert!(summary.permissionless_relay);
1251        assert!(!summary.has_hooks);
1252        assert!(!summary.is_fast_transfer);
1253
1254        let json = serde_json::to_value(&summary).expect("summary should serialize");
1255        let round_tripped: ParsedV2MessageSummary =
1256            serde_json::from_value(json).expect("summary should round-trip");
1257        assert_eq!(round_tripped, summary);
1258    }
1259
1260    #[test]
1261    fn test_parsed_v2_message_summary_omits_empty_optionals() {
1262        let summary = ParsedV2MessageSummary {
1263            message_hash: FixedBytes::from([0x11; 32]),
1264            message_len_bytes: 376,
1265            source_domain: DomainId::Ethereum,
1266            destination_domain: DomainId::Base,
1267            message_version: 1,
1268            body_version: 1,
1269            nonce: FixedBytes::from([0x22; 32]),
1270            has_placeholder_nonce: false,
1271            sender_bytes: address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word(),
1272            sender: Some(address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D")),
1273            recipient_bytes: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
1274            recipient: Some(address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")),
1275            destination_caller_bytes: FixedBytes::ZERO,
1276            destination_caller: None,
1277            permissionless_relay: true,
1278            requested_finality: Some(FinalityThreshold::Standard),
1279            attested_finality: Some(FinalityThreshold::Standard),
1280            burn_token_bytes: address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D").into_word(),
1281            burn_token: Some(address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D")),
1282            mint_recipient_bytes: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
1283            mint_recipient: Some(address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")),
1284            amount: U256::from(1_000_000u64),
1285            message_sender_bytes: address!("7F7D081724F0240c64C9E01CDe4626602f9a0192").into_word(),
1286            message_sender: Some(address!("7F7D081724F0240c64C9E01CDe4626602f9a0192")),
1287            max_fee: U256::ZERO,
1288            fee_executed: U256::ZERO,
1289            expiration_block: U256::ZERO,
1290            hook_data: Bytes::new(),
1291            hook_data_len_bytes: 0,
1292            has_hooks: false,
1293            is_fast_transfer: false,
1294        };
1295
1296        let json = serde_json::to_value(summary).expect("summary should serialize");
1297        assert!(json.get("destination_caller").is_none());
1298        assert!(json.get("hook_data").is_none());
1299    }
1300
1301    #[test]
1302    fn test_summary_drops_evm_address_for_non_evm_source() {
1303        let solana_sender_word = FixedBytes::<32>::from([0xABu8; 32]);
1304        let solana_burn_token_word = FixedBytes::<32>::from([0xCDu8; 32]);
1305        let solana_message_sender_word = FixedBytes::<32>::from([0xEFu8; 32]);
1306        let recipient = address!("7F7D081724F0240c64C9E01CDe4626602f9a0192");
1307
1308        let header = MessageHeader::new(
1309            1,
1310            DomainId::Solana,
1311            DomainId::Base,
1312            FixedBytes::from([0x11u8; 32]),
1313            solana_sender_word,
1314            recipient.into_word(),
1315            FixedBytes::ZERO,
1316            FinalityThreshold::Standard.as_u32(),
1317            FinalityThreshold::Standard.as_u32(),
1318        );
1319        let body = BurnMessageV2 {
1320            version: 1,
1321            burn_token: solana_burn_token_word,
1322            mint_recipient: recipient.into_word(),
1323            amount: U256::from(1_000_000u64),
1324            message_sender: solana_message_sender_word,
1325            max_fee: U256::ZERO,
1326            fee_executed: U256::ZERO,
1327            expiration_block: U256::ZERO,
1328            hook_data: Bytes::new(),
1329        };
1330        let message = ParsedV2Message { header, body };
1331        let encoded = message.encode();
1332        let parsed = ParsedV2Message::parse(&encoded)
1333            .expect("non-EVM source body words must parse and round-trip");
1334        assert_eq!(parsed.encode(), encoded);
1335
1336        assert_eq!(parsed.header.sender_address(), None);
1337        assert_eq!(parsed.header.recipient_address(), Some(recipient));
1338        assert_eq!(parsed.body.burn_token, solana_burn_token_word);
1339        assert_eq!(parsed.body.burn_token_address(), None);
1340        assert_eq!(parsed.body.message_sender, solana_message_sender_word);
1341        assert_eq!(parsed.body.message_sender_address(), None);
1342        assert_eq!(parsed.body.mint_recipient_address(), Some(recipient));
1343
1344        let summary = parsed.summary();
1345        assert_eq!(summary.sender, None);
1346        assert_eq!(summary.sender_bytes, solana_sender_word);
1347        assert_eq!(summary.recipient, Some(recipient));
1348        assert_eq!(summary.recipient_bytes, recipient.into_word());
1349        assert_eq!(summary.burn_token, None);
1350        assert_eq!(summary.burn_token_bytes, solana_burn_token_word);
1351        assert_eq!(summary.message_sender, None);
1352        assert_eq!(summary.message_sender_bytes, solana_message_sender_word);
1353        assert_eq!(summary.mint_recipient, Some(recipient));
1354        assert_eq!(summary.mint_recipient_bytes, recipient.into_word());
1355
1356        let json = serde_json::to_value(&summary).expect("summary should serialize");
1357        assert!(
1358            json.get("sender").is_none(),
1359            "EVM sender field should be omitted for non-EVM source domain"
1360        );
1361        assert!(
1362            json.get("burn_token").is_none(),
1363            "EVM burn_token field should be omitted for non-EVM source domain"
1364        );
1365        assert!(
1366            json.get("message_sender").is_none(),
1367            "EVM message_sender field should be omitted for non-EVM source domain"
1368        );
1369        assert_eq!(
1370            json["sender_bytes"].as_str(),
1371            Some(format!("0x{}", hex::encode(solana_sender_word)).as_str())
1372        );
1373
1374        let round_tripped: ParsedV2MessageSummary =
1375            serde_json::from_value(json).expect("non-EVM summary should round-trip");
1376        assert_eq!(round_tripped, summary);
1377        assert_eq!(round_tripped.sender, None);
1378        assert_eq!(round_tripped.sender_bytes, solana_sender_word);
1379        assert_eq!(round_tripped.burn_token, None);
1380        assert_eq!(round_tripped.burn_token_bytes, solana_burn_token_word);
1381    }
1382
1383    #[test]
1384    fn test_summary_drops_evm_address_for_non_evm_destination() {
1385        let sender = address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D");
1386        let starknet_recipient_word = FixedBytes::<32>::from([0x42u8; 32]);
1387        let starknet_caller_word = FixedBytes::<32>::from([0x77u8; 32]);
1388        let starknet_mint_recipient_word = FixedBytes::<32>::from([0x99u8; 32]);
1389
1390        let header = MessageHeader::new(
1391            1,
1392            DomainId::Ethereum,
1393            DomainId::StarknetTestnet,
1394            FixedBytes::from([0x22u8; 32]),
1395            sender.into_word(),
1396            starknet_recipient_word,
1397            starknet_caller_word,
1398            FinalityThreshold::Standard.as_u32(),
1399            FinalityThreshold::Standard.as_u32(),
1400        );
1401        let body = BurnMessageV2 {
1402            version: 1,
1403            burn_token: address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF").into_word(),
1404            mint_recipient: starknet_mint_recipient_word,
1405            amount: U256::from(1_000_000u64),
1406            message_sender: sender.into_word(),
1407            max_fee: U256::ZERO,
1408            fee_executed: U256::ZERO,
1409            expiration_block: U256::ZERO,
1410            hook_data: Bytes::new(),
1411        };
1412        let message = ParsedV2Message { header, body };
1413        let parsed = ParsedV2Message::parse(&message.encode())
1414            .expect("non-EVM destination mint recipient words must parse");
1415
1416        assert!(!parsed.header.is_permissionless());
1417        assert_eq!(parsed.header.sender_address(), Some(sender));
1418        assert_eq!(parsed.header.recipient_address(), None);
1419        assert_eq!(parsed.header.destination_caller_address(), None);
1420        assert_eq!(parsed.body.mint_recipient, starknet_mint_recipient_word);
1421        assert_eq!(parsed.body.mint_recipient_address(), None);
1422
1423        let summary = parsed.summary();
1424        assert_eq!(summary.sender, Some(sender));
1425        assert_eq!(summary.recipient, None);
1426        assert_eq!(summary.recipient_bytes, starknet_recipient_word);
1427        assert_eq!(summary.destination_caller, None);
1428        assert_eq!(summary.destination_caller_bytes, starknet_caller_word);
1429        assert!(!summary.permissionless_relay);
1430        assert_eq!(
1431            summary.burn_token,
1432            Some(address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF"))
1433        );
1434        assert_eq!(summary.mint_recipient, None);
1435        assert_eq!(summary.mint_recipient_bytes, starknet_mint_recipient_word);
1436        assert_eq!(summary.message_sender, Some(sender));
1437
1438        let json = serde_json::to_value(&summary).expect("summary should serialize");
1439        assert!(
1440            json.get("recipient").is_none(),
1441            "EVM recipient field should be omitted for non-EVM destination domain"
1442        );
1443        assert!(
1444            json.get("mint_recipient").is_none(),
1445            "EVM mint_recipient field should be omitted for non-EVM destination domain"
1446        );
1447        assert!(
1448            json.get("destination_caller").is_none(),
1449            "EVM destination_caller field should be omitted for non-EVM destination domain"
1450        );
1451    }
1452
1453    #[test]
1454    fn test_summary_keeps_caller_bytes_for_non_permissionless_non_evm_destination() {
1455        let sender = address!("75FaF114EAFb1bdbE2f0316Df893Fd58ce46AA4D");
1456        let solana_recipient_word = FixedBytes::<32>::from([0x33u8; 32]);
1457        let solana_caller_word = FixedBytes::<32>::from([0x44u8; 32]);
1458
1459        let header = MessageHeader::new(
1460            1,
1461            DomainId::Ethereum,
1462            DomainId::Solana,
1463            FixedBytes::from([0x55u8; 32]),
1464            sender.into_word(),
1465            solana_recipient_word,
1466            solana_caller_word,
1467            FinalityThreshold::Standard.as_u32(),
1468            FinalityThreshold::Standard.as_u32(),
1469        );
1470        let body = BurnMessageV2::new(
1471            address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF"),
1472            address!("1111111111111111111111111111111111111111"),
1473            U256::from(2_500_000u64),
1474            sender,
1475        );
1476        let message = ParsedV2Message { header, body };
1477
1478        let summary = message.summary();
1479        assert_eq!(summary.recipient, None);
1480        assert_eq!(summary.recipient_bytes, solana_recipient_word);
1481        assert_eq!(summary.destination_caller, None);
1482        assert_eq!(summary.destination_caller_bytes, solana_caller_word);
1483        assert!(
1484            !summary.permissionless_relay,
1485            "non-zero caller word must not be reported as permissionless even \
1486             when the destination is non-EVM and destination_caller is None"
1487        );
1488    }
1489}