jcp-x402: refuse a payment scheme this crate does not implement #442
1 changed files with 92 additions and 4 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue