open-standards/jcp/jcp-rs/crates/jcp-receipt/tests/crash_points.rs
Transaction Science 910b359d4d
All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 6m16s
Payments: a capture reversal through a delegation chain, and across a restart
A reversal returned money on a lone grant only, and a hold taken up again
after a restart began with nothing drawn, so a capture made before the
restart could no longer be reversed after it.

- Runtime: reverse_capture_chained returns the money to the hold on every
  link that carries a money ceiling, checking every link before changing
  any, so it is refused whole or applied whole. reverse_capture is now the
  one-link case of the same rule. adopt_hold takes what captures drew and
  reversals have not returned.
- Receipts: append_reversal_chained; an open hold reports drawn_minor, the
  money its captures drew net of reversals.
- Agreed terms: a reopened quote-bound hold carries drawn_minor, and
  ConditionalHolds::reverse takes a reversed part out of what counts as
  paid toward the payee's minimum.
- SPEC 8.1: a reversal applies to a chain as to a lone grant; a hold taken
  up again keeps what was drawn. C32 checks the chain case.
- lifecycle.json gains a delegate's hold captured, reversed through the
  chain and settled; verify_lifecycle.py has a fault for a reader that
  returns a chained reversal on the leaf only, and refuses it.

Seeded faults, each caught: a chained reversal on the leaf only; a
reopened hold forgetting what was drawn; a quote-bound reversal still
counted as paid; a restore recounting drawn money; a chained reversal
past what one link drew.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:08:09 -05:00

319 lines
14 KiB
Rust

