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::{HeaderMap, HeaderValue, StatusCode};
25use r402::facilitator::Facilitator;
26use r402::proto;
27use r402::proto::Base64Bytes;
28use r402::proto::v2;
29use serde_json::json;
30use tower::Service;
31#[cfg(feature = "telemetry")]
32use tracing::{Instrument, instrument};
33use url::Url;
34
35const PAYMENT_HEADER: &str = "Payment-Signature";
36
37/// Verification errors for the payment gate.
38#[derive(Debug, thiserror::Error)]
39pub enum VerificationError {
40    /// The `Payment-Signature` header is missing from the request.
41    #[error("Payment-Signature header is required")]
42    PaymentHeaderMissing,
43    /// The payment header is present but malformed.
44    #[error("Invalid or malformed payment header")]
45    InvalidPaymentHeader,
46    /// No accepted price tag matches the payment payload.
47    #[error("Unable to find matching payment requirements")]
48    NoPaymentMatching,
49    /// The facilitator rejected the payment.
50    #[error("Verification failed: {0}")]
51    VerificationFailed(String),
52}
53
54/// Payment gate error encompassing verification and settlement failures.
55#[derive(Debug, thiserror::Error)]
56pub enum PaygateError {
57    /// Payment verification failed.
58    #[error(transparent)]
59    Verification(#[from] VerificationError),
60    /// On-chain settlement failed.
61    #[error("Settlement failed: {0}")]
62    Settlement(String),
63}
64
65type PaymentPayload = v2::PaymentPayload<v2::PaymentRequirements, serde_json::Value>;
66
67/// Template for resource metadata included in 402 responses.
68///
69/// When `url` is `None`, the full resource URL is derived at request time
70/// from the base URL and the request URI.
71#[derive(Debug, Clone)]
72pub struct ResourceTemplate {
73    /// Description of the protected resource.
74    pub description: String,
75    /// MIME type of the protected resource.
76    pub mime_type: String,
77    /// Optional explicit URL; when `None`, derived from the request.
78    pub url: Option<String>,
79}
80
81impl Default for ResourceTemplate {
82    fn default() -> Self {
83        Self {
84            description: String::new(),
85            mime_type: "application/json".to_owned(),
86            url: None,
87        }
88    }
89}
90
91impl ResourceTemplate {
92    /// Resolves this template into a concrete [`v2::ResourceInfo`].
93    ///
94    /// If `url` is already set, it is used directly. Otherwise, the URL is
95    /// constructed by joining `base_url` (or a fallback derived from the
96    /// `Host` header) with the request path and query.
97    ///
98    /// # Panics
99    ///
100    /// Panics if the hardcoded fallback URL `http://localhost` cannot be
101    /// parsed, which should never happen in practice.
102    #[allow(clippy::unwrap_used, reason = "fallback URL is a hardcoded constant")]
103    pub fn resolve(&self, base_url: Option<&Url>, req: &Request) -> v2::ResourceInfo {
104        let url = self.url.clone().unwrap_or_else(|| {
105            let mut url = base_url.cloned().unwrap_or_else(|| {
106                let host = req
107                    .headers()
108                    .get("host")
109                    .and_then(|h| h.to_str().ok())
110                    .unwrap_or("localhost");
111                let origin = format!("http://{host}");
112                let url =
113                    Url::parse(&origin).unwrap_or_else(|_| Url::parse("http://localhost").unwrap());
114                #[cfg(feature = "telemetry")]
115                tracing::warn!(
116                    "X402Middleware base_url is not configured; \
117                     using {url} as origin for resource resolution"
118                );
119                url
120            });
121            url.set_path(req.uri().path());
122            url.set_query(req.uri().query());
123            url.to_string()
124        });
125        v2::ResourceInfo {
126            description: self.description.clone(),
127            mime_type: self.mime_type.clone(),
128            url,
129        }
130    }
131}
132
133/// Builder for constructing a [`Paygate`] with validated configuration.
134///
135/// # Example
136///
137/// ```ignore
138/// let gate = Paygate::builder(facilitator)
139///     .accept(price_tag)
140///     .resource(resource_info)
141///     .build();
142/// ```
143#[allow(
144    missing_debug_implementations,
145    reason = "generic facilitator may not impl Debug"
146)]
147pub struct PaygateBuilder<TFacilitator> {
148    facilitator: TFacilitator,
149    accepts: Vec<v2::PriceTag>,
150    resource: Option<v2::ResourceInfo>,
151}
152
153impl<TFacilitator> PaygateBuilder<TFacilitator> {
154    /// Adds a single accepted payment option.
155    #[must_use]
156    pub fn accept(mut self, price_tag: v2::PriceTag) -> Self {
157        self.accepts.push(price_tag);
158        self
159    }
160
161    /// Adds multiple accepted payment options.
162    #[must_use]
163    pub fn accepts(mut self, price_tags: impl IntoIterator<Item = v2::PriceTag>) -> Self {
164        self.accepts.extend(price_tags);
165        self
166    }
167
168    /// Sets the resource metadata returned in 402 responses.
169    #[must_use]
170    pub fn resource(mut self, resource: v2::ResourceInfo) -> Self {
171        self.resource = Some(resource);
172        self
173    }
174
175    /// Consumes the builder and produces a configured [`Paygate`].
176    ///
177    /// Uses empty resource info if none was provided.
178    pub fn build(self) -> Paygate<TFacilitator> {
179        Paygate {
180            facilitator: self.facilitator,
181            accepts: self.accepts.into(),
182            resource: self.resource.unwrap_or_else(|| v2::ResourceInfo {
183                description: String::new(),
184                mime_type: "application/json".to_owned(),
185                url: String::new(),
186            }),
187        }
188    }
189}
190
191/// V2-only payment gate for enforcing x402 payments.
192///
193/// Handles the full payment lifecycle: header extraction, verification,
194/// settlement, and 402 response generation using the V2 wire format.
195///
196/// Construct via [`PaygateBuilder`] (obtained from [`Paygate::builder`]).
197///
198/// To add lifecycle hooks (before/after verify and settle), wrap your
199/// facilitator with [`HookedFacilitator`](r402::hooks::HookedFacilitator)
200/// before passing it to the payment gate.
201#[allow(
202    missing_debug_implementations,
203    reason = "generic facilitator may not impl Debug"
204)]
205pub struct Paygate<TFacilitator> {
206    pub(crate) facilitator: TFacilitator,
207    pub(crate) accepts: Arc<[v2::PriceTag]>,
208    pub(crate) resource: v2::ResourceInfo,
209}
210
211impl<TFacilitator> Paygate<TFacilitator> {
212    /// Returns a new builder seeded with the given facilitator.
213    pub const fn builder(facilitator: TFacilitator) -> PaygateBuilder<TFacilitator> {
214        PaygateBuilder {
215            facilitator,
216            accepts: Vec::new(),
217            resource: None,
218        }
219    }
220
221    /// Returns a reference to the underlying facilitator.
222    pub const fn facilitator(&self) -> &TFacilitator {
223        &self.facilitator
224    }
225
226    /// Returns a reference to the accepted price tags.
227    pub fn accepts(&self) -> &[v2::PriceTag] {
228        &self.accepts
229    }
230
231    /// Returns a reference to the resource information.
232    pub const fn resource(&self) -> &v2::ResourceInfo {
233        &self.resource
234    }
235
236    /// Converts a [`PaygateError`] into a proper HTTP response.
237    ///
238    /// Verification errors produce a 402 with the `Payment-Required` header
239    /// and a JSON body. Settlement errors produce a 402 with error details.
240    ///
241    /// # Panics
242    ///
243    /// Panics if the payment-required response cannot be serialized to JSON
244    /// or if the HTTP response builder fails. These indicate a bug.
245    #[must_use]
246    #[allow(
247        clippy::expect_used,
248        reason = "infallible JSON/HTTP construction; panic indicates a bug"
249    )]
250    pub fn error_response(&self, err: PaygateError) -> Response {
251        match err {
252            PaygateError::Verification(ve) => {
253                let payment_required = v2::PaymentRequired {
254                    error: Some(ve.to_string()),
255                    accepts: self
256                        .accepts
257                        .iter()
258                        .map(|pt| pt.requirements.clone())
259                        .collect(),
260                    x402_version: v2::V2,
261                    resource: self.resource.clone(),
262                    extensions: None,
263                };
264                let body_bytes =
265                    serde_json::to_vec(&payment_required).expect("serialization failed");
266                let header_value =
267                    HeaderValue::from_bytes(Base64Bytes::encode(&body_bytes).as_ref())
268                        .expect("invalid header value");
269
270                Response::builder()
271                    .status(StatusCode::PAYMENT_REQUIRED)
272                    .header("Payment-Required", header_value)
273                    .header("Content-Type", "application/json")
274                    .body(Body::from(body_bytes))
275                    .expect("failed to construct response")
276            }
277            PaygateError::Settlement(ref detail) => {
278                #[cfg(feature = "telemetry")]
279                tracing::error!(details = %detail, "Settlement failed");
280                let body = json!({ "error": "Settlement failed", "details": detail }).to_string();
281
282                Response::builder()
283                    .status(StatusCode::PAYMENT_REQUIRED)
284                    .header("Content-Type", "application/json")
285                    .body(Body::from(body))
286                    .expect("failed to construct response")
287            }
288        }
289    }
290}
291
292impl<TFacilitator> Paygate<TFacilitator>
293where
294    TFacilitator: Facilitator + Sync,
295{
296    /// Enriches price tags with facilitator capabilities (e.g., fee payer address).
297    pub async fn enrich_accepts(&mut self) {
298        let capabilities = self.facilitator.supported().await.unwrap_or_default();
299        let accepts: Vec<_> = self
300            .accepts
301            .iter()
302            .cloned()
303            .map(|mut pt| {
304                pt.enrich(&capabilities);
305                pt
306            })
307            .collect();
308        self.accepts = accepts.into();
309    }
310
311    /// Verifies the payment from request headers without executing the inner
312    /// service or settling on-chain.
313    ///
314    /// Returns a [`VerifiedPayment`] token on success, which the caller can
315    /// later [`settle`](VerifiedPayment::settle) at their discretion.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`PaygateError::Verification`] if the payment header is missing,
320    /// malformed, or rejected by the facilitator.
321    #[cfg_attr(feature = "telemetry", instrument(name = "x402.verify_only", skip_all))]
322    pub async fn verify_only(&self, headers: &HeaderMap) -> Result<VerifiedPayment, PaygateError> {
323        let header_bytes = headers
324            .get(PAYMENT_HEADER)
325            .map(HeaderValue::as_bytes)
326            .ok_or(VerificationError::PaymentHeaderMissing)?;
327
328        let payload: PaymentPayload =
329            decode_payment_payload(header_bytes).ok_or(VerificationError::InvalidPaymentHeader)?;
330
331        let verify_request = build_verify_request(payload, &self.accepts)?;
332
333        let verify_response = self
334            .facilitator
335            .verify(verify_request.clone())
336            .await
337            .map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
338
339        if let proto::VerifyResponse::Invalid { reason, .. } = verify_response {
340            return Err(VerificationError::VerificationFailed(reason).into());
341        }
342
343        Ok(VerifiedPayment {
344            settle_request: verify_request.into(),
345        })
346    }
347
348    /// Handles an incoming request with **sequential** settlement.
349    ///
350    /// ```text
351    /// verify → execute → settle → attach header → return
352    /// ```
353    ///
354    /// Settlement only runs if the handler returns a success status (not 4xx/5xx).
355    ///
356    /// # Errors
357    ///
358    /// Returns [`PaygateError`] if payment verification or settlement fails.
359    #[cfg_attr(
360        feature = "telemetry",
361        instrument(name = "x402.handle_request", skip_all)
362    )]
363    pub async fn handle_request<
364        ReqBody,
365        ResBody,
366        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
367    >(
368        &self,
369        inner: S,
370        req: http::Request<ReqBody>,
371    ) -> Result<Response, PaygateError>
372    where
373        S::Response: IntoResponse,
374        S::Error: IntoResponse,
375        S::Future: Send,
376    {
377        let verified = self.verify_only(req.headers()).await?;
378
379        let response = match call_inner(inner, req).await {
380            Ok(r) => r,
381            Err(err) => return Ok(err.into_response()),
382        };
383
384        if response.status().is_client_error() || response.status().is_server_error() {
385            return Ok(response.into_response());
386        }
387
388        let settlement = verified.settle(&self.facilitator).await?;
389        let header_value = settlement_to_header(&settlement)?;
390
391        let mut res = response;
392        res.headers_mut().insert("Payment-Response", header_value);
393        Ok(res.into_response())
394    }
395}
396
397impl<TFacilitator> Paygate<TFacilitator>
398where
399    TFacilitator: Facilitator + Clone + Send + Sync + 'static,
400{
401    /// Handles an incoming request with **concurrent** settlement.
402    ///
403    /// ```text
404    /// verify → (settle ∥ execute) → await settle → attach header → return
405    /// ```
406    ///
407    /// Settlement is spawned immediately after verification and runs in
408    /// parallel with the handler, reducing total latency by one facilitator RTT.
409    /// On handler error (4xx/5xx), the settlement task is abandoned.
410    ///
411    /// # Errors
412    ///
413    /// Returns [`PaygateError`] if payment verification or settlement fails.
414    #[cfg_attr(
415        feature = "telemetry",
416        instrument(name = "x402.handle_request_concurrent", skip_all)
417    )]
418    pub async fn handle_request_concurrent<
419        ReqBody,
420        ResBody,
421        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
422    >(
423        &self,
424        inner: S,
425        req: http::Request<ReqBody>,
426    ) -> Result<Response, PaygateError>
427    where
428        S::Response: IntoResponse,
429        S::Error: IntoResponse,
430        S::Future: Send + 'static,
431        ReqBody: Send + 'static,
432    {
433        let verified = self.verify_only(req.headers()).await?;
434
435        let facilitator = self.facilitator.clone();
436        let settle_handle = tokio::spawn(async move { verified.settle(&facilitator).await });
437
438        let response = match call_inner(inner, req).await {
439            Ok(r) => r,
440            Err(err) => {
441                drop(settle_handle);
442                return Ok(err.into_response());
443            }
444        };
445
446        if response.status().is_client_error() || response.status().is_server_error() {
447            drop(settle_handle);
448            return Ok(response.into_response());
449        }
450
451        let settlement = settle_handle
452            .await
453            .map_err(|e| PaygateError::Settlement(format!("settle task panicked: {e}")))??;
454        let header_value = settlement_to_header(&settlement)?;
455
456        let mut res = response;
457        res.headers_mut().insert("Payment-Response", header_value);
458        Ok(res.into_response())
459    }
460
461    /// Handles an incoming request with **background** (fire-and-forget) settlement.
462    ///
463    /// ```text
464    /// verify → spawn settle (fire-and-forget) → execute → return
465    /// ```
466    ///
467    /// Settlement is spawned immediately after verification but **never awaited**.
468    /// The response is returned to the client as soon as the handler completes,
469    /// without waiting for on-chain settlement.
470    ///
471    /// This is ideal for **streaming** responses (e.g. SSE / LLM token streams)
472    /// where the client should start receiving data immediately.
473    ///
474    /// **Trade-off:** the `Payment-Response` header is **not** attached to the
475    /// response since settlement may still be in progress.
476    ///
477    /// # Errors
478    ///
479    /// Returns [`PaygateError::Verification`] if payment verification fails.
480    /// Settlement errors are logged but do not propagate.
481    #[cfg_attr(
482        feature = "telemetry",
483        instrument(name = "x402.handle_request_background", skip_all)
484    )]
485    pub async fn handle_request_background<
486        ReqBody,
487        ResBody,
488        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
489    >(
490        &self,
491        inner: S,
492        req: http::Request<ReqBody>,
493    ) -> Result<Response, PaygateError>
494    where
495        S::Response: IntoResponse,
496        S::Error: IntoResponse,
497        S::Future: Send + 'static,
498        ReqBody: Send + 'static,
499    {
500        let verified = self.verify_only(req.headers()).await?;
501
502        // Fire-and-forget: spawn settlement without awaiting
503        let facilitator = self.facilitator.clone();
504        tokio::spawn(async move {
505            if let Err(e) = verified.settle(&facilitator).await {
506                #[cfg(feature = "telemetry")]
507                tracing::error!(error = %e, "background settlement failed");
508                #[cfg(not(feature = "telemetry"))]
509                let _ = e;
510            }
511        });
512
513        match call_inner(inner, req).await {
514            Ok(r) => Ok(r.into_response()),
515            Err(err) => Ok(err.into_response()),
516        }
517    }
518}
519
520/// A verified payment token ready for on-chain settlement.
521///
522/// Produced by [`Paygate::verify_only`] after the facilitator confirms the
523/// payment signature is valid. [`settle`](Self::settle) **consumes** `self`,
524/// preventing double-settlement at the type level.
525#[derive(Debug)]
526pub struct VerifiedPayment {
527    settle_request: proto::SettleRequest,
528}
529
530impl VerifiedPayment {
531    /// Executes on-chain settlement, consuming `self` to prevent reuse.
532    ///
533    /// # Errors
534    ///
535    /// Returns [`PaygateError::Settlement`] if the facilitator rejects the
536    /// settlement or if the on-chain transaction fails.
537    pub async fn settle<F: Facilitator>(
538        self,
539        facilitator: &F,
540    ) -> Result<proto::SettleResponse, PaygateError> {
541        let settlement = facilitator
542            .settle(self.settle_request)
543            .await
544            .map_err(|e| PaygateError::Settlement(format!("{e}")))?;
545
546        if let proto::SettleResponse::Error {
547            reason, message, ..
548        } = &settlement
549        {
550            let detail = message.as_deref().unwrap_or(reason.as_str());
551            return Err(PaygateError::Settlement(detail.to_owned()));
552        }
553
554        Ok(settlement)
555    }
556
557    /// Returns a reference to the underlying settle request.
558    #[must_use]
559    pub const fn settle_request(&self) -> &proto::SettleRequest {
560        &self.settle_request
561    }
562}
563
564/// Encodes a successful [`proto::SettleResponse`] as an HTTP header value.
565///
566/// # Errors
567///
568/// Returns [`PaygateError::Settlement`] if the response is an error variant
569/// or if serialisation / header encoding fails.
570pub fn settlement_to_header(
571    settlement: &proto::SettleResponse,
572) -> Result<HeaderValue, PaygateError> {
573    let encoded = settlement
574        .encode_base64()
575        .ok_or_else(|| PaygateError::Settlement("cannot encode error settlement".to_owned()))?;
576    HeaderValue::from_bytes(encoded.as_ref()).map_err(|e| PaygateError::Settlement(e.to_string()))
577}
578
579/// Calls the inner service with optional telemetry instrumentation.
580async fn call_inner<
581    ReqBody,
582    ResBody,
583    S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
584>(
585    mut inner: S,
586    req: http::Request<ReqBody>,
587) -> Result<http::Response<ResBody>, S::Error>
588where
589    S::Future: Send,
590{
591    #[cfg(feature = "telemetry")]
592    {
593        inner
594            .call(req)
595            .instrument(tracing::info_span!("inner"))
596            .await
597    }
598    #[cfg(not(feature = "telemetry"))]
599    {
600        inner.call(req).await
601    }
602}
603
604/// Decodes a base64-encoded JSON payment payload from raw header bytes.
605fn decode_payment_payload<T: serde::de::DeserializeOwned>(header_bytes: &[u8]) -> Option<T> {
606    let decoded = Base64Bytes::from(header_bytes).decode().ok()?;
607    serde_json::from_slice(decoded.as_ref()).ok()
608}
609
610/// Matches the payment payload against accepted price tags and builds a
611/// [`proto::VerifyRequest`].
612fn build_verify_request(
613    payload: PaymentPayload,
614    accepts: &[v2::PriceTag],
615) -> Result<proto::VerifyRequest, VerificationError> {
616    let selected = accepts
617        .iter()
618        .find(|pt| **pt == payload.accepted)
619        .ok_or(VerificationError::NoPaymentMatching)?;
620
621    let verify = v2::VerifyRequest {
622        x402_version: v2::V2,
623        payment_payload: payload,
624        payment_requirements: selected.requirements.clone(),
625    };
626
627    let json = serde_json::to_value(&verify)
628        .map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
629
630    Ok(proto::VerifyRequest::from(json))
631}