Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "x402-rs"
version = "0.10.0"
version = "0.9.2"
authors = ["Sergey Ukustov <sergey@ukstv.me>"]
edition = "2024"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion crates/x402-axum/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
151 changes: 130 additions & 21 deletions crates/x402-axum/src/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -126,7 +127,13 @@ pub struct X402Middleware<F> {
/// - 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<PaymentOffers>,
payment_offers: Arc<RwLock<PaymentOffers>>,
/// 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<RwLock<Option<StalePaymentOffers>>>,
}

impl TryFrom<&str> for X402Middleware<FacilitatorClient> {
Expand Down Expand Up @@ -160,7 +167,8 @@ impl<F> X402Middleware<F> {
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)),
}
}

Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<T: Into<Vec<PriceTag>>>(&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();
Expand Down Expand Up @@ -506,7 +565,8 @@ where
base_url,
}
};
self.payment_offers = Arc::new(payment_offers);

self.payment_offers = Arc::new(RwLock::new(payment_offers));
self
}
}
Expand All @@ -523,7 +583,9 @@ pub struct X402MiddlewareService<F> {
/// Payment facilitator (local or remote)
facilitator: Arc<F>,
/// Payment requirements either with static or dynamic resource URLs
payment_offers: Arc<PaymentOffers>,
payment_offers: Arc<RwLock<PaymentOffers>>,
/// Cached stale payment offers shared across all service instances.
stale_payment_offers: Arc<RwLock<Option<StalePaymentOffers>>>,
/// Whether to settle payment before executing the request (true) or after (false)
settle_before_execution: bool,
/// The inner Axum service being wrapped
Expand All @@ -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),
}
}
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`]
Expand Down