use serde::{Deserialize, Serialize};
use crate::budget::{Budget, BudgetUsage};
use crate::decision::PolicyDecision;
pub const X402_ANCHOR: &str = "x402:http-402-payment-required";
pub const X402_PAYMENT_REQUIRED_STATUS: u16 = 402;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct X402Challenge {
pub scheme: String,
pub asset: String,
pub network: Option<String>,
pub amount_atomic: u128,
pub decimals: u32,
pub pay_to: Option<String>,
pub resource: Option<String>,
}
impl X402Challenge {
pub fn from_body(body: &serde_json::Value) -> Option<Self> {
let first = body.get("accepts").and_then(|a| a.as_array())?.first()?;
Self::from_requirements(first)
}
pub fn from_requirements(req: &serde_json::Value) -> Option<Self> {
let amount_atomic = req
.get("maxAmountRequired")
.or_else(|| req.get("amount"))
.and_then(parse_atomic_amount)?;
let extra = req.get("extra");
let asset = extra
.and_then(|e| e.get("name"))
.and_then(|v| v.as_str())
.or_else(|| req.get("symbol").and_then(|v| v.as_str()))
.or_else(|| {
req.get("asset")
.and_then(|v| v.as_str())
.filter(|s| !is_address(s))
})
.unwrap_or("UNKNOWN")
.to_string();
let decimals = extra
.and_then(|e| e.get("decimals"))
.or_else(|| req.get("decimals"))
.and_then(|v| v.as_u64())
.map(|d| d as u32)
.or_else(|| default_decimals(&asset))
.unwrap_or(0);
let scheme = req
.get("scheme")
.and_then(|v| v.as_str())
.unwrap_or("exact")
.to_string();
Some(Self {
scheme,
asset,
network: req
.get("network")
.and_then(|v| v.as_str())
.map(str::to_string),
amount_atomic,
decimals,
pay_to: req
.get("payTo")
.and_then(|v| v.as_str())
.map(str::to_string),
resource: req
.get("resource")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
pub fn to_cost_event(&self) -> Option<X402CostEvent> {
if !is_usd_pegged(&self.asset) {
return None;
}
let cost_cents = atomic_to_cents_ceil(self.amount_atomic, self.decimals);
Some(X402CostEvent {
scheme: self.scheme.clone(),
asset: self.asset.clone(),
network: self.network.clone(),
amount_atomic: self.amount_atomic,
decimals: self.decimals,
cost_cents,
resource: self.resource.clone(),
pay_to: self.pay_to.clone(),
})
}
pub fn summary(&self) -> String {
match &self.network {
Some(net) => format!(
"{} {} ({} atomic, {} decimals) on {net} [{}]",
self.scheme, self.asset, self.amount_atomic, self.decimals, self.scheme
),
None => format!(
"{} {} ({} atomic, {} decimals)",
self.scheme, self.asset, self.amount_atomic, self.decimals
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct X402CostEvent {
pub scheme: String,
pub asset: String,
pub network: Option<String>,
pub amount_atomic: u128,
pub decimals: u32,
pub cost_cents: u64,
pub resource: Option<String>,
pub pay_to: Option<String>,
}
impl X402CostEvent {
pub fn apply_to(&self, usage: &mut BudgetUsage) {
usage.cost_cents = usage.cost_cents.saturating_add(self.cost_cents);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum X402GateOutcome {
Authorize {
event: X402CostEvent,
budget_remaining_cents: Option<u64>,
},
Deny {
event: X402CostEvent,
reason: String,
over_by_cents: u64,
},
DenyUnpriceable { asset: String, reason: String },
}
impl X402GateOutcome {
pub fn is_authorized(&self) -> bool {
matches!(self, X402GateOutcome::Authorize { .. })
}
pub fn alert_line(&self) -> Option<String> {
match self {
X402GateOutcome::Authorize { .. } => None,
X402GateOutcome::Deny {
event,
over_by_cents,
..
} => Some(format!(
"x402 spend gate BLOCKED payment: {}¢ in {} would breach the budget by {over_by_cents}¢ — payment not authorized",
event.cost_cents, event.asset
)),
X402GateOutcome::DenyUnpriceable { asset, reason } => Some(format!(
"x402 spend gate BLOCKED payment: asset {asset} is unpriceable ({reason}) — payment not authorized"
)),
}
}
pub fn to_policy_decision(&self) -> PolicyDecision {
match self {
X402GateOutcome::Authorize { event, .. } => PolicyDecision::allow(format!(
"x402 payment authorized: {}¢ in {} within budget",
event.cost_cents, event.asset
)),
X402GateOutcome::Deny { reason, .. } => PolicyDecision::deny(reason.clone()),
X402GateOutcome::DenyUnpriceable { reason, .. } => PolicyDecision::deny(reason.clone()),
}
}
}
pub fn evaluate_x402_payment(
challenge: &X402Challenge,
budget: &Budget,
usage: &BudgetUsage,
) -> X402GateOutcome {
let Some(event) = challenge.to_cost_event() else {
return X402GateOutcome::DenyUnpriceable {
asset: challenge.asset.clone(),
reason: format!(
"x402 quote in '{}' has no known USD peg; cannot price against a cents budget (deny-by-default)",
challenge.asset
),
};
};
if budget.has_cost_headroom(usage, event.cost_cents) {
let budget_remaining_cents = budget
.max_cost_cents
.map(|cap| cap.saturating_sub(usage.cost_cents.saturating_add(event.cost_cents)));
X402GateOutcome::Authorize {
event,
budget_remaining_cents,
}
} else {
let cap = budget.max_cost_cents.unwrap_or(0);
let projected = usage.cost_cents.saturating_add(event.cost_cents);
let over_by_cents = projected.saturating_sub(cap);
let reason = format!(
"x402 spend gate: paying {}¢ ({} {}) would push run cost to {projected}¢ over the {cap}¢ budget (by {over_by_cents}¢)",
event.cost_cents, event.amount_atomic, event.asset
);
X402GateOutcome::Deny {
event,
reason,
over_by_cents,
}
}
}
fn is_usd_pegged(symbol: &str) -> bool {
matches!(
symbol.trim().to_ascii_uppercase().as_str(),
"USDC" | "USDC.E" | "USDT" | "DAI" | "PYUSD" | "USDP" | "GUSD" | "USDG" | "OUSD"
)
}
fn default_decimals(symbol: &str) -> Option<u32> {
match symbol.trim().to_ascii_uppercase().as_str() {
"USDC" | "USDC.E" | "USDT" | "PYUSD" | "USDP" | "GUSD" | "USDG" | "OUSD" => Some(6),
"DAI" => Some(18),
_ => None,
}
}
fn atomic_to_cents_ceil(amount_atomic: u128, decimals: u32) -> u64 {
let divisor = 10u128.checked_pow(decimals).unwrap_or(u128::MAX);
let numerator = amount_atomic.saturating_mul(100);
let cents = numerator.div_ceil(divisor);
cents.min(u64::MAX as u128) as u64
}
fn parse_atomic_amount(v: &serde_json::Value) -> Option<u128> {
if let Some(s) = v.as_str() {
s.trim().parse::<u128>().ok()
} else {
v.as_u64().map(u128::from)
}
}
fn is_address(s: &str) -> bool {
let s = s.trim();
s.starts_with("0x") && s.len() >= 6
}
#[cfg(test)]
mod tests {
use super::*;
fn budget_with_cap(cap: u64) -> Budget {
Budget {
max_cost_cents: Some(cap),
..Budget::default()
}
}
fn usdc_body(atomic: &str) -> serde_json::Value {
serde_json::json!({
"x402Version": 1,
"error": "payment required",
"accepts": [{
"scheme": "exact",
"network": "base-sepolia",
"maxAmountRequired": atomic,
"resource": "https://api.example.com/paywalled",
"payTo": "0xabc0000000000000000000000000000000000001",
"asset": "0xUSDCcontractaddress0000000000000000000000",
"extra": { "name": "USDC", "decimals": 6 }
}]
})
}
#[test]
fn parses_x402_body_first_accepts_entry() {
let ch = X402Challenge::from_body(&usdc_body("10000")).expect("parse");
assert_eq!(ch.scheme, "exact");
assert_eq!(ch.asset, "USDC");
assert_eq!(ch.network.as_deref(), Some("base-sepolia"));
assert_eq!(ch.amount_atomic, 10_000);
assert_eq!(ch.decimals, 6);
assert_eq!(
ch.resource.as_deref(),
Some("https://api.example.com/paywalled")
);
}
#[test]
fn normalizes_usdc_atomic_to_cents_rounding_up() {
let ev = X402Challenge::from_body(&usdc_body("10000"))
.unwrap()
.to_cost_event()
.unwrap();
assert_eq!(ev.cost_cents, 1);
let ev = X402Challenge::from_body(&usdc_body("1000000"))
.unwrap()
.to_cost_event()
.unwrap();
assert_eq!(ev.cost_cents, 100);
let ev = X402Challenge::from_body(&usdc_body("1"))
.unwrap()
.to_cost_event()
.unwrap();
assert_eq!(ev.cost_cents, 1);
let ev = X402Challenge::from_body(&usdc_body("15000"))
.unwrap()
.to_cost_event()
.unwrap();
assert_eq!(ev.cost_cents, 2);
}
#[test]
fn gate_authorizes_a_payment_that_fits() {
let budget = budget_with_cap(100);
let usage = BudgetUsage {
cost_cents: 40,
..BudgetUsage::default()
};
let ch = X402Challenge::from_body(&usdc_body("500000")).unwrap(); let outcome = evaluate_x402_payment(&ch, &budget, &usage);
match outcome {
X402GateOutcome::Authorize {
event,
budget_remaining_cents,
} => {
assert_eq!(event.cost_cents, 50);
assert_eq!(budget_remaining_cents, Some(10)); }
other => panic!("expected authorize, got {other:?}"),
}
assert!(evaluate_x402_payment(&ch, &budget, &usage).is_authorized());
assert!(evaluate_x402_payment(&ch, &budget, &usage)
.alert_line()
.is_none());
}
#[test]
fn gate_hard_stops_a_payment_that_breaches_the_ceiling() {
let budget = budget_with_cap(100);
let usage = BudgetUsage {
cost_cents: 80,
..BudgetUsage::default()
};
let ch = X402Challenge::from_body(&usdc_body("500000")).unwrap(); let outcome = evaluate_x402_payment(&ch, &budget, &usage);
match &outcome {
X402GateOutcome::Deny {
event,
over_by_cents,
reason,
} => {
assert_eq!(event.cost_cents, 50);
assert_eq!(*over_by_cents, 30);
assert!(reason.contains("130")); }
other => panic!("expected deny, got {other:?}"),
}
assert!(outcome.to_policy_decision().is_denied());
assert!(outcome.alert_line().unwrap().contains("30¢"));
}
#[test]
fn boundary_exactly_at_cap_is_authorized() {
let budget = budget_with_cap(100);
let usage = BudgetUsage {
cost_cents: 50,
..BudgetUsage::default()
};
let ch = X402Challenge::from_body(&usdc_body("500000")).unwrap();
let outcome = evaluate_x402_payment(&ch, &budget, &usage);
assert!(outcome.is_authorized());
if let X402GateOutcome::Authorize {
budget_remaining_cents,
..
} = outcome
{
assert_eq!(budget_remaining_cents, Some(0));
}
}
#[test]
fn unpriceable_asset_is_denied_by_default() {
let body = serde_json::json!({
"accepts": [{
"scheme": "exact",
"maxAmountRequired": "1000000000000000000",
"extra": { "name": "WETH", "decimals": 18 }
}]
});
let ch = X402Challenge::from_body(&body).unwrap();
assert_eq!(ch.asset, "WETH");
assert!(ch.to_cost_event().is_none());
let outcome =
evaluate_x402_payment(&ch, &budget_with_cap(100_000), &BudgetUsage::default());
match &outcome {
X402GateOutcome::DenyUnpriceable { asset, .. } => assert_eq!(asset, "WETH"),
other => panic!("expected unpriceable deny, got {other:?}"),
}
assert!(outcome.to_policy_decision().is_denied());
assert!(outcome.alert_line().is_some());
}
#[test]
fn no_cost_cap_authorizes_and_reports_unbounded() {
let budget = Budget {
max_cost_cents: None,
..Budget::default()
};
let ch = X402Challenge::from_body(&usdc_body("999999999")).unwrap();
let outcome = evaluate_x402_payment(&ch, &budget, &BudgetUsage::default());
match outcome {
X402GateOutcome::Authorize {
budget_remaining_cents,
..
} => assert_eq!(budget_remaining_cents, None),
other => panic!("expected authorize, got {other:?}"),
}
}
#[test]
fn apply_to_folds_paid_cost_into_the_same_ledger_as_tokens() {
let mut usage = BudgetUsage {
cost_cents: 30, ..BudgetUsage::default()
};
let ev = X402Challenge::from_body(&usdc_body("500000"))
.unwrap()
.to_cost_event()
.unwrap(); ev.apply_to(&mut usage);
assert_eq!(usage.cost_cents, 80); assert_eq!(usage.tool_calls, 0);
}
#[test]
fn parses_amount_as_json_number_and_string() {
let as_number = serde_json::json!({
"accepts": [{ "maxAmountRequired": 1000000u64, "extra": { "name": "USDC", "decimals": 6 } }]
});
let as_string = serde_json::json!({
"accepts": [{ "maxAmountRequired": "1000000", "extra": { "name": "USDC", "decimals": 6 } }]
});
assert_eq!(
X402Challenge::from_body(&as_number).unwrap().amount_atomic,
1_000_000
);
assert_eq!(
X402Challenge::from_body(&as_string).unwrap().amount_atomic,
1_000_000
);
}
#[test]
fn infers_decimals_from_known_symbol_when_omitted() {
let body = serde_json::json!({
"accepts": [{ "maxAmountRequired": "2000000", "symbol": "USDC" }]
});
let ch = X402Challenge::from_body(&body).unwrap();
assert_eq!(ch.decimals, 6);
assert_eq!(ch.to_cost_event().unwrap().cost_cents, 200); }
#[test]
fn body_without_accepts_is_none() {
assert!(X402Challenge::from_body(&serde_json::json!({"error": "nope"})).is_none());
assert!(X402Challenge::from_body(&serde_json::json!({"accepts": []})).is_none());
}
#[test]
fn challenge_round_trips_through_serde() {
let ch = X402Challenge::from_body(&usdc_body("250000")).unwrap();
let json = serde_json::to_string(&ch).unwrap();
let back: X402Challenge = serde_json::from_str(&json).unwrap();
assert_eq!(back, ch);
}
}