diff --git a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs index 5806eed6c..0872cbee8 100644 --- a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs @@ -67,6 +67,17 @@ pub enum X402Error { /// What was wrong. detail: String, }, + /// The requirements are well formed but name a payment scheme this crate + /// does not implement. + /// + /// Refused rather than accepted, because nothing downstream reads the + /// scheme: an accepted `upto` would be gated as `exact`, charging its + /// per-request **maximum** as the price. + #[error("unsupported x402 payment scheme {scheme:?}: this crate honours \"exact\" only")] + UnsupportedScheme { + /// The scheme the server asked for. + scheme: String, + }, /// The payment grant could not be issued. #[error("grant issue failed: {0}")] Grant(#[from] jcp_grant::GrantError), @@ -131,6 +142,32 @@ pub fn ingest_requirements(req: &Value) -> Result { let obj = root.as_object().ok_or_else(|| malformed("requirements", "not a JSON object"))?; let s = |k: &str| obj.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + + // The scheme is settled before any figure below is read, because nothing + // downstream consults it: `.scheme` is written here and never read again. + // An absent scheme means `exact` (the v1 default), and `exact` is what the + // money path below implements — the authorized price *is* the settled + // price, so `spent_minor` accumulating `price.minor` at authorization is + // exact by construction. + // + // Any other value names different semantics. `upto` in particular means + // "charge up to `maxAmountRequired`, settle for the actual amount", and + // accepting it here would gate the request as `exact`: the per-request + // maximum would be debited as the price, and `seal_settlement` debits + // nothing, so a session settling at a tenth of its cap would exhaust a + // money ceiling ten times too fast. Honouring `upto` needs a settle-down + // reconciliation that does not exist; until it does, the honest answer is + // to refuse rather than to proceed under semantics this code does not + // implement. + let scheme = { + let sc = s("scheme"); + if sc.is_empty() || sc == "exact" { + "exact".to_string() + } else { + return Err(X402Error::UnsupportedScheme { scheme: sc }); + } + }; + let network = s("network"); let pay_to = obj .get("payTo") @@ -164,10 +201,7 @@ pub fn ingest_requirements(req: &Value) -> Result { } Ok(X402Requirements { - scheme: { - let sc = s("scheme"); - if sc.is_empty() { "exact".into() } else { sc } - }, + scheme, network, max_amount, resource: s("resource"), @@ -444,6 +478,60 @@ mod tests { }) } + /// **`upto` is refused, not silently gated as `exact`.** + /// + /// Nothing downstream reads `.scheme`, so accepting an `upto` requirement + /// would charge its per-request **maximum** as the price — and + /// `seal_settlement` debits nothing, so a session settling at a tenth of + /// its cap would exhaust a money ceiling ten times too fast. The scheme is + /// the only place that can refuse it. + #[test] + fn an_unimplemented_scheme_is_refused_rather_than_treated_as_exact() { + let mut req = sample_req(); + req["scheme"] = json!("upto"); + match ingest_requirements(&req) { + Err(X402Error::UnsupportedScheme { scheme }) => assert_eq!(scheme, "upto"), + other => panic!("an `upto` requirement was not refused: {other:?}"), + } + // Any other scheme the same way — the refusal is "not implemented", + // not a denylist of one name. + for sc in ["permit", "stream", "EXACT", "exact "] { + let mut req = sample_req(); + req["scheme"] = json!(sc); + assert!( + matches!(ingest_requirements(&req), Err(X402Error::UnsupportedScheme { .. })), + "{sc:?} was accepted; only \"exact\" is implemented" + ); + } + } + + /// An absent scheme is `exact` — the v1 default, and the semantics the + /// money path actually implements. This is what the refusal above must not + /// break. + #[test] + fn an_absent_scheme_is_the_v1_exact_default() { + let mut req = sample_req(); + req.as_object_mut().unwrap().remove("scheme"); + let r = ingest_requirements(&req).expect("an absent scheme is the v1 default"); + assert_eq!(r.scheme, "exact"); + assert_eq!(r.price(), Price::new("USDC", 1_000_000)); + } + + /// **The refusal is ordered before the figures are read.** A document that + /// names an unimplemented scheme *and* carries a broken amount must report + /// the scheme, because a caller that fixed the amount would otherwise be + /// led to retry into semantics this code does not implement. + #[test] + fn the_scheme_is_refused_before_the_amount_is_parsed() { + let mut req = sample_req(); + req["scheme"] = json!("upto"); + req["maxAmountRequired"] = json!("not a number"); + match ingest_requirements(&req) { + Err(X402Error::UnsupportedScheme { scheme }) => assert_eq!(scheme, "upto"), + other => panic!("the amount was parsed before the scheme was refused: {other:?}"), + } + } + #[test] fn ingests_requirements_including_string_amount() { let r = ingest_requirements(&sample_req()).unwrap();