Skip to main content

r402_http/server/
layer.rs

1//! Axum middleware for enforcing [x402](https://www.x402.org) payments on protected routes.
2//!
3//! This middleware validates incoming payment headers using a configured x402 facilitator,
4//! verifies the payment, executes the request, and settles valid payments after successful
5//! execution. If the handler returns an error (4xx/5xx), settlement is skipped.
6//!
7//! Returns a `402 Payment Required` response if the request lacks a valid payment.
8//!
9//! ## Settlement Modes
10//!
11//! - **[`SettlementMode::Sequential`]** (default): verify → execute → settle.
12//!   Safer — settlement only runs after the handler succeeds.
13//! - **[`SettlementMode::Concurrent`]**: verify → (settle ∥ execute) → await settle.
14//!   Lower latency — overlaps settlement with handler execution.
15//! - **[`SettlementMode::Background`]**: verify → spawn settle → execute → return.
16//!   Fire-and-forget — ideal for streaming responses.
17//!
18//! ## Configuration Notes
19//!
20//! - **[`X402Middleware::with_price_tag`]** sets the assets and amounts accepted for payment (static pricing).
21//! - **[`X402Middleware::with_dynamic_price`]** sets a callback for dynamic pricing based on request context.
22//! - **[`X402Middleware::with_base_url`]** sets the base URL for computing full resource URLs.
23//!   If not set, defaults to `http://localhost/` (avoid in production).
24//! - **[`X402LayerBuilder::with_settlement_mode`]** selects sequential or concurrent settlement.
25//! - **[`X402LayerBuilder::with_description`]** is optional but helps the payer understand what is being paid for.
26//! - **[`X402LayerBuilder::with_mime_type`]** sets the MIME type of the protected resource (default: `application/json`).
27//! - **[`X402LayerBuilder::with_resource`]** explicitly sets the full URI of the protected resource.
28//!
29
30use std::convert::Infallible;
31use std::future::Future;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::task::{Context, Poll};
35use std::time::Duration;
36
37use axum_core::extract::Request;
38use axum_core::response::Response;
39use http::{HeaderMap, Uri};
40use r402::facilitator::Facilitator;
41use r402::proto::v2;
42use tower::util::BoxCloneSyncService;
43use tower::{Layer, Service};
44use url::Url;
45
46use super::facilitator::FacilitatorClient;
47use super::paygate::{Paygate, ResourceTemplate};
48use super::pricing::{DynamicPriceTags, PriceTagSource, StaticPriceTags};
49
50/// Controls when on-chain settlement executes relative to the inner service.
51///
52/// # Variants
53///
54/// - **Sequential** (default): verify → execute → settle.  Settlement only
55///   runs after the handler returns a successful response.  This is the
56///   safest option — no settlement occurs on handler errors.
57///
58/// - **Concurrent**: verify → (settle ∥ execute) → await settle.  Settlement
59///   is spawned immediately after verification and runs in parallel with the
60///   handler, reducing total request latency by one facilitator RTT.
61///   On handler error the settlement task is detached (fire-and-forget).
62///
63/// - **Background**: verify → spawn settle (fire-and-forget) → execute → return.
64///   Settlement runs entirely in the background — the response is returned to
65///   the client immediately after the handler completes, without waiting for
66///   settlement.  Ideal for **streaming** responses (e.g. SSE / LLM token
67///   streams) where the client should start receiving data as soon as possible.
68///   **Trade-off:** the `Payment-Response` header is not attached since settlement
69///   may still be in progress when the response is sent.
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
71pub enum SettlementMode {
72    /// Settlement runs **after** the handler completes.
73    #[default]
74    Sequential,
75    /// Settlement runs **concurrently** with the handler; response waits for settlement.
76    Concurrent,
77    /// Settlement is fire-and-forget; response is returned immediately.
78    Background,
79}
80
81/// The main X402 middleware instance for enforcing x402 payments on routes.
82///
83/// Create a single instance per application and use it to build payment layers
84/// for protected routes.
85pub struct X402Middleware<F> {
86    facilitator: F,
87    base_url: Option<Url>,
88}
89
90impl<F: Clone> Clone for X402Middleware<F> {
91    fn clone(&self) -> Self {
92        Self {
93            facilitator: self.facilitator.clone(),
94            base_url: self.base_url.clone(),
95        }
96    }
97}
98
99impl<F: std::fmt::Debug> std::fmt::Debug for X402Middleware<F> {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("X402Middleware")
102            .field("facilitator", &self.facilitator)
103            .field("base_url", &self.base_url)
104            .finish()
105    }
106}
107
108impl<F> X402Middleware<F> {
109    /// Creates a middleware instance from any facilitator implementation.
110    ///
111    /// Use this when you already have a configured facilitator (e.g. one
112    /// with custom timeouts, caching, or a non-default HTTP client).
113    #[must_use]
114    pub const fn from_facilitator(facilitator: F) -> Self {
115        Self {
116            facilitator,
117            base_url: None,
118        }
119    }
120
121    /// Returns a reference to the underlying facilitator.
122    pub const fn facilitator(&self) -> &F {
123        &self.facilitator
124    }
125}
126
127impl X402Middleware<Arc<FacilitatorClient>> {
128    /// Creates a new middleware instance with a default facilitator URL.
129    ///
130    /// # Panics
131    ///
132    /// Panics if the facilitator URL is invalid.
133    #[must_use]
134    pub fn new(url: &str) -> Self {
135        let facilitator = FacilitatorClient::try_from(url).expect("Invalid facilitator URL");
136        Self {
137            facilitator: Arc::new(facilitator),
138            base_url: None,
139        }
140    }
141
142    /// Creates a new middleware instance with a facilitator URL.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the URL is invalid.
147    pub fn try_new(url: &str) -> Result<Self, Box<dyn std::error::Error>> {
148        let facilitator = FacilitatorClient::try_from(url)?;
149        Ok(Self {
150            facilitator: Arc::new(facilitator),
151            base_url: None,
152        })
153    }
154
155    /// Returns the configured facilitator URL.
156    #[must_use]
157    pub fn facilitator_url(&self) -> &Url {
158        self.facilitator.base_url()
159    }
160
161    /// Sets the TTL for caching the facilitator's supported response.
162    ///
163    /// Default is 10 minutes. Use [`FacilitatorClient::without_supported_cache()`]
164    /// to disable caching entirely.
165    #[must_use]
166    pub fn with_supported_cache_ttl(&self, ttl: Duration) -> Self {
167        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
168        let facilitator = Arc::new(inner.with_supported_cache_ttl(ttl));
169        Self {
170            facilitator,
171            base_url: self.base_url.clone(),
172        }
173    }
174
175    /// Sets a per-request timeout for all facilitator HTTP calls (verify, settle, supported).
176    ///
177    /// Without this, the underlying `reqwest::Client` uses no timeout by default,
178    /// which can cause requests to hang indefinitely if the facilitator is slow
179    /// or unreachable, eventually triggering OS-level TCP timeouts (typically 2–5 minutes).
180    ///
181    /// A reasonable production value is 30 seconds.
182    #[must_use]
183    pub fn with_facilitator_timeout(&self, timeout: Duration) -> Self {
184        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
185        let facilitator = Arc::new(inner.with_timeout(timeout));
186        Self {
187            facilitator,
188            base_url: self.base_url.clone(),
189        }
190    }
191}
192
193impl TryFrom<&str> for X402Middleware<Arc<FacilitatorClient>> {
194    type Error = Box<dyn std::error::Error>;
195
196    fn try_from(value: &str) -> Result<Self, Self::Error> {
197        Self::try_new(value)
198    }
199}
200
201impl TryFrom<String> for X402Middleware<Arc<FacilitatorClient>> {
202    type Error = Box<dyn std::error::Error>;
203
204    fn try_from(value: String) -> Result<Self, Self::Error> {
205        Self::try_new(&value)
206    }
207}
208
209impl<F> X402Middleware<F>
210where
211    F: Clone,
212{
213    /// Sets the base URL used to construct resource URLs dynamically.
214    ///
215    /// If [`X402LayerBuilder::with_resource`] is not called, this base URL is combined with
216    /// each request's path/query to compute the resource. If not set, defaults to `http://localhost/`.
217    ///
218    /// In production, prefer calling `with_resource` or setting a precise `base_url`.
219    #[must_use]
220    pub fn with_base_url(&self, base_url: Url) -> Self {
221        let mut this = self.clone();
222        this.base_url = Some(base_url);
223        this
224    }
225}
226
227impl<TFacilitator> X402Middleware<TFacilitator>
228where
229    TFacilitator: Clone,
230{
231    /// Sets the price tag for the protected route.
232    ///
233    /// Creates a layer builder that can be further configured with additional
234    /// price tags and resource information.
235    #[must_use]
236    pub fn with_price_tag(
237        &self,
238        price_tag: v2::PriceTag,
239    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
240        X402LayerBuilder {
241            facilitator: self.facilitator.clone(),
242            price_source: StaticPriceTags::new(vec![price_tag]),
243            base_url: self.base_url.clone().map(Arc::new),
244            resource: Arc::new(ResourceTemplate::default()),
245            settlement_mode: SettlementMode::default(),
246        }
247    }
248
249    /// Sets multiple price tags for the protected route.
250    ///
251    /// Convenience method for services that accept several payment options
252    /// (e.g. multiple tokens / networks).  Returns an empty-bypass builder
253    /// when the list is empty — the middleware will pass requests through
254    /// without payment enforcement.
255    #[must_use]
256    pub fn with_price_tags(
257        &self,
258        price_tags: Vec<v2::PriceTag>,
259    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
260        X402LayerBuilder {
261            facilitator: self.facilitator.clone(),
262            price_source: StaticPriceTags::new(price_tags),
263            base_url: self.base_url.clone().map(Arc::new),
264            resource: Arc::new(ResourceTemplate::default()),
265            settlement_mode: SettlementMode::default(),
266        }
267    }
268
269    /// Sets a dynamic price source for the protected route.
270    ///
271    /// The `callback` receives request headers, URI, and base URL, and returns
272    /// a vector of V2 price tags.
273    #[must_use]
274    pub fn with_dynamic_price<F, Fut>(
275        &self,
276        callback: F,
277    ) -> X402LayerBuilder<DynamicPriceTags, TFacilitator>
278    where
279        F: Fn(&HeaderMap, &Uri, Option<&Url>) -> Fut + Send + Sync + 'static,
280        Fut: Future<Output = Vec<v2::PriceTag>> + Send + 'static,
281    {
282        X402LayerBuilder {
283            facilitator: self.facilitator.clone(),
284            price_source: DynamicPriceTags::new(callback),
285            base_url: self.base_url.clone().map(Arc::new),
286            resource: Arc::new(ResourceTemplate::default()),
287            settlement_mode: SettlementMode::default(),
288        }
289    }
290}
291
292/// Builder for configuring the X402 middleware layer.
293///
294/// Generic over `TSource` which implements [`PriceTagSource`] to support
295/// both static and dynamic pricing strategies.
296#[derive(Clone)]
297#[allow(missing_debug_implementations)] // generic types may not implement Debug
298pub struct X402LayerBuilder<TSource, TFacilitator> {
299    facilitator: TFacilitator,
300    base_url: Option<Arc<Url>>,
301    price_source: TSource,
302    resource: Arc<ResourceTemplate>,
303    settlement_mode: SettlementMode,
304}
305
306impl<TFacilitator> X402LayerBuilder<StaticPriceTags, TFacilitator> {
307    /// Adds another payment option.
308    ///
309    /// Allows specifying multiple accepted payment methods (e.g., different networks).
310    ///
311    /// Note: This method is only available for static price tag sources.
312    #[must_use]
313    pub fn with_price_tag(mut self, price_tag: v2::PriceTag) -> Self {
314        self.price_source = self.price_source.with_price_tag(price_tag);
315        self
316    }
317}
318
319#[allow(missing_debug_implementations)] // generic types may not implement Debug
320impl<TSource, TFacilitator> X402LayerBuilder<TSource, TFacilitator> {
321    /// Sets a description of what the payment grants access to.
322    ///
323    /// This is included in 402 responses to inform clients what they're paying for.
324    #[must_use]
325    pub fn with_description(mut self, description: String) -> Self {
326        let mut new_resource = (*self.resource).clone();
327        new_resource.description = description;
328        self.resource = Arc::new(new_resource);
329        self
330    }
331
332    /// Sets the MIME type of the protected resource.
333    ///
334    /// Defaults to `application/json` if not specified.
335    #[must_use]
336    pub fn with_mime_type(mut self, mime: String) -> Self {
337        let mut new_resource = (*self.resource).clone();
338        new_resource.mime_type = mime;
339        self.resource = Arc::new(new_resource);
340        self
341    }
342
343    /// Sets the full URL of the protected resource.
344    ///
345    /// When set, this URL is used directly instead of constructing it from the base URL
346    /// and request URI. This is the preferred approach in production.
347    #[must_use]
348    #[allow(clippy::needless_pass_by_value)] // Url consumed via to_string()
349    pub fn with_resource(mut self, resource: Url) -> Self {
350        let mut new_resource = (*self.resource).clone();
351        new_resource.url = Some(resource.to_string());
352        self.resource = Arc::new(new_resource);
353        self
354    }
355
356    /// Sets the settlement mode.
357    ///
358    /// - [`SettlementMode::Sequential`] (default): verify → execute → settle.
359    /// - [`SettlementMode::Concurrent`]: verify → (settle ∥ execute) → await settle.
360    /// - [`SettlementMode::Background`]: verify → spawn settle → execute → return.
361    ///
362    /// Concurrent mode reduces total latency by overlapping settlement with
363    /// handler execution. Background mode is ideal for streaming responses
364    /// where the client should receive data immediately (settlement errors
365    /// are logged but do not propagate).
366    #[must_use]
367    pub const fn with_settlement_mode(mut self, mode: SettlementMode) -> Self {
368        self.settlement_mode = mode;
369        self
370    }
371}
372
373impl<S, TSource, TFacilitator> Layer<S> for X402LayerBuilder<TSource, TFacilitator>
374where
375    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
376    S::Future: Send + 'static,
377    TFacilitator: Facilitator + Clone,
378    TSource: PriceTagSource,
379{
380    type Service = X402MiddlewareService<TSource, TFacilitator>;
381
382    fn layer(&self, inner: S) -> Self::Service {
383        X402MiddlewareService {
384            facilitator: self.facilitator.clone(),
385            base_url: self.base_url.clone(),
386            price_source: self.price_source.clone(),
387            resource: Arc::clone(&self.resource),
388            settlement_mode: self.settlement_mode,
389            inner: BoxCloneSyncService::new(inner),
390        }
391    }
392}
393
394/// Axum service that enforces x402 payments on incoming requests.
395///
396/// Generic over `TSource` which implements [`PriceTagSource`] to support
397/// both static and dynamic pricing strategies.
398#[derive(Clone)]
399#[allow(missing_debug_implementations)] // BoxCloneSyncService does not implement Debug
400pub struct X402MiddlewareService<TSource, TFacilitator> {
401    /// Payment facilitator (local or remote)
402    facilitator: TFacilitator,
403    /// Base URL for constructing resource URLs
404    base_url: Option<Arc<Url>>,
405    /// Price tag source - can be static or dynamic
406    price_source: TSource,
407    /// Resource information
408    resource: Arc<ResourceTemplate>,
409    /// Settlement strategy (sequential, concurrent, or background)
410    settlement_mode: SettlementMode,
411    /// The inner Axum service being wrapped
412    inner: BoxCloneSyncService<Request, Response, Infallible>,
413}
414
415impl<TSource, TFacilitator> Service<Request> for X402MiddlewareService<TSource, TFacilitator>
416where
417    TSource: PriceTagSource,
418    TFacilitator: Facilitator + Clone + Send + Sync + 'static,
419{
420    type Response = Response;
421    type Error = Infallible;
422    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
423
424    /// Delegates readiness polling to the wrapped inner service.
425    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
426        self.inner.poll_ready(cx)
427    }
428
429    /// Intercepts the request, injects payment enforcement logic, and forwards to the wrapped service.
430    fn call(&mut self, req: Request) -> Self::Future {
431        let price_source = self.price_source.clone();
432        let facilitator = self.facilitator.clone();
433        let base_url = self.base_url.clone();
434        let resource_builder = Arc::clone(&self.resource);
435        let settlement_mode = self.settlement_mode;
436        let mut inner = self.inner.clone();
437
438        Box::pin(async move {
439            // Resolve price tags from the source
440            let accepts = price_source
441                .resolve(req.headers(), req.uri(), base_url.as_deref())
442                .await;
443
444            // If no price tags are configured, bypass payment enforcement
445            if accepts.is_empty() {
446                return inner.call(req).await;
447            }
448
449            let resource = resource_builder.resolve(base_url.as_deref(), &req);
450
451            let mut gate = Paygate::builder(facilitator)
452                .accepts(accepts)
453                .resource(resource)
454                .build();
455            gate.enrich_accepts().await;
456
457            let result = match settlement_mode {
458                SettlementMode::Sequential => gate.handle_request(inner, req).await,
459                SettlementMode::Concurrent => gate.handle_request_concurrent(inner, req).await,
460                SettlementMode::Background => gate.handle_request_background(inner, req).await,
461            };
462            Ok(result.unwrap_or_else(|err| gate.error_response(err)))
463        })
464    }
465}