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