From 3005307df156dd1b1a77cc377b4abddff6a3e804 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 22 Oct 2026 07:27:44 -0400 Subject: [PATCH] jcp-x402: refuse a payment scheme this crate does not implement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ingest_requirements` parsed `scheme`, defaulted an empty value to `"exact"`, and then **nothing ever read the field** — `git grep '\.scheme\b' -- jcp/` returns zero hits. So a requirement naming any other scheme was gated exactly as if it were `exact`. That matters most for x402's `upto`, which means "charge up to `maxAmountRequired`, settle for the actual amount". Accepted as `exact`, its per-request **maximum** is debited as the price: `spent_minor` accumulates `price.minor` at authorization (`jcp-runtime` lib.rs ~315 and ~931), and `seal_settlement` passes `Price::new(&req.asset, 0)` — its own comment says "debits nothing, reads nothing". Nothing ever reconciles authorized down to settled, so a session settling at a tenth of its cap exhausts a money ceiling ten times too fast. The fix is to refuse, not to reconcile. This crate implements `exact`, where the authorized price *is* the settled price — so `spent_minor` accumulating `price.minor` at authorization is exact by construction and there is nothing to reconcile. Honouring `upto` would need a settle-down debit that does not exist, and inventing one would mean making `spent_minor` non-monotonic, which is a change to the ceiling's soundness model rather than a bug fix. Supporting `upto` is a feature; proceeding under semantics the code does not implement is the defect. `X402Error::UnsupportedScheme` is a new variant rather than `Malformed`, because the document is well formed — this crate simply does not honour what it asks for. An absent scheme stays `exact`, which is the v1 default and the semantics the money path implements; an existing test that omits the field proved that is load-bearing when a mutation removed it. The check is an allowlist, not a denylist of `upto`, and is ordered **before any figure is parsed**. A caller that fixed a broken amount would otherwise be led to retry into semantics this code does not implement. That ordering was wrong in my first version and a test caught it: I wrote "settled before any figure below is read" in the comment while placing the check after `maxAmountRequired` was parsed, so the document reported a malformed amount instead of the unsupported scheme. The claim was false until the check moved above every figure. Falsification, three mutations, all killed by their named test: the pre-fix behaviour (any scheme accepted as exact); refusing an absent scheme too (over-strict — also breaks an existing test that omits it); and a denylist of one name instead of an allowlist. Found by the 2026-10-22 landscape sweep, which ranked it against EMVCo's agentic-payments framework draft. The EMVCo draft itself is log-in gated and its licence excludes derivatives, so it was not retrieved or used; the shipped counterpart x402 (Apache-2.0, spec v2) is what this reads. Verified: workspace check 0 errors, 66 test binaries green, 0 failures, clippy `-D warnings` clean, `check-spec-refs` 1773 citations across 16 standards, 47 lockfiles current. Co-Authored-By: Claude Opus 5 --- jcp/jcp-rs/crates/jcp-x402/src/lib.rs | 96 +++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) 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(); -- 2.54.0 (Apple Git-157)