Skip to main content

r402_core/wire/
response.rs

1//! Verify and settle response bodies.
2//!
3//! Both responses use a boolean discriminator on the wire (`isValid` /
4//! `success`) and convert to/from a Rust enum via a private "wire" struct.
5//! Successful responses may now carry an `extensions` block returned by the
6//! facilitator; settlement success also carries the actual amount settled
7//! (required by the `upto` scheme).
8
9use compact_str::CompactString;
10use serde::{Deserialize, Serialize};
11
12use super::{Base64Bytes, Extensions};
13use crate::error::FacilitatorError;
14use crate::error_reason::{AsPaymentProblem, ErrorReason};
15
16/// Verification outcome returned by a facilitator.
17///
18/// Serialised as a flat boolean-discriminated JSON object (matching the x402
19/// wire format). Consumers get a safe, pattern-matchable Rust enum.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(into = "VerifyResponseWire", try_from = "VerifyResponseWire")]
22#[non_exhaustive]
23pub enum VerifyResponse {
24    /// Payload passed all verification checks.
25    Valid {
26        /// Address of the verified payer.
27        payer: CompactString,
28        /// Facilitator-attached extension data.
29        extensions: Extensions,
30    },
31    /// Payload was well-formed but failed verification.
32    Invalid {
33        /// Wire-level reason code.
34        reason: ErrorReason,
35        /// Optional human-readable message.
36        message: Option<CompactString>,
37        /// Optional payer address if identifiable from the payload.
38        payer: Option<CompactString>,
39        /// Facilitator-attached extension data.
40        extensions: Extensions,
41    },
42}
43
44impl VerifyResponse {
45    /// Convenience: constructs a `Valid` response with no extensions.
46    #[must_use]
47    pub fn valid(payer: impl Into<CompactString>) -> Self {
48        Self::Valid {
49            payer: payer.into(),
50            extensions: Extensions::new(),
51        }
52    }
53
54    /// Convenience: constructs an `Invalid` response without a human message.
55    #[must_use]
56    pub fn invalid(payer: Option<CompactString>, reason: ErrorReason) -> Self {
57        Self::Invalid {
58            reason,
59            message: None,
60            payer,
61            extensions: Extensions::new(),
62        }
63    }
64
65    /// Convenience: constructs an `Invalid` response with a message.
66    #[must_use]
67    pub fn invalid_with_message(
68        payer: Option<CompactString>,
69        reason: ErrorReason,
70        message: impl Into<CompactString>,
71    ) -> Self {
72        Self::Invalid {
73            reason,
74            message: Some(message.into()),
75            payer,
76            extensions: Extensions::new(),
77        }
78    }
79
80    /// Returns `true` for `Valid` outcomes.
81    #[must_use]
82    pub const fn is_valid(&self) -> bool {
83        matches!(self, Self::Valid { .. })
84    }
85
86    /// Converts a [`FacilitatorError`] into an `Invalid` response for the
87    /// HTTP boundary, preserving the structured reason code.
88    #[must_use]
89    pub fn from_facilitator_error(error: &FacilitatorError) -> Self {
90        let problem = error.as_payment_problem();
91        Self::Invalid {
92            reason: problem.reason(),
93            message: Some(CompactString::from(problem.details())),
94            payer: None,
95            extensions: Extensions::new(),
96        }
97    }
98}
99
100/// Flat wire representation of [`VerifyResponse`].
101#[derive(Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103struct VerifyResponseWire {
104    is_valid: bool,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    payer: Option<CompactString>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    invalid_reason: Option<ErrorReason>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    invalid_message: Option<CompactString>,
111    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
112    extensions: Extensions,
113}
114
115impl From<VerifyResponse> for VerifyResponseWire {
116    fn from(value: VerifyResponse) -> Self {
117        match value {
118            VerifyResponse::Valid { payer, extensions } => Self {
119                is_valid: true,
120                payer: Some(payer),
121                invalid_reason: None,
122                invalid_message: None,
123                extensions,
124            },
125            VerifyResponse::Invalid {
126                reason,
127                message,
128                payer,
129                extensions,
130            } => Self {
131                is_valid: false,
132                payer,
133                invalid_reason: Some(reason),
134                invalid_message: message,
135                extensions,
136            },
137        }
138    }
139}
140
141impl TryFrom<VerifyResponseWire> for VerifyResponse {
142    type Error = String;
143    fn try_from(wire: VerifyResponseWire) -> Result<Self, Self::Error> {
144        if wire.is_valid {
145            Ok(Self::Valid {
146                payer: wire.payer.ok_or("missing field: payer")?,
147                extensions: wire.extensions,
148            })
149        } else {
150            Ok(Self::Invalid {
151                reason: wire.invalid_reason.ok_or("missing field: invalidReason")?,
152                message: wire.invalid_message,
153                payer: wire.payer,
154                extensions: wire.extensions,
155            })
156        }
157    }
158}
159
160/// Settlement outcome returned by a facilitator.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(into = "SettleResponseWire", try_from = "SettleResponseWire")]
163#[non_exhaustive]
164pub enum SettleResponse {
165    /// Settlement succeeded.
166    Success {
167        /// The payer address on the target chain.
168        payer: CompactString,
169        /// On-chain transaction hash / signature.
170        transaction: CompactString,
171        /// CAIP-2 chain identifier the transaction landed on.
172        network: CompactString,
173        /// Actual amount settled, in the token's smallest unit.
174        ///
175        /// Required when the `upto` scheme is in use; included for `exact` too
176        /// to give clients unambiguous receipts.
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        amount: Option<CompactString>,
179        /// Facilitator-attached extension data.
180        extensions: Extensions,
181    },
182    /// Settlement failed.
183    Failure {
184        /// Wire-level reason code.
185        reason: ErrorReason,
186        /// Optional human-readable message.
187        message: Option<CompactString>,
188        /// Optional payer address if identifiable.
189        payer: Option<CompactString>,
190        /// CAIP-2 chain identifier on which settlement was attempted.
191        network: CompactString,
192        /// Facilitator-attached extension data.
193        extensions: Extensions,
194    },
195}
196
197impl SettleResponse {
198    /// Returns `true` when the settlement succeeded.
199    #[must_use]
200    pub const fn is_success(&self) -> bool {
201        matches!(self, Self::Success { .. })
202    }
203
204    /// Encodes a successful settlement as base64 bytes for the
205    /// `Payment-Response` HTTP header.
206    ///
207    /// Returns `None` for `Failure` variants to avoid accidentally
208    /// transmitting a failed settlement as if it were successful. Call
209    /// [`Self::encode_base64_any`] when the failure body is needed on the
210    /// wire (for example, when a paygate must inform a browser client of a
211    /// failed settlement via the `Payment-Response` header).
212    #[must_use]
213    pub fn encode_base64(&self) -> Option<Base64Bytes> {
214        if !self.is_success() {
215            return None;
216        }
217        let json = serde_json::to_vec(self).ok()?;
218        Some(Base64Bytes::encode(json))
219    }
220
221    /// Encodes any [`SettleResponse`] (success or failure) as base64 bytes
222    /// for the `Payment-Response` HTTP header.
223    ///
224    /// Use this when surfacing failed settlements is required by the spec,
225    /// e.g. paygate error paths that still want to communicate the
226    /// machine-readable error reason and chain to a browser client.
227    /// Prefer [`Self::encode_base64`] when you only want to forward
228    /// successful settlements.
229    #[must_use]
230    pub fn encode_base64_any(&self) -> Option<Base64Bytes> {
231        let json = serde_json::to_vec(self).ok()?;
232        Some(Base64Bytes::encode(json))
233    }
234
235    /// Builds a `Failure` response from a [`FacilitatorError`].
236    #[must_use]
237    pub fn from_facilitator_error(
238        error: &FacilitatorError,
239        network: impl Into<CompactString>,
240    ) -> Self {
241        let problem = error.as_payment_problem();
242        Self::Failure {
243            reason: problem.reason(),
244            message: Some(CompactString::from(problem.details())),
245            payer: None,
246            network: network.into(),
247            extensions: Extensions::new(),
248        }
249    }
250}
251
252/// Flat wire representation of [`SettleResponse`].
253#[derive(Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255struct SettleResponseWire {
256    success: bool,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    error_reason: Option<ErrorReason>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    error_message: Option<CompactString>,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    payer: Option<CompactString>,
263    /// Always serialized per x402 v2 spec §5.3.2: empty string on failure,
264    /// transaction hash on success. Deserialization tolerates omission for
265    /// legacy clients but the success path enforces non-empty in `try_from`.
266    #[serde(default)]
267    transaction: CompactString,
268    network: CompactString,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    amount: Option<CompactString>,
271    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
272    extensions: Extensions,
273}
274
275impl From<SettleResponse> for SettleResponseWire {
276    fn from(value: SettleResponse) -> Self {
277        match value {
278            SettleResponse::Success {
279                payer,
280                transaction,
281                network,
282                amount,
283                extensions,
284            } => Self {
285                success: true,
286                error_reason: None,
287                error_message: None,
288                payer: Some(payer),
289                transaction,
290                network,
291                amount,
292                extensions,
293            },
294            SettleResponse::Failure {
295                reason,
296                message,
297                payer,
298                network,
299                extensions,
300            } => Self {
301                success: false,
302                error_reason: Some(reason),
303                error_message: message,
304                payer,
305                transaction: CompactString::default(),
306                network,
307                amount: None,
308                extensions,
309            },
310        }
311    }
312}
313
314impl TryFrom<SettleResponseWire> for SettleResponse {
315    type Error = String;
316    fn try_from(wire: SettleResponseWire) -> Result<Self, Self::Error> {
317        if wire.success {
318            let payer = wire.payer.ok_or("missing field: payer")?;
319            if wire.transaction.is_empty() {
320                return Err("missing field: transaction".to_owned());
321            }
322            Ok(Self::Success {
323                payer,
324                transaction: wire.transaction,
325                network: wire.network,
326                amount: wire.amount,
327                extensions: wire.extensions,
328            })
329        } else {
330            Ok(Self::Failure {
331                reason: wire.error_reason.ok_or("missing field: errorReason")?,
332                message: wire.error_message,
333                payer: wire.payer,
334                network: wire.network,
335                extensions: wire.extensions,
336            })
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use serde_json::json;
344
345    use super::*;
346    use crate::wire::ExtensionEntry;
347
348    #[test]
349    fn verify_valid_roundtrip() {
350        let response = VerifyResponse::valid("0xABC");
351        let encoded = serde_json::to_value(&response).unwrap();
352        assert_eq!(encoded["isValid"], true);
353        assert_eq!(encoded["payer"], "0xABC");
354        assert!(encoded.get("invalidReason").is_none());
355
356        let back: VerifyResponse = serde_json::from_value(encoded).unwrap();
357        assert_eq!(back, response);
358    }
359
360    #[test]
361    fn verify_valid_with_extensions_roundtrip() {
362        let mut extensions = Extensions::new();
363        extensions.insert(
364            "payment-identifier",
365            ExtensionEntry::info(json!({"id": "order-123"})),
366        );
367        let response = VerifyResponse::Valid {
368            payer: "0xABC".into(),
369            extensions,
370        };
371        let encoded = serde_json::to_value(&response).unwrap();
372        assert_eq!(
373            encoded["extensions"]["payment-identifier"]["info"]["id"],
374            "order-123"
375        );
376        let back: VerifyResponse = serde_json::from_value(encoded).unwrap();
377        assert_eq!(back, response);
378    }
379
380    #[test]
381    fn verify_invalid_roundtrip() {
382        let response = VerifyResponse::invalid_with_message(
383            Some("0xDEF".into()),
384            ErrorReason::InsufficientFunds,
385            "not enough USDC",
386        );
387        let encoded = serde_json::to_value(&response).unwrap();
388        assert_eq!(encoded["isValid"], false);
389        assert_eq!(encoded["invalidReason"], "insufficient_funds");
390        assert_eq!(encoded["invalidMessage"], "not enough USDC");
391        let back: VerifyResponse = serde_json::from_value(encoded).unwrap();
392        assert_eq!(back, response);
393    }
394
395    #[test]
396    fn settle_success_with_amount_roundtrip() {
397        let response = SettleResponse::Success {
398            payer: "0xABC".into(),
399            transaction: "0xTX".into(),
400            network: "eip155:8453".into(),
401            amount: Some("1000000".into()),
402            extensions: Extensions::new(),
403        };
404        let encoded = serde_json::to_value(&response).unwrap();
405        assert_eq!(encoded["success"], true);
406        assert_eq!(encoded["amount"], "1000000");
407        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
408        assert_eq!(back, response);
409    }
410
411    #[test]
412    fn settle_success_without_amount_is_valid() {
413        let response = SettleResponse::Success {
414            payer: "0xABC".into(),
415            transaction: "0xTX".into(),
416            network: "eip155:1".into(),
417            amount: None,
418            extensions: Extensions::new(),
419        };
420        let encoded = serde_json::to_value(&response).unwrap();
421        assert!(encoded.get("amount").is_none());
422        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
423        assert_eq!(back, response);
424    }
425
426    #[test]
427    fn settle_success_missing_transaction_rejects() {
428        let json = json!({
429            "success": true,
430            "payer": "0xABC",
431            "transaction": "",
432            "network": "eip155:1"
433        });
434        assert!(serde_json::from_value::<SettleResponse>(json).is_err());
435    }
436
437    #[test]
438    fn settle_failure_roundtrip() {
439        let response = SettleResponse::Failure {
440            reason: ErrorReason::DuplicateSettlement,
441            message: Some("already processed".into()),
442            payer: None,
443            network: "solana:mainnet".into(),
444            extensions: Extensions::new(),
445        };
446        let encoded = serde_json::to_value(&response).unwrap();
447        assert_eq!(encoded["errorReason"], "duplicate_settlement");
448        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
449        assert_eq!(back, response);
450    }
451
452    /// Regression test for F-002: spec §5.3.2 marks `transaction` as Required
453    /// (empty string on failure). Go SDK rejects responses lacking the field.
454    #[test]
455    fn settle_failure_serializes_empty_transaction() {
456        let response = SettleResponse::Failure {
457            reason: ErrorReason::UnexpectedSettleError,
458            message: None,
459            payer: None,
460            network: "eip155:8453".into(),
461            extensions: Extensions::new(),
462        };
463        let encoded = serde_json::to_value(&response).unwrap();
464        assert_eq!(
465            encoded["transaction"], "",
466            "spec §5.3.2 requires `transaction` field present (empty on failure)"
467        );
468    }
469
470    #[test]
471    fn settle_encode_base64_only_for_success() {
472        let success = SettleResponse::Success {
473            payer: "0xA".into(),
474            transaction: "0xT".into(),
475            network: "eip155:1".into(),
476            amount: None,
477            extensions: Extensions::new(),
478        };
479        assert!(success.encode_base64().is_some());
480
481        let failure = SettleResponse::Failure {
482            reason: ErrorReason::UnexpectedSettleError,
483            message: None,
484            payer: None,
485            network: "eip155:1".into(),
486            extensions: Extensions::new(),
487        };
488        assert!(failure.encode_base64().is_none());
489    }
490}