//! **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 = 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"); } }