All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 6m16s
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]>
319 lines
14 KiB
Rust
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");
|
|
}
|
|
}
|