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