//! **Every prefix of a receipt log is a crash point.**
//!
//! An enforcement point that stops — a crash, a deploy, a power cut — comes
//! back holding only what its receipt log says (SPEC §4.11, §8.1). The replay
//! tests elsewhere check that at points someone chose. This checks it at all
//! of them: random sequences of the whole priced lifecycle are run against a
//! live runtime, every decision is sealed, and **after every single step** the
//! log so far is replayed into a fresh runtime that must stand exactly where
//! the live one does.
//!
//! "Exactly" has one deliberate asymmetry, and it is the specification's: a
//! hold nothing has settled restores as *debited* what it still reserves, so
//! the restored spend is the live spend plus the live reservation, in both
//! dimensions. A restart must never hand an interrupted charge's room to
//! someone else.
//!
//! The generators are deliberately irregular. A test whose amounts are all
//! drawn from one small uniform range exercises the middle of every rule and
//! none of its edges; these mix in the boundary values each rule turns on —
//! nothing, exactly what is left, one more than what is left, the wrong
//! currency, a moment either side of a deadline.
use ed25519_dalek::SigningKey;
use jcp_grant::issue;
use jcp_receipt::{replay_ledger, unresolved_holds, verify_log, FlowSeal, ReceiptLog};
use jcp_runtime::{HoldId, Price, Runtime};
use jcp_schema::flow::FlowFacts;
use jcp_schema::{Capability, Caveat, ContentId, Did, Energy, Frame, FrameKind, Funds, Grant, SignedGrant, Trust};
const STEWARD: &str = "did:example:crash-points";
fn sk(s: u8) -> SigningKey {
SigningKey::from_bytes(&[s; 32])
}
fn did(s: u8) -> Did {
Did::from_verifying_key(&sk(s).verifying_key())
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: u64) -> u64 {
if n == 0 { 0 } else { self.next() % n }
}
/// An amount near `edge`: mostly the values a bound turns on, sometimes
/// anything at all.
fn around(&mut self, edge: u64) -> u64 {
match self.below(7) {
0 => 0,
1 => edge,
2 => edge.saturating_add(1),
3 => edge.saturating_sub(1),
4 => edge / 2,
5 => edge.saturating_mul(3),
_ => self.below(edge.saturating_add(50)),
}
}
fn currency(&mut self) -> &'static str {
// mostly the grant's own, sometimes not
if self.below(9) == 0 { "EUR" } else { "USD" }
}
}
fn grant(seed: u64) -> SignedGrant {
issue(
&sk(1),
Grant {
issuer: did(1),
subject: did(2),
capability: Capability::new(ContentId::of_bytes(b"crash"), ["pay"]),
budget_uj: 1_500,
budget_funds: Some(Funds::new("USD", 1_000)),
// half the grants state a per-call ceiling, so both the concurrent
// and the serializing shape are walked
caveats: if seed.is_multiple_of(2) { vec![Caveat::MaxCostUj(120)] } else { Vec::new() },
expiry: 0,
parent: None,
delegate_depth: None,
nonce: [seed as u8; 16],
},
)
.unwrap()
}
fn frame(sg: &SignedGrant) -> Frame {
Frame {
kind: FrameKind::ToolCall,
trust: Trust::Control,
grant_id: sg.grant.id(),
action: "pay".into(),
resource: ContentId::of_bytes(b"crash"),
payload_hash: ContentId::of_bytes(b"charge"),
model: None,
}
}
/// Replay the log so far into a fresh runtime and require it to stand where
/// the live one does.
fn check(live: &Runtime, log: &ReceiptLog, gid: &ContentId, seed: u64, step: usize, what: &str) {
let key = sk(7).verifying_key().to_bytes();
let entries = log.entries();
let mut back = Runtime::new();
back.restore_ledger(replay_ledger(entries.iter().map(Vec::as_slice), STEWARD, &key));
let at = format!("seed {seed} step {step} after {what}");
assert_eq!(
back.spent(gid),
live.spent(gid) + live.held(gid),
"{at}: restored joules are not the live spend plus what is still reserved"
);
assert_eq!(
back.spent_minor(gid),
live.spent_minor(gid) + live.held_minor(gid),
"{at}: restored money is not the live debit plus what is still reserved"
);
assert_eq!(back.refunded_minor(gid), live.refunded_minor(gid), "{at}: refunds");
assert_eq!(
unresolved_holds(entries.iter().map(Vec::as_slice), STEWARD, &key).len(),
live.outstanding_holds(gid),
"{at}: the log and the runtime disagree about which holds are outstanding"
);
}
#[test]
fn a_restart_at_any_point_stands_where_the_live_runtime_stood() {
let facts = FlowFacts::bottom();
let clean = FlowSeal::clean();
for seed in 1..=60u64 {
let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
let sg = grant(seed);
let (g, gid, f) = (&sg.grant, sg.grant.id(), frame(&sg));
let mut rt = Runtime::new();
let mut log = ReceiptLog::new(sk(7), STEWARD);
// (runtime handle, the hold receipt's id)
let mut out: Vec<(HoldId, ContentId)> = Vec::new();
// (hold, the capture's receipt, what it drew and has not been returned)
let mut captured: Vec<(HoldId, ContentId, u64)> = Vec::new();
for step in 0..36 {
let now = 10 + rng.below(40);
let what = match rng.below(9) {
// the rail declines a capture: return some of what a capture drew
8 if !captured.is_empty() => {
let i = rng.below(captured.len() as u64) as usize;
let (id, receipt, drawn) = captured[i];
let amount = Price::new(rng.currency(), rng.around(drawn));
let d = rt.reverse_capture(g, id, &amount);
log.append_reversal(&f, g, &d, receipt, amount.minor);
if d.is_allow() {
captured[i].2 = drawn - amount.minor;
}
"a reversal"
}
0 | 1 => {
let free = 1_000u64.saturating_sub(rt.net_minor(&gid) + rt.held_minor(&gid));
let ceiling = Price::new(rng.currency(), rng.around(free));
let (d, h) = match rng.below(2) {
0 => rt.hold_priced(&f, &sg, did(2), now, &facts, &ceiling),
_ => rt.hold_priced_until(&f, &sg, did(2), now, &facts, &ceiling, 30),
};
let (uj, minor) = h.map_or((0, 0), |h| (h.uj, h.minor));
log.append_hold(&f, g, &d, &clean, uj, minor);
if let Some(h) = h {
out.push((h.id, log.head()));
}
"a hold"
}
2 if !out.is_empty() => {
let (id, receipt) = out[rng.below(out.len() as u64) as usize];
let left = rt.hold_remaining(&gid, id).map_or(0, |(_, m)| m);
let amount = Price::new(rng.currency(), rng.around(left));
let d = rt.capture_priced(g, id, Energy::measured(rng.around(60)), &amount, now, &facts);
log.append_capture(&f, g, &d, &clean, receipt);
if d.is_allow() && amount.minor > 0 {
captured.push((id, log.head(), amount.minor));
}
"a capture"
}
3 if !out.is_empty() => {
let i = rng.below(out.len() as u64) as usize;
let (id, receipt) = out[i];
let left = rt.hold_remaining(&gid, id).map_or(0, |(_, m)| m);
let settled = Price::new(rng.currency(), rng.around(left));
let d = rt.settle_priced(g, id, Energy::measured(rng.around(130)), &settled, &facts);
log.append_settle(&f, g, &d, &clean, receipt);
if d.consumed_the_hold() {
out.remove(i);
}
"a settle"
}
4 if !out.is_empty() => {
let (id, receipt) = out[rng.below(out.len() as u64) as usize];
let (d, _) = rt.reclaim(g, id, now);
log.append_reclaim(&f, g, &d, receipt);
"a reclaim"
}
5 => {
let amount = rng.around(rt.net_minor(&gid));
let who = if rng.below(4) == 0 { did(2) } else { did(1) };
let d = rt.refund(g, who, &Price::new(rng.currency(), amount));
log.append_refund(&f, g, &d, amount);
"a refund"
}
_ => {
let free = 1_000u64.saturating_sub(rt.net_minor(&gid) + rt.held_minor(&gid));
let d = rt.decide_priced(
&f,
&sg,
did(2),
Energy::measured(rng.around(40)),
&Price::new(rng.currency(), rng.around(free)),
now,
);
log.append(&f, g, &d);
"a priced call"
}
};
check(&rt, &log, &gid, seed, step, what);
}
let key = sk(7).verifying_key().to_bytes();
assert_eq!(verify_log(log.entries(), STEWARD, &key), Ok(log.entries().len()), "seed {seed}: the chain");
}
}
/// The case the random walk found, kept on its own so it cannot be lost to a
/// change of seed: a settlement refused before it took its hold names the
/// hold it was aimed at and settles nothing. The reservation is still live,
/// so a restart must still restore it.
#[test]
fn a_refused_settlement_leaves_its_hold_in_the_log() {
let facts = FlowFacts::bottom();
let clean = FlowSeal::clean();
let sg = grant(2);
let (g, gid, f) = (&sg.grant, sg.grant.id(), frame(&sg));
let mut rt = Runtime::new();
let mut log = ReceiptLog::new(sk(7), STEWARD);
let (d, h) = rt.hold_priced(&f, &sg, did(2), 10, &facts, &Price::new("USD", 400));
let h = h.expect("the hold is within both ceilings");
log.append_hold(&f, g, &d, &clean, h.uj, h.minor);
let receipt = log.head();
check(&rt, &log, &gid, 0, 0, "the hold");
// the wrong currency: refused, and the hold is untouched
let d = rt.settle_priced(g, h.id, Energy::measured(50), &Price::new("EUR", 100), &facts);
assert!(!d.consumed_the_hold());
log.append_settle(&f, g, &d, &clean, receipt);
assert_eq!(rt.outstanding_holds(&gid), 1);
check(&rt, &log, &gid, 0, 1, "the refused settlement");
// the same hold then settles for real, once
let d = rt.settle_priced(g, h.id, Energy::measured(50), &Price::new("USD", 100), &facts);
assert!(d.consumed_the_hold());
log.append_settle(&f, g, &d, &clean, receipt);
assert_eq!(rt.outstanding_holds(&gid), 0);
check(&rt, &log, &gid, 0, 2, "the settlement");
// and a second resolution of it is refused without unsettling anything
let d = rt.settle_priced(g, h.id, Energy::measured(50), &Price::new("USD", 100), &facts);
assert!(!d.consumed_the_hold());
log.append_settle(&f, g, &d, &clean, receipt);
check(&rt, &log, &gid, 0, 3, "the repeated settlement");
}
/// A reversal through a delegation chain returns the money on every funded
/// link, and a restart replaying the log restores every link as the live
/// runtime has it.
#[test]
fn a_chained_reversal_replays_on_every_link() {
use jcp_grant::attenuate_priced;
let parent = grant(4);
let child = attenuate_priced(
&parent,
&sk(2),
did(3),
Capability::new(ContentId::of_bytes(b"crash"), ["pay"]),
1_200,
Some(Funds::new("USD", 800)),
0,
[9; 16],
).unwrap();
let chain = vec![parent.clone(), child.clone()];
let ids: Vec<ContentId> = chain.iter().map(|sg| sg.grant.id()).collect();
let f = Frame { grant_id: child.grant.id(), ..frame(&child) };
let mut rt = Runtime::new();
let mut log = ReceiptLog::new(sk(7), STEWARD);
let (d, h) = rt.hold_chained_priced(&f, &chain, did(3), 10, &Price::new("USD", 500));
let h = h.expect("a chained hold");
log.append_hold_chained(&f, &ids, &child.grant, &d, h.uj, h.minor);
let hold = log.head();
let d = rt.capture_chained_priced(&chain, h.id, Energy::measured(30), &Price::new("USD", 200), 11);
assert!(d.is_allow());
log.append_capture_chained(&f, &ids, &child.grant, &d, hold);
let capture = log.head();
// more than was drawn is refused whole: neither link changes
let over = rt.reverse_capture_chained(&chain, h.id, &Price::new("USD", 201));
assert!(!over.is_allow());
log.append_reversal_chained(&f, &ids, &child.grant, &over, capture, 201);
let d = rt.reverse_capture_chained(&chain, h.id, &Price::new("USD", 150));
assert!(d.is_allow());
log.append_reversal_chained(&f, &ids, &child.grant, &d, capture, 150);
for id in &ids {
assert_eq!((rt.spent_minor(id), rt.held_minor(id)), (50, 450), "the live runtime, on every link");
}
let key = sk(7).verifying_key().to_bytes();
let mut back = Runtime::new();
back.restore_ledger(replay_ledger(log.entries().iter().map(Vec::as_slice), STEWARD, &key));
for id in &ids {
assert_eq!(back.spent_minor(id), 50 + 450, "a restart restores the capture's 50 and the 450 still reserved");
assert_eq!(back.spent(id), rt.spent(id) + rt.held(id), "and the joules as they were");
}
}