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}