All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 4m46s
The chained path lagged the single-grant one — no capture, no deadline, no reclaim, and a hold that reserved the shared parent's whole joule remainder so that sibling delegates queued. The cause was structural rather than an oversight: the lifecycle was written twice, once per grant and once per chain, so each feature landed in one and drifted in the other. Delegation chains are the case that matters going forward — an agent handing metered work to sub-agents, all of them running at once — so this is fixed at the cause. The money side of a capture and of a reclaim is now one implementation over a list of links, and a lone grant is a chain of one. The refusals that come before anything moves, and what is drawn from which reservation, are stated once. A rule added to the lifecycle from here on cannot hold for a lone grant and be missing for a delegated one, and the specification now says an implementation should decide it that way. On top of that core: - `capture_chained_priced` draws from and debits every link, and is refused if any link has less reserved than it asks. - `hold_chained_priced_until` and `reclaim_chained`: past the deadline the money is released on every link and the joules on none. - A chained hold reserves the tightest per-invocation ceiling any link states, from the committed spend. That is what lets siblings run metered work at once: each reserves its bound, and the parent admits as many as fit and still bounds them together in both dimensions. Tested the way that has kept finding things: two delegates under one parent holding, capturing, settling and reclaiming in random order, with the parent's ceilings checked after every step and its reservations required to be exactly the sum of what its delegates still hold, and the parent's money spend exactly the sum of theirs. It passed on its first run, which is the shared core doing its job — the single-grant randomised model had already been walking the same code. Receipts gain the chained capture and reclaim forms, and a replay restores them per link by the same rules. C32 covers siblings at once. Also: `JcpGate` now reports a settlement under a mandate's `amount_range.min`. A metered charge can come in under the least the user authorized; settling nothing is not a payment and passes, but a payment below the floor is outside the mandate and is said to be. Co-Authored-By: Claude <[email protected]>
1468 lines
66 KiB
Rust
1468 lines
66 KiB
Rust
//! SPEC §8.1: a crash cannot refund a debit.
|
|
//!
|
|
//! Each test runs real decisions through a runtime, drops it, and rebuilds a
|
|
//! fresh one from nothing but the sealed receipts.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use jcp_grant::{attenuate, issue};
|
|
use jcp_grant::standing::{issue_policy, mint_on_trigger, MintError, MintLedger, SignedMintPolicy, TriggerEvent};
|
|
use jcp_receipt::{
|
|
aggregate, extract, jcr1, replay_ledger, replay_mints, replay_spend, seal, seal_chained, to_jcr1_wire,
|
|
to_jcr1_wire_chained, verify, verify_chained, verify_log, FlowSeal, ReceiptError, ReceiptLog,
|
|
};
|
|
use jcp_runtime::{Decision, Runtime, Price};
|
|
use jcp_schema::{
|
|
Capability, Caveat, ContentId, DenyReason, Did, Energy, FlowFacts, Frame, FrameKind, Grant, Integrity, Label,
|
|
Origin, Origins, Provenance, Readers, SignedGrant, Trust, Verdict, Window, WindowLimit,
|
|
};
|
|
use serde_json::{json, Value};
|
|
|
|
const STEWARD: &str = "did:example:replay";
|
|
|
|
fn sk(s: u8) -> SigningKey {
|
|
SigningKey::from_bytes(&[s; 32])
|
|
}
|
|
|
|
fn did(k: &SigningKey) -> Did {
|
|
Did::from_verifying_key(&k.verifying_key())
|
|
}
|
|
|
|
fn resource() -> ContentId {
|
|
ContentId::of_bytes(b"db")
|
|
}
|
|
|
|
fn grant(budget_uj: u64, caveats: Vec<Caveat>) -> SignedGrant {
|
|
issue(
|
|
&sk(1),
|
|
Grant {
|
|
issuer: did(&sk(1)),
|
|
subject: did(&sk(2)),
|
|
capability: Capability::new(resource(), ["read"]),
|
|
budget_uj,
|
|
budget_funds: None,
|
|
caveats,
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [7; 16],
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn frame(sg: &SignedGrant) -> Frame {
|
|
Frame {
|
|
kind: FrameKind::ToolCall,
|
|
trust: Trust::Control,
|
|
grant_id: sg.grant.id(),
|
|
action: "read".into(),
|
|
resource: resource(),
|
|
payload_hash: ContentId::of_bytes(b"q"),
|
|
model: None,
|
|
}
|
|
}
|
|
|
|
fn log() -> ReceiptLog {
|
|
ReceiptLog::new(sk(7), STEWARD)
|
|
}
|
|
|
|
/// A fresh runtime holding only what the stream replays to, in every
|
|
/// dimension it records.
|
|
fn restored(entries: &[Vec<u8>]) -> Runtime {
|
|
let mut rt = Runtime::new();
|
|
rt.restore_ledger(replay_ledger(entries.iter().map(Vec::as_slice), STEWARD, &pubkey()));
|
|
rt
|
|
}
|
|
|
|
fn pubkey() -> [u8; 32] {
|
|
sk(7).verifying_key().to_bytes()
|
|
}
|
|
|
|
fn joule_window(max: u64) -> Caveat {
|
|
Caveat::WithinWindow(Window { secs: 60, limit: WindowLimit::MaxUj(max) })
|
|
}
|
|
|
|
/// A root under `caveats` and a delegate of it holding `leaf_budget`.
|
|
fn chain(root_budget: u64, leaf_budget: u64, caveats: Vec<Caveat>) -> (Vec<SignedGrant>, Vec<ContentId>) {
|
|
let root = grant(root_budget, caveats);
|
|
let kid = attenuate(&root, &sk(2), did(&sk(3)), Capability::new(resource(), ["read"]), leaf_budget, 0, [8; 16]).unwrap();
|
|
let links = vec![root, kid];
|
|
let ids = links.iter().map(|sg| sg.grant.id()).collect();
|
|
(links, ids)
|
|
}
|
|
|
|
/// The two-phase path: a call whose measured cost breaches `MaxCostUj`. The
|
|
/// work ran, so the joules are debited and the verdict is a denial (§4.9).
|
|
#[test]
|
|
fn a_cost_breach_survives_a_crash() {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let mut log = log();
|
|
|
|
let mut rt = Runtime::new();
|
|
assert!(rt.authorize(&f, &sg, holder, 10).is_allow());
|
|
let d = rt.charge(&sg.grant, Energy::measured(900));
|
|
assert!(!d.is_allow(), "the breach is a denial");
|
|
assert_eq!(rt.spent(&sg.grant.id()), 900, "and it stays debited");
|
|
log.append(&f, &sg.grant, &d);
|
|
drop(rt);
|
|
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 900, "the crash refunded the breach");
|
|
}
|
|
|
|
/// The same for a joule window (§4.12).
|
|
#[test]
|
|
fn a_window_breach_survives_a_crash() {
|
|
let sg = grant(1_000, vec![joule_window(100)]);
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let mut log = log();
|
|
|
|
let mut rt = Runtime::new();
|
|
assert!(rt.authorize(&f, &sg, holder, 10).is_allow());
|
|
let d = rt.charge(&sg.grant, Energy::measured(400));
|
|
assert!(!d.is_allow(), "the overrun is a denial");
|
|
assert_eq!(rt.spent(&sg.grant.id()), 400);
|
|
log.append(&f, &sg.grant, &d);
|
|
drop(rt);
|
|
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 400, "the crash refunded the overrun");
|
|
}
|
|
|
|
/// A tainted call seals `jcp/3`, the same core with a flow block, and a breach
|
|
/// there debits as any other does.
|
|
#[test]
|
|
fn a_tainted_breach_survives_a_crash() {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let facts = FlowFacts {
|
|
label: Label {
|
|
integrity: Integrity::Untrusted,
|
|
origins: Origins::one(Origin::channel(ContentId::of_bytes(b"mail:read"))),
|
|
readers: Readers::Public,
|
|
},
|
|
witness_id: ContentId::of_bytes(b"a witness"),
|
|
eval_uj: 42,
|
|
eval_prov: Provenance::ModelBased,
|
|
};
|
|
let mut rt = Runtime::new();
|
|
assert!(rt.authorize_flow(&f, &sg, sg.grant.subject, 10, &facts).is_allow());
|
|
let d = rt.charge_flow(&sg.grant, Energy::measured(900), &facts);
|
|
assert!(!d.is_allow());
|
|
let mut log = log();
|
|
log.append_flow(&f, &sg.grant, &d, &FlowSeal { facts: Some(facts), ceiling: None });
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 900, "the crash refunded the breach");
|
|
}
|
|
|
|
/// Over a chain, a breach debits every link, ancestors included.
|
|
#[test]
|
|
fn a_chained_breach_survives_a_crash_on_every_link() {
|
|
let (links, ids) = chain(10_000, 5_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&links[1]);
|
|
|
|
let mut rt = Runtime::new();
|
|
assert!(rt.authorize_chained(&f, &links, did(&sk(3)), 10).is_allow());
|
|
let d = rt.charge_chained(&links, Energy::measured(900));
|
|
assert!(!d.is_allow(), "the breach is a denial");
|
|
let env = seal_chained(&f, &ids, &links[1].grant, &d, &sk(7), STEWARD).unwrap();
|
|
drop(rt);
|
|
|
|
let back = restored(&[env]);
|
|
assert_eq!(back.spent(&ids[1]), 900, "the leaf's debit was refunded");
|
|
assert_eq!(back.spent(&ids[0]), 900, "the ancestor's debit was refunded");
|
|
}
|
|
|
|
/// A refusal debits nothing (C3), though `decide` seals the cost it refused, and
|
|
/// a replay must not find a debit there. Both reasons a breach also carries.
|
|
#[test]
|
|
fn a_refusal_restores_nothing() {
|
|
let caveated = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let windowed = grant(1_000, vec![joule_window(100)]);
|
|
let mut log = log();
|
|
let mut rt = Runtime::new();
|
|
for sg in [&caveated, &windowed] {
|
|
let f = frame(sg);
|
|
let d = rt.decide(&f, sg, sg.grant.subject, Energy::measured(900), 10);
|
|
assert!(!d.is_allow());
|
|
assert_eq!(d.cost.uj(), 900, "the refused cost is on the receipt");
|
|
assert_eq!(rt.spent(&sg.grant.id()), 0, "C3");
|
|
log.append(&f, &sg.grant, &d);
|
|
}
|
|
assert!(replay_spend(log.entries().iter().map(Vec::as_slice), STEWARD, &pubkey()).is_empty());
|
|
}
|
|
|
|
/// The same refusal over a chain: no link was debited.
|
|
#[test]
|
|
fn a_chained_refusal_restores_nothing() {
|
|
let (links, ids) = chain(10_000, 5_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&links[1]);
|
|
let d = Runtime::new().decide_chained(&f, &links, did(&sk(3)), Energy::measured(900), 10);
|
|
assert_eq!(d.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied));
|
|
let env = seal_chained(&f, &ids, &links[1].grant, &d, &sk(7), STEWARD).unwrap();
|
|
assert!(replay_spend([env.as_slice()], STEWARD, &pubkey()).is_empty());
|
|
}
|
|
|
|
/// A two-phase debit saturates at the ceiling, so `cost_uj` is more than was
|
|
/// debited. Replay restores the debit: the grant lands on its ceiling, not past it.
|
|
#[test]
|
|
fn a_capped_debit_restores_what_was_debited() {
|
|
let sg = grant(100, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize(&f, &sg, sg.grant.subject, 10);
|
|
let d = rt.charge(&sg.grant, Energy::measured(150));
|
|
assert!(d.is_allow());
|
|
assert_eq!((d.cost.uj(), d.debited_uj, rt.spent(&sg.grant.id())), (150, 100, 100));
|
|
let mut log = log();
|
|
log.append(&f, &sg.grant, &d);
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 100);
|
|
}
|
|
|
|
/// Each link saturates at its own ceiling, so a capped debit leaves the links
|
|
/// differing, and one figure on the receipt could not be exact for both.
|
|
#[test]
|
|
fn a_capped_chain_restores_each_links_own_debit() {
|
|
let (links, ids) = chain(10_000, 100, Vec::new());
|
|
let f = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize_chained(&f, &links, did(&sk(3)), 10);
|
|
let d = rt.charge_chained(&links, Energy::measured(150));
|
|
assert!(d.is_allow());
|
|
assert_eq!(d.chain_debited_uj, vec![150, 100], "root first");
|
|
assert_eq!(d.debited_uj, 100, "the leaf's");
|
|
let mut log = log();
|
|
log.append_chained(&f, &ids, &links[1].grant, &d);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (rt.spent(&ids[0]), rt.spent(&ids[1])));
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (150, 100));
|
|
}
|
|
|
|
/// The ancestor can be the link that binds: siblings share it, so a leaf with
|
|
/// room left is debited more than a root at its ceiling.
|
|
#[test]
|
|
fn a_chain_capped_at_its_root_restores_each_links_own_debit() {
|
|
let (links, ids) = chain(100, 100, Vec::new());
|
|
let f = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
rt.restore([(ids[0], 60)]);
|
|
rt.resume_windows_at(0);
|
|
rt.authorize_chained(&f, &links, did(&sk(3)), 10);
|
|
let d = rt.charge_chained(&links, Energy::measured(70));
|
|
assert_eq!(d.chain_debited_uj, vec![40, 70]);
|
|
let mut log = log();
|
|
log.append_chained(&f, &ids, &links[1].grant, &d);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (40, 70));
|
|
}
|
|
|
|
/// Links that all saturate to one figure need no list: `debited_uj` says it.
|
|
#[test]
|
|
fn a_chain_capped_alike_states_one_figure() {
|
|
let (links, ids) = chain(100, 100, Vec::new());
|
|
let f = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize_chained(&f, &links, did(&sk(3)), 10);
|
|
let d = rt.charge_chained(&links, Energy::measured(150));
|
|
assert!(d.chain_debited_uj.is_empty());
|
|
let mut log = log();
|
|
log.append_chained(&f, &ids, &links[1].grant, &d);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (100, 100));
|
|
}
|
|
|
|
/// The whole property at once: a log of every kind of decision, across a chain
|
|
/// and two grants, restores the runtime's ledger to the microjoule, and the
|
|
/// restarted runtime carries on from it.
|
|
#[test]
|
|
fn a_replayed_log_is_the_ledger() {
|
|
let single = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let (links, ids) = chain(5_000, 900, vec![Caveat::MaxCostUj(300)]);
|
|
let delegate = did(&sk(3));
|
|
let (fs, fc) = (frame(&single), frame(&links[1]));
|
|
let leaf = &links[1].grant;
|
|
let holder = single.grant.subject;
|
|
let mut log = log();
|
|
let mut rt = Runtime::new();
|
|
|
|
let d = rt.decide(&fs, &single, holder, Energy::measured(40), 10);
|
|
log.append(&fs, &single.grant, &d);
|
|
let d = rt.decide(&fs, &single, holder, Energy::measured(800), 10);
|
|
log.append(&fs, &single.grant, &d);
|
|
rt.authorize(&fs, &single, holder, 11);
|
|
let d = rt.charge(&single.grant, Energy::measured(800));
|
|
log.append_flow(&fs, &single.grant, &d, &FlowSeal::clean());
|
|
|
|
let d = rt.decide_chained(&fc, &links, delegate, Energy::measured(200), 10);
|
|
log.append_chained(&fc, &ids, leaf, &d);
|
|
let d = rt.decide_chained(&fc, &links, delegate, Energy::measured(800), 10);
|
|
log.append_chained(&fc, &ids, leaf, &d);
|
|
rt.authorize_chained(&fc, &links, delegate, 11);
|
|
let d = rt.charge_chained(&links, Energy::measured(500));
|
|
log.append_chained(&fc, &ids, leaf, &d);
|
|
rt.authorize_chained(&fc, &links, delegate, 12);
|
|
let d = rt.charge_chained(&links, Energy::measured(250));
|
|
log.append_chained(&fc, &ids, leaf, &d);
|
|
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(7));
|
|
let mut back = restored(log.entries());
|
|
assert_eq!(rt.spent(&single.grant.id()), 840, "the fixture really debited");
|
|
assert_eq!((rt.spent(&ids[0]), rt.spent(&ids[1])), (950, 900), "and capped the leaf");
|
|
for g in [single.grant.id(), ids[0], ids[1]] {
|
|
assert_eq!(back.spent(&g), rt.spent(&g), "{}", g.to_hex());
|
|
}
|
|
|
|
// The next decision sees the same budget it would have seen.
|
|
let after = back.decide(&fs, &single, holder, Energy::measured(100), 20);
|
|
assert_eq!(after, rt.decide(&fs, &single, holder, Energy::measured(100), 20));
|
|
assert_eq!(after.budget_remaining_uj, 60);
|
|
}
|
|
|
|
/// §8.2: a worker answers for the joules of a breach, and for a capped debit's
|
|
/// figure, or the attribution stops matching the ledger.
|
|
#[test]
|
|
fn aggregation_matches_the_ledger() {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let mut log = log();
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide(&f, &sg, holder, Energy::measured(40), 10);
|
|
log.append(&f, &sg.grant, &d);
|
|
let d = rt.decide(&f, &sg, holder, Energy::measured(800), 10);
|
|
log.append(&f, &sg.grant, &d);
|
|
rt.authorize(&f, &sg, holder, 11);
|
|
let d = rt.charge(&sg.grant, Energy::measured(1_100));
|
|
log.append(&f, &sg.grant, &d);
|
|
|
|
let (totals, bad) = aggregate(log.entries().iter().map(Vec::as_slice), STEWARD, &pubkey());
|
|
assert_eq!(bad, 0);
|
|
let t = &totals[&(sg.grant.id().to_hex(), holder.to_did_key())];
|
|
assert_eq!((t.allows, t.denies), (1, 2));
|
|
assert_eq!(t.spent_uj, rt.spent(&sg.grant.id()));
|
|
assert_eq!(t.spent_uj, 1_000);
|
|
}
|
|
|
|
/// A receipt whose debit the verdict already says keeps the bytes it always had:
|
|
/// nothing that was sealed before this member, and nothing published, moves.
|
|
#[test]
|
|
fn a_debit_the_verdict_says_is_not_stated() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let mut rt = Runtime::new();
|
|
|
|
let allowed = rt.decide(&f, &sg, holder, Energy::measured(40), 10);
|
|
let wire = to_jcr1_wire(&f, &sg.grant, &allowed, STEWARD);
|
|
assert_eq!(wire.get("debited_uj"), None, "an allow that debited its cost");
|
|
|
|
let mut data = f.clone();
|
|
data.trust = Trust::Data;
|
|
let denied = rt.decide(&data, &sg, holder, Energy::measured(40), 10);
|
|
let wire = to_jcr1_wire(&data, &sg.grant, &denied, STEWARD);
|
|
assert_eq!(wire.get("debited_uj"), None, "a denial no debit point can seal");
|
|
|
|
let (links, ids) = chain(1_000, 500, Vec::new());
|
|
let fc = frame(&links[1]);
|
|
let d = rt.decide_chained(&fc, &links, did(&sk(3)), Energy::measured(40), 10);
|
|
let wire = to_jcr1_wire_chained(&fc, &ids, &links[1].grant, &d, STEWARD);
|
|
assert_eq!((wire.get("debited_uj"), wire.get("chain_debited_uj")), (None, None));
|
|
assert!(wire.get("chain_ids").is_some());
|
|
}
|
|
|
|
/// Either reason the debit point seals always states the debit, zero included.
|
|
/// That is what lets a reader tell a refusal from a receipt too old to say.
|
|
#[test]
|
|
fn a_refusal_for_a_reason_the_debit_point_seals_states_its_zero() {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide(&f, &sg, sg.grant.subject, Energy::measured(900), 10);
|
|
assert_eq!(to_jcr1_wire(&f, &sg.grant, &d, STEWARD).get("debited_uj"), Some(&json!(0)));
|
|
let env = seal(&f, &sg.grant, &d, &sk(7), STEWARD).unwrap();
|
|
assert_eq!(extract(&env, STEWARD, &pubkey()).unwrap().debited_uj, Some(0));
|
|
}
|
|
|
|
/// A breach's envelope and the byte offset of its stated debit's value.
|
|
fn breach() -> (Frame, SignedGrant, Decision, Vec<u8>, usize) {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize(&f, &sg, sg.grant.subject, 10);
|
|
let d = rt.charge(&sg.grant, Energy::measured(900));
|
|
let env = seal(&f, &sg.grant, &d, &sk(7), STEWARD).unwrap();
|
|
let key = b"jdebited_uj"; // CBOR text(10), then the key
|
|
let at = env.windows(key.len()).position(|w| w == key).expect("the core states the debit") + key.len();
|
|
assert_eq!(&env[at..at + 3], &[0x19, 0x03, 0x84], "uint16 900 follows the key");
|
|
(f, sg, d, env, at)
|
|
}
|
|
|
|
/// The debit is inside the signature: a stated figure changed on a sealed
|
|
/// receipt, to nothing or to more, makes the receipt fail, and a failed receipt
|
|
/// contributes nothing.
|
|
#[test]
|
|
fn a_debit_changed_on_a_signed_receipt_does_not_verify() {
|
|
let (f, sg, d, env, at) = breach();
|
|
assert!(extract(&env, STEWARD, &pubkey()).is_ok());
|
|
for changed in [[0x19, 0x00, 0x00], [0x19, 0x03, 0x85], [0x19, 0x83, 0x84]] {
|
|
let mut flipped = env.clone();
|
|
flipped[at..at + 3].copy_from_slice(&changed);
|
|
assert!(extract(&flipped, STEWARD, &pubkey()).is_err(), "{changed:x?}");
|
|
assert!(verify(&f, &sg.grant, &d, &flipped, STEWARD, &pubkey()).is_err(), "{changed:x?}");
|
|
assert!(replay_spend([flipped.as_slice()], STEWARD, &pubkey()).is_empty(), "{changed:x?}");
|
|
}
|
|
}
|
|
|
|
/// In a log the same change also breaks the chain at that entry, so a restart
|
|
/// refuses the stream rather than losing the debit from it.
|
|
#[test]
|
|
fn a_debit_changed_in_a_log_breaks_the_chain_there() {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let d = rt.decide(&f, &sg, sg.grant.subject, Energy::measured(40), 10);
|
|
log.append(&f, &sg.grant, &d);
|
|
rt.authorize(&f, &sg, sg.grant.subject, 11);
|
|
let d = rt.charge(&sg.grant, Energy::measured(900));
|
|
log.append(&f, &sg.grant, &d);
|
|
|
|
let mut entries = log.entries().to_vec();
|
|
let key = b"jdebited_uj";
|
|
let at = entries[1].windows(key.len()).position(|w| w == key).unwrap() + key.len();
|
|
entries[1][at + 2] ^= 0x01;
|
|
assert_eq!(verify_log(&entries, STEWARD, &pubkey()), Err(1));
|
|
assert!(ReceiptLog::new(sk(7), STEWARD).resume(entries).is_err());
|
|
}
|
|
|
|
/// A decision whose debit differs from the receipt's is not that receipt's
|
|
/// decision, in either direction, and the check does not fall back to the form
|
|
/// without the member when the envelope carries it.
|
|
#[test]
|
|
fn a_decision_with_another_debit_is_not_this_receipts() {
|
|
let (f, sg, d, env, _) = breach();
|
|
assert!(verify(&f, &sg.grant, &d, &env, STEWARD, &pubkey()).is_ok());
|
|
for debited_uj in [0, 899, 901] {
|
|
let other = Decision { debited_uj, ..d.clone() };
|
|
assert!(
|
|
matches!(verify(&f, &sg.grant, &other, &env, STEWARD, &pubkey()), Err(ReceiptError::PayloadMismatch)),
|
|
"{debited_uj}"
|
|
);
|
|
}
|
|
|
|
let (links, ids) = chain(10_000, 100, Vec::new());
|
|
let fc = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize_chained(&fc, &links, did(&sk(3)), 10);
|
|
let d = rt.charge_chained(&links, Energy::measured(150));
|
|
let env = seal_chained(&fc, &ids, &links[1].grant, &d, &sk(7), STEWARD).unwrap();
|
|
assert!(verify_chained(&fc, &ids, &links[1].grant, &d, &env, STEWARD, &pubkey()).is_ok());
|
|
let other = Decision { chain_debited_uj: vec![100, 100], ..d.clone() };
|
|
assert!(verify_chained(&fc, &ids, &links[1].grant, &other, &env, STEWARD, &pubkey()).is_err());
|
|
}
|
|
|
|
/// Seal `wire` as it stands, as a receipt whose form this implementation would
|
|
/// not produce itself.
|
|
fn seal_wire(wire: &Value) -> Vec<u8> {
|
|
jcr1::cose_sign1_envelope(&jcr1::signed_core_bytes(wire).unwrap(), &sk(7), STEWARD)
|
|
}
|
|
|
|
/// A receipt sealed before the member exists: it keeps verifying and reading,
|
|
/// and an unknown debit is not read as a zero one. A breach and a refusal of
|
|
/// that age look alike, so both read as the most either can have debited.
|
|
#[test]
|
|
fn a_receipt_sealed_before_the_member_reads_as_the_most_it_can_have_debited() {
|
|
let (f, sg, d, env, _) = breach();
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &d, STEWARD);
|
|
wire.as_object_mut().unwrap().remove("debited_uj");
|
|
let old = seal_wire(&wire);
|
|
assert_ne!(old, env);
|
|
|
|
let core = extract(&old, STEWARD, &pubkey()).expect("still readable");
|
|
assert_eq!(core.debited_uj, None);
|
|
assert!(verify(&f, &sg.grant, &d, &old, STEWARD, &pubkey()).is_ok(), "still verifies");
|
|
assert_eq!(replay_spend([old.as_slice()], STEWARD, &pubkey()), vec![(sg.grant.id(), 900)]);
|
|
|
|
let mut rt = Runtime::new();
|
|
let refused = rt.decide(&f, &sg, sg.grant.subject, Energy::measured(800), 10);
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &refused, STEWARD);
|
|
wire.as_object_mut().unwrap().remove("debited_uj");
|
|
let old = seal_wire(&wire);
|
|
assert!(verify(&f, &sg.grant, &refused, &old, STEWARD, &pubkey()).is_ok());
|
|
assert_eq!(replay_spend([old.as_slice()], STEWARD, &pubkey()), vec![(sg.grant.id(), 800)]);
|
|
}
|
|
|
|
/// Every other old receipt reads as it always did.
|
|
#[test]
|
|
fn an_old_receipt_the_verdict_settles_reads_as_it_did() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut data = f.clone();
|
|
data.trust = Trust::Data;
|
|
let mut rt = Runtime::new();
|
|
let allowed = rt.decide(&f, &sg, sg.grant.subject, Energy::measured(40), 10);
|
|
let denied = rt.decide(&data, &sg, sg.grant.subject, Energy::measured(40), 10);
|
|
let allow = seal(&f, &sg.grant, &allowed, &sk(7), STEWARD).unwrap();
|
|
let deny = seal(&data, &sg.grant, &denied, &sk(7), STEWARD).unwrap();
|
|
assert_eq!(extract(&allow, STEWARD, &pubkey()).unwrap().debited_uj, None);
|
|
assert_eq!(extract(&deny, STEWARD, &pubkey()).unwrap().debited_uj, None);
|
|
assert_eq!(replay_spend([allow.as_slice(), deny.as_slice()], STEWARD, &pubkey()), vec![(sg.grant.id(), 40)]);
|
|
}
|
|
|
|
/// An old log can restore a grant past its ceiling: a capped allow reads at its
|
|
/// full cost. A debit against that spend must not underflow the remainder.
|
|
#[test]
|
|
fn a_charge_after_an_over_restored_grant_leaves_nothing_remaining() {
|
|
let sg = grant(100, Vec::new());
|
|
let mut rt = Runtime::new();
|
|
rt.restore([(sg.grant.id(), 150)]);
|
|
let d = rt.charge(&sg.grant, Energy::measured(10));
|
|
assert_eq!((d.budget_remaining_uj, d.debited_uj), (0, 0));
|
|
}
|
|
|
|
/// A debit member that does not parse must not read as absent, which means
|
|
/// something else, and a list that does not match the chain must not shed a
|
|
/// link's debit by zipping short.
|
|
#[test]
|
|
fn a_malformed_debit_member_is_refused() {
|
|
let (f, sg, d, _, _) = breach();
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &d, STEWARD);
|
|
wire["debited_uj"] = json!("900");
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_err(), "a text figure");
|
|
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &d, STEWARD);
|
|
wire["chain_debited_uj"] = json!([900]);
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_err(), "a list with no chain");
|
|
|
|
let (links, ids) = chain(10_000, 100, Vec::new());
|
|
let fc = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize_chained(&fc, &links, did(&sk(3)), 10);
|
|
let d = rt.charge_chained(&links, Energy::measured(150));
|
|
let mut wire = to_jcr1_wire_chained(&fc, &ids, &links[1].grant, &d, STEWARD);
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_ok());
|
|
wire["chain_debited_uj"] = json!([150]);
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_err(), "one entry for two links");
|
|
wire["chain_debited_uj"] = json!([150, "100"]);
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_err(), "a text entry");
|
|
}
|
|
|
|
/// A one-link chain appends what `append` would, as its seal does.
|
|
#[test]
|
|
fn a_one_link_chain_appends_a_plain_entry() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let d = Runtime::new().decide(&f, &sg, sg.grant.subject, Energy::measured(40), 10);
|
|
let (mut plain, mut chained) = (log(), log());
|
|
assert_eq!(chained.append_chained(&f, &[sg.grant.id()], &sg.grant, &d), plain.append(&f, &sg.grant, &d));
|
|
}
|
|
|
|
// ── P1: a replay restores money and the tainted sub-budget too ──────────────
|
|
//
|
|
// Each test below once reproduced a restart that handed back money or a
|
|
// tainted wallet, since a replay restored joules alone. The core now states
|
|
// both (SPEC §8.1), and a replay restores them. The positive control before
|
|
// each fault assertion holds that the replay itself ran.
|
|
|
|
/// A grant bounded in money as well as joules (SPEC §4.8).
|
|
fn priced_grant(budget_uj: u64, minor: u64) -> SignedGrant {
|
|
issue(
|
|
&sk(1),
|
|
Grant {
|
|
issuer: did(&sk(1)),
|
|
subject: did(&sk(2)),
|
|
capability: Capability::new(resource(), ["read"]),
|
|
budget_uj,
|
|
budget_funds: Some(jcp_schema::Funds::new("USD", minor)),
|
|
caveats: Vec::new(),
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [9; 16],
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
/// Fault P1, money: a restart through receipt replay handed back a grant's
|
|
/// money spend, since the core recorded joules only. It now states the money
|
|
/// a decision debited, so a spent money ceiling stays spent.
|
|
#[test]
|
|
fn p1_a_replayed_restart_refunds_money_spend() {
|
|
let sg = priced_grant(1_000, 300);
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let usd = |minor| jcp_runtime::Price::new("USD", minor);
|
|
let mut log = log();
|
|
|
|
let mut live = Runtime::new();
|
|
let d = live.decide_priced(&f, &sg, holder, Energy::measured(10), &usd(300), 10);
|
|
assert!(d.is_allow());
|
|
log.append(&f, &sg.grant, &d);
|
|
assert_eq!(live.spent_minor(&sg.grant.id()), 300, "the money ceiling is spent");
|
|
let next = live.decide_priced(&f, &sg, holder, Energy::measured(10), &usd(1), 10).verdict;
|
|
assert_eq!(next, Verdict::Deny(DenyReason::FundsExceeded));
|
|
|
|
let mut back = restored(log.entries());
|
|
assert_eq!(back.spent(&sg.grant.id()), 10, "the replay restored the joules");
|
|
let spent_minor = back.spent_minor(&sg.grant.id());
|
|
let after = back.decide_priced(&f, &sg, holder, Energy::measured(10), &usd(1), 10).verdict;
|
|
assert_eq!(
|
|
after, next,
|
|
"fault P1: the restart refunded money spend: it restored {spent_minor} of 300 minor units, so a spent \
|
|
money ceiling admits another priced call"
|
|
);
|
|
}
|
|
|
|
/// Fault P1, the tainted sub-budget: a restart through receipt replay handed
|
|
/// back what tainted calls accrued against a grant's tainted sub-budget
|
|
/// (SPEC §4.10), so an exhausted tainted wallet admitted tainted work again.
|
|
/// The accrual is now read off the receipt's flow block.
|
|
#[test]
|
|
fn p1_a_replayed_restart_refunds_the_tainted_sub_budget() {
|
|
let wallet = jcp_schema::FlowCeiling { ceiling: Label::top(), tainted_budget_uj: Some(40) };
|
|
let sg = grant(1_000, vec![Caveat::FlowBelow(wallet)]);
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let facts = FlowFacts {
|
|
label: Label {
|
|
integrity: Integrity::Untrusted,
|
|
origins: Origins::one(Origin::channel(ContentId::of_bytes(b"mail:read"))),
|
|
readers: Readers::Public,
|
|
},
|
|
witness_id: ContentId::of_bytes(b"a witness"),
|
|
eval_uj: 42,
|
|
eval_prov: Provenance::ModelBased,
|
|
};
|
|
let seal = FlowSeal { facts: Some(facts.clone()), ceiling: None };
|
|
let mut log = log();
|
|
|
|
let mut live = Runtime::new();
|
|
assert!(live.authorize_flow(&f, &sg, holder, 10, &facts).is_allow());
|
|
let d = live.charge_flow(&sg.grant, Energy::measured(40), &facts);
|
|
assert!(d.is_allow());
|
|
log.append_flow(&f, &sg.grant, &d, &seal);
|
|
assert_eq!(live.spent_tainted(&sg.grant.id()), 40, "the tainted wallet is spent");
|
|
let next = live.authorize_flow(&f, &sg, holder, 10, &facts).verdict;
|
|
assert_eq!(next, Verdict::Deny(DenyReason::TaintBudgetExceeded));
|
|
|
|
let mut back = restored(log.entries());
|
|
assert_eq!(back.spent(&sg.grant.id()), 40, "the replay restored the joules");
|
|
let tainted = back.spent_tainted(&sg.grant.id());
|
|
let after = back.authorize_flow(&f, &sg, holder, 10, &facts).verdict;
|
|
assert_eq!(
|
|
after, next,
|
|
"fault P1: the restart refunded the tainted sub-budget: it restored {tainted} of 40 µJ, so an exhausted \
|
|
tainted wallet admits another tainted call"
|
|
);
|
|
}
|
|
|
|
// ── the members that close P1 (SPEC §8.1) ───────────────────────────────────
|
|
|
|
/// An argument from an untrusted channel.
|
|
fn tainted_facts() -> FlowFacts {
|
|
FlowFacts {
|
|
label: Label {
|
|
integrity: Integrity::Untrusted,
|
|
origins: Origins::one(Origin::channel(ContentId::of_bytes(b"mail:read"))),
|
|
readers: Readers::Public,
|
|
},
|
|
witness_id: ContentId::of_bytes(b"a witness"),
|
|
eval_uj: 42,
|
|
eval_prov: Provenance::ModelBased,
|
|
}
|
|
}
|
|
|
|
/// Money over a chain: every link with a money ceiling is debited, and one
|
|
/// without is debited none, so the receipt says what each link was and a
|
|
/// replay restores exactly the runtime's money ledger.
|
|
#[test]
|
|
fn a_priced_chain_states_each_links_money() {
|
|
let usd = |minor| jcp_runtime::Price::new("USD", minor);
|
|
let root = grant(10_000, Vec::new());
|
|
let kid = jcp_grant::attenuate_priced(
|
|
&root,
|
|
&sk(2),
|
|
did(&sk(3)),
|
|
Capability::new(resource(), ["read"]),
|
|
5_000,
|
|
Some(jcp_schema::Funds::new("USD", 500)),
|
|
0,
|
|
[8; 16],
|
|
)
|
|
.unwrap();
|
|
let links = vec![root, kid];
|
|
let ids: Vec<ContentId> = links.iter().map(|sg| sg.grant.id()).collect();
|
|
let f = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide_chained_priced(&f, &links, did(&sk(3)), Energy::measured(40), &usd(200), 10);
|
|
assert!(d.is_allow());
|
|
assert_eq!((d.debited_minor, d.chain_debited_minor.clone()), (200, vec![0, 200]));
|
|
let wire = to_jcr1_wire_chained(&f, &ids, &links[1].grant, &d, STEWARD);
|
|
assert_eq!((wire.get("debited_minor"), wire.get("chain_debited_minor")), (Some(&json!(200)), Some(&json!([0, 200]))));
|
|
let env = seal_chained(&f, &ids, &links[1].grant, &d, &sk(7), STEWARD).unwrap();
|
|
assert!(verify_chained(&f, &ids, &links[1].grant, &d, &env, STEWARD, &pubkey()).is_ok());
|
|
let back = restored(&[env]);
|
|
assert_eq!((back.spent_minor(&ids[0]), back.spent_minor(&ids[1])), (0, 200));
|
|
assert_eq!((rt.spent_minor(&ids[0]), rt.spent_minor(&ids[1])), (0, 200));
|
|
|
|
// Both links bounded: one figure for every link, and no per-link list.
|
|
let bounded = priced_grant(10_000, 500);
|
|
let heir = attenuate(&bounded, &sk(2), did(&sk(3)), Capability::new(resource(), ["read"]), 5_000, 0, [8; 16]).unwrap();
|
|
let both = vec![bounded, heir];
|
|
let ids: Vec<ContentId> = both.iter().map(|sg| sg.grant.id()).collect();
|
|
let f = frame(&both[1]);
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide_chained_priced(&f, &both, did(&sk(3)), Energy::measured(40), &usd(200), 10);
|
|
assert!(d.is_allow() && d.chain_debited_minor.is_empty());
|
|
let env = seal_chained(&f, &ids, &both[1].grant, &d, &sk(7), STEWARD).unwrap();
|
|
let back = restored(&[env]);
|
|
assert_eq!((back.spent_minor(&ids[0]), back.spent_minor(&ids[1])), (200, 200));
|
|
}
|
|
|
|
/// A priced receipt sealed before `debited_minor` existed keeps verifying, and
|
|
/// cannot show its money debit: it reads as its joules and no money. One that
|
|
/// carries the member binds it, in either direction.
|
|
#[test]
|
|
fn a_priced_receipt_sealed_before_the_money_member_cannot_show_its_money() {
|
|
let sg = priced_grant(1_000, 300);
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide_priced(&f, &sg, sg.grant.subject, Energy::measured(10), &jcp_runtime::Price::new("USD", 300), 10);
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &d, STEWARD);
|
|
assert_eq!(wire.get("debited_minor"), Some(&json!(300)));
|
|
wire.as_object_mut().unwrap().remove("debited_minor");
|
|
let old = seal_wire(&wire);
|
|
assert!(verify(&f, &sg.grant, &d, &old, STEWARD, &pubkey()).is_ok(), "still verifies");
|
|
let core = extract(&old, STEWARD, &pubkey()).unwrap();
|
|
assert_eq!((core.debits(), core.money_debits()), (vec![(sg.grant.id(), 10)], vec![]));
|
|
|
|
let env = seal(&f, &sg.grant, &d, &sk(7), STEWARD).unwrap();
|
|
assert!(verify(&f, &sg.grant, &d, &env, STEWARD, &pubkey()).is_ok());
|
|
for debited_minor in [0, 299, 301] {
|
|
let other = Decision { debited_minor, ..d.clone() };
|
|
assert!(
|
|
matches!(verify(&f, &sg.grant, &other, &env, STEWARD, &pubkey()), Err(ReceiptError::PayloadMismatch)),
|
|
"{debited_minor}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A tainted call sealed with its flow block keeps the bytes it always had:
|
|
/// the block and the debit say what it accrued. A refusal there accrued
|
|
/// nothing, though it records the cost it refused.
|
|
#[test]
|
|
fn a_flow_block_says_what_a_tainted_call_accrued() {
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let facts = tainted_facts();
|
|
let seal_t = FlowSeal { facts: Some(facts.clone()), ceiling: None };
|
|
let mut rt = Runtime::new();
|
|
rt.authorize_flow(&f, &sg, sg.grant.subject, 10, &facts);
|
|
let d = rt.charge_flow(&sg.grant, Energy::measured(40), &facts);
|
|
assert_eq!(d.tainted_uj, 40);
|
|
let wire = jcp_receipt::to_jcr1_wire_flow(&f, &sg.grant, &d, STEWARD, &seal_t);
|
|
assert_eq!((wire["jcp_receipt"].as_str(), wire.get("tainted_uj")), (Some("jcp/3"), None));
|
|
let env = jcp_receipt::seal_flow(&f, &sg.grant, &d, &sk(7), STEWARD, &seal_t).unwrap();
|
|
assert_eq!(extract(&env, STEWARD, &pubkey()).unwrap().tainted_accruals(), vec![(sg.grant.id(), 40)]);
|
|
|
|
let refused = rt.decide_flow(&f, &sg, sg.grant.subject, Energy::measured(900), 10, &facts);
|
|
assert_eq!(refused.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied));
|
|
let env = jcp_receipt::seal_flow(&f, &sg.grant, &refused, &sk(7), STEWARD, &seal_t).unwrap();
|
|
let core = extract(&env, STEWARD, &pubkey()).unwrap();
|
|
assert!(core.flow && core.tainted_accruals().is_empty(), "a refusal accrued nothing");
|
|
|
|
// A flow block on a decision that accrued nothing states its zero.
|
|
let untainted = Decision { tainted_uj: 0, ..d.clone() };
|
|
let wire = jcp_receipt::to_jcr1_wire_flow(&f, &sg.grant, &untainted, STEWARD, &seal_t);
|
|
assert_eq!(wire.get("tainted_uj"), Some(&json!(0)));
|
|
}
|
|
|
|
/// Where no flow block says it, the receipt states the accrual: a chained
|
|
/// tainted decision, on every link, and a tainted call sealed plain.
|
|
#[test]
|
|
fn a_tainted_accrual_no_flow_block_says_is_stated() {
|
|
let facts = tainted_facts();
|
|
let (links, ids) = chain(10_000, 5_000, Vec::new());
|
|
let f = frame(&links[1]);
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide_chained_flow(&f, &links, did(&sk(3)), Energy::measured(30), 10, &facts);
|
|
assert!(d.is_allow());
|
|
let env = seal_chained(&f, &ids, &links[1].grant, &d, &sk(7), STEWARD).unwrap();
|
|
assert_eq!(extract(&env, STEWARD, &pubkey()).unwrap().tainted_uj, Some(30));
|
|
let back = restored(&[env]);
|
|
for id in &ids {
|
|
assert_eq!((back.spent_tainted(id), rt.spent_tainted(id)), (30, 30));
|
|
}
|
|
|
|
// A breach sealed plain states both its debit and its accrual. Sealed
|
|
// before `tainted_uj` existed, it still verifies, and reads as no accrual.
|
|
let sg = grant(1_000, vec![Caveat::MaxCostUj(100)]);
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
rt.authorize_flow(&f, &sg, sg.grant.subject, 10, &facts);
|
|
let d = rt.charge_flow(&sg.grant, Energy::measured(900), &facts);
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &d, STEWARD);
|
|
assert_eq!((wire.get("debited_uj"), wire.get("tainted_uj")), (Some(&json!(900)), Some(&json!(900))));
|
|
wire.as_object_mut().unwrap().remove("tainted_uj");
|
|
let old = seal_wire(&wire);
|
|
assert!(verify(&f, &sg.grant, &d, &old, STEWARD, &pubkey()).is_ok());
|
|
assert!(extract(&old, STEWARD, &pubkey()).unwrap().tainted_accruals().is_empty());
|
|
let other = Decision { tainted_uj: 899, ..d.clone() };
|
|
assert!(verify(&f, &sg.grant, &other, &seal(&f, &sg.grant, &d, &sk(7), STEWARD).unwrap(), STEWARD, &pubkey()).is_err());
|
|
}
|
|
|
|
/// A money or accrual member that does not parse is refused rather than read
|
|
/// as absent, and a per-link money list must match the chain.
|
|
#[test]
|
|
fn a_malformed_money_or_accrual_member_is_refused() {
|
|
let sg = priced_grant(1_000, 300);
|
|
let f = frame(&sg);
|
|
let d = Runtime::new().decide_priced(&f, &sg, sg.grant.subject, Energy::measured(10), &jcp_runtime::Price::new("USD", 300), 10);
|
|
for (member, value) in [("debited_minor", json!("300")), ("tainted_uj", json!("40")), ("chain_debited_minor", json!([300]))] {
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &d, STEWARD);
|
|
wire[member] = value;
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_err(), "{member}");
|
|
}
|
|
}
|
|
|
|
// ── holds: the decision recorded before the work (SPEC §8.1, §4.11) ────────
|
|
|
|
fn unresolved(entries: &[Vec<u8>]) -> Vec<ContentId> {
|
|
jcp_receipt::unresolved_holds(entries.iter().map(Vec::as_slice), STEWARD, &pubkey())
|
|
}
|
|
|
|
/// The two-phase path seals its allow as a hold before the work runs. A
|
|
/// stream that ends at the hold, as one does when the enforcement point stops
|
|
/// while the work runs, restores what the hold states; one holding the
|
|
/// receipt that settles it restores the work's debit alone, in whatever
|
|
/// order the two are read.
|
|
#[test]
|
|
fn a_hold_restores_its_figure_until_a_receipt_settles_it() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let auth = rt.authorize(&f, &sg, sg.grant.subject, 10);
|
|
let bound = rt.hold_bound(&sg.grant, &FlowFacts::bottom());
|
|
assert_eq!(bound, 1_000, "the most a charge can debit: what the grant has left");
|
|
log.append_hold(&f, &sg.grant, &auth, &FlowSeal::clean(), bound, 0);
|
|
let hold = log.head();
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 1_000, "a stop while the work ran refunded it");
|
|
assert_eq!(unresolved(log.entries()), vec![hold]);
|
|
|
|
let d = rt.charge(&sg.grant, Energy::measured(70));
|
|
log.append_settle(&f, &sg.grant, &d, &FlowSeal::clean(), hold);
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 70, "a settled hold added to its work's debit");
|
|
assert!(unresolved(log.entries()).is_empty());
|
|
let reversed: Vec<Vec<u8>> = log.entries().iter().rev().cloned().collect();
|
|
assert_eq!(restored(&reversed).spent(&sg.grant.id()), 70, "the settle read first still settles the hold");
|
|
let core = extract(&log.entries()[1], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!((core.settles, core.debits()), (Some(hold), vec![(sg.grant.id(), 70)]));
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(2));
|
|
}
|
|
|
|
/// **An unresolved hold restores its money, not only its joules.** A replay
|
|
/// that restored one dimension and dropped the other would rebuild a state in
|
|
/// which the interrupted charge can be paid for a second time.
|
|
#[test]
|
|
fn a_hold_restores_the_money_it_reserved_until_it_is_settled() {
|
|
let g = issue(
|
|
&sk(1),
|
|
Grant {
|
|
issuer: did(&sk(1)),
|
|
subject: did(&sk(2)),
|
|
capability: Capability::new(resource(), ["read"]),
|
|
budget_uj: 1_000,
|
|
budget_funds: Some(jcp_schema::Funds::new("USD", 500)),
|
|
caveats: vec![Caveat::MaxCostUj(100)],
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [9; 16],
|
|
},
|
|
)
|
|
.unwrap();
|
|
let f = frame(&g);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let gid = g.grant.id();
|
|
|
|
let (auth, held) = rt.hold_priced(&f, &g, g.grant.subject, 10, &FlowFacts::bottom(), &Price::new("USD", 400));
|
|
let h = held.expect("the hold was allowed");
|
|
assert_eq!((h.uj, h.minor), (100, 400));
|
|
log.append_hold(&f, &g.grant, &auth, &FlowSeal::clean(), h.uj, h.minor);
|
|
let hold = log.head();
|
|
|
|
// a stop while the work ran: the replay restores BOTH reservations
|
|
let back = restored(log.entries());
|
|
assert_eq!(back.spent(&gid), 100, "the joule reservation came back");
|
|
assert_eq!(back.spent_minor(&gid), 400, "the money reservation came back");
|
|
assert_eq!(unresolved(log.entries()), vec![hold]);
|
|
|
|
// settled for a tenth of the maximum: the replay restores what it cost
|
|
let d = rt.settle_priced(&g.grant, h.id, Energy::measured(40), &Price::new("USD", 40), &FlowFacts::bottom());
|
|
assert!(d.is_allow());
|
|
log.append_settle(&f, &g.grant, &d, &FlowSeal::clean(), hold);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&gid), back.spent_minor(&gid)), (40, 40));
|
|
assert!(unresolved(log.entries()).is_empty());
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(2));
|
|
|
|
let core = extract(&log.entries()[0], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.held_minor, Some(400));
|
|
assert_eq!(core.held_money(), vec![(gid, 400)]);
|
|
}
|
|
|
|
/// A hold reserving no money omits `held_minor` entirely, so a joules-only
|
|
/// hold's receipt is byte-identical to one sealed before the money dimension
|
|
/// existed — which is what keeps every published vector valid.
|
|
#[test]
|
|
fn a_joules_only_hold_states_no_money_member() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let auth = rt.authorize(&f, &sg, sg.grant.subject, 10);
|
|
let bound = rt.hold_bound(&sg.grant, &FlowFacts::bottom());
|
|
let with_zero = log.append_hold(&f, &sg.grant, &auth, &FlowSeal::clean(), bound, 0);
|
|
let core = extract(&with_zero, STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.held_minor, None, "a zero reservation is omitted, not stated as 0");
|
|
assert!(core.held_money().is_empty());
|
|
assert_eq!(core.held(), vec![(sg.grant.id(), bound)]);
|
|
}
|
|
|
|
/// **The marker reaches the audit trail.** §4.3's whole point is that an allow
|
|
/// by a point that had not yet heard of a revocation is explicable, while one
|
|
/// by a point that heard and ignored it is a violation — and that nothing in a
|
|
/// receipt told them apart. A receipt from a point that declares its marker now
|
|
/// states it, under the same signature as the verdict.
|
|
#[test]
|
|
fn a_receipt_states_the_sealing_points_revocation_marker() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
|
|
let mut log = log();
|
|
assert_eq!(log.revocation_marker(), None, "a fresh log has not said");
|
|
let silent = log.append(&f, &sg.grant, &rt.decide(&f, &sg, sg.grant.subject, Energy::measured(10), 10));
|
|
let core = extract(&silent, STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.known_through, None, "a point that has not said states nothing");
|
|
|
|
log.declare_revocation_marker(7);
|
|
let stated = log.append(&f, &sg.grant, &rt.decide(&f, &sg, sg.grant.subject, Energy::measured(10), 11));
|
|
let core = extract(&stated, STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.known_through, Some(7));
|
|
assert_eq!(core.verdict, "allow", "the marker rides beside the verdict, not instead of it");
|
|
|
|
// A revocation issued at marker 9 is not explained by a point at 7 having
|
|
// heard it; one at 11 would be. The auditor's comparison, now possible.
|
|
assert!(core.known_through.is_some_and(|m| m < 9), "an allow at 7 is explicable against M=9");
|
|
log.declare_revocation_marker(11);
|
|
let later = log.append(&f, &sg.grant, &rt.decide(&f, &sg, sg.grant.subject, Energy::measured(10), 12));
|
|
let core = extract(&later, STEWARD, &pubkey()).unwrap();
|
|
assert!(core.known_through.is_some_and(|m| m >= 9), "an allow at 11 is not");
|
|
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(3));
|
|
}
|
|
|
|
/// A point that declares nothing seals exactly what it sealed before the member
|
|
/// existed — so "did not say" stays distinguishable from "said 0", and every
|
|
/// published vector stays valid.
|
|
#[test]
|
|
fn an_undeclared_marker_is_omitted_rather_than_stated_as_zero() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let d = rt.decide(&f, &sg, sg.grant.subject, Energy::measured(10), 10);
|
|
|
|
let silent = log().append(&f, &sg.grant, &d);
|
|
let mut zero = log();
|
|
zero.declare_revocation_marker(0);
|
|
let claimed = zero.append(&f, &sg.grant, &d);
|
|
assert_ne!(silent, claimed, "saying 0 is a claim; saying nothing is not");
|
|
assert_eq!(extract(&claimed, STEWARD, &pubkey()).unwrap().known_through, Some(0));
|
|
assert_eq!(extract(&silent, STEWARD, &pubkey()).unwrap().known_through, None);
|
|
}
|
|
|
|
fn lifecycle_grant(nonce: u8) -> SignedGrant {
|
|
issue(
|
|
&sk(1),
|
|
Grant {
|
|
issuer: did(&sk(1)),
|
|
subject: did(&sk(2)),
|
|
capability: Capability::new(resource(), ["read"]),
|
|
budget_uj: 10_000,
|
|
budget_funds: Some(jcp_schema::Funds::new("USD", 1_000)),
|
|
caveats: vec![Caveat::MaxCostUj(500)],
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [nonce; 16],
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
/// **A partially captured hold restores what it still reserves, not what it
|
|
/// first reserved.** The captures restore their own debits; restoring the whole
|
|
/// reservation beside them would count the captured part twice and leave a
|
|
/// restarted point refusing calls it has room for.
|
|
#[test]
|
|
fn a_replay_restores_a_partly_captured_hold_less_what_was_captured() {
|
|
let g = lifecycle_grant(21);
|
|
let f = frame(&g);
|
|
let gid = g.grant.id();
|
|
let facts = FlowFacts::bottom();
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
|
|
let (auth, h) = rt.hold_priced(&f, &g, g.grant.subject, 10, &facts, &Price::new("USD", 600));
|
|
let h = h.expect("held");
|
|
log.append_hold(&f, &g.grant, &auth, &FlowSeal::clean(), h.uj, h.minor);
|
|
let hold = log.head();
|
|
|
|
let c = rt.capture_priced(&g.grant, h.id, Energy::measured(100), &Price::new("USD", 200), 11, &facts);
|
|
assert!(c.is_allow());
|
|
log.append_capture(&f, &g.grant, &c, &FlowSeal::clean(), hold);
|
|
|
|
// a stop here: 200 captured + 400 still reserved = 600, not 800
|
|
let back = restored(log.entries());
|
|
assert_eq!(back.spent_minor(&gid), 600, "the captured part was counted twice");
|
|
assert_eq!(back.spent(&gid), 500, "100 captured + 400 joules still reserved");
|
|
assert_eq!(unresolved(log.entries()), vec![hold], "a capture does not settle its hold");
|
|
let core = extract(&log.entries()[1], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!((core.captures, core.settles), (Some(hold), None));
|
|
|
|
// settled: only what the captures and the final draw debited
|
|
let d = rt.settle_priced(&g.grant, h.id, Energy::measured(10), &Price::new("USD", 50), &facts);
|
|
log.append_settle(&f, &g.grant, &d, &FlowSeal::clean(), hold);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&gid), back.spent_minor(&gid)), (110, 250));
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(3));
|
|
}
|
|
|
|
/// A reclaimed hold restores no money and still its joules; a refund restores
|
|
/// as a refund, beside a gross debit that never decreases.
|
|
#[test]
|
|
fn a_replay_restores_a_reclaim_and_a_refund_as_what_they_were() {
|
|
let g = lifecycle_grant(22);
|
|
let f = frame(&g);
|
|
let gid = g.grant.id();
|
|
let facts = FlowFacts::bottom();
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
|
|
let (auth, h) = rt.hold_priced_until(&f, &g, g.grant.subject, 10, &facts, &Price::new("USD", 600), 100);
|
|
let h = h.expect("held");
|
|
log.append_hold(&f, &g.grant, &auth, &FlowSeal::clean(), h.uj, h.minor);
|
|
let hold = log.head();
|
|
|
|
let (d, released) = rt.reclaim(&g.grant, h.id, 101);
|
|
assert_eq!((d.is_allow(), released), (true, 600));
|
|
log.append_reclaim(&f, &g.grant, &d, hold);
|
|
let back = restored(log.entries());
|
|
assert_eq!(back.spent_minor(&gid), 0, "a reclaimed reservation was restored");
|
|
assert_eq!(back.spent(&gid), 500, "the joule reservation is not reclaimed");
|
|
|
|
// spend, then the issuer refunds part
|
|
let spend = rt.decide_priced(&f, &g, g.grant.subject, Energy::measured(1), &Price::new("USD", 300), 102);
|
|
assert!(spend.is_allow() || spend.verdict == Verdict::Deny(DenyReason::BudgetExceeded));
|
|
let mut log2 = self::log();
|
|
let mut rt2 = Runtime::new();
|
|
let spend = rt2.decide_priced(&f, &g, g.grant.subject, Energy::measured(1), &Price::new("USD", 300), 10);
|
|
assert!(spend.is_allow());
|
|
log2.append(&f, &g.grant, &spend);
|
|
let r = rt2.refund(&g.grant, g.grant.issuer, &Price::new("USD", 120));
|
|
assert!(r.is_allow());
|
|
log2.append_refund(&f, &g.grant, &r, 120);
|
|
let refused = rt2.refund(&g.grant, g.grant.subject, &Price::new("USD", 50));
|
|
log2.append_refund(&f, &g.grant, &refused, 50);
|
|
|
|
let back = restored(log2.entries());
|
|
assert_eq!((back.spent_minor(&gid), back.refunded_minor(&gid), back.net_minor(&gid)), (300, 120, 180));
|
|
let core = extract(&log2.entries()[1], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.refunded_minor, Some(120));
|
|
let core = extract(&log2.entries()[2], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.refunded_minor, None, "a refused refund states no amount");
|
|
}
|
|
|
|
/// **A chained hold, captured and reclaimed, replays on every link.** Each
|
|
/// link restores its joules less what was captured, and no money once the
|
|
/// reclaim is read — the same rules as a lone grant, applied per link.
|
|
#[test]
|
|
fn a_replay_restores_a_chained_capture_and_reclaim_on_every_link() {
|
|
let root = issue(
|
|
&sk(1),
|
|
Grant {
|
|
issuer: did(&sk(1)),
|
|
subject: did(&sk(2)),
|
|
capability: Capability::new(resource(), ["read"]),
|
|
budget_uj: 5_000,
|
|
budget_funds: Some(jcp_schema::Funds::new("USD", 1_000)),
|
|
caveats: vec![Caveat::MaxCostUj(400)],
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [41; 16],
|
|
},
|
|
)
|
|
.unwrap();
|
|
let kid = attenuate(&root, &sk(2), did(&sk(3)), Capability::new(resource(), ["read"]), 3_000, 0, [42; 16]).unwrap();
|
|
let chain = [root.clone(), kid.clone()];
|
|
let ids = [root.grant.id(), kid.grant.id()];
|
|
let f = frame(&kid);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
|
|
let (auth, h) = rt.hold_chained_priced_until(&f, &chain, did(&sk(3)), 10, &Price::new("USD", 600), 100);
|
|
let h = h.expect("held");
|
|
log.append_hold_chained(&f, &ids, &kid.grant, &auth, h.uj, h.minor);
|
|
let hold = log.head();
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent_minor(&ids[0])), (400, 600), "the parent restores the reservation");
|
|
assert_eq!((back.spent(&ids[1]), back.spent_minor(&ids[1])), (400, 600), "and so does the delegate");
|
|
|
|
let c = rt.capture_chained_priced(&chain, h.id, Energy::measured(150), &Price::new("USD", 200), 50);
|
|
assert!(c.is_allow());
|
|
log.append_capture_chained(&f, &ids, &kid.grant, &c, hold);
|
|
let back = restored(log.entries());
|
|
for g in &ids {
|
|
assert_eq!((back.spent(g), back.spent_minor(g)), (400, 600), "150+250 joules, 200+400 money: nothing counted twice");
|
|
}
|
|
|
|
let (d, released) = rt.reclaim_chained(&chain, h.id, 101);
|
|
assert_eq!((d.is_allow(), released), (true, 400));
|
|
log.append_reclaim_chained(&f, &ids, &kid.grant, &d, hold);
|
|
let back = restored(log.entries());
|
|
for g in &ids {
|
|
assert_eq!(back.spent_minor(g), 200, "only the captured money remains on each link");
|
|
assert_eq!(back.spent(g), 400, "the joule reservation is not reclaimed on any link");
|
|
}
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(3));
|
|
}
|
|
|
|
/// Following a hold is not settling it: the receipt a restarted enforcement
|
|
/// point seals next links to the hold by `prev`, names no hold, and leaves the
|
|
/// hold restoring its figure.
|
|
#[test]
|
|
fn a_receipt_after_a_hold_does_not_settle_it() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let auth = rt.authorize(&f, &sg, sg.grant.subject, 10);
|
|
log.append_hold(&f, &sg.grant, &auth, &FlowSeal::clean(), rt.hold_bound(&sg.grant, &FlowFacts::bottom()), 0);
|
|
let mut back = restored(log.entries());
|
|
let refused = back.authorize(&f, &sg, sg.grant.subject, 11);
|
|
assert_eq!(refused.verdict, Verdict::Deny(DenyReason::BudgetExceeded), "the held grant admits nothing more");
|
|
log.append(&f, &sg.grant, &refused);
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 1_000);
|
|
assert_eq!(unresolved(log.entries()).len(), 1);
|
|
}
|
|
|
|
/// A tainted hold carries its call's flow block, so a hold nothing settles
|
|
/// holds the tainted sub-budget as well as the joules; settled, it accrues
|
|
/// nothing and its settle accrues the call's cost.
|
|
#[test]
|
|
fn a_tainted_hold_holds_the_wallet_too() {
|
|
let wallet = jcp_schema::FlowCeiling { ceiling: Label::top(), tainted_budget_uj: Some(40) };
|
|
let sg = grant(1_000, vec![Caveat::FlowBelow(wallet)]);
|
|
let f = frame(&sg);
|
|
let facts = tainted_facts();
|
|
let seal = FlowSeal { facts: Some(facts.clone()), ceiling: None };
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let auth = rt.authorize_flow(&f, &sg, sg.grant.subject, 10, &facts);
|
|
assert!(auth.is_allow());
|
|
let bound = rt.hold_bound(&sg.grant, &facts);
|
|
log.append_hold(&f, &sg.grant, &auth, &seal, bound, 0);
|
|
let hold = log.head();
|
|
let mut back = restored(log.entries());
|
|
assert_eq!((back.spent(&sg.grant.id()), back.spent_tainted(&sg.grant.id())), (bound, bound));
|
|
assert!(!back.authorize_flow(&f, &sg, sg.grant.subject, 11, &facts).is_allow());
|
|
|
|
let d = rt.charge_flow(&sg.grant, Energy::measured(30), &facts);
|
|
log.append_settle(&f, &sg.grant, &d, &seal, hold);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&sg.grant.id()), back.spent_tainted(&sg.grant.id())), (30, 30));
|
|
}
|
|
|
|
/// §8.2: a call sealed as a hold and its settle is one action, counted once
|
|
/// at the settle's debit; a hold no receipt settles is not attributed at all,
|
|
/// though a replay restores its `held_uj`. An aggregator that read a hold as
|
|
/// an allow would count each call twice, and one that read an unsettled hold
|
|
/// as a debit would attribute a figure no receipt shows was spent.
|
|
#[test]
|
|
fn aggregation_counts_a_held_call_once_and_an_unsettled_hold_not_at_all() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let holder = sg.grant.subject;
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let totals = |entries: &[Vec<u8>]| {
|
|
let (totals, bad) = aggregate(entries.iter().map(Vec::as_slice), STEWARD, &pubkey());
|
|
assert_eq!(bad, 0);
|
|
totals[&(sg.grant.id().to_hex(), holder.to_did_key())].clone()
|
|
};
|
|
|
|
let auth = rt.authorize(&f, &sg, holder, 10);
|
|
log.append_hold(&f, &sg.grant, &auth, &FlowSeal::clean(), rt.hold_bound(&sg.grant, &FlowFacts::bottom()), 0);
|
|
let hold = log.head();
|
|
let d = rt.charge(&sg.grant, Energy::measured(70));
|
|
log.append_settle(&f, &sg.grant, &d, &FlowSeal::clean(), hold);
|
|
let t = totals(log.entries());
|
|
assert_eq!((t.allows, t.denies, t.spent_uj), (1, 0, 70), "a hold and its settle are one call");
|
|
assert_eq!(t.spent_uj, rt.spent(&sg.grant.id()), "and the attribution matches the ledger");
|
|
|
|
// The next call stops between its hold and its outcome.
|
|
let auth = rt.authorize(&f, &sg, holder, 11);
|
|
let bound = rt.hold_bound(&sg.grant, &FlowFacts::bottom());
|
|
assert_eq!(bound, 930, "what the grant has left");
|
|
log.append_hold(&f, &sg.grant, &auth, &FlowSeal::clean(), bound, 0);
|
|
let t = totals(log.entries());
|
|
assert_eq!((t.allows, t.denies, t.spent_uj), (1, 0, 70), "a hold no receipt settles is not attributed");
|
|
assert_eq!(restored(log.entries()).spent(&sg.grant.id()), 70 + bound, "though a replay restores its figure");
|
|
}
|
|
|
|
/// A refusal debits nothing (C3) and lets nothing run, so it holds nothing:
|
|
/// `append_hold` seals it without `held_uj`, and a refusal that carries one
|
|
/// reads by the ordinary rules.
|
|
#[test]
|
|
fn a_refusal_holds_nothing() {
|
|
let sg = grant(100, Vec::new());
|
|
let f = frame(&sg);
|
|
let refused = Runtime::new().decide(&f, &sg, sg.grant.subject, Energy::measured(500), 10);
|
|
assert!(!refused.is_allow());
|
|
let mut log = log();
|
|
log.append_hold(&f, &sg.grant, &refused, &FlowSeal::clean(), 100, 0);
|
|
let core = extract(&log.entries()[0], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!(core.held_uj, None);
|
|
assert!(replay_spend(log.entries().iter().map(Vec::as_slice), STEWARD, &pubkey()).is_empty());
|
|
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &refused, STEWARD);
|
|
wire["held_uj"] = json!(100);
|
|
let env = seal_wire(&wire);
|
|
let core = extract(&env, STEWARD, &pubkey()).unwrap();
|
|
assert!(!core.is_hold() && core.held().is_empty());
|
|
assert!(replay_spend([env.as_slice()], STEWARD, &pubkey()).is_empty());
|
|
}
|
|
|
|
/// A hold placed through a delegation chain names every link (SPEC §8.1).
|
|
/// Live, it counts against each, so a sibling delegate's call through the
|
|
/// held root is refused; a stream that ends at it reads every link as debited
|
|
/// its `held_uj`, and one holding its settle restores each link's own debit.
|
|
#[test]
|
|
fn a_chained_hold_counts_and_restores_on_every_link() {
|
|
let (links, ids) = chain(1_000, 400, Vec::new());
|
|
let sibling = vec![
|
|
links[0].clone(),
|
|
attenuate(&links[0], &sk(2), did(&sk(4)), Capability::new(resource(), ["read"]), 300, 0, [9; 16]).unwrap(),
|
|
];
|
|
let (f, fs) = (frame(&links[1]), frame(&sibling[1]));
|
|
let full = Verdict::Deny(DenyReason::BudgetExceeded);
|
|
let mut rt = Runtime::new();
|
|
let mut log = log();
|
|
let (auth, held) = rt.hold_chained(&f, &links, did(&sk(3)), 10);
|
|
let held = held.expect("a hold on fresh links");
|
|
assert_eq!(held.uj, 1_000, "the most the work can debit any link: the root's remainder");
|
|
assert_eq!(rt.decide_chained(&fs, &sibling, did(&sk(4)), Energy::measured(1), 10).verdict, full, "live, the sibling waits");
|
|
log.append_hold_chained(&f, &ids, &links[1].grant, &auth, held.uj, held.minor);
|
|
let hold = log.head();
|
|
let core = extract(&log.entries()[0], STEWARD, &pubkey()).unwrap();
|
|
assert_eq!((core.chain_ids.as_deref(), core.held_uj), (Some(ids.as_slice()), Some(1_000)));
|
|
|
|
let mut back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (1_000, 1_000), "a stop while the work ran refunded a link");
|
|
assert_eq!(back.decide_chained(&fs, &sibling, did(&sk(4)), Energy::measured(1), 11).verdict, full, "restored, the sibling waits");
|
|
assert_eq!(unresolved(log.entries()), vec![hold]);
|
|
|
|
let d = rt.settle_chained(&links, held.id, Energy::measured(500));
|
|
assert_eq!(d.chain_debited_uj, vec![500, 400], "each link saturates at its own ceiling");
|
|
log.append_settle_chained(&f, &ids, &links[1].grant, &d, hold);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (500, 400), "a settled hold added to its work's debit");
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (rt.spent(&ids[0]), rt.spent(&ids[1])));
|
|
assert!(unresolved(log.entries()).is_empty());
|
|
assert!(rt.decide_chained(&fs, &sibling, did(&sk(4)), Energy::measured(300), 12).is_allow(), "the root has 500 left");
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(2));
|
|
}
|
|
|
|
/// A hold member that does not parse is refused rather than read as absent.
|
|
#[test]
|
|
fn a_malformed_hold_member_is_refused() {
|
|
let sg = grant(1_000, Vec::new());
|
|
let f = frame(&sg);
|
|
let auth = Runtime::new().authorize(&f, &sg, sg.grant.subject, 10);
|
|
for (member, value) in [("held_uj", json!("1000")), ("settles", json!(7)), ("settles", json!("not hex"))] {
|
|
let mut wire = to_jcr1_wire(&f, &sg.grant, &auth, STEWARD);
|
|
wire[member] = value;
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).is_err(), "{member}");
|
|
}
|
|
}
|
|
|
|
// ── standing grants: the firing recorded before the child leaves (SPEC §4.6, §8.1) ──
|
|
|
|
/// A standing grant the issuer holds and fires itself, with a policy allowing
|
|
/// three firings of 200 µJ each.
|
|
fn standing() -> (SignedGrant, SignedMintPolicy) {
|
|
let sg = issue(
|
|
&sk(1),
|
|
Grant {
|
|
issuer: did(&sk(1)),
|
|
subject: did(&sk(1)),
|
|
capability: Capability::new(resource(), ["read", "write"]),
|
|
budget_uj: 1_000,
|
|
budget_funds: None,
|
|
caveats: Vec::new(),
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [8; 16],
|
|
},
|
|
)
|
|
.unwrap();
|
|
let policy =
|
|
issue_policy(&sk(1), &sg, ContentId::of_bytes(b"webhook:builds"), 200, 3, Capability::new(resource(), ["read"]), 0, [9; 16])
|
|
.unwrap();
|
|
(sg, policy)
|
|
}
|
|
|
|
/// Fire the standing grant on event `n` and seal the firing, as an
|
|
/// enforcement point does before the child leaves it.
|
|
fn fire(
|
|
sg: &SignedGrant,
|
|
policy: &SignedMintPolicy,
|
|
ledger: &mut MintLedger,
|
|
log: &mut ReceiptLog,
|
|
n: u8,
|
|
) -> Result<SignedGrant, MintError> {
|
|
let event = TriggerEvent { class: ContentId::of_bytes(b"webhook:builds"), payload_id: ContentId::of_bytes(&[n]) };
|
|
let child = mint_on_trigger(sg, policy, ledger, &sk(1), did(&sk(2)), &event, [n; 16])?;
|
|
let policy_id = ContentId::of(&policy.policy).unwrap();
|
|
let remaining = sg.grant.budget_uj.saturating_sub(ledger.spent_uj);
|
|
log.append_mint(&sg.grant, policy_id, event.payload_id, policy.policy.per_fire_budget_uj, remaining);
|
|
Ok(child)
|
|
}
|
|
|
|
/// The mint ledger a restart holds: the copy it persisted, restored with what
|
|
/// its receipt stream rebuilds for the standing grant.
|
|
fn restarted(persisted: MintLedger, entries: &[Vec<u8>], standing: ContentId) -> MintLedger {
|
|
let minted = replay_mints(entries.iter().map(Vec::as_slice), STEWARD, &pubkey());
|
|
let mut ledger = persisted;
|
|
ledger.restore(&MintLedger::of_firings(minted.into_iter().filter(|(g, _)| *g == standing).map(|(_, uj)| uj)));
|
|
ledger
|
|
}
|
|
|
|
/// Fault P3: a mint ledger reading fewer fires than happened looked the same
|
|
/// as one that fired less, so a standing grant restarted from a stale ledger
|
|
/// fired past `max_fires`. Nothing a firing left behind rebuilt the ledger: a
|
|
/// mint sealed no receipt, and a child never exercised left no trace. Each
|
|
/// firing now seals a receipt stating `minted_uj` before the child leaves,
|
|
/// and a restart restores the ledger it persisted with what its receipt
|
|
/// stream rebuilds, keeping the higher figures.
|
|
#[test]
|
|
fn p3_a_stale_mint_ledger_fires_a_standing_grant_past_max_fires() {
|
|
let (sg, policy) = standing(); // max_fires 3, 200 µJ a fire
|
|
let mut log = log();
|
|
let mut ledger = MintLedger::default();
|
|
fire(&sg, &policy, &mut ledger, &mut log, 1).unwrap();
|
|
// The enforcement point persists the ledger here, and no later.
|
|
let persisted = ledger.clone();
|
|
fire(&sg, &policy, &mut ledger, &mut log, 2).unwrap();
|
|
fire(&sg, &policy, &mut ledger, &mut log, 3).unwrap();
|
|
assert!(matches!(fire(&sg, &policy, &mut ledger, &mut log, 4), Err(MintError::RateLimited)), "the policy is spent");
|
|
assert_eq!(verify_log(log.entries(), STEWARD, &pubkey()), Ok(3), "the stream holds the three firings");
|
|
|
|
// A restart reads the ledger it persisted, and its receipt stream.
|
|
let mut back = restarted(persisted, log.entries(), sg.grant.id());
|
|
let refired = (5..8).filter(|&n| fire(&sg, &policy, &mut back, &mut log, n).is_ok()).count();
|
|
assert_eq!(
|
|
refired, 0,
|
|
"fault P3: a standing grant with max_fires 3 that fired 3 times fired {refired} more after a restart \
|
|
from a ledger reading 1 fire"
|
|
);
|
|
}
|
|
|
|
/// The stream rebuilds the ledger the firings left, whatever copy the
|
|
/// restart holds: none at all, a stale one, or the live one.
|
|
#[test]
|
|
fn a_firing_stream_rebuilds_the_mint_ledger() {
|
|
let (sg, policy) = standing();
|
|
let mut log = log();
|
|
let mut ledger = MintLedger::default();
|
|
for n in 1..=2 {
|
|
fire(&sg, &policy, &mut ledger, &mut log, n).unwrap();
|
|
}
|
|
assert_eq!(ledger, MintLedger { fires: 2, spent_uj: 400 });
|
|
for persisted in [MintLedger::default(), MintLedger { fires: 1, spent_uj: 200 }, ledger.clone()] {
|
|
assert_eq!(restarted(persisted.clone(), log.entries(), sg.grant.id()), ledger, "from {persisted:?}");
|
|
}
|
|
let other = grant(1_000, Vec::new()).grant.id();
|
|
assert_eq!(restarted(MintLedger::default(), log.entries(), other), MintLedger::default(), "another grant's firings");
|
|
}
|
|
|
|
/// A firing's receipt is an allow on the standing grant that runs no work: it
|
|
/// debits nothing and accrues nothing, group accounting does not count it as
|
|
/// an action, and the call its child makes debits the child and the standing
|
|
/// grant as any chained call does, and is no firing.
|
|
#[test]
|
|
fn a_firing_debits_nothing_and_its_child_debits_the_chain() {
|
|
let (sg, policy) = standing();
|
|
let mut log = log();
|
|
let mut ledger = MintLedger::default();
|
|
let child = fire(&sg, &policy, &mut ledger, &mut log, 1).unwrap();
|
|
let core = extract(&log.entries()[0], STEWARD, &pubkey()).unwrap();
|
|
assert!(core.is_mint());
|
|
assert_eq!((core.minted_uj, core.action.as_str(), core.cost_uj, core.budget_remaining_uj), (Some(200), "mint", 0, 800));
|
|
assert_eq!(core.grant_id, sg.grant.id());
|
|
assert!(core.debits().is_empty() && core.money_debits().is_empty() && core.tainted_accruals().is_empty());
|
|
assert_eq!(replay_ledger(log.entries().iter().map(Vec::as_slice), STEWARD, &pubkey()), jcp_runtime::Ledger::default());
|
|
let (totals, bad) = aggregate(log.entries().iter().map(Vec::as_slice), STEWARD, &pubkey());
|
|
assert!(totals.is_empty() && bad == 0, "a firing is not an action: {totals:?}");
|
|
|
|
let links = vec![sg.clone(), child.clone()];
|
|
let ids: Vec<ContentId> = links.iter().map(|g| g.grant.id()).collect();
|
|
let f = frame(&child);
|
|
let d = Runtime::new().decide_chained(&f, &links, did(&sk(2)), Energy::measured(50), 10);
|
|
assert!(d.is_allow());
|
|
log.append_chained(&f, &ids, &child.grant, &d);
|
|
let back = restored(log.entries());
|
|
assert_eq!((back.spent(&ids[0]), back.spent(&ids[1])), (50, 50));
|
|
assert_eq!(replay_mints(log.entries().iter().map(Vec::as_slice), STEWARD, &pubkey()), vec![(ids[0], 200)]);
|
|
}
|
|
|
|
/// A refusal mints nothing (C3), so a denial stating `minted_uj` fires
|
|
/// nothing; a member that does not parse is refused rather than read as
|
|
/// absent; and a firing's member, changed on its signed receipt, does not
|
|
/// verify.
|
|
#[test]
|
|
fn a_refusal_fires_nothing_and_a_malformed_member_is_refused() {
|
|
let (sg, policy) = standing();
|
|
let mut log = log();
|
|
fire(&sg, &policy, &mut MintLedger::default(), &mut log, 1).unwrap();
|
|
let payload = jcr1::verify_cose_sign1(&log.entries()[0], STEWARD, &pubkey()).unwrap();
|
|
let mut wire: Value = jcp_schema::from_canonical_cbor(&payload).unwrap();
|
|
for m in ["seq", "prev"] {
|
|
wire.as_object_mut().unwrap().remove(m);
|
|
}
|
|
assert!(extract(&seal_wire(&wire), STEWARD, &pubkey()).unwrap().is_mint());
|
|
|
|
let mut refused = wire.clone();
|
|
refused["verdict"] = json!("deny");
|
|
refused["deny_reason"] = json!("rate-limited");
|
|
let env = seal_wire(&refused);
|
|
assert!(!extract(&env, STEWARD, &pubkey()).unwrap().is_mint());
|
|
assert!(replay_mints([env.as_slice()], STEWARD, &pubkey()).is_empty());
|
|
|
|
let mut malformed = wire.clone();
|
|
malformed["minted_uj"] = json!("200");
|
|
assert!(extract(&seal_wire(&malformed), STEWARD, &pubkey()).is_err());
|
|
|
|
let name = [&[0x60 | 9u8][..], b"minted_uj"].concat();
|
|
let mut changed = log.entries().to_vec();
|
|
let at = changed[0].windows(name.len()).position(|w| w == name).unwrap() + name.len();
|
|
changed[0][at] ^= 0x01;
|
|
assert!(extract(&changed[0], STEWARD, &pubkey()).is_err());
|
|
assert!(replay_mints(changed.iter().map(Vec::as_slice), STEWARD, &pubkey()).is_empty());
|
|
}
|