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