Skip to main content

r402_core/
client.rs

1//! Client-side payment orchestration (official `x402Client`, V2-only).
2//!
3//! Registers scheme clients, applies policies, selects a candidate, signs,
4//! and runs lifecycle hooks including **`on_payment_response`**.
5//!
6//! Transports (HTTP / MCP) wrap this type; they do not re-implement selection.
7
8use std::fmt::{self, Debug, Formatter};
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use crate::error::ClientError;
14use crate::facilitator::{BoxFuture, FailureRecovery, HookDecision};
15use crate::scheme::{FirstMatch, PaymentCandidate, PaymentPolicy, PaymentSelector, SchemeClient};
16use crate::wire::{PaymentRequired, SettleResponse};
17
18/// Context for payment-creation hooks.
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub struct PaymentCreationContext {
22    /// Parsed payment requirements from the 402 challenge.
23    pub payment_required: PaymentRequired,
24}
25
26impl PaymentCreationContext {
27    /// Constructs a creation context from a 402 challenge.
28    #[must_use]
29    pub const fn new(payment_required: PaymentRequired) -> Self {
30        Self { payment_required }
31    }
32}
33
34/// Result of successful payment creation (transport-agnostic).
35#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub struct CreatedPayment {
38    /// Base64-encoded payment payload for `Payment-Signature` / MCP meta.
39    pub signed_payload: String,
40    /// Challenge that was paid.
41    pub payment_required: PaymentRequired,
42}
43
44impl CreatedPayment {
45    /// Constructs a created-payment record.
46    #[must_use]
47    pub fn new(signed_payload: impl Into<String>, payment_required: PaymentRequired) -> Self {
48        Self {
49            signed_payload: signed_payload.into(),
50            payment_required,
51        }
52    }
53}
54
55/// Context delivered after a paid request completes.
56///
57/// Official semantics: exactly one of `settle_response` or
58/// `corrective_payment_required` is typically set —
59/// - settle: paid request succeeded with `Payment-Response`
60/// - corrective 402: server rejected with a new `Payment-Required`
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct PaymentResponseContext {
64    /// Original 402 challenge used to build the payment.
65    pub payment_required: PaymentRequired,
66    /// Signed payload that was submitted.
67    pub signed_payload: String,
68    /// Parsed settle outcome when present.
69    pub settle_response: Option<SettleResponse>,
70    /// Corrective `Payment-Required` when the paid retry returned 402.
71    pub corrective_payment_required: Option<PaymentRequired>,
72}
73
74impl PaymentResponseContext {
75    /// Constructs a payment-response context.
76    #[must_use]
77    pub fn new(payment_required: PaymentRequired, signed_payload: impl Into<String>) -> Self {
78        Self {
79            payment_required,
80            signed_payload: signed_payload.into(),
81            settle_response: None,
82            corrective_payment_required: None,
83        }
84    }
85
86    /// Builder: attach a settle response.
87    #[must_use]
88    pub fn with_settle_response(mut self, settle: SettleResponse) -> Self {
89        self.settle_response = Some(settle);
90        self
91    }
92
93    /// Builder: attach a corrective payment-required challenge.
94    #[must_use]
95    pub fn with_corrective_payment_required(mut self, required: PaymentRequired) -> Self {
96        self.corrective_payment_required = Some(required);
97        self
98    }
99}
100
101/// Result of [`ClientHooks::on_payment_response`].
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct PaymentResponseResult {
105    /// When `true`, the transport should retry once with a freshly built payload.
106    pub recovered: bool,
107}
108
109impl PaymentResponseResult {
110    /// No recovery — continue with the response as-is.
111    #[must_use]
112    pub const fn continue_() -> Self {
113        Self { recovered: false }
114    }
115
116    /// Signal one corrective retry.
117    #[must_use]
118    pub const fn recovered() -> Self {
119        Self { recovered: true }
120    }
121}
122
123/// Lifecycle hooks for the payment client (V2).
124///
125/// All methods default to no-ops. Override only what you need.
126pub trait ClientHooks: Send + Sync {
127    /// Runs before payment payload creation. Abort skips signing.
128    fn before_payment_creation<'a>(
129        &'a self,
130        _ctx: &'a PaymentCreationContext,
131    ) -> impl Future<Output = HookDecision> + Send + 'a {
132        async { HookDecision::Continue }
133    }
134
135    /// Runs after a payment payload is successfully created.
136    fn after_payment_creation<'a>(
137        &'a self,
138        _ctx: &'a PaymentCreationContext,
139        _created: &'a CreatedPayment,
140    ) -> impl Future<Output = ()> + Send + 'a {
141        async {}
142    }
143
144    /// Runs when payment creation fails. May recover with a substitute payload.
145    fn on_payment_creation_failure<'a>(
146        &'a self,
147        _ctx: &'a PaymentCreationContext,
148        _error: &'a str,
149    ) -> impl Future<Output = FailureRecovery<CreatedPayment>> + Send + 'a {
150        async { FailureRecovery::Propagate }
151    }
152
153    /// Runs after each paid response (settle success or corrective 402).
154    ///
155    /// Returning [`PaymentResponseResult::recovered`] asks the transport to
156    /// retry once with a freshly built payment payload.
157    fn on_payment_response<'a>(
158        &'a self,
159        _ctx: &'a PaymentResponseContext,
160    ) -> impl Future<Output = PaymentResponseResult> + Send + 'a {
161        async { PaymentResponseResult::continue_() }
162    }
163}
164
165/// Object-safe erasure of [`ClientHooks`].
166pub trait DynClientHooks: Send + Sync {
167    /// See [`ClientHooks::before_payment_creation`].
168    fn before_payment_creation<'a>(
169        &'a self,
170        ctx: &'a PaymentCreationContext,
171    ) -> Pin<Box<dyn Future<Output = HookDecision> + Send + 'a>>;
172
173    /// See [`ClientHooks::after_payment_creation`].
174    fn after_payment_creation<'a>(
175        &'a self,
176        ctx: &'a PaymentCreationContext,
177        created: &'a CreatedPayment,
178    ) -> BoxFuture<'a, ()>;
179
180    /// See [`ClientHooks::on_payment_creation_failure`].
181    fn on_payment_creation_failure<'a>(
182        &'a self,
183        ctx: &'a PaymentCreationContext,
184        error: &'a str,
185    ) -> BoxFuture<'a, FailureRecovery<CreatedPayment>>;
186
187    /// See [`ClientHooks::on_payment_response`].
188    fn on_payment_response<'a>(
189        &'a self,
190        ctx: &'a PaymentResponseContext,
191    ) -> Pin<Box<dyn Future<Output = PaymentResponseResult> + Send + 'a>>;
192}
193
194impl<T: ClientHooks + ?Sized> DynClientHooks for T {
195    fn before_payment_creation<'a>(
196        &'a self,
197        ctx: &'a PaymentCreationContext,
198    ) -> Pin<Box<dyn Future<Output = HookDecision> + Send + 'a>> {
199        Box::pin(<Self as ClientHooks>::before_payment_creation(self, ctx))
200    }
201
202    fn after_payment_creation<'a>(
203        &'a self,
204        ctx: &'a PaymentCreationContext,
205        created: &'a CreatedPayment,
206    ) -> BoxFuture<'a, ()> {
207        Box::pin(<Self as ClientHooks>::after_payment_creation(
208            self, ctx, created,
209        ))
210    }
211
212    fn on_payment_creation_failure<'a>(
213        &'a self,
214        ctx: &'a PaymentCreationContext,
215        error: &'a str,
216    ) -> BoxFuture<'a, FailureRecovery<CreatedPayment>> {
217        Box::pin(<Self as ClientHooks>::on_payment_creation_failure(
218            self, ctx, error,
219        ))
220    }
221
222    fn on_payment_response<'a>(
223        &'a self,
224        ctx: &'a PaymentResponseContext,
225    ) -> Pin<Box<dyn Future<Output = PaymentResponseResult> + Send + 'a>> {
226        Box::pin(<Self as ClientHooks>::on_payment_response(self, ctx))
227    }
228}
229
230impl Debug for dyn DynClientHooks {
231    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
232        f.write_str("DynClientHooks")
233    }
234}
235
236/// V2 payment client — scheme registry + selector + policies + hooks.
237///
238/// Mirrors Go/TS `x402Client` without V1 registration paths.
239pub struct PaymentClient<S = FirstMatch> {
240    schemes: Vec<Arc<dyn SchemeClient>>,
241    selector: S,
242    policies: Vec<Arc<dyn PaymentPolicy>>,
243    hooks: Vec<Arc<dyn DynClientHooks>>,
244}
245
246impl PaymentClient<FirstMatch> {
247    /// Empty client with [`FirstMatch`] selection.
248    #[must_use]
249    pub fn new() -> Self {
250        Self::default()
251    }
252}
253
254impl Default for PaymentClient<FirstMatch> {
255    fn default() -> Self {
256        Self {
257            schemes: Vec::new(),
258            selector: FirstMatch,
259            policies: Vec::new(),
260            hooks: Vec::new(),
261        }
262    }
263}
264
265impl<S> Debug for PaymentClient<S> {
266    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
267        f.debug_struct("PaymentClient")
268            .field("schemes", &self.schemes.len())
269            .field("policies", &self.policies.len())
270            .field("hooks", &self.hooks.len())
271            .finish_non_exhaustive()
272    }
273}
274
275impl<S> PaymentClient<S> {
276    /// Registers a V2 scheme client.
277    #[must_use]
278    pub fn register(mut self, scheme: impl SchemeClient + 'static) -> Self {
279        self.schemes.push(Arc::new(scheme));
280        self
281    }
282
283    /// Replaces the payment selector.
284    #[must_use]
285    pub fn with_selector<P: PaymentSelector>(self, selector: P) -> PaymentClient<P> {
286        PaymentClient {
287            schemes: self.schemes,
288            selector,
289            policies: self.policies,
290            hooks: self.hooks,
291        }
292    }
293
294    /// Appends a payment policy (applied in registration order).
295    #[must_use]
296    pub fn with_policy(mut self, policy: impl PaymentPolicy + 'static) -> Self {
297        self.policies.push(Arc::new(policy));
298        self
299    }
300
301    /// Registers a client lifecycle hook.
302    #[must_use]
303    pub fn with_hook(mut self, hook: impl ClientHooks + 'static) -> Self {
304        self.hooks.push(Arc::new(hook));
305        self
306    }
307
308    /// Number of registered scheme clients.
309    #[must_use]
310    pub fn scheme_count(&self) -> usize {
311        self.schemes.len()
312    }
313
314    /// Number of registered hooks.
315    #[must_use]
316    pub fn hook_count(&self) -> usize {
317        self.hooks.len()
318    }
319}
320
321impl<S: PaymentSelector> PaymentClient<S> {
322    /// Collects candidates from every registered scheme client.
323    #[must_use]
324    pub fn candidates(&self, payment_required: &PaymentRequired) -> Vec<PaymentCandidate> {
325        let mut out = Vec::new();
326        for client in &self.schemes {
327            out.extend(client.accept(payment_required));
328        }
329        out
330    }
331
332    /// Creates a signed payment payload for a 402 challenge.
333    ///
334    /// Runs before/after/failure hooks around selection + signing.
335    ///
336    /// # Errors
337    ///
338    /// Propagates selection, signing, and before-hook abort errors.
339    pub async fn create_payment(
340        &self,
341        payment_required: &PaymentRequired,
342    ) -> Result<CreatedPayment, ClientError> {
343        let ctx = PaymentCreationContext {
344            payment_required: payment_required.clone(),
345        };
346
347        if let Some(err) = self.run_before_creation(&ctx).await {
348            return Err(err);
349        }
350
351        match self.create_payment_inner(payment_required).await {
352            Ok(created) => {
353                self.run_after_creation(&ctx, &created).await;
354                Ok(created)
355            }
356            Err(err) => self.recover_creation(&ctx, err).await,
357        }
358    }
359
360    async fn run_before_creation(&self, ctx: &PaymentCreationContext) -> Option<ClientError> {
361        for hook in &self.hooks {
362            if let Some(err) =
363                Self::client_error_from_abort(hook.before_payment_creation(ctx).await)
364            {
365                return Some(err);
366            }
367        }
368        None
369    }
370
371    fn client_error_from_abort(decision: HookDecision) -> Option<ClientError> {
372        let HookDecision::Abort { reason, message } = decision else {
373            return None;
374        };
375        let detail = if message.is_empty() {
376            reason
377        } else {
378            format!("{reason}: {message}")
379        };
380        Some(ClientError::Parse(detail))
381    }
382
383    async fn run_after_creation(&self, ctx: &PaymentCreationContext, created: &CreatedPayment) {
384        for hook in &self.hooks {
385            hook.after_payment_creation(ctx, created).await;
386        }
387    }
388
389    async fn recover_creation(
390        &self,
391        ctx: &PaymentCreationContext,
392        err: ClientError,
393    ) -> Result<CreatedPayment, ClientError> {
394        let msg = err.to_string();
395        for hook in &self.hooks {
396            let recovery = hook.on_payment_creation_failure(ctx, &msg).await;
397            if let FailureRecovery::Recovered(created) = recovery {
398                return Ok(created);
399            }
400        }
401        Err(err)
402    }
403
404    async fn create_payment_inner(
405        &self,
406        payment_required: &PaymentRequired,
407    ) -> Result<CreatedPayment, ClientError> {
408        let candidates = self.candidates(payment_required);
409        let mut filtered: Vec<&PaymentCandidate> = candidates.iter().collect();
410        for policy in &self.policies {
411            filtered = policy.apply(filtered);
412            if filtered.is_empty() {
413                return Err(ClientError::NoMatchingPaymentOption);
414            }
415        }
416        let selected = self
417            .selector
418            .select(&filtered)
419            .ok_or(ClientError::NoMatchingPaymentOption)?;
420        let signed_payload = selected.sign().await?;
421        Ok(CreatedPayment::new(
422            signed_payload,
423            payment_required.clone(),
424        ))
425    }
426
427    /// Dispatches [`ClientHooks::on_payment_response`] for every hook.
428    ///
429    /// First `recovered: true` wins; remaining hooks still run (instrumentation).
430    pub async fn handle_payment_response(
431        &self,
432        ctx: &PaymentResponseContext,
433    ) -> PaymentResponseResult {
434        let mut recovered = false;
435        for hook in &self.hooks {
436            let result = hook.on_payment_response(ctx).await;
437            if result.recovered {
438                recovered = true;
439            }
440        }
441        if recovered {
442            PaymentResponseResult::recovered()
443        } else {
444            PaymentResponseResult::continue_()
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use std::future::Future;
452
453    use super::*;
454    use crate::chain::ChainId;
455    use crate::scheme::{PaymentCandidateSigner, SchemeId, Sealed};
456    use crate::wire::{PaymentRequirements, ResourceInfo};
457
458    struct StubSigner(String);
459    impl PaymentCandidateSigner for StubSigner {
460        fn sign_payment<'a>(
461            &'a self,
462        ) -> Pin<Box<dyn Future<Output = Result<String, ClientError>> + Send + 'a>> {
463            Box::pin(async move { Ok(self.0.clone()) })
464        }
465    }
466
467    struct StubScheme;
468    impl Sealed for StubScheme {}
469    impl SchemeId for StubScheme {
470        fn namespace(&self) -> &'static str {
471            "eip155"
472        }
473        fn scheme(&self) -> &'static str {
474            "exact"
475        }
476    }
477    impl SchemeClient for StubScheme {
478        fn accept(&self, required: &PaymentRequired) -> Vec<PaymentCandidate> {
479            required
480                .accepts
481                .iter()
482                .map(|r| PaymentCandidate {
483                    chain_id: r.network.clone(),
484                    asset: r.asset.clone(),
485                    amount: r.amount.clone(),
486                    scheme: r.scheme.clone(),
487                    pay_to: r.pay_to.clone(),
488                    signer: Box::new(StubSigner("c2lnbmVk".into())),
489                })
490                .collect()
491        }
492    }
493
494    struct AbortHook;
495    impl ClientHooks for AbortHook {
496        fn before_payment_creation<'a>(
497            &'a self,
498            _: &PaymentCreationContext,
499        ) -> impl Future<Output = HookDecision> + Send + 'a {
500            std::future::ready(HookDecision::Abort {
501                reason: "nope".into(),
502                message: String::new(),
503            })
504        }
505    }
506
507    struct RecoverHook;
508    impl ClientHooks for RecoverHook {
509        fn on_payment_response<'a>(
510            &'a self,
511            _: &PaymentResponseContext,
512        ) -> impl Future<Output = PaymentResponseResult> + Send + 'a {
513            std::future::ready(PaymentResponseResult::recovered())
514        }
515    }
516
517    fn sample_required() -> PaymentRequired {
518        let req = PaymentRequirements::new(
519            "exact".into(),
520            "eip155:1".parse::<ChainId>().unwrap(),
521            "1".into(),
522            "0xa".into(),
523            "0xb".into(),
524            60,
525        );
526        PaymentRequired::new(ResourceInfo::new("https://example.com")).with_accepts(vec![req])
527    }
528
529    #[tokio::test]
530    async fn create_payment_signs() {
531        let client = PaymentClient::new().register(StubScheme);
532        let created = client.create_payment(&sample_required()).await.unwrap();
533        assert_eq!(created.signed_payload, "c2lnbmVk");
534    }
535
536    #[tokio::test]
537    async fn before_hook_aborts() {
538        let client = PaymentClient::new()
539            .register(StubScheme)
540            .with_hook(AbortHook);
541        let err = client.create_payment(&sample_required()).await.unwrap_err();
542        assert!(matches!(err, ClientError::Parse(s) if s.contains("nope")));
543    }
544
545    #[tokio::test]
546    async fn payment_response_hook_recovers() {
547        let client = PaymentClient::new().with_hook(RecoverHook);
548        let ctx = PaymentResponseContext::new(sample_required(), "x")
549            .with_corrective_payment_required(sample_required());
550        let result = client.handle_payment_response(&ctx).await;
551        assert!(result.recovered);
552    }
553
554    #[tokio::test]
555    async fn no_schemes_yields_no_match() {
556        let client = PaymentClient::new();
557        let err = client.create_payment(&sample_required()).await.unwrap_err();
558        assert!(matches!(err, ClientError::NoMatchingPaymentOption));
559    }
560}