1use 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
52fn 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct MessageHeader {
145 pub version: u32,
147 pub source_domain: DomainId,
149 pub destination_domain: DomainId,
151 pub nonce: FixedBytes<32>,
153 pub sender: FixedBytes<32>,
155 pub recipient: FixedBytes<32>,
157 pub destination_caller: FixedBytes<32>,
159 pub min_finality_threshold: u32,
161 pub finality_threshold_executed: u32,
163}
164
165impl MessageHeader {
166 pub const SIZE: usize = 148;
168 pub const SUPPORTED_VERSION: u32 = CCTP_V2_MESSAGE_VERSION;
170
171 #[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 pub fn encode(&self) -> Bytes {
201 let mut bytes = Vec::with_capacity(Self::SIZE);
202
203 bytes.extend_from_slice(&self.version.to_be_bytes());
205 bytes.extend_from_slice(&self.source_domain.as_u32().to_be_bytes());
207 bytes.extend_from_slice(&self.destination_domain.as_u32().to_be_bytes());
209 bytes.extend_from_slice(self.nonce.as_slice());
211 bytes.extend_from_slice(self.sender.as_slice());
213 bytes.extend_from_slice(self.recipient.as_slice());
215 bytes.extend_from_slice(self.destination_caller.as_slice());
217 bytes.extend_from_slice(&self.min_finality_threshold.to_be_bytes());
219 bytes.extend_from_slice(&self.finality_threshold_executed.to_be_bytes());
221
222 Bytes::from(bytes)
223 }
224
225 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 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 pub fn has_placeholder_nonce(&self) -> bool {
284 self.nonce.as_slice().iter().all(|byte| *byte == 0)
285 }
286
287 #[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 #[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 #[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 pub fn is_permissionless(&self) -> bool {
329 self.destination_caller
330 .as_slice()
331 .iter()
332 .all(|byte| *byte == 0)
333 }
334
335 #[must_use]
337 pub fn requested_finality(&self) -> Option<FinalityThreshold> {
338 FinalityThreshold::from_u32(self.min_finality_threshold)
339 }
340
341 #[must_use]
343 pub fn attested_finality(&self) -> Option<FinalityThreshold> {
344 FinalityThreshold::from_u32(self.finality_threshold_executed)
345 }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct BurnMessageV2 {
368 pub version: u32,
370 pub burn_token: FixedBytes<32>,
372 pub mint_recipient: FixedBytes<32>,
374 pub amount: U256,
376 pub message_sender: FixedBytes<32>,
378 pub max_fee: U256,
380 pub fee_executed: U256,
382 pub expiration_block: U256,
384 pub hook_data: Bytes,
386}
387
388impl BurnMessageV2 {
389 pub const MIN_SIZE: usize = 228;
391 pub const SUPPORTED_VERSION: u32 = CCTP_V2_BURN_BODY_VERSION;
393
394 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 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 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 pub fn with_hook_data(mut self, hook_data: Bytes) -> Self {
458 self.hook_data = hook_data;
459 self
460 }
461
462 pub fn with_max_fee(mut self, max_fee: U256) -> Self {
464 self.max_fee = max_fee;
465 self
466 }
467
468 pub fn with_expiration_block(mut self, expiration_block: U256) -> Self {
470 self.expiration_block = expiration_block;
471 self
472 }
473
474 #[must_use]
476 pub fn burn_token_address(&self) -> Option<Address> {
477 decode_address_word(self.burn_token.as_slice())
478 }
479
480 #[must_use]
482 pub fn mint_recipient_address(&self) -> Option<Address> {
483 decode_address_word(self.mint_recipient.as_slice())
484 }
485
486 #[must_use]
488 pub fn message_sender_address(&self) -> Option<Address> {
489 decode_address_word(self.message_sender.as_slice())
490 }
491
492 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 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 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 pub fn has_hooks(&self) -> bool {
594 !self.hook_data.is_empty()
595 }
596
597 pub fn is_fast_transfer(&self) -> bool {
599 self.max_fee > U256::ZERO
600 }
601}
602
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608pub struct ParsedV2Message {
609 pub header: MessageHeader,
610 pub body: BurnMessageV2,
611}
612
613impl ParsedV2Message {
614 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 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 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 #[must_use]
661 pub fn message_hash(&self) -> FixedBytes<32> {
662 alloy_primitives::keccak256(self.encode())
663 }
664
665 #[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#[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 pub sender_bytes: FixedBytes<32>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub sender: Option<Address>,
767 pub recipient_bytes: FixedBytes<32>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub recipient: Option<Address>,
772 pub destination_caller_bytes: FixedBytes<32>,
774 #[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 pub burn_token_bytes: FixedBytes<32>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub burn_token: Option<Address>,
795 pub mint_recipient_bytes: FixedBytes<32>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub mint_recipient: Option<Address>,
800 pub amount: U256,
801 pub message_sender_bytes: FixedBytes<32>,
803 #[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 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 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 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 let mut bytes = hex::decode("0000000100000003000000062f3cb13cf4a6103f9e3b256495b08c4e05630fcba639565d199ed420a5f2be010000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000008fe6b999dc680ccfdd5bf7eb0974218be2542daa0000000000000000000000000000000000000000000000000000000000000000000007d0000007d00000000100000000000000000000000075faf114eafb1bdbe2f0316df893fd58ce46aa4d0000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a019200000000000000000000000000000000000000000000000000000000000f42400000000000000000000000007f7d081724f0240c64c9e01cde4626602f9a0192000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap();
1093
1094 let canonical = ParsedV2Message::parse(&bytes).expect("canonical message parses");
1096 assert_eq!(canonical.encode().as_ref(), bytes.as_slice());
1097
1098 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 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}