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}