Fault P3 from the crash-refund review, reproduced by a5851fba3: a standing grant's MintLedger could not be rebuilt from anything the enforcement point kept. A firing sealed no receipt, a child never exercised left no trace, and the type's doc named default() as the genesis, so a ledger reading fewer fires than happened looked like one that fired less. A restart from a ledger persisted after the first of three firings fired two more past max_fires. Each firing is now a decision sealed as a receipt. It is an allow on the standing grant with action mint, cost 0, the mint policy's id as resource and the trigger event's payload id as payload_hash, and it states minted_uj, the budget the child carries: one new signed member of the jcp/2 core. ReceiptLog::append_mint seals it, and the enforcement point records it before the child leaves (SPEC §4.6, §4.11). A firing debits nothing and accrues nothing, and group accounting does not count it as an action; the child's own receipts debit what its work spends. jcp_receipt::replay_mints lists a stream's firings, and MintLedger::of_firings rebuilds the ledger from them: one fire each, and their minted_uj summed. MintLedger::restore applies a stored copy beside it, keeping the higher count and the higher spend, so neither a stale copy nor a short stream hands a firing back. A refusal stating minted_uj fires nothing (C3). No receipt sealed before this changes its bytes or how it reads. SPEC §8.1 states that streams sealed before minted_uj hold no record of firings, so an enforcement point restoring one keeps the ledger it stored beside the rebuilt one, or holds the standing grant as fired out. §8.2 leaves firings out of group accounting, and §8 lists the member. Conformance: replay.json gains three entries at its end, two firings of one standing grant with a call by their child between them, per-entry mints figures and a mints_after total, all taken from the MintLedger the firings advanced, not from the receipts. The 23 existing entries and the 5 legacy receipts keep their bytes. verify_replay.py reads firings, checks mints_after, and refuses a changed minted_uj, changed totals and four more planted reader faults. C20 now seals each firing and checks that a restart from a stale ledger, restored beside the stream, stays rate-limited. replay_pack reads the firings through the library. The P3 test moves from jcp-grant, which cannot seal receipts, to jcp-receipt's replay_debits.rs. It keeps its name and its fault assertion, its restart now restores from the stream as well as the persisted ledger, and it no longer carries should_panic. jcp-grant keeps a unit test of the restore direction. Changelog: none Co-Authored-By: Claude Opus 5.5 <[email protected]>
285 lines
14 KiB
Rust
285 lines
14 KiB
Rust
//! The published replay log (`replay.json`, SPEC §8.1), read through the
|
|
//! reference library. `conformance/verify_replay.py` reads the same file without it.
|
|
|
|
use jcp_grant::standing::MintLedger;
|
|
use jcp_receipt::{extract, jcr1, read_stream, replay_ledger, replay_mints, verify_log, ReceiptCore};
|
|
use jcp_runtime::Runtime;
|
|
use jcp_schema::ContentId;
|
|
use serde_json::Value;
|
|
use std::path::PathBuf;
|
|
|
|
fn pack_file(name: &str) -> Value {
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/v1").join(name);
|
|
serde_json::from_slice(&std::fs::read(path).expect("read pack file")).expect("the pack file parses")
|
|
}
|
|
|
|
fn steward_and_key() -> (String, [u8; 32]) {
|
|
let m = pack_file("meta.json");
|
|
let key = hex::decode(m["pubkey_hex"].as_str().unwrap()).unwrap().try_into().unwrap();
|
|
(m["steward"].as_str().unwrap().to_string(), key)
|
|
}
|
|
|
|
fn envelope(entry: &Value) -> Vec<u8> {
|
|
hex::decode(entry["envelope_hex"].as_str().unwrap()).unwrap()
|
|
}
|
|
|
|
fn entries(doc: &Value) -> Vec<Vec<u8>> {
|
|
doc["entries"].as_array().expect("the log lists entries").iter().map(envelope).collect()
|
|
}
|
|
|
|
/// What an entry publishes for one dimension: `debits` and `tainted` in
|
|
/// joules, `money` in minor units.
|
|
fn published(entry: &Value, dim: &str) -> Vec<(ContentId, u64)> {
|
|
let unit = if dim == "money" { "minor" } else { "uj" };
|
|
entry[dim]
|
|
.as_array()
|
|
.unwrap_or_else(|| panic!("the entry publishes {dim}"))
|
|
.iter()
|
|
.map(|d| (ContentId::from_hex(d["grant_hex"].as_str().unwrap()).unwrap(), d[unit].as_u64().unwrap()))
|
|
.collect()
|
|
}
|
|
|
|
/// A receipt read in every dimension, as the pack publishes it.
|
|
fn reads(core: &ReceiptCore) -> [Vec<(ContentId, u64)>; 3] {
|
|
[core.debits(), core.money_debits(), core.tainted_accruals()]
|
|
}
|
|
|
|
fn publishes(entry: &Value) -> [Vec<(ContentId, u64)>; 3] {
|
|
[published(entry, "debits"), published(entry, "money"), published(entry, "tainted")]
|
|
}
|
|
|
|
/// What an entry publishes a standing grant's mint ledger moved by: the grant,
|
|
/// its fires, and the budget they minted.
|
|
fn published_mints(entry: &Value) -> Vec<(ContentId, u64, u64)> {
|
|
entry["mints"]
|
|
.as_array()
|
|
.expect("the entry publishes mints")
|
|
.iter()
|
|
.map(|d| {
|
|
let grant = ContentId::from_hex(d["grant_hex"].as_str().unwrap()).unwrap();
|
|
(grant, d["fires"].as_u64().unwrap(), d["uj"].as_u64().unwrap())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// What a receipt adds to a mint ledger: one fire and its `minted_uj`, on a firing.
|
|
fn reads_mints(core: &ReceiptCore) -> Vec<(ContentId, u64, u64)> {
|
|
core.minted_uj.filter(|_| core.is_mint()).map(|uj| (core.grant_id, 1, uj)).into_iter().collect()
|
|
}
|
|
|
|
/// The log verifies end to end, and each core is the published wire form.
|
|
#[test]
|
|
fn the_published_log_is_one_verified_chain() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let log = entries(&doc);
|
|
assert!(!log.is_empty(), "a wrong path must not look like a pass");
|
|
assert_eq!(verify_log(&log, &steward, &key), Ok(log.len()));
|
|
for (env, entry) in log.iter().zip(doc["entries"].as_array().unwrap()) {
|
|
let payload = jcr1::verify_cose_sign1(env, &steward, &key).unwrap();
|
|
let core: Value = jcp_schema::from_canonical_cbor(&payload).unwrap();
|
|
assert_eq!(core, entry["wire"], "{}", entry["what"]);
|
|
}
|
|
}
|
|
|
|
/// Each entry reads as what the runtime's ledgers moved by when it was
|
|
/// sealed: its joules, its money and its tainted accrual. A hold is read
|
|
/// against the whole log, so the log is read as one stream: a hold no entry
|
|
/// settles reads as what a restart holds, the figure the runtime bounded it by.
|
|
#[test]
|
|
fn each_published_entry_reads_as_its_ledger_movement() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let log = entries(&doc);
|
|
let read = read_stream(log.iter().map(Vec::as_slice), &steward, &key);
|
|
assert_eq!(read.len(), log.len());
|
|
for ((entry, ledger), env) in doc["entries"].as_array().unwrap().iter().zip(read).zip(&log) {
|
|
let l = ledger.expect("every published entry verifies");
|
|
assert_eq!([l.uj, l.minor, l.tainted_uj], publishes(entry), "{}", entry["what"]);
|
|
let core = extract(env, &steward, &key).unwrap();
|
|
assert_eq!(reads_mints(&core), published_mints(entry), "{}", entry["what"]);
|
|
}
|
|
}
|
|
|
|
/// The firings in the log rebuild each standing grant's mint ledger the
|
|
/// firings left, and the child's call between them is no firing.
|
|
#[test]
|
|
fn replaying_the_published_log_rebuilds_the_mint_ledger() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let log = entries(&doc);
|
|
let minted = replay_mints(log.iter().map(Vec::as_slice), &steward, &key);
|
|
let listed = doc["mints_after"].as_object().expect("the log publishes mints_after");
|
|
assert!(!listed.is_empty(), "a wrong path must not look like a pass");
|
|
for (grant, want) in listed {
|
|
let id = ContentId::from_hex(grant).unwrap();
|
|
let rebuilt = MintLedger::of_firings(minted.iter().filter(|(g, _)| *g == id).map(|(_, uj)| *uj));
|
|
let want = MintLedger { fires: want["fires"].as_u64().unwrap(), spent_uj: want["uj"].as_u64().unwrap() };
|
|
assert_eq!(rebuilt, want, "{grant}");
|
|
let mut stale = MintLedger { fires: 1, spent_uj: 0 };
|
|
stale.restore(&rebuilt);
|
|
assert_eq!(stale, want, "a stale copy restored beside the stream");
|
|
}
|
|
assert_eq!(minted.len() as u64, listed.values().map(|v| v["fires"].as_u64().unwrap()).sum::<u64>());
|
|
}
|
|
|
|
/// The holds in the log: the one an entry settles reads as nothing alone,
|
|
/// and the two nothing settles are what `unresolved_holds` names.
|
|
#[test]
|
|
fn the_published_holds_read_as_the_log_settles_them() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let log = entries(&doc);
|
|
let holds: Vec<usize> = (0..log.len()).filter(|&i| extract(&log[i], &steward, &key).unwrap().is_hold()).collect();
|
|
assert_eq!(holds.len(), 3, "a settled hold, an unsettled one, and an unsettled tainted one");
|
|
let unresolved = jcp_receipt::unresolved_holds(log.iter().map(Vec::as_slice), &steward, &key);
|
|
assert_eq!(unresolved, holds[1..].iter().map(|&i| ContentId::of_bytes(&log[i])).collect::<Vec<_>>());
|
|
let settle = extract(&log[holds[0] + 1], &steward, &key).unwrap();
|
|
assert_eq!(settle.settles, Some(ContentId::of_bytes(&log[holds[0]])));
|
|
for &i in &holds {
|
|
let core = extract(&log[i], &steward, &key).unwrap();
|
|
assert!(core.debits().is_empty() && core.tainted_accruals().is_empty(), "a hold alone debits nothing");
|
|
}
|
|
}
|
|
|
|
/// The whole log restores the ledgers the runtime ended with, grant by grant,
|
|
/// in every dimension, and a grant the pack lists nothing for holds nothing.
|
|
#[test]
|
|
fn replaying_the_published_log_restores_the_ledger() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let log = entries(&doc);
|
|
let mut rt = Runtime::new();
|
|
rt.restore_ledger(replay_ledger(log.iter().map(Vec::as_slice), &steward, &key));
|
|
|
|
let held = |total: &str, g: &ContentId| match total {
|
|
"spent_after" => rt.spent(g),
|
|
"money_after" => rt.spent_minor(g),
|
|
_ => rt.spent_tainted(g),
|
|
};
|
|
let budgets = doc["grants"].as_object().unwrap();
|
|
for total in ["spent_after", "money_after", "tainted_after"] {
|
|
let listed = doc[total].as_object().unwrap_or_else(|| panic!("the log publishes {total}"));
|
|
assert!(!listed.is_empty(), "{total}");
|
|
for g in budgets.values() {
|
|
let id = g["id_hex"].as_str().unwrap();
|
|
let want = listed.get(id).map_or(0, |v| v.as_u64().unwrap());
|
|
assert_eq!(held(total, &ContentId::from_hex(id).unwrap()), want, "{total} {id}");
|
|
}
|
|
}
|
|
for g in budgets.values() {
|
|
let id = ContentId::from_hex(g["id_hex"].as_str().unwrap()).unwrap();
|
|
assert!(rt.spent(&id) <= g["budget_uj"].as_u64().unwrap(), "replay passed a joule ceiling on {id:?}");
|
|
if let Some(minor) = g["budget_funds"]["minor"].as_u64() {
|
|
assert!(rt.spent_minor(&id) <= minor, "replay passed a money ceiling on {id:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The log keeps each form the reading rules distinguish, so no rule can stop
|
|
/// being exercised by a later regeneration.
|
|
#[test]
|
|
fn the_published_log_holds_each_form() {
|
|
let doc = pack_file("replay.json");
|
|
let wires: Vec<&Value> = doc["entries"].as_array().unwrap().iter().map(|e| &e["wire"]).collect();
|
|
let denial = |w: &&&Value| w["verdict"] == "deny";
|
|
let stated = |w: &Value| w.get("debited_uj").and_then(Value::as_u64);
|
|
assert!(wires.iter().any(|w| w["verdict"] == "allow" && stated(w).is_none()), "a plain allow");
|
|
assert!(wires.iter().filter(denial).any(|w| stated(w).is_some_and(|uj| uj > 0)), "a denial that debited");
|
|
assert!(wires.iter().filter(denial).any(|w| stated(w) == Some(0)), "a refusal stating its zero");
|
|
assert!(wires.iter().filter(denial).any(|w| stated(w).is_none()), "a refusal stating nothing");
|
|
assert!(wires.iter().any(|w| stated(w).is_some_and(|uj| uj > 0 && uj < w["cost_uj"].as_u64().unwrap())), "a cap");
|
|
assert!(wires.iter().any(|w| w.get("chain_ids").is_some()), "a chained receipt");
|
|
assert!(wires.iter().any(|w| w.get("chain_debited_uj").is_some()), "links debited differently");
|
|
assert!(wires.iter().any(|w| w.get("debited_minor").is_some()), "a money debit");
|
|
assert!(wires.iter().any(|w| w.get("chain_debited_minor").is_some()), "links debited money differently");
|
|
let flow = |w: &&&Value| w.get("flow").is_some();
|
|
assert!(wires.iter().filter(flow).any(|w| w["verdict"] == "allow" && w.get("tainted_uj").is_none()), "an accrual its flow block says");
|
|
assert!(wires.iter().filter(flow).any(|w| w["verdict"] == "deny" && w["cost_uj"].as_u64() > Some(0)), "a tainted refusal with a cost");
|
|
assert!(wires.iter().any(|w| w.get("tainted_uj").is_some() && w.get("flow").is_none()), "an accrual stated");
|
|
let hold = |w: &&&Value| w.get("held_uj").is_some() && w["verdict"] == "allow";
|
|
assert_eq!(wires.iter().filter(hold).count(), 3, "holds: settled, unsettled, unsettled and tainted");
|
|
assert!(wires.iter().filter(hold).any(|w| w.get("flow").is_some()), "a tainted hold");
|
|
assert!(wires.iter().any(|w| w.get("settles").is_some()), "a settle");
|
|
let firing = |w: &&&Value| w.get("minted_uj").is_some() && w["verdict"] == "allow";
|
|
assert_eq!(wires.iter().filter(firing).count(), 2, "two firings of one standing grant");
|
|
let standing = wires.iter().find(firing).map(|w| w["grant_id"].clone()).unwrap();
|
|
assert!(
|
|
wires.iter().any(|w| w.get("minted_uj").is_none() && w.get("chain_ids").is_some_and(|c| c[0] == standing)),
|
|
"a call under a child a firing minted"
|
|
);
|
|
assert!(wires.iter().all(|w| w.get("seq").is_some() && w.get("prev").is_some()), "a log's links");
|
|
}
|
|
|
|
/// A published debit changed in place fails the signature, and the log breaks
|
|
/// at that entry.
|
|
#[test]
|
|
fn a_debit_changed_on_a_published_receipt_is_refused() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let log = entries(&doc);
|
|
for member in ["debited_uj", "debited_minor", "tainted_uj", "held_uj", "settles", "minted_uj"] {
|
|
let stated: Vec<usize> = doc["entries"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| e["wire"].get(member).is_some())
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
assert!(!stated.is_empty(), "{member}");
|
|
// The key as CBOR text: its length in the header, then the bytes.
|
|
let name = [&[0x60 | member.len() as u8][..], member.as_bytes()].concat();
|
|
for i in stated {
|
|
let mut changed = log.clone();
|
|
let at = changed[i].windows(name.len()).position(|w| w == name).unwrap() + name.len();
|
|
changed[i][at] ^= 0x01;
|
|
assert!(extract(&changed[i], &steward, &key).is_err(), "{member} on entry {i}");
|
|
assert_eq!(verify_log(&changed, &steward, &key), Err(i));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An entry cannot leave the log: its `prev` ties the next one to it.
|
|
#[test]
|
|
fn removing_a_published_entry_breaks_the_log() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let mut log = entries(&doc);
|
|
let breach = doc["entries"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.position(|e| e["wire"]["verdict"] == "deny" && e["wire"].get("debited_uj").and_then(Value::as_u64).is_some_and(|uj| uj > 0))
|
|
.expect("the log holds a breach");
|
|
log.remove(breach);
|
|
assert_eq!(verify_log(&log, &steward, &key), Err(breach));
|
|
}
|
|
|
|
/// The published receipts in the form sealed before a member verify and state
|
|
/// none of it. Without `debited_uj` a breach and a refusal read alike, as the
|
|
/// most either can have debited; without `debited_minor` or `tainted_uj` a
|
|
/// receipt reads as no money and no accrual, which it cannot show.
|
|
#[test]
|
|
fn the_published_receipts_without_a_member_read_as_published() {
|
|
let doc = pack_file("replay.json");
|
|
let (steward, key) = steward_and_key();
|
|
let legacy = doc["legacy"].as_array().expect("the pack lists them");
|
|
let before = |m: &str| legacy.iter().filter(|e| e["sealed_before"] == m).count();
|
|
assert_eq!((before("debited_uj"), before("debited_minor"), before("tainted_uj"), before("settles")), (2, 1, 1, 1));
|
|
for entry in legacy {
|
|
let core = extract(&envelope(entry), &steward, &key).expect("still readable");
|
|
let what = &entry["what"];
|
|
assert_eq!(reads(&core), publishes(entry), "{what}");
|
|
match entry["sealed_before"].as_str().unwrap() {
|
|
"debited_uj" => {
|
|
assert_eq!((core.debited_uj, core.verdict.as_str()), (None, "deny"), "{what}");
|
|
assert_eq!(core.debits()[0].1, core.cost_uj);
|
|
}
|
|
"debited_minor" => assert_eq!(core.debited_minor, None, "{what}"),
|
|
"tainted_uj" => assert_eq!(core.tainted_uj, None, "{what}"),
|
|
"settles" => assert_eq!(core.settles, None, "{what}"),
|
|
other => panic!("an unknown member {other}"),
|
|
}
|
|
}
|
|
}
|