Skip to main content

discovery/
payments.rs

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(&params, &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}