From ec2292174b266ee42ed08317d1443415d531f513 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 2 Nov 2026 19:14:53 -0500 Subject: [PATCH] =?UTF-8?q?jcp=20=C2=A74.13:=20a=20retry=20is=20not=20a=20?= =?UTF-8?q?second=20call,=20where=20the=20caller=20names=20the=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §4.11 makes a restart give no budget back. This is the case one layer out: the same call delivered twice. An endpoint already recognised a repeat within its freshness window, by the request's content address, and answered it from the record without debiting again — so the mechanism was there, and this narrows it rather than inventing it. The gap is what that key is made of. The content address covers the caller's stamp, so it catches a redelivery that reproduced the request's bytes exactly, and misses the one that matters: a transport whose retry is slow enough that the first stamp has aged out, where the caller MUST re-stamp to be admitted at all. That retry has a different address, is admitted as a new call, and is debited again. The overspend §4.11 exists to prevent happens anyway, for a reason §4.11 does not reach. The cause is that identity was incidental — derived from the bytes — rather than declared. What changes - `WireRequest::call_id`: 32 bytes the caller chose, the same across every delivery of one call and different between calls. Signed with the rest of the request, `Option` with `serde(default, skip_serializing_if)`, so a request without one has the bytes it always had and still verifies against the old signable bytes. A test pins both. - Where a request carries one, `admit` recognises a repeat by `(caller, call_id)` before it looks at the bytes, and answers what the first delivery was answered without debiting the grant again. - The index is re-established by the replay `with_journal` already performs, so a restart does not forget which calls it decided and debit their retries. - `Endpoint::with_replay_horizon` sets how long a call id is remembered, defaulting to `DEFAULT_SKEW_SECS` so an endpoint that sets nothing behaves as it did. A `Called` entry is retained by **when it was decided**, not by the request's stamp: an entry pruned by the original stamp would already be gone when the re-stamped retry arrives, which is the whole case. Three things the spec says that the code holds **The id is scoped to its caller.** The key is the pair, never the id alone. A global id space would let one caller suppress another's call by guessing its id — a denial with no trace, since a suppressed call looks exactly like a recognised repeat. **An absent id is not a promise of uniqueness.** Without one, every delivery is a distinct call. A host must not infer an identity the caller did not declare: two byte-identical calls may be two calls the caller meant, and a test holds that two ids that differ both happen. **The guarantee is bounded, and says so.** Past the horizon a repeat is no longer recognised. Remembering every call forever would need state forever; a bounded guarantee that names its bound is honest where an unbounded one would not be true. The horizon MUST be at least the longest retry of the transport in front of it, or the mechanism does not cover the case it was added for. What this deliberately does not add Whether an action *may* be re-dispatched is a property of the action, not of the id, and §4.11 already governs the unresolved case well: no silent retry, no reporting an in-flight action as done or failed, surfaced naming the party that held it. A declared id is what makes a re-dispatch *decidable* for a dispatcher that does classify its actions — "safe to re-dispatch" means nothing until the re-dispatch can be recognised as the same call. So no second replay class is introduced here beside the one the dispatching layer already has. The spec states the authority instead: the classification is the implementing party's, the decision to act on it is the enforcement point's, and a model is never asked whether an action already ran, because it would be guessing about state it cannot observe and a guess is not evidence. Incidentally, the signable-bytes variants collapse into one. `signed_bytes(&self)` builds the canonical bytes from whichever optionals a request carries, and `verify_caller` reads it instead of branching on `challenge`; `sign_parts` takes both optionals and `sign`/`sign_challenged` delegate. Adding the third optional field would otherwise have doubled the number of ways to ask the same question. Conformance class C29. Evidence, on the Framework box (nothing on the CI host, which is mid-matrix for another PR): the pack reports `✓ C29 a retry is not a second call` and all checks passed; 66 test suites green across the workspace; clippy `-D warnings --no-deps` clean on both crates touched. The two `unused_mut` warnings clippy reports in `jcp-energy` are pre-existing and in a crate this change does not touch, so they are left for their own change. Co-Authored-By: Claude Opus 5 --- jcp/SPEC.md | 59 ++++ jcp/jcp-rs/crates/jcp-conformance/src/lib.rs | 89 ++++++ jcp/jcp-rs/crates/jcp-wire/src/lib.rs | 304 +++++++++++++++++-- 3 files changed, 433 insertions(+), 19 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 0fc88928c..9fe973e70 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -628,6 +628,65 @@ bounded at each point separately, so `n` points admit up to `n` times the limit. A deployment that needs one bound across points MUST route the grant's calls through one of them, or share its history between them. +### 4.13 A retry is not a second call (RECOMMENDED where a transport can redeliver) + +§4.11 makes a restart give no budget back. It leaves open the case one layer +out: the same call delivered twice. + +An endpoint already recognises a repeat it has seen within its freshness window, +by the request's content address, and answers it from the record without +debiting again. That catches a redelivery which reproduced the request's bytes +exactly — and the bytes include the caller's stamp. So it catches the fast, +byte-identical redelivery, and misses the one that matters: a transport whose +retry is slow enough that the first stamp has aged out, where the caller must +**re-stamp** to be admitted at all. That retry has a different content address, +is admitted as a new call, and is debited again. The overspend §4.11 exists to +prevent happens anyway, for a reason §4.11 does not reach. + +The cause is that identity is **incidental** — derived from the bytes — rather +than declared. A caller therefore **SHOULD** name its call: 32 bytes it chose, +the same across every delivery of that call and different between calls, signed +with the rest of the request. Where a request carries one, an endpoint **MUST** +recognise a repeat by it rather than by the request's bytes, and **MUST** answer +a repeat what it answered the first delivery without debiting the grant again. + +Two consequences follow from the id being the caller's. + +- **It is scoped to the caller.** The key is the pair, never the id alone. A + global id space would let one caller suppress another's call by guessing its + id — a denial with no trace, since a suppressed call looks exactly like a + recognised repeat. +- **An absent id is not a promise of uniqueness.** Where a request carries none, + every delivery is a distinct call and the content-address behaviour above is + all an endpoint can offer. A host **MUST NOT** infer an identity the caller + did not declare: two calls that happen to be byte-identical may be two calls + the caller meant. + +**The guarantee is bounded, and says so.** An endpoint remembers a call id for a +declared **horizon**, and a redelivery arriving after it is not recognised. The +horizon **MUST** be at least as long as the longest retry of the transport in +front of it, or the mechanism does not cover the case it was added for. +Remembering every call forever would need state forever; a bounded guarantee +that names its bound is honest where an unbounded one would not be true. + +**What this does not decide.** Whether an action *may* be re-dispatched at all +is a property of the action, not of the call id, and §4.11 already governs the +unresolved case: an endpoint does not silently retry, does not report an +in-flight action as completed or failed, and surfaces it naming the party that +held it. A declared id is what makes a re-dispatch *decidable* where a +dispatcher does classify its actions — "safe to re-dispatch" means nothing until +the re-dispatch can be recognised as the same call. Any such classification is +the implementing party's declaration, and the decision to act on it is the +enforcement point's. **A model is never asked whether an action already ran**: +it would be guessing about state it cannot observe, and a guess is not evidence. + +And a recognised repeat is not free of cost, only free of the call's cost. The +endpoint did work to recognise it. What it must not do is debit the call twice. + +Conformance class **C29** exercises the re-stamped retry with and without a +declared id, two deliberately-distinct calls, the caller scoping, survival +across a restart, and the horizon's bound. + ## 5. Energy (REQUIRED) Every metered cost is integer microjoules with a provenance tag: `HwShunt` diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 548a93c05..7f8d80417 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -83,11 +83,100 @@ pub fn checks() -> Vec { Check { id: "C25 flow: label under ceiling, floor, fail-closed, no debit", run: c25_flow }, Check { id: "C27 windows: calls, payloads and joules over a sliding window", run: c27_windows }, Check { id: "C28 endpoint evidence: a signed challenge, bound to the answer", run: c28_endpoint_evidence }, + Check { id: "C29 a retry is not a second call", run: c29_call_identity }, Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials }, Check { id: "C-neg canonical rejects floats", run: cneg_canonical }, ] } +/// C29 — a retry is not a second call (SPEC §4.13). +fn c29_call_identity() -> Result<(), String> { + use jcp_wire::{Endpoint, JournalRecord, MemoryJournal, WireRequest}; + fn clock() -> u64 { + 10 + } + let resource = ContentId::of_bytes(b"c29"); + let caller = sk(3); + let chain = vec![grant(&sk(1), did(&caller), resource, &["read"], 100_000, 1_000)]; + let ep = || { + Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(clock) + }; + let req = |now: u64, who: &SigningKey, call: Option<[u8; 32]>| { + let f = frame(chain.last().unwrap(), resource, "read", Trust::Control); + WireRequest::sign_parts(f, chain.clone(), who, Energy::estimator(7), now, None, call) + .map_err(|e| format!("sign: {e:?}")) + }; + let leaf = chain.last().unwrap().grant.id(); + + // Without a declared id, a retry that re-stamped is a second call. + let mut anon = ep(); + anon.handle(&req(10, &caller, None)?); + let once = anon.runtime.spent(&leaf); + anon.handle(&req(11, &caller, None)?); + if anon.runtime.spent(&leaf) != once * 2 { + return Err("a re-stamped retry with no declared id should be a second call".into()); + } + + // With one, it is the same call: paid once, answered what it was answered. + let mut named = ep(); + let first = named.handle(&req(10, &caller, Some([0xC1; 32]))?); + let paid = named.runtime.spent(&leaf); + let again = named.handle(&req(11, &caller, Some([0xC1; 32]))?); + if named.runtime.spent(&leaf) != paid { + return Err("a declared repeat was debited twice".into()); + } + if again.receipt != first.receipt { + return Err("a declared repeat was answered something else".into()); + } + + // Two calls that say they differ both happen. + let mut distinct = ep(); + distinct.handle(&req(10, &caller, Some([0xA1; 32]))?); + let one = distinct.runtime.spent(&leaf); + distinct.handle(&req(10, &caller, Some([0xA2; 32]))?); + if distinct.runtime.spent(&leaf) != one * 2 { + return Err("a different declared id should be a different call".into()); + } + + // The id is scoped to its caller: another caller's identical id is not a + // repeat of mine, or one caller could suppress another's call. + let mut scoped = ep(); + let mine = req(10, &caller, Some([0xC1; 32]))?; + let mine_answer = scoped.handle(&mine); + let theirs = req(10, &sk(4), Some([0xC1; 32]))?; + if scoped.handle(&theirs).receipt == mine_answer.receipt { + return Err("another caller's id was taken for mine".into()); + } + + // The index survives a restart, or the retry is debited again — the + // overspend §4.11 prevents, one layer out. + let recorded = [JournalRecord::Decided { at: 10, request: req(10, &caller, Some([0xC1; 32]))? }]; + let mut restarted = ep() + .with_journal(MemoryJournal::default(), &recorded) + .map_err(|e| format!("replay: {e:?}"))?; + let replayed = restarted.runtime.spent(&leaf); + restarted.handle(&req(11, &caller, Some([0xC1; 32]))?); + if restarted.runtime.spent(&leaf) != replayed { + return Err("a restart forgot a call it had decided".into()); + } + + // And the guarantee is bounded by the horizon rather than unbounded. + fn late() -> u64 { + 400 + } + let mut past = Endpoint::new(Runtime::new(), sk(7), "did:example:steward") + .with_clock(late) + .with_replay_horizon(60) + .with_journal(MemoryJournal::default(), &recorded) + .map_err(|e| format!("replay: {e:?}"))?; + let before = past.runtime.spent(&leaf); + past.handle(&req(400, &caller, Some([0xC1; 32]))?); + if past.runtime.spent(&leaf) <= before { + return Err("past the horizon a call should no longer be recognised".into()); + } + Ok(()) +} + fn sk(s: u8) -> SigningKey { SigningKey::from_bytes(&[s; 32]) } diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index 43bbf43ef..aca7db712 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -114,6 +114,15 @@ pub struct WireRequest { /// always had. #[serde(default, skip_serializing_if = "Option::is_none")] pub challenge: Option<[u8; 32]>, + /// The caller's name for *this call* (SPEC §4.13): 32 bytes it chose, + /// kept the same across every delivery of the same call and different + /// between calls. Where it is present the endpoint recognises a repeat by + /// it instead of by the request's bytes, so a retry that re-stamps `now` + /// is still the same call rather than a second one. It is signed with the + /// rest of the request, and omitted when absent, so a request without one + /// has the bytes it always had. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub call_id: Option<[u8; 32]>, } /// The borrowed signable view of a request — every field but the signature. @@ -126,6 +135,8 @@ struct SignableRequest<'a> { now: u64, #[serde(skip_serializing_if = "Option::is_none")] challenge: Option<&'a [u8; 32]>, + #[serde(skip_serializing_if = "Option::is_none")] + call_id: Option<&'a [u8; 32]>, } impl WireRequest { @@ -137,7 +148,7 @@ impl WireRequest { cost: &Energy, now: u64, ) -> Result, SchemaError> { - canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None }) + canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None, call_id: None }) } /// The canonical bytes the caller signs for a request carrying @@ -151,7 +162,52 @@ impl WireRequest { now: u64, challenge: &[u8; 32], ) -> Result, SchemaError> { - canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge) }) + canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge), call_id: None }) + } + + /// The canonical bytes *this* request's signature covers, whichever + /// optional fields it carries. + /// + /// One function rather than one per combination of optionals: each is + /// skipped when absent, so a request carrying none has the bytes it + /// always had, and adding another optional does not double the number of + /// ways to ask this question. + pub fn signed_bytes(&self) -> Result, SchemaError> { + canonical_cbor(&SignableRequest { + frame: &self.frame, + chain: &self.chain, + caller: &self.caller, + cost: &self.cost, + now: self.now, + challenge: self.challenge.as_ref(), + call_id: self.call_id.as_ref(), + }) + } + + /// Build and sign a request, naming each optional field explicitly. + /// [`sign`](Self::sign) and [`sign_challenged`](Self::sign_challenged) + /// are this with one of them fixed. + /// + /// The argument list is the request's own fields; collapsing the two + /// optionals into a struct would hide which of them a call site sets, + /// which is the thing a reader of a signing call needs to see. + #[allow(clippy::too_many_arguments)] + pub fn sign_parts( + frame: Frame, + chain: Vec, + caller_sk: &SigningKey, + cost: Energy, + now: u64, + challenge: Option<[u8; 32]>, + call_id: Option<[u8; 32]>, + ) -> Result { + use ed25519_dalek::Signer; + let caller = Did::from_verifying_key(&caller_sk.verifying_key()); + let mut req = + Self { frame, chain, caller, cost, now, signature: Vec::new(), challenge, call_id }; + let bytes = req.signed_bytes()?; + req.signature = caller_sk.sign(&bytes).to_bytes().to_vec(); + Ok(req) } /// Build and sign a request that asks for the endpoint's evidence, bound @@ -164,11 +220,7 @@ impl WireRequest { now: u64, challenge: [u8; 32], ) -> Result { - use ed25519_dalek::Signer; - let caller = Did::from_verifying_key(&caller_sk.verifying_key()); - let bytes = Self::challenged_signable_bytes(&frame, &chain, &caller, &cost, now, &challenge)?; - let signature = caller_sk.sign(&bytes).to_bytes().to_vec(); - Ok(Self { frame, chain, caller, cost, now, signature, challenge: Some(challenge) }) + Self::sign_parts(frame, chain, caller_sk, cost, now, Some(challenge), None) } /// Build and sign a request with the caller's key. @@ -179,11 +231,7 @@ impl WireRequest { cost: Energy, now: u64, ) -> Result { - use ed25519_dalek::Signer; - let caller = Did::from_verifying_key(&caller_sk.verifying_key()); - let bytes = Self::signable_bytes(&frame, &chain, &caller, &cost, now)?; - let signature = caller_sk.sign(&bytes).to_bytes().to_vec(); - Ok(Self { frame, chain, caller, cost, now, signature, challenge: None }) + Self::sign_parts(frame, chain, caller_sk, cost, now, None, None) } /// Attach an externally produced signature (e.g. a FROST k-of-n aggregate) @@ -196,18 +244,14 @@ impl WireRequest { now: u64, signature: Vec, ) -> Self { - Self { frame, chain, caller, cost, now, signature, challenge: None } + Self { frame, chain, caller, cost, now, signature, challenge: None, call_id: None } } /// Whether `signature` is the caller's, over this request's signable bytes. pub fn verify_caller(&self) -> bool { use ed25519_dalek::Verifier; let Ok(vk) = self.caller.verifying_key() else { return false }; - let bytes = match &self.challenge { - None => Self::signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now), - Some(c) => Self::challenged_signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now, c), - }; - let Ok(bytes) = bytes else { + let Ok(bytes) = self.signed_bytes() else { return false; }; let Ok(sig) = ed25519_dalek::Signature::from_slice(&self.signature) else { return false }; @@ -330,6 +374,18 @@ pub fn read_msg(r: &mut impl Read) -> Result, + /// The calls decided within the replay horizon, by the caller and the + /// call id it declared (SPEC §4.13). Scoped to the caller because the id + /// is the caller's to choose: were it global, one caller could suppress + /// another's call by guessing its id. + calls: HashMap<(Did, [u8; 32]), Called>, + /// How long a declared call id is remembered. The guarantee is bounded by + /// this and says so: recognising a repeat forever would need state + /// forever. + replay_horizon_secs: u64, /// Where each admitted request and each revocation is recorded first. journal: Box, /// Where this endpoint's evidence comes from, if it can give any. @@ -377,6 +442,8 @@ impl Endpoint { clock: system_clock, skew_secs: DEFAULT_SKEW_SECS, decided: HashMap::new(), + calls: HashMap::new(), + replay_horizon_secs: DEFAULT_SKEW_SECS, journal: Box::new(MemoryJournal::default()), attester: None, } @@ -415,6 +482,16 @@ impl Endpoint { // this is the answer the request got before the restart, // byte for byte, and a repeat of it gets that again. let response = self.respond(request, decision); + // The call index is re-established by the same replay, or + // a restart would forget which calls it had already + // decided and debit their retries again — the overspend + // §4.11 exists to prevent, one layer out. + if let Some(cid) = request.call_id { + self.calls.insert( + (request.caller, cid), + Called { decided_at: *at, response: response.clone() }, + ); + } self.decided.insert(id, Answered { stamp: request.now, response }); } JournalRecord::Revoked { revocation, grant } => { @@ -427,6 +504,8 @@ impl Endpoint { let now = (self.clock)(); let skew = self.skew_secs; self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now); + let horizon = self.replay_horizon_secs; + self.calls.retain(|_, c| c.decided_at.saturating_add(horizon) >= now); self.journal = Box::new(journal); Ok(self) } @@ -441,6 +520,18 @@ impl Endpoint { Ok(()) } + /// Remember a declared call id for `secs` instead of the default + /// [`DEFAULT_SKEW_SECS`] (SPEC §4.13). + /// + /// A transport that can redeliver after an outage needs a horizon at + /// least as long as its longest retry, or a redelivery arrives after the + /// endpoint has forgotten the call and is debited as a new one. The cost + /// is state, which is why it is a choice and not a constant. + pub fn with_replay_horizon(mut self, secs: u64) -> Self { + self.replay_horizon_secs = secs; + self + } + /// Decide at `clock` (unix seconds) instead of the system clock. pub fn with_clock(mut self, clock: fn() -> u64) -> Self { self.clock = clock; @@ -462,9 +553,20 @@ impl Endpoint { fn admit(&mut self, req: &WireRequest, now: u64) -> Result { let skew = self.skew_secs; self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now); + let horizon = self.replay_horizon_secs; + self.calls.retain(|_, c| c.decided_at.saturating_add(horizon) >= now); if now.abs_diff(req.now) > skew { return Err(jcp_schema::DenyReason::StaleRequest); } + // A declared call id answers first. Recognising a repeat by the + // request's bytes only catches a redelivery that reproduced them + // exactly, including `now` — so it misses the retry that re-stamped, + // which is the one a slow transport actually sends. + if let Some(cid) = req.call_id { + if let Some(c) = self.calls.get(&(req.caller, cid)) { + return Ok(Admission::Repeat(c.response.clone())); + } + } let id = ContentId::of(req).map_err(|_| jcp_schema::DenyReason::BadSignature)?; Ok(match self.decided.get(&id) { Some(a) => Admission::Repeat(a.response.clone()), @@ -538,6 +640,12 @@ impl Endpoint { let response = self.respond(req, decision); if let (Some(id), None) = (id, refused) { self.decided.insert(id, Answered { stamp: req.now, response: response.clone() }); + if let Some(cid) = req.call_id { + self.calls.insert( + (req.caller, cid), + Called { decided_at: now, response: response.clone() }, + ); + } } response } @@ -840,7 +948,7 @@ mod tests { assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded)); } - /// A window survives a restart (SPEC §4.12): the endpoint replays each + /// A window survives a restart (SPEC §4.13): the endpoint replays each /// decision at the time it made it, so the window a call filled is still /// full afterwards. #[test] @@ -1209,4 +1317,162 @@ mod tests { tampered.now = 99; assert!(!tampered.verify_caller()); } + + + /// A request stamped `now`, carrying the caller's name for the call. + fn identified(chain: &[SignedGrant], r: ContentId, cost: u64, now: u64, call: [u8; 32]) -> WireRequest { + let frame = request(chain, did(&sk(3)), r, Trust::Control, cost).frame; + WireRequest::sign_parts(frame, chain.to_vec(), &sk(3), Energy::estimator(cost), now, None, Some(call)) + .unwrap() + } + + fn spent_on_leaf(ep: &Endpoint, chain: &[SignedGrant]) -> u64 { + ep.runtime.spent(&chain.last().unwrap().grant.id()) + } + + /// A request without a call id is byte-for-byte what it was before the + /// field existed, and still verifies against the old signable bytes. + #[test] + fn a_request_without_a_call_id_has_the_bytes_it_always_had() { + let (chain, _, r) = fixture(); + let plain = stamped(&chain, r, 1, 10); + let wire = canonical_cbor(&plain).unwrap(); + assert!(!wire.windows(7).any(|w| w == b"call_id"), "no call_id field on the wire"); + use ed25519_dalek::Verifier; + let old = + WireRequest::signable_bytes(&plain.frame, &plain.chain, &plain.caller, &plain.cost, plain.now) + .unwrap(); + let sig = ed25519_dalek::Signature::from_slice(&plain.signature).unwrap(); + sk(3).verifying_key().verify(&old, &sig).expect("signed over the bytes it always was"); + } + + /// The id is inside the signature, so a relay cannot retarget a call by + /// editing it — which would let it replay one call as another. + #[test] + fn the_call_id_is_covered_by_the_signature() { + let (chain, _, r) = fixture(); + let mut req = identified(&chain, r, 1, 10, [0xC1; 32]); + assert!(req.verify_caller()); + req.call_id = Some([0xC2; 32]); + assert!(!req.verify_caller(), "editing the call id must break the signature"); + } + + /// The gap this closes. A transport retries; the first stamp has aged, so + /// the client re-stamps. Recognising a repeat by the request's bytes + /// cannot see that this is the same call, and the grant is debited twice. + /// A declared id is seen. + #[test] + fn a_re_stamped_retry_is_the_same_call_only_when_it_says_so() { + let (chain, _, r) = fixture(); + + // Without an id: the re-stamped retry is a second call, and pays. + let mut anon = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + anon.handle(&stamped(&chain, r, 7, 10)); + let once = spent_on_leaf(&anon, &chain); + anon.handle(&stamped(&chain, r, 7, 11)); + assert_eq!(spent_on_leaf(&anon, &chain), once * 2, "the retry was debited again"); + + // With one: the same call, answered from the record. + let mut named = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + let first = named.handle(&identified(&chain, r, 7, 10, [0xC1; 32])); + let after_first = spent_on_leaf(&named, &chain); + let again = named.handle(&identified(&chain, r, 7, 11, [0xC1; 32])); + assert_eq!(spent_on_leaf(&named, &chain), after_first, "a declared repeat pays once"); + assert_eq!(again.receipt, first.receipt, "and is answered what it was answered"); + } + + /// Two calls the caller means as distinct are distinct, even when they + /// would otherwise be byte-identical. Identity is declared, not inferred. + #[test] + fn two_calls_that_say_they_differ_both_happen() { + let (chain, _, r) = fixture(); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + ep.handle(&identified(&chain, r, 7, 10, [0xA1; 32])); + let once = spent_on_leaf(&ep, &chain); + ep.handle(&identified(&chain, r, 7, 10, [0xA2; 32])); + assert_eq!(spent_on_leaf(&ep, &chain), once * 2, "a different id is a different call"); + } + + /// The id is the caller's to choose, so it is scoped to the caller. Were + /// it global, one caller could suppress another's call by guessing its id. + #[test] + fn a_call_id_is_scoped_to_its_caller() { + let (chain, _, r) = fixture(); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + let mine = identified(&chain, r, 7, 10, [0xC1; 32]); + ep.handle(&mine); + // The same 32 bytes from a different caller. The grant does not admit + // that caller, so what matters is that it is *decided* rather than + // answered from my record: a suppressed call would be a repeat. + let frame = request(&chain, did(&sk(4)), r, Trust::Control, 7).frame; + let theirs = WireRequest::sign_parts( + frame, + chain.to_vec(), + &sk(4), + Energy::estimator(7), + 10, + None, + Some([0xC1; 32]), + ) + .unwrap(); + let resp = ep.handle(&theirs); + assert_ne!(resp.receipt, ep.handle(&mine).receipt, "another caller's id is not mine"); + } + + /// The index is re-established by the same replay §4.11 already does, or a + /// restart would forget which calls it decided and debit their retries + /// again — the overspend §4.11 exists to prevent, one layer out. + #[test] + fn a_restart_still_knows_which_calls_it_decided() { + let (chain, _, r) = fixture(); + let req = identified(&chain, r, 7, 10, [0xC1; 32]); + let mut before = Endpoint::new(Runtime::new(), sk(7), "did:example:steward") + .with_clock(at_10) + .with_journal(MemoryJournal::default(), &[]) + .unwrap(); + before.handle(&req); + + let recorded = [JournalRecord::Decided { at: 10, request: req.clone() }]; + let mut after = Endpoint::new(Runtime::new(), sk(7), "did:example:steward") + .with_clock(at_10) + .with_journal(MemoryJournal::default(), &recorded) + .unwrap(); + let replayed = spent_on_leaf(&after, &chain); + // The retry arrives after the restart, re-stamped. + after.handle(&identified(&chain, r, 7, 11, [0xC1; 32])); + assert_eq!(spent_on_leaf(&after, &chain), replayed, "the retry pays nothing after a restart"); + } + + /// The guarantee is bounded by the horizon, and says so. Remembering a + /// call forever would need state forever, so past it the endpoint no + /// longer recognises the repeat — which is a limit to state, not a limit + /// to pretend is absent. + #[test] + fn the_guarantee_is_bounded_by_the_horizon() { + let (chain, _, r) = fixture(); + fn at_400() -> u64 { + 400 + } + // Decided at 10 and remembered for 60 s; the endpoint now reads 400. + let recorded = [JournalRecord::Decided { + at: 10, + request: identified(&chain_of(), ContentId::of_bytes(b"db"), 7, 10, [0xC1; 32]), + }]; + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward") + .with_clock(at_400) + .with_replay_horizon(60) + .with_journal(MemoryJournal::default(), &recorded) + .unwrap(); + let replayed = spent_on_leaf(&ep, &chain); + // A retry stamped inside the freshness window, but past the horizon. + ep.handle(&identified(&chain, r, 7, 400, [0xC1; 32])); + assert!( + spent_on_leaf(&ep, &chain) > replayed, + "past the horizon the call is no longer recognised, and is debited" + ); + } + + fn chain_of() -> Vec { + fixture().0 + } } -- 2.54.0 (Apple Git-157)