use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::Deserialize;
use serde_json::{Value, json};
#[cfg(feature = "http-client")]
use sha2::Digest as _;
pub const UCP_PATH: &str = "/.well-known/ucp";
pub const ACP_PATH: &str = "/.well-known/acp.json";
#[derive(Debug, Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct X402Config {
#[serde(default)]
pub facilitator_url: Option<String>,
#[serde(default)]
pub pay_to: Option<String>,
#[serde(default)]
pub network: Option<String>,
#[serde(default)]
pub asset: Option<String>,
#[serde(default)]
pub asset_name: Option<String>,
#[serde(default)]
pub asset_version: Option<String>,
#[serde(default)]
pub scheme: Option<String>,
#[serde(default)]
pub max_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct PaidRoute {
pub method: String,
pub path: String,
pub amount: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub mpp_method: Option<String>,
#[serde(default)]
pub mpp_currency: Option<String>,
#[serde(default)]
pub mpp_amount: Option<String>,
}
impl PaidRoute {
#[must_use]
pub fn new(
method: impl Into<String>,
path: impl Into<String>,
amount: impl Into<String>,
) -> Self {
Self {
method: method.into(),
path: path.into(),
amount: amount.into(),
..Self::default()
}
}
#[must_use]
pub fn matches(&self, method: &str, path: &str) -> bool {
self.method_matches(method) && template_matches(&self.path, path)
}
fn method_matches(&self, method: &str) -> bool {
self.method.eq_ignore_ascii_case(method)
|| (method.eq_ignore_ascii_case("HEAD") && self.method.eq_ignore_ascii_case("GET"))
}
#[must_use]
pub const fn is_x402(&self) -> bool {
self.mpp_method.is_none()
}
}
fn normalize(path: &str) -> &str {
if path.len() > 1 {
path.strip_suffix('/').unwrap_or(path)
} else {
path
}
}
fn template_matches(template: &str, path: &str) -> bool {
let mut want = normalize(template).split('/');
let mut got = normalize(path).split('/');
loop {
match (want.next(), got.next()) {
(None, None) => return true,
(Some(w), Some(g)) => {
if w.starts_with("{*") && w.ends_with('}') {
return !g.is_empty();
}
let is_param = w.starts_with('{') && w.ends_with('}');
if (is_param && g.is_empty()) || (!is_param && w != g) {
return false;
}
}
_ => return false,
}
}
}
#[must_use]
pub fn valid_amount(amount: &str) -> bool {
!amount.is_empty()
&& amount.bytes().all(|b| b.is_ascii_digit())
&& (amount == "0" || !amount.starts_with('0'))
}
#[must_use]
pub fn unmatchable_route(route: &PaidRoute) -> Option<String> {
const METHODS: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
if !METHODS
.iter()
.any(|m| m.eq_ignore_ascii_case(&route.method))
{
return Some(format!(
"{:?} {}: method must be one of {}",
route.method,
route.path,
METHODS.join(", ")
));
}
if !route.path.starts_with('/')
|| route
.path
.chars()
.any(|c| c.is_whitespace() || c == '?' || c == '#')
{
return Some(format!(
"{} {:?}: path must start with `/`, with no spaces, query, or fragment",
route.method, route.path
));
}
if !valid_template(&route.path) {
return Some(format!(
"{} {:?}: a capture must be a whole `{{name}}` segment, and `{{*name}}` only last",
route.method, route.path
));
}
None
}
#[must_use]
pub fn mixed_protocol_route(routes: &[PaidRoute]) -> Option<String> {
routes.iter().filter(|r| r.is_x402()).find_map(|x402| {
routes
.iter()
.filter(|r| !r.is_x402())
.find(|mpp| x402.method_matches(&mpp.method) && same_template(&mpp.path, &x402.path))
.map(|mpp| {
format!(
"{} {:?} is priced for both x402 and MPP (`mpp_method` on {:?}); price it \
with one",
x402.method, x402.path, mpp.path
)
})
})
}
fn same_template(left: &str, right: &str) -> bool {
same_shape(normalize(left), normalize(right))
}
fn same_shape(left: &str, right: &str) -> bool {
let kind = |segment: &str| {
if segment.starts_with("{*") && segment.ends_with('}') {
"*"
} else if segment.starts_with('{') && segment.ends_with('}') {
"{}"
} else {
""
}
};
let mut lhs = left.split('/');
let mut rhs = right.split('/');
loop {
match (lhs.next(), rhs.next()) {
(None, None) => return true,
(Some(l), Some(r)) => {
if kind(l) != kind(r) || (kind(l).is_empty() && l != r) {
return false;
}
}
_ => return false,
}
}
}
fn specificity(template: &str) -> Vec<u8> {
normalize(template)
.split('/')
.map(|segment| {
if segment.starts_with("{*") {
0
} else if segment.starts_with('{') {
1
} else {
2
}
})
.collect()
}
fn valid_template(path: &str) -> bool {
let segments: Vec<&str> = path.split('/').collect();
segments.iter().enumerate().all(|(i, s)| {
if !s.contains(['{', '}']) {
return true;
}
let Some(inner) = s.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
return false;
};
let (name, catch_all) = inner
.strip_prefix('*')
.map_or((inner, false), |n| (n, true));
!name.is_empty()
&& !name.contains(['{', '}', '*'])
&& (!catch_all || i == segments.len() - 1)
})
}
#[must_use]
pub fn paid_route_problems(routes: &[PaidRoute]) -> Vec<String> {
let mut problems = Vec::new();
for r in routes {
problems.extend(unmatchable_route(r));
if !valid_amount(&r.amount) {
problems.push(format!(
"{} {}: amount {:?} is not digits",
r.method, r.path, r.amount
));
}
if let Some(m) = &r.mpp_method {
if m.is_empty() || m.bytes().any(|b| !(b.is_ascii_lowercase() || b == b'-')) {
problems.push(format!(
"{} {}: mpp_method {m:?} must be lowercase",
r.method, r.path
));
}
if r.mpp_amount.as_deref().is_some_and(|a| !valid_amount(a)) {
problems.push(format!("{} {}: mpp_amount is not digits", r.method, r.path));
}
}
}
problems
}
fn route_is_valid(r: &PaidRoute) -> bool {
paid_route_problems(std::slice::from_ref(r)).is_empty()
}
impl X402Config {
#[must_use]
pub fn is_complete(&self) -> bool {
[
&self.facilitator_url,
&self.pay_to,
&self.network,
&self.asset,
]
.iter()
.all(|v| v.as_deref().is_some_and(|v| !v.trim().is_empty()))
}
#[must_use]
pub fn requirements(&self, route: &PaidRoute) -> Value {
json!({
"scheme": self.scheme.as_deref().unwrap_or("exact"),
"network": self.network.as_deref().unwrap_or_default(),
"amount": route.amount,
"asset": self.asset.as_deref().unwrap_or_default(),
"payTo": self.pay_to.as_deref().unwrap_or_default(),
"maxTimeoutSeconds": self.max_timeout_secs.unwrap_or(300),
"extra": {
"name": self.asset_name.as_deref().unwrap_or("USDC"),
"version": self.asset_version.as_deref().unwrap_or("2"),
},
})
}
#[must_use]
pub fn payment_required(&self, route: &PaidRoute, resource_url: &str, error: &str) -> Value {
json!({
"x402Version": 2,
"error": error,
"resource": {
"url": resource_url,
"description": route.description.as_deref().unwrap_or_default(),
},
"accepts": [self.requirements(route)],
})
}
}
#[must_use]
pub fn encode_header(value: &Value) -> String {
STANDARD.encode(value.to_string())
}
#[must_use]
pub fn decode_header(header: &str) -> Option<Value> {
let bytes = STANDARD.decode(header.trim()).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[must_use]
#[cfg_attr(not(feature = "http-client"), allow(dead_code))]
pub(crate) fn accepted_matches(accepted: &Value, offered: &Value) -> bool {
["scheme", "network", "amount", "asset", "payTo"]
.iter()
.all(|k| accepted.get(k).is_some() && accepted.get(k) == offered.get(k))
}
pub(crate) fn apply_mpp(spec: &mut Value, routes: &[PaidRoute]) {
for route in routes.iter().filter(|r| route_is_valid(r)) {
let Some(method) = route.mpp_method.as_deref() else {
continue;
};
let Some(op) = spec
.get_mut("paths")
.and_then(Value::as_object_mut)
.and_then(|paths| {
let key = paths
.keys()
.find(|k| same_shape(k, &route.path))
.or_else(|| paths.keys().find(|k| same_template(k, &route.path)))?
.clone();
paths.get_mut(&key)
})
.and_then(|p| p.get_mut(route.method.to_ascii_lowercase()))
.and_then(Value::as_object_mut)
else {
continue;
};
let mut info = json!({
"intent": "charge",
"method": method,
"amount": route.mpp_amount.as_deref().unwrap_or(&route.amount),
});
if let Some(currency) = &route.mpp_currency {
info["currency"] = json!(currency);
}
if let Some(d) = &route.description {
info["description"] = json!(d);
}
op.insert("x-payment-info".to_owned(), info);
let responses = op.entry("responses").or_insert_with(|| json!({}));
if let Some(responses) = responses.as_object_mut() {
responses
.entry("402")
.or_insert_with(|| json!({ "description": "Payment Required" }));
}
}
}
pub fn spec_json_with_mpp(
spec: &impl serde::Serialize,
config: &crate::config::AutumnConfig,
) -> serde_json::Result<String> {
if !has_mpp(config) {
return serde_json::to_string_pretty(spec);
}
let mut value = serde_json::to_value(spec)?;
apply_mpp(&mut value, &config.aeo.paid_routes);
serde_json::to_string_pretty(&value)
}
#[cfg(feature = "openapi")]
pub fn write_mpp_spec(
spec: &crate::openapi::OpenApiSpec,
config: &crate::config::AutumnConfig,
dist_dir: &std::path::Path,
) -> std::io::Result<()> {
if !has_mpp(config) {
return Ok(());
}
let mut value = serde_json::to_value(spec).map_err(std::io::Error::other)?;
apply_mpp(&mut value, &config.aeo.paid_routes);
let json = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
std::fs::write(dist_dir.join("openapi.json"), json)?;
let yaml = serde_saphyr::to_string(&value).map_err(std::io::Error::other)?;
std::fs::write(dist_dir.join("openapi.yaml"), yaml)
}
fn has_mpp(config: &crate::config::AutumnConfig) -> bool {
config.aeo.enabled
&& config
.aeo
.paid_routes
.iter()
.any(|r| r.mpp_method.is_some())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CommerceError {
#[error("{doc}: `{field}` is missing or not valid")]
Invalid {
doc: &'static str,
field: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UcpProfile(Value);
impl UcpProfile {
pub fn new(profile: Value) -> Result<Self, CommerceError> {
let missing = |field| CommerceError::Invalid {
doc: "UCP profile",
field,
};
let ucp = profile.get("ucp").ok_or_else(|| missing("ucp"))?;
if ucp
.get("version")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
return Err(missing("ucp.version"));
}
if !ucp.get("services").is_some_and(Value::is_object) {
return Err(missing("ucp.services"));
}
if !ucp.get("payment_handlers").is_some_and(Value::is_object) {
return Err(missing("ucp.payment_handlers"));
}
Ok(Self(profile))
}
#[must_use]
pub const fn as_json(&self) -> &Value {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcpDiscovery(Value);
impl AcpDiscovery {
pub fn new(document: Value) -> Result<Self, CommerceError> {
let missing = |field| CommerceError::Invalid {
doc: "ACP discovery",
field,
};
let non_empty_str =
|v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty());
let non_empty_arr =
|v: Option<&Value>| v.and_then(Value::as_array).is_some_and(|a| !a.is_empty());
let protocol = document.get("protocol");
if protocol.and_then(|p| p.get("name")).and_then(Value::as_str) != Some("acp") {
return Err(missing("protocol.name"));
}
if !non_empty_str(protocol.and_then(|p| p.get("version"))) {
return Err(missing("protocol.version"));
}
if !non_empty_arr(protocol.and_then(|p| p.get("supported_versions"))) {
return Err(missing("protocol.supported_versions"));
}
let absolute = document
.get("api_base_url")
.and_then(Value::as_str)
.and_then(|u| url::Url::parse(u).ok())
.is_some_and(|u| matches!(u.scheme(), "http" | "https"));
if !absolute {
return Err(missing("api_base_url"));
}
if !non_empty_arr(document.get("transports")) {
return Err(missing("transports"));
}
if !non_empty_arr(document.get("capabilities").and_then(|c| c.get("services"))) {
return Err(missing("capabilities.services"));
}
Ok(Self(document))
}
#[must_use]
pub const fn as_json(&self) -> &Value {
&self.0
}
}
#[cfg(feature = "http-client")]
#[derive(Clone)]
pub(crate) struct X402Layer {
state: std::sync::Arc<X402State>,
static_paths: StaticPaths,
}
#[cfg(feature = "http-client")]
struct X402State {
config: X402Config,
priced: PricedRoutes,
available: bool,
client: crate::http_client::Client,
base_url: Option<String>,
used: std::sync::Mutex<UsedPayments>,
}
#[cfg(feature = "http-client")]
#[derive(Debug)]
struct UsedPayments {
until: std::collections::HashMap<[u8; 32], u64>,
capacity: usize,
}
#[cfg(feature = "http-client")]
#[derive(Debug, PartialEq, Eq)]
enum ClaimError {
Used,
Full,
}
#[cfg(feature = "http-client")]
impl UsedPayments {
fn new(capacity: usize) -> Self {
Self {
until: std::collections::HashMap::new(),
capacity,
}
}
fn claim(&mut self, key: [u8; 32], expires: u64, now: u64) -> Result<(), ClaimError> {
if self.until.contains_key(&key) {
return Err(ClaimError::Used);
}
if self.until.len() >= self.capacity {
self.until.retain(|_, until| *until > now);
if self.until.len() >= self.capacity {
return Err(ClaimError::Full);
}
}
self.until.insert(key, expires);
Ok(())
}
fn release(&mut self, key: &[u8; 32]) {
self.until.remove(key);
}
}
#[cfg(feature = "http-client")]
fn payment_key(payload: &Value) -> [u8; 32] {
fn hex_value(v: &Value) -> String {
let s = match v {
Value::String(s) => s.to_ascii_lowercase(),
other => other.to_string(),
};
let s = s.strip_prefix("0x").unwrap_or(&s);
s.trim_start_matches('0').to_owned()
}
let proof = &payload["payload"];
let auth = &proof["authorization"];
let mut hasher = sha2::Sha256::new();
if auth["from"].is_string() && !auth["nonce"].is_null() {
hasher.update(b"eip3009");
for part in [
&payload["accepted"]["network"],
&payload["accepted"]["asset"],
&auth["from"],
&auth["nonce"],
] {
hasher.update(b"\0");
hasher.update(hex_value(part).as_bytes());
}
} else if let Some(signature) = proof["signature"].as_str() {
hasher.update(b"signature\0");
hasher.update(signature.to_ascii_lowercase().as_bytes());
} else {
hasher.update(b"payload\0");
hasher.update(canonical_json(proof).as_bytes());
}
hasher.finalize().into()
}
#[cfg(feature = "http-client")]
fn canonical_json(value: &Value) -> String {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let fields: Vec<String> = keys
.into_iter()
.map(|k| format!("{}:{}", Value::from(k.as_str()), canonical_json(&map[k])))
.collect();
format!("{{{}}}", fields.join(","))
}
Value::Array(items) => {
let items: Vec<String> = items.iter().map(canonical_json).collect();
format!("[{}]", items.join(","))
}
other => other.to_string(),
}
}
#[cfg(feature = "http-client")]
fn payment_expiry(payload: &Value, max_timeout_secs: u64, now: u64) -> u64 {
let valid_before = &payload["payload"]["authorization"]["validBefore"];
valid_before
.as_u64()
.or_else(|| valid_before.as_str().and_then(|s| s.parse().ok()))
.unwrap_or_else(|| now.saturating_add(max_timeout_secs))
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, Copy)]
struct X402Handled;
#[derive(Debug, Clone)]
struct PricedRoutes {
routes: Vec<PaidRoute>,
locales: Vec<String>,
unprefixed: Vec<String>,
mounted: Vec<String>,
nested: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RoutedTemplates {
pub(crate) mounted: Vec<String>,
pub(crate) nested: Vec<String>,
}
impl PricedRoutes {
fn from_config(
config: &crate::config::AutumnConfig,
state: &crate::state::AppState,
) -> Option<Self> {
let aeo = &config.aeo;
let routes = aeo.paid_routes.clone();
if !aeo.enabled || !routes.iter().any(PaidRoute::is_x402) {
return None;
}
#[cfg(feature = "i18n")]
let locales = if config.i18n.locale_prefix_enabled {
crate::router::validated_locale_prefix_locales(&config.i18n)
} else {
Vec::new()
};
#[cfg(feature = "i18n")]
let unprefixed = routes
.iter()
.filter(|r| {
config.i18n.locale_prefix_exclude_exact.contains(&r.path)
|| crate::router::matches_locale_exclude_prefix(
&r.path,
&config.i18n.locale_prefix_exclude,
)
})
.map(|r| r.path.clone())
.collect();
#[cfg(not(feature = "i18n"))]
let (locales, unprefixed) = (Vec::new(), Vec::new());
let RoutedTemplates { mounted, nested } = state
.extension::<RoutedTemplates>()
.map(|r| (*r).clone())
.unwrap_or_default();
Some(Self {
routes,
locales,
unprefixed,
mounted,
nested,
})
}
fn route_for(&self, req: &axum::http::Request<axum::body::Body>) -> Option<&PaidRoute> {
let method = req.method().as_str();
self.lookup(req, |r| r.method.eq_ignore_ascii_case(method))
.or_else(|| self.lookup(req, |r| r.method_matches(method)))
.filter(|r| r.is_x402())
}
fn lookup(
&self,
req: &axum::http::Request<axum::body::Body>,
accepts: impl Fn(&PaidRoute) -> bool,
) -> Option<&PaidRoute> {
let template = req
.extensions()
.get::<axum::extract::MatchedPath>()
.map(axum::extract::MatchedPath::as_str)
.or_else(|| req.extensions().get::<StaticRoute>().map(|r| r.0.as_str()));
let routes = self.routes.iter().filter(|r| accepts(r));
if let Some(t) = template {
if self.nested.iter().any(|n| same_shape(n, t)) {
return None;
}
let unnested = |r: &&PaidRoute| !self.nested.iter().any(|n| same_template(n, &r.path));
return routes
.clone()
.find(|r| same_shape(&r.path, t) && unnested(r))
.or_else(|| {
routes.clone().find(|r| {
same_template(&r.path, t)
&& !self.another_route(&r.path, t, t)
&& unnested(r)
})
})
.or_else(|| {
let bare = self.strip_locale(t);
routes.clone().find(|r| {
self.localized(r)
&& same_template(&r.path, bare)
&& !self.another_route(&r.path, bare, t)
})
});
}
if !matches!(
*req.method(),
axum::http::Method::GET | axum::http::Method::HEAD
) {
return None;
}
let path = req.uri().path();
routes
.filter(|r| {
template_matches(&r.path, path)
|| (self.localized(r) && template_matches(&r.path, self.strip_locale(path)))
})
.rev()
.max_by_key(|r| specificity(&r.path))
}
fn another_route(&self, path: &str, compared: &str, matched: &str) -> bool {
if path.ends_with('/') == compared.ends_with('/') {
return false;
}
let other = match matched.strip_suffix('/') {
Some(bare) if !bare.is_empty() => bare.to_owned(),
_ => format!("{matched}/"),
};
self.mounted.iter().any(|r| same_shape(r, &other))
}
fn localized(&self, route: &PaidRoute) -> bool {
!self.unprefixed.contains(&route.path)
|| self.nested.iter().any(|n| same_template(n, &route.path))
}
fn strip_locale<'a>(&self, path: &'a str) -> &'a str {
for locale in &self.locales {
if let Some(rest) = path
.strip_prefix('/')
.and_then(|p| p.strip_prefix(locale.as_str()))
{
if rest.is_empty() {
return "/";
}
if rest.starts_with('/') {
return rest;
}
}
}
path
}
}
pub(crate) type StaticPages = std::collections::HashMap<String, Option<String>>;
pub(crate) type StaticPaths = Option<std::sync::Arc<StaticPages>>;
#[cfg(feature = "mcp")]
#[must_use]
pub(crate) fn prices_route(state: &crate::state::AppState, method: &str, template: &str) -> bool {
#[cfg(feature = "http-client")]
{
let Some(layer) = state.extension::<SharedX402>().and_then(|s| s.0.clone()) else {
return false;
};
let Ok(method) = axum::http::Method::from_bytes(method.as_bytes()) else {
return false;
};
let Ok(mut req) = axum::http::Request::builder()
.method(method)
.uri("/")
.body(axum::body::Body::empty())
else {
return false;
};
req.extensions_mut()
.insert(StaticRoute(template.to_owned()));
layer.state.priced.route_for(&req).is_some()
}
#[cfg(not(feature = "http-client"))]
{
let _ = (state, method, template);
false
}
}
pub(crate) fn priced_static_pages(
config: &crate::config::AutumnConfig,
state: &crate::state::AppState,
pages: &StaticPages,
) -> (std::collections::HashSet<String>, StaticPages) {
let mut routed = std::collections::HashSet::new();
let mut unrouted = StaticPages::new();
let Some(priced) = PricedRoutes::from_config(config, state) else {
return (routed, unrouted);
};
for (page, template) in pages {
let charged = [axum::http::Method::GET, axum::http::Method::HEAD]
.into_iter()
.any(|method| {
let Ok(mut req) = axum::http::Request::builder()
.method(method)
.uri(page.as_str())
.body(axum::body::Body::empty())
else {
return false;
};
if let Some(t) = template {
req.extensions_mut().insert(StaticRoute(t.clone()));
}
priced.route_for(&req).is_some()
});
match template {
Some(_) if charged => {
routed.insert(page.clone());
}
None if charged => {
unrouted.insert(page.clone(), None);
}
_ => {}
}
}
(routed, unrouted)
}
#[derive(Clone)]
struct StaticRoute(String);
fn static_miss(
static_paths: Option<&std::sync::Arc<StaticPages>>,
req: &mut axum::http::Request<axum::body::Body>,
) -> bool {
let Some(pages) = static_paths else {
return false;
};
let Some(template) = pages.get(normalize(req.uri().path())) else {
return true;
};
if let Some(t) = template {
req.extensions_mut().insert(StaticRoute(t.clone()));
}
false
}
#[cfg(feature = "http-client")]
impl X402Layer {
#[must_use]
pub(crate) fn for_static_pages(mut self, paths: std::sync::Arc<StaticPages>) -> Self {
self.static_paths = Some(paths);
self
}
}
#[cfg(not(feature = "http-client"))]
impl X402Unavailable {
#[must_use]
pub(crate) fn for_static_pages(mut self, paths: std::sync::Arc<StaticPages>) -> Self {
self.static_paths = Some(paths);
self
}
}
#[derive(Clone)]
pub(crate) struct X402CsrfLayer {
priced: std::sync::Arc<PricedRoutes>,
}
impl X402CsrfLayer {
#[must_use]
pub(crate) fn from_config(
config: &crate::config::AutumnConfig,
state: &crate::state::AppState,
) -> Option<Self> {
Some(Self {
priced: std::sync::Arc::new(PricedRoutes::from_config(config, state)?),
})
}
}
impl<S> tower::Layer<S> for X402CsrfLayer {
type Service = X402CsrfService<S>;
fn layer(&self, inner: S) -> Self::Service {
X402CsrfService {
inner,
priced: std::sync::Arc::clone(&self.priced),
}
}
}
#[derive(Clone)]
pub(crate) struct X402CsrfService<S> {
inner: S,
priced: std::sync::Arc<PricedRoutes>,
}
impl<S> tower::Service<axum::http::Request<axum::body::Body>> for X402CsrfService<S>
where
S: tower::Service<axum::http::Request<axum::body::Body>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: axum::http::Request<axum::body::Body>) -> Self::Future {
let headers = req.headers();
let not_forgeable = !headers.contains_key(axum::http::header::COOKIE)
|| headers.contains_key("payment-signature");
if not_forgeable && self.priced.route_for(&req).is_some() {
req.extensions_mut()
.insert(crate::security::csrf::CsrfNotApplicable);
}
self.inner.call(req)
}
}
#[cfg(feature = "http-client")]
impl X402Layer {
#[must_use]
pub(crate) fn from_config(
config: &crate::config::AutumnConfig,
state: &crate::state::AppState,
) -> Option<Self> {
let aeo = &config.aeo;
let priced = PricedRoutes::from_config(config, state)?;
let complete = aeo.x402.is_complete();
let safe = facilitator_url_is_safe(aeo.x402.facilitator_url.as_deref().unwrap_or_default());
if !complete {
tracing::error!(
"aeo: [[aeo.paid_routes]] has x402 routes but [aeo.x402] needs \
facilitator_url, pay_to, network, and asset; those routes answer 503"
);
} else if !safe {
tracing::error!(
"aeo: [aeo.x402] facilitator_url must be https (http only for localhost), \
with no query or fragment; priced routes answer 503"
);
}
Some(Self {
static_paths: None,
state: std::sync::Arc::new(X402State {
config: aeo.x402.clone(),
priced,
available: complete && safe,
client: crate::http_client::Client::from_state(state).named("x402"),
base_url: config.seo.base_url.clone(),
used: std::sync::Mutex::new(UsedPayments::new(10_000)),
}),
})
}
}
#[cfg(feature = "http-client")]
impl X402Layer {
#[must_use]
pub(crate) fn shared(
config: &crate::config::AutumnConfig,
state: &crate::state::AppState,
) -> Option<Self> {
if let Some(shared) = state.extension::<SharedX402>() {
return shared.0.clone();
}
let layer = Self::from_config(config, state);
state.insert_extension(SharedX402(layer.clone()));
layer
}
}
#[cfg(not(feature = "http-client"))]
#[derive(Clone)]
pub(crate) struct X402Unavailable {
priced: std::sync::Arc<PricedRoutes>,
static_paths: StaticPaths,
}
#[cfg(not(feature = "http-client"))]
impl X402Unavailable {
#[must_use]
pub(crate) fn from_config(
config: &crate::config::AutumnConfig,
state: &crate::state::AppState,
) -> Option<Self> {
let priced = PricedRoutes::from_config(config, state)?;
tracing::error!(
"aeo: [[aeo.paid_routes]] has x402 routes, but x402 needs the `http-client` \
feature; those routes answer 503"
);
Some(Self {
priced: std::sync::Arc::new(priced),
static_paths: None,
})
}
}
#[cfg(not(feature = "http-client"))]
impl<S> tower::Layer<S> for X402Unavailable {
type Service = X402UnavailableService<S>;
fn layer(&self, inner: S) -> Self::Service {
X402UnavailableService {
inner,
priced: std::sync::Arc::clone(&self.priced),
static_paths: self.static_paths.clone(),
}
}
}
#[cfg(not(feature = "http-client"))]
#[derive(Clone)]
pub(crate) struct X402UnavailableService<S> {
inner: S,
priced: std::sync::Arc<PricedRoutes>,
static_paths: StaticPaths,
}
#[cfg(not(feature = "http-client"))]
impl<S> tower::Service<axum::http::Request<axum::body::Body>> for X402UnavailableService<S>
where
S: tower::Service<axum::http::Request<axum::body::Body>, Response = axum::response::Response>,
{
type Response = axum::response::Response;
type Error = S::Error;
type Future = futures::future::Either<
std::future::Ready<Result<axum::response::Response, S::Error>>,
S::Future,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: axum::http::Request<axum::body::Body>) -> Self::Future {
use axum::response::IntoResponse as _;
if !is_internal_render(&req)
&& !static_miss(self.static_paths.as_ref(), &mut req)
&& self.priced.route_for(&req).is_some()
{
let res = (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"payments are not available",
)
.into_response();
return futures::future::Either::Left(std::future::ready(Ok(res)));
}
futures::future::Either::Right(self.inner.call(req))
}
}
fn is_internal_render(req: &axum::http::Request<axum::body::Body>) -> bool {
req.extensions()
.get::<crate::static_gen::RenderDeadlineExempt>()
.is_some()
}
#[cfg(feature = "http-client")]
struct SharedX402(Option<X402Layer>);
#[cfg(feature = "http-client")]
impl<S> tower::Layer<S> for X402Layer {
type Service = X402Service<S>;
fn layer(&self, inner: S) -> Self::Service {
X402Service {
inner,
state: std::sync::Arc::clone(&self.state),
static_paths: self.static_paths.clone(),
}
}
}
#[cfg(feature = "http-client")]
#[derive(Clone)]
pub(crate) struct X402Service<S> {
inner: S,
state: std::sync::Arc<X402State>,
static_paths: StaticPaths,
}
#[cfg(feature = "http-client")]
impl<S> tower::Service<axum::http::Request<axum::body::Body>> for X402Service<S>
where
S: tower::Service<axum::http::Request<axum::body::Body>, Response = axum::response::Response>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
{
type Response = axum::response::Response;
type Error = S::Error;
type Future = futures::future::Either<
S::Future,
std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<axum::response::Response, S::Error>> + Send,
>,
>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
#[allow(clippy::too_many_lines)]
fn call(&mut self, mut req: axum::http::Request<axum::body::Body>) -> Self::Future {
let route = if req.extensions().get::<X402Handled>().is_some()
|| is_internal_render(&req)
|| static_miss(self.static_paths.as_ref(), &mut req)
{
None
} else {
self.state.priced.route_for(&req).cloned()
};
let Some(route) = route else {
return futures::future::Either::Left(self.inner.call(req));
};
req.extensions_mut().insert(X402Handled);
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let state = std::sync::Arc::clone(&self.state);
futures::future::Either::Right(Box::pin(async move {
if !state.available || !route_is_valid(&route) {
return Ok(plain(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"payments are not available",
));
}
let resource = resource_url(&state, &req);
let offered = state.config.requirements(&route);
let Some(header) = req
.headers()
.get("payment-signature")
.and_then(|v| v.to_str().ok())
else {
return Ok(payment_required(
&state,
&route,
&resource,
"Payment required",
));
};
let Some(payload) = decode_header(header) else {
return Ok(plain(
axum::http::StatusCode::BAD_REQUEST,
"malformed PAYMENT-SIGNATURE",
));
};
if !payload
.get("accepted")
.is_some_and(|a| accepted_matches(a, &offered))
{
return Ok(payment_required(
&state,
&route,
&resource,
"payment does not match the requirements",
));
}
let body = json!({
"x402Version": 2,
"paymentPayload": payload,
"paymentRequirements": offered,
});
let key = payment_key(&payload);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let expires =
payment_expiry(&payload, state.config.max_timeout_secs.unwrap_or(300), now);
let claim = state
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.claim(key, expires, now);
let mut claim = match claim {
Ok(()) => PaymentClaim::new(std::sync::Arc::clone(&state), key),
Err(ClaimError::Used) => {
return Ok(payment_required(
&state,
&route,
&resource,
"payment already used",
));
}
Err(ClaimError::Full) => {
tracing::error!(
"aeo: x402 used-payment record is full of unexpired payments; \
refusing new payments until some expire"
);
return Ok(plain(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"payment capacity reached",
));
}
};
let verify = match facilitator(&state, "verify", &body).await {
Ok(v) => v,
Err(status) => return Ok(plain(status, "payment facilitator unavailable")),
};
if verify.get("isValid").and_then(Value::as_bool) != Some(true) {
let reason = verify
.get("invalidReason")
.and_then(Value::as_str)
.unwrap_or("invalid payment")
.to_owned();
return Ok(payment_required(&state, &route, &resource, &reason));
}
let safe = matches!(
*req.method(),
axum::http::Method::GET | axum::http::Method::HEAD
);
let early = if safe {
None
} else {
claim.disarm();
match settle(&state, &route, &resource, &body).await {
Ok(receipt) => Some(receipt),
Err((failed, rejected)) => {
if rejected {
release_payment(&state, &key);
}
return Ok(*failed);
}
}
};
let mut res = inner.call(req).await?;
let receipt = match early {
Some(receipt) => receipt,
None if res.status().is_success() => {
claim.disarm();
match settle(&state, &route, &resource, &body).await {
Ok(receipt) => receipt,
Err((failed, rejected)) => {
if rejected {
release_payment(&state, &key);
}
return Ok(*failed);
}
}
}
None => return Ok(res),
};
let headers = res.headers_mut();
if let Some(h) = receipt {
headers.insert("payment-response", h);
}
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("private, no-store"),
);
expose_payment_headers(headers);
Ok(res)
}))
}
}
#[cfg(feature = "http-client")]
struct PaymentClaim {
state: std::sync::Arc<X402State>,
key: [u8; 32],
armed: bool,
}
#[cfg(feature = "http-client")]
impl PaymentClaim {
const fn new(state: std::sync::Arc<X402State>, key: [u8; 32]) -> Self {
Self {
state,
key,
armed: true,
}
}
const fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(feature = "http-client")]
impl Drop for PaymentClaim {
fn drop(&mut self) {
if self.armed {
release_payment(&self.state, &self.key);
}
}
}
#[cfg(feature = "http-client")]
fn release_payment(state: &X402State, key: &[u8; 32]) {
state
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.release(key);
}
#[cfg(feature = "http-client")]
async fn settle(
state: &X402State,
route: &PaidRoute,
resource: &str,
body: &Value,
) -> Result<Option<axum::http::HeaderValue>, (Box<axum::response::Response>, bool)> {
let settled = facilitator(state, "settle", body).await.map_err(|status| {
(
Box::new(plain(status, "payment facilitator unavailable")),
false,
)
})?;
let header = axum::http::HeaderValue::from_str(&encode_header(&settled)).ok();
if settled.get("success").and_then(Value::as_bool) == Some(true) {
return Ok(header);
}
let mut failed = payment_required(state, route, resource, "settlement failed");
if let Some(h) = header {
failed.headers_mut().insert("payment-response", h);
}
Err((Box::new(failed), true))
}
#[cfg(feature = "http-client")]
fn facilitator_url_is_safe(raw: &str) -> bool {
url::Url::parse(raw).is_ok_and(|u| {
u.query().is_none()
&& u.fragment().is_none()
&& match u.scheme() {
"https" => true,
"http" => matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")),
_ => false,
}
})
}
#[cfg(feature = "http-client")]
fn resource_url(state: &X402State, req: &axum::http::Request<axum::body::Body>) -> String {
let (origin, _) = super::documents::Origin::for_request(state.base_url.as_deref(), req);
let path = req
.uri()
.path_and_query()
.map_or("/", axum::http::uri::PathAndQuery::as_str);
format!("{}{path}", origin.base)
}
#[cfg(feature = "http-client")]
fn payment_required(
state: &X402State,
route: &PaidRoute,
resource: &str,
error: &str,
) -> axum::response::Response {
use axum::response::IntoResponse as _;
let required = state.config.payment_required(route, resource, error);
let mut res = (
axum::http::StatusCode::PAYMENT_REQUIRED,
axum::Json(required.clone()),
)
.into_response();
let headers = res.headers_mut();
if let Ok(v) = axum::http::HeaderValue::from_str(&encode_header(&required)) {
headers.insert("payment-required", v);
}
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
expose_payment_headers(headers);
res
}
#[cfg_attr(not(any(feature = "http-client", feature = "mcp")), allow(dead_code))]
pub(crate) fn expose_payment_headers(headers: &mut axum::http::HeaderMap) {
const EXPOSE: &str = "PAYMENT-REQUIRED, PAYMENT-RESPONSE";
let name = axum::http::header::ACCESS_CONTROL_EXPOSE_HEADERS;
let covered = headers.get_all(&name).iter().any(|v| {
v.to_str().is_ok_and(|v| {
v.split(',')
.any(|h| h.trim().eq_ignore_ascii_case("payment-response"))
})
});
if !covered {
headers.append(name, axum::http::HeaderValue::from_static(EXPOSE));
}
}
#[cfg(feature = "http-client")]
fn plain(status: axum::http::StatusCode, msg: &'static str) -> axum::response::Response {
use axum::response::IntoResponse as _;
(status, msg).into_response()
}
#[cfg(feature = "http-client")]
async fn facilitator(
state: &X402State,
op: &str,
body: &Value,
) -> Result<Value, axum::http::StatusCode> {
let base = state
.config
.facilitator_url
.as_deref()
.unwrap_or_default()
.trim_end_matches('/');
let res = state
.client
.post(format!("{base}/{op}"))
.json(body)
.send()
.await
.map_err(|err| {
tracing::warn!(error = %err, op, "aeo: x402 facilitator call failed");
axum::http::StatusCode::BAD_GATEWAY
})?;
if !res.is_success() {
tracing::warn!(
status = res.status().as_u16(),
op,
"aeo: x402 facilitator answered an error"
);
return Err(axum::http::StatusCode::BAD_GATEWAY);
}
res.json::<Value>().map_err(|err| {
tracing::warn!(error = %err, op, "aeo: x402 facilitator sent no JSON");
axum::http::StatusCode::BAD_GATEWAY
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payment_headers_are_named_past_a_wildcard() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
axum::http::HeaderValue::from_static("*"),
);
expose_payment_headers(&mut headers);
let exposed: Vec<&str> = headers
.get_all(axum::http::header::ACCESS_CONTROL_EXPOSE_HEADERS)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert_eq!(exposed, ["*", "PAYMENT-REQUIRED, PAYMENT-RESPONSE"]);
expose_payment_headers(&mut headers);
assert_eq!(
headers
.get_all(axum::http::header::ACCESS_CONTROL_EXPOSE_HEADERS)
.iter()
.count(),
2
);
}
fn x402() -> X402Config {
X402Config {
facilitator_url: Some("https://facilitator.example".to_owned()),
pay_to: Some("0xabc".to_owned()),
network: Some("eip155:84532".to_owned()),
asset: Some("0xusdc".to_owned()),
..X402Config::default()
}
}
fn route() -> PaidRoute {
PaidRoute {
method: "GET".to_owned(),
path: "/api/reports/{id}".to_owned(),
amount: "10000".to_owned(),
description: Some("A report".to_owned()),
mpp_method: Some("stripe".to_owned()),
mpp_currency: Some("usd".to_owned()),
..PaidRoute::default()
}
}
#[cfg(not(feature = "http-client"))]
#[tokio::test]
async fn without_the_http_client_priced_routes_fail_closed() {
use tower::{Layer as _, ServiceExt as _};
let mut config = crate::config::AutumnConfig::default();
config.aeo.x402 = x402();
config.aeo.paid_routes.push(route());
let layer = X402Unavailable::from_config(&config, &crate::state::AppState::for_test())
.expect("a route is priced");
let inner = tower::service_fn(|_req: axum::http::Request<axum::body::Body>| async {
Ok::<_, std::convert::Infallible>(axum::response::Response::new(
axum::body::Body::empty(),
))
});
let call = |path: &str| {
let req = axum::http::Request::get(path)
.body(axum::body::Body::empty())
.unwrap();
layer.layer(inner).oneshot(req)
};
assert_eq!(call("/api/reports/7").await.unwrap().status(), 503);
assert_eq!(call("/free").await.unwrap().status(), 200);
}
#[test]
fn a_route_outside_the_locale_nests_keeps_its_locale_segment() {
let priced = PricedRoutes {
routes: vec![
PaidRoute::new("GET", "/en/admin", "1"),
PaidRoute::new("GET", "/api", "2"),
],
locales: vec!["en".to_owned()],
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let get = |path: &str| {
axum::http::Request::get(path)
.body(axum::body::Body::empty())
.unwrap()
};
let amount = |path: &str| priced.route_for(&get(path)).map(|r| r.amount.clone());
assert_eq!(amount("/en/admin").as_deref(), Some("1"), "unlocalized");
assert_eq!(amount("/en/api").as_deref(), Some("2"), "localized");
assert_eq!(amount("/api").as_deref(), Some("2"));
assert_eq!(amount("/admin"), None);
}
#[test]
fn a_trailing_slash_route_of_its_own_keeps_its_price() {
let priced = |paths: &[&str], mounted: &[&str]| PricedRoutes {
routes: paths
.iter()
.map(|&p| PaidRoute::new("GET", p, "1"))
.collect(),
locales: vec!["en".to_owned()],
unprefixed: Vec::new(),
mounted: mounted.iter().map(|&r| r.to_owned()).collect(),
nested: Vec::new(),
};
let matched = |template: &str| {
let mut req = axum::http::Request::get(template)
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut()
.insert(StaticRoute(template.to_owned()));
req
};
let charged = |p: &PricedRoutes, template: &str| p.route_for(&matched(template)).is_some();
let both = priced(&["/report"], &["/report", "/report/"]);
assert!(charged(&both, "/report"));
assert!(!charged(&both, "/report/"), "a free /report/ stays free");
let both = priced(&["/report/"], &["/report", "/report/"]);
assert!(!charged(&both, "/report"));
assert!(charged(&both, "/report/"));
let nested = priced(&["/report"], &["/en/report", "/en/report/"]);
assert!(charged(&nested, "/en/report"));
assert!(!charged(&nested, "/en/report/"));
assert!(charged(&priced(&["/report/"], &["/report"]), "/report"));
assert!(charged(&priced(&["/report"], &["/report/"]), "/report/"));
assert!(charged(&priced(&["/report"], &[]), "/report/"));
}
#[test]
fn a_route_the_router_nested_is_priced_under_a_locale() {
let matched = |template: &str| {
let mut req = axum::http::Request::get(template)
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut()
.insert(StaticRoute(template.to_owned()));
req
};
let priced = |nested: &[&str]| PricedRoutes {
routes: vec![PaidRoute::new("GET", "/report/", "1")],
locales: vec!["en".to_owned()],
unprefixed: vec!["/report/".to_owned()],
mounted: Vec::new(),
nested: nested.iter().map(|&n| n.to_owned()).collect(),
};
assert!(
priced(&["/report"])
.route_for(&matched("/en/report"))
.is_some()
);
assert!(priced(&[]).route_for(&matched("/en/report")).is_none());
}
#[test]
fn a_locale_redirect_is_never_priced() {
let priced = PricedRoutes {
routes: vec![PaidRoute::new("POST", "/submit", "1")],
locales: vec!["en".to_owned()],
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: vec!["/submit".to_owned()],
};
let matched = |template: &str| {
let mut req = axum::http::Request::post(template)
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut()
.insert(StaticRoute(template.to_owned()));
req
};
assert!(priced.route_for(&matched("/submit")).is_none());
assert!(priced.route_for(&matched("/en/submit")).is_some());
}
#[test]
fn a_nested_route_is_priced_at_its_mounted_path() {
let priced = PricedRoutes {
routes: vec![PaidRoute::new("GET", "/en/report", "1")],
locales: vec!["en".to_owned()],
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: vec!["/report".to_owned(), "/en/report".to_owned()],
};
let matched = |template: &str| {
let mut req = axum::http::Request::get(template)
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut()
.insert(StaticRoute(template.to_owned()));
req
};
assert!(priced.route_for(&matched("/en/report")).is_none());
assert!(priced.route_for(&matched("/en/en/report")).is_some());
}
#[test]
fn an_exact_entry_wins_without_a_route_inventory() {
let priced = PricedRoutes {
routes: vec![
PaidRoute::new("GET", "/report", "1"),
PaidRoute::new("GET", "/report/", "2"),
],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let amount = |template: &str| {
let mut req = axum::http::Request::get(template)
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut()
.insert(StaticRoute(template.to_owned()));
priced.route_for(&req).map(|r| r.amount.clone())
};
assert_eq!(amount("/report").as_deref(), Some("1"));
assert_eq!(amount("/report/").as_deref(), Some("2"));
}
#[test]
fn an_excluded_route_is_not_priced_under_a_locale() {
let priced = PricedRoutes {
routes: vec![PaidRoute::new("GET", "/api", "1")],
locales: vec!["en".to_owned()],
unprefixed: vec!["/api".to_owned()],
mounted: Vec::new(),
nested: Vec::new(),
};
let get = |path: &str| {
axum::http::Request::get(path)
.body(axum::body::Body::empty())
.unwrap()
};
let amount = |path: &str| priced.route_for(&get(path)).map(|r| r.amount.clone());
assert_eq!(amount("/api").as_deref(), Some("1"));
assert_eq!(amount("/en/api"), None);
}
#[test]
fn the_route_axum_matched_sets_the_price() {
let priced = PricedRoutes {
routes: vec![
PaidRoute::new("GET", "/reports/{id}", "1"),
PaidRoute::new("GET", "/reports/admin", "100"),
],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let req = |path: &str| {
axum::http::Request::get(path)
.body(axum::body::Body::empty())
.unwrap()
};
assert!(same_template("/reports/{id}", "/reports/{rid}"));
assert!(same_template("/report/", "/report"));
assert!(!same_shape("/report/", "/report"));
assert!(same_shape("/report/", "/report/"));
assert!(!same_template("/reports/{id}", "/reports/admin"));
assert!(!same_template("/files/{*p}", "/files/{p}"));
let amount = |path: &str| priced.route_for(&req(path)).map(|r| r.amount.clone());
assert_eq!(amount("/reports/admin").as_deref(), Some("100"));
assert_eq!(amount("/reports/7").as_deref(), Some("1"));
}
#[test]
fn one_route_cannot_take_both_payment_protocols() {
let mut x402 = route();
x402.mpp_method = None;
let mut mpp = route();
mpp.path = "/api/reports/{rid}/".to_owned();
let problem = mixed_protocol_route(&[x402.clone(), mpp.clone()]).unwrap();
assert!(problem.contains("both x402 and MPP"), "{problem}");
let config = super::super::AeoConfig {
paid_routes: vec![x402.clone(), mpp],
..Default::default()
};
assert!(config.validate().is_err());
let mut head = route();
head.method = "HEAD".to_owned();
assert!(
mixed_protocol_route(&[x402.clone(), head]).is_some(),
"an x402 GET prices HEAD too"
);
let mut other = route();
other.method = "POST".to_owned();
assert!(
mixed_protocol_route(&[x402, other]).is_none(),
"another method"
);
}
#[test]
fn an_unmatched_unsafe_request_is_never_priced() {
let priced = PricedRoutes {
routes: vec![
PaidRoute::new("POST", "/api", "1"),
PaidRoute::new("GET", "/files/{name}", "2"),
],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let req = |method: &str, path: &str| {
axum::http::Request::builder()
.method(method)
.uri(path)
.body(axum::body::Body::empty())
.unwrap()
};
assert!(priced.route_for(&req("POST", "/api")).is_none());
assert!(priced.route_for(&req("POST", "/api/")).is_none());
assert_eq!(
priced
.route_for(&req("GET", "/files/a.pdf"))
.map(|r| r.amount.as_str()),
Some("2")
);
}
#[test]
fn a_static_file_priced_for_mpp_is_not_charged_by_x402() {
let mut mpp = PaidRoute::new("GET", "/downloads/report.pdf", "5");
mpp.mpp_method = Some("tempo".to_owned());
let priced = PricedRoutes {
routes: vec![PaidRoute::new("GET", "/downloads/{*path}", "1"), mpp],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let amount = |method: &str, path: &str| {
let req = axum::http::Request::builder()
.method(method)
.uri(path)
.body(axum::body::Body::empty())
.unwrap();
priced.route_for(&req).map(|r| r.amount.clone())
};
assert_eq!(amount("GET", "/downloads/report.pdf"), None);
assert_eq!(amount("HEAD", "/downloads/report.pdf"), None);
assert_eq!(amount("GET", "/downloads/other.pdf").as_deref(), Some("1"));
}
#[test]
fn a_static_file_takes_the_route_axum_would() {
let mut mpp = PaidRoute::new("GET", "/downloads/{file}", "5");
mpp.mpp_method = Some("tempo".to_owned());
for routes in [
vec![
mpp.clone(),
PaidRoute::new("GET", "/downloads/{*path}", "1"),
],
vec![PaidRoute::new("GET", "/downloads/{*path}", "1"), mpp],
] {
let priced = PricedRoutes {
routes,
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let amount = |path: &str| {
let req = axum::http::Request::get(path)
.body(axum::body::Body::empty())
.unwrap();
priced.route_for(&req).map(|r| r.amount.clone())
};
assert_eq!(amount("/downloads/report.pdf"), None, "MPP's");
assert_eq!(amount("/downloads/a/b.pdf").as_deref(), Some("1"));
}
let priced = PricedRoutes {
routes: vec![
PaidRoute::new("GET", "/downloads/{file}", "1"),
PaidRoute::new("GET", "/downloads/{name}", "2"),
],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let req = axum::http::Request::get("/downloads/a.pdf")
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(priced.route_for(&req).map(|r| r.amount.as_str()), Some("1"));
assert!(specificity("/a/b/{*p}") > specificity("/a/{x}/c"));
}
#[test]
fn an_explicit_head_price_beats_the_get_fallback() {
let priced = PricedRoutes {
routes: vec![
PaidRoute::new("GET", "/report", "1"),
PaidRoute::new("HEAD", "/report", "5"),
],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let amount = |method: &str| {
let req = axum::http::Request::builder()
.method(method)
.uri("/report")
.body(axum::body::Body::empty())
.unwrap();
priced.route_for(&req).map(|r| r.amount.clone())
};
assert_eq!(amount("HEAD").as_deref(), Some("5"));
assert_eq!(amount("GET").as_deref(), Some("1"));
let get_only = PricedRoutes {
routes: vec![PaidRoute::new("GET", "/report", "1")],
locales: Vec::new(),
unprefixed: Vec::new(),
mounted: Vec::new(),
nested: Vec::new(),
};
let head = axum::http::Request::head("/report")
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(
get_only.route_for(&head).map(|r| r.amount.as_str()),
Some("1")
);
}
#[test]
fn route_matching_normalizes_head_slash_and_wildcards() {
let r = route();
assert!(r.matches("HEAD", "/api/reports/7"));
assert!(r.matches("GET", "/api/reports/7/"));
let files = PaidRoute::new("GET", "/files/{*path}", "1");
assert!(files.matches("GET", "/files/a/b"));
assert!(!files.matches("GET", "/files/"));
assert!(!PaidRoute::new("POST", "/x", "1").matches("HEAD", "/x"));
}
#[test]
fn paid_route_problems_flag_bad_amounts_and_methods() {
assert!(valid_amount("10000") && valid_amount("0"));
assert!(!valid_amount("010") && !valid_amount("1.5") && !valid_amount(""));
let mut r = PaidRoute::new("GET", "/x", "01");
r.mpp_method = Some("Stripe".to_owned());
assert_eq!(paid_route_problems(&[r]).len(), 2);
}
#[test]
fn a_route_that_can_never_match_is_a_problem() {
assert!(unmatchable_route(&PaidRoute::new("get", "/x", "1")).is_none());
for r in [
PaidRoute::new("", "/x", "1"),
PaidRoute::new("FETCH", "/x", "1"),
PaidRoute::new(" GET ", "/x", "1"),
PaidRoute::new("GET", "/x ", "1"),
PaidRoute::new("GET", "/api?plan=pro", "1"),
PaidRoute::new("GET", "/api#top", "1"),
PaidRoute::new("GET", "/api/{id", "1"),
PaidRoute::new("GET", "/api/{}", "1"),
PaidRoute::new("GET", "/files/{*rest}/x", "1"),
PaidRoute::new("GET", "/img/{name}.png", "1"),
PaidRoute::new("GET", "api", "1"),
PaidRoute::new("GET", "", "1"),
] {
assert!(unmatchable_route(&r).is_some(), "{r:?}");
}
}
#[cfg(feature = "http-client")]
#[test]
fn a_live_payment_record_is_never_evicted() {
let mut used = UsedPayments::new(2);
assert_eq!(used.claim([1; 32], 100, 10), Ok(()));
assert_eq!(used.claim([1; 32], 100, 10), Err(ClaimError::Used));
assert_eq!(used.claim([2; 32], 50, 10), Ok(()));
assert_eq!(used.claim([3; 32], 100, 10), Err(ClaimError::Full));
assert_eq!(used.claim([1; 32], 100, 10), Err(ClaimError::Used));
assert_eq!(used.claim([3; 32], 100, 60), Ok(()));
assert_eq!(used.claim([1; 32], 100, 60), Err(ClaimError::Used));
used.release(&[3; 32]);
assert_eq!(used.claim([3; 32], 100, 60), Ok(()));
}
#[cfg(feature = "http-client")]
#[test]
fn a_payment_has_one_key_however_it_is_written() {
let parse = |s: &str| serde_json::from_str::<Value>(s).unwrap();
let a = parse(
r#"{"accepted":{"network":"eip155:8453","asset":"0xUSDC"},
"payload":{"signature":"0xAB","authorization":{"from":"0xAbC","nonce":"0x01"}}}"#,
);
let b = parse(
r#"{ "payload" : { "authorization" : { "nonce" : "0x0000001", "from" : "0xabc" },
"signature" : "0xab" }, "accepted" : { "asset" : "0xusdc", "network" : "eip155:8453" } }"#,
);
assert_eq!(payment_key(&a), payment_key(&b));
let other_nonce = parse(
r#"{"accepted":{"network":"eip155:8453","asset":"0xUSDC"},
"payload":{"signature":"0xAB","authorization":{"from":"0xAbC","nonce":"0x02"}}}"#,
);
assert_ne!(payment_key(&a), payment_key(&other_nonce));
let other_chain = parse(
r#"{"accepted":{"network":"eip155:1","asset":"0xUSDC"},
"payload":{"signature":"0xAB","authorization":{"from":"0xAbC","nonce":"0x01"}}}"#,
);
assert_ne!(payment_key(&a), payment_key(&other_chain));
assert_eq!(
payment_key(&parse(r#"{"payload":{"signature":"0xSIG"}}"#)),
payment_key(&parse(r#"{"payload":{"signature":"0xsig"}}"#))
);
assert_eq!(
payment_key(&parse(
r#"{"payload":{"transaction":"t","extra":[1,{"b":2,"a":1}]}}"#
)),
payment_key(&parse(
r#"{"payload":{"extra":[1,{"a":1,"b":2}],"transaction":"t"}}"#
))
);
}
#[cfg(feature = "http-client")]
#[test]
fn payment_expiry_reads_valid_before() {
let payload = json!({"payload": {"authorization": {"validBefore": "1700"}}});
assert_eq!(payment_expiry(&payload, 300, 1000), 1700);
let payload = json!({"payload": {"authorization": {"validBefore": 1800}}});
assert_eq!(payment_expiry(&payload, 300, 1000), 1800);
assert_eq!(payment_expiry(&json!({}), 300, 1000), 1300);
}
#[cfg(feature = "http-client")]
#[test]
fn facilitator_must_be_https_or_loopback() {
assert!(facilitator_url_is_safe("https://x402.org/facilitator"));
assert!(facilitator_url_is_safe("http://localhost:8080"));
assert!(facilitator_url_is_safe("http://[::1]:8080"));
assert!(facilitator_url_is_safe("http://127.0.0.1:8080"));
assert!(!facilitator_url_is_safe("http://facilitator.example"));
assert!(!facilitator_url_is_safe("not a url"));
assert!(!facilitator_url_is_safe("https://pay.example/api?key=x"));
assert!(!facilitator_url_is_safe("https://pay.example/api#v1"));
}
#[test]
fn route_matching() {
let r = route();
assert!(r.matches("GET", "/api/reports/7"));
assert!(r.matches("get", "/api/reports/7"));
assert!(!r.matches("POST", "/api/reports/7"));
assert!(!r.matches("GET", "/api/reports"));
assert!(!r.matches("GET", "/api/reports/7/x"));
assert!(!r.matches("GET", "/api/reports/"));
}
#[test]
fn config_completeness() {
assert!(x402().is_complete());
assert!(!X402Config::default().is_complete());
}
#[test]
fn payment_required_is_x402_v2() {
let pr = x402().payment_required(
&route(),
"https://shop.example/api/reports/7",
"Payment required",
);
assert_eq!(pr["x402Version"], 2);
assert_eq!(pr["error"], "Payment required");
assert_eq!(pr["resource"]["url"], "https://shop.example/api/reports/7");
assert_eq!(pr["resource"]["description"], "A report");
let req = &pr["accepts"][0];
assert_eq!(req["scheme"], "exact");
assert_eq!(req["network"], "eip155:84532");
assert_eq!(req["amount"], "10000");
assert_eq!(req["asset"], "0xusdc");
assert_eq!(req["payTo"], "0xabc");
assert_eq!(req["maxTimeoutSeconds"], 300);
assert_eq!(req["extra"], json!({"name": "USDC", "version": "2"}));
}
#[test]
fn header_round_trip_uses_padded_standard_base64() {
let v = json!({"a": "?>"});
let h = encode_header(&v);
assert!(
h.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"+/=".contains(&b)),
"{h}"
);
assert_eq!(decode_header(&h), Some(v));
assert_eq!(decode_header("not base64!"), None);
assert_eq!(decode_header(&STANDARD.encode("not json")), None);
}
#[test]
fn accepted_must_match_the_offer() {
let offered = x402().requirements(&route());
assert!(accepted_matches(&offered.clone(), &offered));
let mut cheaper = offered.clone();
cheaper["amount"] = json!("1");
assert!(!accepted_matches(&cheaper, &offered));
let mut other_wallet = offered.clone();
other_wallet["payTo"] = json!("0xevil");
assert!(!accepted_matches(&other_wallet, &offered));
assert!(!accepted_matches(&Value::Null, &offered));
}
#[test]
fn mpp_keeps_a_documented_402() {
let documented = json!({
"description": "Pay first",
"headers": { "WWW-Authenticate": { "schema": { "type": "string" } } },
"content": { "application/json": { "schema": { "type": "object" } } }
});
let mut spec = json!({
"paths": {
"/api/reports/{id}": { "get": { "responses": { "402": documented } } }
}
});
apply_mpp(&mut spec, &[route()]);
let op = &spec["paths"]["/api/reports/{id}"]["get"];
assert!(op.get("x-payment-info").is_some());
assert_eq!(op["responses"]["402"], documented);
}
#[test]
fn mpp_extends_matching_operations() {
let mut spec = json!({
"openapi": "3.1.0",
"paths": {
"/api/reports/{id}": { "get": { "responses": { "200": { "description": "OK" } } } },
"/api/free": { "get": { "responses": {} } }
}
});
let mut no_mpp = route();
no_mpp.path = "/api/free".to_owned();
no_mpp.mpp_method = None;
apply_mpp(&mut spec, &[route(), no_mpp]);
let op = &spec["paths"]["/api/reports/{id}"]["get"];
assert_eq!(
op["x-payment-info"],
json!({"intent": "charge", "method": "stripe", "amount": "10000", "currency": "usd", "description": "A report"})
);
assert_eq!(op["responses"]["402"]["description"], "Payment Required");
assert_eq!(op["responses"]["200"]["description"], "OK");
assert!(
spec["paths"]["/api/free"]["get"]
.get("x-payment-info")
.is_none()
);
let mut both = json!({
"paths": {
"/report": { "get": { "responses": {} } },
"/report/": { "get": { "responses": {} } }
}
});
let mut slashed = route();
slashed.path = "/report/".to_owned();
apply_mpp(&mut both, &[slashed]);
assert!(
both["paths"]["/report"]["get"]
.get("x-payment-info")
.is_none()
);
assert!(
both["paths"]["/report/"]["get"]
.get("x-payment-info")
.is_some()
);
let mut slashed = route();
slashed.path = "/api/free/".to_owned();
apply_mpp(&mut spec, &[slashed]);
assert!(
spec["paths"]["/api/free"]["get"]
.get("x-payment-info")
.is_some()
);
let mut spec = json!({"paths": {"/api/reports/{id}": {"get": {}}}});
let mut renamed = route();
renamed.path = "/api/reports/{rid}".to_owned();
apply_mpp(&mut spec, &[renamed]);
assert!(
spec["paths"]["/api/reports/{id}"]["get"]
.get("x-payment-info")
.is_some()
);
}
#[test]
fn ucp_requires_core_fields() {
let ok = json!({"ucp": {"version": "2026-08-25", "services": {}, "payment_handlers": {}}});
assert!(UcpProfile::new(ok).is_ok());
let err =
UcpProfile::new(json!({"ucp": {"version": "2026-08-25", "services": {}}})).unwrap_err();
assert_eq!(
err,
CommerceError::Invalid {
doc: "UCP profile",
field: "ucp.payment_handlers"
}
);
assert!(UcpProfile::new(json!({})).is_err());
}
#[test]
fn acp_requires_core_fields() {
let ok = json!({
"protocol": {"name": "acp", "version": "2025-09-29", "supported_versions": ["2025-09-29"]},
"api_base_url": "https://shop.example/api",
"transports": ["rest"],
"capabilities": {"services": ["checkout"]}
});
assert!(AcpDiscovery::new(ok.clone()).is_ok());
for (field, patch) in [
(
"protocol.name",
json!({"protocol": {"name": "x", "version": "1", "supported_versions": ["1"]}}),
),
("api_base_url", json!({"api_base_url": "/relative"})),
("transports", json!({"transports": []})),
(
"capabilities.services",
json!({"capabilities": {"services": []}}),
),
] {
let mut doc = ok.clone();
for (k, v) in patch.as_object().unwrap() {
doc[k] = v.clone();
}
assert!(
matches!(AcpDiscovery::new(doc), Err(CommerceError::Invalid { field: f, .. }) if f == field),
"{field}"
);
}
}
}