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