Skip to main content

r402_protocol/payment/
settle.rs

1//! Facilitator `/settle` request and response.
2
3use compact_str::CompactString;
4use serde::{Deserialize, Serialize};
5
6use super::verify::{TypedVerifyRequest, VerifyRequest};
7use super::{Base64Bytes, Extensions};
8use crate::error::{ErrorReason, FacilitatorError, VerificationError};
9use crate::scheme::SchemeSlug;
10
11/// Wire-level settle request. Same JSON as [`VerifyRequest`], distinct type.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SettleRequest(serde_json::Value);
14
15impl SettleRequest {
16    /// Consumes the request and returns the raw JSON.
17    #[must_use]
18    pub fn into_json(self) -> serde_json::Value {
19        self.0
20    }
21
22    /// Inspects the request for scheme routing.
23    #[must_use]
24    pub fn scheme_slug(&self) -> Option<SchemeSlug> {
25        super::scheme_slug_from_json(&self.0)
26    }
27
28    /// CAIP-2 network identifier from `paymentRequirements.network`.
29    #[must_use]
30    pub fn network(&self) -> &str {
31        super::network_from_json(&self.0)
32    }
33
34    /// Overrides `paymentRequirements.amount` in-place (upto scheme).
35    ///
36    /// # Errors
37    ///
38    /// Returns [`VerificationError::InvalidFormat`] when `paymentRequirements`
39    /// is missing.
40    pub fn set_settlement_amount(&mut self, amount: &str) -> Result<(), VerificationError> {
41        let req = self
42            .0
43            .get_mut("paymentRequirements")
44            .and_then(serde_json::Value::as_object_mut)
45            .ok_or_else(|| {
46                VerificationError::InvalidFormat(
47                    "settle request missing paymentRequirements object".into(),
48                )
49            })?;
50        let _ = req.insert(
51            "amount".to_owned(),
52            serde_json::Value::String(amount.to_owned()),
53        );
54        Ok(())
55    }
56}
57
58impl From<serde_json::Value> for SettleRequest {
59    fn from(value: serde_json::Value) -> Self {
60        Self(value)
61    }
62}
63
64impl From<VerifyRequest> for SettleRequest {
65    fn from(request: VerifyRequest) -> Self {
66        Self(request.into_json())
67    }
68}
69
70#[allow(
71    clippy::multiple_inherent_impl,
72    reason = "from_settle is owned by the settle request type"
73)]
74impl<const V: u8, TPayload, TRequirements> TypedVerifyRequest<V, TPayload, TRequirements>
75where
76    Self: serde::de::DeserializeOwned,
77{
78    /// Decodes a raw [`SettleRequest`] into this typed variant.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`VerificationError::InvalidFormat`] when deserialisation fails.
83    pub fn from_settle(request: SettleRequest) -> Result<Self, VerificationError> {
84        serde_json::from_value(request.into_json())
85            .map_err(|e| VerificationError::InvalidFormat(e.to_string()))
86    }
87}
88
89/// Settlement outcome returned by a facilitator.
90///
91/// `extension_responses` is the `EXTENSION-RESPONSES` sidechannel. It is not
92/// serialized into JSON and is never merged into `extensions`.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(into = "SettleResponseWire", try_from = "SettleResponseWire")]
95#[non_exhaustive]
96pub enum SettleResponse {
97    /// Settlement succeeded.
98    Success {
99        /// Payer address on the target chain, if the facilitator included it.
100        payer: Option<CompactString>,
101        /// On-chain transaction hash / signature.
102        transaction: CompactString,
103        /// CAIP-2 chain identifier the transaction landed on.
104        network: CompactString,
105        /// Actual amount settled, in the token's smallest unit.
106        amount: Option<CompactString>,
107        /// Facilitator-attached extension data (JSON body).
108        extensions: Extensions,
109        /// Facilitator `EXTENSION-RESPONSES` sidechannel. Not buyer wire.
110        extension_responses: Extensions,
111        /// Scheme-specific additional data.
112        extra: Option<serde_json::Value>,
113    },
114    /// Settlement failed.
115    Failure {
116        /// Wire-level reason code.
117        reason: ErrorReason,
118        /// Optional human-readable message.
119        message: Option<CompactString>,
120        /// Optional payer address if identifiable.
121        payer: Option<CompactString>,
122        /// Broadcast transaction hash. Empty when no transaction was submitted.
123        /// Spec ยง9: MUST be non-empty when `reason` is [`ErrorReason::SettlementPending`].
124        transaction: CompactString,
125        /// CAIP-2 chain identifier on which settlement was attempted.
126        network: CompactString,
127        /// Facilitator-attached extension data (JSON body).
128        extensions: Extensions,
129        /// Facilitator `EXTENSION-RESPONSES` sidechannel. Not buyer wire.
130        extension_responses: Extensions,
131        /// Scheme-specific additional data.
132        extra: Option<serde_json::Value>,
133    },
134}
135
136impl SettleResponse {
137    /// Whether settlement succeeded.
138    #[must_use]
139    pub const fn is_success(&self) -> bool {
140        matches!(self, Self::Success { .. })
141    }
142
143    /// Sidechannel map (empty when the header was absent).
144    #[must_use]
145    pub const fn extension_responses(&self) -> &Extensions {
146        match self {
147            Self::Success {
148                extension_responses,
149                ..
150            }
151            | Self::Failure {
152                extension_responses,
153                ..
154            } => extension_responses,
155        }
156    }
157
158    /// Buyer-wire `extensions` map.
159    pub const fn extensions_mut(&mut self) -> &mut Extensions {
160        match self {
161            Self::Success { extensions, .. } | Self::Failure { extensions, .. } => extensions,
162        }
163    }
164
165    /// Replaces the sidechannel map.
166    pub fn set_extension_responses(&mut self, responses: Extensions) {
167        match self {
168            Self::Success {
169                extension_responses,
170                ..
171            }
172            | Self::Failure {
173                extension_responses,
174                ..
175            } => *extension_responses = responses,
176        }
177    }
178
179    /// Encodes a successful settlement as base64 for `Payment-Response`.
180    ///
181    /// Returns `None` for `Failure`. Use [`Self::encode_base64_any`] when the
182    /// failure body must go on the wire.
183    #[must_use]
184    pub fn encode_base64(&self) -> Option<Base64Bytes> {
185        if !self.is_success() {
186            return None;
187        }
188        let json = serde_json::to_vec(self).ok()?;
189        Some(Base64Bytes::encode(json))
190    }
191
192    /// Encodes any [`SettleResponse`] as base64 for `Payment-Response`.
193    #[must_use]
194    pub fn encode_base64_any(&self) -> Option<Base64Bytes> {
195        let json = serde_json::to_vec(self).ok()?;
196        Some(Base64Bytes::encode(json))
197    }
198
199    /// Builds a `Failure` response from a [`FacilitatorError`].
200    ///
201    /// The caller supplies `transaction` (`""` when no broadcast hash is known).
202    /// Returns `None` for [`FacilitatorError::Transport`]: that is HTTP 502,
203    /// not a 402 body.
204    #[must_use]
205    pub fn from_facilitator_error(
206        error: &FacilitatorError,
207        network: impl Into<CompactString>,
208        transaction: impl Into<CompactString>,
209    ) -> Option<Self> {
210        let problem = error.as_payment_problem()?;
211        let reason = problem.reason();
212        let transaction = transaction.into();
213        debug_assert!(
214            reason != ErrorReason::SettlementPending || !transaction.is_empty(),
215            "settlement_pending requires a non-empty transaction hash"
216        );
217        Some(Self::Failure {
218            reason,
219            message: Some(CompactString::from(problem.details())),
220            payer: None,
221            transaction,
222            network: network.into(),
223            extensions: Extensions::new(),
224            extension_responses: Extensions::new(),
225            extra: None,
226        })
227    }
228
229    /// `success: false`, `errorReason == settlement_pending`, and a non-empty hash.
230    #[must_use]
231    pub fn is_retryable_settlement_pending(&self) -> bool {
232        match self {
233            Self::Failure {
234                reason: ErrorReason::SettlementPending,
235                transaction,
236                ..
237            } => !transaction.is_empty(),
238            _ => false,
239        }
240    }
241}
242
243#[derive(Serialize, Deserialize)]
244#[serde(rename_all = "camelCase", deny_unknown_fields)]
245struct SettleResponseWire {
246    success: bool,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    error_reason: Option<ErrorReason>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    error_message: Option<CompactString>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    payer: Option<CompactString>,
253    transaction: CompactString,
254    network: CompactString,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    amount: Option<CompactString>,
257    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
258    extensions: Extensions,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    extra: Option<serde_json::Value>,
261}
262
263impl From<SettleResponse> for SettleResponseWire {
264    fn from(value: SettleResponse) -> Self {
265        match value {
266            SettleResponse::Success {
267                payer,
268                transaction,
269                network,
270                amount,
271                extensions,
272                extra,
273                extension_responses: _,
274            } => Self {
275                success: true,
276                error_reason: None,
277                error_message: None,
278                payer,
279                transaction,
280                network,
281                amount,
282                extensions,
283                extra,
284            },
285            SettleResponse::Failure {
286                reason,
287                message,
288                payer,
289                transaction,
290                network,
291                extensions,
292                extra,
293                extension_responses: _,
294            } => Self {
295                success: false,
296                error_reason: Some(reason),
297                error_message: message,
298                payer,
299                transaction,
300                network,
301                amount: None,
302                extensions,
303                extra,
304            },
305        }
306    }
307}
308
309impl TryFrom<SettleResponseWire> for SettleResponse {
310    type Error = String;
311    fn try_from(wire: SettleResponseWire) -> Result<Self, Self::Error> {
312        if wire.success {
313            Ok(Self::Success {
314                payer: wire.payer,
315                transaction: wire.transaction,
316                network: wire.network,
317                amount: wire.amount,
318                extensions: wire.extensions,
319                extension_responses: Extensions::new(),
320                extra: wire.extra,
321            })
322        } else {
323            Ok(Self::Failure {
324                reason: wire.error_reason.ok_or("missing field: errorReason")?,
325                message: wire.error_message,
326                payer: wire.payer,
327                transaction: wire.transaction,
328                network: wire.network,
329                extensions: wire.extensions,
330                extension_responses: Extensions::new(),
331                extra: wire.extra,
332            })
333        }
334    }
335}
336
337#[cfg(test)]
338#[allow(
339    clippy::unwrap_used,
340    clippy::panic,
341    clippy::indexing_slicing,
342    reason = "unit tests panic on assertion failure"
343)]
344mod tests {
345    use serde_json::json;
346
347    use super::*;
348    use crate::error::FacilitatorTransportKind;
349    use crate::payment::ExtensionEntry;
350
351    fn v2_json(network: &str, scheme: &str) -> serde_json::Value {
352        json!({
353            "x402Version": 2,
354            "paymentPayload": {
355                "accepted": { "network": network, "scheme": scheme }
356            },
357            "paymentRequirements": { "network": network }
358        })
359    }
360
361    #[test]
362    fn settle_request_from_verify_preserves_slug() {
363        let verify = VerifyRequest::from(v2_json("eip155:42161", "exact"));
364        let settle: SettleRequest = verify.into();
365        assert_eq!(
366            settle.scheme_slug().unwrap().to_string(),
367            "eip155:42161:exact"
368        );
369    }
370
371    #[test]
372    fn settle_request_network_missing_returns_empty() {
373        let settle = SettleRequest::from(serde_json::json!({}));
374        assert_eq!(settle.network(), "");
375    }
376
377    #[test]
378    fn settle_amount_override_rewrites_payment_requirements() {
379        let mut settle = SettleRequest::from(json!({
380            "x402Version": 2,
381            "paymentPayload": { "accepted": { "network": "eip155:8453", "scheme": "upto" } },
382            "paymentRequirements": { "network": "eip155:8453", "amount": "5000000" }
383        }));
384        settle.set_settlement_amount("1500000").unwrap();
385        let json = settle.into_json();
386        assert_eq!(
387            json["paymentRequirements"]["amount"].as_str(),
388            Some("1500000")
389        );
390    }
391
392    #[test]
393    fn settle_amount_override_errors_when_requirements_missing() {
394        let mut settle = SettleRequest::from(json!({}));
395        let err = settle.set_settlement_amount("1").unwrap_err();
396        assert!(matches!(err, VerificationError::InvalidFormat(_)));
397    }
398
399    #[test]
400    fn settle_success_with_amount_roundtrip() {
401        let response = SettleResponse::Success {
402            payer: Some("0xABC".into()),
403            transaction: "0xTX".into(),
404            network: "eip155:8453".into(),
405            amount: Some("1000000".into()),
406            extensions: Extensions::new(),
407            extension_responses: Extensions::new(),
408            extra: None,
409        };
410        let encoded = serde_json::to_value(&response).unwrap();
411        assert_eq!(encoded["success"], true);
412        assert_eq!(encoded["amount"], "1000000");
413        assert!(encoded.get("extensionResponses").is_none());
414        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
415        assert_eq!(back, response);
416    }
417
418    #[test]
419    fn settle_success_without_amount_is_valid() {
420        let response = SettleResponse::Success {
421            payer: Some("0xABC".into()),
422            transaction: "0xTX".into(),
423            network: "eip155:1".into(),
424            amount: None,
425            extensions: Extensions::new(),
426            extension_responses: Extensions::new(),
427            extra: None,
428        };
429        let encoded = serde_json::to_value(&response).unwrap();
430        assert!(encoded.get("amount").is_none());
431        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
432        assert_eq!(back, response);
433    }
434
435    #[test]
436    fn settle_success_empty_transaction_allowed() {
437        let json = json!({
438            "success": true,
439            "payer": "0xABC",
440            "transaction": "",
441            "network": "eip155:1"
442        });
443        let back: SettleResponse = serde_json::from_value(json).unwrap();
444        assert!(matches!(
445            back,
446            SettleResponse::Success {
447                ref transaction,
448                payer: Some(ref payer),
449                ..
450            } if transaction.is_empty() && payer == "0xABC"
451        ));
452    }
453
454    #[test]
455    fn settle_success_empty_transaction_omitted_payer() {
456        let json = json!({
457            "success": true,
458            "transaction": "",
459            "network": "eip155:1"
460        });
461        let back: SettleResponse = serde_json::from_value(json).unwrap();
462        assert!(matches!(
463            back,
464            SettleResponse::Success {
465                ref transaction,
466                payer: None,
467                ..
468            } if transaction.is_empty()
469        ));
470        let encoded = serde_json::to_value(&back).unwrap();
471        assert_eq!(encoded["transaction"], "");
472        assert!(encoded.get("payer").is_none());
473    }
474
475    #[test]
476    fn settle_failure_roundtrip() {
477        let response = SettleResponse::Failure {
478            reason: ErrorReason::DuplicateSettlement,
479            message: Some("already processed".into()),
480            payer: None,
481            transaction: CompactString::default(),
482            network: "solana:mainnet".into(),
483            extensions: Extensions::new(),
484            extension_responses: Extensions::new(),
485            extra: None,
486        };
487        let encoded = serde_json::to_value(&response).unwrap();
488        assert_eq!(encoded["errorReason"], "duplicate_settlement");
489        assert_eq!(encoded["transaction"], "");
490        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
491        assert_eq!(back, response);
492    }
493
494    #[test]
495    fn settle_failure_serializes_empty_transaction() {
496        let response = SettleResponse::Failure {
497            reason: ErrorReason::UnexpectedSettleError,
498            message: None,
499            payer: None,
500            transaction: CompactString::default(),
501            network: "eip155:8453".into(),
502            extensions: Extensions::new(),
503            extension_responses: Extensions::new(),
504            extra: None,
505        };
506        let encoded = serde_json::to_value(&response).unwrap();
507        assert_eq!(encoded["transaction"], "");
508    }
509
510    #[test]
511    fn settle_failure_pending_roundtrip_preserves_transaction() {
512        let response = SettleResponse::Failure {
513            reason: ErrorReason::SettlementPending,
514            message: Some("rpc timeout waiting for receipt".into()),
515            payer: Some("0xpayer".into()),
516            transaction: "0xabc".into(),
517            network: "eip155:8453".into(),
518            extensions: Extensions::new(),
519            extension_responses: Extensions::new(),
520            extra: None,
521        };
522        let encoded = serde_json::to_value(&response).unwrap();
523        assert_eq!(encoded["success"], false);
524        assert_eq!(encoded["errorReason"], "settlement_pending");
525        assert_eq!(encoded["transaction"], "0xabc");
526        assert!(encoded.get("extra").is_none());
527        let back: SettleResponse = serde_json::from_value(encoded).unwrap();
528        assert_eq!(back, response);
529        assert!(back.is_retryable_settlement_pending());
530    }
531
532    #[test]
533    fn settle_failure_pending_empty_transaction_is_not_retryable() {
534        let response = SettleResponse::Failure {
535            reason: ErrorReason::SettlementPending,
536            message: None,
537            payer: None,
538            transaction: CompactString::default(),
539            network: "eip155:8453".into(),
540            extensions: Extensions::new(),
541            extension_responses: Extensions::new(),
542            extra: None,
543        };
544        assert!(!response.is_retryable_settlement_pending());
545    }
546
547    #[test]
548    fn verify_and_settle_extra_roundtrip() {
549        let extra = json!({"assetTransferMethod": "eip3009"});
550        let settle = SettleResponse::Success {
551            payer: Some("0xABC".into()),
552            transaction: "0xTX".into(),
553            network: "eip155:1".into(),
554            amount: None,
555            extensions: Extensions::new(),
556            extension_responses: Extensions::new(),
557            extra: Some(extra.clone()),
558        };
559        let settle_json = serde_json::to_value(&settle).unwrap();
560        assert_eq!(settle_json["extra"]["assetTransferMethod"], "eip3009");
561        let settle_back: SettleResponse = serde_json::from_value(settle_json).unwrap();
562        assert_eq!(settle_back, settle);
563
564        let failure = SettleResponse::Failure {
565            reason: ErrorReason::UnexpectedSettleError,
566            message: None,
567            payer: None,
568            transaction: CompactString::default(),
569            network: "eip155:1".into(),
570            extensions: Extensions::new(),
571            extension_responses: Extensions::new(),
572            extra: Some(extra),
573        };
574        let failure_json = serde_json::to_value(&failure).unwrap();
575        assert_eq!(failure_json["extra"]["assetTransferMethod"], "eip3009");
576        let failure_back: SettleResponse = serde_json::from_value(failure_json).unwrap();
577        assert_eq!(failure_back, failure);
578    }
579
580    #[test]
581    fn settle_deny_unknown_top_level_fields() {
582        let settle_typo = json!({
583            "success": true,
584            "payer": "0xABC",
585            "transaction": "0xTX",
586            "network": "eip155:1",
587            "extraTypo": {"k": 1}
588        });
589        assert!(serde_json::from_value::<SettleResponse>(settle_typo).is_err());
590    }
591
592    #[test]
593    fn from_facilitator_error_carries_transaction() {
594        let err = FacilitatorError::Onchain("rpc timeout".into());
595        let response =
596            SettleResponse::from_facilitator_error(&err, "eip155:8453", "0xpending").unwrap();
597        match response {
598            SettleResponse::Failure {
599                transaction,
600                network,
601                extra,
602                ..
603            } => {
604                assert_eq!(transaction, "0xpending");
605                assert_eq!(network, "eip155:8453");
606                assert!(extra.is_none());
607            }
608            SettleResponse::Success { .. } => panic!("expected failure"),
609        }
610    }
611
612    #[test]
613    fn from_facilitator_error_refuses_transport() {
614        let err = FacilitatorError::transport(FacilitatorTransportKind::Timeout);
615        assert!(SettleResponse::from_facilitator_error(&err, "eip155:8453", "").is_none());
616    }
617
618    #[test]
619    fn settle_encode_base64_only_for_success() {
620        let success = SettleResponse::Success {
621            payer: Some("0xA".into()),
622            transaction: "0xT".into(),
623            network: "eip155:1".into(),
624            amount: None,
625            extensions: Extensions::new(),
626            extension_responses: Extensions::new(),
627            extra: None,
628        };
629        assert!(success.encode_base64().is_some());
630
631        let failure = SettleResponse::Failure {
632            reason: ErrorReason::UnexpectedSettleError,
633            message: None,
634            payer: None,
635            transaction: CompactString::default(),
636            network: "eip155:1".into(),
637            extensions: Extensions::new(),
638            extension_responses: Extensions::new(),
639            extra: None,
640        };
641        assert!(failure.encode_base64().is_none());
642        assert!(failure.encode_base64_any().is_some());
643    }
644
645    #[test]
646    fn encode_base64_strips_extension_responses() {
647        let mut success = SettleResponse::Success {
648            payer: Some("0xA".into()),
649            transaction: "0xT".into(),
650            network: "eip155:1".into(),
651            amount: None,
652            extensions: Extensions::new(),
653            extension_responses: Extensions::new(),
654            extra: None,
655        };
656        let mut side = Extensions::new();
657        side.insert("bazaar", ExtensionEntry::raw(json!({"status": "success"})));
658        success.set_extension_responses(side);
659        let bytes = success.encode_base64().unwrap();
660        let json: serde_json::Value = serde_json::from_slice(&bytes.decode().unwrap()).unwrap();
661        assert!(json.get("extensionResponses").is_none());
662    }
663}