Runtime::charge debits before it computes the verdict, so a call that breaches MaxCostUj or a joule window at the debit point is denied and stays debited. replay_spend kept only verdict == "allow", so a replayed stream restored less than was spent: reproduced at the base with three failing tests (0 restored against 900, 400, and 900 on each link of a chain). The verdict cannot say what was debited. The same reasons also refuse a call before any work, and a refusal seals the cost it refused, so cost does not separate them either. And a two-phase debit saturates at the ceiling, each link of a chain at its own, so it can be less than cost_uj. The signed core now says, where the verdict does not. debited_uj is a uint, since the core holds no bool, and chain_debited_uj lists each link's figure beside chain_ids where a cap left them differing. Only a capped debit and a denial naming caveat-unsatisfied or window-exceeded carry a member, the denial always, 0 on a refusal, so its absence there can only mean a receipt sealed before the member. Every other receipt keeps its bytes and the tag stays jcp/2: regenerating the pack changes no published file. Those older receipts keep verifying. A denial of either reason without the member reads as its cost_uj, the most it can have debited, since an unknown debit is not a zero one: an old log restores at least what was debited, and SPEC §8.1 says exactly what it can no longer prove. Decision carries the debit, so it is no longer Copy: a chain's per-link figures are a Vec. aggregate now matches the ledger too, charge no longer underflows a grant an old log restored past its ceiling, and ReceiptLog gains append_chained, without which a logged chained decision lost its ancestors. conformance/v1/replay.json publishes a hash-chained log with chain_ids, seq, prev and every form of the member, its spend taken from the runtime's ledger and not from the receipts. conformance/verify_replay.py is a second reader sharing no code with the reference implementation, run by the repository checks with its own planted faults. Changelog: none Co-Authored-By: Claude Opus 5.5 <[email protected]>
294 lines
13 KiB
Rust
294 lines
13 KiB
Rust
//! Consume the published `jcp` conformance pack — the files nothing read.
|
|
//!
|
|
//! jcp was already the best-covered standard here: `jcp-action-receipt`'s
|
|
//! `tests/conformance.rs` genuinely reads `action_receipt.json` and
|
|
//! `action_receipt_meta.json` from disk. But three of the pack's files had no
|
|
//! reader at all:
|
|
//!
|
|
//! - **`legacy_jcp1.json`** — receipts sealed under the superseded `jcp/1`
|
|
//! profile, published, in the pack's own words, "so a verifier can prove the
|
|
//! jcp/2 migration path: these MUST still verify".
|
|
//!
|
|
//! The guarantee is *not* untested: `jcp-receipt`'s own
|
|
//! `published_v1_receipts_still_verify` checks it. But it checks **one inlined
|
|
//! hex constant**, which is a copy of this pack's *allow* receipt. The pack also
|
|
//! publishes a **legacy deny** (`untrusted-control`) that no inlined constant
|
|
//! covers, and the file itself is read by nothing — so the published bytes, and
|
|
//! the legacy refusal path, were unverified. (`jcp-receipt`'s doc comment titled
|
|
//! *The migration guarantee* is also misplaced: it sits ~80 lines above, over an
|
|
//! unrelated test about environment bindings.)
|
|
//!
|
|
//! The mechanism is simpler than that comment suggests, too. There is no version
|
|
//! dispatch in `jcr1.rs`; `payload_hash` is merely **optional**, so its absence
|
|
//! leaves a legacy preimage byte-identical — which is what the neighbouring
|
|
//! comment ("Absent, the field changes nothing") actually says.
|
|
//! - **`dual_ceilings.json`** — §4.8's money-beside-joules ceiling, including the
|
|
//! two refusals that make it real (`funds-exceeded`, `currency-mismatch`). Those
|
|
//! are negative vectors, and nothing executed them.
|
|
//! - **`standing.json`** — the standing-grant lineage, and (added with §4.7's
|
|
//! remainder rule) the spend-bearing checkpoint whose arithmetic a third
|
|
//! party can redo.
|
|
//!
|
|
//! Note `legacy_jcp1.json` is deliberately absent from the drift check below: it is
|
|
//! a frozen historical artifact ("not minted any more"), so regenerating it would
|
|
//! destroy the very thing it exists to prove.
|
|
|
|
use jcp_receipt::{extract, replay_spend};
|
|
use serde_json::Value;
|
|
use std::path::PathBuf;
|
|
|
|
fn pack_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../../conformance/v1")
|
|
.canonicalize()
|
|
.expect("the conformance pack must exist next to the spec")
|
|
}
|
|
|
|
fn doc(name: &str) -> Value {
|
|
serde_json::from_slice(&std::fs::read(pack_dir().join(name)).expect("read pack file"))
|
|
.unwrap_or_else(|e| panic!("{name} must parse: {e}"))
|
|
}
|
|
|
|
/// The steward identity and key the pack publishes.
|
|
fn steward_and_key() -> (String, [u8; 32]) {
|
|
let m = doc("meta.json");
|
|
let steward = m["steward"].as_str().expect("meta names the steward").to_string();
|
|
let key: [u8; 32] = hex::decode(m["pubkey_hex"].as_str().expect("meta publishes a key"))
|
|
.expect("hex")
|
|
.try_into()
|
|
.expect("a 32-byte Ed25519 key");
|
|
(steward, key)
|
|
}
|
|
|
|
fn envelopes(d: &Value) -> Vec<Vec<u8>> {
|
|
d["receipts"]
|
|
.as_array()
|
|
.expect("the pack lists receipts")
|
|
.iter()
|
|
.map(|r| hex::decode(r["envelope_hex"].as_str().expect("receipt carries an envelope")).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
/// The migration guarantee, over the **published** bytes rather than an inlined copy.
|
|
///
|
|
/// Every receipt sealed under the superseded `jcp/1` profile must still verify under
|
|
/// today's implementation, or the `payload_hash` bump silently invalidated every
|
|
/// receipt in the wild. `jcp-receipt` already checks the allow receipt from an
|
|
/// inlined constant; what was missing is the file itself and the legacy **deny**,
|
|
/// which no inlined constant covers. Both verdicts are required below so the
|
|
/// refusal cannot quietly disappear from the pack.
|
|
#[test]
|
|
fn receipts_sealed_under_the_superseded_profile_still_verify() {
|
|
let d = doc("legacy_jcp1.json");
|
|
let (steward, key) = steward_and_key();
|
|
assert_eq!(d["profile"], "jcp/1", "this pack is the superseded profile's evidence");
|
|
|
|
let receipts = d["receipts"].as_array().expect("the pack lists legacy receipts");
|
|
assert!(!receipts.is_empty(), "a wrong path must not look like a pass");
|
|
|
|
let verdicts: Vec<&str> = receipts.iter().map(|r| r["verdict"].as_str().unwrap()).collect();
|
|
assert!(verdicts.contains(&"allow"), "the legacy pack must keep an allow: {verdicts:?}");
|
|
assert!(
|
|
verdicts.contains(&"deny"),
|
|
"the legacy pack must keep a deny — the refusal path is the half no inlined \
|
|
constant covers: {verdicts:?}"
|
|
);
|
|
|
|
for (i, r) in receipts.iter().enumerate() {
|
|
let env = hex::decode(r["envelope_hex"].as_str().unwrap()).unwrap();
|
|
let core = extract(&env, &steward, &key).unwrap_or_else(|e| {
|
|
panic!("legacy receipt {i} no longer verifies ({e:?}) — the profile bump broke migration")
|
|
});
|
|
assert_eq!(
|
|
core.verdict,
|
|
r["verdict"].as_str().unwrap(),
|
|
"legacy receipt {i}: verdict differs from the published one"
|
|
);
|
|
assert_eq!(core.steward, steward, "legacy receipt {i}: steward differs");
|
|
}
|
|
}
|
|
|
|
/// The migration check is not vacuous: tampering with a legacy envelope, or
|
|
/// verifying it under another key, must both fail.
|
|
#[test]
|
|
fn a_tampered_legacy_receipt_does_not_verify() {
|
|
let d = doc("legacy_jcp1.json");
|
|
let (steward, key) = steward_and_key();
|
|
let mut env = hex::decode(d["receipts"][0]["envelope_hex"].as_str().unwrap()).unwrap();
|
|
|
|
let stranger = ed25519_dalek::SigningKey::from_bytes(&[0x42; 32]).verifying_key().to_bytes();
|
|
assert!(
|
|
extract(&env, &steward, &stranger).is_err(),
|
|
"a legacy receipt verified under a key that did not seal it"
|
|
);
|
|
assert!(
|
|
extract(&env, "did:example:not-this-steward", &key).is_err(),
|
|
"a legacy receipt verified against the wrong steward"
|
|
);
|
|
|
|
let mid = env.len() / 2;
|
|
env[mid] ^= 0x01;
|
|
assert!(extract(&env, &steward, &key).is_err(), "a tampered legacy receipt verified");
|
|
}
|
|
|
|
/// §4.8's dual ceilings, with the refusals actually executed.
|
|
///
|
|
/// The pack's value is in its two denials: a spend that exceeds the money ceiling,
|
|
/// and a spend in the wrong currency. Both are compared as the whole recorded
|
|
/// verdict — verdict *and* reason — because "it denied" would pass even if it
|
|
/// denied for the wrong reason.
|
|
#[test]
|
|
fn the_dual_ceiling_refusals_replay_with_their_recorded_reasons() {
|
|
let d = doc("dual_ceilings.json");
|
|
let (steward, key) = steward_and_key();
|
|
let receipts = d["receipts"].as_array().expect("the pack lists receipts");
|
|
assert_eq!(receipts.len(), 3, "the scenario is one allow and two refusals");
|
|
|
|
let mut reasons = Vec::new();
|
|
for (i, r) in receipts.iter().enumerate() {
|
|
let env = hex::decode(r["envelope_hex"].as_str().unwrap()).unwrap();
|
|
let core = extract(&env, &steward, &key)
|
|
.unwrap_or_else(|e| panic!("dual-ceiling receipt {i} does not verify: {e:?}"));
|
|
assert_eq!(core.verdict, r["verdict"].as_str().unwrap(), "receipt {i}: verdict differs");
|
|
|
|
let want_reason = r["wire"]["deny_reason"].as_str();
|
|
assert_eq!(
|
|
core.deny_reason.as_deref(),
|
|
want_reason,
|
|
"receipt {i}: denied for a different reason than the pack records"
|
|
);
|
|
if let Some(reason) = want_reason {
|
|
reasons.push(reason.to_string());
|
|
}
|
|
}
|
|
|
|
// The two rules §4.8 exists to enforce: a money ceiling, and no invented FX.
|
|
assert!(reasons.iter().any(|r| r == "funds-exceeded"), "no funds-exceeded refusal: {reasons:?}");
|
|
assert!(
|
|
reasons.iter().any(|r| r == "currency-mismatch"),
|
|
"no currency-mismatch refusal: {reasons:?}"
|
|
);
|
|
|
|
// Replaying the spend must land on the recorded total: only the allow debits.
|
|
let envs = envelopes(&d);
|
|
let spent: u64 = replay_spend(envs.iter().map(|e| e.as_slice()), &steward, &key)
|
|
.into_iter()
|
|
.map(|(_grant, uj)| uj)
|
|
.sum();
|
|
assert_eq!(
|
|
spent,
|
|
d["spent_after"]["uj"].as_u64().expect("the pack records the joules spent"),
|
|
"replaying the receipts does not reproduce the recorded joule spend"
|
|
);
|
|
assert!(
|
|
spent <= d["grant"]["budget_uj"].as_u64().unwrap(),
|
|
"the replayed spend exceeds the grant's own joule ceiling"
|
|
);
|
|
}
|
|
|
|
/// The standing-grant lineage, as far as the pack makes it checkable.
|
|
///
|
|
/// One link closes: `minted_child` names `standing_grant` as its parent, and its
|
|
/// budget is within the amended ceiling. One does not: `continuation_grant` names a
|
|
/// `parent_hex` that is **not among the ids the pack publishes**. Its budget matches
|
|
/// the amendment's `new_budget_uj`, so the parent is plausibly the post-amendment
|
|
/// grant — but that grant is not in the pack, so a third party cannot close the
|
|
/// link from the published files alone. Asserted as the current state, with a
|
|
/// message to replace it once the parent is published.
|
|
#[test]
|
|
fn the_standing_lineage_links_as_far_as_the_pack_allows() {
|
|
let d = doc("standing.json");
|
|
let id = |k: &str| d[k]["id_hex"].as_str().unwrap_or_default().to_string();
|
|
|
|
for k in ["standing_grant", "mint_policy", "minted_child", "amendment", "checkpoint", "continuation_grant"] {
|
|
let i = id(k);
|
|
assert_eq!(i.len(), 64, "{k} has no 32-byte id");
|
|
assert!(i.bytes().all(|b| b.is_ascii_hexdigit()), "{k} id is not hex");
|
|
}
|
|
|
|
assert_eq!(
|
|
d["minted_child"]["parent_hex"].as_str().unwrap(),
|
|
id("standing_grant"),
|
|
"the minted child does not descend from the standing grant"
|
|
);
|
|
|
|
let amended = d["amendment"]["new_budget_uj"].as_u64().expect("the amendment sets a budget");
|
|
assert_eq!(
|
|
d["continuation_grant"]["budget_uj"].as_u64().unwrap(),
|
|
amended,
|
|
"the continuation does not carry the amended budget"
|
|
);
|
|
assert!(
|
|
d["minted_child"]["budget_uj"].as_u64().unwrap() <= amended,
|
|
"a minted child may not exceed the amended ceiling"
|
|
);
|
|
|
|
// The spend-bearing checkpoint (§4.7's remainder rule). Two things are
|
|
// checkable from the published files alone: the remainder is arithmetic a
|
|
// third party can redo, and the entry's id differs from `checkpoint`'s —
|
|
// which is what makes the additivity claim non-vacuous. `checkpoint` was
|
|
// generated with no spend and keeps the id it had before the field existed;
|
|
// this one carries the field and therefore cannot.
|
|
let sp = &d["checkpoint_with_spend"];
|
|
let issued = sp["grant_budget_uj"].as_u64().expect("the entry publishes the issued budget");
|
|
let spent = sp["spent_uj"].as_u64().expect("the entry publishes the spend");
|
|
let remaining = sp["remaining_uj"].as_u64().expect("the entry publishes the remainder");
|
|
assert_eq!(
|
|
issued.saturating_sub(spent), remaining,
|
|
"the published remainder is not the issued budget minus the spend"
|
|
);
|
|
assert!(remaining < issued, "a vector for the remainder rule must actually have spent something");
|
|
assert_ne!(
|
|
id("checkpoint_with_spend"), id("checkpoint"),
|
|
"a declared spend must change the checkpoint's id — it is signed, not advisory"
|
|
);
|
|
|
|
let published: Vec<String> = ["standing_grant", "mint_policy", "minted_child", "amendment", "checkpoint", "continuation_grant"]
|
|
.iter()
|
|
.map(|k| id(k))
|
|
.collect();
|
|
let cont_parent = d["continuation_grant"]["parent_hex"].as_str().unwrap();
|
|
assert!(
|
|
!published.contains(&cont_parent.to_string()),
|
|
"the continuation's parent is now published — replace this with a real lineage \
|
|
assertion linking it to that grant"
|
|
);
|
|
}
|
|
|
|
/// The generator still emits the files it owns.
|
|
///
|
|
/// `legacy_jcp1.json` is excluded on purpose: it is frozen pre-`payload_hash`
|
|
/// evidence, so regenerating it would erase what it exists to prove.
|
|
#[test]
|
|
fn the_generator_still_emits_the_files_it_owns() {
|
|
let tmp = std::env::temp_dir().join(format!("jcp-pack-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
std::fs::create_dir_all(&tmp).expect("create temp dir");
|
|
|
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_gen-vectors"))
|
|
.arg(&tmp)
|
|
.output()
|
|
.expect("gen-vectors must run");
|
|
assert!(out.status.success(), "gen-vectors failed: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let mut checked = 0;
|
|
for f in ["meta.json", "vectors.json", "dual_ceilings.json", "standing.json", "replay.json"] {
|
|
let regenerated = tmp.join(f);
|
|
if !regenerated.exists() {
|
|
continue; // the generator does not own this one
|
|
}
|
|
assert_eq!(
|
|
std::fs::read(®enerated).unwrap(),
|
|
std::fs::read(pack_dir().join(f)).unwrap(),
|
|
"{f} has drifted from what the generator emits"
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert!(checked >= 3, "expected the generator to own at least three files, saw {checked}");
|
|
assert!(
|
|
!tmp.join("legacy_jcp1.json").exists(),
|
|
"the generator now rewrites the frozen legacy pack — that would erase the \
|
|
pre-payload_hash evidence the migration guarantee rests on"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
}
|