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    #[allow(
135        clippy::expect_used,
136        reason = "constructor panics on invalid URL by design"
137    )]
138    pub fn new(url: &str) -> Self {
139        let facilitator = FacilitatorClient::try_from(url).expect("Invalid facilitator URL");
140        Self {
141            facilitator: Arc::new(facilitator),
142            base_url: None,
143        }
144    }
145
146    /// Creates a new middleware instance with a facilitator URL.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the URL is invalid.
151    pub fn try_new(url: &str) -> Result<Self, Box<dyn std::error::Error>> {
152        let facilitator = FacilitatorClient::try_from(url)?;
153        Ok(Self {
154            facilitator: Arc::new(facilitator),
155            base_url: None,
156        })
157    }
158
159    /// Returns the configured facilitator URL.
160    #[must_use]
161    pub fn facilitator_url(&self) -> &Url {
162        self.facilitator.base_url()
163    }
164
165    /// Sets the TTL for caching the facilitator's supported response.
166    ///
167    /// Default is 10 minutes. Use [`FacilitatorClient::without_supported_cache()`]
168    /// to disable caching entirely.
169    #[must_use]
170    pub fn with_supported_cache_ttl(&self, ttl: Duration) -> Self {
171        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
172        let facilitator = Arc::new(inner.with_supported_cache_ttl(ttl));
173        Self {
174            facilitator,
175            base_url: self.base_url.clone(),
176        }
177    }
178
179    /// Sets a per-request timeout for all facilitator HTTP calls (verify, settle, supported).
180    ///
181    /// Without this, the underlying `reqwest::Client` uses no timeout by default,
182    /// which can cause requests to hang indefinitely if the facilitator is slow
183    /// or unreachable, eventually triggering OS-level TCP timeouts (typically 2–5 minutes).
184    ///
185    /// A reasonable production value is 30 seconds.
186    #[must_use]
187    pub fn with_facilitator_timeout(&self, timeout: Duration) -> Self {
188        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
189        let facilitator = Arc::new(inner.with_timeout(timeout));
190        Self {
191            facilitator,
192            base_url: self.base_url.clone(),
193        }
194    }
195}
196
197impl TryFrom<&str> for X402Middleware<Arc<FacilitatorClient>> {
198    type Error = Box<dyn std::error::Error>;
199
200    fn try_from(value: &str) -> Result<Self, Self::Error> {
201        Self::try_new(value)
202    }
203}
204
205impl TryFrom<String> for X402Middleware<Arc<FacilitatorClient>> {
206    type Error = Box<dyn std::error::Error>;
207
208    fn try_from(value: String) -> Result<Self, Self::Error> {
209        Self::try_new(&value)
210    }
211}
212
213impl<F> X402Middleware<F>
214where
215    F: Clone,
216{
217    /// Sets the base URL used to construct resource URLs dynamically.
218    ///
219    /// If [`X402LayerBuilder::with_resource`] is not called, this base URL is combined with
220    /// each request's path/query to compute the resource. If not set, defaults to `http://localhost/`.
221    ///
222    /// In production, prefer calling `with_resource` or setting a precise `base_url`.
223    #[must_use]
224    pub fn with_base_url(&self, base_url: Url) -> Self {
225        let mut this = self.clone();
226        this.base_url = Some(base_url);
227        this
228    }
229}
230
231impl<TFacilitator> X402Middleware<TFacilitator>
232where
233    TFacilitator: Clone,
234{
235    /// Sets the price tag for the protected route.
236    ///
237    /// Creates a layer builder that can be further configured with additional
238    /// price tags and resource information.
239    #[must_use]
240    pub fn with_price_tag(
241        &self,
242        price_tag: v2::PriceTag,
243    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
244        X402LayerBuilder {
245            facilitator: self.facilitator.clone(),
246            price_source: StaticPriceTags::new(vec![price_tag]),
247            base_url: self.base_url.clone().map(Arc::new),
248            resource: Arc::new(ResourceTemplate::default()),
249            settlement_mode: SettlementMode::default(),
250        }
251    }
252
253    /// Sets multiple price tags for the protected route.
254    ///
255    /// Convenience method for services that accept several payment options
256    /// (e.g. multiple tokens / networks).  Returns an empty-bypass builder
257    /// when the list is empty — the middleware will pass requests through
258    /// without payment enforcement.
259    #[must_use]
260    pub fn with_price_tags(
261        &self,
262        price_tags: Vec<v2::PriceTag>,
263    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
264        X402LayerBuilder {
265            facilitator: self.facilitator.clone(),
266            price_source: StaticPriceTags::new(price_tags),
267            base_url: self.base_url.clone().map(Arc::new),
268            resource: Arc::new(ResourceTemplate::default()),
269            settlement_mode: SettlementMode::default(),
270        }
271    }
272
273    /// Sets a dynamic price source for the protected route.
274    ///
275    /// The `callback` receives request headers, URI, and base URL, and returns
276    /// a vector of V2 price tags.
277    #[must_use]
278    pub fn with_dynamic_price<F, Fut>(
279        &self,
280        callback: F,
281    ) -> X402LayerBuilder<DynamicPriceTags, TFacilitator>
282    where
283        F: Fn(&HeaderMap, &Uri, Option<&Url>) -> Fut + Send + Sync + 'static,
284        Fut: Future<Output = Vec<v2::PriceTag>> + Send + 'static,
285    {
286        X402LayerBuilder {
287            facilitator: self.facilitator.clone(),
288            price_source: DynamicPriceTags::new(callback),
289            base_url: self.base_url.clone().map(Arc::new),
290            resource: Arc::new(ResourceTemplate::default()),
291            settlement_mode: SettlementMode::default(),
292        }
293    }
294}
295
296/// Builder for configuring the X402 middleware layer.
297///
298/// Generic over `TSource` which implements [`PriceTagSource`] to support
299/// both static and dynamic pricing strategies.
300#[derive(Clone)]
301#[allow(
302    missing_debug_implementations,
303    reason = "generic types may not impl Debug"
304)]
305pub struct X402LayerBuilder<TSource, TFacilitator> {
306    facilitator: TFacilitator,
307    base_url: Option<Arc<Url>>,
308    price_source: TSource,
309    resource: Arc<ResourceTemplate>,
310    settlement_mode: SettlementMode,
311}
312
313impl<TFacilitator> X402LayerBuilder<StaticPriceTags, TFacilitator> {
314    /// Adds another payment option.
315    ///
316    /// Allows specifying multiple accepted payment methods (e.g., different networks).
317    ///
318    /// Note: This method is only available for static price tag sources.
319    #[must_use]
320    pub fn with_price_tag(mut self, price_tag: v2::PriceTag) -> Self {
321        self.price_source = self.price_source.with_price_tag(price_tag);
322        self
323    }
324}
325
326#[allow(
327    missing_debug_implementations,
328    reason = "generic types may not impl Debug"
329)]
330impl<TSource, TFacilitator> X402LayerBuilder<TSource, TFacilitator> {
331    /// Sets a description of what the payment grants access to.
332    ///
333    /// This is included in 402 responses to inform clients what they're paying for.
334    #[must_use]
335    pub fn with_description(mut self, description: String) -> Self {
336        let mut new_resource = (*self.resource).clone();
337        new_resource.description = description;
338        self.resource = Arc::new(new_resource);
339        self
340    }
341
342    /// Sets the MIME type of the protected resource.
343    ///
344    /// Defaults to `application/json` if not specified.
345    #[must_use]
346    pub fn with_mime_type(mut self, mime: String) -> Self {
347        let mut new_resource = (*self.resource).clone();
348        new_resource.mime_type = mime;
349        self.resource = Arc::new(new_resource);
350        self
351    }
352
353    /// Sets the full URL of the protected resource.
354    ///
355    /// When set, this URL is used directly instead of constructing it from the base URL
356    /// and request URI. This is the preferred approach in production.
357    #[must_use]
358    #[allow(
359        clippy::needless_pass_by_value,
360        reason = "Url consumed via to_string()"
361    )]
362    pub fn with_resource(mut self, resource: Url) -> Self {
363        let mut new_resource = (*self.resource).clone();
364        new_resource.url = Some(resource.to_string());
365        self.resource = Arc::new(new_resource);
366        self
367    }
368
369    /// Sets the settlement mode.
370    ///
371    /// - [`SettlementMode::Sequential`] (default): verify → execute → settle.
372    /// - [`SettlementMode::Concurrent`]: verify → (settle ∥ execute) → await settle.
373    /// - [`SettlementMode::Background`]: verify → spawn settle → execute → return.
374    ///
375    /// Concurrent mode reduces total latency by overlapping settlement with
376    /// handler execution. Background mode is ideal for streaming responses
377    /// where the client should receive data immediately (settlement errors
378    /// are logged but do not propagate).
379    #[must_use]
380    pub const fn with_settlement_mode(mut self, mode: SettlementMode) -> Self {
381        self.settlement_mode = mode;
382        self
383    }
384}
385
386impl<S, TSource, TFacilitator> Layer<S> for X402LayerBuilder<TSource, TFacilitator>
387where
388    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
389    S::Future: Send + 'static,
390    TFacilitator: Facilitator + Clone,
391    TSource: PriceTagSource,
392{
393    type Service = X402MiddlewareService<TSource, TFacilitator>;
394
395    fn layer(&self, inner: S) -> Self::Service {
396        X402MiddlewareService {
397            facilitator: self.facilitator.clone(),
398            base_url: self.base_url.clone(),
399            price_source: self.price_source.clone(),
400            resource: Arc::clone(&self.resource),
401            settlement_mode: self.settlement_mode,
402            inner: BoxCloneSyncService::new(inner),
403        }
404    }
405}
406
407/// Axum service that enforces x402 payments on incoming requests.
408///
409/// Generic over `TSource` which implements [`PriceTagSource`] to support
410/// both static and dynamic pricing strategies.
411#[derive(Clone)]
412#[allow(
413    missing_debug_implementations,
414    reason = "BoxCloneSyncService does not impl Debug"
415)]
416pub struct X402MiddlewareService<TSource, TFacilitator> {
417    /// Payment facilitator (local or remote)
418    facilitator: TFacilitator,
419    /// Base URL for constructing resource URLs
420    base_url: Option<Arc<Url>>,
421    /// Price tag source - can be static or dynamic
422    price_source: TSource,
423    /// Resource information
424    resource: Arc<ResourceTemplate>,
425    /// Settlement strategy (sequential, concurrent, or background)
426    settlement_mode: SettlementMode,
427    /// The inner Axum service being wrapped
428    inner: BoxCloneSyncService<Request, Response, Infallible>,
429}
430
431impl<TSource, TFacilitator> Service<Request> for X402MiddlewareService<TSource, TFacilitator>
432where
433    TSource: PriceTagSource,
434    TFacilitator: Facilitator + Clone + Send + Sync + 'static,
435{
436    type Response = Response;
437    type Error = Infallible;
438    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
439
440    /// Delegates readiness polling to the wrapped inner service.
441    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
442        self.inner.poll_ready(cx)
443    }
444
445    /// Intercepts the request, injects payment enforcement logic, and forwards to the wrapped service.
446    fn call(&mut self, req: Request) -> Self::Future {
447        let price_source = self.price_source.clone();
448        let facilitator = self.facilitator.clone();
449        let base_url = self.base_url.clone();
450        let resource_builder = Arc::clone(&self.resource);
451        let settlement_mode = self.settlement_mode;
452        let mut inner = self.inner.clone();
453
454        Box::pin(async move {
455            // Resolve price tags from the source
456            let accepts = price_source
457                .resolve(req.headers(), req.uri(), base_url.as_deref())
458                .await;
459
460            // If no price tags are configured, bypass payment enforcement
461            if accepts.is_empty() {
462                return inner.call(req).await;
463            }
464
465            let resource = resource_builder.resolve(base_url.as_deref(), &req);
466
467            let mut gate = Paygate::builder(facilitator)
468                .accepts(accepts)
469                .resource(resource)
470                .build();
471            gate.enrich_accepts().await;
472
473            let result = match settlement_mode {
474                SettlementMode::Sequential => gate.handle_request(inner, req).await,
475                SettlementMode::Concurrent => gate.handle_request_concurrent(inner, req).await,
476                SettlementMode::Background => gate.handle_request_background(inner, req).await,
477            };
478            Ok(result.unwrap_or_else(|err| gate.error_response(err)))
479        })
480    }
481}