jcp-x402: refuse a payment scheme this crate does not implement #442

Merged
dcharlot merged 1 commit from fix/x402-refuse-unimplemented-scheme into main 2026-10-22 12:04:49 -04:00

View file

@ -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<X402Requirements, X402Error> {
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<X402Requirements, X402Error> {
}
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();