cctp_rs/protocol/
attestation.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use alloy_primitives::{hex::FromHex, Bytes};
6use serde::{Deserialize, Deserializer};
7
8/// The bytes of the attestation.
9pub type AttestationBytes = Vec<u8>;
10
11// ============================================================================
12// V2 Attestation Response Types
13// ============================================================================
14
15/// Represents the response from the CCTP v2 attestation API
16///
17/// The v2 API uses a different endpoint format (`/v2/messages/{domain}?transactionHash={tx}`)
18/// and returns a wrapper containing an array of messages, since a single transaction
19/// can emit multiple `MessageSent` events.
20///
21/// # Example Response
22///
23/// ```json
24/// {
25///   "messages": [
26///     {
27///       "status": "complete",
28///       "message": "0x...",
29///       "attestation": "0x..."
30///     }
31///   ]
32/// }
33/// ```
34#[derive(Debug, Deserialize)]
35pub struct V2AttestationResponse {
36    /// Array of messages from the transaction
37    pub messages: Vec<V2Message>,
38}
39
40/// Represents a single message in the v2 attestation response
41///
42/// Each message contains the attestation status, the original message bytes,
43/// and the signed attestation (when complete).
44#[derive(Debug, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct V2Message {
47    /// Status of the attestation
48    pub status: AttestationStatus,
49
50    /// The original message bytes from the MessageSent event
51    #[serde(default, deserialize_with = "deserialize_optional_bytes_or_pending")]
52    pub message: Option<Bytes>,
53
54    /// The signed attestation bytes (null/PENDING until complete)
55    #[serde(default, deserialize_with = "deserialize_optional_bytes_or_pending")]
56    pub attestation: Option<Bytes>,
57}
58
59// ============================================================================
60// V1 Attestation Response Types
61// ============================================================================
62
63/// Represents the response from the attestation service
64///
65/// It contains the status of the attestation and optionally the attestation data itself.
66/// The attestation data is a hex-encoded string (with or without "0x" prefix) that is
67/// automatically deserialized into bytes.
68///
69/// **API Quirk**: Circle's Iris API sometimes returns the string `"PENDING"` for the
70/// attestation field instead of `null` when the attestation is not yet ready. This
71/// deserializer handles that case gracefully by treating "PENDING" as `None`.
72#[derive(Debug, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct AttestationResponse {
75    pub status: AttestationStatus,
76    #[serde(default, deserialize_with = "deserialize_optional_bytes_or_pending")]
77    pub attestation: Option<Bytes>,
78}
79
80/// Custom deserializer that handles Circle API quirk where attestation field
81/// may be the string "PENDING" instead of null
82///
83/// Handles the following cases:
84/// - Valid hex string (with or without "0x") → deserializes to `Some(Bytes)`
85/// - "PENDING" or "pending" → returns `None`
86/// - null or missing field → returns `None`
87/// - Empty string → returns `None`
88/// - Invalid hex → returns error
89fn deserialize_optional_bytes_or_pending<'de, D>(deserializer: D) -> Result<Option<Bytes>, D::Error>
90where
91    D: Deserializer<'de>,
92{
93    let opt: Option<String> = Option::deserialize(deserializer)?;
94
95    match opt {
96        None => Ok(None),
97        Some(s) if s.is_empty() => Ok(None),
98        Some(s) if s.eq_ignore_ascii_case("pending") => Ok(None),
99        Some(s) => {
100            let bytes = Bytes::from_hex(s).map_err(serde::de::Error::custom)?;
101            Ok(Some(bytes))
102        }
103    }
104}
105
106/// Represents the status of the attestation.
107#[derive(Debug, Deserialize, PartialEq)]
108#[serde(rename_all = "snake_case")]
109pub enum AttestationStatus {
110    Complete,
111    Pending,
112    PendingConfirmations,
113    Failed,
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_deserialize_attestation_with_valid_hex() {
122        let json = r#"{"status":"complete","attestation":"0x1234abcd"}"#;
123        let response: AttestationResponse = serde_json::from_str(json).unwrap();
124
125        assert_eq!(response.status, AttestationStatus::Complete);
126        assert!(response.attestation.is_some());
127        assert_eq!(
128            response.attestation.unwrap().to_vec(),
129            vec![0x12, 0x34, 0xab, 0xcd]
130        );
131    }
132
133    #[test]
134    fn test_deserialize_attestation_with_pending_string() {
135        let json = r#"{"status":"pending","attestation":"PENDING"}"#;
136        let response: AttestationResponse = serde_json::from_str(json).unwrap();
137
138        assert_eq!(response.status, AttestationStatus::Pending);
139        assert!(response.attestation.is_none());
140    }
141
142    #[test]
143    fn test_deserialize_attestation_with_pending_lowercase() {
144        let json = r#"{"status":"pending","attestation":"pending"}"#;
145        let response: AttestationResponse = serde_json::from_str(json).unwrap();
146
147        assert_eq!(response.status, AttestationStatus::Pending);
148        assert!(response.attestation.is_none());
149    }
150
151    #[test]
152    fn test_deserialize_attestation_with_null() {
153        let json = r#"{"status":"pending","attestation":null}"#;
154        let response: AttestationResponse = serde_json::from_str(json).unwrap();
155
156        assert_eq!(response.status, AttestationStatus::Pending);
157        assert!(response.attestation.is_none());
158    }
159
160    #[test]
161    fn test_deserialize_attestation_missing_field() {
162        let json = r#"{"status":"pending"}"#;
163        let response: AttestationResponse = serde_json::from_str(json).unwrap();
164
165        assert_eq!(response.status, AttestationStatus::Pending);
166        assert!(response.attestation.is_none());
167    }
168
169    #[test]
170    fn test_deserialize_attestation_with_empty_string() {
171        let json = r#"{"status":"pending","attestation":""}"#;
172        let response: AttestationResponse = serde_json::from_str(json).unwrap();
173
174        assert_eq!(response.status, AttestationStatus::Pending);
175        assert!(response.attestation.is_none());
176    }
177
178    #[test]
179    fn test_deserialize_attestation_with_hex_no_prefix() {
180        let json = r#"{"status":"complete","attestation":"deadbeef"}"#;
181        let response: AttestationResponse = serde_json::from_str(json).unwrap();
182
183        assert_eq!(response.status, AttestationStatus::Complete);
184        assert!(response.attestation.is_some());
185        assert_eq!(
186            response.attestation.unwrap().to_vec(),
187            vec![0xde, 0xad, 0xbe, 0xef]
188        );
189    }
190
191    #[test]
192    fn test_deserialize_attestation_with_invalid_hex_fails() {
193        let json = r#"{"status":"complete","attestation":"not_valid_hex"}"#;
194        let result = serde_json::from_str::<AttestationResponse>(json);
195
196        assert!(result.is_err());
197    }
198
199    #[test]
200    fn test_deserialize_all_status_variants() {
201        let complete = r#"{"status":"complete"}"#;
202        let pending = r#"{"status":"pending"}"#;
203        let pending_confirmations = r#"{"status":"pending_confirmations"}"#;
204        let failed = r#"{"status":"failed"}"#;
205
206        assert_eq!(
207            serde_json::from_str::<AttestationResponse>(complete)
208                .unwrap()
209                .status,
210            AttestationStatus::Complete
211        );
212        assert_eq!(
213            serde_json::from_str::<AttestationResponse>(pending)
214                .unwrap()
215                .status,
216            AttestationStatus::Pending
217        );
218        assert_eq!(
219            serde_json::from_str::<AttestationResponse>(pending_confirmations)
220                .unwrap()
221                .status,
222            AttestationStatus::PendingConfirmations
223        );
224        assert_eq!(
225            serde_json::from_str::<AttestationResponse>(failed)
226                .unwrap()
227                .status,
228            AttestationStatus::Failed
229        );
230    }
231
232    // ========================================================================
233    // V2 Response Tests
234    // ========================================================================
235
236    #[test]
237    fn test_v2_deserialize_complete_response() {
238        let json = r#"{
239            "messages": [
240                {
241                    "status": "complete",
242                    "message": "0xdeadbeef",
243                    "attestation": "0x1234abcd"
244                }
245            ]
246        }"#;
247        let response: V2AttestationResponse = serde_json::from_str(json).unwrap();
248
249        assert_eq!(response.messages.len(), 1);
250        assert_eq!(response.messages[0].status, AttestationStatus::Complete);
251        assert!(response.messages[0].message.is_some());
252        assert!(response.messages[0].attestation.is_some());
253        assert_eq!(
254            response.messages[0].attestation.as_ref().unwrap().to_vec(),
255            vec![0x12, 0x34, 0xab, 0xcd]
256        );
257        assert_eq!(
258            response.messages[0].message.as_ref().unwrap().to_vec(),
259            vec![0xde, 0xad, 0xbe, 0xef]
260        );
261    }
262
263    #[test]
264    fn test_v2_deserialize_pending_response() {
265        let json = r#"{
266            "messages": [
267                {
268                    "status": "pending",
269                    "message": null,
270                    "attestation": null
271                }
272            ]
273        }"#;
274        let response: V2AttestationResponse = serde_json::from_str(json).unwrap();
275
276        assert_eq!(response.messages.len(), 1);
277        assert_eq!(response.messages[0].status, AttestationStatus::Pending);
278        assert!(response.messages[0].message.is_none());
279        assert!(response.messages[0].attestation.is_none());
280    }
281
282    #[test]
283    fn test_v2_deserialize_pending_with_string() {
284        let json = r#"{
285            "messages": [
286                {
287                    "status": "pending",
288                    "message": "PENDING",
289                    "attestation": "PENDING"
290                }
291            ]
292        }"#;
293        let response: V2AttestationResponse = serde_json::from_str(json).unwrap();
294
295        assert_eq!(response.messages.len(), 1);
296        assert_eq!(response.messages[0].status, AttestationStatus::Pending);
297        assert!(response.messages[0].message.is_none());
298        assert!(response.messages[0].attestation.is_none());
299    }
300
301    #[test]
302    fn test_v2_deserialize_multiple_messages() {
303        let json = r#"{
304            "messages": [
305                {
306                    "status": "complete",
307                    "message": "0xaa",
308                    "attestation": "0xbb"
309                },
310                {
311                    "status": "complete",
312                    "message": "0xcc",
313                    "attestation": "0xdd"
314                }
315            ]
316        }"#;
317        let response: V2AttestationResponse = serde_json::from_str(json).unwrap();
318
319        assert_eq!(response.messages.len(), 2);
320        assert_eq!(response.messages[0].status, AttestationStatus::Complete);
321        assert_eq!(response.messages[1].status, AttestationStatus::Complete);
322    }
323
324    #[test]
325    fn test_v2_deserialize_empty_messages() {
326        let json = r#"{"messages": []}"#;
327        let response: V2AttestationResponse = serde_json::from_str(json).unwrap();
328
329        assert_eq!(response.messages.len(), 0);
330    }
331
332    #[test]
333    fn test_v2_deserialize_pending_confirmations() {
334        let json = r#"{
335            "messages": [
336                {
337                    "status": "pending_confirmations",
338                    "message": "0xdeadbeef",
339                    "attestation": null
340                }
341            ]
342        }"#;
343        let response: V2AttestationResponse = serde_json::from_str(json).unwrap();
344
345        assert_eq!(response.messages.len(), 1);
346        assert_eq!(
347            response.messages[0].status,
348            AttestationStatus::PendingConfirmations
349        );
350        assert!(response.messages[0].message.is_some());
351        assert!(response.messages[0].attestation.is_none());
352    }
353}