diff --git a/Cargo.toml b/Cargo.toml index 437f393..b42ef7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "x402-rs" -version = "0.10.0" +version = "0.9.2" authors = ["Sergey Ukustov "] edition = "2024" license = "Apache-2.0" diff --git a/crates/x402-axum/Cargo.toml b/crates/x402-axum/Cargo.toml index 8bf44fe..cb56862 100644 --- a/crates/x402-axum/Cargo.toml +++ b/crates/x402-axum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "x402-axum" -version = "0.6.2" +version = "0.6.1" edition = "2024" description = "Axum middleware for enforcing x402 protocol payments on protected routes" license = "Apache-2.0" diff --git a/crates/x402-axum/src/layer.rs b/crates/x402-axum/src/layer.rs index 62293fd..1e98812 100644 --- a/crates/x402-axum/src/layer.rs +++ b/crates/x402-axum/src/layer.rs @@ -69,7 +69,8 @@ use once_cell::sync::Lazy; use serde_json::json; use std::collections::HashSet; use std::fmt::{Debug, Display}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use std::{ convert::Infallible, future::Future, @@ -126,7 +127,13 @@ pub struct X402Middleware { /// - a fully constructed list of [`PaymentRequirements`] (if [`X402Middleware::with_resource`] was used), /// - or a partial list without `resource`, in which case the resource URL will be computed dynamically per request. /// In this case, please add `base_url` via [`X402Middleware::with_base_url`]. - payment_offers: Arc, + payment_offers: Arc>, + /// Cached stale payment offers shared across all service instances. + /// + /// When prices are updated via [`X402Middleware::update_price_tags`], the current offers + /// are moved here and remain valid for a short grace period (defined by `STALE_PAYMENT_VALIDITY_MILLIS`). + /// This allows in-flight requests with old payment authorizations to complete successfully. + stale_payment_offers: Arc>>, } impl TryFrom<&str> for X402Middleware { @@ -160,7 +167,8 @@ impl X402Middleware { input_schema: None, output_schema: None, settle_before_execution: false, - payment_offers: Arc::new(PaymentOffers::Ready(Arc::new(Vec::new()))), + payment_offers: Arc::new(RwLock::new(PaymentOffers::Ready(Arc::new(Vec::new())))), + stale_payment_offers: Arc::new(RwLock::new(None)), } } @@ -343,7 +351,7 @@ where let mut this = self.clone(); this.settle_before_execution = false; this - } + } /// Enables dynamic, per-request price computation via a callback. /// @@ -420,6 +428,57 @@ where }); this } + + /// Updates the payment price tags dynamically, moving current offers to stale for graceful transition. + /// + /// This method allows you to change pricing on the fly while ensuring that in-flight requests + /// with old payment authorizations can still complete successfully. The old prices remain + /// valid for a short grace period (defined by `STALE_PAYMENT_VALIDITY_MILLIS`, currently 5 seconds). + /// + /// # Example + /// + /// ```rust,no_run + /// use x402_axum::X402Middleware; + /// use x402_rs::network::{Network, USDCDeployment}; + /// use x402_axum::IntoPriceTag; + /// + /// let x402 = X402Middleware::try_from("https://facilitator.example.com/") + /// .unwrap() + /// .with_price_tag( + /// USDCDeployment::by_network(Network::BaseSepolia) + /// .amount("0.01") + /// .pay_to("0xADDRESS") + /// .unwrap() + /// ); + /// + /// // Later, update the price + /// x402.update_price_tags( + /// USDCDeployment::by_network(Network::BaseSepolia) + /// .amount("0.02") + /// .pay_to("0xADDRESS") + /// .unwrap() + /// ); + /// ``` + #[allow(dead_code)] // Public for consumption by downstream crates. + pub fn update_price_tags>>(&self, new_price_tags: T) { + let new_price_tags = new_price_tags.into().clone(); + + // Move current offers to stale + let current_offers = self.payment_offers.read().unwrap().clone(); + let mut stale = self.stale_payment_offers.write().unwrap(); + *stale = Some(StalePaymentOffers::new(current_offers)); + drop(stale); + + // Create a new instance with the new price tags and recompute offers + let mut this = self.clone(); + this.price_tag = new_price_tags; + this = this.recompute_offers(); + + // Update the shared payment_offers with the newly computed offers + let mut offers = self.payment_offers.write().unwrap(); + *offers = this.payment_offers.read().unwrap().clone(); + drop(offers); + } fn recompute_offers(mut self) -> Self { let base_url = self.base_url(); @@ -506,7 +565,8 @@ where base_url, } }; - self.payment_offers = Arc::new(payment_offers); + + self.payment_offers = Arc::new(RwLock::new(payment_offers)); self } } @@ -523,7 +583,9 @@ pub struct X402MiddlewareService { /// Payment facilitator (local or remote) facilitator: Arc, /// Payment requirements either with static or dynamic resource URLs - payment_offers: Arc, + payment_offers: Arc>, + /// Cached stale payment offers shared across all service instances. + stale_payment_offers: Arc>>, /// Whether to settle payment before executing the request (true) or after (false) settle_before_execution: bool, /// The inner Axum service being wrapped @@ -549,6 +611,7 @@ where facilitator: self.facilitator.clone(), payment_offers: self.payment_offers.clone(), settle_before_execution: self.settle_before_execution, + stale_payment_offers: self.stale_payment_offers.clone(), inner: BoxCloneSyncService::new(inner), } } @@ -568,21 +631,33 @@ where } /// Intercepts the request, injects payment enforcement logic, and forwards to the wrapped service. - fn call(&mut self, req: Request) -> Self::Future { - let offers = self.payment_offers.clone(); - let facilitator = self.facilitator.clone(); + async fn call(&mut self, req: Request) -> Self::Future { + let payment_offers = self.payment_offers.read().unwrap(); + let payment_requirements = + gather_payment_requirements(&payment_offers, req.uri(), req.headers()); + drop(payment_offers); + + let mut all_valid_payment_requirements = payment_requirements.as_ref().clone(); + + // Check if there are stale offers that are still valid + let stale_offers_guard = self.stale_payment_offers.read().unwrap(); + if let Some(ref stale) = *stale_offers_guard { + if stale.still_valid() { + let stale_requirements = gather_payment_requirements(&stale.payment_offers, req.uri(), req.headers()).await; + all_valid_payment_requirements.extend(stale_requirements.as_ref().clone()); + } + } + drop(stale_offers_guard); + + println!("All valid payment requirements: {:?}", all_valid_payment_requirements); + + let gate = X402Paygate { + facilitator: self.facilitator.clone(), + payment_requirements: Arc::new(all_valid_payment_requirements), + settle_before_execution: self.settle_before_execution, + }; let inner = self.inner.clone(); - let settle_before_execution = self.settle_before_execution; - Box::pin(async move { - let payment_requirements = - gather_payment_requirements(offers.as_ref(), req.uri(), req.headers()).await; - let gate = X402Paygate { - facilitator, - payment_requirements, - settle_before_execution, - }; - gate.call(inner, req).await - }) + Box::pin(gate.call(inner, req)) } } @@ -764,8 +839,9 @@ where let selected = self .find_matching_payment_requirements(&payment_payload) .ok_or(X402Error::no_payment_matching( - self.payment_requirements.as_ref().clone(), + self.payment_requirements.as_ref().clone() ))?; + let verify_request = VerifyRequest { x402_version: payment_payload.x402_version, payment_payload, @@ -1023,6 +1099,39 @@ pub enum PaymentOffers { }, } +const STALE_PAYMENT_VALIDITY_MILLIS: u128 = 5_000; // 5 seconds TODO: configurable. + +#[derive(Clone, Debug)] +pub struct StalePaymentOffers { + pub payment_offers: PaymentOffers, + pub valid_until: u128, // unix timestamp in milliseconds +} + +impl StalePaymentOffers { + pub fn new(payment_offers: PaymentOffers) -> Self { + let now = SystemTime::now(); + let timestamp_duration = now + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + + let valid_until = timestamp_duration.as_millis() + STALE_PAYMENT_VALIDITY_MILLIS; + + Self { + payment_offers, + valid_until, + } + } + + fn still_valid(&self) -> bool { + let now = SystemTime::now(); + let timestamp_duration = now + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + + timestamp_duration.as_millis() < self.valid_until + } +} + /// Constructs a full list of [`PaymentRequirements`] for a request. /// /// This function returns a shared, reference-counted vector of [`PaymentRequirements`]