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};
12
13use super::DomainId;
14
15/// CCTP v2 Message Header
16///
17/// The message header contains metadata about cross-chain messages,
18/// including source/destination domains, finality requirements, and routing.
19///
20/// # Format
21///
22/// - version: uint32 (4 bytes)
23/// - sourceDomain: uint32 (4 bytes)
24/// - destinationDomain: uint32 (4 bytes)
25/// - nonce: bytes32 (32 bytes) - unique identifier assigned by Circle
26/// - sender: bytes32 (32 bytes) - message sender address
27/// - recipient: bytes32 (32 bytes) - message recipient address
28/// - destinationCaller: bytes32 (32 bytes) - authorized caller on destination
29/// - minFinalityThreshold: uint32 (4 bytes) - minimum required finality
30/// - finalityThresholdExecuted: uint32 (4 bytes) - actual finality level
31///
32/// Total fixed size: 4 + 4 + 4 + 32 + 32 + 32 + 32 + 4 + 4 = 148 bytes
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct MessageHeader {
35    /// Message format version
36    pub version: u32,
37    /// Source blockchain domain ID
38    pub source_domain: DomainId,
39    /// Destination blockchain domain ID
40    pub destination_domain: DomainId,
41    /// Unique message nonce assigned by Circle
42    pub nonce: FixedBytes<32>,
43    /// Address that sent the message (padded to 32 bytes)
44    pub sender: FixedBytes<32>,
45    /// Address that will receive the message (padded to 32 bytes)
46    pub recipient: FixedBytes<32>,
47    /// Address authorized to call receiveMessage on destination (0 = anyone)
48    pub destination_caller: FixedBytes<32>,
49    /// Minimum finality threshold required (1000 = Fast, 2000 = Standard)
50    pub min_finality_threshold: u32,
51    /// Actual finality threshold when message was attested
52    pub finality_threshold_executed: u32,
53}
54
55impl MessageHeader {
56    /// Size of the message header in bytes
57    pub const SIZE: usize = 148;
58
59    /// Creates a new message header
60    #[allow(clippy::too_many_arguments)]
61    pub fn new(
62        version: u32,
63        source_domain: DomainId,
64        destination_domain: DomainId,
65        nonce: FixedBytes<32>,
66        sender: FixedBytes<32>,
67        recipient: FixedBytes<32>,
68        destination_caller: FixedBytes<32>,
69        min_finality_threshold: u32,
70        finality_threshold_executed: u32,
71    ) -> Self {
72        Self {
73            version,
74            source_domain,
75            destination_domain,
76            nonce,
77            sender,
78            recipient,
79            destination_caller,
80            min_finality_threshold,
81            finality_threshold_executed,
82        }
83    }
84
85    /// Encodes the message header to bytes
86    ///
87    /// The encoding follows Circle's v2 message format specification.
88    pub fn encode(&self) -> Bytes {
89        let mut bytes = Vec::with_capacity(Self::SIZE);
90
91        // version (4 bytes)
92        bytes.extend_from_slice(&self.version.to_be_bytes());
93        // sourceDomain (4 bytes)
94        bytes.extend_from_slice(&self.source_domain.as_u32().to_be_bytes());
95        // destinationDomain (4 bytes)
96        bytes.extend_from_slice(&self.destination_domain.as_u32().to_be_bytes());
97        // nonce (32 bytes)
98        bytes.extend_from_slice(self.nonce.as_slice());
99        // sender (32 bytes)
100        bytes.extend_from_slice(self.sender.as_slice());
101        // recipient (32 bytes)
102        bytes.extend_from_slice(self.recipient.as_slice());
103        // destinationCaller (32 bytes)
104        bytes.extend_from_slice(self.destination_caller.as_slice());
105        // minFinalityThreshold (4 bytes)
106        bytes.extend_from_slice(&self.min_finality_threshold.to_be_bytes());
107        // finalityThresholdExecuted (4 bytes)
108        bytes.extend_from_slice(&self.finality_threshold_executed.to_be_bytes());
109
110        Bytes::from(bytes)
111    }
112
113    /// Decodes a message header from bytes
114    ///
115    /// Returns `None` if the bytes are not at least [`MessageHeader::SIZE`] bytes long
116    /// or if domain IDs are invalid.
117    pub fn decode(bytes: &[u8]) -> Option<Self> {
118        if bytes.len() < Self::SIZE {
119            return None;
120        }
121
122        let version = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
123
124        let source_domain = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
125        let source_domain = DomainId::from_u32(source_domain)?;
126
127        let destination_domain = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
128        let destination_domain = DomainId::from_u32(destination_domain)?;
129
130        let nonce = FixedBytes::from_slice(&bytes[12..44]);
131        let sender = FixedBytes::from_slice(&bytes[44..76]);
132        let recipient = FixedBytes::from_slice(&bytes[76..108]);
133        let destination_caller = FixedBytes::from_slice(&bytes[108..140]);
134
135        let min_finality_threshold =
136            u32::from_be_bytes([bytes[140], bytes[141], bytes[142], bytes[143]]);
137        let finality_threshold_executed =
138            u32::from_be_bytes([bytes[144], bytes[145], bytes[146], bytes[147]]);
139
140        Some(Self {
141            version,
142            source_domain,
143            destination_domain,
144            nonce,
145            sender,
146            recipient,
147            destination_caller,
148            min_finality_threshold,
149            finality_threshold_executed,
150        })
151    }
152}
153
154/// CCTP v2 Burn Message Body
155///
156/// The burn message body contains information about a token burn operation
157/// for cross-chain USDC transfers.
158///
159/// # Format
160///
161/// - version: uint32 (4 bytes)
162/// - burnToken: bytes32 (32 bytes) - address of token being burned
163/// - mintRecipient: bytes32 (32 bytes) - address to receive minted tokens
164/// - amount: uint256 (32 bytes) - amount being transferred
165/// - messageSender: bytes32 (32 bytes) - original sender address
166/// - maxFee: uint256 (32 bytes) - maximum fee willing to pay
167/// - feeExecuted: uint256 (32 bytes) - actual fee charged
168/// - expirationBlock: uint256 (32 bytes) - block number when message expires
169/// - hookData: dynamic bytes - arbitrary data for destination chain hooks
170///
171/// Total fixed size: 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 = 228 bytes + dynamic hookData
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct BurnMessageV2 {
174    /// Message body version
175    pub version: u32,
176    /// Address of the token being burned (USDC contract)
177    pub burn_token: Address,
178    /// Address that will receive minted tokens on destination chain
179    pub mint_recipient: Address,
180    /// Amount of tokens being transferred (in wei/smallest unit)
181    pub amount: U256,
182    /// Address of the original message sender
183    pub message_sender: Address,
184    /// Maximum fee the sender is willing to pay (for Fast Transfers)
185    pub max_fee: U256,
186    /// Actual fee that was charged
187    pub fee_executed: U256,
188    /// Block number after which the message expires (anti-replay protection)
189    pub expiration_block: U256,
190    /// Optional hook data for programmable transfers
191    pub hook_data: Bytes,
192}
193
194impl BurnMessageV2 {
195    /// Minimum size of the burn message body in bytes (without hookData)
196    pub const MIN_SIZE: usize = 228;
197
198    /// Creates a new burn message with standard settings (no fast transfer, no hooks)
199    pub fn new(
200        burn_token: Address,
201        mint_recipient: Address,
202        amount: U256,
203        message_sender: Address,
204    ) -> Self {
205        Self {
206            version: 1,
207            burn_token,
208            mint_recipient,
209            amount,
210            message_sender,
211            max_fee: U256::ZERO,
212            fee_executed: U256::ZERO,
213            expiration_block: U256::ZERO,
214            hook_data: Bytes::new(),
215        }
216    }
217
218    /// Creates a new burn message with fast transfer settings
219    pub fn new_with_fast_transfer(
220        burn_token: Address,
221        mint_recipient: Address,
222        amount: U256,
223        message_sender: Address,
224        max_fee: U256,
225    ) -> Self {
226        Self {
227            version: 1,
228            burn_token,
229            mint_recipient,
230            amount,
231            message_sender,
232            max_fee,
233            fee_executed: U256::ZERO,
234            expiration_block: U256::ZERO,
235            hook_data: Bytes::new(),
236        }
237    }
238
239    /// Creates a new burn message with hook data
240    pub fn new_with_hooks(
241        burn_token: Address,
242        mint_recipient: Address,
243        amount: U256,
244        message_sender: Address,
245        hook_data: Bytes,
246    ) -> Self {
247        Self {
248            version: 1,
249            burn_token,
250            mint_recipient,
251            amount,
252            message_sender,
253            max_fee: U256::ZERO,
254            fee_executed: U256::ZERO,
255            expiration_block: U256::ZERO,
256            hook_data,
257        }
258    }
259
260    /// Sets the hook data for this message
261    pub fn with_hook_data(mut self, hook_data: Bytes) -> Self {
262        self.hook_data = hook_data;
263        self
264    }
265
266    /// Sets the maximum fee for fast transfer
267    pub fn with_max_fee(mut self, max_fee: U256) -> Self {
268        self.max_fee = max_fee;
269        self
270    }
271
272    /// Sets the expiration block
273    pub fn with_expiration_block(mut self, expiration_block: U256) -> Self {
274        self.expiration_block = expiration_block;
275        self
276    }
277
278    /// Returns true if this message has hook data
279    pub fn has_hooks(&self) -> bool {
280        !self.hook_data.is_empty()
281    }
282
283    /// Returns true if this message is configured for fast transfer (max_fee > 0)
284    pub fn is_fast_transfer(&self) -> bool {
285        self.max_fee > U256::ZERO
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use alloy_primitives::address;
293
294    #[test]
295    fn test_message_header_size() {
296        assert_eq!(MessageHeader::SIZE, 148);
297    }
298
299    #[test]
300    fn test_message_header_encode_decode() {
301        let header = MessageHeader::new(
302            1,
303            DomainId::Ethereum,
304            DomainId::Arbitrum,
305            FixedBytes::from([1u8; 32]),
306            FixedBytes::from([2u8; 32]),
307            FixedBytes::from([3u8; 32]),
308            FixedBytes::from([0u8; 32]),
309            1000,
310            1000,
311        );
312
313        let encoded = header.encode();
314        assert_eq!(encoded.len(), MessageHeader::SIZE);
315
316        let decoded = MessageHeader::decode(&encoded).expect("should decode");
317        assert_eq!(header, decoded);
318    }
319
320    #[test]
321    fn test_message_header_decode_too_short() {
322        let short_bytes = vec![0u8; 100];
323        assert!(MessageHeader::decode(&short_bytes).is_none());
324    }
325
326    #[test]
327    fn test_message_header_decode_invalid_domain() {
328        let mut bytes = vec![0u8; MessageHeader::SIZE];
329        // Set invalid source domain ID (999)
330        bytes[4..8].copy_from_slice(&999u32.to_be_bytes());
331        assert!(MessageHeader::decode(&bytes).is_none());
332    }
333
334    #[test]
335    fn test_burn_message_v2_new() {
336        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
337        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
338        let amount = U256::from(1000000u64);
339        let sender = address!("1234567890abcdef1234567890abcdef12345678");
340
341        let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender);
342
343        assert_eq!(msg.version, 1);
344        assert_eq!(msg.burn_token, burn_token);
345        assert_eq!(msg.mint_recipient, mint_recipient);
346        assert_eq!(msg.amount, amount);
347        assert_eq!(msg.message_sender, sender);
348        assert_eq!(msg.max_fee, U256::ZERO);
349        assert_eq!(msg.fee_executed, U256::ZERO);
350        assert_eq!(msg.expiration_block, U256::ZERO);
351        assert!(msg.hook_data.is_empty());
352        assert!(!msg.has_hooks());
353        assert!(!msg.is_fast_transfer());
354    }
355
356    #[test]
357    fn test_burn_message_v2_fast_transfer() {
358        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
359        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
360        let amount = U256::from(1000000u64);
361        let sender = address!("1234567890abcdef1234567890abcdef12345678");
362        let max_fee = U256::from(100u64);
363
364        let msg = BurnMessageV2::new_with_fast_transfer(
365            burn_token,
366            mint_recipient,
367            amount,
368            sender,
369            max_fee,
370        );
371
372        assert_eq!(msg.max_fee, max_fee);
373        assert!(msg.is_fast_transfer());
374        assert!(!msg.has_hooks());
375    }
376
377    #[test]
378    fn test_burn_message_v2_with_hooks() {
379        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
380        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
381        let amount = U256::from(1000000u64);
382        let sender = address!("1234567890abcdef1234567890abcdef12345678");
383        let hook_data = Bytes::from(vec![1, 2, 3, 4]);
384
385        let msg = BurnMessageV2::new_with_hooks(
386            burn_token,
387            mint_recipient,
388            amount,
389            sender,
390            hook_data.clone(),
391        );
392
393        assert_eq!(msg.hook_data, hook_data);
394        assert!(msg.has_hooks());
395        assert!(!msg.is_fast_transfer());
396    }
397
398    #[test]
399    fn test_burn_message_v2_builder() {
400        let burn_token = address!("A2d2a41577ce14e20a6c2de999A8Ec2BD9fe34aF");
401        let mint_recipient = address!("742d35Cc6634C0532925a3b844Bc9e7595f8fA0d");
402        let amount = U256::from(1000000u64);
403        let sender = address!("1234567890abcdef1234567890abcdef12345678");
404
405        let msg = BurnMessageV2::new(burn_token, mint_recipient, amount, sender)
406            .with_max_fee(U256::from(100u64))
407            .with_hook_data(Bytes::from(vec![1, 2, 3]))
408            .with_expiration_block(U256::from(1000u64));
409
410        assert!(msg.is_fast_transfer());
411        assert!(msg.has_hooks());
412        assert_eq!(msg.expiration_block, U256::from(1000u64));
413    }
414}