jcp-wire: a request presented again gets its first answer, so a lost response is recoverable #349
4 changed files with 202 additions and 58 deletions
23
jcp/SPEC.md
23
jcp/SPEC.md
|
|
@ -1245,9 +1245,9 @@ A conforming implementation passes, with real checks:
|
|||
decision enforces an inherited caveat; and a chain whose child dropped one does
|
||||
not verify.
|
||||
- **C8 — wire binding:** a request round-trips the `jcp/1` framing canonically
|
||||
and an enforcement endpoint decides + seals over a live socket; a replayed
|
||||
request is refused `replayed`, and one stamped outside the window
|
||||
`stale-request`.
|
||||
and an enforcement endpoint decides + seals over a live socket; a request
|
||||
presented again gets its first response unchanged and is not debited again,
|
||||
and one stamped outside the window is refused `stale-request`.
|
||||
- **C9 — energy honesty:** the metering ladder always yields a figure and its
|
||||
provenance is internally consistent.
|
||||
- **C10 — delegation control:** depth narrows per hop; a leaf-only grant cannot
|
||||
|
|
@ -1333,10 +1333,19 @@ wire.
|
|||
**A request is decided once.** The wire never carries a secret: grants are
|
||||
signed public objects, and a request is a signed public object too. The endpoint
|
||||
MUST remember every request it decided while that request's stamp is inside the
|
||||
window, and MUST deny one presented again as `replayed`, so a captured request
|
||||
is neither decided nor debited a second time. Once a request's stamp has left
|
||||
the window, a replay of it is stale. Two byte-identical requests are one
|
||||
request, so a caller that means the same action twice stamps the second later.
|
||||
window, together with its response. A request presented again MUST get that same
|
||||
response, the same decision and the same receipt, and MUST NOT be decided,
|
||||
debited or recorded a second time. That holds across a restart, which replays
|
||||
the record (below) and so recovers each response: the decision is deterministic
|
||||
and so is the seal. A captured request is therefore worth nothing beyond what
|
||||
its first exchange carried. A caller whose response was lost recovers it by
|
||||
sending the same request again. Refusing the repeat instead would leave that
|
||||
caller only one way to learn the outcome: stamp the request again, which is a
|
||||
new request and decides and debits the same action a second time. A request
|
||||
refused before it was decided (stale, unauthenticated, or `unrecorded`) is not
|
||||
remembered. Once a request's stamp has left the window, a repeat of it is stale.
|
||||
Two byte-identical requests are one request, so a caller that means the same
|
||||
action twice stamps the second later.
|
||||
|
||||
**The cost is the holder's measurement.** `cost` is the requester's own figure,
|
||||
and the endpoint debits it as given, so a budget binds only as honestly as that
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
//! sibling delegations share their root's budget — delegation never mints
|
||||
//! joules. Caveats bind on every link, and a child cannot drop one.
|
||||
//! - **C8 — wire binding:** a request round-trips the `jcp/1` framing
|
||||
//! canonically and an enforcement endpoint decides + seals over a live socket.
|
||||
//! canonically and an enforcement endpoint decides + seals over a live
|
||||
//! socket; the same request again gets its first answer, not a second
|
||||
//! decision.
|
||||
//! - **C9 — energy honesty:** the meter ladder always yields a figure and its
|
||||
//! provenance is internally consistent (HwShunt ⇒ measured present;
|
||||
//! Estimator ⇒ measured absent).
|
||||
|
|
@ -49,7 +51,7 @@ pub fn checks() -> Vec<Check> {
|
|||
Check { id: "C5 receipt seal + verify", run: c5_receipt },
|
||||
Check { id: "C6 revocation authority + subtree kill", run: c6_revocation },
|
||||
Check { id: "C7 chain verification + budget conservation + inherited caveats", run: c7_chain },
|
||||
Check { id: "C8 wire binding round-trip + live decide + no replay, endpoint clock", run: c8_wire },
|
||||
Check { id: "C8 wire binding round-trip + live decide + decided once, endpoint clock", run: c8_wire },
|
||||
Check { id: "C9 energy provenance honesty", run: c9_energy },
|
||||
Check { id: "C10 delegation control (depth narrows, leaf-only holds)", run: c10_delegation_control },
|
||||
Check { id: "C11 sharded budgets conserve the root at mint", run: c11_shard },
|
||||
|
|
@ -594,10 +596,11 @@ fn c8_wire() -> Result<(), String> {
|
|||
}
|
||||
jcp_receipt::verify(&req.frame, &sg.grant, &resp.decision, &resp.receipt, "did:example:steward", &steward_vk.to_bytes())
|
||||
.map_err(|e| format!("wire receipt failed to verify: {e}"))?;
|
||||
// The same request again is refused, not decided and debited twice.
|
||||
// The same request again gets the answer it got, not a second decision
|
||||
// and debit: the same decision, the same budget left, the same receipt.
|
||||
let again = client.call(&req).map_err(|e| e.to_string())?;
|
||||
if again.decision.verdict != Verdict::Deny(DenyReason::Replayed) {
|
||||
return Err(format!("a replayed wire request was decided again: {:?}", again.decision.verdict));
|
||||
if again.decision != resp.decision || again.receipt != resp.receipt {
|
||||
return Err(format!("a wire request presented again was decided again: {:?}", again.decision));
|
||||
}
|
||||
drop(client);
|
||||
server.join().map_err(|_| "wire server panicked".to_string())?;
|
||||
|
|
|
|||
|
|
@ -607,7 +607,11 @@ pub enum DenyReason {
|
|||
/// request signed for another time is not decided.
|
||||
StaleRequest,
|
||||
/// A wire request the enforcement point has already decided, presented
|
||||
/// again (SPEC §11). It is decided once, and debited once.
|
||||
/// again. The reference endpoint no longer refuses one. A request is still
|
||||
/// decided and debited once, but a repeat now gets its first answer,
|
||||
/// unchanged, so that a caller whose response was lost can recover it
|
||||
/// (SPEC §11). The reason is kept so that receipts sealed with it, and
|
||||
/// endpoints that still refuse, keep decoding.
|
||||
Replayed,
|
||||
/// The enforcement point could not record the request before deciding it
|
||||
/// (SPEC §4.11), so it does not decide it: a decision it could not record
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@
|
|||
//! signed public objects. The endpoint decides at **its own clock**: a
|
||||
//! request's `now` is a freshness stamp, and one outside the window
|
||||
//! ([`Endpoint::with_skew`]) is denied `stale-request`. Within the window the
|
||||
//! endpoint remembers what it decided, so a captured request presented again
|
||||
//! is denied `replayed` rather than decided, and debited, a second time.
|
||||
//! endpoint remembers what it decided and what it answered, so a request
|
||||
//! presented again gets that same answer, the same decision and the same
|
||||
//! receipt, and is neither decided nor debited a second time. A caller whose
|
||||
//! response was lost recovers it by sending the same request again.
|
||||
//!
|
||||
//! The `cost` a request declares is the requester's own figure, so the
|
||||
//! requester is the grant's holder and the party that metered the action
|
||||
|
|
@ -30,7 +32,8 @@
|
|||
//! request is recorded before it is decided, each revocation before it is
|
||||
//! applied, and [`Endpoint::with_journal`] replays them through the gate at the
|
||||
//! times they were decided, so a restart gives no budget back, unrevokes
|
||||
//! nothing, and forgets no request that may still be shown again.
|
||||
//! nothing, and forgets no request that may still be shown again, nor the
|
||||
//! answer it got.
|
||||
|
||||
mod journal;
|
||||
pub use journal::{FileJournal, Journal, JournalRecord, MemoryJournal};
|
||||
|
|
@ -164,7 +167,7 @@ impl WireRequest {
|
|||
|
||||
/// The enforcement point's answer: the deterministic decision and the sealed
|
||||
/// JCR-1 receipt covering it (allow and deny alike).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WireResponse {
|
||||
pub decision: Decision,
|
||||
pub receipt: Vec<u8>,
|
||||
|
|
@ -213,6 +216,23 @@ pub fn read_msg<T: serde::de::DeserializeOwned>(r: &mut impl Read) -> Result<T,
|
|||
/// How far, by default, a request's `now` may stray from the endpoint's clock.
|
||||
pub const DEFAULT_SKEW_SECS: u64 = 300;
|
||||
|
||||
/// A request the endpoint decided, kept while its stamp is in the window.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Answered {
|
||||
/// The request's own stamp, which says when it leaves the window.
|
||||
stamp: u64,
|
||||
/// What it was answered, given again to the same request.
|
||||
response: WireResponse,
|
||||
}
|
||||
|
||||
/// What [`Endpoint::admit`] lets a fresh, authenticated request do.
|
||||
enum Admission {
|
||||
/// Be decided: the endpoint has not decided it.
|
||||
Decide(ContentId),
|
||||
/// Get the answer it got before: the endpoint decided it already.
|
||||
Repeat(WireResponse),
|
||||
}
|
||||
|
||||
/// An enforcement point serving `jcp/1` over byte streams: the runtime that
|
||||
/// decides, and the steward key that seals every decision.
|
||||
pub struct Endpoint {
|
||||
|
|
@ -223,8 +243,9 @@ pub struct Endpoint {
|
|||
clock: fn() -> u64,
|
||||
/// How far a request's `now` may stray from `clock`.
|
||||
skew_secs: u64,
|
||||
/// The requests decided within the window, by content id, with their stamp.
|
||||
decided: HashMap<ContentId, u64>,
|
||||
/// The requests decided within the window, by content id: each one's
|
||||
/// stamp, and the answer it got.
|
||||
decided: HashMap<ContentId, Answered>,
|
||||
/// Where each admitted request and each revocation is recorded first.
|
||||
journal: Box<dyn Journal + Send>,
|
||||
}
|
||||
|
|
@ -259,10 +280,16 @@ impl Endpoint {
|
|||
return Err(inconsistent(i, "the caller's signature does not verify"));
|
||||
}
|
||||
let id = ContentId::of(request).map_err(|e| inconsistent(i, &e.to_string()))?;
|
||||
if self.decided.insert(id, request.now).is_some() {
|
||||
if self.decided.contains_key(&id) {
|
||||
return Err(inconsistent(i, "the request was decided twice"));
|
||||
}
|
||||
self.runtime.decide_chained(&request.frame, &request.chain, request.caller, request.cost, *at);
|
||||
let decision =
|
||||
self.runtime.decide_chained(&request.frame, &request.chain, request.caller, request.cost, *at);
|
||||
// The decision is deterministic and so is the seal, so
|
||||
// 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);
|
||||
self.decided.insert(id, Answered { stamp: request.now, response });
|
||||
}
|
||||
JournalRecord::Revoked { revocation, grant } => {
|
||||
self.runtime
|
||||
|
|
@ -273,7 +300,7 @@ impl Endpoint {
|
|||
}
|
||||
let now = (self.clock)();
|
||||
let skew = self.skew_secs;
|
||||
self.decided.retain(|_, stamp| stamp.saturating_add(skew) >= now);
|
||||
self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now);
|
||||
self.journal = Box::new(journal);
|
||||
Ok(self)
|
||||
}
|
||||
|
|
@ -300,35 +327,63 @@ impl Endpoint {
|
|||
self
|
||||
}
|
||||
|
||||
/// Whether `req` may be decided now: fresh, and not already decided. A
|
||||
/// request that may be is remembered until its stamp leaves the window,
|
||||
/// after which a replay of it is stale.
|
||||
fn admit(&mut self, req: &WireRequest, now: u64) -> Result<(), jcp_schema::DenyReason> {
|
||||
/// What may be done with `req` now. A stale request is refused. A fresh
|
||||
/// one is decided, unless the endpoint decided it already, in which case
|
||||
/// it gets the answer it got then: a request is decided once, and a
|
||||
/// caller whose response was lost recovers it by sending the same request
|
||||
/// again. A request decided is remembered until its stamp leaves the
|
||||
/// window, after which a repeat of it is stale.
|
||||
fn admit(&mut self, req: &WireRequest, now: u64) -> Result<Admission, jcp_schema::DenyReason> {
|
||||
let skew = self.skew_secs;
|
||||
self.decided.retain(|_, stamp| stamp.saturating_add(skew) >= now);
|
||||
self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now);
|
||||
if now.abs_diff(req.now) > skew {
|
||||
return Err(jcp_schema::DenyReason::StaleRequest);
|
||||
}
|
||||
let id = ContentId::of(req).map_err(|_| jcp_schema::DenyReason::BadSignature)?;
|
||||
if self.decided.insert(id, req.now).is_some() {
|
||||
return Err(jcp_schema::DenyReason::Replayed);
|
||||
}
|
||||
Ok(())
|
||||
Ok(match self.decided.get(&id) {
|
||||
Some(a) => Admission::Repeat(a.response.clone()),
|
||||
None => Admission::Decide(id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Seal `decision` for `req`. The receipt names the leaf grant and — for a
|
||||
/// multi-hop chain — every debited link, so the receipt stream replays
|
||||
/// exactly (SPEC §8.1).
|
||||
fn respond(&self, req: &WireRequest, decision: Decision) -> WireResponse {
|
||||
let chain_ids: Vec<_> = req.chain.iter().map(|sg| sg.grant.id()).collect();
|
||||
let receipt = req
|
||||
.chain
|
||||
.last()
|
||||
.and_then(|leaf| {
|
||||
jcp_receipt::seal_chained(&req.frame, &chain_ids, &leaf.grant, &decision, &self.steward_sk, &self.steward)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
WireResponse { decision, receipt }
|
||||
}
|
||||
|
||||
/// Decide one request and seal the receipt — the whole protocol step,
|
||||
/// transport-free. The caller's signature is verified first: an
|
||||
/// unauthenticated request is denied (`bad-signature`) and sealed like any
|
||||
/// other decision — the refused impersonation is evidence too.
|
||||
///
|
||||
/// A request the endpoint decided already, inside the window, gets the
|
||||
/// answer it got then, unchanged: not decided again, not debited again,
|
||||
/// and not sealed again. It carries nothing the first answer did not, to
|
||||
/// whoever presents it. A request refused before it was decided (stale,
|
||||
/// unauthenticated, or unrecorded) is not remembered, and is decided when
|
||||
/// it is presented again.
|
||||
pub fn handle(&mut self, req: &WireRequest) -> WireResponse {
|
||||
let now = (self.clock)();
|
||||
let mut refused = if req.verify_caller() { self.admit(req, now).err() } else { Some(jcp_schema::DenyReason::BadSignature) };
|
||||
let admitted = if req.verify_caller() { self.admit(req, now) } else { Err(jcp_schema::DenyReason::BadSignature) };
|
||||
let (id, mut refused) = match admitted {
|
||||
Ok(Admission::Repeat(response)) => return response,
|
||||
Ok(Admission::Decide(id)) => (Some(id), None),
|
||||
Err(reason) => (None, Some(reason)),
|
||||
};
|
||||
// Recorded before it is decided: a decision that is lost on a restart
|
||||
// would take its debit with it (SPEC §4.11).
|
||||
if refused.is_none() && self.journal.append(&JournalRecord::Decided { at: now, request: req.clone() }).is_err() {
|
||||
if let Ok(id) = ContentId::of(req) {
|
||||
self.decided.remove(&id);
|
||||
}
|
||||
refused = Some(jcp_schema::DenyReason::Unrecorded);
|
||||
}
|
||||
let decision = match refused {
|
||||
|
|
@ -343,18 +398,11 @@ impl Endpoint {
|
|||
.unwrap_or(0),
|
||||
},
|
||||
};
|
||||
// The receipt names the leaf grant and — for a multi-hop chain — every
|
||||
// debited link, so the receipt stream replays exactly (SPEC §8.1).
|
||||
let chain_ids: Vec<_> = req.chain.iter().map(|sg| sg.grant.id()).collect();
|
||||
let receipt = req
|
||||
.chain
|
||||
.last()
|
||||
.and_then(|leaf| {
|
||||
jcp_receipt::seal_chained(&req.frame, &chain_ids, &leaf.grant, &decision, &self.steward_sk, &self.steward)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
WireResponse { decision, receipt }
|
||||
let response = self.respond(req, decision);
|
||||
if let (Some(id), None) = (id, refused) {
|
||||
self.decided.insert(id, Answered { stamp: req.now, response: response.clone() });
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Serve one connection: read requests, answer decisions, until the peer
|
||||
|
|
@ -506,21 +554,99 @@ mod tests {
|
|||
assert!(ok.handle(&stamped(&chain, r, 1, 97)).decision.is_allow());
|
||||
}
|
||||
|
||||
/// A captured request presented again is refused, not decided and debited
|
||||
/// a second time; a request stamped differently is a new request.
|
||||
/// A request presented again is decided once: it gets the answer it got
|
||||
/// the first time, decision and receipt alike, and is debited once. A
|
||||
/// caller whose response was lost therefore recovers it, where it used to
|
||||
/// be refused `replayed` and could only stamp the request again, which
|
||||
/// decided and debited the same action a second time. A request stamped
|
||||
/// differently is a new request.
|
||||
#[test]
|
||||
fn a_captured_request_is_decided_once() {
|
||||
fn a_request_presented_again_gets_its_first_answer() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
|
||||
let journal = Shared::default();
|
||||
let mut ep = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap();
|
||||
let req = stamped(&chain, r, 10_000, 10);
|
||||
assert!(ep.handle(&req).decision.is_allow());
|
||||
let first = ep.handle(&req);
|
||||
assert!(first.decision.is_allow());
|
||||
let again = ep.handle(&req);
|
||||
assert_eq!(again.decision.verdict, Verdict::Deny(DenyReason::Replayed));
|
||||
assert_eq!(again, first, "the same decision and the same receipt bytes");
|
||||
assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000, "debited once");
|
||||
assert_eq!(journal.0.lock().unwrap().len(), 1, "and recorded once");
|
||||
assert!(ep.handle(&stamped(&chain, r, 10_000, 11)).decision.is_allow());
|
||||
assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 20_000);
|
||||
}
|
||||
|
||||
/// A refusal is an answer too: a request denied by the gate gets the same
|
||||
/// denial again, and one refused before it was decided is decided when
|
||||
/// presented again.
|
||||
#[test]
|
||||
fn a_denial_is_answered_again_and_an_undecided_request_is_not_remembered() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
|
||||
let over = stamped(&chain, r, 60_000, 10);
|
||||
let first = ep.handle(&over);
|
||||
assert_eq!(first.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
|
||||
assert_eq!(ep.handle(&over), first);
|
||||
|
||||
let mut forged = stamped(&chain, r, 1, 10);
|
||||
forged.signature[0] ^= 1;
|
||||
assert_eq!(ep.handle(&forged).decision.verdict, Verdict::Deny(DenyReason::BadSignature));
|
||||
assert_eq!(ep.decided.len(), 1, "only the request the gate decided is remembered");
|
||||
}
|
||||
|
||||
/// What a repeat gets is the answer to that request, made when it was
|
||||
/// decided: a revocation since binds the next request, and does not turn
|
||||
/// an earlier answer into a new decision.
|
||||
#[test]
|
||||
fn a_repeat_after_a_revocation_is_the_earlier_answer_and_a_new_request_is_refused() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut ep = endpoint(at_10);
|
||||
let req = stamped(&chain, r, 10_000, 10);
|
||||
let first = ep.handle(&req);
|
||||
assert!(first.decision.is_allow());
|
||||
let of_root = revoke(&sk(1), &chain[0].grant, [9; 16]).unwrap();
|
||||
ep.revoke(&of_root, &chain[0].grant).unwrap();
|
||||
assert_eq!(ep.handle(&req), first, "the answer it got");
|
||||
assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000, "and nothing more");
|
||||
assert_eq!(ep.handle(&stamped(&chain, r, 1, 11)).decision.verdict, Verdict::Deny(DenyReason::Revoked));
|
||||
}
|
||||
|
||||
/// Over a live stream: the caller's first response never arrives, and it
|
||||
/// sends the same request again and gets that response.
|
||||
#[test]
|
||||
fn a_lost_response_is_recovered_by_sending_the_same_request() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut ep = endpoint(at_10);
|
||||
let req = stamped(&chain, r, 10_000, 10);
|
||||
let lost = ep.handle(&req);
|
||||
let mut wire = Vec::new();
|
||||
write_msg(&mut wire, &req).unwrap();
|
||||
let mut stream = Loopback { input: std::io::Cursor::new(wire), output: Vec::new() };
|
||||
ep.serve_stream(&mut stream).unwrap();
|
||||
let got: WireResponse = read_msg(&mut std::io::Cursor::new(stream.output)).unwrap();
|
||||
assert_eq!(got, lost);
|
||||
assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000);
|
||||
}
|
||||
|
||||
/// A stream whose reads come from `input` and whose writes go to `output`.
|
||||
struct Loopback {
|
||||
input: std::io::Cursor<Vec<u8>>,
|
||||
output: Vec<u8>,
|
||||
}
|
||||
impl Read for Loopback {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.input.read(buf)
|
||||
}
|
||||
}
|
||||
impl Write for Loopback {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.output.write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1_000);
|
||||
fn moving() -> u64 {
|
||||
CLOCK.load(std::sync::atomic::Ordering::SeqCst)
|
||||
|
|
@ -594,19 +720,21 @@ mod tests {
|
|||
assert_eq!(after.handle(&stamped(&chain, r, 1, 10)).decision.verdict, Verdict::Deny(DenyReason::Revoked));
|
||||
}
|
||||
|
||||
/// The endpoint remembers what it decided across a restart, so a request
|
||||
/// still inside the window is refused, not decided a second time.
|
||||
/// The endpoint remembers what it decided, and what it answered, across a
|
||||
/// restart: a request still inside the window gets its first answer, byte
|
||||
/// for byte, rather than being decided a second time.
|
||||
#[test]
|
||||
fn a_request_decided_before_a_restart_is_still_refused() {
|
||||
fn a_request_decided_before_a_restart_gets_the_same_answer_after_it() {
|
||||
let (chain, _, r) = fixture();
|
||||
let journal = Shared::default();
|
||||
let req = stamped(&chain, r, 10_000, 10);
|
||||
let mut before = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap();
|
||||
assert!(before.handle(&req).decision.is_allow());
|
||||
let first = before.handle(&req);
|
||||
assert!(first.decision.is_allow());
|
||||
drop(before);
|
||||
let recorded = journal.0.lock().unwrap().clone();
|
||||
let mut after = endpoint(at_10).with_journal(MemoryJournal::default(), &recorded).unwrap();
|
||||
assert_eq!(after.handle(&req).decision.verdict, Verdict::Deny(DenyReason::Replayed));
|
||||
assert_eq!(after.handle(&req), first, "the answer it got before the restart");
|
||||
assert_eq!(after.runtime.spent(&chain[1].grant.id()), 10_000);
|
||||
|
||||
let twice = [recorded[0].clone(), recorded[0].clone()];
|
||||
|
|
|
|||
Loading…
Reference in a new issue