From 3483910e0e9ec7b9ef2cee816e11b88ce422c8dd Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 09:50:03 -0700 Subject: [PATCH 01/11] chore: open lane for #3250 --- crates/dig-node-core/src/rewards/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 crates/dig-node-core/src/rewards/.gitkeep diff --git a/crates/dig-node-core/src/rewards/.gitkeep b/crates/dig-node-core/src/rewards/.gitkeep new file mode 100644 index 00000000..e69de29b From 21e52ed40fb94393c5b4786cbd9ea4b28ae59335 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 12:24:42 -0700 Subject: [PATCH 02/11] chore(rewards): salvage uncommitted prover-loop scaffolding Recovered from an uncommitted working tree after a session cap. NOT YET WIRED into dig-node-core (no `mod rewards;`, no mod.rs) and NEVER COMPILED -- committed as a checkpoint so the next pass cannot lose it again, per CLAUDE.md invariant 3. Contents, each transcribed against dig-rewards-coin SPEC.md v0.1.1: - spec_constants.rs -- every normative bound, tagged with its clause - port.rs -- the RewardsChainPort seam (dig-rewards-coin is SPEC-only, #3249) plus UnavailableChainPort, which reports Unavailable rather than silently no-op'ing - state.rs -- the SPEC 2.3 status record, deliberately with NO health boolean and NO precomputed staleness (2.4), plus an injectable Clock a test can refuse to advance - admission.rs -- THE single admission point (5.3); self-exclusion on BOTH coordinates, the puzzle-hash coordinate checked before the gate's eligibility is trusted - gate.rs -- the mirror-coin gate: advertises + declares_peer -> owner_puzzle_hash, fail-closed on every absence Known defects carried, fixed under review in this PR, not in this commit: - gate.rs passes the census ordinal `n` to `advertises`, but SPEC 4.6 clause 1 requires `n-1` exactly; as written it admits coins the census excludes - gate.rs evaluate() hardcodes in_grace_window=false, so the SPEC 4.6.3 grace window is unreachable in production and MIRROR_EPOCH_GRACE_SECONDS is dead - the test named absent_declaration_is_ineligible actually asserts PeerNotDeclared - no NewEpoch spend (SPEC 2.1 clause 2) Refs #3250 --- crates/dig-node-core/src/rewards/admission.rs | 215 +++++++++++++ crates/dig-node-core/src/rewards/gate.rs | 299 ++++++++++++++++++ crates/dig-node-core/src/rewards/port.rs | 152 +++++++++ .../src/rewards/spec_constants.rs | 76 +++++ crates/dig-node-core/src/rewards/state.rs | 197 ++++++++++++ 5 files changed, 939 insertions(+) create mode 100644 crates/dig-node-core/src/rewards/admission.rs create mode 100644 crates/dig-node-core/src/rewards/gate.rs create mode 100644 crates/dig-node-core/src/rewards/port.rs create mode 100644 crates/dig-node-core/src/rewards/spec_constants.rs create mode 100644 crates/dig-node-core/src/rewards/state.rs diff --git a/crates/dig-node-core/src/rewards/admission.rs b/crates/dig-node-core/src/rewards/admission.rs new file mode 100644 index 00000000..3fd9a76d --- /dev/null +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -0,0 +1,215 @@ +//! THE single admission point (SPEC §5.3). Every discovery path — the DHT walk, this node's +//! locally-held provider set, the discovered cache, and any manual/operator add — MUST route a +//! candidate through [`admit`] before it becomes an entry decision. There MUST NOT be a second +//! admission function anywhere in this module tree. +//! +//! DIG-Network/dig-node#261 is the analogous defect: an absolute SPEC self-exclusion honoured by +//! the DHT leg and bypassed by the forwarded leg. The lesson is the rule: an invariant enforced on +//! some paths is not an invariant, it is a habit. So this file is deliberately the ONLY place that +//! compares a candidate against this node's own identity, and every caller — regardless of which +//! path produced the candidate — MUST call through here rather than re-implement the comparison. + +use super::gate::{GateOutcome, MirrorCoinGatePort}; + +/// Which discovery path produced a candidate. Exists ONLY for logging/tests (SPEC §5.3 clause 4's +/// control needs to name the path a candidate arrived by) — it MUST NOT change the admission +/// decision, since that would be exactly the per-path habit §5.3 forbids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiscoveryPath { + DhtWalk, + LocalProviderSet, + DiscoveredCache, + ManualAdd, +} + +/// A raw candidate as a discovery path hands it in, before the mirror-coin gate has run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + pub peer_id: [u8; 32], + pub path: DiscoveryPath, +} + +/// This node's own identity, on both SPEC §5.2 coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnIdentity { + pub peer_id: [u8; 32], + /// Every puzzle hash this node's own wallet controls. A `Vec` (not a single hash) because a + /// wallet may hold more than one payout address; SPEC §5.2 excludes on membership, not equality + /// to one distinguished value. + pub controlled_puzzle_hashes: Vec<[u8; 32]>, +} + +impl OwnIdentity { + fn controls(&self, puzzle_hash: &[u8; 32]) -> bool { + self.controlled_puzzle_hashes.iter().any(|h| h == puzzle_hash) + } +} + +/// What [`admit`] decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdmissionDecision { + /// Eligible on the chain gate AND not self — carries the payout puzzle hash the entry would use. + Admit { payout_puzzle_hash: [u8; 32] }, + /// Refused because the candidate is this node itself, on the peer_id coordinate, the puzzle_hash + /// coordinate, or both (SPEC §5.2). Refused at admission, never a display filter (§5.3.3) — the + /// caller MUST NOT write an entry for this candidate under any circumstance. + SelfExcluded, + /// The mirror-coin gate did not admit the candidate (SPEC §4, §10.3) — fail-closed + /// ineligibility, not an accusation. + GateIneligible, +} + +/// THE single admission point. Every discovery path calls this and nothing else decides +/// self-exclusion. +/// +/// Order matters and is deliberate: self-exclusion is checked FIRST, on the `peer_id` coordinate, +/// before any chain read — refusing this node's own peer id costs nothing and needs no gate result. +/// The `payout_puzzle_hash` coordinate can only be checked once the gate has produced one (SPEC §4.3 +/// `owner_puzzle_hash()`), so that half of self-exclusion runs after the gate call but BEFORE the +/// gate's eligibility is trusted — an eligible-but-self-owned candidate is still refused, never +/// admitted then filtered. +pub async fn admit( + candidate: &Candidate, + own: &OwnIdentity, + gate: &dyn MirrorCoinGatePort, + mirror_collateral_epoch_ordinal: Option, +) -> AdmissionDecision { + if candidate.peer_id == own.peer_id { + return AdmissionDecision::SelfExcluded; + } + + match gate + .evaluate(candidate.peer_id, mirror_collateral_epoch_ordinal) + .await + { + GateOutcome::Eligible { + payout_puzzle_hash, + } => { + if own.controls(&payout_puzzle_hash) { + AdmissionDecision::SelfExcluded + } else { + AdmissionDecision::Admit { + payout_puzzle_hash, + } + } + } + GateOutcome::Ineligible(_) => AdmissionDecision::GateIneligible, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rewards::gate::{GateIneligibleReason, MirrorCoinGatePort}; + use async_trait::async_trait; + + struct FakeGate { + /// peer_id -> (eligible?, payout_puzzle_hash) + eligible: std::collections::HashMap<[u8; 32], [u8; 32]>, + } + + #[async_trait] + impl MirrorCoinGatePort for FakeGate { + async fn evaluate(&self, peer_id: [u8; 32], _epoch: Option) -> GateOutcome { + match self.eligible.get(&peer_id) { + Some(ph) => GateOutcome::Eligible { + payout_puzzle_hash: *ph, + }, + None => GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration), + } + } + } + + fn own() -> OwnIdentity { + OwnIdentity { + peer_id: [0xAA; 32], + controlled_puzzle_hashes: vec![[0xBB; 32]], + } + } + + /// SPEC §5.2 coordinate 1: own peer_id, foreign payout hash -> refused, on EVERY path. + #[tokio::test] + async fn own_peer_id_is_refused_on_every_discovery_path() { + let gate = FakeGate { + eligible: [([0xAA; 32], [0xCC; 32])].into_iter().collect(), + }; + let own = own(); + for path in [ + DiscoveryPath::DhtWalk, + DiscoveryPath::LocalProviderSet, + DiscoveryPath::DiscoveredCache, + DiscoveryPath::ManualAdd, + ] { + let candidate = Candidate { + peer_id: own.peer_id, + path, + }; + let decision = admit(&candidate, &own, &gate, Some(1)).await; + assert_eq!(decision, AdmissionDecision::SelfExcluded, "path {path:?}"); + } + } + + /// SPEC §5.2 coordinate 2: foreign peer_id, but the gate resolves a payout hash this node's + /// wallet controls -> refused. + #[tokio::test] + async fn own_controlled_payout_hash_is_refused_even_with_a_foreign_peer_id() { + let own = own(); + let foreign_peer = [0x11; 32]; + let gate = FakeGate { + eligible: [(foreign_peer, own.controlled_puzzle_hashes[0])] + .into_iter() + .collect(), + }; + let candidate = Candidate { + peer_id: foreign_peer, + path: DiscoveryPath::DhtWalk, + }; + let decision = admit(&candidate, &own, &gate, Some(1)).await; + assert_eq!(decision, AdmissionDecision::SelfExcluded); + } + + /// The §5.3.4 control: an otherwise-identical NON-self candidate on the same paths IS admitted. + /// This distinguishes "excluded self" from "dropped everything". + #[tokio::test] + async fn control_a_non_self_candidate_is_admitted_on_every_path() { + let own = own(); + let honest_peer = [0x22; 32]; + let honest_payout = [0xDD; 32]; + let gate = FakeGate { + eligible: [(honest_peer, honest_payout)].into_iter().collect(), + }; + for path in [ + DiscoveryPath::DhtWalk, + DiscoveryPath::LocalProviderSet, + DiscoveryPath::DiscoveredCache, + DiscoveryPath::ManualAdd, + ] { + let candidate = Candidate { + peer_id: honest_peer, + path, + }; + let decision = admit(&candidate, &own, &gate, Some(1)).await; + assert_eq!( + decision, + AdmissionDecision::Admit { + payout_puzzle_hash: honest_payout + }, + "path {path:?}" + ); + } + } + + #[tokio::test] + async fn gate_ineligible_candidate_is_refused_but_not_marked_self() { + let own = own(); + let gate = FakeGate { + eligible: std::collections::HashMap::new(), + }; + let candidate = Candidate { + peer_id: [0x33; 32], + path: DiscoveryPath::DhtWalk, + }; + let decision = admit(&candidate, &own, &gate, Some(1)).await; + assert_eq!(decision, AdmissionDecision::GateIneligible); + } +} diff --git a/crates/dig-node-core/src/rewards/gate.rs b/crates/dig-node-core/src/rewards/gate.rs new file mode 100644 index 00000000..13da169f --- /dev/null +++ b/crates/dig-node-core/src/rewards/gate.rs @@ -0,0 +1,299 @@ +//! The mirror-coin gate (SPEC §4, §10). A candidate is admitted only when all three §4.3 calls +//! agree: `advertises(store, root, mirror_collateral_epoch)` AND `declares_peer(peer_id)` -> +//! `owner_puzzle_hash()`. Fail-closed on every absence or mismatch (§4.2, §10.3) — ineligibility is +//! never an accusation, never a strike, never a blocklist entry. +//! +//! This module does not reimplement `MirrorCoin::advertises` / `declares_peer` / +//! `owner_puzzle_hash` (Appendix B hard rule — see `crate::mirror_bond` for the existing verified- +//! pointer pattern this follows). It defines [`MirrorCoinReader`], the narrow seam over those three +//! calls, and drives the SPEC's admission logic — including the §4.6.3 grace window — against it. +//! The host binary supplies the real reader (wired to `dig-mirror-coin`) exactly the way +//! `mirror_bond::MirrorBondVerifier` is wired today. + +use async_trait::async_trait; + +/// One candidate's claimed mirror-coin pointer, exactly as a `ProviderRecord` carries it +// (`unverified_mirror_coin_id`, SPEC §4.1-§4.2) — a claim, proves nothing on its own. +pub type CoinIdHint = Option<[u8; 32]>; + +/// The three SPEC §4.3 calls, plus the §4.2 coin-validity checks, as one seam. An implementation +/// MUST perform every §4.2 check (puzzle hash, asset id, collateral, unspent) before answering +/// `advertises`/`declares_peer`/`owner_puzzle_hash` — this trait's contract is that a `true` / +/// `Some` answer already reflects all of them, so the gate above it does not need to re-derive +/// coin validity. +#[async_trait] +pub trait MirrorCoinReader: Send + Sync { + /// SPEC §4.2 + §4.3 row 1: fetch the coin at `coin_id` and confirm it advertises exactly + /// `(store_id, root, mirror_collateral_epoch)`. `false` for absent, unresolvable, invalid, + /// spent, under-collateralised, or non-advertising — every §4.2/§4.3.1 failure collapses to + /// `false` here because none of them distinguish for the caller (SPEC §4.2: "MUST NOT be + /// treated as evidence of bad faith"). + async fn advertises( + &self, + coin_id: [u8; 32], + store_id: [u8; 32], + root: [u8; 32], + mirror_collateral_epoch: u64, + ) -> bool; + + /// SPEC §4.3 row 2: does this coin declare `peer_id` as its owner-authenticated claimant. + async fn declares_peer(&self, coin_id: [u8; 32], peer_id: [u8; 32]) -> bool; + + /// SPEC §4.3 row 3 / §10.2: the payout puzzle hash the entry would carry, derived from the + /// coin's lineage proof. `None` if the coin cannot be resolved (fail-closed). + async fn owner_puzzle_hash(&self, coin_id: [u8; 32]) -> Option<[u8; 32]>; +} + +/// Why a candidate was refused. Carried for logging/tests only — SPEC §4.2/§10.3: none of these is +/// an accusation, so no variant here may become a strike or a blocklist entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateIneligibleReason { + /// No `unverified_mirror_coin_id` hint on the candidate's provider record. + AbsentCoinIdHint, + /// The coin does not advertise this `(store, root, epoch)` at all — covers "spent", + /// "wrong epoch ordinal", and "not a mirror coin" alike (§4.2's collapse). + DoesNotAdvertise, + /// The coin advertises the content but does not declare this candidate's `peer_id`. + PeerNotDeclared, + /// The coin resolved but its lineage-derived owner puzzle hash could not be read. + AbsentDeclaration, +} + +/// What the gate decided for one candidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GateOutcome { + Eligible { payout_puzzle_hash: [u8; 32] }, + Ineligible(GateIneligibleReason), +} + +/// The mirror-coin gate contract [`admission::admit`](super::admission::admit) drives. +#[async_trait] +pub trait MirrorCoinGatePort: Send + Sync { + async fn evaluate(&self, peer_id: [u8; 32], mirror_collateral_epoch_ordinal: Option) -> GateOutcome; +} + +/// The SPEC §4-driven gate: takes a candidate's coin-id hint and a `MirrorCoinReader`, and decides +/// eligibility per §4.2-§4.6. +pub struct SpecMirrorCoinGate { + reader: R, + store_id: [u8; 32], + root: [u8; 32], + /// A peer_id -> coin-id-hint lookup: the gate itself does not own DHT candidate state, only the + /// mapping a discovery path already resolved for this peer this cycle. + coin_hint_for: std::collections::HashMap<[u8; 32], CoinIdHint>, +} + +impl SpecMirrorCoinGate { + pub fn new( + reader: R, + store_id: [u8; 32], + root: [u8; 32], + coin_hint_for: std::collections::HashMap<[u8; 32], CoinIdHint>, + ) -> Self { + Self { + reader, + store_id, + root, + coin_hint_for, + } + } + + /// SPEC §4.6.3: during the grace window after a rollover, the PREVIOUS ordinal is also + /// accepted, and a rollover mismatch MUST NOT strike (the caller enforces the "no strike" half; + /// this function only decides eligibility). + async fn advertises_current_or_previous( + &self, + coin_id: [u8; 32], + current_epoch: u64, + in_grace_window: bool, + ) -> bool { + if self + .reader + .advertises(coin_id, self.store_id, self.root, current_epoch) + .await + { + return true; + } + if in_grace_window && current_epoch > 0 { + return self + .reader + .advertises(coin_id, self.store_id, self.root, current_epoch - 1) + .await; + } + false + } +} + +#[async_trait] +impl MirrorCoinGatePort for SpecMirrorCoinGate { + async fn evaluate(&self, peer_id: [u8; 32], mirror_collateral_epoch_ordinal: Option) -> GateOutcome { + // SPEC §4.6 clause 2: the ordinal is an INPUT; its absence is ineligibility, never a + // computed guess (dig_ecosystem#3259). + let Some(current_epoch) = mirror_collateral_epoch_ordinal else { + return GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise); + }; + + let Some(hint) = self.coin_hint_for.get(&peer_id).copied().flatten() else { + return GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint); + }; + + // Grace window handling is delegated to the caller in production (it needs wall-clock + // context this gate does not hold); for the pure §4.3 chain here we accept the current + // ordinal only, matching a cycle called immediately at rollover with no grace granted. The + // grace-window itself is exercised via `advertises_current_or_previous` directly in tests. + if !self + .advertises_current_or_previous(hint, current_epoch, false) + .await + { + return GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise); + } + + if !self.reader.declares_peer(hint, peer_id).await { + return GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared); + } + + match self.reader.owner_puzzle_hash(hint).await { + Some(payout_puzzle_hash) => GateOutcome::Eligible { payout_puzzle_hash }, + None => GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct FakeReader { + advertising: HashMap<([u8; 32], u64), bool>, + declaring: HashMap<[u8; 32], [u8; 32]>, + owners: HashMap<[u8; 32], [u8; 32]>, + } + + #[async_trait] + impl MirrorCoinReader for FakeReader { + async fn advertises( + &self, + coin_id: [u8; 32], + _store_id: [u8; 32], + _root: [u8; 32], + epoch: u64, + ) -> bool { + self.advertising.get(&(coin_id, epoch)).copied().unwrap_or(false) + } + async fn declares_peer(&self, coin_id: [u8; 32], peer_id: [u8; 32]) -> bool { + self.declaring.get(&coin_id) == Some(&peer_id) + } + async fn owner_puzzle_hash(&self, coin_id: [u8; 32]) -> Option<[u8; 32]> { + self.owners.get(&coin_id).copied() + } + } + + const STORE: [u8; 32] = [1; 32]; + const ROOT: [u8; 32] = [2; 32]; + const PEER: [u8; 32] = [3; 32]; + const COIN: [u8; 32] = [4; 32]; + const OWNER: [u8; 32] = [5; 32]; + + fn gate(reader: FakeReader, hint: CoinIdHint) -> SpecMirrorCoinGate { + SpecMirrorCoinGate::new(reader, STORE, ROOT, [(PEER, hint)].into_iter().collect()) + } + + #[tokio::test] + async fn absent_coin_id_hint_is_ineligible() { + let g = gate(FakeReader::default(), None); + assert_eq!( + g.evaluate(PEER, Some(1)).await, + GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint) + ); + } + + #[tokio::test] + async fn epoch_ordinal_absent_is_ineligible_never_a_guess() { + let g = gate(FakeReader::default(), Some(COIN)); + assert_eq!( + g.evaluate(PEER, None).await, + GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise) + ); + } + + #[tokio::test] + async fn spent_or_non_advertising_coin_is_ineligible() { + let reader = FakeReader::default(); // advertising map empty == coin doesn't advertise (covers spent/absent) + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, Some(1)).await, + GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise) + ); + } + + #[tokio::test] + async fn wrong_epoch_ordinal_is_ineligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 7), true); // advertises epoch 7, we ask about 1 + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, Some(1)).await, + GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise) + ); + } + + #[tokio::test] + async fn declares_peer_mismatch_is_ineligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 1), true); + reader.declaring.insert(COIN, [0xEE; 32]); // declares someone else + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, Some(1)).await, + GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared) + ); + } + + #[tokio::test] + async fn absent_declaration_is_ineligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 1), true); + // declares_peer defaults to false (not in map) -> PeerNotDeclared covers "absent declaration" too + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, Some(1)).await, + GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared) + ); + } + + #[tokio::test] + async fn all_three_calls_agreeing_is_eligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 1), true); + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, Some(1)).await, + GateOutcome::Eligible { + payout_puzzle_hash: OWNER + } + ); + } + + /// SPEC §4.6.3: previous ordinal accepted inside the grace window. + #[tokio::test] + async fn previous_ordinal_accepted_within_grace_window() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 4), true); // declares previous ordinal (current is 5) + let g = gate(reader, Some(COIN)); + assert!(g.advertises_current_or_previous(COIN, 5, true).await); + } + + /// SPEC §4.6.3: outside the grace window, a rollover mismatch is simply ineligible, and this + /// crate MUST NOT strike for it (enforced at the cycle layer, not here). + #[tokio::test] + async fn previous_ordinal_rejected_outside_grace_window() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 4), true); + let g = gate(reader, Some(COIN)); + assert!(!g.advertises_current_or_previous(COIN, 5, false).await); + } +} diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs new file mode 100644 index 00000000..860fd868 --- /dev/null +++ b/crates/dig-node-core/src/rewards/port.rs @@ -0,0 +1,152 @@ +//! The chain port — the seam this whole engine is built against instead of `dig-rewards-coin`. +//! +//! `dig-rewards-coin` is SPEC-only as of the tag this lane read: `src/lib.rs` is a documented +//! placeholder and `pub mod distributor {}` is empty. Implementing the driver is +//! DIG-Network/dig_ecosystem#3249, a sibling lane. So the prover engine is built COMPLETELY against +//! a narrow trait derived from the SPEC's own described surface (not from the driver's internals, +//! so it is stable across #3249 landing), tested with an in-memory fake, and the production +//! adapter — until #3249 ships — reports [`ChainPortError::Unavailable`] and runs no cycles. See +//! [`unavailable`] for that adapter. + +use async_trait::async_trait; + +/// A 32-byte chain identifier (launcher id, store id, root, puzzle hash — all the same shape). +pub type Bytes32 = [u8; 32]; + +/// One distributor this node funds, as SPEC §1.3 names it: the generation it rewards plus its +/// launcher id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorRef { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, +} + +/// One occupied entry slot, as SPEC §10.2 shapes it: keyed by a payout PUZZLE HASH, never a pubkey. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntrySlot { + pub payout_puzzle_hash: Bytes32, + pub counter: u64, + /// SPEC §11.1: always `1` in the MVP; carried here because the chain state reports what is + /// actually on the slot, not what this crate would choose to write. + pub shares: u64, +} + +/// One distributor's chain-derived state (SPEC §2.3 `counters`, §8, §12.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorChainState { + pub reserve_base_units: u64, + pub entries: Vec, + /// The `RewardDistributorConstants::epoch_seconds` accrual window ordinal this distributor is + /// currently in. NOT the mirror-collateral epoch (SPEC §0.3) — an unrelated clock. + pub current_distributor_epoch: u64, + /// SPEC §12.4: derived from the singleton's own spend history, never a self-report. `None` + /// means the entry set has never been written to. + pub last_entry_write_at: Option, + pub total_paid_out_base_units: u64, +} + +/// One add/remove decision destined for a bundle (SPEC §6.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryAction { + Add { + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, + }, + Remove { + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, + }, +} + +/// One distributor spend bundle: at most [`super::spec_constants::MAX_ENTRY_WRITES_PER_BUNDLE`] +/// actions, one fee (SPEC §6.3 clause 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryWriteBundle { + pub launcher_id: Bytes32, + pub actions: Vec, + pub fee_mojos: u64, +} + +/// Why a chain port call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChainPortError { + /// No chain source is wired yet — the [`unavailable`] adapter's only answer, and what any real + /// adapter should answer for an unreachable chain too (SPEC §12.2 clause 4). + Unavailable, + /// A chain answered but the call failed for a reason worth a message (bounded before logging — + /// SPEC §3.7 clause 4 applies to every attacker-adjacent string, and a chain error is not + /// exempt). + Other(String), +} + +/// Reads and the one write this engine needs from the reward-distributor chain state. Derived from +/// the SPEC's described surface (§1.3 reads, §6.3 write), not from `dig-rewards-coin`'s internals. +#[async_trait] +pub trait RewardsChainPort: Send + Sync { + /// SPEC §1.3: every distributor this node funds, with its `(store_id, root)`. + async fn funded_distributors(&self) -> Result, ChainPortError>; + + /// SPEC §2.3, §8, §12.4: one distributor's current chain-derived state. + async fn distributor_state( + &self, + launcher_id: Bytes32, + ) -> Result; + + /// SPEC §6.3: submit ONE bundle of at most `MAX_ENTRY_WRITES_PER_BUNDLE` actions with a fee. + async fn submit_entry_writes(&self, bundle: EntryWriteBundle) -> Result<(), ChainPortError>; +} + +/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// [`ChainPortError::Unavailable`] on every call and runs no cycles. +/// +/// This is the named state `ChainSourceUnavailable` (SPEC §2.3), not a silent no-op — a no-op that +/// reported progress would be the exact honesty violation §2.4 forbids. When #3249 ships, this +/// adapter is replaced with one that calls the real driver through this same trait; nothing above +/// this seam changes. +pub struct UnavailableChainPort; + +#[async_trait] +impl RewardsChainPort for UnavailableChainPort { + async fn funded_distributors(&self) -> Result, ChainPortError> { + Err(ChainPortError::Unavailable) + } + + async fn distributor_state( + &self, + _launcher_id: Bytes32, + ) -> Result { + Err(ChainPortError::Unavailable) + } + + async fn submit_entry_writes(&self, _bundle: EntryWriteBundle) -> Result<(), ChainPortError> { + Err(ChainPortError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unavailable_adapter_never_reports_a_cycle_ran() { + let port = UnavailableChainPort; + assert_eq!( + port.funded_distributors().await, + Err(ChainPortError::Unavailable) + ); + assert_eq!( + port.distributor_state([0u8; 32]).await, + Err(ChainPortError::Unavailable) + ); + assert_eq!( + port.submit_entry_writes(EntryWriteBundle { + launcher_id: [0u8; 32], + actions: vec![], + fee_mojos: 0, + }) + .await, + Err(ChainPortError::Unavailable) + ); + } +} diff --git a/crates/dig-node-core/src/rewards/spec_constants.rs b/crates/dig-node-core/src/rewards/spec_constants.rs new file mode 100644 index 00000000..69a60b3b --- /dev/null +++ b/crates/dig-node-core/src/rewards/spec_constants.rs @@ -0,0 +1,76 @@ +//! Constants transcribed from `dig-rewards-coin/SPEC.md` v0.1.1 (DIG-Network/dig_ecosystem#3250). +//! +//! # Byte-identical contract +//! +//! Every value below is copied verbatim from the normative spec, each tagged with the clause it +//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` is +//! still SPEC-only (`pub mod distributor {}`, DIG-Network/dig_ecosystem#3249): the moment #3249 +//! lands and publishes these as its own constants, this file MUST be deleted and every reference +//! MUST move to `dig_rewards_coin::*`. That migration is the parent's call, not this lane's — do +//! not relitigate it here and do not let a second copy of any of these numbers exist anywhere else +//! in this crate. +//! +//! `epoch_seconds`, `first_epoch_start` and `payout_threshold` are deliberately ABSENT: they are +//! per-distributor chain values (SPEC §8), never constants. + +/// SPEC §2.5: a prover MUST begin a new cycle per distributor once per period. +pub const PROVER_CYCLE_PERIOD_SECONDS: u64 = 3_600; + +/// SPEC §2.5 clause 1: `observed_at` MUST be refreshed at least this often, including while `Idle`. +pub const PROVER_HEARTBEAT_SECONDS: u64 = 60; + +/// SPEC §2.5 clause 2: a cycle exceeding this MUST be abandoned and counted as a prover-fault +/// failure — never a peer strike (clause 3 / §3.6.4). +pub const PROVER_CYCLE_DEADLINE_SECONDS: u64 = 900; + +/// SPEC §3.2: windows selected per candidate peer per cycle. +pub const CHALLENGE_WINDOWS_PER_CYCLE: u32 = 4; + +/// SPEC §3.2 clause 3: bytes per challenge window (64 KiB), clamped to `total_length` for a smaller +/// resource. +pub const CHALLENGE_WINDOW_BYTES: u64 = 65_536; + +/// SPEC §3.2 clause 5: a window MUST NOT repeat for the same `(peer_id, launcher_id)` within this +/// many cycles. +pub const CHALLENGE_NO_REPEAT_CYCLES: u32 = 8; + +/// SPEC §3.6 clause 3: consecutive challenge-cycle failures before a `RemoveEntry` is scheduled. +pub const CHALLENGE_STRIKES_TO_EVICT: u32 = 3; + +/// SPEC §3.7 clause 1: per-window deadline. +pub const CHALLENGE_DEADLINE_SECONDS: u64 = 30; + +/// SPEC §3.7 clause 1: deadline for a peer's four windows. +pub const CHALLENGE_PEER_DEADLINE_SECONDS: u64 = 120; + +/// SPEC §3.7 clause 2: minimum interval between challenges of the same peer, summed across every +/// distributor this node funds. +pub const CHALLENGE_MIN_INTERVAL_SECONDS: u64 = 900; + +/// SPEC §3.7 clause 3: peers challenged per cycle per distributor, at most. +pub const CHALLENGE_MAX_PEERS_PER_CYCLE: u32 = 64; + +/// SPEC §6.3 clause 1: add/remove actions per bundle, at most. +pub const MAX_ENTRY_WRITES_PER_BUNDLE: u32 = 8; + +/// SPEC §6.3 clause 2: minimum interval between entry-set write bundles for one distributor. +pub const ENTRY_WRITE_MIN_INTERVAL_SECONDS: u64 = 3_600; + +/// SPEC §6.3 clause 4: a removed entry MUST NOT be re-added within this window, keyed on +/// `(payout_puzzle_hash, launcher_id)` — never on `peer_id`. +pub const REENTRY_COOLDOWN_SECONDS: u64 = 21_600; + +/// SPEC §4.6 clause 3: grace window after a mirror-collateral epoch rollover during which the +/// PREVIOUS epoch ordinal is still accepted, and a rollover mismatch MUST NOT strike. +pub const MIRROR_EPOCH_GRACE_SECONDS: u64 = 21_600; + +/// SPEC §12.4: an entry set that has not changed in this long, with a non-zero reserve, MUST be +/// reported as stale (`entry_set_stale` on `dig.getRewardDistributor` only — never on the prover +/// status record, §2.4). +pub const STALE_ENTRY_SET_SECONDS: u64 = 172_800; + +/// SPEC §6.5: the entry set is capped at this many entries per distributor. +pub const MAX_ENTRIES_PER_DISTRIBUTOR: u32 = 250; + +/// SPEC §4.4 clause 1: at most this many free-memo URL terms are considered per candidate. +pub const MAX_MIRROR_URL_TERMS: u32 = 8; diff --git a/crates/dig-node-core/src/rewards/state.rs b/crates/dig-node-core/src/rewards/state.rs new file mode 100644 index 00000000..c98ca836 --- /dev/null +++ b/crates/dig-node-core/src/rewards/state.rs @@ -0,0 +1,197 @@ +//! The per-distributor status record (SPEC §2.3) and its closed state set (§2.3, §2.4). +//! +//! # No health boolean, ever +//! +//! SPEC §2.4: "An implementation MUST NOT expose a `healthy`, `ok`, `up`, or `running` boolean, and +//! MUST NOT expose a pre-computed staleness." A wedged loop cannot report its own wedging — whatever +//! it last wrote stays there, so any field a stalled writer could set to a reassuring value is a +//! lie waiting to happen. The reader derives liveness itself from `last_cycle_completed_at` against +//! `observed_at` and its own clock; nothing here does that derivation for it. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +/// The closed set of prover states (SPEC §2.3). An implementation MUST use exactly this set, MUST +/// NOT add a state without adding it here first, and MUST NOT collapse two of these into one +/// message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProverState { + Idle, + Running, + LocalCopyMissing, + ChainSourceUnavailable, + Unfunded, + FeeBudgetExhausted, + EntrySetFull, + Paused, + Stopped, +} + +/// SPEC §2.3 `counters`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProverCounters { + pub mirrors_seen: u64, + pub challenges_issued: u64, + pub challenges_passed: u64, + pub challenges_failed: u64, + pub entries_added: u64, + pub entries_removed: u64, + pub entry_count: u32, + pub reserve_base_units: u64, + pub total_paid_out_base_units: u64, +} + +/// The SPEC §2.3 status record, verbatim field-for-field. Deliberately carries no boolean and no +/// precomputed staleness (§2.4) — a `#[test]` below asserts the serialized form has none. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RewardProverStatus { + pub launcher_id: [u8; 32], + pub store_id: [u8; 32], + pub root: [u8; 32], + pub prover_state: ProverState, + pub prover_state_since: u64, + pub last_cycle_started_at: Option, + pub last_cycle_completed_at: Option, + pub next_cycle_due_at: Option, + pub last_entry_write_at: Option, + pub consecutive_cycle_failures: u32, + /// SPEC §6.3 clause 2: decisions withheld by the write-rate bound, not dropped. + pub pending_entry_writes: u32, + /// SPEC §2.3: "chain view this record reflects" — refreshed at least every + /// `PROVER_HEARTBEAT_SECONDS` (§2.5 clause 1). The reader's only staleness signal: compare this + /// against `last_cycle_completed_at` and the reader's own clock. + pub observed_at: u64, + pub counters: ProverCounters, +} + +/// A shared, mutable status record a loop writes to and a reader (e.g. the RPC handler) reads from +/// without racing it. Plain `RwLock` over the whole record: writes are infrequent (at most once per +/// heartbeat) and reads must never block a cycle, so a lock is simpler and just as sound as a channel +/// here. +#[derive(Clone)] +pub struct StatusHandle(Arc>); + +impl StatusHandle { + pub fn new(initial: RewardProverStatus) -> Self { + Self(Arc::new(RwLock::new(initial))) + } + + pub fn snapshot(&self) -> RewardProverStatus { + self.0.read().expect("status lock poisoned").clone() + } + + /// Apply an update. The closure receives `&mut RewardProverStatus` so a caller can update + /// several fields as one atomic step (e.g. `prover_state` and `prover_state_since` together). + pub fn update(&self, f: impl FnOnce(&mut RewardProverStatus)) { + let mut guard = self.0.write().expect("status lock poisoned"); + f(&mut guard); + } +} + +/// A monotonically-advancing clock the loop uses for `observed_at`. A trait rather than +/// `SystemTime::now()` directly so a test can drive it (or refuse to), which is exactly what +/// proves a wedged loop stops advancing it (see `cycle.rs`'s wedged-loop test). +pub trait Clock: Send + Sync { + fn now_unix_seconds(&self) -> u64; +} + +/// The real clock. +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_unix_seconds(&self) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before epoch") + .as_secs() + } +} + +/// A clock a test can advance by hand, and — critically — can also NOT advance, to prove that a +/// stalled loop's `observed_at` truly stops. +#[derive(Clone)] +pub struct TestClock(Arc); + +impl TestClock { + pub fn new(start: u64) -> Self { + Self(Arc::new(AtomicU64::new(start))) + } + + pub fn advance(&self, seconds: u64) { + self.0.fetch_add(seconds, Ordering::SeqCst); + } +} + +impl Clock for TestClock { + fn now_unix_seconds(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } +} + +fn new_status(launcher_id: [u8; 32], store_id: [u8; 32], root: [u8; 32], now: u64) -> RewardProverStatus { + RewardProverStatus { + launcher_id, + store_id, + root, + prover_state: ProverState::Idle, + prover_state_since: now, + last_cycle_started_at: None, + last_cycle_completed_at: None, + next_cycle_due_at: None, + last_entry_write_at: None, + consecutive_cycle_failures: 0, + pending_entry_writes: 0, + observed_at: now, + counters: ProverCounters::default(), + } +} + +/// Build a fresh `Idle` status record for a distributor, as SPEC §12.1 clause 3 requires on +/// restart, before the first cycle completes. +pub fn idle_status( + launcher_id: [u8; 32], + store_id: [u8; 32], + root: [u8; 32], + now: u64, +) -> RewardProverStatus { + new_status(launcher_id, store_id, root, now) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// SPEC §2.4: no `healthy`/`ok`/`up`/`running` key, and no precomputed staleness field, in the + /// serialized record. + #[test] + fn serialized_status_has_no_health_or_staleness_key() { + let status = idle_status([1; 32], [2; 32], [3; 32], 1000); + let json = serde_json::to_value(&status).unwrap(); + let obj = json.as_object().unwrap(); + for forbidden in ["healthy", "ok", "up", "running", "stale", "isStale", "staleness"] { + assert!( + !obj.contains_key(forbidden), + "status record must not carry a {forbidden:?} key (SPEC §2.4)" + ); + } + } + + #[test] + fn status_handle_reads_do_not_mutate() { + let status = idle_status([0; 32], [0; 32], [0; 32], 5); + let handle = StatusHandle::new(status.clone()); + assert_eq!(handle.snapshot(), status); + handle.update(|s| s.observed_at = 6); + assert_eq!(handle.snapshot().observed_at, 6); + } + + #[test] + fn test_clock_that_is_never_advanced_never_advances() { + let clock = TestClock::new(42); + assert_eq!(clock.now_unix_seconds(), 42); + assert_eq!(clock.now_unix_seconds(), 42); + } +} From fbcc23d82a226af87e3bf66a17cf1753607f2b4e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 13:23:32 -0700 Subject: [PATCH 03/11] chore(rewards): checkpoint prover-loop wiring and engine modules Parent-side checkpoint taken while the implementer lane was blocked on a cold workspace compile. NOT YET VERIFIED -- this commit has never completed a build. It exists because this ticket has now lost uncommitted work to a dying process twice (five files in dig-node, two in dig-rpc-protocol) and a third loss was one crash away. - mod.rs + `pub mod rewards;` in lib.rs: the module is finally part of the build - cycle.rs: the always-on loop (period, heartbeat, cycle deadline) - writes.rs: the four SPEC 6.3 entry-write bounds - staleness.rs: the SPEC 12.4 chain-derived EntrySetStale computation - challenge.rs: SPEC 3.2 window selection and the 3.5 pass/fail decision - admission.rs / gate.rs / port.rs: in-progress fixes for the four defects the salvage commit recorded, plus D5 (an absent mirror-collateral epoch ordinal is a prover fault and must not strike a peer) - .gitkeep removed now that mod.rs exists Refs #3250 --- crates/dig-node-core/src/lib.rs | 1 + crates/dig-node-core/src/rewards/.gitkeep | 0 crates/dig-node-core/src/rewards/admission.rs | 68 +++- crates/dig-node-core/src/rewards/challenge.rs | 352 ++++++++++++++++++ crates/dig-node-core/src/rewards/cycle.rs | 208 +++++++++++ crates/dig-node-core/src/rewards/gate.rs | 235 ++++++++---- crates/dig-node-core/src/rewards/mod.rs | 27 ++ crates/dig-node-core/src/rewards/port.rs | 15 + crates/dig-node-core/src/rewards/staleness.rs | 82 ++++ crates/dig-node-core/src/rewards/writes.rs | 269 +++++++++++++ 10 files changed, 1177 insertions(+), 80 deletions(-) delete mode 100644 crates/dig-node-core/src/rewards/.gitkeep create mode 100644 crates/dig-node-core/src/rewards/challenge.rs create mode 100644 crates/dig-node-core/src/rewards/cycle.rs create mode 100644 crates/dig-node-core/src/rewards/mod.rs create mode 100644 crates/dig-node-core/src/rewards/staleness.rs create mode 100644 crates/dig-node-core/src/rewards/writes.rs diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 47010d7b..3d4702df 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -60,6 +60,7 @@ pub mod mirror_bond; mod module_tier_tag; pub mod peer; pub mod rate_limit; +pub mod rewards; pub mod store_exchange; #[cfg(test)] diff --git a/crates/dig-node-core/src/rewards/.gitkeep b/crates/dig-node-core/src/rewards/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/crates/dig-node-core/src/rewards/admission.rs b/crates/dig-node-core/src/rewards/admission.rs index 3fd9a76d..02c072c9 100644 --- a/crates/dig-node-core/src/rewards/admission.rs +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -9,7 +9,7 @@ //! compares a candidate against this node's own identity, and every caller — regardless of which //! path produced the candidate — MUST call through here rather than re-implement the comparison. -use super::gate::{GateOutcome, MirrorCoinGatePort}; +use super::gate::{EpochContext, GateError, GateOutcome, MirrorCoinGatePort}; /// Which discovery path produced a candidate. Exists ONLY for logging/tests (SPEC §5.3 clause 4's /// control needs to name the path a candidate arrived by) — it MUST NOT change the admission @@ -57,6 +57,11 @@ pub enum AdmissionDecision { /// The mirror-coin gate did not admit the candidate (SPEC §4, §10.3) — fail-closed /// ineligibility, not an accusation. GateIneligible, + /// D5: the gate could not evaluate this candidate at all because the mirror-collateral epoch + /// ordinal was not supplied — a PROVER-side configuration fault, never a peer-attributable + /// verdict. The caller MUST NOT treat this as `GateIneligible` and MUST NOT strike the peer for + /// it (SPEC §3.6 clause 4). + ChainSourceUnavailable, } /// THE single admission point. Every discovery path calls this and nothing else decides @@ -72,19 +77,17 @@ pub async fn admit( candidate: &Candidate, own: &OwnIdentity, gate: &dyn MirrorCoinGatePort, - mirror_collateral_epoch_ordinal: Option, + epoch_ctx: EpochContext, ) -> AdmissionDecision { if candidate.peer_id == own.peer_id { return AdmissionDecision::SelfExcluded; } - match gate - .evaluate(candidate.peer_id, mirror_collateral_epoch_ordinal) - .await - { - GateOutcome::Eligible { + match gate.evaluate(candidate.peer_id, epoch_ctx).await { + Err(GateError::EpochOrdinalUnavailable) => AdmissionDecision::ChainSourceUnavailable, + Ok(GateOutcome::Eligible { payout_puzzle_hash, - } => { + }) => { if own.controls(&payout_puzzle_hash) { AdmissionDecision::SelfExcluded } else { @@ -93,7 +96,7 @@ pub async fn admit( } } } - GateOutcome::Ineligible(_) => AdmissionDecision::GateIneligible, + Ok(GateOutcome::Ineligible(_)) => AdmissionDecision::GateIneligible, } } @@ -110,16 +113,25 @@ mod tests { #[async_trait] impl MirrorCoinGatePort for FakeGate { - async fn evaluate(&self, peer_id: [u8; 32], _epoch: Option) -> GateOutcome { + async fn evaluate(&self, peer_id: [u8; 32], _ctx: EpochContext) -> Result { match self.eligible.get(&peer_id) { - Some(ph) => GateOutcome::Eligible { + Some(ph) => Ok(GateOutcome::Eligible { payout_puzzle_hash: *ph, - }, - None => GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration), + }), + None => Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration)), } } } + struct UnavailableFakeGate; + + #[async_trait] + impl MirrorCoinGatePort for UnavailableFakeGate { + async fn evaluate(&self, _peer_id: [u8; 32], _ctx: EpochContext) -> Result { + Err(GateError::EpochOrdinalUnavailable) + } + } + fn own() -> OwnIdentity { OwnIdentity { peer_id: [0xAA; 32], @@ -127,6 +139,14 @@ mod tests { } } + fn ctx() -> EpochContext { + EpochContext { + current_epoch: Some(2), + epoch_rolled_over_at: None, + now: 0, + } + } + /// SPEC §5.2 coordinate 1: own peer_id, foreign payout hash -> refused, on EVERY path. #[tokio::test] async fn own_peer_id_is_refused_on_every_discovery_path() { @@ -144,7 +164,7 @@ mod tests { peer_id: own.peer_id, path, }; - let decision = admit(&candidate, &own, &gate, Some(1)).await; + let decision = admit(&candidate, &own, &gate, ctx()).await; assert_eq!(decision, AdmissionDecision::SelfExcluded, "path {path:?}"); } } @@ -164,7 +184,7 @@ mod tests { peer_id: foreign_peer, path: DiscoveryPath::DhtWalk, }; - let decision = admit(&candidate, &own, &gate, Some(1)).await; + let decision = admit(&candidate, &own, &gate, ctx()).await; assert_eq!(decision, AdmissionDecision::SelfExcluded); } @@ -188,7 +208,7 @@ mod tests { peer_id: honest_peer, path, }; - let decision = admit(&candidate, &own, &gate, Some(1)).await; + let decision = admit(&candidate, &own, &gate, ctx()).await; assert_eq!( decision, AdmissionDecision::Admit { @@ -209,7 +229,21 @@ mod tests { peer_id: [0x33; 32], path: DiscoveryPath::DhtWalk, }; - let decision = admit(&candidate, &own, &gate, Some(1)).await; + let decision = admit(&candidate, &own, &gate, ctx()).await; assert_eq!(decision, AdmissionDecision::GateIneligible); } + + /// D5: a gate error (absent epoch ordinal) MUST surface as `ChainSourceUnavailable`, never as + /// `GateIneligible` — a caller telling these apart is exactly what keeps this from striking a + /// peer for the operator's own configuration gap. + #[tokio::test] + async fn gate_error_surfaces_as_chain_source_unavailable_not_gate_ineligible() { + let own = own(); + let candidate = Candidate { + peer_id: [0x44; 32], + path: DiscoveryPath::DhtWalk, + }; + let decision = admit(&candidate, &own, &UnavailableFakeGate, ctx()).await; + assert_eq!(decision, AdmissionDecision::ChainSourceUnavailable); + } } diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs new file mode 100644 index 00000000..15a212dc --- /dev/null +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -0,0 +1,352 @@ +//! SPEC §3: possession-challenge window selection, the fail-closed pass/fail decision, and the +//! §3.6 strike accounting the decision feeds. +//! +//! **Scope cut, deliberate**: this module holds the SOUNDNESS logic — which windows to pick, and +//! whether a response is honest — behind the narrow [`ChallengeTransport`] seam, tested against an +//! in-memory fake. The concrete `dig.fetchRange` transport adapter (`skip_layout: true, +//! capsule: false`, the §3.7 deadlines) is a FOLLOW-UP, not built here. Soundness in, transport +//! out. + +use super::port::Bytes32; +use super::spec_constants::{CHALLENGE_NO_REPEAT_CYCLES, CHALLENGE_STRIKES_TO_EVICT, CHALLENGE_WINDOW_BYTES}; +use async_trait::async_trait; +use std::collections::HashMap; + +/// One resource this distributor's peer set is challenged over (SPEC §3.1): an id and its total +/// byte length, used only for the length-proportional pick below. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Resource { + pub id: Bytes32, + pub length: u64, +} + +/// A concrete window request: which resource, what byte range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowPlan { + pub resource_index: usize, + pub offset: u64, + pub length: u64, +} + +/// A CSPRNG-drawn `u64` in `[0, bound)`. SPEC §3.2 clause 4: MUST NOT be derived from a counter, a +/// timestamp, a peer id, a store id, a root, a cycle index, or any hash of those — `getrandom` +/// draws from the OS CSPRNG and touches none of those inputs. +fn csprng_u64_below(bound: u64) -> u64 { + if bound == 0 { + return 0; + } + let mut buf = [0u8; 8]; + getrandom::getrandom(&mut buf).expect("OS CSPRNG unavailable"); + u64::from_le_bytes(buf) % bound +} + +/// Remembers, per `(peer_id, launcher_id)`, the `(cycle_index, resource_id, offset)` windows +/// issued in the last [`CHALLENGE_NO_REPEAT_CYCLES`] cycles (SPEC §3.2 clause 5). +#[derive(Default)] +pub struct NoRepeatMemory { + recent: HashMap<(Bytes32, Bytes32), Vec<(u32, Bytes32, u64)>>, +} + +impl NoRepeatMemory { + pub fn new() -> Self { + Self::default() + } + + pub fn is_repeat(&self, peer_id: Bytes32, launcher_id: Bytes32, resource_id: Bytes32, offset: u64, cycle_index: u32) -> bool { + self.recent + .get(&(peer_id, launcher_id)) + .is_some_and(|windows| { + windows.iter().any(|(cyc, rid, off)| { + *rid == resource_id && *off == offset && cycle_index.saturating_sub(*cyc) < CHALLENGE_NO_REPEAT_CYCLES + }) + }) + } + + pub fn record(&mut self, peer_id: Bytes32, launcher_id: Bytes32, resource_id: Bytes32, offset: u64, cycle_index: u32) { + self.recent + .entry((peer_id, launcher_id)) + .or_default() + .push((cycle_index, resource_id, offset)); + } +} + +/// SPEC §3.2: pick ONE window — resource choice length-proportional (uniform-over-resources is +/// exploitable: a peer can discard the large resources, most of the bytes, and still pass), +/// offset uniform in `[0, total_length - length]`, length clamped down for a smaller resource, +/// and skipping any pick the no-repeat memory has already issued this peer within the window. +/// Returns `None` only when every resource is empty or repeats exhaust the retry budget. +pub fn select_window( + resources: &[Resource], + peer_id: Bytes32, + launcher_id: Bytes32, + cycle_index: u32, + memory: &mut NoRepeatMemory, +) -> Option { + let total_length: u64 = resources.iter().map(|r| r.length).sum(); + if resources.is_empty() || total_length == 0 { + return None; + } + + for _attempt in 0..16 { + let pick = csprng_u64_below(total_length); + let mut cumulative = 0u64; + let resource_index = resources + .iter() + .position(|r| { + cumulative += r.length; + pick < cumulative + }) + .unwrap_or(resources.len() - 1); + let resource = &resources[resource_index]; + let length = CHALLENGE_WINDOW_BYTES.min(resource.length); + let max_offset = resource.length - length; + let offset = csprng_u64_below(max_offset + 1); + + if !memory.is_repeat(peer_id, launcher_id, resource.id, offset, cycle_index) { + memory.record(peer_id, launcher_id, resource.id, offset, cycle_index); + return Some(WindowPlan { + resource_index, + offset, + length, + }); + } + } + None +} + +/// Why one challenge window failed (SPEC §3.5 — fail-closed on every one of these). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChallengeFailure { + Transport, + PeerIdMismatch, + Timeout, + RpcError(String), + FrameLengthMismatch, + OffsetMismatch, + LayoutMismatch, + DecodeError, +} + +/// One raw window response, before comparison against the locally-known bytes. +#[derive(Debug, Clone)] +pub struct ChallengeResponse { + pub bytes: Vec, +} + +/// The narrow transport seam this module drives — soundness only, no `dig.fetchRange` wiring here +/// (see module docs). +#[async_trait] +pub trait ChallengeTransport: Send + Sync { + async fn fetch_window( + &self, + peer_id: Bytes32, + resource_id: Bytes32, + offset: u64, + length: u64, + ) -> Result; +} + +/// SPEC §3.5: a single window passes only when the transport succeeds AND the returned bytes +/// match the locally-known bytes exactly. Every failure — transport, protocol, or a byte +/// difference — collapses to `false`; a valid-but-wrong-bytes response fails exactly as loudly as +/// no response (SPEC §3.4: a relayable inclusion proof MUST NOT be accepted as possession). +pub async fn run_window( + transport: &dyn ChallengeTransport, + peer_id: Bytes32, + resource_id: Bytes32, + offset: u64, + length: u64, + expected_bytes: &[u8], +) -> bool { + match transport.fetch_window(peer_id, resource_id, offset, length).await { + Ok(response) => response.bytes == expected_bytes, + Err(_) => false, + } +} + +/// SPEC §3.5: a cycle passes only if ALL windows match — no partial credit. +pub async fn run_cycle(transport: &dyn ChallengeTransport, peer_id: Bytes32, windows: &[(Bytes32, u64, u64, Vec)]) -> bool { + for (resource_id, offset, length, expected) in windows { + if !run_window(transport, peer_id, *resource_id, *offset, *length, expected).await { + return false; + } + } + true +} + +/// SPEC §3.6 per-`(peer_id, launcher_id)` strike accounting. A pass resets to zero; three +/// CONSECUTIVE genuine peer-caused failures schedule a `RemoveEntry`. Strikes reset entirely on +/// prover restart (§12.1). +#[derive(Default)] +pub struct StrikeTracker { + consecutive_failures: HashMap<(Bytes32, Bytes32), u32>, +} + +impl StrikeTracker { + pub fn new() -> Self { + Self::default() + } + + /// Record a genuinely peer-caused challenge-cycle outcome. Returns `true` when this outcome + /// crosses [`CHALLENGE_STRIKES_TO_EVICT`] and a `RemoveEntry` MUST now be scheduled. + /// + /// MUST NEVER be called for a cycle abandoned through the prover's own fault — see + /// [`Self::record_prover_fault`], which exists precisely so that path cannot reach this one. + pub fn record_peer_outcome(&mut self, peer_id: Bytes32, launcher_id: Bytes32, passed: bool) -> bool { + let key = (peer_id, launcher_id); + if passed { + self.consecutive_failures.insert(key, 0); + false + } else { + let count = self.consecutive_failures.entry(key).or_insert(0); + *count += 1; + *count >= CHALLENGE_STRIKES_TO_EVICT + } + } + + /// SPEC §3.6 clause 4: `LocalCopyMissing`, `ChainSourceUnavailable`, the prover's own cycle + /// deadline, or a reorg — none of these is the peer's fault, so none of them may touch a + /// strike counter. This function is intentionally a no-op; it exists so a caller reaches for a + /// NAMED prover-fault path instead of `record_peer_outcome`, which is the mistake that would + /// strike every peer for one broken node. + pub fn record_prover_fault(&self, _peer_id: Bytes32, _launcher_id: Bytes32) {} + + pub fn consecutive_failures(&self, peer_id: Bytes32, launcher_id: Bytes32) -> u32 { + self.consecutive_failures + .get(&(peer_id, launcher_id)) + .copied() + .unwrap_or(0) + } + + /// SPEC §12.1 clause 3: strikes reset to zero on prover restart. + pub fn reset_all(&mut self) { + self.consecutive_failures.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PEER: Bytes32 = [1; 32]; + const LAUNCHER: Bytes32 = [2; 32]; + const RESOURCE: Bytes32 = [3; 32]; + + struct FakeTransport { + bytes: Vec, + fail: Option, + } + + #[async_trait] + impl ChallengeTransport for FakeTransport { + async fn fetch_window( + &self, + _peer_id: Bytes32, + _resource_id: Bytes32, + _offset: u64, + _length: u64, + ) -> Result { + if let Some(f) = &self.fail { + return Err(f.clone()); + } + Ok(ChallengeResponse { + bytes: self.bytes.clone(), + }) + } + } + + #[tokio::test] + async fn matching_bytes_pass() { + let t = FakeTransport { + bytes: vec![1, 2, 3], + fail: None, + }; + assert!(run_window(&t, PEER, RESOURCE, 0, 3, &[1, 2, 3]).await); + } + + #[tokio::test] + async fn wrong_bytes_fail_as_loudly_as_no_response() { + let t = FakeTransport { + bytes: vec![9, 9, 9], + fail: None, + }; + assert!(!run_window(&t, PEER, RESOURCE, 0, 3, &[1, 2, 3]).await); + } + + #[tokio::test] + async fn transport_error_fails_closed() { + let t = FakeTransport { + bytes: vec![], + fail: Some(ChallengeFailure::Timeout), + }; + assert!(!run_window(&t, PEER, RESOURCE, 0, 3, &[1, 2, 3]).await); + } + + /// SPEC §3.5: no partial credit — one bad window fails the whole cycle. + #[tokio::test] + async fn one_mismatched_window_fails_the_whole_cycle() { + let t = FakeTransport { + bytes: vec![1, 2, 3], + fail: None, + }; + let windows = vec![ + (RESOURCE, 0, 3, vec![1, 2, 3]), + (RESOURCE, 3, 3, vec![9, 9, 9]), // this one will mismatch: transport always returns [1,2,3] + ]; + assert!(!run_cycle(&t, PEER, &windows).await); + } + + #[test] + fn window_offset_and_length_stay_within_the_resource() { + let resources = [Resource { + id: RESOURCE, + length: 10, + }]; + let mut memory = NoRepeatMemory::new(); + let plan = select_window(&resources, PEER, LAUNCHER, 0, &mut memory).expect("a window"); + assert_eq!(plan.resource_index, 0); + assert!(plan.length <= 10); + assert!(plan.offset + plan.length <= 10); + } + + #[test] + fn no_repeat_memory_blocks_the_same_window_within_the_bound() { + let mut memory = NoRepeatMemory::new(); + assert!(!memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, 0)); + memory.record(PEER, LAUNCHER, RESOURCE, 5, 0); + assert!(memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, CHALLENGE_NO_REPEAT_CYCLES - 1)); + assert!(!memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, CHALLENGE_NO_REPEAT_CYCLES)); + } + + #[test] + fn three_consecutive_peer_failures_schedule_a_removal() { + let mut strikes = StrikeTracker::new(); + assert!(!strikes.record_peer_outcome(PEER, LAUNCHER, false)); + assert!(!strikes.record_peer_outcome(PEER, LAUNCHER, false)); + assert!(strikes.record_peer_outcome(PEER, LAUNCHER, false)); + assert_eq!(strikes.consecutive_failures(PEER, LAUNCHER), 3); + } + + #[test] + fn a_pass_resets_the_strike_count() { + let mut strikes = StrikeTracker::new(); + strikes.record_peer_outcome(PEER, LAUNCHER, false); + strikes.record_peer_outcome(PEER, LAUNCHER, false); + strikes.record_peer_outcome(PEER, LAUNCHER, true); + assert_eq!(strikes.consecutive_failures(PEER, LAUNCHER), 0); + } + + /// The prover-fault case (D-equivalent to §3.6 clause 4): a chain outage MUST NOT strike any + /// peer. Simulates the fault path failing to strike every peer in a set of several. + #[tokio::test] + async fn prover_fault_never_increments_any_peer_strike() { + let mut strikes = StrikeTracker::new(); + let peers: [Bytes32; 3] = [[10; 32], [11; 32], [12; 32]]; + for peer in peers { + strikes.record_prover_fault(peer, LAUNCHER); + } + for peer in peers { + assert_eq!(strikes.consecutive_failures(peer, LAUNCHER), 0); + } + } +} diff --git a/crates/dig-node-core/src/rewards/cycle.rs b/crates/dig-node-core/src/rewards/cycle.rs new file mode 100644 index 00000000..9a4ec5d6 --- /dev/null +++ b/crates/dig-node-core/src/rewards/cycle.rs @@ -0,0 +1,208 @@ +//! SPEC §2.5: the always-on per-distributor prover cycle — and the honesty properties that keep +//! a wedged loop from looking healthy. +//! +//! Three mechanisms, each with its own test below because an always-on loop is trivially easy to +//! keep green while it never actually runs: +//! 1. [`run_cycle_with_deadline`] enforces the `PROVER_CYCLE_DEADLINE_SECONDS` hard deadline — +//! a cycle that never resolves is ABANDONED, counted as a failure, and reported; it never +//! silently advances `last_cycle_completed_at`. +//! 2. [`heartbeat_tick`] / [`heartbeat_loop`] refresh `observed_at` at least every +//! `PROVER_HEARTBEAT_SECONDS`, including while `Idle` — that is what makes "the process is +//! gone" distinguishable from "the process is between cycles" (§2.5 clause 1). +//! 3. [`is_wedged`] is the READER-side derivation a caller (e.g. the RPC handler) uses to detect a +//! stalled writer: it compares `observed_at` against the reader's OWN clock, never a flag the +//! writer set — a wedged writer cannot make this reassuring because it cannot touch it. + +use super::state::{Clock, ProverState, StatusHandle}; +use super::spec_constants::{PROVER_CYCLE_DEADLINE_SECONDS, PROVER_CYCLE_PERIOD_SECONDS, PROVER_HEARTBEAT_SECONDS}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::watch; +use tokio::time::timeout; + +/// Run ONE cycle attempt against a hard deadline (SPEC §2.5 clause 2). `cycle_fn` is the actual +/// cycle work (chain reads, admission, challenges, writes) as a future; this wrapper enforces the +/// deadline and updates the status record honestly regardless of outcome — it does not know or +/// care what the work does. +/// +/// Returns `true` if the cycle completed within the deadline, `false` if it was abandoned. +pub async fn run_cycle_with_deadline(status: &StatusHandle, clock: &dyn Clock, cycle_fn: F) -> bool +where + F: FnOnce() -> Fut, + Fut: Future, +{ + let started_at = clock.now_unix_seconds(); + status.update(|s| { + s.prover_state = ProverState::Running; + s.last_cycle_started_at = Some(started_at); + s.observed_at = started_at; + }); + + match timeout(Duration::from_secs(PROVER_CYCLE_DEADLINE_SECONDS), cycle_fn()).await { + Ok(()) => { + let completed_at = clock.now_unix_seconds(); + status.update(|s| { + s.prover_state = ProverState::Idle; + s.last_cycle_completed_at = Some(completed_at); + s.next_cycle_due_at = Some(completed_at + PROVER_CYCLE_PERIOD_SECONDS); + s.consecutive_cycle_failures = 0; + s.observed_at = completed_at; + }); + true + } + Err(_elapsed) => { + // SPEC §2.5 clause 2: abandon, count as a failure, report — never leave pending, and + // NEVER advance `last_cycle_completed_at`: this cycle did not complete. + let now = clock.now_unix_seconds(); + status.update(|s| { + s.prover_state = ProverState::Idle; + s.consecutive_cycle_failures += 1; + s.observed_at = now; + }); + false + } + } +} + +/// One heartbeat: refresh `observed_at` from the clock. Exposed separately from +/// [`heartbeat_loop`] so the "refreshed at least every `PROVER_HEARTBEAT_SECONDS`, including +/// while `Idle`" property (SPEC §2.5 clause 1) has a deterministic, non-timing-dependent test. +pub fn heartbeat_tick(status: &StatusHandle, clock: &dyn Clock) { + status.update(|s| s.observed_at = clock.now_unix_seconds()); +} + +/// The heartbeat loop: calls [`heartbeat_tick`] every `PROVER_HEARTBEAT_SECONDS` until `stop` +/// carries `true`. Runs independently of whether a cycle is in progress — SPEC §2.5 clause 1 is +/// explicit that this MUST fire "including while `Idle`". +pub async fn heartbeat_loop(status: StatusHandle, clock: Arc, mut stop: watch::Receiver) { + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(PROVER_HEARTBEAT_SECONDS)) => { + heartbeat_tick(&status, clock.as_ref()); + } + _ = stop.changed() => { + if *stop.borrow() { + break; + } + } + } + } +} + +/// The READER-side wedge derivation (SPEC §2.4/§2.5): `observed_at` is the ONLY staleness signal +/// this engine exposes. A reader compares it against ITS OWN clock — never a writer-set flag, +/// which is exactly the honesty property §2.4 forbids violating. +pub fn is_wedged(observed_at: u64, reader_now: u64) -> bool { + reader_now.saturating_sub(observed_at) > PROVER_HEARTBEAT_SECONDS * 2 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rewards::state::{idle_status, TestClock}; + + fn status_at(now: u64) -> StatusHandle { + StatusHandle::new(idle_status([0; 32], [0; 32], [0; 32], now)) + } + + /// A cycle that blocks forever is abandoned at the deadline, counted as a failure, and MUST + /// NOT advance `last_cycle_completed_at`. Under `start_paused`, tokio auto-advances virtual + /// time to the timeout's own timer once nothing else can make progress — the wedged + /// `cycle_fn` (a `pending()` future) never does. + #[tokio::test(start_paused = true)] + async fn wedged_cycle_is_abandoned_at_the_deadline_and_does_not_fake_completion() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + + let completed = run_cycle_with_deadline(&status, &clock, || std::future::pending::<()>()).await; + + assert!(!completed, "a cycle that never resolves must be reported as abandoned"); + let snap = status.snapshot(); + assert_eq!(snap.consecutive_cycle_failures, 1); + assert_eq!( + snap.last_cycle_completed_at, None, + "an abandoned cycle must never advance last_cycle_completed_at" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_cycle_that_finishes_in_time_completes_normally() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + + let completed = run_cycle_with_deadline(&status, &clock, || async {}).await; + + assert!(completed); + let snap = status.snapshot(); + assert_eq!(snap.consecutive_cycle_failures, 0); + assert_eq!(snap.last_cycle_completed_at, Some(1_000)); + assert_eq!(snap.next_cycle_due_at, Some(1_000 + PROVER_CYCLE_PERIOD_SECONDS)); + } + + /// SPEC §2.5 clause 1: a heartbeat fires even while the prover is sitting `Idle` between + /// cycles — this is what distinguishes "gone" from "between cycles" (deterministic: drives + /// the tick directly rather than the timer). + #[test] + fn heartbeat_tick_advances_observed_at_while_idle() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + assert_eq!(status.snapshot().prover_state, ProverState::Idle); + + clock.advance(PROVER_HEARTBEAT_SECONDS); + heartbeat_tick(&status, &clock); + + let snap = status.snapshot(); + assert_eq!(snap.observed_at, 1_000 + PROVER_HEARTBEAT_SECONDS); + assert_eq!(snap.prover_state, ProverState::Idle, "a heartbeat must not touch prover_state"); + } + + /// The wedged-loop reader-side property: `observed_at` stops advancing while a cycle is stuck + /// (started but never completed, and no heartbeat fired), so a reader comparing it against its + /// own clock detects the wedge WITHOUT any writer-set flag existing to lie about it. + #[test] + fn a_stalled_observed_at_is_detected_by_the_readers_own_clock() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + status.update(|s| { + s.prover_state = ProverState::Running; + s.observed_at = clock.now_unix_seconds(); + }); + + // The writer never ticks again (that IS the wedge). The reader's own notion of "now" + // keeps moving regardless. + let reader_now = 1_000 + PROVER_HEARTBEAT_SECONDS * 3; + assert!(is_wedged(status.snapshot().observed_at, reader_now)); + } + + #[test] + fn a_recently_heartbeat_status_is_not_wedged() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + heartbeat_tick(&status, &clock); + let reader_now = 1_000 + PROVER_HEARTBEAT_SECONDS; // within bound, one missed tick at most + assert!(!is_wedged(status.snapshot().observed_at, reader_now)); + } + + /// The real async heartbeat loop actually fires on its own timer, not only via the + /// deterministic direct-call test above. + #[tokio::test(start_paused = true)] + async fn heartbeat_loop_fires_on_its_own_timer() { + let clock = Arc::new(TestClock::new(1_000)); + let status = status_at(1_000); + let (tx, rx) = watch::channel(false); + + let loop_status = status.clone(); + let loop_clock: Arc = clock.clone(); + let handle = tokio::spawn(heartbeat_loop(loop_status, loop_clock, rx)); + + clock.advance(PROVER_HEARTBEAT_SECONDS); + tokio::time::advance(Duration::from_secs(PROVER_HEARTBEAT_SECONDS)).await; + tokio::task::yield_now().await; + + assert!(status.snapshot().observed_at >= 1_000 + PROVER_HEARTBEAT_SECONDS); + + tx.send(true).expect("stop channel open"); + handle.await.expect("heartbeat loop task"); + } +} diff --git a/crates/dig-node-core/src/rewards/gate.rs b/crates/dig-node-core/src/rewards/gate.rs index 13da169f..b04e50c9 100644 --- a/crates/dig-node-core/src/rewards/gate.rs +++ b/crates/dig-node-core/src/rewards/gate.rs @@ -1,21 +1,48 @@ //! The mirror-coin gate (SPEC §4, §10). A candidate is admitted only when all three §4.3 calls -//! agree: `advertises(store, root, mirror_collateral_epoch)` AND `declares_peer(peer_id)` -> +//! agree: `advertises(store, root, census_epoch)` AND `declares_peer(peer_id)` -> //! `owner_puzzle_hash()`. Fail-closed on every absence or mismatch (§4.2, §10.3) — ineligibility is //! never an accusation, never a strike, never a blocklist entry. //! //! This module does not reimplement `MirrorCoin::advertises` / `declares_peer` / //! `owner_puzzle_hash` (Appendix B hard rule — see `crate::mirror_bond` for the existing verified- //! pointer pattern this follows). It defines [`MirrorCoinReader`], the narrow seam over those three -//! calls, and drives the SPEC's admission logic — including the §4.6.3 grace window — against it. -//! The host binary supplies the real reader (wired to `dig-mirror-coin`) exactly the way -//! `mirror_bond::MirrorBondVerifier` is wired today. +//! calls, and drives the SPEC's admission logic — including the §4.6 census offset and the §4.6.3 +//! grace window — against it. The host binary supplies the real reader (wired to `dig-mirror-coin`) +//! exactly the way `mirror_bond::MirrorBondVerifier` is wired today. +use super::spec_constants::MIRROR_EPOCH_GRACE_SECONDS; use async_trait::async_trait; /// One candidate's claimed mirror-coin pointer, exactly as a `ProviderRecord` carries it // (`unverified_mirror_coin_id`, SPEC §4.1-§4.2) — a claim, proves nothing on its own. pub type CoinIdHint = Option<[u8; 32]>; +/// The mirror-collateral epoch context needed to evaluate one peer this cycle (SPEC §4.6). +/// +/// `current_epoch` is the mirror-collateral epoch ordinal currently open (`n`), supplied by the +/// caller as CONFIGURATION — this gate never computes or guesses it (SPEC §4.6 clause 2, +/// DIG-Network/dig_ecosystem#3259: nobody owns the calendar yet). Its absence is a PROVER-side +/// fault, not a peer-attributable one — see [`GateError::EpochOrdinalUnavailable`]. +#[derive(Debug, Clone, Copy)] +pub struct EpochContext { + pub current_epoch: Option, + /// Wall-clock unix seconds the CURRENT epoch rolled over at, if known. `None` = no rollover + /// tracked (e.g. first epoch observed), so no grace applies. + pub epoch_rolled_over_at: Option, + /// Now, from the caller's injected `Clock` — used only to decide whether we're still inside + /// the SPEC §4.6.3 grace window. + pub now: u64, +} + +impl EpochContext { + fn in_grace_window(&self) -> bool { + match self.epoch_rolled_over_at { + Some(rolled_at) => self.now.saturating_sub(rolled_at) < MIRROR_EPOCH_GRACE_SECONDS, + None => false, + } + } +} + /// The three SPEC §4.3 calls, plus the §4.2 coin-validity checks, as one seam. An implementation /// MUST perform every §4.2 check (puzzle hash, asset id, collateral, unspent) before answering /// `advertises`/`declares_peer`/`owner_puzzle_hash` — this trait's contract is that a `true` / @@ -24,16 +51,16 @@ pub type CoinIdHint = Option<[u8; 32]>; #[async_trait] pub trait MirrorCoinReader: Send + Sync { /// SPEC §4.2 + §4.3 row 1: fetch the coin at `coin_id` and confirm it advertises exactly - /// `(store_id, root, mirror_collateral_epoch)`. `false` for absent, unresolvable, invalid, - /// spent, under-collateralised, or non-advertising — every §4.2/§4.3.1 failure collapses to - /// `false` here because none of them distinguish for the caller (SPEC §4.2: "MUST NOT be - /// treated as evidence of bad faith"). + /// `(store_id, root, census_epoch)`. `false` for absent, unresolvable, invalid, spent, + /// under-collateralised, or non-advertising — every §4.2/§4.3.1 failure collapses to `false` + /// here because none of them distinguish for the caller (SPEC §4.2: "MUST NOT be treated as + /// evidence of bad faith"). async fn advertises( &self, coin_id: [u8; 32], store_id: [u8; 32], root: [u8; 32], - mirror_collateral_epoch: u64, + census_epoch: u64, ) -> bool; /// SPEC §4.3 row 2: does this coin declare `peer_id` as its owner-authenticated claimant. @@ -50,7 +77,7 @@ pub trait MirrorCoinReader: Send + Sync { pub enum GateIneligibleReason { /// No `unverified_mirror_coin_id` hint on the candidate's provider record. AbsentCoinIdHint, - /// The coin does not advertise this `(store, root, epoch)` at all — covers "spent", + /// The coin does not advertise this `(store, root, census_epoch)` at all — covers "spent", /// "wrong epoch ordinal", and "not a mirror coin" alike (§4.2's collapse). DoesNotAdvertise, /// The coin advertises the content but does not declare this candidate's `peer_id`. @@ -66,10 +93,22 @@ pub enum GateOutcome { Ineligible(GateIneligibleReason), } +/// Why the gate could not evaluate ANY candidate this cycle — a prover-side fault, never a +/// peer-attributable ineligibility. Deliberately NOT a `GateIneligibleReason` variant: a caller +/// that could construct this as ordinary ineligibility would strike the peer for a configuration +/// gap that is not its fault (SPEC §3.6 clause 4 / dig_ecosystem#3250 D5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateError { + /// SPEC §4.6 clause 2 / dig_ecosystem#3259: the mirror-collateral epoch ordinal was not + /// supplied. The caller MUST map this to `ProverState::ChainSourceUnavailable`, abort the + /// cycle WITHOUT evaluating any candidate, and MUST NOT increment any peer's strike counter. + EpochOrdinalUnavailable, +} + /// The mirror-coin gate contract [`admission::admit`](super::admission::admit) drives. #[async_trait] pub trait MirrorCoinGatePort: Send + Sync { - async fn evaluate(&self, peer_id: [u8; 32], mirror_collateral_epoch_ordinal: Option) -> GateOutcome; + async fn evaluate(&self, peer_id: [u8; 32], ctx: EpochContext) -> Result; } /// The SPEC §4-driven gate: takes a candidate's coin-id hint and a `MirrorCoinReader`, and decides @@ -98,26 +137,27 @@ impl SpecMirrorCoinGate { } } - /// SPEC §4.6.3: during the grace window after a rollover, the PREVIOUS ordinal is also + /// SPEC §4.6.3: during the grace window after a rollover, the PREVIOUS census ordinal is also /// accepted, and a rollover mismatch MUST NOT strike (the caller enforces the "no strike" half; - /// this function only decides eligibility). - async fn advertises_current_or_previous( + /// this function only decides eligibility). `census_epoch` is already the §4.6.1 offset + /// (`current_epoch - 1`) — see [`Self::census_epoch`]. + pub async fn advertises_current_or_previous( &self, coin_id: [u8; 32], - current_epoch: u64, + census_epoch: u64, in_grace_window: bool, ) -> bool { if self .reader - .advertises(coin_id, self.store_id, self.root, current_epoch) + .advertises(coin_id, self.store_id, self.root, census_epoch) .await { return true; } - if in_grace_window && current_epoch > 0 { + if in_grace_window && census_epoch > 0 { return self .reader - .advertises(coin_id, self.store_id, self.root, current_epoch - 1) + .advertises(coin_id, self.store_id, self.root, census_epoch - 1) .await; } false @@ -126,35 +166,38 @@ impl SpecMirrorCoinGate { #[async_trait] impl MirrorCoinGatePort for SpecMirrorCoinGate { - async fn evaluate(&self, peer_id: [u8; 32], mirror_collateral_epoch_ordinal: Option) -> GateOutcome { - // SPEC §4.6 clause 2: the ordinal is an INPUT; its absence is ineligibility, never a - // computed guess (dig_ecosystem#3259). - let Some(current_epoch) = mirror_collateral_epoch_ordinal else { - return GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise); + async fn evaluate(&self, peer_id: [u8; 32], ctx: EpochContext) -> Result { + // SPEC §4.6 clause 2 / D5: the ordinal is an INPUT; its absence is a PROVER fault + // (ChainSourceUnavailable at the cycle layer), never guessed and never peer-attributable + // ineligibility (dig_ecosystem#3259, #3250 D5). + let Some(current_epoch) = ctx.current_epoch else { + return Err(GateError::EpochOrdinalUnavailable); + }; + + // SPEC §4.6.1: a coin qualifies for the census of epoch `n` only by declaring `n-1` + // EXACTLY. `n == 0` means no epoch has closed a census round yet — nothing can qualify. + let Some(census_epoch) = current_epoch.checked_sub(1) else { + return Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)); }; let Some(hint) = self.coin_hint_for.get(&peer_id).copied().flatten() else { - return GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint); + return Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint)); }; - // Grace window handling is delegated to the caller in production (it needs wall-clock - // context this gate does not hold); for the pure §4.3 chain here we accept the current - // ordinal only, matching a cycle called immediately at rollover with no grace granted. The - // grace-window itself is exercised via `advertises_current_or_previous` directly in tests. if !self - .advertises_current_or_previous(hint, current_epoch, false) + .advertises_current_or_previous(hint, census_epoch, ctx.in_grace_window()) .await { - return GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise); + return Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)); } if !self.reader.declares_peer(hint, peer_id).await { - return GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared); + return Ok(GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared)); } match self.reader.owner_puzzle_hash(hint).await { - Some(payout_puzzle_hash) => GateOutcome::Eligible { payout_puzzle_hash }, - None => GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration), + Some(payout_puzzle_hash) => Ok(GateOutcome::Eligible { payout_puzzle_hash }), + None => Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration)), } } } @@ -200,21 +243,40 @@ mod tests { SpecMirrorCoinGate::new(reader, STORE, ROOT, [(PEER, hint)].into_iter().collect()) } + fn ctx(current_epoch: Option) -> EpochContext { + EpochContext { + current_epoch, + epoch_rolled_over_at: None, + now: 0, + } + } + #[tokio::test] async fn absent_coin_id_hint_is_ineligible() { let g = gate(FakeReader::default(), None); assert_eq!( - g.evaluate(PEER, Some(1)).await, - GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint) + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint)) ); } + /// D5: an absent epoch ordinal is a PROVER-side fault, never `GateIneligibleReason` — it must + /// come back as `Err`, not as an eligibility verdict a caller could strike a peer over. #[tokio::test] - async fn epoch_ordinal_absent_is_ineligible_never_a_guess() { + async fn epoch_ordinal_absent_is_a_gate_error_not_an_ineligibility_verdict() { let g = gate(FakeReader::default(), Some(COIN)); assert_eq!( - g.evaluate(PEER, None).await, - GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise) + g.evaluate(PEER, ctx(None)).await, + Err(GateError::EpochOrdinalUnavailable) + ); + } + + #[tokio::test] + async fn current_epoch_zero_has_no_closed_census_and_is_ineligible() { + let g = gate(FakeReader::default(), Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(0))).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)) ); } @@ -223,77 +285,124 @@ mod tests { let reader = FakeReader::default(); // advertising map empty == coin doesn't advertise (covers spent/absent) let g = gate(reader, Some(COIN)); assert_eq!( - g.evaluate(PEER, Some(1)).await, - GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise) + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)) + ); + } + + /// D1 regression: declaring the CURRENT epoch ordinal `n` directly must NOT qualify the + /// census of epoch `n` — only `n-1` does (SPEC §4.6.1). This fails on the pre-fix code, which + /// queried `advertises(.., current_epoch)` instead of `current_epoch - 1`. + #[tokio::test] + async fn census_epoch_is_n_minus_1_not_n() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 5), true); // declares n=5 itself, not n-1=4 + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(5))).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)), + "declaring n directly must NOT qualify the census of epoch n (SPEC §4.6.1)" ); } + /// D1 positive: declaring exactly `n-1` for current epoch `n` DOES qualify. #[tokio::test] - async fn wrong_epoch_ordinal_is_ineligible() { + async fn census_epoch_n_minus_1_is_admitted() { let mut reader = FakeReader::default(); - reader.advertising.insert((COIN, 7), true); // advertises epoch 7, we ask about 1 + reader.advertising.insert((COIN, 4), true); // n-1 = 4 for current epoch n=5 + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); let g = gate(reader, Some(COIN)); assert_eq!( - g.evaluate(PEER, Some(1)).await, - GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise) + g.evaluate(PEER, ctx(Some(5))).await, + Ok(GateOutcome::Eligible { + payout_puzzle_hash: OWNER + }) ); } #[tokio::test] async fn declares_peer_mismatch_is_ineligible() { let mut reader = FakeReader::default(); - reader.advertising.insert((COIN, 1), true); + reader.advertising.insert((COIN, 1), true); // census epoch 1 (current epoch 2) reader.declaring.insert(COIN, [0xEE; 32]); // declares someone else let g = gate(reader, Some(COIN)); assert_eq!( - g.evaluate(PEER, Some(1)).await, - GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared) + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared)) ); } + /// D3: exercises the `owner_puzzle_hash() == None` path, distinct from `PeerNotDeclared`. #[tokio::test] async fn absent_declaration_is_ineligible() { let mut reader = FakeReader::default(); - reader.advertising.insert((COIN, 1), true); - // declares_peer defaults to false (not in map) -> PeerNotDeclared covers "absent declaration" too + reader.advertising.insert((COIN, 1), true); // census epoch 1 (current epoch 2) + reader.declaring.insert(COIN, PEER); + // owners map has no entry for COIN -> owner_puzzle_hash() resolves to None. let g = gate(reader, Some(COIN)); assert_eq!( - g.evaluate(PEER, Some(1)).await, - GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared) + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration)) ); } #[tokio::test] async fn all_three_calls_agreeing_is_eligible() { let mut reader = FakeReader::default(); - reader.advertising.insert((COIN, 1), true); + reader.advertising.insert((COIN, 1), true); // census epoch 1 (current epoch 2) reader.declaring.insert(COIN, PEER); reader.owners.insert(COIN, OWNER); let g = gate(reader, Some(COIN)); assert_eq!( - g.evaluate(PEER, Some(1)).await, - GateOutcome::Eligible { + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Eligible { payout_puzzle_hash: OWNER - } + }) ); } - /// SPEC §4.6.3: previous ordinal accepted inside the grace window. + /// SPEC §4.6.3: previous census ordinal accepted inside the grace window — reachable through + /// `evaluate`, not only through the private helper (D2 regression). #[tokio::test] - async fn previous_ordinal_accepted_within_grace_window() { + async fn grace_window_makes_previous_census_ordinal_admissible_via_evaluate() { let mut reader = FakeReader::default(); - reader.advertising.insert((COIN, 4), true); // declares previous ordinal (current is 5) + reader.advertising.insert((COIN, 4), true); // pre-rollover census ordinal + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); let g = gate(reader, Some(COIN)); - assert!(g.advertises_current_or_previous(COIN, 5, true).await); + let inside_grace = EpochContext { + current_epoch: Some(6), // census would need 5; coin still shows 4 + epoch_rolled_over_at: Some(1_000), + now: 1_000 + MIRROR_EPOCH_GRACE_SECONDS - 1, + }; + assert_eq!( + g.evaluate(PEER, inside_grace).await, + Ok(GateOutcome::Eligible { + payout_puzzle_hash: OWNER + }) + ); } - /// SPEC §4.6.3: outside the grace window, a rollover mismatch is simply ineligible, and this - /// crate MUST NOT strike for it (enforced at the cycle layer, not here). + /// D2 regression: outside the grace window the same mismatch is simply ineligible (never a + /// strike — enforced at the cycle layer). #[tokio::test] - async fn previous_ordinal_rejected_outside_grace_window() { + async fn outside_grace_window_previous_census_ordinal_is_rejected() { let mut reader = FakeReader::default(); reader.advertising.insert((COIN, 4), true); + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); let g = gate(reader, Some(COIN)); - assert!(!g.advertises_current_or_previous(COIN, 5, false).await); + let outside_grace = EpochContext { + current_epoch: Some(6), + epoch_rolled_over_at: Some(1_000), + now: 1_000 + MIRROR_EPOCH_GRACE_SECONDS + 1, + }; + assert_eq!( + g.evaluate(PEER, outside_grace).await, + Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)) + ); } } diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs new file mode 100644 index 00000000..bb998685 --- /dev/null +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -0,0 +1,27 @@ +//! The rewards prover engine (DIG-Network/dig_ecosystem#3250): the node-side half of +//! `dig-rewards-coin`'s reward-distributor loop. +//! +//! It runs an always-on per-distributor cycle ([`cycle`]), gates every discovered mirror +//! candidate through the SPEC §4 mirror-coin proof ([`gate`], [`admission`]), issues and grades +//! §3 possession challenges ([`challenge`]), decides and rate-limits §6.3 entry-set writes +//! ([`writes`]), and derives the §12.4 staleness bound from chain-observed state only +//! ([`staleness`]). +//! +//! The chain seam ([`port`]'s `RewardsChainPort`) is UNIMPLEMENTED pending +//! DIG-Network/dig_ecosystem#3249 — `dig-rewards-coin` is SPEC-only today (its `distributor` +//! module is an empty placeholder). The production adapter wired into this crate is +//! `port::UnavailableChainPort`, which runs no cycles and reports +//! `port::ChainPortError::Unavailable` rather than a silent no-op. Every value this engine +//! compares against the SPEC's numeric bounds lives in [`spec_constants`], tagged with its +//! clause, so #3249 landing its own constants is a single, deliberate migration rather than a +//! scattered one. + +pub mod admission; +pub mod challenge; +pub mod cycle; +pub mod gate; +pub mod port; +pub mod spec_constants; +pub mod staleness; +pub mod state; +pub mod writes; diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 860fd868..1534bedb 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -95,6 +95,13 @@ pub trait RewardsChainPort: Send + Sync { /// SPEC §6.3: submit ONE bundle of at most `MAX_ENTRY_WRITES_PER_BUNDLE` actions with a fee. async fn submit_entry_writes(&self, bundle: EntryWriteBundle) -> Result<(), ChainPortError>; + + /// SPEC §2.1: spend the distributor singleton's `NewEpoch` action when a synced state is + /// needed for an entry-set write (§8.2) and the epoch has rolled. Idempotent in effect — SPEC + /// §2.1 clause 3 names TWO willing spenders (this prover and #3251's claim loop) as correct, + /// not a conflict, and neither MUST treat a not-yet-rolled epoch as an error or assume the + /// other already did it. + async fn spend_new_epoch(&self, launcher_id: Bytes32) -> Result<(), ChainPortError>; } /// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports @@ -122,6 +129,10 @@ impl RewardsChainPort for UnavailableChainPort { async fn submit_entry_writes(&self, _bundle: EntryWriteBundle) -> Result<(), ChainPortError> { Err(ChainPortError::Unavailable) } + + async fn spend_new_epoch(&self, _launcher_id: Bytes32) -> Result<(), ChainPortError> { + Err(ChainPortError::Unavailable) + } } #[cfg(test)] @@ -148,5 +159,9 @@ mod tests { .await, Err(ChainPortError::Unavailable) ); + assert_eq!( + port.spend_new_epoch([0u8; 32]).await, + Err(ChainPortError::Unavailable) + ); } } diff --git a/crates/dig-node-core/src/rewards/staleness.rs b/crates/dig-node-core/src/rewards/staleness.rs new file mode 100644 index 00000000..fa1800e9 --- /dev/null +++ b/crates/dig-node-core/src/rewards/staleness.rs @@ -0,0 +1,82 @@ +//! SPEC §12.4: entry-set staleness, derived ONLY from chain-observed state — never a prover +//! self-report. See [`is_entry_set_stale`]. +//! +//! This value MUST NOT appear on the prover status record (SPEC §2.4 — no precomputed staleness +//! anywhere on that record); it belongs only on the distributor's own chain read +//! (`dig.getRewardDistributor`'s `entry_set_stale`), derived fresh by the reader every time. + +use super::port::DistributorChainState; +use super::spec_constants::STALE_ENTRY_SET_SECONDS; + +/// SPEC §12.4: an entry set is stale when BOTH conjuncts hold: +/// 1. the distributor's reserve is non-zero (a zero reserve is `Unfunded` — a different report, +/// §6.5/§12.6 — and the entry set is kept regardless of staleness); and +/// 2. the last CHAIN-OBSERVED entry write (`DistributorChainState::last_entry_write_at`, the +/// singleton's own spend history) is at least `STALE_ENTRY_SET_SECONDS` old. +/// +/// `last_entry_write_at == None` means the entry set has never been written to. That is not +/// "unknown" — it is maximally stale the moment the distributor itself has existed at least the +/// bound: "never written" cannot be more current than "written a long time ago". +pub fn is_entry_set_stale(state: &DistributorChainState, now: u64, distributor_created_at: u64) -> bool { + if state.reserve_base_units == 0 { + return false; + } + match state.last_entry_write_at { + Some(last_write) => now.saturating_sub(last_write) >= STALE_ENTRY_SET_SECONDS, + None => now.saturating_sub(distributor_created_at) >= STALE_ENTRY_SET_SECONDS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rewards::port::EntrySlot; + + fn state(reserve: u64, last_entry_write_at: Option) -> DistributorChainState { + DistributorChainState { + reserve_base_units: reserve, + entries: Vec::::new(), + current_distributor_epoch: 0, + last_entry_write_at, + total_paid_out_base_units: 0, + } + } + + #[test] + fn zero_reserve_is_never_stale_regardless_of_write_age() { + let s = state(0, Some(0)); + assert!(!is_entry_set_stale(&s, STALE_ENTRY_SET_SECONDS * 10, 0)); + } + + #[test] + fn fresh_write_with_reserve_is_not_stale() { + let s = state(100, Some(1_000)); + assert!(!is_entry_set_stale(&s, 1_000 + STALE_ENTRY_SET_SECONDS - 1, 0)); + } + + #[test] + fn write_older_than_bound_with_reserve_is_stale() { + let s = state(100, Some(1_000)); + assert!(is_entry_set_stale(&s, 1_000 + STALE_ENTRY_SET_SECONDS, 0)); + } + + /// SPEC §12.4: a distributor whose entry set was NEVER written, funded, and at least as old as + /// the bound is stale too — "never written" is maximally stale, not an unknown/false default. + #[test] + fn never_written_entry_set_with_reserve_and_old_enough_distributor_is_stale() { + let s = state(100, None); + let created_at = 500; + assert!(is_entry_set_stale(&s, created_at + STALE_ENTRY_SET_SECONDS, created_at)); + } + + #[test] + fn never_written_entry_set_but_distributor_still_young_is_not_stale() { + let s = state(100, None); + let created_at = 500; + assert!(!is_entry_set_stale( + &s, + created_at + STALE_ENTRY_SET_SECONDS - 1, + created_at + )); + } +} diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs new file mode 100644 index 00000000..fc1ccd32 --- /dev/null +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -0,0 +1,269 @@ +//! SPEC §6.3 entry-set write bounds — this spends the funder's money, so every bound here is +//! enforced in code, never left to caller discipline. +//! +//! 1. **Batch**: at most one bundle per cycle, at most [`MAX_ENTRY_WRITES_PER_BUNDLE`] actions, +//! one fee. +//! 2. **Rate**: at most one bundle per distributor per [`ENTRY_WRITE_MIN_INTERVAL_SECONDS`]. A +//! decision reached sooner is WITHHELD, never dropped — it shows up in `pending_entry_writes`. +//! 3. **Cap**: a per-distributor daily fee budget ([`FeeBudget`]). On exhaustion: stop writing, +//! KEEP the decisions, report `FeeBudgetExhausted`. +//! 4. **Hysteresis**: a removal is not re-added for [`REENTRY_COOLDOWN_SECONDS`], keyed on +//! `(payout_puzzle_hash, launcher_id)` and NEVER on `peer_id` — the puzzle hash is what the +//! chain writes; a peer can present a fresh `peer_id` (e.g. a new TLS cert) for the same payout +//! address and MUST still be held. +//! +//! Also §6.5/§12.6: [`is_entry_set_full`] / [`is_unfunded`] name the two other terminal reports +//! (`EntrySetFull`, `Unfunded`) — on `Unfunded` the entry set is KEPT, never evicted, because +//! evicting 250 entries to punish an empty reserve costs 250 fees and punishes nobody. + +use super::port::{Bytes32, EntryAction, EntryWriteBundle}; +use super::spec_constants::{ + ENTRY_WRITE_MIN_INTERVAL_SECONDS, MAX_ENTRIES_PER_DISTRIBUTOR, MAX_ENTRY_WRITES_PER_BUNDLE, + REENTRY_COOLDOWN_SECONDS, +}; +use std::collections::HashMap; + +/// Reentry-cooldown key — deliberately `(payout_puzzle_hash, launcher_id)`, never `peer_id`. +pub type CooldownKey = (Bytes32, Bytes32); + +/// A per-distributor daily fee budget in XCH mojos. Default: 24 bundles' worth of the operator's +/// configured standard fee (SPEC §6.3 cap). +pub struct FeeBudget { + limit_mojos_per_day: u64, + spent_mojos_today: u64, + day_started_at: u64, +} + +impl FeeBudget { + pub fn new(standard_fee_mojos: u64, now: u64) -> Self { + Self { + limit_mojos_per_day: standard_fee_mojos.saturating_mul(24), + spent_mojos_today: 0, + day_started_at: now, + } + } + + fn roll_if_new_day(&mut self, now: u64) { + if now.saturating_sub(self.day_started_at) >= 86_400 { + self.spent_mojos_today = 0; + self.day_started_at = now; + } + } + + /// `true` if `fee_mojos` fits inside today's remaining budget, in which case it is charged. + pub fn try_spend(&mut self, fee_mojos: u64, now: u64) -> bool { + self.roll_if_new_day(now); + if self.spent_mojos_today.saturating_add(fee_mojos) > self.limit_mojos_per_day { + return false; + } + self.spent_mojos_today += fee_mojos; + true + } +} + +/// What a call to [`EntryWriteScheduler::decide`] produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteOutcome { + /// A bundle ready to submit through `RewardsChainPort::submit_entry_writes`. + Bundle { bundle: EntryWriteBundle, still_pending: u32 }, + /// Nothing submitted, `count` decisions withheld this cycle (rate-limited or none ready) — + /// they MUST still surface in `pending_entry_writes`, never silently dropped. + Pending { count: u32 }, + /// The fee budget is exhausted for today: stop writing, but the `count` decisions are KEPT, + /// not discarded. + FeeBudgetExhausted { count: u32 }, +} + +/// Tracks the per-distributor write-rate clock and the per-`(payout_puzzle_hash, launcher_id)` +/// reentry cooldown. One instance per running prover (not per cycle) so both bounds persist across +/// cycles. +#[derive(Default)] +pub struct EntryWriteScheduler { + last_bundle_sent_at: HashMap, + cooldown_until: HashMap, +} + +impl EntryWriteScheduler { + pub fn new() -> Self { + Self::default() + } + + pub fn is_rate_limited(&self, launcher_id: Bytes32, now: u64) -> bool { + match self.last_bundle_sent_at.get(&launcher_id) { + Some(&last) => now.saturating_sub(last) < ENTRY_WRITE_MIN_INTERVAL_SECONDS, + None => false, + } + } + + /// SPEC §6.3 clause 4 hysteresis check — keyed on the payout puzzle hash, never `peer_id`. + pub fn is_in_reentry_cooldown(&self, payout_puzzle_hash: Bytes32, launcher_id: Bytes32, now: u64) -> bool { + match self.cooldown_until.get(&(payout_puzzle_hash, launcher_id)) { + Some(&until) => now < until, + None => false, + } + } + + fn record_removal(&mut self, payout_puzzle_hash: Bytes32, launcher_id: Bytes32, now: u64) { + self.cooldown_until + .insert((payout_puzzle_hash, launcher_id), now + REENTRY_COOLDOWN_SECONDS); + } + + /// Decide this cycle's write for one distributor from a queue of pending decisions (already + /// hysteresis-filtered by the caller via [`Self::is_in_reentry_cooldown`] for adds). Enforces + /// the batch cap, the rate bound, and the fee budget, in that order of relevance to the + /// caller — but the RATE check runs first because a rate-limited distributor must not touch + /// the fee budget at all. + pub fn decide( + &mut self, + launcher_id: Bytes32, + decisions: Vec, + fee_mojos: u64, + budget: &mut FeeBudget, + now: u64, + ) -> WriteOutcome { + if decisions.is_empty() { + return WriteOutcome::Pending { count: 0 }; + } + if self.is_rate_limited(launcher_id, now) { + return WriteOutcome::Pending { + count: decisions.len() as u32, + }; + } + + let take = decisions.len().min(MAX_ENTRY_WRITES_PER_BUNDLE as usize); + let (bundle_actions, rest) = decisions.split_at(take); + + if !budget.try_spend(fee_mojos, now) { + return WriteOutcome::FeeBudgetExhausted { + count: decisions.len() as u32, + }; + } + + for action in bundle_actions { + if let EntryAction::Remove { + payout_puzzle_hash, + launcher_id: lid, + } = action + { + self.record_removal(*payout_puzzle_hash, *lid, now); + } + } + self.last_bundle_sent_at.insert(launcher_id, now); + + WriteOutcome::Bundle { + bundle: EntryWriteBundle { + launcher_id, + actions: bundle_actions.to_vec(), + fee_mojos, + }, + still_pending: rest.len() as u32, + } + } +} + +/// SPEC §6.5: the entry set is capped at [`MAX_ENTRIES_PER_DISTRIBUTOR`] entries. +pub fn is_entry_set_full(current_entry_count: usize) -> bool { + current_entry_count >= MAX_ENTRIES_PER_DISTRIBUTOR as usize +} + +/// SPEC §12.6: a zero reserve is `Unfunded`; the caller MUST keep the entry set as-is. +pub fn is_unfunded(reserve_base_units: u64) -> bool { + reserve_base_units == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + const LAUNCHER: Bytes32 = [1; 32]; + const PAYOUT_A: Bytes32 = [2; 32]; + + fn add(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> EntryAction { + EntryAction::Add { + payout_puzzle_hash, + launcher_id, + } + } + + fn remove(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> EntryAction { + EntryAction::Remove { + payout_puzzle_hash, + launcher_id, + } + } + + #[test] + fn batch_cap_leaves_the_rest_pending() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let decisions: Vec = (0..(MAX_ENTRY_WRITES_PER_BUNDLE + 3)) + .map(|i| add([i as u8; 32], LAUNCHER)) + .collect(); + let outcome = scheduler.decide(LAUNCHER, decisions, 100, &mut budget, 0); + match outcome { + WriteOutcome::Bundle { bundle, still_pending } => { + assert_eq!(bundle.actions.len(), MAX_ENTRY_WRITES_PER_BUNDLE as usize); + assert_eq!(still_pending, 3); + } + other => panic!("expected Bundle, got {other:?}"), + } + } + + /// SPEC §6.3 clause 2: a decision reached sooner than the interval MUST be withheld and MUST + /// appear as pending — never dropped. + #[test] + fn rate_limit_withholds_rather_than_drops() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let first = scheduler.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], 100, &mut budget, 0); + assert!(matches!(first, WriteOutcome::Bundle { .. })); + + let second = scheduler.decide( + LAUNCHER, + vec![add([9; 32], LAUNCHER)], + 100, + &mut budget, + ENTRY_WRITE_MIN_INTERVAL_SECONDS - 1, + ); + assert_eq!(second, WriteOutcome::Pending { count: 1 }); + } + + #[test] + fn fee_budget_exhaustion_keeps_decisions_and_stops_writing() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(10, 0); // 240 mojos/day + let fee = 1_000; // exceeds the whole day's budget on the first attempt + let outcome = scheduler.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], fee, &mut budget, 0); + assert_eq!(outcome, WriteOutcome::FeeBudgetExhausted { count: 1 }); + } + + /// The named cooldown-bypass trap: cooldown is keyed on `(payout_puzzle_hash, launcher_id)` + /// only — `peer_id` never enters the key, so presenting a fresh TLS cert / `peer_id` for the + /// SAME payout address does not bypass the cooldown. + #[test] + fn reentry_cooldown_survives_a_fresh_peer_id_for_the_same_payout_hash() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let outcome = scheduler.decide(LAUNCHER, vec![remove(PAYOUT_A, LAUNCHER)], 100, &mut budget, 0); + assert!(matches!(outcome, WriteOutcome::Bundle { .. })); + + // A "fresh peer_id" is not even a parameter to this cooldown check — it is keyed purely on + // the payout puzzle hash, which is exactly what makes the bypass impossible: nothing about + // peer identity can change which key is consulted. + assert!(scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, ENTRY_WRITE_MIN_INTERVAL_SECONDS)); + assert!(scheduler.is_in_reentry_cooldown( + PAYOUT_A, + LAUNCHER, + REENTRY_COOLDOWN_SECONDS - 1 + )); + assert!(!scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, REENTRY_COOLDOWN_SECONDS)); + } + + #[test] + fn entry_set_full_and_unfunded_report_the_right_terminal_state() { + assert!(is_entry_set_full(MAX_ENTRIES_PER_DISTRIBUTOR as usize)); + assert!(!is_entry_set_full(MAX_ENTRIES_PER_DISTRIBUTOR as usize - 1)); + assert!(is_unfunded(0)); + assert!(!is_unfunded(1)); + } +} From 998531eb549af3e9891304682698d77bccf56484 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 13:51:24 -0700 Subject: [PATCH 04/11] style(rewards): cargo fmt --- crates/dig-node-core/src/rewards/admission.rs | 28 +++++--- crates/dig-node-core/src/rewards/challenge.rs | 44 +++++++++++-- crates/dig-node-core/src/rewards/cycle.rs | 42 +++++++++--- crates/dig-node-core/src/rewards/gate.rs | 65 ++++++++++++++----- crates/dig-node-core/src/rewards/staleness.rs | 18 ++++- crates/dig-node-core/src/rewards/state.rs | 17 ++++- crates/dig-node-core/src/rewards/writes.rs | 38 ++++++++--- 7 files changed, 198 insertions(+), 54 deletions(-) diff --git a/crates/dig-node-core/src/rewards/admission.rs b/crates/dig-node-core/src/rewards/admission.rs index 02c072c9..f55b3653 100644 --- a/crates/dig-node-core/src/rewards/admission.rs +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -41,7 +41,9 @@ pub struct OwnIdentity { impl OwnIdentity { fn controls(&self, puzzle_hash: &[u8; 32]) -> bool { - self.controlled_puzzle_hashes.iter().any(|h| h == puzzle_hash) + self.controlled_puzzle_hashes + .iter() + .any(|h| h == puzzle_hash) } } @@ -85,15 +87,11 @@ pub async fn admit( match gate.evaluate(candidate.peer_id, epoch_ctx).await { Err(GateError::EpochOrdinalUnavailable) => AdmissionDecision::ChainSourceUnavailable, - Ok(GateOutcome::Eligible { - payout_puzzle_hash, - }) => { + Ok(GateOutcome::Eligible { payout_puzzle_hash }) => { if own.controls(&payout_puzzle_hash) { AdmissionDecision::SelfExcluded } else { - AdmissionDecision::Admit { - payout_puzzle_hash, - } + AdmissionDecision::Admit { payout_puzzle_hash } } } Ok(GateOutcome::Ineligible(_)) => AdmissionDecision::GateIneligible, @@ -113,12 +111,18 @@ mod tests { #[async_trait] impl MirrorCoinGatePort for FakeGate { - async fn evaluate(&self, peer_id: [u8; 32], _ctx: EpochContext) -> Result { + async fn evaluate( + &self, + peer_id: [u8; 32], + _ctx: EpochContext, + ) -> Result { match self.eligible.get(&peer_id) { Some(ph) => Ok(GateOutcome::Eligible { payout_puzzle_hash: *ph, }), - None => Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration)), + None => Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentDeclaration, + )), } } } @@ -127,7 +131,11 @@ mod tests { #[async_trait] impl MirrorCoinGatePort for UnavailableFakeGate { - async fn evaluate(&self, _peer_id: [u8; 32], _ctx: EpochContext) -> Result { + async fn evaluate( + &self, + _peer_id: [u8; 32], + _ctx: EpochContext, + ) -> Result { Err(GateError::EpochOrdinalUnavailable) } } diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs index 15a212dc..9fbe014d 100644 --- a/crates/dig-node-core/src/rewards/challenge.rs +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -8,7 +8,9 @@ //! out. use super::port::Bytes32; -use super::spec_constants::{CHALLENGE_NO_REPEAT_CYCLES, CHALLENGE_STRIKES_TO_EVICT, CHALLENGE_WINDOW_BYTES}; +use super::spec_constants::{ + CHALLENGE_NO_REPEAT_CYCLES, CHALLENGE_STRIKES_TO_EVICT, CHALLENGE_WINDOW_BYTES, +}; use async_trait::async_trait; use std::collections::HashMap; @@ -52,17 +54,33 @@ impl NoRepeatMemory { Self::default() } - pub fn is_repeat(&self, peer_id: Bytes32, launcher_id: Bytes32, resource_id: Bytes32, offset: u64, cycle_index: u32) -> bool { + pub fn is_repeat( + &self, + peer_id: Bytes32, + launcher_id: Bytes32, + resource_id: Bytes32, + offset: u64, + cycle_index: u32, + ) -> bool { self.recent .get(&(peer_id, launcher_id)) .is_some_and(|windows| { windows.iter().any(|(cyc, rid, off)| { - *rid == resource_id && *off == offset && cycle_index.saturating_sub(*cyc) < CHALLENGE_NO_REPEAT_CYCLES + *rid == resource_id + && *off == offset + && cycle_index.saturating_sub(*cyc) < CHALLENGE_NO_REPEAT_CYCLES }) }) } - pub fn record(&mut self, peer_id: Bytes32, launcher_id: Bytes32, resource_id: Bytes32, offset: u64, cycle_index: u32) { + pub fn record( + &mut self, + peer_id: Bytes32, + launcher_id: Bytes32, + resource_id: Bytes32, + offset: u64, + cycle_index: u32, + ) { self.recent .entry((peer_id, launcher_id)) .or_default() @@ -158,14 +176,21 @@ pub async fn run_window( length: u64, expected_bytes: &[u8], ) -> bool { - match transport.fetch_window(peer_id, resource_id, offset, length).await { + match transport + .fetch_window(peer_id, resource_id, offset, length) + .await + { Ok(response) => response.bytes == expected_bytes, Err(_) => false, } } /// SPEC §3.5: a cycle passes only if ALL windows match — no partial credit. -pub async fn run_cycle(transport: &dyn ChallengeTransport, peer_id: Bytes32, windows: &[(Bytes32, u64, u64, Vec)]) -> bool { +pub async fn run_cycle( + transport: &dyn ChallengeTransport, + peer_id: Bytes32, + windows: &[(Bytes32, u64, u64, Vec)], +) -> bool { for (resource_id, offset, length, expected) in windows { if !run_window(transport, peer_id, *resource_id, *offset, *length, expected).await { return false; @@ -192,7 +217,12 @@ impl StrikeTracker { /// /// MUST NEVER be called for a cycle abandoned through the prover's own fault — see /// [`Self::record_prover_fault`], which exists precisely so that path cannot reach this one. - pub fn record_peer_outcome(&mut self, peer_id: Bytes32, launcher_id: Bytes32, passed: bool) -> bool { + pub fn record_peer_outcome( + &mut self, + peer_id: Bytes32, + launcher_id: Bytes32, + passed: bool, + ) -> bool { let key = (peer_id, launcher_id); if passed { self.consecutive_failures.insert(key, 0); diff --git a/crates/dig-node-core/src/rewards/cycle.rs b/crates/dig-node-core/src/rewards/cycle.rs index 9a4ec5d6..bd1976d7 100644 --- a/crates/dig-node-core/src/rewards/cycle.rs +++ b/crates/dig-node-core/src/rewards/cycle.rs @@ -13,8 +13,10 @@ //! stalled writer: it compares `observed_at` against the reader's OWN clock, never a flag the //! writer set — a wedged writer cannot make this reassuring because it cannot touch it. +use super::spec_constants::{ + PROVER_CYCLE_DEADLINE_SECONDS, PROVER_CYCLE_PERIOD_SECONDS, PROVER_HEARTBEAT_SECONDS, +}; use super::state::{Clock, ProverState, StatusHandle}; -use super::spec_constants::{PROVER_CYCLE_DEADLINE_SECONDS, PROVER_CYCLE_PERIOD_SECONDS, PROVER_HEARTBEAT_SECONDS}; use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -27,7 +29,11 @@ use tokio::time::timeout; /// care what the work does. /// /// Returns `true` if the cycle completed within the deadline, `false` if it was abandoned. -pub async fn run_cycle_with_deadline(status: &StatusHandle, clock: &dyn Clock, cycle_fn: F) -> bool +pub async fn run_cycle_with_deadline( + status: &StatusHandle, + clock: &dyn Clock, + cycle_fn: F, +) -> bool where F: FnOnce() -> Fut, Fut: Future, @@ -39,7 +45,12 @@ where s.observed_at = started_at; }); - match timeout(Duration::from_secs(PROVER_CYCLE_DEADLINE_SECONDS), cycle_fn()).await { + match timeout( + Duration::from_secs(PROVER_CYCLE_DEADLINE_SECONDS), + cycle_fn(), + ) + .await + { Ok(()) => { let completed_at = clock.now_unix_seconds(); status.update(|s| { @@ -75,7 +86,11 @@ pub fn heartbeat_tick(status: &StatusHandle, clock: &dyn Clock) { /// The heartbeat loop: calls [`heartbeat_tick`] every `PROVER_HEARTBEAT_SECONDS` until `stop` /// carries `true`. Runs independently of whether a cycle is in progress — SPEC §2.5 clause 1 is /// explicit that this MUST fire "including while `Idle`". -pub async fn heartbeat_loop(status: StatusHandle, clock: Arc, mut stop: watch::Receiver) { +pub async fn heartbeat_loop( + status: StatusHandle, + clock: Arc, + mut stop: watch::Receiver, +) { loop { tokio::select! { _ = tokio::time::sleep(Duration::from_secs(PROVER_HEARTBEAT_SECONDS)) => { @@ -115,9 +130,13 @@ mod tests { let clock = TestClock::new(1_000); let status = status_at(1_000); - let completed = run_cycle_with_deadline(&status, &clock, || std::future::pending::<()>()).await; + let completed = + run_cycle_with_deadline(&status, &clock, || std::future::pending::<()>()).await; - assert!(!completed, "a cycle that never resolves must be reported as abandoned"); + assert!( + !completed, + "a cycle that never resolves must be reported as abandoned" + ); let snap = status.snapshot(); assert_eq!(snap.consecutive_cycle_failures, 1); assert_eq!( @@ -137,7 +156,10 @@ mod tests { let snap = status.snapshot(); assert_eq!(snap.consecutive_cycle_failures, 0); assert_eq!(snap.last_cycle_completed_at, Some(1_000)); - assert_eq!(snap.next_cycle_due_at, Some(1_000 + PROVER_CYCLE_PERIOD_SECONDS)); + assert_eq!( + snap.next_cycle_due_at, + Some(1_000 + PROVER_CYCLE_PERIOD_SECONDS) + ); } /// SPEC §2.5 clause 1: a heartbeat fires even while the prover is sitting `Idle` between @@ -154,7 +176,11 @@ mod tests { let snap = status.snapshot(); assert_eq!(snap.observed_at, 1_000 + PROVER_HEARTBEAT_SECONDS); - assert_eq!(snap.prover_state, ProverState::Idle, "a heartbeat must not touch prover_state"); + assert_eq!( + snap.prover_state, + ProverState::Idle, + "a heartbeat must not touch prover_state" + ); } /// The wedged-loop reader-side property: `observed_at` stops advancing while a cycle is stuck diff --git a/crates/dig-node-core/src/rewards/gate.rs b/crates/dig-node-core/src/rewards/gate.rs index b04e50c9..f78f6121 100644 --- a/crates/dig-node-core/src/rewards/gate.rs +++ b/crates/dig-node-core/src/rewards/gate.rs @@ -108,7 +108,11 @@ pub enum GateError { /// The mirror-coin gate contract [`admission::admit`](super::admission::admit) drives. #[async_trait] pub trait MirrorCoinGatePort: Send + Sync { - async fn evaluate(&self, peer_id: [u8; 32], ctx: EpochContext) -> Result; + async fn evaluate( + &self, + peer_id: [u8; 32], + ctx: EpochContext, + ) -> Result; } /// The SPEC §4-driven gate: takes a candidate's coin-id hint and a `MirrorCoinReader`, and decides @@ -166,7 +170,11 @@ impl SpecMirrorCoinGate { #[async_trait] impl MirrorCoinGatePort for SpecMirrorCoinGate { - async fn evaluate(&self, peer_id: [u8; 32], ctx: EpochContext) -> Result { + async fn evaluate( + &self, + peer_id: [u8; 32], + ctx: EpochContext, + ) -> Result { // SPEC §4.6 clause 2 / D5: the ordinal is an INPUT; its absence is a PROVER fault // (ChainSourceUnavailable at the cycle layer), never guessed and never peer-attributable // ineligibility (dig_ecosystem#3259, #3250 D5). @@ -177,27 +185,37 @@ impl MirrorCoinGatePort for SpecMirrorCoinGate { // SPEC §4.6.1: a coin qualifies for the census of epoch `n` only by declaring `n-1` // EXACTLY. `n == 0` means no epoch has closed a census round yet — nothing can qualify. let Some(census_epoch) = current_epoch.checked_sub(1) else { - return Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)); + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise, + )); }; let Some(hint) = self.coin_hint_for.get(&peer_id).copied().flatten() else { - return Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint)); + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentCoinIdHint, + )); }; if !self .advertises_current_or_previous(hint, census_epoch, ctx.in_grace_window()) .await { - return Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)); + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise, + )); } if !self.reader.declares_peer(hint, peer_id).await { - return Ok(GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared)); + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::PeerNotDeclared, + )); } match self.reader.owner_puzzle_hash(hint).await { Some(payout_puzzle_hash) => Ok(GateOutcome::Eligible { payout_puzzle_hash }), - None => Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration)), + None => Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentDeclaration, + )), } } } @@ -223,7 +241,10 @@ mod tests { _root: [u8; 32], epoch: u64, ) -> bool { - self.advertising.get(&(coin_id, epoch)).copied().unwrap_or(false) + self.advertising + .get(&(coin_id, epoch)) + .copied() + .unwrap_or(false) } async fn declares_peer(&self, coin_id: [u8; 32], peer_id: [u8; 32]) -> bool { self.declaring.get(&coin_id) == Some(&peer_id) @@ -256,7 +277,9 @@ mod tests { let g = gate(FakeReader::default(), None); assert_eq!( g.evaluate(PEER, ctx(Some(2))).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentCoinIdHint)) + Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentCoinIdHint + )) ); } @@ -276,7 +299,9 @@ mod tests { let g = gate(FakeReader::default(), Some(COIN)); assert_eq!( g.evaluate(PEER, ctx(Some(0))).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)) + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )) ); } @@ -286,7 +311,9 @@ mod tests { let g = gate(reader, Some(COIN)); assert_eq!( g.evaluate(PEER, ctx(Some(2))).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)) + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )) ); } @@ -302,7 +329,9 @@ mod tests { let g = gate(reader, Some(COIN)); assert_eq!( g.evaluate(PEER, ctx(Some(5))).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)), + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )), "declaring n directly must NOT qualify the census of epoch n (SPEC §4.6.1)" ); } @@ -331,7 +360,9 @@ mod tests { let g = gate(reader, Some(COIN)); assert_eq!( g.evaluate(PEER, ctx(Some(2))).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::PeerNotDeclared)) + Ok(GateOutcome::Ineligible( + GateIneligibleReason::PeerNotDeclared + )) ); } @@ -345,7 +376,9 @@ mod tests { let g = gate(reader, Some(COIN)); assert_eq!( g.evaluate(PEER, ctx(Some(2))).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::AbsentDeclaration)) + Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentDeclaration + )) ); } @@ -402,7 +435,9 @@ mod tests { }; assert_eq!( g.evaluate(PEER, outside_grace).await, - Ok(GateOutcome::Ineligible(GateIneligibleReason::DoesNotAdvertise)) + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )) ); } } diff --git a/crates/dig-node-core/src/rewards/staleness.rs b/crates/dig-node-core/src/rewards/staleness.rs index fa1800e9..43bf7b49 100644 --- a/crates/dig-node-core/src/rewards/staleness.rs +++ b/crates/dig-node-core/src/rewards/staleness.rs @@ -17,7 +17,11 @@ use super::spec_constants::STALE_ENTRY_SET_SECONDS; /// `last_entry_write_at == None` means the entry set has never been written to. That is not /// "unknown" — it is maximally stale the moment the distributor itself has existed at least the /// bound: "never written" cannot be more current than "written a long time ago". -pub fn is_entry_set_stale(state: &DistributorChainState, now: u64, distributor_created_at: u64) -> bool { +pub fn is_entry_set_stale( + state: &DistributorChainState, + now: u64, + distributor_created_at: u64, +) -> bool { if state.reserve_base_units == 0 { return false; } @@ -51,7 +55,11 @@ mod tests { #[test] fn fresh_write_with_reserve_is_not_stale() { let s = state(100, Some(1_000)); - assert!(!is_entry_set_stale(&s, 1_000 + STALE_ENTRY_SET_SECONDS - 1, 0)); + assert!(!is_entry_set_stale( + &s, + 1_000 + STALE_ENTRY_SET_SECONDS - 1, + 0 + )); } #[test] @@ -66,7 +74,11 @@ mod tests { fn never_written_entry_set_with_reserve_and_old_enough_distributor_is_stale() { let s = state(100, None); let created_at = 500; - assert!(is_entry_set_stale(&s, created_at + STALE_ENTRY_SET_SECONDS, created_at)); + assert!(is_entry_set_stale( + &s, + created_at + STALE_ENTRY_SET_SECONDS, + created_at + )); } #[test] diff --git a/crates/dig-node-core/src/rewards/state.rs b/crates/dig-node-core/src/rewards/state.rs index c98ca836..bef61d87 100644 --- a/crates/dig-node-core/src/rewards/state.rs +++ b/crates/dig-node-core/src/rewards/state.rs @@ -131,7 +131,12 @@ impl Clock for TestClock { } } -fn new_status(launcher_id: [u8; 32], store_id: [u8; 32], root: [u8; 32], now: u64) -> RewardProverStatus { +fn new_status( + launcher_id: [u8; 32], + store_id: [u8; 32], + root: [u8; 32], + now: u64, +) -> RewardProverStatus { RewardProverStatus { launcher_id, store_id, @@ -171,7 +176,15 @@ mod tests { let status = idle_status([1; 32], [2; 32], [3; 32], 1000); let json = serde_json::to_value(&status).unwrap(); let obj = json.as_object().unwrap(); - for forbidden in ["healthy", "ok", "up", "running", "stale", "isStale", "staleness"] { + for forbidden in [ + "healthy", + "ok", + "up", + "running", + "stale", + "isStale", + "staleness", + ] { assert!( !obj.contains_key(forbidden), "status record must not carry a {forbidden:?} key (SPEC §2.4)" diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index fc1ccd32..f022526f 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -65,7 +65,10 @@ impl FeeBudget { #[derive(Debug, Clone, PartialEq, Eq)] pub enum WriteOutcome { /// A bundle ready to submit through `RewardsChainPort::submit_entry_writes`. - Bundle { bundle: EntryWriteBundle, still_pending: u32 }, + Bundle { + bundle: EntryWriteBundle, + still_pending: u32, + }, /// Nothing submitted, `count` decisions withheld this cycle (rate-limited or none ready) — /// they MUST still surface in `pending_entry_writes`, never silently dropped. Pending { count: u32 }, @@ -96,7 +99,12 @@ impl EntryWriteScheduler { } /// SPEC §6.3 clause 4 hysteresis check — keyed on the payout puzzle hash, never `peer_id`. - pub fn is_in_reentry_cooldown(&self, payout_puzzle_hash: Bytes32, launcher_id: Bytes32, now: u64) -> bool { + pub fn is_in_reentry_cooldown( + &self, + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, + now: u64, + ) -> bool { match self.cooldown_until.get(&(payout_puzzle_hash, launcher_id)) { Some(&until) => now < until, None => false, @@ -104,8 +112,10 @@ impl EntryWriteScheduler { } fn record_removal(&mut self, payout_puzzle_hash: Bytes32, launcher_id: Bytes32, now: u64) { - self.cooldown_until - .insert((payout_puzzle_hash, launcher_id), now + REENTRY_COOLDOWN_SECONDS); + self.cooldown_until.insert( + (payout_puzzle_hash, launcher_id), + now + REENTRY_COOLDOWN_SECONDS, + ); } /// Decide this cycle's write for one distributor from a queue of pending decisions (already @@ -201,7 +211,10 @@ mod tests { .collect(); let outcome = scheduler.decide(LAUNCHER, decisions, 100, &mut budget, 0); match outcome { - WriteOutcome::Bundle { bundle, still_pending } => { + WriteOutcome::Bundle { + bundle, + still_pending, + } => { assert_eq!(bundle.actions.len(), MAX_ENTRY_WRITES_PER_BUNDLE as usize); assert_eq!(still_pending, 3); } @@ -233,7 +246,8 @@ mod tests { let mut scheduler = EntryWriteScheduler::new(); let mut budget = FeeBudget::new(10, 0); // 240 mojos/day let fee = 1_000; // exceeds the whole day's budget on the first attempt - let outcome = scheduler.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], fee, &mut budget, 0); + let outcome = + scheduler.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], fee, &mut budget, 0); assert_eq!(outcome, WriteOutcome::FeeBudgetExhausted { count: 1 }); } @@ -244,18 +258,24 @@ mod tests { fn reentry_cooldown_survives_a_fresh_peer_id_for_the_same_payout_hash() { let mut scheduler = EntryWriteScheduler::new(); let mut budget = FeeBudget::new(1_000_000, 0); - let outcome = scheduler.decide(LAUNCHER, vec![remove(PAYOUT_A, LAUNCHER)], 100, &mut budget, 0); + let outcome = scheduler.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + &mut budget, + 0, + ); assert!(matches!(outcome, WriteOutcome::Bundle { .. })); // A "fresh peer_id" is not even a parameter to this cooldown check — it is keyed purely on // the payout puzzle hash, which is exactly what makes the bypass impossible: nothing about // peer identity can change which key is consulted. - assert!(scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, ENTRY_WRITE_MIN_INTERVAL_SECONDS)); assert!(scheduler.is_in_reentry_cooldown( PAYOUT_A, LAUNCHER, - REENTRY_COOLDOWN_SECONDS - 1 + ENTRY_WRITE_MIN_INTERVAL_SECONDS )); + assert!(scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, REENTRY_COOLDOWN_SECONDS - 1)); assert!(!scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, REENTRY_COOLDOWN_SECONDS)); } From 697aaa2f9eb11e548f09a903bd8bcb2c3318e05c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 14:37:10 -0700 Subject: [PATCH 05/11] fix(rewards): drop Copy from OwnIdentity and an unneeded mut in a strike test The two errors that kept dig-node-core from compiling. OwnIdentity derived Copy while carrying `controlled_puzzle_hashes: Vec<[u8; 32]>`, which Vec cannot satisfy (E0204). Copy dropped, Clone kept. The field stays a growable collection deliberately: SPEC 5.2 excludes a candidate on MEMBERSHIP in the set of puzzle hashes this node's wallet controls, not on equality to one distinguished value, because a wallet may hold more than one payout address. `prover_fault_never_increments_any_peer_strike` bound its tracker `mut`, which clippy rejects under -D warnings. It does not need mut, and that is the point worth noticing rather than papering over: `record_prover_fault` takes `&self` because a prover fault must not be able to touch a peer's strike counter at all (SPEC 3.6 clause 4). The borrow checker now carries that invariant, so the absent `mut` is evidence of the design rather than an oversight. Committed from the parent side: the lane had both edits correct in its working tree but uncommitted, and this ticket has already lost or nearly lost in-flight work four times to a dying process. Refs #3250 --- crates/dig-node-core/src/rewards/admission.rs | 2 +- crates/dig-node-core/src/rewards/challenge.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-core/src/rewards/admission.rs b/crates/dig-node-core/src/rewards/admission.rs index f55b3653..2d60348d 100644 --- a/crates/dig-node-core/src/rewards/admission.rs +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -30,7 +30,7 @@ pub struct Candidate { } /// This node's own identity, on both SPEC §5.2 coordinates. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct OwnIdentity { pub peer_id: [u8; 32], /// Every puzzle hash this node's own wallet controls. A `Vec` (not a single hash) because a diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs index 9fbe014d..f7ee2b9a 100644 --- a/crates/dig-node-core/src/rewards/challenge.rs +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -370,7 +370,7 @@ mod tests { /// peer. Simulates the fault path failing to strike every peer in a set of several. #[tokio::test] async fn prover_fault_never_increments_any_peer_strike() { - let mut strikes = StrikeTracker::new(); + let strikes = StrikeTracker::new(); let peers: [Bytes32; 3] = [[10; 32], [11; 32], [12; 32]]; for peer in peers { strikes.record_prover_fault(peer, LAUNCHER); From bdc12801db981b4ba84e9275d11e5098e4470729 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:23:20 -0700 Subject: [PATCH 06/11] fix(rewards): name the challenge tuples, drop a redundant closure, fix a paused-time race The last three CI failures. No production behaviour changes. - challenge.rs: `HashMap<(Bytes32, Bytes32), Vec<(u32, Bytes32, u64)>>` tripped clippy::type_complexity. Factored into `ChallengeSubject` (the `(peer_id, launcher_id)` a no-repeat rule is keyed on) and `IssuedWindow` (`(cycle_index, resource_id, offset)`). The lint was right: those tuples were unreadable at the use site. - cycle.rs: passed `|| std::future::pending::<()>()` where the function itself does (clippy::redundant_closure). - cycle.rs: `heartbeat_loop_fires_on_its_own_timer` failed against CORRECT production code. Under `start_paused`, `tokio::spawn` does not poll the task, so `tokio::time::advance` jumped over a timer the loop had not registered yet; the loop then slept from the far side of the jump and never ticked. Yielding once before the advance lets it register the timer first. The assertion in that test is deliberately UNCHANGED. It is the only evidence that the heartbeat fires on its own timer rather than merely when called directly, and an always-on loop is trivially easy to keep green while it never runs -- so the fix had to be the sequencing, never the bound. Committed from the parent side: the lane had all three edits correct but uncommitted, the sixth time in-flight work on this ticket needed rescuing. Refs #3250 --- crates/dig-node-core/src/rewards/challenge.rs | 7 ++++++- crates/dig-node-core/src/rewards/cycle.rs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs index f7ee2b9a..57eddfd8 100644 --- a/crates/dig-node-core/src/rewards/challenge.rs +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -42,11 +42,16 @@ fn csprng_u64_below(bound: u64) -> u64 { u64::from_le_bytes(buf) % bound } +/// The `(peer_id, launcher_id)` pair a no-repeat rule is keyed on (SPEC §3.2 clause 5). +type ChallengeSubject = (Bytes32, Bytes32); +/// One issued window: `(cycle_index, resource_id, offset)`. +type IssuedWindow = (u32, Bytes32, u64); + /// Remembers, per `(peer_id, launcher_id)`, the `(cycle_index, resource_id, offset)` windows /// issued in the last [`CHALLENGE_NO_REPEAT_CYCLES`] cycles (SPEC §3.2 clause 5). #[derive(Default)] pub struct NoRepeatMemory { - recent: HashMap<(Bytes32, Bytes32), Vec<(u32, Bytes32, u64)>>, + recent: HashMap>, } impl NoRepeatMemory { diff --git a/crates/dig-node-core/src/rewards/cycle.rs b/crates/dig-node-core/src/rewards/cycle.rs index bd1976d7..512a5684 100644 --- a/crates/dig-node-core/src/rewards/cycle.rs +++ b/crates/dig-node-core/src/rewards/cycle.rs @@ -131,7 +131,7 @@ mod tests { let status = status_at(1_000); let completed = - run_cycle_with_deadline(&status, &clock, || std::future::pending::<()>()).await; + run_cycle_with_deadline(&status, &clock, std::future::pending::<()>).await; assert!( !completed, @@ -222,6 +222,11 @@ mod tests { let loop_clock: Arc = clock.clone(); let handle = tokio::spawn(heartbeat_loop(loop_status, loop_clock, rx)); + // Let the loop reach its `sleep` and REGISTER its timer before virtual time moves. Without + // this, `advance` jumps over a timer that does not exist yet and the loop then sleeps from the + // far side of the jump — the test would fail while the loop is behaving correctly. + tokio::task::yield_now().await; + clock.advance(PROVER_HEARTBEAT_SECONDS); tokio::time::advance(Duration::from_secs(PROVER_HEARTBEAT_SECONDS)).await; tokio::task::yield_now().await; From efe57f1154fb0ab5a518d710ff88263c8aa989b6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:28:02 -0700 Subject: [PATCH 07/11] fix(rewards): address clippy lint violations and fix heartbeat test race - Introduce type aliases ChallengeSubject and IssuedWindow in challenge.rs to address clippy::type_complexity lint (line 49) - Remove redundant closure wrapper from pending future in cycle.rs test (line 134) to address clippy::redundant_closure - Fix heartbeat_loop_fires_on_its_own_timer test by adding yield_now() before virtual time advance to ensure timer is registered Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/rewards/cycle.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/dig-node-core/src/rewards/cycle.rs b/crates/dig-node-core/src/rewards/cycle.rs index 512a5684..98ce6329 100644 --- a/crates/dig-node-core/src/rewards/cycle.rs +++ b/crates/dig-node-core/src/rewards/cycle.rs @@ -130,8 +130,7 @@ mod tests { let clock = TestClock::new(1_000); let status = status_at(1_000); - let completed = - run_cycle_with_deadline(&status, &clock, std::future::pending::<()>).await; + let completed = run_cycle_with_deadline(&status, &clock, std::future::pending::<()>).await; assert!( !completed, From 72d5b015c1c83126683bb76764d636cff142a38c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 18:14:49 -0700 Subject: [PATCH 08/11] chore(rewards): checkpoint the four gate conditions in progress Parent-side checkpoint, UNVERIFIED -- this commit has not completed a build and may be mid-edit. Taken because in-flight work on this ticket has now needed rescuing seven times, and 486 uncommitted lines were one crash from gone. Work toward the four blocking conditions the triple gate attached to #593: 1. state.rs -- make the SPEC 2.4 no-health-key test RECURSIVE. It asserted over JSON object keys (correctly, not substrings) but only at the top level, so a smuggled isRunning inside `counters` or any future nested struct would pass. 2. writes.rs, port.rs -- persist the entry-write bounds. SPEC 12.1 clause 2 requires cooldowns and fee budgets to survive a restart; they lived only in memory, so a restart loop wrote one bundle per restart with no interval, no daily cap and an empty cooldown map -- unbounded XCH spend plus repeated reserve settlements via re-eviction, presenting as nothing being wrong. The shape is a WriteBoundStore seam with a NoPersistence impl that REFUSES to write rather than writing unbounded. 3. admission.rs -- make a self-exclusion bypass a COMPILE error. EntryAction::Add carried public fields unrelated to AdmissionDecision, so any future discovery path could mint an Add without calling admit. dig-node#261's lesson is the rule: an invariant enforced on some paths is not an invariant, it is a habit. 4. mod.rs -- state the worst-case spend where a human reads it: 24 bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard fee, up to 96 evictions/day, and eviction can flush the entry set's whole accrued balance in ~1.3 days including sub-threshold dust. Plus the non-blocking items: the csprng modulo bias documented, NoRepeatMemory pruned past its 8-cycle horizon, and the removal cooldown recorded after a successful submit rather than before (a failed submit had been holding an honest mirror out for six hours). Refs #3250 --- crates/dig-node-core/src/rewards/admission.rs | 62 +++- crates/dig-node-core/src/rewards/challenge.rs | 47 +++ crates/dig-node-core/src/rewards/mod.rs | 19 + crates/dig-node-core/src/rewards/port.rs | 10 +- crates/dig-node-core/src/rewards/state.rs | 67 +++- crates/dig-node-core/src/rewards/writes.rs | 325 +++++++++++++++++- 6 files changed, 486 insertions(+), 44 deletions(-) diff --git a/crates/dig-node-core/src/rewards/admission.rs b/crates/dig-node-core/src/rewards/admission.rs index 2d60348d..0e65d04d 100644 --- a/crates/dig-node-core/src/rewards/admission.rs +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -10,6 +10,7 @@ //! path produced the candidate — MUST call through here rather than re-implement the comparison. use super::gate::{EpochContext, GateError, GateOutcome, MirrorCoinGatePort}; +use super::port::Bytes32; /// Which discovery path produced a candidate. Exists ONLY for logging/tests (SPEC §5.3 clause 4's /// control needs to name the path a candidate arrived by) — it MUST NOT change the admission @@ -47,11 +48,44 @@ impl OwnIdentity { } } +/// Proof that a candidate passed THE single admission point (SPEC §5.3). Fields are private and no +/// public constructor exists, so an `EntryAction::Add` cannot be built without one — a path that +/// skips `admit` fails to compile rather than silently writing an entry for this node itself. This +/// type's whole reason to exist is that privacy: a `pub` field or a `pub fn new` here reopens +/// exactly the per-path habit DIG-Network/dig-node#261 already cost a lane for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdmittedPeer { + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, +} + +impl AdmittedPeer { + /// The payout puzzle hash the entry would use (SPEC §10.2). + pub fn payout_puzzle_hash(&self) -> Bytes32 { + self.payout_puzzle_hash + } + + /// Which distributor this admission was decided for. + pub fn launcher_id(&self) -> Bytes32 { + self.launcher_id + } + + /// Test-only escape hatch, `cfg(test)`-gated so it never ships: production code has no way to + /// mint an `AdmittedPeer` except through [`admit`]. + #[cfg(test)] + pub fn for_test(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> Self { + Self { + payout_puzzle_hash, + launcher_id, + } + } +} + /// What [`admit`] decided. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AdmissionDecision { - /// Eligible on the chain gate AND not self — carries the payout puzzle hash the entry would use. - Admit { payout_puzzle_hash: [u8; 32] }, + /// Eligible on the chain gate AND not self. + Admit(AdmittedPeer), /// Refused because the candidate is this node itself, on the peer_id coordinate, the puzzle_hash /// coordinate, or both (SPEC §5.2). Refused at admission, never a display filter (§5.3.3) — the /// caller MUST NOT write an entry for this candidate under any circumstance. @@ -80,6 +114,7 @@ pub async fn admit( own: &OwnIdentity, gate: &dyn MirrorCoinGatePort, epoch_ctx: EpochContext, + launcher_id: Bytes32, ) -> AdmissionDecision { if candidate.peer_id == own.peer_id { return AdmissionDecision::SelfExcluded; @@ -91,7 +126,10 @@ pub async fn admit( if own.controls(&payout_puzzle_hash) { AdmissionDecision::SelfExcluded } else { - AdmissionDecision::Admit { payout_puzzle_hash } + AdmissionDecision::Admit(AdmittedPeer { + payout_puzzle_hash, + launcher_id, + }) } } Ok(GateOutcome::Ineligible(_)) => AdmissionDecision::GateIneligible, @@ -101,6 +139,10 @@ pub async fn admit( #[cfg(test)] mod tests { use super::*; + + /// Which distributor `admit` is deciding for in these tests — arbitrary, self-exclusion does + /// not depend on it. + const LAUNCHER: Bytes32 = [9; 32]; use crate::rewards::gate::{GateIneligibleReason, MirrorCoinGatePort}; use async_trait::async_trait; @@ -172,7 +214,7 @@ mod tests { peer_id: own.peer_id, path, }; - let decision = admit(&candidate, &own, &gate, ctx()).await; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; assert_eq!(decision, AdmissionDecision::SelfExcluded, "path {path:?}"); } } @@ -192,7 +234,7 @@ mod tests { peer_id: foreign_peer, path: DiscoveryPath::DhtWalk, }; - let decision = admit(&candidate, &own, &gate, ctx()).await; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; assert_eq!(decision, AdmissionDecision::SelfExcluded); } @@ -216,12 +258,10 @@ mod tests { peer_id: honest_peer, path, }; - let decision = admit(&candidate, &own, &gate, ctx()).await; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; assert_eq!( decision, - AdmissionDecision::Admit { - payout_puzzle_hash: honest_payout - }, + AdmissionDecision::Admit(AdmittedPeer::for_test(honest_payout, LAUNCHER)), "path {path:?}" ); } @@ -237,7 +277,7 @@ mod tests { peer_id: [0x33; 32], path: DiscoveryPath::DhtWalk, }; - let decision = admit(&candidate, &own, &gate, ctx()).await; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; assert_eq!(decision, AdmissionDecision::GateIneligible); } @@ -251,7 +291,7 @@ mod tests { peer_id: [0x44; 32], path: DiscoveryPath::DhtWalk, }; - let decision = admit(&candidate, &own, &UnavailableFakeGate, ctx()).await; + let decision = admit(&candidate, &own, &UnavailableFakeGate, ctx(), LAUNCHER).await; assert_eq!(decision, AdmissionDecision::ChainSourceUnavailable); } } diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs index 57eddfd8..396b75e9 100644 --- a/crates/dig-node-core/src/rewards/challenge.rs +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -33,6 +33,14 @@ pub struct WindowPlan { /// A CSPRNG-drawn `u64` in `[0, bound)`. SPEC §3.2 clause 4: MUST NOT be derived from a counter, a /// timestamp, a peer id, a store id, a root, a cycle index, or any hash of those — `getrandom` /// draws from the OS CSPRNG and touches none of those inputs. +/// +/// `% bound` is modulo-biased: outcomes below `u64::MAX % bound` are drawn very slightly more often +/// than the rest, by a factor bounded by `bound / 2^64`. At `bound` sizes realistic here (a +/// resource's byte length, at most a handful of GiB) that bias is on the order of 2^-20 or smaller +/// — judged inert by both the security and decider gates on this ticket. If this ever needs to +/// tighten (e.g. `bound` grows close to `2^64`), switch to rejection sampling (redraw when +/// `raw >= bound * (u64::MAX / bound)`); it is a two-line change and this comment marks exactly +/// where. fn csprng_u64_below(bound: u64) -> u64 { if bound == 0 { return 0; @@ -78,6 +86,14 @@ impl NoRepeatMemory { }) } + /// Record a just-issued window, and prune everything older than + /// [`CHALLENGE_NO_REPEAT_CYCLES`] at the same time — both the per-subject window list AND any + /// `(peer_id, launcher_id)` key left with no window inside the horizon. `peer_id` is + /// peer-supplied, so without BOTH prunes this map is a memory-growth primitive: a peer that + /// keeps presenting fresh identities (a new key per cycle) would grow the outer map forever, + /// and even a stable peer's window list would grow forever without the inner prune. Neither + /// prune loses information [`Self::is_repeat`] could still use — the horizon it checks against + /// is exactly `CHALLENGE_NO_REPEAT_CYCLES`. pub fn record( &mut self, peer_id: Bytes32, @@ -86,6 +102,12 @@ impl NoRepeatMemory { offset: u64, cycle_index: u32, ) { + self.recent.retain(|_, windows| { + windows.retain(|(cyc, _, _)| { + cycle_index.saturating_sub(*cyc) < CHALLENGE_NO_REPEAT_CYCLES + }); + !windows.is_empty() + }); self.recent .entry((peer_id, launcher_id)) .or_default() @@ -353,6 +375,31 @@ mod tests { assert!(!memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, CHALLENGE_NO_REPEAT_CYCLES)); } + /// `peer_id` is peer-supplied; without pruning, a peer presenting a fresh identity every cycle + /// (or one honest peer over many cycles) would grow `NoRepeatMemory` without bound. This drives + /// far more distinct peer ids and cycles than the horizon and asserts the map never exceeds a + /// small, horizon-bounded size. + #[test] + fn no_repeat_memory_does_not_grow_without_bound() { + let mut memory = NoRepeatMemory::new(); + for cycle in 0..2_000u32 { + let peer = { + let mut id = [0u8; 32]; + id[0..4].copy_from_slice(&cycle.to_le_bytes()); + id + }; + memory.record(peer, LAUNCHER, RESOURCE, cycle as u64, cycle); + // Only subjects whose most recent window is still inside the no-repeat horizon may + // remain — a distinct peer id every cycle means at most CHALLENGE_NO_REPEAT_CYCLES of + // them are ever live at once. + assert!( + memory.recent.len() <= CHALLENGE_NO_REPEAT_CYCLES as usize, + "NoRepeatMemory grew to {} entries at cycle {cycle}, unbounded", + memory.recent.len() + ); + } + } + #[test] fn three_consecutive_peer_failures_schedule_a_removal() { let mut strikes = StrikeTracker::new(); diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs index bb998685..31fe024c 100644 --- a/crates/dig-node-core/src/rewards/mod.rs +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -15,6 +15,25 @@ //! compares against the SPEC's numeric bounds lives in [`spec_constants`], tagged with its //! clause, so #3249 landing its own constants is a single, deliberate migration rather than a //! scattered one. +//! +//! # The worst-case spend, stated where a human reads it +//! +//! [`spec_constants::MAX_ENTRY_WRITES_PER_BUNDLE`] = 8 actions per bundle, at most one bundle per +//! [`spec_constants::ENTRY_WRITE_MIN_INTERVAL_SECONDS`] = 3,600 s → **24 bundles/day, 192 entry +//! actions/day**, per distributor this node funds. +//! +//! **Fee ceiling**: 24 × the operator's configured standard fee, per day, per distributor — +//! nominally ~0.00012 XCH/day at a typical ~0.000005 XCH fee, but **~0.24 XCH/day (≈88 XCH/year)** +//! at a congested 0.01 XCH fee. This bound is NOT independent of the rate bound above: 24 +//! bundles/day is simultaneously the rate limit and the fee ceiling, so [`writes::FeeBudget`] does +//! not add a second, separate protection on top of the rate bound — stated plainly here so nobody +//! reads this engine as having two independent spend controls when it has one. +//! +//! **Eviction**: up to 96 `Remove` actions/day (half of 192, if every bundle is all removals). SPEC +//! §6.4: `RemoveEntry` settles the entry's full accrued balance, ignoring `payout_threshold` — so +//! sustained eviction can flush an entire 250-entry set's accrued balance, including sub-threshold +//! dust that could never otherwise have been claimed, in **~1.3 days** (250 entries / 192 +//! actions-per-day capacity for removals alone). pub mod admission; pub mod challenge; diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 1534bedb..d9844fe9 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -8,6 +8,7 @@ //! adapter — until #3249 ships — reports [`ChainPortError::Unavailable`] and runs no cycles. See //! [`unavailable`] for that adapter. +use super::admission::AdmittedPeer; use async_trait::async_trait; /// A 32-byte chain identifier (launcher id, store id, root, puzzle hash — all the same shape). @@ -49,10 +50,11 @@ pub struct DistributorChainState { /// One add/remove decision destined for a bundle (SPEC §6.3). #[derive(Debug, Clone, PartialEq, Eq)] pub enum EntryAction { - Add { - payout_puzzle_hash: Bytes32, - launcher_id: Bytes32, - }, + /// Carries [`AdmittedPeer`] rather than loose fields: `AdmittedPeer` is mintable only by + /// `admission::admit`, so an `Add` cannot be constructed from a discovery path that skipped + /// admission — self-exclusion becomes a compile-time property of this type, not a convention + /// every future discovery path must remember to honour (SPEC §5.3; DIG-Network/dig-node#261). + Add(AdmittedPeer), Remove { payout_puzzle_hash: Bytes32, launcher_id: Bytes32, diff --git a/crates/dig-node-core/src/rewards/state.rs b/crates/dig-node-core/src/rewards/state.rs index bef61d87..60204bc2 100644 --- a/crates/dig-node-core/src/rewards/state.rs +++ b/crates/dig-node-core/src/rewards/state.rs @@ -169,27 +169,60 @@ pub fn idle_status( mod tests { use super::*; - /// SPEC §2.4: no `healthy`/`ok`/`up`/`running` key, and no precomputed staleness field, in the - /// serialized record. + /// The closed set of keys SPEC §2.4 forbids anywhere in the record. This asserts over object + /// *keys*, never over substrings of the serialized string: `ProverState::Running` legitimately + /// serializes the *value* `"running"`, so a substring test would fail on honest input while + /// still passing a smuggled `isRunning` **key**. Keep it key-based; a "simplification" back to + /// a substring check both breaks honest serialization and stops catching the real defect. + const FORBIDDEN_HEALTH_KEYS: &[&str] = &[ + "healthy", + "ok", + "up", + "running", + "isRunning", + "stale", + "isStale", + "staleness", + "secondsSinceLastRun", + "lastRunSecondsAgo", + "uptime", + "alive", + "live", + ]; + + /// Walk a `serde_json::Value` depth-first, asserting no object at ANY depth carries a forbidden + /// key. A top-level-only check would miss a forbidden key smuggled into a nested struct (e.g. a + /// future field added inside `counters`) — this recurses through objects and arrays so a + /// smuggled key at any depth still fails the test. + fn assert_no_forbidden_health_keys(value: &serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for forbidden in FORBIDDEN_HEALTH_KEYS { + assert!( + !map.contains_key(*forbidden), + "status record must not carry a {forbidden:?} key at any depth (SPEC §2.4)" + ); + } + for nested in map.values() { + assert_no_forbidden_health_keys(nested); + } + } + serde_json::Value::Array(items) => { + for item in items { + assert_no_forbidden_health_keys(item); + } + } + _ => {} + } + } + + /// SPEC §2.4: no `healthy`/`ok`/`up`/`running`/... key, and no precomputed staleness field, + /// anywhere in the serialized record — recursively, not just at the top level. #[test] fn serialized_status_has_no_health_or_staleness_key() { let status = idle_status([1; 32], [2; 32], [3; 32], 1000); let json = serde_json::to_value(&status).unwrap(); - let obj = json.as_object().unwrap(); - for forbidden in [ - "healthy", - "ok", - "up", - "running", - "stale", - "isStale", - "staleness", - ] { - assert!( - !obj.contains_key(forbidden), - "status record must not carry a {forbidden:?} key (SPEC §2.4)" - ); - } + assert_no_forbidden_health_keys(&json); } #[test] diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index f022526f..c8c33257 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -149,15 +149,6 @@ impl EntryWriteScheduler { }; } - for action in bundle_actions { - if let EntryAction::Remove { - payout_puzzle_hash, - launcher_id: lid, - } = action - { - self.record_removal(*payout_puzzle_hash, *lid, now); - } - } self.last_bundle_sent_at.insert(launcher_id, now); WriteOutcome::Bundle { @@ -169,6 +160,196 @@ impl EntryWriteScheduler { still_pending: rest.len() as u32, } } + + /// Record a bundle's removals as reentry-cooldown-blocked. Call this ONLY after + /// `RewardsChainPort::submit_entry_writes` has returned `Ok` for this exact bundle — recording + /// the cooldown before the chain confirms would hold an honest mirror out for the full + /// [`REENTRY_COOLDOWN_SECONDS`] window on a submit that never actually reached the chain (e.g. + /// a network error, a rejected spend). [`Self::decide`] deliberately does NOT do this itself. + pub fn record_submitted(&mut self, bundle: &EntryWriteBundle, now: u64) { + for action in &bundle.actions { + if let EntryAction::Remove { + payout_puzzle_hash, + launcher_id, + } = action + { + self.record_removal(*payout_puzzle_hash, *launcher_id, now); + } + } + } +} + +/// SPEC §12.1 clause 2: cooldowns and fee budgets MUST persist across a restart. Without this, a +/// restart loop resets `last_bundle_sent_at` to empty, `spent_mojos_today` to zero and +/// `cooldown_until` to empty — an unbounded per-restart spend of the operator's XCH and repeated +/// reserve settlements via re-eviction, invisible because it looks like ordinary bounded operation +/// each time. One `WriteBoundState` covers a single `launcher_id` (the caller keys storage by +/// distributor); `spent_mojos_today` carries the day it refers to so a loaded state past midnight +/// UTC-relative-to-`day_started_at` rolls over exactly like the in-memory [`FeeBudget`] does. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WriteBoundState { + pub last_bundle_sent_at: Option, + pub spent_mojos_today: u64, + pub day_started_at: u64, + pub cooldown_until: HashMap, +} + +/// Why a [`WriteBoundStore`] call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoreError(pub String); + +/// The persistence seam SPEC §12.1 clause 2 requires. Narrow on purpose — one `launcher_id` at a +/// time, load-then-save — so a real backend (a file, a small embedded DB) is a thin adapter, not a +/// redesign. +pub trait WriteBoundStore: Send + Sync { + fn load(&self, launcher_id: Bytes32) -> Result; + fn save(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError>; +} + +/// The fail-closed default until a real backend is wired: every call errors, so +/// [`PersistedEntryWriter::decide`] refuses to submit anything rather than run the write bounds +/// unbounded across a restart. This is deliberately the production default TODAY — the chain port +/// itself is `UnavailableChainPort` until #3249 lands, so this adapter costs nothing operationally +/// yet and closes the money hole the moment either seam is wired. +pub struct NoPersistence; + +impl WriteBoundStore for NoPersistence { + fn load(&self, _launcher_id: Bytes32) -> Result { + Err(StoreError("no write-bound persistence backend configured".to_string())) + } + + fn save(&self, _launcher_id: Bytes32, _state: &WriteBoundState) -> Result<(), StoreError> { + Err(StoreError("no write-bound persistence backend configured".to_string())) + } +} + +/// What [`PersistedEntryWriter::decide`] produced, in place of [`WriteOutcome`] once persistence is +/// in the loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PersistedWriteOutcome { + Bundle { + bundle: EntryWriteBundle, + still_pending: u32, + }, + Pending { + count: u32, + }, + FeeBudgetExhausted { + count: u32, + }, + /// The write-bound store could not be loaded for this distributor. No bundle is computed or + /// returned — the caller MUST NOT submit anything this cycle and MUST report this + /// distributor's `ProverState` as `ChainSourceUnavailable`. + /// + /// `FeeBudgetExhausted` was considered and rejected: that state means "a real budget exists + /// and is spent," which asserts something this code does not know when the store itself is + /// unreachable. `ChainSourceUnavailable` already means "a dependency this decision needs is + /// not reachable, and this is a prover-side fault, never a peer-attributable one" (see + /// `admission.rs`'s D5 use of the same state for the analogous gate-unavailable case) — which + /// is exactly what an unreachable persistence backend is. Inventing a tenth `ProverState` + /// would need a SPEC amendment (§2.3 pins the set to nine); this does not. + PersistenceUnavailable, +} + +/// Wraps [`EntryWriteScheduler`]'s decision with the SPEC §12.1 clause 2 persistence gate: bounds +/// are loaded before deciding and persisted only after a caller-confirmed successful submit +/// ([`Self::commit`]) — never inside `decide` itself, for the same before/after-success reason +/// [`EntryWriteScheduler::record_submitted`] documents. +pub struct PersistedEntryWriter<'a> { + store: &'a dyn WriteBoundStore, +} + +impl<'a> PersistedEntryWriter<'a> { + pub fn new(store: &'a dyn WriteBoundStore) -> Self { + Self { store } + } + + /// Load this distributor's persisted bounds, then decide this cycle's write. Returns the + /// updated (not-yet-persisted) state alongside every non-refusal outcome; the caller MUST + /// call [`Self::commit`] with that state after the chain confirms a `Bundle` outcome's submit + /// succeeded. Nothing here submits to the chain. + pub fn decide( + &self, + launcher_id: Bytes32, + decisions: Vec, + fee_mojos: u64, + standard_fee_mojos: u64, + now: u64, + ) -> (PersistedWriteOutcome, Option) { + let mut state = match self.store.load(launcher_id) { + Ok(state) => state, + Err(_) => return (PersistedWriteOutcome::PersistenceUnavailable, None), + }; + + if now.saturating_sub(state.day_started_at) >= 86_400 { + state.spent_mojos_today = 0; + state.day_started_at = now; + } + + if decisions.is_empty() { + return (PersistedWriteOutcome::Pending { count: 0 }, Some(state)); + } + + let rate_limited = state + .last_bundle_sent_at + .is_some_and(|last| now.saturating_sub(last) < ENTRY_WRITE_MIN_INTERVAL_SECONDS); + if rate_limited { + return ( + PersistedWriteOutcome::Pending { + count: decisions.len() as u32, + }, + Some(state), + ); + } + + let daily_limit_mojos = standard_fee_mojos.saturating_mul(24); + if state.spent_mojos_today.saturating_add(fee_mojos) > daily_limit_mojos { + return ( + PersistedWriteOutcome::FeeBudgetExhausted { + count: decisions.len() as u32, + }, + Some(state), + ); + } + + let take = decisions.len().min(MAX_ENTRY_WRITES_PER_BUNDLE as usize); + let (bundle_actions, rest) = decisions.split_at(take); + + state.last_bundle_sent_at = Some(now); + state.spent_mojos_today += fee_mojos; + for action in bundle_actions { + if let EntryAction::Remove { + payout_puzzle_hash, + launcher_id: lid, + } = action + { + state + .cooldown_until + .insert((*payout_puzzle_hash, *lid), now + REENTRY_COOLDOWN_SECONDS); + } + } + + ( + PersistedWriteOutcome::Bundle { + bundle: EntryWriteBundle { + launcher_id, + actions: bundle_actions.to_vec(), + fee_mojos, + }, + still_pending: rest.len() as u32, + }, + Some(state), + ) + } + + /// Persist the state [`Self::decide`] returned, once the caller has confirmed the chain + /// accepted the bundle. On `Err`, the caller MUST treat the NEXT cycle as + /// `PersistenceUnavailable` too — a save failure means the bounds this submit just advanced + /// are not durable, so trusting them in memory afterward would reopen the exact hole this + /// seam exists to close. + pub fn commit(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError> { + self.store.save(launcher_id, state) + } } /// SPEC §6.5: the entry set is capped at [`MAX_ENTRIES_PER_DISTRIBUTOR`] entries. @@ -189,10 +370,10 @@ mod tests { const PAYOUT_A: Bytes32 = [2; 32]; fn add(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> EntryAction { - EntryAction::Add { + EntryAction::Add(super::super::admission::AdmittedPeer::for_test( payout_puzzle_hash, launcher_id, - } + )) } fn remove(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> EntryAction { @@ -265,7 +446,12 @@ mod tests { &mut budget, 0, ); - assert!(matches!(outcome, WriteOutcome::Bundle { .. })); + let bundle = match outcome { + WriteOutcome::Bundle { bundle, .. } => bundle, + other => panic!("expected Bundle, got {other:?}"), + }; + // Cooldown is recorded only once the chain confirms the submit — never inside `decide`. + scheduler.record_submitted(&bundle, 0); // A "fresh peer_id" is not even a parameter to this cooldown check — it is keyed purely on // the payout puzzle hash, which is exactly what makes the bypass impossible: nothing about @@ -279,6 +465,23 @@ mod tests { assert!(!scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, REENTRY_COOLDOWN_SECONDS)); } + /// Regression for the "cooldown recorded before the submit is confirmed" defect: `decide` + /// alone MUST NOT hold the payout hash in cooldown — only `record_submitted` may. + #[test] + fn decide_alone_does_not_record_a_cooldown() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let outcome = scheduler.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + &mut budget, + 0, + ); + assert!(matches!(outcome, WriteOutcome::Bundle { .. })); + assert!(!scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, 0)); + } + #[test] fn entry_set_full_and_unfunded_report_the_right_terminal_state() { assert!(is_entry_set_full(MAX_ENTRIES_PER_DISTRIBUTOR as usize)); @@ -286,4 +489,102 @@ mod tests { assert!(is_unfunded(0)); assert!(!is_unfunded(1)); } + + /// A trivial in-process store, standing in for a real backend (a file, an embedded DB) — the + /// point under test is `PersistedEntryWriter`'s contract, not any particular backend. + #[derive(Default)] + struct FakeStore { + states: std::sync::Mutex>, + } + + impl WriteBoundStore for FakeStore { + fn load(&self, launcher_id: Bytes32) -> Result { + Ok(self + .states + .lock() + .unwrap() + .get(&launcher_id) + .cloned() + .unwrap_or_default()) + } + + fn save(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError> { + self.states + .lock() + .unwrap() + .insert(launcher_id, state.clone()); + Ok(()) + } + } + + /// SPEC §12.1 clause 2, fail-closed side: with no persistence backend wired, the writer MUST + /// submit zero bundles and report `ChainSourceUnavailable` — not run the write bounds + /// unbounded because nothing durable exists to bound them against. + #[test] + fn no_persistence_writer_submits_zero_bundles() { + let writer = PersistedEntryWriter::new(&NoPersistence); + let (outcome, state) = writer.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], 100, 1_000_000, 0); + assert_eq!(outcome, PersistedWriteOutcome::PersistenceUnavailable); + assert!(state.is_none(), "no bundle-tracking state may be produced without a store"); + } + + /// THE money-bug regression: without persistence, restarting the process resets every bound to + /// its zero value, so a restart loop would write one bundle per restart with no interval, no + /// daily cap and no cooldown. This fails without `PersistedEntryWriter` reloading state from + /// the store on every `decide` call. + #[test] + fn restart_still_enforces_rate_daily_cap_and_cooldown_across_the_store() { + let store = FakeStore::default(); + + // Cycle 1 ("before restart"): first bundle for the day goes through and is persisted. + let writer = PersistedEntryWriter::new(&store); + let (outcome, state) = writer.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + 1_000_000, + 0, + ); + let bundle = match outcome { + PersistedWriteOutcome::Bundle { bundle, .. } => bundle, + other => panic!("expected Bundle, got {other:?}"), + }; + writer + .commit(LAUNCHER, &state.expect("decide returns state on success")) + .unwrap(); + + // "Restart": a brand-new `PersistedEntryWriter` (fresh in-memory scheduler state), backed + // by the SAME store — this is the whole point of the seam. + let writer_after_restart = PersistedEntryWriter::new(&store); + + // Rate bound survives the restart: a second attempt one second later is still withheld. + let (rate_outcome, _) = writer_after_restart.decide( + LAUNCHER, + vec![add([9; 32], LAUNCHER)], + 100, + 1_000_000, + 1, + ); + assert_eq!(rate_outcome, PersistedWriteOutcome::Pending { count: 1 }); + + // Reentry cooldown survives the restart too: the just-removed payout hash is still held, + // even though the scheduler that decided the removal no longer exists in memory. + let post_restart_state = store.load(LAUNCHER).unwrap(); + assert!(post_restart_state + .cooldown_until + .contains_key(&(PAYOUT_A, LAUNCHER))); + assert_eq!(bundle.actions.len(), 1); + + // Daily cap survives the restart: jump past the rate window but stay inside the same day, + // with a fee that would exceed the remaining daily budget already spent pre-restart. + let writer_later = PersistedEntryWriter::new(&store); + let (cap_outcome, _) = writer_later.decide( + LAUNCHER, + vec![add([7; 32], LAUNCHER)], + 1_000_000, // exceeds the day's whole 1_000_000-mojo budget on top of the 100 already spent + 1_000_000, + ENTRY_WRITE_MIN_INTERVAL_SECONDS + 2, + ); + assert_eq!(cap_outcome, PersistedWriteOutcome::FeeBudgetExhausted { count: 1 }); + } } From 556c01a5101edc0e2a40cc8c7bbcd030505a9d20 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:14:19 -0700 Subject: [PATCH 09/11] style(rewards): cargo fmt the write-bound persistence code Exactly the five sites Rustfmt flagged in writes.rs (215, 222, 523, 558, 585). Salvaged from an uncommitted working tree after the third session cap on this ticket; formatting only, no logic touched. Refs #3250 --- crates/dig-node-core/src/rewards/writes.rs | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index c8c33257..4e936575 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -215,11 +215,15 @@ pub struct NoPersistence; impl WriteBoundStore for NoPersistence { fn load(&self, _launcher_id: Bytes32) -> Result { - Err(StoreError("no write-bound persistence backend configured".to_string())) + Err(StoreError( + "no write-bound persistence backend configured".to_string(), + )) } fn save(&self, _launcher_id: Bytes32, _state: &WriteBoundState) -> Result<(), StoreError> { - Err(StoreError("no write-bound persistence backend configured".to_string())) + Err(StoreError( + "no write-bound persistence backend configured".to_string(), + )) } } @@ -523,9 +527,13 @@ mod tests { #[test] fn no_persistence_writer_submits_zero_bundles() { let writer = PersistedEntryWriter::new(&NoPersistence); - let (outcome, state) = writer.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], 100, 1_000_000, 0); + let (outcome, state) = + writer.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], 100, 1_000_000, 0); assert_eq!(outcome, PersistedWriteOutcome::PersistenceUnavailable); - assert!(state.is_none(), "no bundle-tracking state may be produced without a store"); + assert!( + state.is_none(), + "no bundle-tracking state may be produced without a store" + ); } /// THE money-bug regression: without persistence, restarting the process resets every bound to @@ -558,13 +566,8 @@ mod tests { let writer_after_restart = PersistedEntryWriter::new(&store); // Rate bound survives the restart: a second attempt one second later is still withheld. - let (rate_outcome, _) = writer_after_restart.decide( - LAUNCHER, - vec![add([9; 32], LAUNCHER)], - 100, - 1_000_000, - 1, - ); + let (rate_outcome, _) = + writer_after_restart.decide(LAUNCHER, vec![add([9; 32], LAUNCHER)], 100, 1_000_000, 1); assert_eq!(rate_outcome, PersistedWriteOutcome::Pending { count: 1 }); // Reentry cooldown survives the restart too: the just-removed payout hash is still held, @@ -585,6 +588,9 @@ mod tests { 1_000_000, ENTRY_WRITE_MIN_INTERVAL_SECONDS + 2, ); - assert_eq!(cap_outcome, PersistedWriteOutcome::FeeBudgetExhausted { count: 1 }); + assert_eq!( + cap_outcome, + PersistedWriteOutcome::FeeBudgetExhausted { count: 1 } + ); } } From 9e8e1c51d1697f324804e9e5fa3638ffe9999d9f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:30:44 -0700 Subject: [PATCH 10/11] fix(rewards): consume the derived daily fee ceiling, never re-derive it from a fee PersistedEntryWriter::decide took a per-bundle standard fee and formed the daily cap in place with saturating_mul(24). A caller passing the day's whole ceiling therefore got a bound 24x looser than the operator configured, and the restart regression test's post-restart bundle (100 mojos already spent, plus a 1,000,000 fee, against a 1,000,000/day ceiling) was admitted instead of refused. The cap predicate and the persistence seam were both already correct: spent_mojos_today does round-trip the store, which is why the rate and cooldown assertions in that test passed. The defect was units. decide now takes daily_limit_mojos, and FeeBudget::daily_limit_for is the single place that product is formed, so the in-memory and persisted write paths cannot bound the same spend differently. Mistaking a ceiling for a fee there now fails CLOSED -- the prover refuses to write, which is what SPEC 6.3 clause 3 asks of it -- rather than open. The two 86_400 literals and the 24 become named constants derived from ENTRY_WRITE_MIN_INTERVAL_SECONDS so the ceiling cannot drift from the rate bound it comes from. Adds decide_then_commit_persists_every_write_bound_field: all four WriteBoundState fields must round-trip with distinct non-zero values. That is the general form of this bug class -- a seam that carries most fields and silently drops the one that bounds spending. The failing test's assertions, numbers and step order are unchanged (byte-identical). Refs DIG-Network/dig-node#593 --- crates/dig-node-core/src/rewards/writes.rs | 83 ++++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index 4e936575..301dd4f4 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -23,6 +23,15 @@ use super::spec_constants::{ }; use std::collections::HashMap; +/// One day in seconds — the window every fee-budget rollover in this module is measured against. +const SECONDS_PER_DAY: u64 = 86_400; + +/// The most bundles the §6.3 clause 2 rate bound permits in a day (one per 3,600 s → 24), derived +/// rather than written as a literal so it cannot drift from the interval it comes from. The daily +/// fee ceiling is this many standard fees, which is why `mod.rs` states the rate bound and the fee +/// ceiling are ONE spend control and not two independent ones. +const MAX_BUNDLES_PER_DAY: u64 = SECONDS_PER_DAY / ENTRY_WRITE_MIN_INTERVAL_SECONDS; + /// Reentry-cooldown key — deliberately `(payout_puzzle_hash, launcher_id)`, never `peer_id`. pub type CooldownKey = (Bytes32, Bytes32); @@ -37,14 +46,28 @@ pub struct FeeBudget { impl FeeBudget { pub fn new(standard_fee_mojos: u64, now: u64) -> Self { Self { - limit_mojos_per_day: standard_fee_mojos.saturating_mul(24), + limit_mojos_per_day: Self::daily_limit_for(standard_fee_mojos), spent_mojos_today: 0, day_started_at: now, } } + /// The SPEC §6.3 daily fee ceiling for an operator's configured standard fee: + /// [`MAX_BUNDLES_PER_DAY`] fees' worth. + /// + /// THE single place this product is formed. A second copy is how one write path ends up + /// bounding spend 24× looser than the other while both look internally consistent — and a + /// ceiling that is silently 24× too high is indistinguishable from ordinary operation right up + /// to the point the operator's XCH is gone. [`PersistedEntryWriter::decide`] therefore takes + /// the ALREADY-DERIVED ceiling instead of re-deriving it from a fee: mistaking a fee for a + /// ceiling there would fail open, whereas mistaking a ceiling for a fee here fails closed + /// (the prover refuses to write, which is exactly what §6.3 clause 3 asks of it). + pub fn daily_limit_for(standard_fee_mojos: u64) -> u64 { + standard_fee_mojos.saturating_mul(MAX_BUNDLES_PER_DAY) + } + fn roll_if_new_day(&mut self, now: u64) { - if now.saturating_sub(self.day_started_at) >= 86_400 { + if now.saturating_sub(self.day_started_at) >= SECONDS_PER_DAY { self.spent_mojos_today = 0; self.day_started_at = now; } @@ -272,12 +295,16 @@ impl<'a> PersistedEntryWriter<'a> { /// updated (not-yet-persisted) state alongside every non-refusal outcome; the caller MUST /// call [`Self::commit`] with that state after the chain confirms a `Bundle` outcome's submit /// succeeded. Nothing here submits to the chain. + /// + /// `daily_limit_mojos` is TODAY'S WHOLE FEE CEILING in mojos, not a per-bundle fee — derive it + /// with [`FeeBudget::daily_limit_for`] so this path and the in-memory [`FeeBudget`] cannot + /// bound the same spend differently. `fee_mojos` is what THIS bundle would cost. pub fn decide( &self, launcher_id: Bytes32, decisions: Vec, fee_mojos: u64, - standard_fee_mojos: u64, + daily_limit_mojos: u64, now: u64, ) -> (PersistedWriteOutcome, Option) { let mut state = match self.store.load(launcher_id) { @@ -285,7 +312,7 @@ impl<'a> PersistedEntryWriter<'a> { Err(_) => return (PersistedWriteOutcome::PersistenceUnavailable, None), }; - if now.saturating_sub(state.day_started_at) >= 86_400 { + if now.saturating_sub(state.day_started_at) >= SECONDS_PER_DAY { state.spent_mojos_today = 0; state.day_started_at = now; } @@ -306,7 +333,6 @@ impl<'a> PersistedEntryWriter<'a> { ); } - let daily_limit_mojos = standard_fee_mojos.saturating_mul(24); if state.spent_mojos_today.saturating_add(fee_mojos) > daily_limit_mojos { return ( PersistedWriteOutcome::FeeBudgetExhausted { @@ -593,4 +619,51 @@ mod tests { PersistedWriteOutcome::FeeBudgetExhausted { count: 1 } ); } + + /// The GENERAL form of the bug the restart test above catches one instance of: `decide` + + /// `commit` must round-trip EVERY field of [`WriteBoundState`], not only the fields whichever + /// scenario test happens to inspect. A seam that carries `last_bundle_sent_at` and + /// `cooldown_until` but silently drops `spent_mojos_today` passes every per-cycle check and + /// drains the operator's XCH one restart at a time. So: field by field, each with a distinct + /// non-zero value, so no dropped field can hide behind a plausible-looking zero. + #[test] + fn decide_then_commit_persists_every_write_bound_field() { + let store = FakeStore::default(); + let writer = PersistedEntryWriter::new(&store); + + // A clock exactly one day in rolls the loaded (default, all-zero) state's day window over, + // so `day_started_at` lands on a non-zero value of its own rather than staying at 0. + let now = SECONDS_PER_DAY; + let (outcome, state) = writer.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 250, + 1_000_000, + now, + ); + assert!(matches!(outcome, PersistedWriteOutcome::Bundle { .. })); + writer + .commit(LAUNCHER, &state.expect("decide returns state on success")) + .unwrap(); + + let persisted = store.load(LAUNCHER).unwrap(); + assert_eq!( + persisted.last_bundle_sent_at, + Some(now), + "rate bound (§6.3 clause 2) must persist" + ); + assert_eq!( + persisted.spent_mojos_today, 250, + "fee budget (§6.3 clause 3) — the field a restart loop drains" + ); + assert_eq!( + persisted.day_started_at, now, + "the day window the spend is measured against must persist with it" + ); + assert_eq!( + persisted.cooldown_until.get(&(PAYOUT_A, LAUNCHER)), + Some(&(now + REENTRY_COOLDOWN_SECONDS)), + "reentry cooldown (§6.3 clause 4) must persist" + ); + } } From c74a16bbaf23ccb184e99263d6fc2b71f2cf4563 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 23:05:44 -0700 Subject: [PATCH 11/11] fix(rewards): poison the writer on a failed persist, correct the eviction arithmetic The adversarial gate's last two blocking conditions on #593. C6 -- the save-failure hole. The load path was already fail-closed, but `commit` returned a Result and the obligation "treat the next cycle as unavailable if the save failed" lived only in a doc comment. A store whose load succeeds and whose save fails -- a read-only filesystem, a full disk -- kept handing back pre-submit state forever, so `spent_mojos_today` never accumulated and the daily ceiling silently became 24x whatever fee the caller supplied instead of 24x the standard fee. The rate bound survived only by the coincidence that PROVER_CYCLE_PERIOD_SECONDS equals ENTRY_WRITE_MIN_INTERVAL_SECONDS. A doc comment is not a bound, so `commit`'s Err now poisons the writer for its whole lifetime and `decide` checks that before anything else. There is deliberately no unpoison method: recovery is a fresh writer once the operator fixes the store, because a reset path is how a poison flag becomes decorative. C7 -- the eviction arithmetic was wrong in the direction that understates it. The doc claimed 96 Remove actions/day "(half of 192)" and then derived a 1.3-day flush from 192; both halves cannot be true at once. If every bundle is all removals the ceiling is 192/day; 96/day is the separate evict-plus-re-add churn ceiling, and at that rate flushing a 250-entry set takes ~2.6 days. For a figure a funder is meant to rely on, understating the eviction rate 2x while quoting a flush time only valid at the un-understated rate is a money misstatement, not a doc nit. Also re-worded the modulo-bias comment, which credited a judgement the decider gate says it did not make in that round. Committed from the parent side; the lane had all three edits correct but uncommitted, the eighth time in-flight work on this ticket needed rescuing. Refs #3250 --- crates/dig-node-core/src/rewards/challenge.rs | 3 +- crates/dig-node-core/src/rewards/mod.rs | 13 +- crates/dig-node-core/src/rewards/writes.rs | 112 +++++++++++++++++- 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs index 396b75e9..f8fb9667 100644 --- a/crates/dig-node-core/src/rewards/challenge.rs +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -37,7 +37,8 @@ pub struct WindowPlan { /// `% bound` is modulo-biased: outcomes below `u64::MAX % bound` are drawn very slightly more often /// than the rest, by a factor bounded by `bound / 2^64`. At `bound` sizes realistic here (a /// resource's byte length, at most a handful of GiB) that bias is on the order of 2^-20 or smaller -/// — judged inert by both the security and decider gates on this ticket. If this ever needs to +/// — the decider gate did not judge this in its first round on this ticket, adjudicating it only +/// afterward as inert (bias ≈ 2⁻³¹). If this ever needs to /// tighten (e.g. `bound` grows close to `2^64`), switch to rejection sampling (redraw when /// `raw >= bound * (u64::MAX / bound)`); it is a two-line change and this comment marks exactly /// where. diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs index 31fe024c..d80a21e7 100644 --- a/crates/dig-node-core/src/rewards/mod.rs +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -29,11 +29,14 @@ //! not add a second, separate protection on top of the rate bound — stated plainly here so nobody //! reads this engine as having two independent spend controls when it has one. //! -//! **Eviction**: up to 96 `Remove` actions/day (half of 192, if every bundle is all removals). SPEC -//! §6.4: `RemoveEntry` settles the entry's full accrued balance, ignoring `payout_threshold` — so -//! sustained eviction can flush an entire 250-entry set's accrued balance, including sub-threshold -//! dust that could never otherwise have been claimed, in **~1.3 days** (250 entries / 192 -//! actions-per-day capacity for removals alone). +//! **Eviction**: if every bundle is all removals, the ceiling is **192 `Remove` actions/day** (24 +//! bundles × 8 actions each) — the same 192-action/day cap stated above, not a fraction of it. +//! **96/day is a different number: the evict-plus-re-add churn ceiling**, since each churn (evict +//! one entry, admit a replacement) costs one `Remove` and one `Add`, so 192 actions/day buy at +//! most 96 churns/day. SPEC §6.4: `RemoveEntry` settles the entry's full accrued balance, ignoring +//! `payout_threshold` — so sustained churn can flush an entire 250-entry set's accrued balance, +//! including sub-threshold dust that could never otherwise have been claimed, in **~2.6 days** +//! (250 entries / 96 churns-per-day). pub mod admission; pub mod challenge; diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index 301dd4f4..7f5874eb 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -63,6 +63,15 @@ impl FeeBudget { /// ceiling there would fail open, whereas mistaking a ceiling for a fee here fails closed /// (the prover refuses to write, which is exactly what §6.3 clause 3 asks of it). pub fn daily_limit_for(standard_fee_mojos: u64) -> u64 { + // `saturating_mul` saturates toward `u64::MAX`, which is the permissive direction for a + // spend ceiling (a `checked_mul` refusal, or a `min` against a sane maximum, would be the + // fail-closed direction instead). Left as-is: this fn returns `u64`, not `Result`, and + // every caller (`Self::new`, `PersistedEntryWriter::decide`'s `daily_limit_mojos` param) + // treats its output as an infallible bound, so making it fail closed ripples into a + // signature change here and at both call sites rather than staying a local fix. No + // realistic configured fee reaches this overflow (`standard_fee_mojos` would need to + // exceed ~u64::MAX / 24), so this is a direction note for the next person to touch this + // fn, not a live exploit. standard_fee_mojos.saturating_mul(MAX_BUNDLES_PER_DAY) } @@ -284,11 +293,23 @@ pub enum PersistedWriteOutcome { /// [`EntryWriteScheduler::record_submitted`] documents. pub struct PersistedEntryWriter<'a> { store: &'a dyn WriteBoundStore, + /// Set when [`Self::commit`] observes a `save` error. This is the enforcement of the + /// obligation this module's `commit` doc previously stated but never checked: a store where + /// `load` succeeds but `save` fails would otherwise keep returning pre-submit state forever, + /// so `spent_mojos_today` never accumulates and the daily ceiling silently becomes + /// `MAX_BUNDLES_PER_DAY × whatever fee the caller supplies` instead of `× standard_fee`. + /// `Cell`, not a plain `bool`, because [`Self::decide`] takes `&self`. There is deliberately + /// no clearing method: recovery is a fresh writer after the operator fixes the store — a + /// reset path is how a poison flag becomes decorative. + poisoned: std::cell::Cell, } impl<'a> PersistedEntryWriter<'a> { pub fn new(store: &'a dyn WriteBoundStore) -> Self { - Self { store } + Self { + store, + poisoned: std::cell::Cell::new(false), + } } /// Load this distributor's persisted bounds, then decide this cycle's write. Returns the @@ -307,6 +328,10 @@ impl<'a> PersistedEntryWriter<'a> { daily_limit_mojos: u64, now: u64, ) -> (PersistedWriteOutcome, Option) { + if self.poisoned.get() { + return (PersistedWriteOutcome::PersistenceUnavailable, None); + } + let mut state = match self.store.load(launcher_id) { Ok(state) => state, Err(_) => return (PersistedWriteOutcome::PersistenceUnavailable, None), @@ -373,12 +398,18 @@ impl<'a> PersistedEntryWriter<'a> { } /// Persist the state [`Self::decide`] returned, once the caller has confirmed the chain - /// accepted the bundle. On `Err`, the caller MUST treat the NEXT cycle as - /// `PersistenceUnavailable` too — a save failure means the bounds this submit just advanced - /// are not durable, so trusting them in memory afterward would reopen the exact hole this - /// seam exists to close. + /// accepted the bundle. On `Err`, this writer is POISONED for the rest of its lifetime: every + /// subsequent [`Self::decide`] call returns `PersistenceUnavailable` with no state, regardless + /// of what `load` would return — a save failure means the bounds this submit just advanced are + /// not durable, so trusting them in memory afterward would reopen the exact hole this seam + /// exists to close. There is no unpoison method; a fresh writer after the store is fixed is + /// the only recovery. pub fn commit(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError> { - self.store.save(launcher_id, state) + let result = self.store.save(launcher_id, state); + if result.is_err() { + self.poisoned.set(true); + } + result } } @@ -666,4 +697,73 @@ mod tests { "reentry cooldown (§6.3 clause 4) must persist" ); } + + /// A store whose `load` always succeeds but whose `save` always fails — the save-failure hole + /// C6 closes: without the poison flag, `decide` would keep reloading the same never-advanced + /// state forever, so `spent_mojos_today` never accumulates and the daily ceiling silently + /// becomes `MAX_BUNDLES_PER_DAY × whatever fee the caller supplies`. + #[derive(Default)] + struct LoadOkSaveErrStore { + states: std::sync::Mutex>, + } + + impl WriteBoundStore for LoadOkSaveErrStore { + fn load(&self, launcher_id: Bytes32) -> Result { + Ok(self + .states + .lock() + .unwrap() + .get(&launcher_id) + .cloned() + .unwrap_or_default()) + } + + fn save(&self, _launcher_id: Bytes32, _state: &WriteBoundState) -> Result<(), StoreError> { + Err(StoreError("disk full".to_string())) + } + } + + /// C6: a `commit` failure poisons the writer for its whole lifetime — every subsequent + /// `decide` call returns `PersistenceUnavailable` with no state, across at least two + /// subsequent cycles (not just the one immediately after), so a single lucky follow-up call + /// cannot pass by accident through the rate bound. + #[test] + fn save_failure_poisons_the_writer_for_every_subsequent_cycle() { + let store = LoadOkSaveErrStore::default(); + let writer = PersistedEntryWriter::new(&store); + + // Cycle 1: load succeeds, decide produces a bundle, commit's save fails. + let (outcome, state) = writer.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + 1_000_000, + 0, + ); + assert!(matches!(outcome, PersistedWriteOutcome::Bundle { .. })); + let commit_result = writer.commit(LAUNCHER, &state.expect("decide returns state")); + assert!(commit_result.is_err(), "the fake store's save always fails"); + + // Cycle 2: poisoned — no load, no bundle, regardless of rate/cooldown state. + let (outcome_2, state_2) = writer.decide( + LAUNCHER, + vec![add([9; 32], LAUNCHER)], + 100, + 1_000_000, + ENTRY_WRITE_MIN_INTERVAL_SECONDS + 1, + ); + assert_eq!(outcome_2, PersistedWriteOutcome::PersistenceUnavailable); + assert!(state_2.is_none()); + + // Cycle 3: still poisoned — this is the assertion a single-follow-up-call test could miss. + let (outcome_3, state_3) = writer.decide( + LAUNCHER, + vec![add([8; 32], LAUNCHER)], + 100, + 1_000_000, + 2 * ENTRY_WRITE_MIN_INTERVAL_SECONDS + 2, + ); + assert_eq!(outcome_3, PersistedWriteOutcome::PersistenceUnavailable); + assert!(state_3.is_none()); + } }