1use serde_json::{json, Map, Value};
2
3use crate::standards::{parse_challenge_params, split_challenges};
4
5pub const RAW_CAP: usize = 1024;
6const HEADER_CAP: usize = 64 * 1024;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct PaymentSignal {
10 pub standard: String,
11 pub resource: String,
12 pub summary: Value,
13 pub reason: Option<String>,
14}
15
16#[derive(Debug, Clone, PartialEq, Default)]
17pub struct PaymentClassification {
18 pub signals: Vec<PaymentSignal>,
19 pub read_body: bool,
20}
21
22fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
23 headers
24 .iter()
25 .find(|(key, _)| key.eq_ignore_ascii_case(name))
26 .map(|(_, value)| value.as_str())
27}
28
29fn capped(raw: &str) -> String {
30 let mut end = raw.len().min(RAW_CAP);
31 while !raw.is_char_boundary(end) {
32 end -= 1;
33 }
34 raw[..end].to_string()
35}
36
37fn invalid(standard: &str, resource: &str, reason: &str, raw: Option<&str>) -> PaymentSignal {
38 let challenge = raw.map_or(Value::Null, |text| Value::String(capped(text)));
39 PaymentSignal {
40 standard: standard.into(),
41 resource: resource.into(),
42 summary: json!({ "status": 402, "challenge": challenge }),
43 reason: Some(reason.into()),
44 }
45}
46
47fn sextet(byte: u8) -> Option<u32> {
48 match byte {
49 b'A'..=b'Z' => Some(u32::from(byte - b'A')),
50 b'a'..=b'z' => Some(u32::from(byte - b'a') + 26),
51 b'0'..=b'9' => Some(u32::from(byte - b'0') + 52),
52 b'+' | b'-' => Some(62),
53 b'/' | b'_' => Some(63),
54 _ => None,
55 }
56}
57
58pub fn decode_base64(text: &str) -> Option<Vec<u8>> {
59 let trimmed = text.trim().trim_end_matches('=');
60 if trimmed.len() % 4 == 1 {
61 return None;
62 }
63 let mut out = Vec::with_capacity(trimmed.len() * 3 / 4);
64 let mut acc: u32 = 0;
65 let mut bits = 0;
66 for byte in trimmed.bytes() {
67 acc = (acc << 6) | sextet(byte)?;
68 bits += 6;
69 if bits >= 8 {
70 bits -= 8;
71 out.push(((acc >> bits) & 0xff) as u8);
72 }
73 }
74 Some(out)
75}
76
77fn decode_json(text: &str) -> Option<Value> {
78 let bytes = decode_base64(text)?;
79 serde_json::from_slice(&bytes).ok()
80}
81
82fn text_of(value: Option<&Value>) -> Value {
83 match value {
84 Some(Value::String(text)) => Value::String(text.clone()),
85 Some(Value::Number(number)) => Value::String(number.to_string()),
86 _ => Value::Null,
87 }
88}
89
90fn resource_of(value: Option<&Value>, fallback: &str) -> String {
91 match value {
92 Some(Value::String(url)) if !url.is_empty() => url.clone(),
93 Some(Value::Object(map)) => map
94 .get("url")
95 .and_then(Value::as_str)
96 .filter(|url| !url.is_empty())
97 .unwrap_or(fallback)
98 .to_string(),
99 _ => fallback.to_string(),
100 }
101}
102
103fn x402_offer(accept: &Value, version: u64) -> Value {
104 let amount = if version == 1 {
105 accept.get("maxAmountRequired")
106 } else {
107 accept.get("amount")
108 };
109 json!({
110 "scheme": text_of(accept.get("scheme")),
111 "network": text_of(accept.get("network")),
112 "amount": text_of(amount),
113 "asset": text_of(accept.get("asset")),
114 "payTo": text_of(accept.get("payTo")),
115 "expires": Value::Null,
116 "maxTimeoutSeconds": accept.get("maxTimeoutSeconds").cloned().unwrap_or(Value::Null),
117 })
118}
119
120fn x402_signal(payload: &Value, version: u64, url: &str, raw: &str) -> PaymentSignal {
121 let declared = payload.get("x402Version").and_then(Value::as_u64);
122 let accepts = payload.get("accepts").and_then(Value::as_array);
123 let Some(accepts) = accepts.filter(|_| declared == Some(version)) else {
124 return invalid("x402", url, "no x402 payload", Some(raw));
125 };
126 let first = accepts.first().and_then(|accept| accept.get("resource"));
127 let resource = if version == 1 {
128 resource_of(first, url)
129 } else {
130 resource_of(payload.get("resource"), url)
131 };
132 let offers: Vec<Value> = accepts.iter().map(|accept| x402_offer(accept, version)).collect();
133 PaymentSignal {
134 standard: "x402".into(),
135 resource: resource.clone(),
136 summary: json!({ "status": 402, "version": version, "resource": resource, "offers": offers }),
137 reason: None,
138 }
139}
140
141fn mpp_offer(params: &std::collections::BTreeMap<String, String>, request: &Value) -> Value {
142 let mut offer = Map::new();
143 for key in ["method", "intent", "realm"] {
144 offer.insert(key.into(), params.get(key).map_or(Value::Null, |v| Value::String(v.clone())));
145 }
146 offer.insert("amount".into(), text_of(request.get("amount")));
147 offer.insert("asset".into(), text_of(request.get("currency")));
148 offer.insert(
149 "expires".into(),
150 params.get("expires").map_or(Value::Null, |v| Value::String(v.clone())),
151 );
152 Value::Object(offer)
153}
154
155fn mpp_signals(headers: &[(String, String)], url: &str) -> Vec<PaymentSignal> {
156 let mut offers = Vec::new();
157 let mut failure: Option<String> = None;
158 for (name, value) in headers {
159 if !name.eq_ignore_ascii_case("www-authenticate") {
160 continue;
161 }
162 for challenge in split_challenges(&capped_header(value)) {
163 let trimmed = challenge.trim();
164 let Some((scheme, rest)) = trimmed.split_once(|c: char| c.is_ascii_whitespace()) else {
165 continue;
166 };
167 if !scheme.eq_ignore_ascii_case("payment") {
168 continue;
169 }
170 let params = parse_challenge_params(rest);
171 match params.get("request").and_then(|request| decode_json(request)) {
172 Some(request) => offers.push(mpp_offer(¶ms, &request)),
173 None => failure = Some(trimmed.to_string()),
174 }
175 }
176 }
177 if offers.is_empty() {
178 return failure
179 .map(|raw| vec![invalid("mpp", url, "request is not base64url JSON", Some(&raw))])
180 .unwrap_or_default();
181 }
182 vec![PaymentSignal {
183 standard: "mpp".into(),
184 resource: url.into(),
185 summary: json!({ "status": 402, "resource": url, "offers": offers }),
186 reason: None,
187 }]
188}
189
190fn capped_header(value: &str) -> String {
191 let mut end = value.len().min(HEADER_CAP);
192 while !value.is_char_boundary(end) {
193 end -= 1;
194 }
195 value[..end].to_string()
196}
197
198fn is_json(content_type: Option<&str>) -> bool {
199 content_type.is_some_and(|ct| {
200 let ct = ct.to_ascii_lowercase();
201 let media = ct.split(';').next().unwrap_or("").trim();
202 media == "application/json" || media.ends_with("+json")
203 })
204}
205
206pub fn classify_payment(headers: &[(String, String)], url: &str) -> PaymentClassification {
207 let mut signals = Vec::new();
208 let required = header(headers, "payment-required");
209 if let Some(raw) = required {
210 signals.push(match decode_json(raw) {
211 Some(payload) => x402_signal(&payload, 2, url, raw),
212 None => invalid("x402", url, "PAYMENT-REQUIRED is not base64 JSON", Some(raw)),
213 });
214 }
215 signals.extend(mpp_signals(headers, url));
216 let json_body = is_json(header(headers, "content-type"));
217 let read_body = required.is_none() && json_body;
218 if required.is_none() && !json_body {
219 if let Some(raw) = header(headers, "x-payment") {
220 signals.push(invalid("x402", url, "no x402 payload", Some(raw)));
221 }
222 }
223 PaymentClassification { signals, read_body }
224}
225
226pub fn classify_x402_body(body: Option<&str>, headers: &[(String, String)], url: &str) -> Option<PaymentSignal> {
227 let legacy = header(headers, "x-payment");
228 let Some(body) = body else {
229 return Some(invalid("x402", url, "body unavailable", legacy));
230 };
231 match serde_json::from_str::<Value>(body) {
232 Ok(payload) if payload.get("x402Version").is_some() || payload.get("accepts").is_some() => {
233 let signal = x402_signal(&payload, 1, url, legacy.unwrap_or(""));
234 if signal.reason.is_some() {
235 return Some(invalid("x402", url, "no x402 payload", legacy));
236 }
237 Some(signal)
238 }
239 _ => legacy.map(|raw| invalid("x402", url, "no x402 payload", Some(raw))),
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn b64(bytes: &[u8]) -> String {
248 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
249 let mut out = String::new();
250 for chunk in bytes.chunks(3) {
251 let n = chunk.iter().enumerate().fold(0u32, |acc, (i, b)| acc | (u32::from(*b) << (16 - 8 * i)));
252 let count = chunk.len() + 1;
253 for i in 0..4 {
254 if i < count {
255 out.push(TABLE[((n >> (18 - 6 * i)) & 63) as usize] as char);
256 } else {
257 out.push('=');
258 }
259 }
260 }
261 out
262 }
263
264 fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
265 pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
266 }
267
268 #[test]
269 fn decodes_padded_unpadded_and_url_safe_base64() {
270 assert_eq!(decode_base64("aGk="), Some(b"hi".to_vec()));
271 assert_eq!(decode_base64("aGk"), Some(b"hi".to_vec()));
272 assert_eq!(decode_base64("-_8"), Some(vec![0xfb, 0xff]));
273 assert_eq!(decode_base64("a"), None);
274 assert_eq!(decode_base64("a*b="), None);
275 }
276
277 #[test]
278 fn x402_v2_reads_the_header_and_keeps_amounts_as_sent() {
279 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}]}"#;
280 let c = classify_payment(&h(&[("PAYMENT-REQUIRED", &b64(payload.as_bytes()))]), "https://x.test/p");
281 assert!(!c.read_body);
282 let s = &c.signals[0];
283 assert_eq!(s.standard, "x402");
284 assert_eq!(s.resource, "https://x.test/r");
285 assert_eq!(s.summary["version"], 2);
286 assert_eq!(s.summary["offers"][0]["amount"], "123456789012345678901234567890");
287 assert_eq!(s.summary["offers"][0]["payTo"], "0xB");
288 assert_eq!(s.summary["offers"][0]["maxTimeoutSeconds"], 60);
289 assert_eq!(s.summary["offers"][1]["amount"], "5");
290 assert_eq!(s.summary["offers"][1]["asset"], Value::Null);
291 }
292
293 #[test]
294 fn x402_v2_with_a_bad_or_foreign_header_is_invalid_with_the_raw_header_only() {
295 let c = classify_payment(&h(&[("payment-required", "!!not base64!!")]), "u");
296 assert_eq!(c.signals[0].reason.as_deref(), Some("PAYMENT-REQUIRED is not base64 JSON"));
297 assert_eq!(c.signals[0].summary, json!({"status": 402, "challenge": "!!not base64!!"}));
298 let other = b64(br#"{"x402Version":3,"accepts":[]}"#);
299 let c = classify_payment(&h(&[("payment-required", &other)]), "u");
300 assert_eq!(c.signals[0].reason.as_deref(), Some("no x402 payload"));
301 let long = "a".repeat(5000);
302 let c = classify_payment(&h(&[("payment-required", &format!("{long}é"))]), "u");
303 assert_eq!(c.signals[0].summary["challenge"].as_str().unwrap().len(), RAW_CAP);
304 }
305
306 #[test]
307 fn mpp_decodes_each_payment_challenge_and_skips_other_schemes() {
308 let request = b64(br#"{"amount":"1000","currency":"usd"}"#).replace('=', "").replace('+', "-").replace('/', "_");
309 let value = format!(r#"Bearer realm="x", Payment id="a1", realm="shop", method="tempo", intent="charge", request="{request}", expires="2026-01-01T00:00:00Z""#);
310 let c = classify_payment(&h(&[("WWW-Authenticate", &value), ("www-authenticate", "Basic")]), "https://s.test/r");
311 assert_eq!(c.signals.len(), 1);
312 let s = &c.signals[0];
313 assert_eq!(s.standard, "mpp");
314 assert_eq!(s.resource, "https://s.test/r");
315 assert_eq!(s.summary["offers"][0], json!({"method": "tempo", "intent": "charge", "realm": "shop", "amount": "1000", "asset": "usd", "expires": "2026-01-01T00:00:00Z"}));
316 let bad = classify_payment(&h(&[("www-authenticate", r#"Payment id="b", request="@@""#)]), "u");
317 assert_eq!(bad.signals[0].reason.as_deref(), Some("request is not base64url JSON"));
318 assert!(classify_payment(&h(&[("www-authenticate", "Payment")]), "u").signals.is_empty());
319 }
320
321 #[test]
322 fn one_response_can_carry_both_and_a_json_402_without_a_header_reads_its_body() {
323 let v2 = b64(br#"{"x402Version":2,"accepts":[{"scheme":"exact","amount":"1"}]}"#);
324 let mpp = format!(r#"Payment method="m", request="{}""#, b64(br#"{"amount":"2"}"#));
325 let c = classify_payment(&h(&[("payment-required", &v2), ("www-authenticate", &mpp)]), "u");
326 let standards: Vec<&str> = c.signals.iter().map(|s| s.standard.as_str()).collect();
327 assert_eq!(standards, ["x402", "mpp"]);
328 assert_eq!(c.signals[0].resource, "u");
329 let body = classify_payment(&h(&[("Content-Type", "application/json; charset=utf-8")]), "u");
330 assert!(body.read_body && body.signals.is_empty());
331 assert!(classify_payment(&h(&[("content-type", "application/problem+json")]), "u").read_body);
332 assert!(!classify_payment(&h(&[("content-type", "text/html")]), "u").read_body);
333 assert!(!classify_payment(&[], "u").read_body);
334 }
335
336 #[test]
337 fn a_legacy_x_payment_header_with_no_payload_is_invalid_and_keeps_its_raw_challenge() {
338 let c = classify_payment(&h(&[("X-Payment", "lightning"), ("content-type", "text/html")]), "u");
339 assert_eq!(c.signals[0].standard, "x402");
340 assert_eq!(c.signals[0].reason.as_deref(), Some("no x402 payload"));
341 assert_eq!(c.signals[0].summary["challenge"], "lightning");
342 assert!(classify_payment(&h(&[("content-type", "text/html")]), "u").signals.is_empty());
343 }
344
345 #[test]
346 fn x402_v1_reads_the_body_and_reports_what_it_cannot_read() {
347 let body = r#"{"x402Version":1,"accepts":[{"scheme":"exact","network":"base","maxAmountRequired":"10000","asset":"0xU","payTo":"0xP","resource":"https://v1.test/r","maxTimeoutSeconds":30}]}"#;
348 let s = classify_x402_body(Some(body), &[], "https://v1.test/p").unwrap();
349 assert_eq!(s.resource, "https://v1.test/r");
350 assert_eq!(s.summary["version"], 1);
351 assert_eq!(s.summary["offers"][0]["amount"], "10000");
352 let gone = classify_x402_body(None, &h(&[("x-payment", "x")]), "u").unwrap();
353 assert_eq!(gone.reason.as_deref(), Some("body unavailable"));
354 assert_eq!(gone.summary["challenge"], "x");
355 let wrong = classify_x402_body(Some(r#"{"x402Version":1}"#), &[], "u").unwrap();
356 assert_eq!(wrong.reason.as_deref(), Some("no x402 payload"));
357 assert!(classify_x402_body(Some(r#"{"error":"x"}"#), &[], "u").is_none());
358 assert!(classify_x402_body(Some("not json"), &[], "u").is_none());
359 let legacy = classify_x402_body(Some("{}"), &h(&[("x-payment", "l")]), "u").unwrap();
360 assert_eq!(legacy.reason.as_deref(), Some("no x402 payload"));
361 }
362}