Skip to main content

r402_http/server/
paygate.rs

1//! Core payment gate logic for enforcing x402 payments (V2-only).
2//!
3//! The [`Paygate`] struct handles the full payment lifecycle:
4//! extracting headers, verifying with the facilitator, settling on-chain,
5//! and returning 402 responses when payment is required.
6//!
7//! Three settlement strategies are available:
8//!
9//! - **Sequential** ([`Paygate::handle_request`]):
10//!   verify → execute → settle. Settlement only runs after the handler
11//!   succeeds.
12//! - **Concurrent** ([`Paygate::handle_request_concurrent`]):
13//!   verify → (settle ∥ execute) → await settle. Settlement runs in
14//!   parallel with the handler, reducing total latency by one settle RTT.
15//! - **Background** ([`Paygate::handle_request_background`]):
16//!   verify → spawn settle (fire-and-forget) → execute → return. Ideal for
17//!   streaming responses where the client should receive data immediately.
18
19use std::sync::Arc;
20
21use axum_core::body::Body;
22use axum_core::extract::Request;
23use axum_core::response::{IntoResponse, Response};
24use http::header::ACCESS_CONTROL_EXPOSE_HEADERS;
25use http::{HeaderMap, HeaderValue, StatusCode};
26use r402_core::error::ErrorReason;
27use r402_core::facilitator::{DynFacilitator, Facilitator};
28use r402_core::resource_server::{CancelReason, ResourceServer, ResourceServerHooks};
29use r402_core::wire;
30use r402_core::wire::Base64Bytes;
31use serde_json::json;
32use tower::Service;
33#[cfg(feature = "telemetry")]
34use tracing::{Instrument, instrument};
35use url::Url;
36
37use super::hooks::DynPaygateHooks;
38use super::tracker::{BackgroundSettlementTracker, SettlementInFlightGuard};
39
40const PAYMENT_HEADER: &str = "Payment-Signature";
41
42/// The canonical list of x402 response headers clients need to see.
43pub const X402_EXPOSED_HEADERS: &str = "Payment-Required, Payment-Response";
44
45/// Ensures `Access-Control-Expose-Headers` advertises the x402 response
46/// headers. Idempotent.
47pub fn ensure_expose_headers(headers: &mut HeaderMap) {
48    let x402 = HeaderValue::from_static(X402_EXPOSED_HEADERS);
49    match headers.get(ACCESS_CONTROL_EXPOSE_HEADERS) {
50        None => {
51            let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
52        }
53        Some(existing) => {
54            let Ok(existing_str) = existing.to_str() else {
55                let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
56                return;
57            };
58            if existing_str.contains("Payment-Required")
59                && existing_str.contains("Payment-Response")
60            {
61                return;
62            }
63            let merged = format!("{existing_str}, {X402_EXPOSED_HEADERS}");
64            if let Ok(value) = HeaderValue::from_str(&merged) {
65                let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, value);
66            }
67        }
68    }
69}
70
71/// Returns the HTTP status corresponding to an [`ErrorReason`].
72///
73/// `Permit2AllowanceRequired` maps to `412`; everything else to `402`.
74#[must_use]
75pub const fn reason_to_status(reason: &ErrorReason) -> StatusCode {
76    match reason {
77        ErrorReason::Permit2AllowanceRequired => StatusCode::PRECONDITION_FAILED,
78        _ => StatusCode::PAYMENT_REQUIRED,
79    }
80}
81
82/// Payment gate error encompassing header, verification, and settlement failures.
83#[derive(Debug, thiserror::Error)]
84pub enum PaygateError {
85    /// The `Payment-Signature` header is missing from the request.
86    #[error("Payment-Signature header is required")]
87    PaymentHeaderMissing,
88    /// The payment header is present but malformed.
89    #[error("Invalid or malformed payment header")]
90    InvalidPaymentHeader,
91    /// No accepted price tag matches the payment payload.
92    #[error("Unable to find matching payment requirements")]
93    NoPaymentMatching,
94    /// The facilitator rejected the payment.
95    #[error("Verification failed: {0}")]
96    VerificationFailed(String),
97    /// Facilitator returned a structured `SettleResponse::Failure`.
98    ///
99    /// The failure body is preserved end-to-end so the paygate can emit it
100    /// via the `Payment-Response` HTTP header per x402 v2 §HTTP transport,
101    /// giving browser clients access to the machine-readable error reason.
102    #[error("settlement failed: {}", settlement_failure_summary(.0))]
103    Settlement(Box<wire::SettleResponse>),
104    /// Internal error before a structured settlement response could be
105    /// obtained (timeout, panic in spawned task, malformed override, etc.).
106    /// Renders as a 402 with no `Payment-Response` header.
107    #[error("settlement aborted: {0}")]
108    SettlementAborted(String),
109}
110
111#[allow(
112    clippy::missing_const_for_fn,
113    reason = "const fn would prevent matching on `Box` indirection"
114)]
115fn settlement_failure_summary(resp: &wire::SettleResponse) -> String {
116    match resp {
117        wire::SettleResponse::Failure {
118            reason,
119            message,
120            network,
121            ..
122        } => format!(
123            "{} ({}){}",
124            reason,
125            network,
126            message
127                .as_ref()
128                .map(|m| format!(": {m}"))
129                .unwrap_or_default(),
130        ),
131        wire::SettleResponse::Success { .. } => "success returned via error path".to_owned(),
132        // wire::SettleResponse is `#[non_exhaustive]`; future variants
133        // surface as a generic placeholder so the formatter remains total.
134        _ => "unknown settlement variant".to_owned(),
135    }
136}
137
138type PaymentPayload = wire::PaymentPayload<wire::PaymentRequirements, serde_json::Value>;
139
140/// Template for resource metadata included in 402 responses.
141///
142/// When `url` is `None`, the full resource URL is derived at request time
143/// from the base URL and the request URI.
144#[derive(Debug, Clone)]
145pub struct ResourceTemplate {
146    /// Description of the protected resource.
147    pub description: String,
148    /// MIME type of the protected resource.
149    pub mime_type: String,
150    /// Optional explicit URL; when `None`, derived from the request.
151    pub url: Option<String>,
152}
153
154impl Default for ResourceTemplate {
155    fn default() -> Self {
156        Self {
157            description: String::new(),
158            mime_type: "application/json".to_owned(),
159            url: None,
160        }
161    }
162}
163
164impl ResourceTemplate {
165    /// Resolves this template into a concrete [`wire::ResourceInfo`].
166    ///
167    /// If `url` is already set, it is used directly. Otherwise, the URL is
168    /// constructed by joining `base_url` (or a fallback derived from the
169    /// `Host` header) with the request path and query.
170    ///
171    /// # Panics
172    ///
173    /// Panics if the hardcoded fallback URL `http://localhost` cannot be
174    /// parsed, which should never happen in practice.
175    #[allow(clippy::unwrap_used, reason = "fallback URL is a hardcoded constant")]
176    pub fn resolve(&self, base_url: Option<&Url>, req: &Request) -> wire::ResourceInfo {
177        let url = self.url.clone().unwrap_or_else(|| {
178            let mut url = base_url.cloned().unwrap_or_else(|| {
179                let host = req
180                    .headers()
181                    .get("host")
182                    .and_then(|h| h.to_str().ok())
183                    .unwrap_or("localhost");
184                let origin = format!("http://{host}");
185                let url =
186                    Url::parse(&origin).unwrap_or_else(|_| Url::parse("http://localhost").unwrap());
187                #[cfg(feature = "telemetry")]
188                tracing::warn!(
189                    "X402Middleware base_url is not configured; \
190                     using {url} as origin for resource resolution"
191                );
192                url
193            });
194            url.set_path(req.uri().path());
195            url.set_query(req.uri().query());
196            url.to_string()
197        });
198        let mut info = wire::ResourceInfo::new(url);
199        if !self.description.is_empty() {
200            info = info.with_description(self.description.clone());
201        }
202        if !self.mime_type.is_empty() {
203            info = info.with_mime_type(self.mime_type.clone());
204        }
205        info
206    }
207}
208
209/// Builder for constructing a [`Paygate`] with validated configuration.
210///
211/// # Example
212///
213/// ```ignore
214/// let gate = Paygate::builder(facilitator)
215///     .accept(price_tag)
216///     .resource(resource_info)
217///     .build();
218/// ```
219#[allow(
220    missing_debug_implementations,
221    reason = "ResourceServer contains dyn facilitator handles"
222)]
223pub struct PaygateBuilder {
224    server: ResourceServer,
225    accepts: Vec<wire::PriceTag>,
226    resource: Option<wire::ResourceInfo>,
227    hooks: Option<Arc<dyn DynPaygateHooks>>,
228    settlement_tracker: Option<BackgroundSettlementTracker>,
229}
230
231impl PaygateBuilder {
232    /// Adds a single accepted payment option.
233    #[must_use]
234    pub fn accept(mut self, price_tag: wire::PriceTag) -> Self {
235        self.accepts.push(price_tag);
236        self
237    }
238
239    /// Adds multiple accepted payment options.
240    #[must_use]
241    pub fn accepts(mut self, price_tags: impl IntoIterator<Item = wire::PriceTag>) -> Self {
242        self.accepts.extend(price_tags);
243        self
244    }
245
246    /// Sets the resource metadata returned in 402 responses.
247    #[must_use]
248    pub fn resource(mut self, resource: wire::ResourceInfo) -> Self {
249        self.resource = Some(resource);
250        self
251    }
252
253    /// Attaches [`PaygateHooks`](super::hooks::PaygateHooks) for pre- and
254    /// post-payment extensibility (Fix-8). Stored as an `Arc<dyn>` so hook
255    /// state can be shared across cloned middleware instances without
256    /// duplication.
257    #[must_use]
258    pub fn hooks<H>(mut self, hooks: H) -> Self
259    where
260        H: super::hooks::PaygateHooks + 'static,
261    {
262        self.hooks = Some(Arc::new(hooks));
263        self
264    }
265
266    /// Attaches hooks that are already stored behind an
267    /// [`Arc<dyn DynPaygateHooks>`].
268    ///
269    /// This avoids re-wrapping when the same hook object needs to be shared
270    /// between the middleware layer and the paygate it constructs.
271    #[must_use]
272    pub fn hooks_dyn(mut self, hooks: Arc<dyn DynPaygateHooks>) -> Self {
273        self.hooks = Some(hooks);
274        self
275    }
276
277    /// Registers a transport-agnostic [`ResourceServerHooks`] lifecycle hook.
278    #[must_use]
279    pub fn with_resource_hook(mut self, hook: impl ResourceServerHooks + 'static) -> Self {
280        self.server.add_hook(hook);
281        self
282    }
283
284    /// Attaches a [`BackgroundSettlementTracker`] so background settlement
285    /// tasks register with it. Used to await in-flight settlements during
286    /// graceful shutdown via [`Paygate::settlement_tracker`] +
287    /// [`BackgroundSettlementTracker::wait_for_drain`].
288    #[must_use]
289    pub fn with_settlement_tracker(mut self, tracker: BackgroundSettlementTracker) -> Self {
290        self.settlement_tracker = Some(tracker);
291        self
292    }
293
294    /// Consumes the builder and produces a configured [`Paygate`].
295    ///
296    /// Uses empty resource info if none was provided.
297    #[must_use]
298    pub fn build(self) -> Paygate {
299        Paygate {
300            server: self.server,
301            accepts: self.accepts.into(),
302            resource: self
303                .resource
304                .unwrap_or_else(|| wire::ResourceInfo::new("").with_mime_type("application/json")),
305            hooks: self.hooks,
306            settlement_tracker: self.settlement_tracker,
307        }
308    }
309}
310
311/// V2-only payment gate for enforcing x402 payments.
312///
313/// Handles the full payment lifecycle: header extraction, verification,
314/// settlement, and 402 response generation using the V2 wire format.
315///
316/// Construct via [`PaygateBuilder`] (obtained from [`Paygate::builder`]).
317///
318/// To add lifecycle hooks (before/after verify and settle), wrap your
319/// facilitator with [`HookedFacilitator`](r402_core::facilitator::HookedFacilitator)
320/// before passing it to the payment gate.
321#[allow(
322    missing_debug_implementations,
323    reason = "ResourceServer contains dyn facilitator handles"
324)]
325pub struct Paygate {
326    pub(crate) server: ResourceServer,
327    pub(crate) accepts: Arc<[wire::PriceTag]>,
328    pub(crate) resource: wire::ResourceInfo,
329    pub(crate) hooks: Option<Arc<dyn DynPaygateHooks>>,
330    /// Optional tracker for background settlement tasks.
331    ///
332    /// When [`PaygateBuilder::with_settlement_tracker`] is set, every
333    /// `handle_request_background` call increments the in-flight counter
334    /// before spawning and decrements it once the supervisor records the
335    /// outcome. Operators call [`Self::settlement_tracker`] +
336    /// [`BackgroundSettlementTracker::wait_for_drain`] during shutdown to
337    /// await the drain (with a timeout safeguard).
338    pub(crate) settlement_tracker: Option<BackgroundSettlementTracker>,
339}
340
341impl Paygate {
342    /// Returns a new builder seeded with the given facilitator.
343    pub fn builder(facilitator: impl Facilitator + 'static) -> PaygateBuilder {
344        PaygateBuilder {
345            server: ResourceServer::new(Arc::new(facilitator)),
346            accepts: Vec::new(),
347            resource: None,
348            hooks: None,
349            settlement_tracker: None,
350        }
351    }
352
353    /// Returns a new builder over an already-erased facilitator handle.
354    #[must_use]
355    pub fn builder_from_dyn(facilitator: Arc<dyn DynFacilitator>) -> PaygateBuilder {
356        PaygateBuilder {
357            server: ResourceServer::from_dyn(facilitator),
358            accepts: Vec::new(),
359            resource: None,
360            hooks: None,
361            settlement_tracker: None,
362        }
363    }
364
365    /// Returns a new builder from an existing [`ResourceServer`] (hooks included).
366    #[must_use]
367    pub fn builder_from_server(server: ResourceServer) -> PaygateBuilder {
368        PaygateBuilder {
369            server,
370            accepts: Vec::new(),
371            resource: None,
372            hooks: None,
373            settlement_tracker: None,
374        }
375    }
376
377    /// Returns a reference to the resource server (facilitator + hooks).
378    #[must_use]
379    pub const fn resource_server(&self) -> &ResourceServer {
380        &self.server
381    }
382
383    /// Returns a clone of the erased facilitator handle.
384    #[must_use]
385    pub fn facilitator(&self) -> Arc<dyn DynFacilitator> {
386        self.server.facilitator()
387    }
388
389    /// Returns a reference to the accepted price tags.
390    #[must_use]
391    pub fn accepts(&self) -> &[wire::PriceTag] {
392        &self.accepts
393    }
394
395    /// Returns the in-flight settlement tracker, if one was attached at
396    /// construction time.
397    ///
398    /// The handle is shareable; clone it and pass to a shutdown task to
399    /// await the in-flight drain via
400    /// [`BackgroundSettlementTracker::wait_for_drain`]:
401    ///
402    /// ```ignore
403    /// if let Some(tracker) = paygate.settlement_tracker().cloned() {
404    ///     tokio::spawn(async move {
405    ///         match tracker.wait_for_drain(Duration::from_secs(30)).await {
406    ///             Ok(()) => tracing::info!("settle drain complete"),
407    ///             Err(remaining) => tracing::warn!(remaining, "drain timeout"),
408    ///         }
409    ///     });
410    /// }
411    /// ```
412    #[must_use]
413    pub const fn settlement_tracker(&self) -> Option<&BackgroundSettlementTracker> {
414        self.settlement_tracker.as_ref()
415    }
416
417    /// Returns a reference to the resource information.
418    #[must_use]
419    pub const fn resource(&self) -> &wire::ResourceInfo {
420        &self.resource
421    }
422
423    /// Returns the attached paygate hooks, if any.
424    ///
425    /// The middleware layer uses this accessor to dispatch
426    /// [`DynPaygateHooks::on_protected_request`] and
427    /// [`DynPaygateHooks::on_payment_verified`] around the payment check.
428    #[must_use]
429    pub fn hooks(&self) -> Option<&Arc<dyn DynPaygateHooks>> {
430        self.hooks.as_ref()
431    }
432
433    /// Converts a [`PaygateError`] into a proper HTTP response.
434    ///
435    /// Verification errors produce a 402 with the `Payment-Required` header
436    /// and a JSON body. Settlement errors produce a 402 with error details.
437    ///
438    /// # Panics
439    ///
440    /// Panics if the payment-required response cannot be serialized to JSON
441    /// or if the HTTP response builder fails. These indicate a bug.
442    #[must_use]
443    #[allow(
444        clippy::expect_used,
445        reason = "infallible JSON/HTTP construction; panic indicates a bug"
446    )]
447    pub fn error_response(&self, err: PaygateError) -> Response {
448        match err {
449            PaygateError::PaymentHeaderMissing
450            | PaygateError::InvalidPaymentHeader
451            | PaygateError::NoPaymentMatching
452            | PaygateError::VerificationFailed(_) => {
453                let (status, payment_required) = {
454                    let status = inferred_status(&err);
455                    let payment_required = wire::PaymentRequired::new(self.resource.clone())
456                        .with_error(err.to_string())
457                        .with_accepts(
458                            self.accepts
459                                .iter()
460                                .map(|pt| pt.requirements.clone())
461                                .collect(),
462                        );
463                    (status, payment_required)
464                };
465                let body_bytes =
466                    serde_json::to_vec(&payment_required).expect("serialization failed");
467                let header_value =
468                    HeaderValue::from_bytes(Base64Bytes::encode(&body_bytes).as_ref())
469                        .expect("invalid header value");
470
471                let mut response = Response::builder()
472                    .status(status)
473                    .header("Payment-Required", header_value)
474                    .header("Content-Type", "application/json")
475                    .body(Body::from(body_bytes))
476                    .expect("failed to construct response");
477                // Fix-6: expose Payment-Required / Payment-Response headers to
478                // browser clients via CORS.
479                ensure_expose_headers(response.headers_mut());
480                response
481            }
482            PaygateError::Settlement(failure) => {
483                #[cfg(feature = "telemetry")]
484                tracing::error!(failure = ?failure, "Settlement failed");
485                let body_bytes = serde_json::to_vec(&*failure).expect("serialization failed");
486                let header_value = failure
487                    .encode_base64_any()
488                    .and_then(|b64| HeaderValue::from_bytes(b64.as_ref()).ok());
489
490                let mut builder = Response::builder()
491                    .status(StatusCode::PAYMENT_REQUIRED)
492                    .header("Content-Type", "application/json");
493                if let Some(header_value) = header_value {
494                    builder = builder.header("Payment-Response", header_value);
495                }
496                let mut response = builder
497                    .body(Body::from(body_bytes))
498                    .expect("failed to construct response");
499                ensure_expose_headers(response.headers_mut());
500                response
501            }
502            PaygateError::SettlementAborted(ref detail) => {
503                #[cfg(feature = "telemetry")]
504                tracing::error!(details = %detail, "Settlement aborted");
505                let body = json!({
506                    "error": "settlement aborted",
507                    "details": detail,
508                })
509                .to_string();
510
511                let mut response = Response::builder()
512                    .status(StatusCode::PAYMENT_REQUIRED)
513                    .header("Content-Type", "application/json")
514                    .body(Body::from(body))
515                    .expect("failed to construct response");
516                ensure_expose_headers(response.headers_mut());
517                response
518            }
519        }
520    }
521}
522
523impl Paygate {
524    /// Enriches price tags with facilitator capabilities (e.g., fee payer address).
525    pub async fn enrich_accepts(&mut self) {
526        let facilitator = self.facilitator();
527        let capabilities = Facilitator::supported(&facilitator)
528            .await
529            .unwrap_or_default();
530        let accepts: Vec<_> = self
531            .accepts
532            .iter()
533            .cloned()
534            .map(|mut pt| {
535                pt.enrich(&capabilities);
536                pt
537            })
538            .collect();
539        self.accepts = accepts.into();
540    }
541
542    /// Verifies the payment from request headers without executing the inner
543    /// service or settling on-chain.
544    ///
545    /// Runs [`ResourceServer`] lifecycle hooks (before/after verify, failure
546    /// recovery). Returns a [`VerifiedPayment`] token on success.
547    ///
548    /// # Errors
549    ///
550    /// Returns a verification [`PaygateError`] if the payment header is missing,
551    /// malformed, or rejected by the facilitator / hooks.
552    #[cfg_attr(feature = "telemetry", instrument(name = "x402.verify_only", skip_all))]
553    pub async fn verify_only(&self, headers: &HeaderMap) -> Result<VerifiedPayment, PaygateError> {
554        let header_bytes = headers
555            .get(PAYMENT_HEADER)
556            .map(HeaderValue::as_bytes)
557            .ok_or(PaygateError::PaymentHeaderMissing)?;
558
559        let payload: PaymentPayload =
560            decode_payment_payload(header_bytes).ok_or(PaygateError::InvalidPaymentHeader)?;
561
562        let requirements = match_requirements(&payload, &self.accepts)?;
563        let outcome = self
564            .server
565            .verify_payment(&payload, &requirements)
566            .await
567            .map_err(|e| PaygateError::VerificationFailed(format!("{e}")))?;
568
569        if let wire::VerifyResponse::Invalid { reason, .. } = &outcome.response {
570            return Err(PaygateError::VerificationFailed(reason.to_string()));
571        }
572
573        // Build settle request from the same payload/requirements pair.
574        let settle_request = build_settle_request(&payload, &requirements).map_err(|e| {
575            PaygateError::VerificationFailed(format!("settle request build failed: {e}"))
576        })?;
577
578        Ok(VerifiedPayment {
579            settle_request,
580            payload,
581            requirements,
582            server: self.server.clone(),
583            skip_handler: outcome.skip_handler,
584        })
585    }
586
587    /// Handles an incoming request with **sequential** settlement.
588    ///
589    /// ```text
590    /// verify → execute → settle → attach header → return
591    /// ```
592    ///
593    /// Settlement only runs if the handler returns a success status (not 4xx/5xx).
594    ///
595    /// # Errors
596    ///
597    /// Returns [`PaygateError`] if payment verification or settlement fails.
598    #[cfg_attr(
599        feature = "telemetry",
600        instrument(name = "x402.handle_request", skip_all)
601    )]
602    pub async fn handle_request<
603        ReqBody,
604        ResBody,
605        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
606    >(
607        &self,
608        inner: S,
609        req: http::Request<ReqBody>,
610    ) -> Result<Response, PaygateError>
611    where
612        S::Response: IntoResponse,
613        S::Error: IntoResponse,
614        S::Future: Send,
615    {
616        let verified = self.verify_only(req.headers()).await?;
617        let cancel = verified.cancellation_guard();
618
619        // After-verify SkipHandler: settle without invoking the resource handler.
620        if let Some(directive) = verified.skip_handler.clone() {
621            let settlement = verified.settle_with_override(None).await?;
622            return skip_handler_response(&directive, &settlement);
623        }
624
625        let response = match call_inner(inner, req).await {
626            Ok(r) => r,
627            Err(err) => {
628                cancel
629                    .cancel(
630                        CancelReason::HandlerThrew,
631                        Some("inner service error"),
632                        None,
633                    )
634                    .await;
635                return Ok(err.into_response());
636            }
637        };
638
639        if response.status().is_client_error() || response.status().is_server_error() {
640            cancel
641                .cancel(
642                    CancelReason::HandlerFailed,
643                    Some("handler returned error status"),
644                    Some(response.status().as_u16()),
645                )
646                .await;
647            return Ok(response.into_response());
648        }
649
650        let mut response = response.into_response();
651        // Upto: Settlement-Overrides header and/or UptoActualAmount extension.
652        let override_amount =
653            super::upto::resolve_response_settlement_amount(&mut response, verified.requirements())
654                .map_err(|e| PaygateError::SettlementAborted(e.to_string()))?;
655
656        let settlement = verified
657            .settle_with_override(override_amount.as_deref())
658            .await?;
659        let header_value = settlement_to_header(&settlement)?;
660
661        response
662            .headers_mut()
663            .insert("Payment-Response", header_value);
664        // Browser clients need Access-Control-Expose-Headers for Payment-Response.
665        ensure_expose_headers(response.headers_mut());
666        Ok(response)
667    }
668}
669
670impl Paygate {
671    /// Handles an incoming request with **concurrent** settlement.
672    ///
673    /// ```text
674    /// verify → (settle ∥ execute) → await settle → attach header → return
675    /// ```
676    ///
677    /// Settlement is spawned immediately after verification and runs in
678    /// parallel with the handler, reducing total latency by one facilitator RTT.
679    /// On handler error (4xx/5xx), the settlement task is abandoned.
680    ///
681    /// # Errors
682    ///
683    /// Returns [`PaygateError`] if payment verification or settlement fails.
684    #[cfg_attr(
685        feature = "telemetry",
686        instrument(name = "x402.handle_request_concurrent", skip_all)
687    )]
688    pub async fn handle_request_concurrent<
689        ReqBody,
690        ResBody,
691        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
692    >(
693        &self,
694        inner: S,
695        req: http::Request<ReqBody>,
696    ) -> Result<Response, PaygateError>
697    where
698        S::Response: IntoResponse,
699        S::Error: IntoResponse,
700        S::Future: Send + 'static,
701        ReqBody: Send + 'static,
702    {
703        let verified = self.verify_only(req.headers()).await?;
704        let cancel = verified.cancellation_guard();
705
706        if let Some(directive) = verified.skip_handler.clone() {
707            let settlement = verified.settle().await?;
708            return skip_handler_response(&directive, &settlement);
709        }
710
711        // Concurrent settle runs at the signed maximum in parallel with the
712        // handler. Partial settlement (Settlement-Overrides / UptoActualAmount)
713        // requires Sequential mode — reject rather than silently over-charge.
714        let settle_handle = tokio::spawn(async move { verified.settle().await });
715
716        let response = match call_inner(inner, req).await {
717            Ok(r) => r,
718            Err(err) => {
719                drop(settle_handle);
720                cancel
721                    .cancel(
722                        CancelReason::HandlerThrew,
723                        Some("inner service error"),
724                        None,
725                    )
726                    .await;
727                return Ok(err.into_response());
728            }
729        };
730
731        if response.status().is_client_error() || response.status().is_server_error() {
732            drop(settle_handle);
733            cancel
734                .cancel(
735                    CancelReason::HandlerFailed,
736                    Some("handler returned error status"),
737                    Some(response.status().as_u16()),
738                )
739                .await;
740            return Ok(response.into_response());
741        }
742
743        let mut res = response.into_response();
744        // Strip billing header even when unused so it never reaches the client.
745        let partial = super::upto::take_settlement_overrides_header(res.headers_mut());
746        let has_ext = res
747            .extensions_mut()
748            .remove::<super::upto::UptoActualAmount>()
749            .is_some();
750        if partial.is_some() || has_ext {
751            drop(settle_handle);
752            return Err(PaygateError::SettlementAborted(
753                "Settlement-Overrides / UptoActualAmount require SettlementMode::Sequential".into(),
754            ));
755        }
756
757        let settlement = settle_handle
758            .await
759            .map_err(|e| PaygateError::SettlementAborted(format!("settle task panicked: {e}")))??;
760        let header_value = settlement_to_header(&settlement)?;
761
762        res.headers_mut().insert("Payment-Response", header_value);
763        ensure_expose_headers(res.headers_mut());
764        Ok(res)
765    }
766
767    /// Handles an incoming request with **background** (fire-and-forget) settlement.
768    ///
769    /// ```text
770    /// verify → spawn settle (fire-and-forget) → execute → return
771    /// ```
772    ///
773    /// Settlement is spawned immediately after verification but **never awaited**.
774    /// The response is returned to the client as soon as the handler completes,
775    /// without waiting for on-chain settlement.
776    ///
777    /// This is ideal for **streaming** responses (e.g. SSE / LLM token streams)
778    /// where the client should start receiving data immediately.
779    ///
780    /// **Trade-off:** the `Payment-Response` header is **not** attached to the
781    /// response since settlement may still be in progress.
782    ///
783    /// # Errors
784    ///
785    /// Returns a verification [`PaygateError`] if payment verification fails.
786    /// Settlement errors are logged but do not propagate.
787    #[cfg_attr(
788        feature = "telemetry",
789        instrument(name = "x402.handle_request_background", skip_all)
790    )]
791    pub async fn handle_request_background<
792        ReqBody,
793        ResBody,
794        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
795    >(
796        &self,
797        inner: S,
798        req: http::Request<ReqBody>,
799    ) -> Result<Response, PaygateError>
800    where
801        S::Response: IntoResponse,
802        S::Error: IntoResponse,
803        S::Future: Send + 'static,
804        ReqBody: Send + 'static,
805    {
806        let verified = self.verify_only(req.headers()).await?;
807        let cancel = verified.cancellation_guard();
808
809        // SkipHandler: no resource body to stream — settle inline and return
810        // the directive response (same as sequential/concurrent).
811        if let Some(directive) = verified.skip_handler.clone() {
812            let settlement = verified.settle().await?;
813            return skip_handler_response(&directive, &settlement);
814        }
815
816        // F-103: spawn the settlement task and a supervisor that awaits the
817        // join handle. The supervisor surfaces three failure modes that
818        // would otherwise be silenced:
819        //
820        // - structured `FacilitatorError` from `settle()`,
821        // - panics inside the settle task (lost into the void by tokio),
822        // - cancellations (e.g. runtime shutdown).
823        //
824        // Two `tokio::spawn` calls cost a single extra heap allocation per
825        // request — negligible compared to the on-chain work — and we get
826        // observable settlement outcomes in exchange.
827        //
828        // Partial settlement is not available in background mode (settle
829        // starts at the signed maximum before the handler returns).
830        let settle_handle = tokio::spawn(async move { verified.settle().await });
831        // F-101/F-102: register with the optional tracker before spawning
832        // the supervisor so `wait_for_pending_settlements` observes the
833        // task even if the supervisor finishes within microseconds.
834        let tracker_guard = self
835            .settlement_tracker
836            .as_ref()
837            .map(BackgroundSettlementTracker::start);
838        // Detached supervisor: we deliberately drop the JoinHandle. The
839        // supervisor itself never panics and only logs, so leaking the
840        // handle is the cheapest fire-and-forget pattern.
841        drop(tokio::spawn(supervise_background_settle(
842            settle_handle,
843            tracker_guard,
844        )));
845
846        // Bind the future result before matching so `cancel` outlives the
847        // temporary (Rust 2024 tail-expression drop order).
848        let call_result = call_inner(inner, req).await;
849        match call_result {
850            Ok(r) => {
851                if r.status().is_client_error() || r.status().is_server_error() {
852                    cancel
853                        .cancel(
854                            CancelReason::HandlerFailed,
855                            Some("handler returned error status"),
856                            Some(r.status().as_u16()),
857                        )
858                        .await;
859                }
860                let mut response = r.into_response();
861                // Never leak internal billing headers to the client.
862                drop(super::upto::take_settlement_overrides_header(
863                    response.headers_mut(),
864                ));
865                drop(
866                    response
867                        .extensions_mut()
868                        .remove::<super::upto::UptoActualAmount>(),
869                );
870                Ok(response)
871            }
872            Err(err) => {
873                cancel
874                    .cancel(
875                        CancelReason::HandlerThrew,
876                        Some("inner service error"),
877                        None,
878                    )
879                    .await;
880                Ok(err.into_response())
881            }
882        }
883    }
884}
885
886/// A verified payment token ready for on-chain settlement.
887///
888/// Produced by [`Paygate::verify_only`] after the resource server confirms the
889/// payment. [`settle`](Self::settle) **consumes** `self`, preventing
890/// double-settlement at the type level.
891#[derive(Debug)]
892pub struct VerifiedPayment {
893    settle_request: wire::SettleRequest,
894    payload: PaymentPayload,
895    requirements: wire::PaymentRequirements,
896    server: ResourceServer,
897    /// When set by after-verify hooks, the resource handler should be skipped.
898    pub skip_handler: Option<r402_core::SkipHandlerDirective>,
899}
900
901impl VerifiedPayment {
902    /// One-shot cancel dispatcher for this verified payment.
903    #[must_use]
904    pub fn cancellation_guard(&self) -> r402_core::CancellationGuard {
905        self.server
906            .cancellation_guard(self.payload.clone(), self.requirements.clone())
907    }
908
909    /// Executes on-chain settlement via the resource server, consuming `self`.
910    ///
911    /// # Errors
912    ///
913    /// Returns [`PaygateError::Settlement`] if settlement fails.
914    pub async fn settle(self) -> Result<wire::SettleResponse, PaygateError> {
915        self.settle_with_override(None).await
916    }
917
918    /// Like [`settle`](Self::settle) but applies an optional atomic amount
919    /// override through [`ResourceServer::settle_payment`] (hooks included).
920    ///
921    /// Intended for the **upto** scheme after resolving
922    /// [`Settlement-Overrides`](super::SETTLEMENT_OVERRIDES_HEADER) /
923    /// [`UptoActualAmount`](super::UptoActualAmount).
924    /// Passing `None` is equivalent to [`settle`](Self::settle).
925    ///
926    /// # Errors
927    ///
928    /// Returns [`PaygateError::Settlement`] when the override is rejected, the
929    /// facilitator fails settlement, or settle hooks abort.
930    pub async fn settle_with_override(
931        self,
932        actual_amount: Option<&str>,
933    ) -> Result<wire::SettleResponse, PaygateError> {
934        use r402_core::SettlementOverrides;
935
936        let overrides = actual_amount.map(SettlementOverrides::amount);
937        let settlement = self
938            .server
939            .settle_payment(&self.payload, &self.requirements, overrides.as_ref())
940            .await
941            .map_err(|e| PaygateError::SettlementAborted(format!("{e}")))?;
942
943        if matches!(settlement, wire::SettleResponse::Failure { .. }) {
944            return Err(PaygateError::Settlement(Box::new(settlement)));
945        }
946
947        Ok(settlement)
948    }
949
950    /// Matched payment requirements (for override resolution).
951    #[must_use]
952    pub const fn requirements(&self) -> &wire::PaymentRequirements {
953        &self.requirements
954    }
955
956    /// Returns a reference to the underlying settle request.
957    #[must_use]
958    pub const fn settle_request(&self) -> &wire::SettleRequest {
959        &self.settle_request
960    }
961}
962
963/// Awaits the join handle of a background settlement task and surfaces the
964/// outcome via tracing.
965///
966/// Three classes of failure are otherwise lost when a fire-and-forget
967/// `tokio::spawn` is used directly:
968///
969/// 1. structured [`FacilitatorError`] returned by `settle()`,
970/// 2. **panics** inside the spawn (tokio aborts the task but the host
971///    process never sees the error),
972/// 3. cancellations (e.g. runtime shutdown).
973///
974/// This supervisor logs each at the appropriate level so operators can
975/// detect silent settlement failures in production. Telemetry is
976/// feature-gated; without `telemetry` the supervisor still consumes the
977/// outcome (preventing a panic-on-drop scenario for the `JoinHandle`).
978async fn supervise_background_settle(
979    handle: tokio::task::JoinHandle<Result<wire::SettleResponse, PaygateError>>,
980    // Held until the supervisor finishes; on drop it decrements the
981    // in-flight counter on the tracker (if any). We deliberately accept
982    // the guard by value so the awaiting `wait_for_pending_settlements`
983    // sees the task as in-flight until the supervisor logs its outcome.
984    _tracker: Option<SettlementInFlightGuard>,
985) {
986    let outcome = handle.await;
987    log_background_settle_outcome(outcome);
988}
989
990/// Logs the result of a background settlement task at the appropriate
991/// level. Split out from [`supervise_background_settle`] so the supervisor
992/// stays under clippy's cognitive-complexity limit and the logging
993/// behaviour is unit-testable in isolation.
994fn log_background_settle_outcome(
995    outcome: Result<Result<wire::SettleResponse, PaygateError>, tokio::task::JoinError>,
996) {
997    match outcome {
998        Ok(Ok(_settlement)) => {
999            #[cfg(feature = "telemetry")]
1000            tracing::debug!("background settlement completed");
1001            record_background_settle_metric("ok");
1002        }
1003        Ok(Err(err)) => {
1004            log_background_settle_error(&err);
1005            record_background_settle_metric("error");
1006        }
1007        Err(join_err) => {
1008            let label = if join_err.is_panic() {
1009                "panic"
1010            } else {
1011                "cancelled"
1012            };
1013            log_background_settle_join_error(&join_err);
1014            record_background_settle_metric(label);
1015        }
1016    }
1017}
1018
1019#[cfg(feature = "metrics")]
1020fn record_background_settle_metric(result: &'static str) {
1021    ::metrics::counter!(
1022        r402_core::metrics::PAYGATE_BACKGROUND_SETTLE_TOTAL,
1023        "result" => result,
1024    )
1025    .increment(1);
1026}
1027#[cfg(not(feature = "metrics"))]
1028fn record_background_settle_metric(_result: &'static str) {}
1029
1030#[cfg(feature = "telemetry")]
1031fn log_background_settle_error(err: &PaygateError) {
1032    tracing::error!(error = %err, "background settlement returned error");
1033}
1034#[cfg(not(feature = "telemetry"))]
1035fn log_background_settle_error(_err: &PaygateError) {}
1036
1037#[cfg(feature = "telemetry")]
1038fn log_background_settle_join_error(join_err: &tokio::task::JoinError) {
1039    if join_err.is_panic() {
1040        tracing::error!(error = %join_err, "background settlement task panicked");
1041    } else {
1042        tracing::warn!(error = %join_err, "background settlement task cancelled");
1043    }
1044}
1045#[cfg(not(feature = "telemetry"))]
1046fn log_background_settle_join_error(_join_err: &tokio::task::JoinError) {}
1047
1048/// Encodes a successful [`wire::SettleResponse`] as an HTTP header value.
1049///
1050/// # Errors
1051///
1052/// Returns [`PaygateError::Settlement`] if the response is an error variant
1053/// or if serialisation / header encoding fails.
1054pub fn settlement_to_header(
1055    settlement: &wire::SettleResponse,
1056) -> Result<HeaderValue, PaygateError> {
1057    let encoded = settlement.encode_base64().ok_or_else(|| {
1058        PaygateError::SettlementAborted("cannot encode error settlement".to_owned())
1059    })?;
1060    HeaderValue::from_bytes(encoded.as_ref())
1061        .map_err(|e| PaygateError::SettlementAborted(e.to_string()))
1062}
1063
1064/// Builds the HTTP response for an after-verify `SkipHandler` directive.
1065///
1066/// Matches foundation Go Gin: default `Content-Type: application/json`, body
1067/// from `directive.body` (`null` when unset), plus `Payment-Response`.
1068fn skip_handler_response(
1069    directive: &r402_core::SkipHandlerDirective,
1070    settlement: &wire::SettleResponse,
1071) -> Result<Response, PaygateError> {
1072    let content_type = directive
1073        .content_type
1074        .as_deref()
1075        .unwrap_or("application/json");
1076    let body_bytes = directive.body.as_ref().map_or_else(
1077        || b"null".to_vec(),
1078        |value| serde_json::to_vec(value).unwrap_or_else(|_| b"null".to_vec()),
1079    );
1080    let header_value = settlement_to_header(settlement)?;
1081    let mut response = Response::builder()
1082        .status(StatusCode::OK)
1083        .header(http::header::CONTENT_TYPE, content_type)
1084        .body(Body::from(body_bytes))
1085        .unwrap_or_else(|_| Response::new(Body::from(b"null".as_slice())));
1086    response
1087        .headers_mut()
1088        .insert("Payment-Response", header_value);
1089    ensure_expose_headers(response.headers_mut());
1090    Ok(response)
1091}
1092
1093/// Calls the inner service with optional telemetry instrumentation.
1094async fn call_inner<
1095    ReqBody,
1096    ResBody,
1097    S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
1098>(
1099    mut inner: S,
1100    req: http::Request<ReqBody>,
1101) -> Result<http::Response<ResBody>, S::Error>
1102where
1103    S::Future: Send,
1104{
1105    #[cfg(feature = "telemetry")]
1106    {
1107        inner
1108            .call(req)
1109            .instrument(tracing::info_span!("inner"))
1110            .await
1111    }
1112    #[cfg(not(feature = "telemetry"))]
1113    {
1114        inner.call(req).await
1115    }
1116}
1117
1118/// Decodes a base64-encoded JSON payment payload from raw header bytes.
1119fn decode_payment_payload<T: serde::de::DeserializeOwned>(header_bytes: &[u8]) -> Option<T> {
1120    let decoded = Base64Bytes::from(header_bytes).decode().ok()?;
1121    serde_json::from_slice(decoded.as_ref()).ok()
1122}
1123
1124/// Maps a paygate verification failure to the HTTP status.
1125///
1126/// A `Permit2AllowanceRequired` inside `VerificationFailed` maps to 412;
1127/// everything else is 402.
1128fn inferred_status(err: &PaygateError) -> StatusCode {
1129    if let PaygateError::VerificationFailed(message) = err
1130        && message.contains("permit2_allowance_required")
1131    {
1132        return StatusCode::PRECONDITION_FAILED;
1133    }
1134    StatusCode::PAYMENT_REQUIRED
1135}
1136
1137fn match_requirements(
1138    payload: &PaymentPayload,
1139    accepts: &[wire::PriceTag],
1140) -> Result<wire::PaymentRequirements, PaygateError> {
1141    accepts
1142        .iter()
1143        .find(|pt| **pt == payload.accepted)
1144        .map(|pt| pt.requirements.clone())
1145        .ok_or(PaygateError::NoPaymentMatching)
1146}
1147
1148fn build_settle_request(
1149    payload: &PaymentPayload,
1150    requirements: &wire::PaymentRequirements,
1151) -> Result<wire::SettleRequest, String> {
1152    let verify: wire::TypedVerifyRequest<2, PaymentPayload, wire::PaymentRequirements> =
1153        wire::TypedVerifyRequest {
1154            x402_version: wire::V2,
1155            payment_payload: payload.clone(),
1156            payment_requirements: requirements.clone(),
1157        };
1158    let json = serde_json::to_value(&verify).map_err(|e| e.to_string())?;
1159    Ok(wire::SettleRequest::from(json))
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    #[test]
1167    fn adds_header_when_absent() {
1168        let mut headers = HeaderMap::new();
1169        ensure_expose_headers(&mut headers);
1170        assert_eq!(
1171            headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap(),
1172            X402_EXPOSED_HEADERS,
1173        );
1174    }
1175
1176    #[test]
1177    fn merges_existing_header() {
1178        let mut headers = HeaderMap::new();
1179        let _ = headers.insert(
1180            ACCESS_CONTROL_EXPOSE_HEADERS,
1181            HeaderValue::from_static("X-Foo"),
1182        );
1183        ensure_expose_headers(&mut headers);
1184        let value = headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
1185        let value = value.to_str().unwrap();
1186        assert!(value.contains("X-Foo"));
1187        assert!(value.contains("Payment-Required"));
1188        assert!(value.contains("Payment-Response"));
1189    }
1190
1191    #[test]
1192    fn expose_headers_idempotent() {
1193        let mut headers = HeaderMap::new();
1194        ensure_expose_headers(&mut headers);
1195        ensure_expose_headers(&mut headers);
1196        let value = headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
1197        assert_eq!(value, X402_EXPOSED_HEADERS);
1198    }
1199
1200    #[test]
1201    fn permit2_allowance_required_maps_to_412() {
1202        assert_eq!(
1203            reason_to_status(&ErrorReason::Permit2AllowanceRequired),
1204            StatusCode::PRECONDITION_FAILED,
1205        );
1206    }
1207
1208    #[test]
1209    fn other_reasons_map_to_402() {
1210        for reason in [
1211            ErrorReason::InvalidPayload,
1212            ErrorReason::InvalidPaymentRequirements,
1213            ErrorReason::InvalidExactEvmPayloadSignature,
1214            ErrorReason::InsufficientFunds,
1215            ErrorReason::DuplicateSettlement,
1216            ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
1217            ErrorReason::InvalidTransactionState,
1218            ErrorReason::UnexpectedSettleError,
1219            ErrorReason::Custom("some_unknown_code".into()),
1220        ] {
1221            assert_eq!(
1222                reason_to_status(&reason),
1223                StatusCode::PAYMENT_REQUIRED,
1224                "reason {reason:?} should map to 402"
1225            );
1226        }
1227    }
1228}