Skip to main content

r402_protocol/payment/
verify.rs

1//! Facilitator `/verify` request and response.
2
3use compact_str::CompactString;
4use serde::{Deserialize, Serialize};
5
6use super::{Extensions, Version};
7use crate::error::{ErrorReason, FacilitatorError, VerificationError};
8use crate::scheme::SchemeSlug;
9
10/// Protocol-versioned verify request parameterized by payload and requirements.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct TypedVerifyRequest<const V: u8, TPayload, TRequirements> {
14    /// Protocol version marker.
15    pub x402_version: Version<V>,
16    /// Signed payment authorization.
17    pub payment_payload: TPayload,
18    /// Payment terms being verified.
19    pub payment_requirements: TRequirements,
20}
21
22impl<const V: u8, TPayload, TRequirements> TypedVerifyRequest<V, TPayload, TRequirements>
23where
24    Self: serde::de::DeserializeOwned,
25{
26    /// Decodes a raw [`VerifyRequest`] into this typed variant.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`VerificationError::InvalidFormat`] when deserialisation fails.
31    pub fn from_verify(request: VerifyRequest) -> Result<Self, VerificationError> {
32        serde_json::from_value(request.into_json())
33            .map_err(|e| VerificationError::InvalidFormat(e.to_string()))
34    }
35}
36
37impl<const V: u8, TPayload, TRequirements> TryFrom<TypedVerifyRequest<V, TPayload, TRequirements>>
38    for VerifyRequest
39where
40    TPayload: Serialize,
41    TRequirements: Serialize,
42{
43    type Error = serde_json::Error;
44    fn try_from(
45        value: TypedVerifyRequest<V, TPayload, TRequirements>,
46    ) -> Result<Self, Self::Error> {
47        let json = serde_json::to_value(value)?;
48        Ok(Self(json))
49    }
50}
51
52/// Wire-level verify request, stored as opaque JSON.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct VerifyRequest(serde_json::Value);
55
56impl VerifyRequest {
57    /// Consumes the request and returns the raw JSON.
58    #[must_use]
59    pub fn into_json(self) -> serde_json::Value {
60        self.0
61    }
62
63    /// Inspects the request for scheme routing without full decoding.
64    #[must_use]
65    pub fn scheme_slug(&self) -> Option<SchemeSlug> {
66        super::scheme_slug_from_json(&self.0)
67    }
68
69    /// CAIP-2 network identifier from `paymentRequirements.network`.
70    #[must_use]
71    pub fn network(&self) -> &str {
72        super::network_from_json(&self.0)
73    }
74}
75
76impl From<serde_json::Value> for VerifyRequest {
77    fn from(value: serde_json::Value) -> Self {
78        Self(value)
79    }
80}
81
82/// Verification outcome returned by a facilitator.
83///
84/// `extension_responses` is the `EXTENSION-RESPONSES` sidechannel. It is not
85/// serialized into JSON and is never merged into `extensions`.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(into = "VerifyResponseWire", try_from = "VerifyResponseWire")]
88#[non_exhaustive]
89pub enum VerifyResponse {
90    /// Payload passed all verification checks.
91    Valid {
92        /// Address of the verified payer, if the facilitator included it.
93        payer: Option<CompactString>,
94        /// Facilitator-attached extension data (JSON body).
95        extensions: Extensions,
96        /// Facilitator `EXTENSION-RESPONSES` sidechannel. Not buyer wire.
97        extension_responses: Extensions,
98        /// Scheme-specific additional data.
99        extra: Option<serde_json::Value>,
100    },
101    /// Payload was well-formed but failed verification.
102    Invalid {
103        /// Wire-level reason code, if the facilitator included it.
104        reason: Option<ErrorReason>,
105        /// Optional human-readable message.
106        message: Option<CompactString>,
107        /// Optional payer address if identifiable from the payload.
108        payer: Option<CompactString>,
109        /// Facilitator-attached extension data (JSON body).
110        extensions: Extensions,
111        /// Facilitator `EXTENSION-RESPONSES` sidechannel. Not buyer wire.
112        extension_responses: Extensions,
113        /// Scheme-specific additional data.
114        extra: Option<serde_json::Value>,
115    },
116}
117
118impl VerifyResponse {
119    /// `Valid` response with empty extensions.
120    #[must_use]
121    pub fn valid(payer: impl Into<CompactString>) -> Self {
122        Self::Valid {
123            payer: Some(payer.into()),
124            extensions: Extensions::new(),
125            extension_responses: Extensions::new(),
126            extra: None,
127        }
128    }
129
130    /// `Invalid` response without a human message.
131    #[must_use]
132    pub fn invalid(payer: Option<CompactString>, reason: ErrorReason) -> Self {
133        Self::Invalid {
134            reason: Some(reason),
135            message: None,
136            payer,
137            extensions: Extensions::new(),
138            extension_responses: Extensions::new(),
139            extra: None,
140        }
141    }
142
143    /// `Invalid` response with a message.
144    #[must_use]
145    pub fn invalid_with_message(
146        payer: Option<CompactString>,
147        reason: ErrorReason,
148        message: impl Into<CompactString>,
149    ) -> Self {
150        Self::Invalid {
151            reason: Some(reason),
152            message: Some(message.into()),
153            payer,
154            extensions: Extensions::new(),
155            extension_responses: Extensions::new(),
156            extra: None,
157        }
158    }
159
160    /// Whether this is a `Valid` outcome.
161    #[must_use]
162    pub const fn is_valid(&self) -> bool {
163        matches!(self, Self::Valid { .. })
164    }
165
166    /// Sidechannel map (empty when the header was absent).
167    #[must_use]
168    pub const fn extension_responses(&self) -> &Extensions {
169        match self {
170            Self::Valid {
171                extension_responses,
172                ..
173            }
174            | Self::Invalid {
175                extension_responses,
176                ..
177            } => extension_responses,
178        }
179    }
180
181    /// Replaces the sidechannel map.
182    pub fn set_extension_responses(&mut self, responses: Extensions) {
183        match self {
184            Self::Valid {
185                extension_responses,
186                ..
187            }
188            | Self::Invalid {
189                extension_responses,
190                ..
191            } => *extension_responses = responses,
192        }
193    }
194
195    /// Converts a [`FacilitatorError`] into an `Invalid` response.
196    ///
197    /// Returns `None` for [`FacilitatorError::Transport`]: that is HTTP 502,
198    /// not a 402 body.
199    #[must_use]
200    pub fn from_facilitator_error(error: &FacilitatorError) -> Option<Self> {
201        let problem = error.as_payment_problem()?;
202        Some(Self::Invalid {
203            reason: Some(problem.reason()),
204            message: Some(CompactString::from(problem.details())),
205            payer: None,
206            extensions: Extensions::new(),
207            extension_responses: Extensions::new(),
208            extra: None,
209        })
210    }
211}
212
213#[derive(Serialize, Deserialize)]
214#[serde(rename_all = "camelCase", deny_unknown_fields)]
215struct VerifyResponseWire {
216    is_valid: bool,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    payer: Option<CompactString>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    invalid_reason: Option<ErrorReason>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    invalid_message: Option<CompactString>,
223    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
224    extensions: Extensions,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    extra: Option<serde_json::Value>,
227}
228
229impl From<VerifyResponse> for VerifyResponseWire {
230    fn from(value: VerifyResponse) -> Self {
231        match value {
232            VerifyResponse::Valid {
233                payer,
234                extensions,
235                extra,
236                extension_responses: _,
237            } => Self {
238                is_valid: true,
239                payer,
240                invalid_reason: None,
241                invalid_message: None,
242                extensions,
243                extra,
244            },
245            VerifyResponse::Invalid {
246                reason,
247                message,
248                payer,
249                extensions,
250                extra,
251                extension_responses: _,
252            } => Self {
253                is_valid: false,
254                payer,
255                invalid_reason: reason,
256                invalid_message: message,
257                extensions,
258                extra,
259            },
260        }
261    }
262}
263
264impl TryFrom<VerifyResponseWire> for VerifyResponse {
265    type Error = String;
266    fn try_from(wire: VerifyResponseWire) -> Result<Self, Self::Error> {
267        if wire.is_valid {
268            Ok(Self::Valid {
269                payer: wire.payer,
270                extensions: wire.extensions,
271                extension_responses: Extensions::new(),
272                extra: wire.extra,
273            })
274        } else {
275            Ok(Self::Invalid {
276                reason: wire.invalid_reason,
277                message: wire.invalid_message,
278                payer: wire.payer,
279                extensions: wire.extensions,
280                extension_responses: Extensions::new(),
281                extra: wire.extra,
282            })
283        }
284    }
285}
286
287#[cfg(test)]
288#[allow(
289    clippy::unwrap_used,
290    clippy::panic,
291    clippy::indexing_slicing,
292    reason = "unit tests panic on assertion failure"
293)]
294mod tests {
295    use serde_json::json;
296
297    use super::*;
298    use crate::error::FacilitatorTransportKind;
299    use crate::payment::ExtensionEntry;
300
301    fn v2_json(network: &str, scheme: &str) -> serde_json::Value {
302        json!({
303            "x402Version": 2,
304            "paymentPayload": {
305                "accepted": { "network": network, "scheme": scheme }
306            },
307            "paymentRequirements": { "network": network }
308        })
309    }
310
311    #[test]
312    fn verify_request_scheme_slug_evm() {
313        let req = VerifyRequest::from(v2_json("eip155:8453", "exact"));
314        let slug = req.scheme_slug().unwrap();
315        assert_eq!(slug.to_string(), "eip155:8453:exact");
316    }
317
318    #[test]
319    fn slug_rejects_wrong_version() {
320        let mut json = v2_json("eip155:1", "exact");
321        json["x402Version"] = json!(99);
322        assert!(super::super::scheme_slug_from_json(&json).is_none());
323    }
324
325    #[test]
326    fn slug_rejects_invalid_caip2() {
327        assert!(super::super::scheme_slug_from_json(&v2_json("not-a-caip2", "exact")).is_none());
328    }
329
330    #[test]
331    fn verify_valid_roundtrip() {
332        let response = VerifyResponse::valid("0xABC");
333        let encoded = serde_json::to_value(&response).unwrap();
334        assert_eq!(encoded["isValid"], true);
335        assert_eq!(encoded["payer"], "0xABC");
336        assert!(encoded.get("invalidReason").is_none());
337        assert!(encoded.get("extensionResponses").is_none());
338
339        let back: VerifyResponse = serde_json::from_value(encoded).unwrap();
340        assert_eq!(back, response);
341    }
342
343    #[test]
344    fn verify_valid_with_extensions_roundtrip() {
345        let mut extensions = Extensions::new();
346        extensions.insert(
347            "payment-identifier",
348            ExtensionEntry::info(json!({"id": "order-123"})),
349        );
350        let response = VerifyResponse::Valid {
351            payer: Some("0xABC".into()),
352            extensions,
353            extension_responses: Extensions::new(),
354            extra: None,
355        };
356        let encoded = serde_json::to_value(&response).unwrap();
357        assert_eq!(
358            encoded["extensions"]["payment-identifier"]["info"]["id"],
359            "order-123"
360        );
361        let back: VerifyResponse = serde_json::from_value(encoded).unwrap();
362        assert_eq!(back, response);
363    }
364
365    #[test]
366    fn verify_invalid_roundtrip() {
367        let response = VerifyResponse::invalid_with_message(
368            Some("0xDEF".into()),
369            ErrorReason::InsufficientFunds,
370            "not enough USDC",
371        );
372        let encoded = serde_json::to_value(&response).unwrap();
373        assert_eq!(encoded["isValid"], false);
374        assert_eq!(encoded["invalidReason"], "insufficient_funds");
375        assert_eq!(encoded["invalidMessage"], "not enough USDC");
376        let back: VerifyResponse = serde_json::from_value(encoded).unwrap();
377        assert_eq!(back, response);
378    }
379
380    #[test]
381    fn extension_responses_are_not_serialized() {
382        let mut response = VerifyResponse::valid("0xABC");
383        let mut side = Extensions::new();
384        side.insert("bazaar", ExtensionEntry::raw(json!({"status": "success"})));
385        response.set_extension_responses(side);
386        let encoded = serde_json::to_value(&response).unwrap();
387        assert!(encoded.get("extensionResponses").is_none());
388        assert!(encoded.get("extensions").is_none());
389        assert!(!response.extension_responses().is_empty());
390    }
391
392    #[test]
393    fn from_facilitator_error_refuses_transport() {
394        let err = FacilitatorError::transport(FacilitatorTransportKind::MalformedSuccessBody);
395        assert!(VerifyResponse::from_facilitator_error(&err).is_none());
396    }
397
398    #[test]
399    fn verify_deny_unknown_top_level_fields() {
400        let verify_typo = json!({
401            "isValid": true,
402            "payer": "0xABC",
403            "extraTypo": {"k": 1}
404        });
405        assert!(serde_json::from_value::<VerifyResponse>(verify_typo).is_err());
406    }
407
408    #[test]
409    fn verify_valid_omitted_payer() {
410        let json = json!({ "isValid": true });
411        let back: VerifyResponse = serde_json::from_value(json).unwrap();
412        assert!(matches!(back, VerifyResponse::Valid { payer: None, .. }));
413        let encoded = serde_json::to_value(&back).unwrap();
414        assert!(encoded.get("payer").is_none());
415    }
416
417    #[test]
418    fn verify_invalid_omitted_reason_and_payer() {
419        let json = json!({ "isValid": false });
420        let back: VerifyResponse = serde_json::from_value(json).unwrap();
421        assert!(matches!(
422            back,
423            VerifyResponse::Invalid {
424                reason: None,
425                payer: None,
426                ..
427            }
428        ));
429        let encoded = serde_json::to_value(&back).unwrap();
430        assert!(encoded.get("invalidReason").is_none());
431        assert!(encoded.get("payer").is_none());
432    }
433}