use serde_json::{json, Map, Value};
use crate::standards::{parse_challenge_params, split_challenges};
pub const RAW_CAP: usize = 1024;
const HEADER_CAP: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct PaymentSignal {
pub standard: String,
pub resource: String,
pub summary: Value,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PaymentClassification {
pub signals: Vec<PaymentSignal>,
pub read_body: bool,
}
fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
fn capped(raw: &str) -> String {
let mut end = raw.len().min(RAW_CAP);
while !raw.is_char_boundary(end) {
end -= 1;
}
raw[..end].to_string()
}
fn invalid(standard: &str, resource: &str, reason: &str, raw: Option<&str>) -> PaymentSignal {
let challenge = raw.map_or(Value::Null, |text| Value::String(capped(text)));
PaymentSignal {
standard: standard.into(),
resource: resource.into(),
summary: json!({ "status": 402, "challenge": challenge }),
reason: Some(reason.into()),
}
}
fn sextet(byte: u8) -> Option<u32> {
match byte {
b'A'..=b'Z' => Some(u32::from(byte - b'A')),
b'a'..=b'z' => Some(u32::from(byte - b'a') + 26),
b'0'..=b'9' => Some(u32::from(byte - b'0') + 52),
b'+' | b'-' => Some(62),
b'/' | b'_' => Some(63),
_ => None,
}
}
pub fn decode_base64(text: &str) -> Option<Vec<u8>> {
let trimmed = text.trim().trim_end_matches('=');
if trimmed.len() % 4 == 1 {
return None;
}
let mut out = Vec::with_capacity(trimmed.len() * 3 / 4);
let mut acc: u32 = 0;
let mut bits = 0;
for byte in trimmed.bytes() {
acc = (acc << 6) | sextet(byte)?;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((acc >> bits) & 0xff) as u8);
}
}
Some(out)
}
fn decode_json(text: &str) -> Option<Value> {
let bytes = decode_base64(text)?;
serde_json::from_slice(&bytes).ok()
}
fn text_of(value: Option<&Value>) -> Value {
match value {
Some(Value::String(text)) => Value::String(text.clone()),
Some(Value::Number(number)) => Value::String(number.to_string()),
_ => Value::Null,
}
}
fn resource_of(value: Option<&Value>, fallback: &str) -> String {
match value {
Some(Value::String(url)) if !url.is_empty() => url.clone(),
Some(Value::Object(map)) => map
.get("url")
.and_then(Value::as_str)
.filter(|url| !url.is_empty())
.unwrap_or(fallback)
.to_string(),
_ => fallback.to_string(),
}
}
fn x402_offer(accept: &Value, version: u64) -> Value {
let amount = if version == 1 {
accept.get("maxAmountRequired")
} else {
accept.get("amount")
};
json!({
"scheme": text_of(accept.get("scheme")),
"network": text_of(accept.get("network")),
"amount": text_of(amount),
"asset": text_of(accept.get("asset")),
"payTo": text_of(accept.get("payTo")),
"expires": Value::Null,
"maxTimeoutSeconds": accept.get("maxTimeoutSeconds").cloned().unwrap_or(Value::Null),
})
}
fn x402_signal(payload: &Value, version: u64, url: &str, raw: &str) -> PaymentSignal {
let declared = payload.get("x402Version").and_then(Value::as_u64);
let accepts = payload.get("accepts").and_then(Value::as_array);
let Some(accepts) = accepts.filter(|_| declared == Some(version)) else {
return invalid("x402", url, "no x402 payload", Some(raw));
};
let first = accepts.first().and_then(|accept| accept.get("resource"));
let resource = if version == 1 {
resource_of(first, url)
} else {
resource_of(payload.get("resource"), url)
};
let offers: Vec<Value> = accepts.iter().map(|accept| x402_offer(accept, version)).collect();
PaymentSignal {
standard: "x402".into(),
resource: resource.clone(),
summary: json!({ "status": 402, "version": version, "resource": resource, "offers": offers }),
reason: None,
}
}
fn mpp_offer(params: &std::collections::BTreeMap<String, String>, request: &Value) -> Value {
let mut offer = Map::new();
for key in ["method", "intent", "realm"] {
offer.insert(key.into(), params.get(key).map_or(Value::Null, |v| Value::String(v.clone())));
}
offer.insert("amount".into(), text_of(request.get("amount")));
offer.insert("asset".into(), text_of(request.get("currency")));
offer.insert(
"expires".into(),
params.get("expires").map_or(Value::Null, |v| Value::String(v.clone())),
);
Value::Object(offer)
}
fn mpp_signals(headers: &[(String, String)], url: &str) -> Vec<PaymentSignal> {
let mut offers = Vec::new();
let mut failure: Option<String> = None;
for (name, value) in headers {
if !name.eq_ignore_ascii_case("www-authenticate") {
continue;
}
for challenge in split_challenges(&capped_header(value)) {
let trimmed = challenge.trim();
let Some((scheme, rest)) = trimmed.split_once(|c: char| c.is_ascii_whitespace()) else {
continue;
};
if !scheme.eq_ignore_ascii_case("payment") {
continue;
}
let params = parse_challenge_params(rest);
match params.get("request").and_then(|request| decode_json(request)) {
Some(request) => offers.push(mpp_offer(¶ms, &request)),
None => failure = Some(trimmed.to_string()),
}
}
}
if offers.is_empty() {
return failure
.map(|raw| vec![invalid("mpp", url, "request is not base64url JSON", Some(&raw))])
.unwrap_or_default();
}
vec![PaymentSignal {
standard: "mpp".into(),
resource: url.into(),
summary: json!({ "status": 402, "resource": url, "offers": offers }),
reason: None,
}]
}
fn capped_header(value: &str) -> String {
let mut end = value.len().min(HEADER_CAP);
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
fn is_json(content_type: Option<&str>) -> bool {
content_type.is_some_and(|ct| {
let ct = ct.to_ascii_lowercase();
let media = ct.split(';').next().unwrap_or("").trim();
media == "application/json" || media.ends_with("+json")
})
}
pub fn classify_payment(headers: &[(String, String)], url: &str) -> PaymentClassification {
let mut signals = Vec::new();
let required = header(headers, "payment-required");
if let Some(raw) = required {
signals.push(match decode_json(raw) {
Some(payload) => x402_signal(&payload, 2, url, raw),
None => invalid("x402", url, "PAYMENT-REQUIRED is not base64 JSON", Some(raw)),
});
}
signals.extend(mpp_signals(headers, url));
let json_body = is_json(header(headers, "content-type"));
let read_body = required.is_none() && json_body;
if required.is_none() && !json_body {
if let Some(raw) = header(headers, "x-payment") {
signals.push(invalid("x402", url, "no x402 payload", Some(raw)));
}
}
PaymentClassification { signals, read_body }
}
pub fn classify_x402_body(body: Option<&str>, headers: &[(String, String)], url: &str) -> Option<PaymentSignal> {
let legacy = header(headers, "x-payment");
let Some(body) = body else {
return Some(invalid("x402", url, "body unavailable", legacy));
};
match serde_json::from_str::<Value>(body) {
Ok(payload) if payload.get("x402Version").is_some() || payload.get("accepts").is_some() => {
let signal = x402_signal(&payload, 1, url, legacy.unwrap_or(""));
if signal.reason.is_some() {
return Some(invalid("x402", url, "no x402 payload", legacy));
}
Some(signal)
}
_ => legacy.map(|raw| invalid("x402", url, "no x402 payload", Some(raw))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn b64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |acc, (i, b)| acc | (u32::from(*b) << (16 - 8 * i)));
let count = chunk.len() + 1;
for i in 0..4 {
if i < count {
out.push(TABLE[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
}
#[test]
fn decodes_padded_unpadded_and_url_safe_base64() {
assert_eq!(decode_base64("aGk="), Some(b"hi".to_vec()));
assert_eq!(decode_base64("aGk"), Some(b"hi".to_vec()));
assert_eq!(decode_base64("-_8"), Some(vec![0xfb, 0xff]));
assert_eq!(decode_base64("a"), None);
assert_eq!(decode_base64("a*b="), None);
}
#[test]
fn x402_v2_reads_the_header_and_keeps_amounts_as_sent() {
let payload = r#"{"x402Version":2,"resource":{"url":"https://x.test/r"},"accepts":[{"scheme":"exact","network":"eip155:8453","amount":"123456789012345678901234567890","asset":"0xA","payTo":"0xB","maxTimeoutSeconds":60},{"scheme":"exact","network":"n","amount":5}]}"#;
let c = classify_payment(&h(&[("PAYMENT-REQUIRED", &b64(payload.as_bytes()))]), "https://x.test/p");
assert!(!c.read_body);
let s = &c.signals[0];
assert_eq!(s.standard, "x402");
assert_eq!(s.resource, "https://x.test/r");
assert_eq!(s.summary["version"], 2);
assert_eq!(s.summary["offers"][0]["amount"], "123456789012345678901234567890");
assert_eq!(s.summary["offers"][0]["payTo"], "0xB");
assert_eq!(s.summary["offers"][0]["maxTimeoutSeconds"], 60);
assert_eq!(s.summary["offers"][1]["amount"], "5");
assert_eq!(s.summary["offers"][1]["asset"], Value::Null);
}
#[test]
fn x402_v2_with_a_bad_or_foreign_header_is_invalid_with_the_raw_header_only() {
let c = classify_payment(&h(&[("payment-required", "!!not base64!!")]), "u");
assert_eq!(c.signals[0].reason.as_deref(), Some("PAYMENT-REQUIRED is not base64 JSON"));
assert_eq!(c.signals[0].summary, json!({"status": 402, "challenge": "!!not base64!!"}));
let other = b64(br#"{"x402Version":3,"accepts":[]}"#);
let c = classify_payment(&h(&[("payment-required", &other)]), "u");
assert_eq!(c.signals[0].reason.as_deref(), Some("no x402 payload"));
let long = "a".repeat(5000);
let c = classify_payment(&h(&[("payment-required", &format!("{long}é"))]), "u");
assert_eq!(c.signals[0].summary["challenge"].as_str().unwrap().len(), RAW_CAP);
}
#[test]
fn mpp_decodes_each_payment_challenge_and_skips_other_schemes() {
let request = b64(br#"{"amount":"1000","currency":"usd"}"#).replace('=', "").replace('+', "-").replace('/', "_");
let value = format!(r#"Bearer realm="x", Payment id="a1", realm="shop", method="tempo", intent="charge", request="{request}", expires="2026-01-01T00:00:00Z""#);
let c = classify_payment(&h(&[("WWW-Authenticate", &value), ("www-authenticate", "Basic")]), "https://s.test/r");
assert_eq!(c.signals.len(), 1);
let s = &c.signals[0];
assert_eq!(s.standard, "mpp");
assert_eq!(s.resource, "https://s.test/r");
assert_eq!(s.summary["offers"][0], json!({"method": "tempo", "intent": "charge", "realm": "shop", "amount": "1000", "asset": "usd", "expires": "2026-01-01T00:00:00Z"}));
let bad = classify_payment(&h(&[("www-authenticate", r#"Payment id="b", request="@@""#)]), "u");
assert_eq!(bad.signals[0].reason.as_deref(), Some("request is not base64url JSON"));
assert!(classify_payment(&h(&[("www-authenticate", "Payment")]), "u").signals.is_empty());
}
#[test]
fn one_response_can_carry_both_and_a_json_402_without_a_header_reads_its_body() {
let v2 = b64(br#"{"x402Version":2,"accepts":[{"scheme":"exact","amount":"1"}]}"#);
let mpp = format!(r#"Payment method="m", request="{}""#, b64(br#"{"amount":"2"}"#));
let c = classify_payment(&h(&[("payment-required", &v2), ("www-authenticate", &mpp)]), "u");
let standards: Vec<&str> = c.signals.iter().map(|s| s.standard.as_str()).collect();
assert_eq!(standards, ["x402", "mpp"]);
assert_eq!(c.signals[0].resource, "u");
let body = classify_payment(&h(&[("Content-Type", "application/json; charset=utf-8")]), "u");
assert!(body.read_body && body.signals.is_empty());
assert!(classify_payment(&h(&[("content-type", "application/problem+json")]), "u").read_body);
assert!(!classify_payment(&h(&[("content-type", "text/html")]), "u").read_body);
assert!(!classify_payment(&[], "u").read_body);
}
#[test]
fn a_legacy_x_payment_header_with_no_payload_is_invalid_and_keeps_its_raw_challenge() {
let c = classify_payment(&h(&[("X-Payment", "lightning"), ("content-type", "text/html")]), "u");
assert_eq!(c.signals[0].standard, "x402");
assert_eq!(c.signals[0].reason.as_deref(), Some("no x402 payload"));
assert_eq!(c.signals[0].summary["challenge"], "lightning");
assert!(classify_payment(&h(&[("content-type", "text/html")]), "u").signals.is_empty());
}
#[test]
fn x402_v1_reads_the_body_and_reports_what_it_cannot_read() {
let body = r#"{"x402Version":1,"accepts":[{"scheme":"exact","network":"base","maxAmountRequired":"10000","asset":"0xU","payTo":"0xP","resource":"https://v1.test/r","maxTimeoutSeconds":30}]}"#;
let s = classify_x402_body(Some(body), &[], "https://v1.test/p").unwrap();
assert_eq!(s.resource, "https://v1.test/r");
assert_eq!(s.summary["version"], 1);
assert_eq!(s.summary["offers"][0]["amount"], "10000");
let gone = classify_x402_body(None, &h(&[("x-payment", "x")]), "u").unwrap();
assert_eq!(gone.reason.as_deref(), Some("body unavailable"));
assert_eq!(gone.summary["challenge"], "x");
let wrong = classify_x402_body(Some(r#"{"x402Version":1}"#), &[], "u").unwrap();
assert_eq!(wrong.reason.as_deref(), Some("no x402 payload"));
assert!(classify_x402_body(Some(r#"{"error":"x"}"#), &[], "u").is_none());
assert!(classify_x402_body(Some("not json"), &[], "u").is_none());
let legacy = classify_x402_body(Some("{}"), &h(&[("x-payment", "l")]), "u").unwrap();
assert_eq!(legacy.reason.as_deref(), Some("no x402 payload"));
}
}