From eac8e1083fc86de0401a4b88a83f28157d252a3d Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:33:03 -0700 Subject: [PATCH 01/12] feat(mirror): persist mirror-bond coin ids (#575) * chore: open lane for #574 * feat(mirror): persist mirror-bond coin ids so a restart cannot double-create Bond identity was reconstructed from a live chain scan on every read (`mirror/observe.rs`), with no persistence of its own. A restart, a cold replica, or a lagging/flaky chain source all rendered a real, unspent, confirmed bond as "no bonds" -- and because the in-flight suppression is keyed on pending/submitted audit entries, a bond whose create had already CONFIRMED was not suppressed either, so the same short scan that emptied the read surface also cleared the one thing that would have stopped a second coin being paid for collateral that already exists (dig-node#574). Persist the (store, root, epoch) -> coin_id mapping in the EXISTING spend audit record (spend-audit.jsonl) rather than a new store: a mirror-coin create already writes store_id + AuditedBond{root, epoch} + amount there, and the coin id itself becomes durable the moment resolve_landed_spends confirms it. This adds the one missing piece -- the advertised URL a create carries -- and a read-side query, confirmed_mirror_bond, that returns the newest CONFIRMED record naming a triple. Chain stays authoritative. mirror::local_bond::recheck_missing_bonds never trusts the record: for a held bond the live scan did not cover, it asks the record for a candidate coin id, then re-verifies that SPECIFIC coin against chain via the same independent check (chain_bond_verdict) that verifies an untrusted peer's claimed bond. Only a fresh `Bonded` verdict is folded back in, as covered; `Unbonded`/`Unverified` fall through to an ordinary create, exactly as if no record existed. Version: 0.254.86 (patch -- per #522 the MSI ProductVersion minor field is exhausted and the counter lives in patch). Co-Authored-By: Claude * test(mirror): prove the recovery wiring end to end through PassRunner::run Adds two integration-level tests over the REAL pass pipeline, not just the isolated recheck_missing_bonds unit tests: a bond missing from the live scan with a chain-reverified durable record is recovered (no double create, correct Bonded state reported), and the control -- the same record but chain disproves it -- correctly falls through to an ordinary create. Together these are the concrete regression test for the cold-start/lagging-chain-source double-create scenario the ticket asked to have measured. Also refactors in_flight_creates to take the already-folded SpendLedger instead of re-reading the log itself, so PassRunner::run reads the audit file once per pass and shares it with the new recovery step, and fixes a doc comment on in_flight_creates that the recovery step would otherwise have made stale on landing ("a Confirmed create has a coin the chain observation already sees" is no longer unconditionally true). Co-Authored-By: Claude * chore(fmt): wrap long test signatures to satisfy rustfmt Co-Authored-By: Claude * chore(clippy): use slice::from_ref instead of cloning for a single-element slice Co-Authored-By: Claude * chore(release): bump to v0.254.89 Base branch moved to develop after PR #576 merged there at v0.254.88 (main and develop are currently identical), leaving this branch's carried-forward .88 as a zero-increment against the new base. Bumped to the next free integer after fetching and verifying both origin/main and origin/develop tip at .88. Co-Authored-By: Claude --------- Co-authored-by: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- DEVELOPMENT_LOG.md | 50 +++ SPEC.md | 34 ++ crates/dig-node-service/src/control.rs | 1 + .../src/mirror/converge_tests.rs | 1 + crates/dig-node-service/src/mirror/funding.rs | 1 + .../dig-node-service/src/mirror/lifecycle.rs | 40 ++ .../dig-node-service/src/mirror/local_bond.rs | 383 ++++++++++++++++++ crates/dig-node-service/src/mirror/mod.rs | 1 + .../src/mirror/resolve_tests.rs | 1 + crates/dig-node-service/src/mirror/runner.rs | 255 +++++++++++- crates/dig-node-service/src/mirror/spends.rs | 13 + crates/dig-node-service/src/spend_audit.rs | 206 ++++++++++ .../dig-node-service/src/spend_audit_cli.rs | 1 + .../tests/mirror_advertised_urls.rs | 72 ++++ .../mirror_funding_reservation_expiry.rs | 1 + .../dig-node-service/tests/spend_audit_e2e.rs | 1 + 18 files changed, 1043 insertions(+), 22 deletions(-) create mode 100644 crates/dig-node-service/src/mirror/local_bond.rs diff --git a/Cargo.lock b/Cargo.lock index 655ed551..32c264ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3040,7 +3040,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.88" +version = "0.254.89" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 91068c52..e785eeb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ edition = "2021" # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.254.88" +version = "0.254.89" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard. diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 200b691f..53cb6ba6 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1597,3 +1597,53 @@ voice that is REACHED and says no. Ask what the nearest wrong implementation is, then ask which input it would answer differently on. If no fixture in the suite is that input, the property is undefended however many tests surround it. + +## A pure re-derivation from a live scan has no memory of its own confirmations (dig-node#574) + +`mirror::observe` was written deliberately stateless — a pure function over four freshly-gathered +readings, with no wallet, no signer, no chain handle. That purity is real and worth keeping: it is +what makes the hostile cases testable as literals instead of a chain that must be induced into a +state. But "no state of its own" and "no memory across restarts" turned out to be the same property +read two ways, and the second reading has a cost nobody had priced: a coin this node created, +confirmed, and durably recorded confirming becomes indistinguishable from a coin that never existed +the moment ONE live scan comes back short — a cold replica, a restart, a chain source that answers +"no coins" instead of erroring. + +Measured on a real host: three bonds, all confirmed on chain, read as zero immediately after a +routine service restart. The capsules were untouched (5 hosted stores, 407 MB cached) and the $DIG +was still locked on chain — only the OBSERVATION emptied, because the observation was rebuilt from +nothing but a chain query that happened to answer short at that instant. + +**The sharper cost was not the display.** `mirror::plan`'s in-flight suppression is keyed on the +audit record's `pending`/`submitted` rows — and a create that has already CONFIRMED has left that +set, correctly, because a confirmed coin needs no suppression as long as the live scan can still see +it. The same short scan that emptied the read surface therefore also cleared the one thing standing +between a lagging chain source and a second coin paid for collateral that already exists. Two +different-looking symptoms (a wrong number on a read-only surface, a possible double-spend on a +money path) turned out to share one root: an observation with no fallback treats "the chain didn't +answer this instant" and "this never happened" as the same fact. + +**The fix keeps the purity and adds a fallback that never outranks chain.** The audit record already +had almost everything needed — `SpendRecord` carries `store_id` + `AuditedBond{root, epoch}` + +`amount_mojos` structurally, and a `Confirmed` status carries the coin id, because +`SpendJournal::confirmed` requires one. `mirror::local_bond::recheck_missing_bonds` reads that record +as a CANDIDATE only: for a bond the live scan missed, it asks the record for a coin id, then asks +chain directly and independently whether THAT SPECIFIC coin still bonds this content and is unspent — +the same check (`chain_bond_verdict`) already used to verify a stranger's claimed bond, reused here +against this node's own past claim about itself. Only a fresh positive verdict is folded back in, as +if the scan had found it; a stale, reclaimed, or wrongly-attributed record falls through to the +ordinary path exactly as if it did not exist. + +Two things worth carrying forward: + +* **A "pure function, no I/O" module can still have a false-negative surface if its ONLY input is a + read that can fail short instead of failing loud.** The purity argument (easy to test, no smuggled + state) is real and does not conflict with adding a fallback — the fallback belongs in the IMPURE + caller that already does I/O, feeding the pure function a better input, never inside the pure + function itself. +* **A local record that could inform a decision is either a belief or a candidate, and the whole + design turns on which.** A candidate is re-verified through the SAME authority the rest of the + system already trusts before it changes anything; a belief is trusted on its own say-so. The + difference is not scrutiny of the record — it is whether a wrong record can ever, by itself, + produce a wrong outcome. Here it cannot: `Unbonded`/`Unverified` from the re-check discards the + candidate no matter how confidently the record states it. diff --git a/SPEC.md b/SPEC.md index 3ee2fe0b..c9d17ca8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -9180,6 +9180,35 @@ A pass runs: at start-up (once the wallet and a chain source are available), on A confirmed create is `Confirmed { height, coin_id }` in the audit record, observed on the created coin. The `intended_coin_id` is recorded at submission so §23.5's reconcile accounts for it. +8. **Recovers a bond step 2's scan came back SHORT on, from what this node itself recorded + creating** (dig-node#574). Step 2 is a live read of a puzzle hash every mirror coin shares; a + restart, a cold or lagging chain source, or a source that answers "no coins" instead of erroring + all render an existing, unspent, fully-collateralised bond identically to one that was never + created. Because step 6's in-flight suppression is keyed on `pending`/`submitted` entries, a + bond whose create has already CONFIRMED is not suppressed either — so the SAME short scan that + would empty §25.8's surface also clears the one thing that would have stopped a second coin + being paid for collateral that already exists. + + For every held bond step 2 did not cover, the audit record is asked what coin this node last + recorded CONFIRMING for that exact `(store, root, epoch)` — never a `pending` or `submitted` + entry, which for a create carries no coin id at all (step 7). If the record names one, that + SPECIFIC coin id is re-verified against chain directly — the same independent check that verifies + an untrusted peer's claimed bond (§25.6a), run here against this node's own past record — and only + a fresh `bonded` verdict is folded back into step 2's observation, as if the scan had found it. A + verdict of `unbonded` or `unverified` recovers nothing: the record is a CANDIDATE to re-check, + never a belief, and a coin chain disproves — reclaimed, or never real — MUST fall through to an + ordinary create exactly as if no record existed. Sufficiency against today's collateral + requirement is NOT re-checked here, matching step 2's own scan: both report a recovered coin at + what it actually locks, never at today's requirement (§25.3). + + The audit record ALSO carries the URLs a create advertised its bond as fetchable from, alongside + the `(store, root, epoch)` it already carried structurally — recorded at submission, from the + same composition that reached the coin, so the two cannot disagree (§25.10 governs what is + composed; this is only that it is written down). + + This step is bounded to what is actually missing: a bond step 2's scan already covers is never + looked up here, so a healthy node's pass makes no additional chain calls through it. + ### 25.5. Presence and debounce > **PARTIALLY PENDING — the debounce rule is implemented and now has a caller; the scanning is @@ -9494,6 +9523,11 @@ one setting to turn off** (§6.0/#207). > page. A bond whose create is refused — for want of an advertised URL, for want of uncommitted > operator $DIG, or because the chain could not be read — reports as uncovered, which is what it is. +A `bonded` row's coin MAY come from step 8's recovery (§25.4) rather than from step 2's live scan +directly — the two are indistinguishable to a caller, and that is deliberate: a recovered coin was +re-verified against chain before being folded in, so it is exactly as `bonded` as one the scan found +on its own, never a lesser, "locally believed" variant of the state. + The lifecycle exposes, per `(store, root)`, over the control plane and with a `dign` verb (§8.6 CLI parity): the bond state — `bonded { coin_id, epoch, amount }`, `pending` (in-flight create), `unfunded { short_dig_base_units }`, `deferred { requirement reason }` (§25.3), `withheld` diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index f7751f31..58d8a07b 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -5784,6 +5784,7 @@ mod tests { fee_mojos: 4_200, store_id: Some("ee".repeat(32)), bond: None, + advertised_urls: Vec::new(), initiated_ms: 1_756_000_000_000, updated_ms: 1_756_000_001_000, status, diff --git a/crates/dig-node-service/src/mirror/converge_tests.rs b/crates/dig-node-service/src/mirror/converge_tests.rs index 32eec257..d66ec4ff 100644 --- a/crates/dig-node-service/src/mirror/converge_tests.rs +++ b/crates/dig-node-service/src/mirror/converge_tests.rs @@ -275,6 +275,7 @@ fn create_intent(store: &str, root: &str, epoch: i64) -> SpendIntent { root: id(root), epoch, }), + advertised_urls: Vec::new(), } } diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 37672ca6..5d1db748 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -2112,6 +2112,7 @@ mod tests { fee_mojos: 0, store_id: None, bond: None, + advertised_urls: Vec::new(), } } diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index d94e83f1..bd8c695b 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -433,6 +433,46 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { Ok(record.and_then(|r| r.confirmed_height)) } + fn recheck_bond( + &self, + store_id: &str, + root: &str, + epoch: i64, + coin_id: &str, + ) -> dig_node_core::mirror_bond::BondVerdict { + use dig_node_core::mirror_bond::BondVerdict; + + // A malformed id is this node's OWN record being wrong, not a stranger's -- but the answer + // is the same either way: nothing here can be re-verified, so it is not promoted. + let (Ok(store_launcher_id), Ok(root_hash), Ok(claimed_coin_id)) = ( + parse_id(store_id, "store id"), + parse_id(root, "root hash"), + parse_id(coin_id, "coin id"), + ) else { + tracing::error!( + target: "mirror", + store_id, + root, + coin_id, + "a locally recorded mirror bond has an unparsable id; it is not re-verified" + ); + return BondVerdict::Unverified; + }; + + super::bond_verify::chain_bond_verdict( + self.source, + store_launcher_id, + root_hash, + &num_bigint::BigInt::from(epoch), + // Sufficiency against TODAY's requirement is deliberately not checked here -- see the + // trait doc on `MirrorEffects::recheck_bond` for why `Some(0)` is the right value + // rather than `None` (which could never promote to `Bonded`) or today's per-coin figure + // (which would hold this path to a stricter bar than the ordinary live-scan path does). + Some(0), + claimed_coin_id, + ) + } + fn dig_balance_base_units(&self) -> Result { self.dig_balance.clone() } diff --git a/crates/dig-node-service/src/mirror/local_bond.rs b/crates/dig-node-service/src/mirror/local_bond.rs new file mode 100644 index 00000000..43b0cc17 --- /dev/null +++ b/crates/dig-node-service/src/mirror/local_bond.rs @@ -0,0 +1,383 @@ +//! Recovering a bond the live chain scan came back short on, from what this node itself recorded +//! creating (dig-node#574). +//! +//! # The gap this closes +//! +//! [`super::observe`] and [`super::plan`] both key a bond's `Bonded` state on ONE reading: +//! [`super::runner::MirrorEffects::observe_chain`], a live scan of every mirror coin this wallet +//! owns. A restart, a cold replica still catching up, or a chain source that answers "no coins" +//! instead of erroring all render identically here — a bond with a real, confirmed, unspent coin +//! reports as if it had never been created at all. +//! +//! That is not only a display defect. [`super::plan::plan`] treats an uncovered held bond as one to +//! CREATE, and the in-flight suppression it also consults is keyed on the audit record's `Pending`/ +//! `Submitted` rows — which a CONFIRMED create has already left. So the same short scan that empties +//! the read surface also clears the one thing that would have stopped a second coin being paid for +//! collateral that already exists. +//! +//! # The fix is a candidate, re-verified — never a belief +//! +//! [`recheck_missing_bonds`] does not trust the audit record. For each held bond the live scan +//! missed, it asks `crate::spend_audit::confirmed_mirror_bond` what this node last confirmed +//! creating for that exact triple, and — only if something answers — asks +//! [`super::runner::MirrorEffects::recheck_bond`] to verify that SPECIFIC coin against chain +//! directly, independent of what the record says. Only a fresh `Bonded` verdict is promoted; anything +//! else (`Unbonded`, `Unverified`) is left alone, and the caller falls through to its ordinary +//! behaviour — which, for a genuinely-reclaimed coin, is correctly to create a fresh one. +//! +//! # Cost is bounded by what is actually missing +//! +//! A bond the live scan already covers is never looked up here at all: the filter that selects +//! "bonds needing a recheck" runs BEFORE any ledger read or chain call. On a healthy node — the +//! overwhelming majority of passes — this makes zero extra calls of any kind. + +use crate::spend_audit::{confirmed_mirror_bond, SpendLedger}; + +use super::plan::{Bond, HeldMirror}; +use super::runner::MirrorEffects; + +use dig_node_core::mirror_bond::BondVerdict; + +/// For every bond in `held` that `on_chain` does not cover at `current_epoch`, recover it from the +/// audit record and a fresh chain re-check, when both agree it is still genuinely bonded. +/// +/// Returns only the RECOVERED coins — the caller extends its own `on_chain` with them before +/// handing it to [`super::pass::decide`], so a recovered bond flows through the ordinary `Bonded` +/// classification rather than a new, parallel one. +pub(super) fn recheck_missing_bonds( + effects: &E, + ledger: &SpendLedger, + held: &[Bond], + on_chain: &[HeldMirror], + current_epoch: i64, +) -> Vec { + held.iter() + .filter(|bond| !covered(on_chain, bond, current_epoch)) + .filter_map(|bond| recover_one(effects, ledger, bond, current_epoch)) + .collect() +} + +/// Does the live scan already show a current-epoch coin for this bond? +fn covered(on_chain: &[HeldMirror], bond: &Bond, current_epoch: i64) -> bool { + on_chain + .iter() + .any(|c| c.epoch == current_epoch && c.store_id == bond.store_id && c.root == bond.root) +} + +/// Recover ONE missing bond, or decide there is nothing to recover. +fn recover_one( + effects: &E, + ledger: &SpendLedger, + bond: &Bond, + current_epoch: i64, +) -> Option { + let candidate = confirmed_mirror_bond(ledger, &bond.store_id, &bond.root, current_epoch)?; + + let verdict = effects.recheck_bond( + &bond.store_id, + &bond.root, + current_epoch, + &candidate.coin_id.0, + ); + + match verdict { + BondVerdict::Bonded => Some(HeldMirror { + coin_id: candidate.coin_id.0, + store_id: bond.store_id.clone(), + root: bond.root.clone(), + epoch: current_epoch, + // The record's OWN amount, never today's requirement: a coin created under a previous + // requirement locks what it actually locked (SPEC.md §25.3), exactly as the ordinary + // live-scan path already reports it. + collateral_dig_base_units: candidate.amount_dig_base_units, + }), + // A stale, reclaimed, or otherwise no-longer-valid record. Falling through here is what + // lets the ordinary plan create a fresh coin instead of one this function invented. + BondVerdict::Unbonded | BondVerdict::Unverified => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spend_audit::{ + kinds, Asset, AuditedBond, Authority, SpendIntent, SpendJournal, SpendKind, SpendLog, + Submission, TargetCoinId, + }; + use std::cell::RefCell; + + /// A distinguishable 64-hex id, by construction rather than by counting characters. + fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s + } + + fn bond(store: &str, root: &str) -> Bond { + Bond::new(id(store), id(root)) + } + + fn coin(tag: &str, store: &str, root: &str, epoch: i64, amount: u64) -> HeldMirror { + HeldMirror { + coin_id: id(tag), + store_id: id(store), + root: id(root), + epoch, + collateral_dig_base_units: amount, + } + } + + const EPOCH: i64 = 105; + + /// A ledger holding one CONFIRMED mirror-coin record for `(store, root, EPOCH)`. + fn ledger_with_confirmed_bond( + store: &str, + root: &str, + coin_id: &str, + amount: u64, + ) -> SpendLedger { + let dir = tempfile::tempdir().expect("temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log.clone()); + let recorded = journal.begin(SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "create a mirror coin".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: amount, + fee_mojos: 0, + store_id: Some(id(store)), + bond: Some(AuditedBond { + root: id(root), + epoch: EPOCH, + }), + advertised_urls: Vec::new(), + }); + journal.submitted( + &recorded, + Submission { + intended_coin_id: None, + funding_coin_ids: Vec::new(), + }, + ); + journal.confirmed(&recorded, TargetCoinId(id(coin_id)), 1); + log.ledger().expect("ledger") + } + + /// A double that records every [`MirrorEffects::recheck_bond`] call it receives and answers a + /// fixed verdict — every OTHER trait method is unreachable, because [`recheck_missing_bonds`] + /// never calls anything else. A call reaching one of them is this test catching the function + /// under test doing more I/O than its own contract promises. + struct FakeRecheck { + verdict: BondVerdict, + calls: RefCell>, + } + + impl FakeRecheck { + fn answering(verdict: BondVerdict) -> Self { + FakeRecheck { + verdict, + calls: RefCell::new(Vec::new()), + } + } + } + + impl MirrorEffects for FakeRecheck { + fn observe_disk( + &self, + ) -> Result, super::super::runner::PassError> + { + unreachable!("recheck_missing_bonds must not scan disk") + } + fn observe_chain(&self) -> Result, super::super::runner::PassError> { + unreachable!("recheck_missing_bonds must not re-scan the chain broadly") + } + fn coin_confirmation( + &self, + _coin_id: &str, + ) -> Result, super::super::runner::PassError> { + unreachable!("recheck_missing_bonds must ask recheck_bond, never coin_confirmation") + } + fn dig_balance_base_units(&self) -> Result { + unreachable!("recheck_missing_bonds is not a funds decision") + } + fn reclaim( + &self, + _mirror: &HeldMirror, + _reason: super::super::plan::ReclaimReason, + ) -> Result<(), super::super::runner::PassError> { + unreachable!("recheck_missing_bonds never spends") + } + fn create( + &self, + _bond: &Bond, + _epoch: i64, + _amount_dig_base_units: u64, + ) -> Result<(), super::super::runner::PassError> { + unreachable!("recheck_missing_bonds never spends") + } + fn recheck_bond( + &self, + store_id: &str, + root: &str, + epoch: i64, + coin_id: &str, + ) -> BondVerdict { + self.calls.borrow_mut().push(( + store_id.to_string(), + root.to_string(), + epoch, + coin_id.to_string(), + )); + self.verdict + } + } + + /// **A bond the live scan ALREADY covers is never looked up here, and never rechecked.** + /// + /// The nearest wrong implementation rechecks every held bond unconditionally and is satisfied + /// identically by an empty RESULT — so the call log, not the return value, is what this test + /// asserts on: a healthy node making a chain call it does not need is the cost this function + /// exists to avoid. + #[test] + fn a_bond_the_live_scan_covers_is_never_rechecked() { + let effects = FakeRecheck::answering(BondVerdict::Bonded); + let ledger = SpendLedger::default(); + let held = [bond("aa", "11")]; + let on_chain = [coin("c1", "aa", "11", EPOCH, 1_000)]; + + let recovered = recheck_missing_bonds(&effects, &ledger, &held, &on_chain, EPOCH); + + assert!(recovered.is_empty()); + assert!( + effects.calls.borrow().is_empty(), + "a covered bond must not even be asked about: {:?}", + effects.calls.borrow() + ); + } + + /// **A bond with NO local record is left alone, and never rechecked.** + /// + /// Proves the ledger lookup gates the chain call, not merely the eventual answer: a wrong + /// implementation that rechecks with an empty or invented coin id would pass on RESULT alone, + /// since this fixture's ledger has nothing to promote either way. + #[test] + fn a_bond_with_no_local_record_is_never_rechecked() { + let effects = FakeRecheck::answering(BondVerdict::Bonded); + let ledger = SpendLedger::default(); + let held = [bond("aa", "11")]; + + let recovered = recheck_missing_bonds(&effects, &ledger, &held, &[], EPOCH); + + assert!(recovered.is_empty()); + assert!( + effects.calls.borrow().is_empty(), + "nothing durable exists for this bond, so nothing should be looked up: {:?}", + effects.calls.borrow() + ); + } + + /// **A bond missing from the live scan, WITH a confirmed local record chain re-verifies as + /// `Bonded`, is recovered with the RECORD's own coin id, amount and epoch.** + /// + /// This is the double-create fix and the display fix in one property: without it, `plan()` + /// would see this bond as uncovered and create a second coin for collateral that already + /// exists. Each of the three recovered fields is given a distinct, recognisable value so a + /// wrong wiring — swapping which field feeds which, or substituting a hardcoded stand-in — + /// cannot pass by accident. + #[test] + fn a_missing_bond_with_a_reverified_local_record_is_recovered() { + let effects = FakeRecheck::answering(BondVerdict::Bonded); + let ledger = ledger_with_confirmed_bond("aa", "11", "the-real-coin", 4_242); + let held = [bond("aa", "11")]; + + let recovered = recheck_missing_bonds(&effects, &ledger, &held, &[], EPOCH); + + assert_eq!(recovered.len(), 1); + let got = &recovered[0]; + assert_eq!(got.coin_id, id("the-real-coin")); + assert_eq!(got.store_id, id("aa")); + assert_eq!(got.root, id("11")); + assert_eq!(got.epoch, EPOCH); + assert_eq!(got.collateral_dig_base_units, 4_242); + + let calls = effects.calls.borrow(); + assert_eq!( + calls.len(), + 1, + "exactly one candidate needed exactly one re-check" + ); + assert_eq!( + calls[0].3, + id("the-real-coin"), + "the CANDIDATE coin id must be the one asked about" + ); + } + + /// **THE constraint this whole module exists to hold: a local record chain re-verifies as + /// `Unbonded` is NOT recovered — the record is never believed over a fresh chain answer.** + /// + /// Without this test, an implementation that promoted any candidate with a local record — + /// regardless of what `recheck_bond` actually answered — would pass every test above + /// identically, since none of them varies the verdict away from `Bonded`. This is the fixture + /// that makes "in addition to chain" a different claim from "instead of chain". + #[test] + fn a_missing_bond_whose_local_coin_chain_says_is_unbonded_is_not_recovered() { + let effects = FakeRecheck::answering(BondVerdict::Unbonded); + let ledger = ledger_with_confirmed_bond("aa", "11", "a-reclaimed-coin", 1_000); + let held = [bond("aa", "11")]; + + let recovered = recheck_missing_bonds(&effects, &ledger, &held, &[], EPOCH); + + assert!( + recovered.is_empty(), + "a coin chain disproves must not be reported as bonded, however durably it was once \ + recorded: {recovered:?}" + ); + } + + /// The THIRD verdict, proven separately from `Unbonded`: "nothing could be established" must + /// also NOT promote. An implementation that only guarded against `Unbonded` — treating anything + /// non-`Unbonded` as good enough — would pass the test above and fail this one. + #[test] + fn a_missing_bond_whose_recheck_is_unverified_is_not_recovered() { + let effects = FakeRecheck::answering(BondVerdict::Unverified); + let ledger = ledger_with_confirmed_bond("aa", "11", "some-coin", 1_000); + let held = [bond("aa", "11")]; + + let recovered = recheck_missing_bonds(&effects, &ledger, &held, &[], EPOCH); + + assert!( + recovered.is_empty(), + "an unverified re-check must never be promoted to bonded by default: {recovered:?}" + ); + } + + /// **A confirmed record from a PRIOR epoch does not cover the CURRENT epoch's bond.** + /// + /// A rollover legitimately leaves a previous epoch's coin on chain while this epoch has none + /// yet; recovering it here would tell the plan a stale coin covers a live requirement it does + /// not, and the bond would never get a fresh coin of its own. + #[test] + fn a_confirmed_record_from_a_prior_epoch_does_not_cover_the_current_one() { + let effects = FakeRecheck::answering(BondVerdict::Bonded); + // Recorded confirmed at EPOCH, asked about at a LATER epoch. + let ledger = ledger_with_confirmed_bond("aa", "11", "last-epoch-coin", 1_000); + let held = [bond("aa", "11")]; + + let recovered = recheck_missing_bonds(&effects, &ledger, &held, &[], EPOCH + 1); + + assert!(recovered.is_empty()); + assert!( + effects.calls.borrow().is_empty(), + "a different-epoch record is not even a candidate, so no re-check is attempted: {:?}", + effects.calls.borrow() + ); + } +} diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index a7c1bc8a..9abf68d1 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -84,6 +84,7 @@ mod converge_tests; pub mod events; pub mod funding; pub mod lifecycle; +pub(crate) mod local_bond; pub mod observe; pub mod pass; pub mod plan; diff --git a/crates/dig-node-service/src/mirror/resolve_tests.rs b/crates/dig-node-service/src/mirror/resolve_tests.rs index 5903d397..266d2aee 100644 --- a/crates/dig-node-service/src/mirror/resolve_tests.rs +++ b/crates/dig-node-service/src/mirror/resolve_tests.rs @@ -84,6 +84,7 @@ fn intent(store: &str, root: &str, epoch: i64) -> SpendIntent { root: id(root), epoch, }), + advertised_urls: Vec::new(), } } diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 5a4d7950..31f655fb 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -161,6 +161,34 @@ pub trait MirrorEffects { /// (§25.3) and must be identical for every create in that pass. An implementation that re-derived /// it per call could price two coins of one pass differently. fn create(&self, bond: &Bond, epoch: i64, amount_dig_base_units: u64) -> Result<(), PassError>; + + /// Re-verify ONE bond this pass's [`Self::observe_chain`] scan did NOT return, using a coin id + /// this node recorded creating as the candidate to check — never as the answer + /// (dig-node#574, `super::local_bond`). + /// + /// This is [`super::bond_verify::chain_bond_verdict`] — the SAME independent chain check that + /// verifies an untrusted PEER's claimed bond — run here against this node's own past record. A + /// coin id this node wrote down is not a stranger's claim, but it is still only a claim: the + /// verdict must come from the coin itself (its puzzle hash, its creating spend, its own declared + /// `(store, root, epoch)`) and never from what this node's ledger says it is, so a stale, + /// since-spent, or — in principle — wrongly-attributed coin id fails this exactly as it would + /// fail a stranger's. Sufficiency against today's collateral requirement is deliberately NOT + /// re-checked: an on-chain coin found by the ordinary scan is reported `Bonded` at whatever it + /// actually locked too (`pass::bond_states`), and this path must not hold a stricter bar than + /// that one. + /// + /// Defaults to [`dig_node_core::mirror_bond::BondVerdict::Unverified`] — a fixture that does not + /// model live chain makes no claim, which is the safe direction: the caller then falls back to + /// its existing chain-only behaviour exactly as if this method did not exist. + fn recheck_bond( + &self, + _store_id: &str, + _root: &str, + _epoch: i64, + _coin_id: &str, + ) -> dig_node_core::mirror_bond::BondVerdict { + dig_node_core::mirror_bond::BondVerdict::Unverified + } } /// What a pass consults that this module does not observe for itself. @@ -338,7 +366,7 @@ impl PassRunner { .presence .observe(&on_disk_held, ctx.now_unix_ms, self.settling_window_ms); - let on_chain = self.effects.observe_chain()?; + let mut on_chain = self.effects.observe_chain()?; // BEFORE the in-flight set is derived, so a create this sweep confirms stops suppressing // itself in the same pass rather than one pass later. Never `?`: resolution is bookkeeping @@ -346,7 +374,40 @@ impl PassRunner { // stop the pass from reclaiming money that is sitting on chain. super::resolve::resolve_landed_spends(&self.journal, &self.effects, &on_chain); - let in_flight = in_flight_creates(&self.log, ctx.current_epoch); + // Read ONCE and shared by both questions the ledger answers this pass: which bonds are + // already covered by a durable record (recovery, dig-node#574) and which creates are still + // in flight (§25.4.6). Never `?`: an audit record this pass cannot read answers neither + // question, which for BOTH is the safe direction — recovering nothing risks a duplicate the + // next epoch's rollover reclaims, exactly as suppressing nothing already did before this + // record existed at all. + let ledger = match self.log.ledger() { + Ok(ledger) => ledger, + Err(e) => { + tracing::warn!( + target: "mirror", + error = %e, + "the spend audit record could not be read; no missing bond is recovered from \ + it and no in-flight create is suppressed this pass" + ); + crate::spend_audit::SpendLedger::default() + } + }; + + // For every HELD bond the live scan did NOT cover, ask whether this node's own durable + // record names a coin it already confirmed creating — and, only if a fresh chain re-check + // agrees, fold it back in as if the scan had found it. A scan that comes back short after a + // restart or a lagging chain source otherwise reads as "no bonds", and the planner below + // would pay for a second coin over collateral that is still genuinely locked. + let recovered = super::local_bond::recheck_missing_bonds( + &self.effects, + &ledger, + &held, + &on_chain, + ctx.current_epoch, + ); + on_chain.extend(recovered); + + let in_flight = in_flight_creates(&ledger, ctx.current_epoch); // NOT `?`. The balance prices creates and nothing else, so a wallet that cannot report its // $DIG must degrade the create half rather than abort the pass — aborting here would leave a // node unable to advertise AND unable to recover what it has already locked, which is rule 1 @@ -584,30 +645,24 @@ fn canonical(bond: &Bond) -> Bond { /// The bonds whose CURRENT-epoch create is open and unresolved (§25.4.6). /// /// Read from the audit record, which is the in-flight ledger. Only `Pending` and `Submitted` count: -/// a `Confirmed` create has a coin the chain observation already sees, and a `Failed` one did not -/// happen. +/// a `Failed` create did not happen, so it needs no suppression. A `Confirmed` create is excluded +/// for a DIFFERENT reason — it needs no suppression FROM THIS SET, because it is not the thing +/// standing between a short chain observation and a duplicate: that job belongs to +/// [`super::local_bond::recheck_missing_bonds`] (dig-node#574), which re-verifies a confirmed +/// bond's OWN coin directly rather than relying on it having been suppressed here. This set is +/// deliberately narrow — it is the ledger's answer to "is a create still IN FLIGHT", never to "is +/// this bond already covered". /// /// `Unresolved` deserves its name here, because it is the tempting one to include. It means the node /// signed and does not know what happened, so there may well be a coin. It does NOT suppress: a /// suppression that never lifts leaves the bond permanently uncollateralised, whereas the duplicate /// it risks is reclaimed at the next rollover as `EpochEnded`. Both directions cost something; only /// one of them is permanent. -fn in_flight_creates(log: &SpendLog, current_epoch: i64) -> Vec { - let ledger = match log.ledger() { - Ok(ledger) => ledger, - Err(e) => { - // Suppress nothing rather than everything. An unreadable ledger read as "everything is - // in flight" would silently stop the node collateralising anything at all, with no - // surface saying why. - tracing::warn!( - target: "mirror", - error = %e, - "the spend audit record could not be read; no in-flight create is suppressed this pass" - ); - return Vec::new(); - } - }; - +/// +/// Takes the already-folded ledger rather than the log: the caller reads it once per pass and +/// shares it with [`super::local_bond::recheck_missing_bonds`], so a pass never parses the audit +/// file twice for two different questions about the same moment. +fn in_flight_creates(ledger: &crate::spend_audit::SpendLedger, current_epoch: i64) -> Vec { ledger .records .iter() @@ -626,6 +681,7 @@ mod tests { use super::*; use crate::spend_audit::{ kinds, Asset, AuditedBond, Authority, FailureStage, SpendIntent, SpendJournal, SpendKind, + Submission, TargetCoinId, }; use dig_node_control_interface::results::{ CollateralRequirementResult, CollateralUnknownReason, @@ -694,6 +750,10 @@ mod tests { /// the `PassError::Funding` arm of the alert wiring is unreachable from any double, and an /// operator-facing path no test can take reads as covered while never having run. create_funding_failure: Option, + /// What [`MirrorEffects::recheck_bond`] answers, for the dig-node#574 recovery wiring. + /// `None` keeps every existing fixture on the trait's own safe default (`Unverified`) — + /// this double changing behaviour only when a test explicitly asks it to. + recheck_verdict: Option, } impl MirrorEffects for FakeEffects { @@ -748,6 +808,17 @@ mod tests { } Ok(()) } + + fn recheck_bond( + &self, + _store_id: &str, + _root: &str, + _epoch: i64, + _coin_id: &str, + ) -> dig_node_core::mirror_bond::BondVerdict { + self.recheck_verdict + .unwrap_or(dig_node_core::mirror_bond::BondVerdict::Unverified) + } } fn held(bonds: &[Bond]) -> Vec { @@ -1339,6 +1410,7 @@ mod tests { root: id(root), epoch, }), + advertised_urls: Vec::new(), } } @@ -1933,4 +2005,147 @@ mod tests { "a broadcast reclaim has not confirmed, so its collateral is still locked" ); } + + /// **A bond a live scan comes back short on, with a CONFIRMED durable record chain re-verifies, + /// is neither double-created NOR reported missing — through the REAL `PassRunner::run()` + /// pipeline** (dig-node#574). + /// + /// `mirror::local_bond`'s own tests prove `recheck_missing_bonds` in isolation; this proves the + /// WIRING in `run()` — that its result actually reaches `on_chain` before `pass::decide`, and + /// therefore both `execute()`'s create loop and the reported `states`. A wiring bug (the wrong + /// epoch threaded through, the recovered coin dropped on the floor instead of extended into + /// `on_chain`) would pass every `local_bond` test unit-tests and still double-create here. + #[test] + fn a_bond_missing_from_the_scan_with_a_reverified_record_is_recovered_not_recreated() { + let capsule = bond("aa", "11"); + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + + // Seed the audit record as an EARLIER session would have left it: a mirror-coin create for + // this exact bond, CONFIRMED, at an amount distinguishable from this epoch's own + // requirement (`REQUIRED`) — so a recovered figure equal to `REQUIRED` by coincidence cannot + // pass this assertion. + const RECORDED_AMOUNT: u64 = 4_242; + let journal = SpendJournal::with_clock(log.clone(), || 1_000); + let recorded = journal.begin(SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "create a mirror coin".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: RECORDED_AMOUNT, + fee_mojos: 0, + store_id: Some(id("aa")), + bond: Some(AuditedBond { + root: id("11"), + epoch: NOW_EPOCH, + }), + advertised_urls: Vec::new(), + }); + journal.submitted( + &recorded, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![], + }, + ); + journal.confirmed(&recorded, TargetCoinId(id("recorded-coin")), 1); + + // The LIVE scan comes back short: no coin for this bond, exactly the cold-replica / lagging + // chain source symptom the ticket measures. Without recovery, `plan()` would see an + // uncovered held bond and create a second coin. + let effects = FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + chain: Vec::new(), + balance: 10 * REQUIRED, + recheck_verdict: Some(dig_node_core::mirror_bond::BondVerdict::Bonded), + ..FakeEffects::default() + }; + let mut pass = runner(effects, log); + let report = pass.run(&ctx()).expect("the pass observes"); + + assert!( + report.created.is_empty(), + "a coin that already exists and re-verified as bonded must not be created again: \ + {:?}", + report.created + ); + let state = report + .states + .iter() + .find(|(b, _)| *b == capsule) + .map(|(_, s)| s.clone()); + assert_eq!( + state, + Some(BondState::Bonded { + coin_id: id("recorded-coin"), + epoch: NOW_EPOCH, + amount_dig_base_units: RECORDED_AMOUNT, + }), + "the recovered row must report the RECORD's own coin id and amount, not a guess or \ + today's requirement: {state:?}" + ); + } + + /// **The control: the SAME durable record, but chain re-verifies it as `Unbonded`, is NOT + /// recovered — the pass creates a fresh coin exactly as if no record existed.** + /// + /// Without this control, the test above is satisfied identically by an implementation that + /// promotes ANY bond with a local record regardless of what the re-check answers — which is + /// precisely "believed over chain" rather than "in addition to it". + #[test] + fn a_record_whose_coin_chain_disproves_does_not_suppress_the_ordinary_create() { + let capsule = bond("aa", "11"); + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + + let journal = SpendJournal::with_clock(log.clone(), || 1_000); + let recorded = journal.begin(SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "create a mirror coin".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: 4_242, + fee_mojos: 0, + store_id: Some(id("aa")), + bond: Some(AuditedBond { + root: id("11"), + epoch: NOW_EPOCH, + }), + advertised_urls: Vec::new(), + }); + journal.submitted( + &recorded, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![], + }, + ); + journal.confirmed(&recorded, TargetCoinId(id("reclaimed-coin")), 1); + + let effects = FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + chain: Vec::new(), + balance: 10 * REQUIRED, + // The only difference from the test above: chain says this specific coin no longer + // bonds anything — reclaimed, most plausibly, since the pass which recorded it. + recheck_verdict: Some(dig_node_core::mirror_bond::BondVerdict::Unbonded), + ..FakeEffects::default() + }; + let mut pass = runner(effects, log); + let report = pass.run(&ctx()).expect("the pass observes"); + + assert_eq!( + report.created, + vec![capsule], + "a coin chain disproves must not suppress the ordinary create — the collateral it once \ + locked is gone, and this bond genuinely needs a fresh coin: {:?}", + report.created + ); + } } diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs index 94582405..06af15f4 100644 --- a/crates/dig-node-service/src/mirror/spends.rs +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -68,6 +68,9 @@ pub struct MirrorSpends { root_hash: Bytes32, epoch: BigInt, collateral_dig_base_units: u64, + /// The URLs a CREATE advertises this bond as fetchable from. Empty for a reclaim, which + /// advertises nothing (dig-node#574). + advertised_urls: Vec, } impl MirrorSpends { @@ -148,6 +151,7 @@ impl MirrorSpends { root: hex::encode(self.root_hash), epoch, }), + advertised_urls: self.advertised_urls.clone(), } } } @@ -177,6 +181,10 @@ pub fn build_create( fee_coins: Vec, fee: u64, ) -> Result { + // Cloned BEFORE the move below, so the audit record can carry the same URLs the coin was + // actually built to advertise — never a second read of `urls` that could name a different set. + let advertised_urls = urls.clone(); + let spends = dig_mirror_coin::create( MirrorAdvertisement { // The peer this collateral stands behind, and NOT an `Option`. A coin that names @@ -211,6 +219,7 @@ pub fn build_create( root_hash, epoch, collateral_dig_base_units, + advertised_urls, }) } @@ -247,6 +256,8 @@ pub fn build_reclaim( // epoch's requirement. A coin bonded under a previous epoch's amount is reclaimed at that // amount (SPEC.md 25.3). collateral_dig_base_units: mirror.collateral(), + // A reclaim returns collateral; it advertises nothing. + advertised_urls: Vec::new(), }) } @@ -272,6 +283,7 @@ pub(crate) fn empty_for_tests(fee_mojos: u64, owner_puzzle_hash: Bytes32) -> Mir root_hash: Bytes32::default(), epoch: BigInt::from(0), collateral_dig_base_units: 0, + advertised_urls: Vec::new(), } } @@ -301,5 +313,6 @@ pub(crate) fn unsignable_for_tests(owner_puzzle_hash: Bytes32) -> MirrorSpends { root_hash: Bytes32::default(), epoch: BigInt::from(0), collateral_dig_base_units: 0, + advertised_urls: Vec::new(), } } diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index ac7de4a7..16b60048 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -379,6 +379,16 @@ pub struct SpendIntent { /// The `(root, epoch)` half of a bond, for spends that bond one. `None` for every other kind. #[serde(default)] pub bond: Option, + /// The URLs a CREATE advertises this bond as fetchable from. Empty for every other spend, + /// including a reclaim: returning collateral advertises nothing (dig-node#574). + /// + /// Carried structurally, beside `bond`, for the same reason `bond` is: a restart loses nothing + /// this node wrote down about its OWN create, and `dig_ecosystem#3203`'s reset/remint work + /// needs to compare a bond's ORIGINAL advertisement against its current one. Recording it here + /// rather than re-deriving it from `super::advertise::Effective` at read time is what makes + /// that comparison meaningful — the current URL set can change after this coin was created. + #[serde(default)] + pub advertised_urls: Vec, } /// One entry in the audit record: a full snapshot of one spend at one revision. @@ -411,6 +421,11 @@ pub struct SpendRecord { /// a duplicate-free decision from the chain instead. #[serde(default)] pub bond: Option, + /// The URLs a CREATE advertises this bond as fetchable from, carried through from the intent. + /// Empty for every other spend. `#[serde(default)]` so a line written before this field existed + /// still parses, answering "no URL recorded" rather than refusing to read (dig-node#574). + #[serde(default)] + pub advertised_urls: Vec, /// When the node decided to spend (unix ms). pub initiated_ms: u64, /// When this revision was written (unix ms). @@ -965,6 +980,7 @@ impl SpendJournal { fee_mojos: intent.fee_mojos, store_id: intent.store_id, bond: intent.bond, + advertised_urls: intent.advertised_urls, initiated_ms: now, updated_ms: now, status: SpendStatus::Pending, @@ -1241,6 +1257,67 @@ pub fn reconcile( Ok(report) } +/// What this node durably knows it created for one `(store, root, epoch)`, from the audit record +/// alone (dig-node#574). +/// +/// Read from a [`SpendStatus::Confirmed`] entry ONLY. A `Pending`, `Submitted` or `Unresolved` +/// mirror-coin create carries no coin id at all (`crate::mirror::resolve`'s module doc explains +/// why: the created coin's parent is whichever funding input the builder drew, and this node +/// cannot derive it at signing time) — so a `Confirmed` record is the ONLY kind that can name a +/// candidate worth re-checking. This is deliberate, not an oversight: the whole point of +/// [`LocalMirrorBond`] is to hand a caller a coin id to go verify against chain, and a status this +/// ledger cannot yet attach one to has nothing to offer. +pub struct LocalMirrorBond { + /// The coin this node last saw chain confirm for this triple. + pub coin_id: TargetCoinId, + /// What that coin locks, read from the record — which is the coin's OWN amount at the time it + /// was created, never today's requirement (SPEC.md §25.3). + pub amount_dig_base_units: u64, + /// The URLs that create advertised this bond as fetchable from. + pub advertised_urls: Vec, +} + +/// The newest CONFIRMED mirror-coin record naming exactly `(store_id, root, epoch)`, if any. +/// +/// **This is a CANDIDATE, never a verdict.** The caller's job is to ask chain whether the coin +/// this returns is still real and still unspent — this function only answers "what did this node +/// write down" (dig-node#574). +/// +/// `None` covers three different honest cases the caller cannot tell apart and does not need to: +/// no record names this triple, every record naming it predates the bond field +/// (`#[serde(default)]` then answers `None` on `AuditedBond` itself), or every record naming it is +/// still open. All three mean the same thing here — there is no local candidate to offer — and the +/// caller falls back to its existing chain-only behaviour exactly as if this function did not exist. +/// +/// A triple with TWO confirmed coins (legitimate — see `mirror::plan`'s own doc on duplicates) is +/// resolved to whichever this scan visits first: any genuinely confirmed coin for the triple is an +/// equally valid candidate to re-verify, and the caller needs only one. +pub fn confirmed_mirror_bond( + ledger: &SpendLedger, + store_id: &str, + root: &str, + epoch: i64, +) -> Option { + ledger.records.iter().find_map(|record| { + if record.kind.as_str() != kinds::MIRROR_COIN { + return None; + } + let bond = record.bond.as_ref()?; + if record.store_id.as_deref() != Some(store_id) || bond.root != root || bond.epoch != epoch + { + return None; + } + let SpendStatus::Confirmed { coin_id, .. } = &record.status else { + return None; + }; + Some(LocalMirrorBond { + coin_id: coin_id.clone(), + amount_dig_base_units: record.amount_mojos, + advertised_urls: record.advertised_urls.clone(), + }) + }) +} + /// Unix milliseconds now. fn now_ms() -> u64 { std::time::SystemTime::now() @@ -1303,9 +1380,137 @@ mod tests { fee_mojos: 10, store_id: Some("store-a".to_string()), bond: None, + advertised_urls: Vec::new(), } } + /// A mirror-coin CREATE intent, for the [`confirmed_mirror_bond`] fixtures below — distinct + /// from [`intent`], which is not a mirror spend at all (`Asset::Xch`, no `bond`). + fn mirror_create_intent( + store_id: &str, + root: &str, + epoch: i64, + urls: Vec, + ) -> SpendIntent { + SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: format!("create a mirror coin for store {store_id} at root {root}"), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: 1_000, + fee_mojos: 0, + store_id: Some(store_id.to_string()), + bond: Some(AuditedBond { + root: root.to_string(), + epoch, + }), + advertised_urls: urls, + } + } + + const EPOCH: i64 = 105; + + /// **No record names the triple: the honest answer is `None`, not a crash.** + #[test] + fn no_record_at_all_answers_no_local_candidate() { + let ledger = SpendLedger::default(); + assert!(confirmed_mirror_bond(&ledger, "store-a", "root-a", EPOCH).is_none()); + } + + /// **A CONFIRMED record is returned with its OWN coin id, amount and URLs — not a plausible + /// stand-in for them.** + /// + /// The coin id, amount and URL are each given a DISTINCT, recognisable value so a wrong wiring + /// — returning the wrong field, or a hardcoded placeholder — cannot pass by accident. + #[test] + fn a_confirmed_record_names_its_own_coin_amount_and_urls() { + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + let recorded = journal.begin(mirror_create_intent( + "store-a", + "root-a", + EPOCH, + vec!["https://node.example/store-a".to_string()], + )); + journal.submitted( + &recorded, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("funding-coin".to_string())], + }, + ); + journal.confirmed(&recorded, TargetCoinId("the-real-coin-id".to_string()), 42); + + let ledger = log.ledger().expect("ledger"); + let found = confirmed_mirror_bond(&ledger, "store-a", "root-a", EPOCH) + .expect("a confirmed mirror-coin record for this triple exists"); + assert_eq!(found.coin_id.0, "the-real-coin-id"); + assert_eq!(found.amount_dig_base_units, 1_000); + assert_eq!( + found.advertised_urls, + vec!["https://node.example/store-a".to_string()] + ); + } + + /// **An OPEN record — signed but not yet confirmed — is never offered as a candidate.** + /// + /// A create's `intended_coin_id` is always `None` (its output coin's parent is undiscoverable + /// at signing time — see `crate::mirror::resolve`), so a wrong implementation that read + /// `intended_coin_id` instead of gating on `SpendStatus::Confirmed` would silently return + /// `None` here too — for the WRONG reason. This fixture is what tells the two apart: it + /// asserts on the whole ledger reaching `Submitted`, not merely on this function's output, + /// so a future change that starts deriving a coin id for an open create does not silently + /// start offering it as a verified candidate. + #[test] + fn an_open_submitted_record_is_not_offered_as_a_candidate() { + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + let recorded = journal.begin(mirror_create_intent("store-a", "root-a", EPOCH, vec![])); + journal.submitted( + &recorded, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("funding-coin".to_string())], + }, + ); + + let ledger = log.ledger().expect("ledger"); + assert_eq!(ledger.records[0].status, SpendStatus::Submitted); + assert!( + confirmed_mirror_bond(&ledger, "store-a", "root-a", EPOCH).is_none(), + "a spend that has not confirmed must not be handed back as a verified candidate" + ); + } + + /// **A confirmed record for a DIFFERENT epoch does not cover the epoch being asked about.** + /// + /// Proves the match is keyed on all three terms of the triple, not on `(store_id, root)` alone + /// — which a rollover makes a real distinction: the previous epoch's coin is legitimately being + /// reclaimed, and offering it here would tell a caller a stale coin still covers the live epoch. + #[test] + fn a_confirmed_record_for_a_different_epoch_does_not_match() { + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + let recorded = journal.begin(mirror_create_intent("store-a", "root-a", EPOCH - 1, vec![])); + journal.submitted( + &recorded, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![], + }, + ); + journal.confirmed(&recorded, TargetCoinId("last-epochs-coin".to_string()), 1); + + let ledger = log.ledger().expect("ledger"); + assert!( + confirmed_mirror_bond(&ledger, "store-a", "root-a", EPOCH).is_none(), + "a coin confirmed for a PRIOR epoch must not be offered as covering the current one" + ); + } + /// **A pending entry is durable BEFORE the producer can sign.** /// /// The fixture reads the file from INSIDE the signing step rather than after it, because the @@ -1602,6 +1807,7 @@ mod tests { fee_mojos: 0, store_id: store.map(str::to_string), bond: None, + advertised_urls: Vec::new(), initiated_ms, updated_ms: initiated_ms, status: SpendStatus::Pending, diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index 71142010..6f4efc47 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -322,6 +322,7 @@ mod tests { fee_mojos: 10, store_id: store.map(str::to_string), bond: None, + advertised_urls: Vec::new(), } } diff --git a/crates/dig-node-service/tests/mirror_advertised_urls.rs b/crates/dig-node-service/tests/mirror_advertised_urls.rs index 1ceb1fb9..649e17ba 100644 --- a/crates/dig-node-service/tests/mirror_advertised_urls.rs +++ b/crates/dig-node-service/tests/mirror_advertised_urls.rs @@ -580,3 +580,75 @@ fn an_all_rejected_value_refuses_and_spends_nothing() { "the refusal must be reached before any chain read, so no coin is selected or reserved" ); } + +/// **The URLs a create actually advertised reach the DURABLE audit record, not only the coin** +/// (dig-node#574). +/// +/// The prior tests in this file prove the advertisement reaches the broadcast bundle — a fact +/// visible on chain to anyone. This proves it also survives in this node's OWN record of what it +/// did, which is the half a restart depends on: `mirror::observe` re-derives a bond's state from a +/// live chain scan alone, and a scan that comes back short after a restart or a lagging replica has +/// nothing else to fall back on unless this node wrote its own creates down. +/// +/// Asserted on the JOURNAL, read back through `SpendLog::ledger()`, exactly as a restarted process +/// would read it — never on the `SpendIntent`/`MirrorSpends` values in memory, which a caching bug +/// could satisfy while writing nothing to disk. +#[test] +fn the_advertised_urls_reach_the_durable_audit_record() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (signer, address) = operator(dir.path()); + + let mut chain = Chain::default(); + chain.fund(&address, &[PER_COIN], salt(4)); + + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log); + let broadcaster = MockBroadcaster::default(); + let runtime = tokio::runtime::Runtime::new().expect("a tokio runtime"); + + let configured = "https://mirror-c.example/dig, https://[2001:db8::9]/dig"; + let advertised = with_advertise_env(configured, || { + effective_urls_from_env(&a_live_node_with_a_public_address()) + }); + assert_eq!(advertised.state, AdvertiseState::Override); + + let effects = NodeMirrorEffects::new( + Vec::new(), + Ok(PER_COIN), + Ok(HashSet::new()), + advertised, + Some(own_peer_id()), + &chain, + signer.owner_puzzle_hash(), + Some(&signer), + &journal, + Some(&broadcaster), + runtime.handle().clone(), + ); + + effects + .create(&bond(0xE5, 0xF6), EPOCH, PER_COIN) + .expect("a configured advertisement and a funding coin are both present"); + + assert_eq!( + broadcast_bytes(&broadcaster).len(), + 1, + "the create must have reached the mempool, or the record below proves nothing real" + ); + + let ledger = journal + .log() + .ledger() + .expect("the audit file this node just wrote parses"); + assert_eq!(ledger.records.len(), 1, "exactly one spend was made"); + assert_eq!( + ledger.records[0].advertised_urls, + vec![ + "https://mirror-c.example/dig".to_string(), + "https://[2001:db8::9]/dig".to_string(), + ], + "the durable record must carry the SAME URLs this create actually advertised, in the same \ + order — a record naming different URLs, or none, cannot later tell an operator what this \ + bond originally promised (dig_ecosystem#3203)" + ); +} diff --git a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs index 61fd1723..4b476bd5 100644 --- a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs +++ b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs @@ -173,6 +173,7 @@ fn intent() -> SpendIntent { fee_mojos: 0, store_id: Some("store-a".to_string()), bond: None, + advertised_urls: Vec::new(), } } diff --git a/crates/dig-node-service/tests/spend_audit_e2e.rs b/crates/dig-node-service/tests/spend_audit_e2e.rs index 383ce8a1..7096dc63 100644 --- a/crates/dig-node-service/tests/spend_audit_e2e.rs +++ b/crates/dig-node-service/tests/spend_audit_e2e.rs @@ -46,6 +46,7 @@ fn intent(store: &str) -> SpendIntent { fee_mojos: 10, store_id: Some(store.to_string()), bond: None, + advertised_urls: Vec::new(), } } From 08261a493240e81e41414d81fe980468edd41a9d Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:06:01 -0700 Subject: [PATCH 02/12] fix(peer): count accepted relayed circuits in the connected pool (#579) serve_accepted_relay_conn served every accepted relayed circuit (full mTLS auth, full L7 peer RPC) while registering it nowhere, so connected_peers under-reported every relayed inbound peer -- the relay-leg twin of the direct-inbound defect #402/#523 already fixed. adopt_inbound_peer_in_pool now dispatches by TraversalKind: Relayed routes to dig-gossip's already-published adopt_relayed_inbound_handle (v0.32.0, the rev this repo already pins), every other tier keeps the unchanged adopt_direct_inbound_handle path. serve_accepted_relay_conn adopts before serving and releases after, mirroring the direct listener exactly. Refs: https://github.com/DIG-Network/dig_ecosystem/issues/3124 --- crates/dig-node-core/src/peer.rs | 239 ++++++++++++++++++++++++++++--- 1 file changed, 218 insertions(+), 21 deletions(-) diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index f52bc556..3b904ca3 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -2474,12 +2474,15 @@ fn wire_relay_reservation( /// /// Bounded by its own accepted-connection semaphore (mirroring the direct listener, audit #179): a /// relay cannot make us spawn unbounded serve tasks. `relay_addr` (observability only) is recorded as -/// the accepted [`PeerConnection`](dig_nat::PeerConnection)'s remote address. +/// the accepted [`PeerConnection`](dig_nat::PeerConnection)'s remote address. `gossip` is the SAME +/// connected-pool handle the direct listener registers into (**dig_ecosystem#3124**, the relay leg) — +/// `None` on the in-process FFI path, `Some` in production. fn spawn_relay_accept_loop( mut inbound: tokio::sync::mpsc::Receiver, identity: Arc, responder: Arc, relay_addr: Option, + gossip: Option, ) { let mut acceptor = dig_nat::RelayAcceptor::new(identity); if let Some(addr) = relay_addr { @@ -2490,9 +2493,10 @@ fn spawn_relay_accept_loop( while let Some(tunnel) = inbound.recv().await { let acceptor = acceptor.clone(); let responder = responder.clone(); + let gossip = gossip.clone(); let spawned = spawn_with_permit(&conn_permits, async move { match acceptor.accept(tunnel).await { - Ok(conn) => serve_accepted_relay_conn(conn, responder).await, + Ok(conn) => serve_accepted_relay_conn(conn, responder, gossip.as_ref()).await, Err(e) => { tracing::debug!(error = %e, "relayed circuit mTLS accept failed; dropped") } @@ -2507,16 +2511,23 @@ fn spawn_relay_accept_loop( /// Serve one ACCEPTED relayed circuit exactly like a direct inbound connection: build the authenticated /// caller [`dig_dht::Contact`] from the mTLS-verified `peer_id` + relay endpoint (identity comes from -/// the certificate the handshake verified, never the wire body), then serve the muxed session against -/// `responder` via [`serve_peer_session_from`]. Identical downstream handling to a direct inbound (§the -/// accepted [`PeerConnection`](dig_nat::PeerConnection) carries the SAME authentication), so a NAT'd -/// peer reaching us over a relay circuit gets the full L7 peer RPC (availability / range / DHT). +/// the certificate the handshake verified, never the wire body), register it in the connected pool for +/// as long as it is served (**dig_ecosystem#3124**, the relay leg — see [`adopt_inbound_peer_in_pool`]), +/// then serve the muxed session against `responder` via [`serve_peer_session_from`]. Identical +/// downstream handling to a direct inbound (the accepted [`PeerConnection`](dig_nat::PeerConnection) +/// carries the SAME authentication), so a NAT'd peer reaching us over a relay circuit gets the full L7 +/// peer RPC (availability / range / DHT) AND is counted while it does. +/// +/// `gossip` is `None` on the in-process FFI path (no peer network, so nothing to register into) and +/// `Some` in production — mirroring the direct listener's own optional pool handle. async fn serve_accepted_relay_conn( conn: dig_nat::PeerConnection, responder: Arc, + gossip: Option<&dig_gossip::GossipHandle>, ) { let dig_nat::PeerConnection { peer_id, + remote_addr, mut session, .. } = conn; @@ -2525,7 +2536,23 @@ async fn serve_accepted_relay_conn( // never fan the relay address network-wide as a bogus direct-dial target under this peer_id // (DiD-1 / #1532) — the peer stays reachable for response routing on the live session. let caller = Some(crate::dht::relayed_caller_contact(&peer_id)); + + // COUNT the peer for as long as we serve it, over the RELAYED entry point — `remote_addr` is the + // relay's own socket, never a dial target, and `TraversalKind::Relayed` is what makes + // `adopt_inbound_peer_in_pool` route here instead of to the direct entry point. + let adopted = adopt_inbound_peer_in_pool( + gossip, + &peer_id, + remote_addr, + dig_nat::TraversalKind::Relayed, + &session, + ) + .await; + serve_peer_session_from(caller, &mut session, responder).await; + + // The serve loop has returned: stop counting, the same way the direct listener does. + release_inbound_pool_slot(gossip, adopted).await; } /// Bring up the peer network (the fallible body of [`spawn_peer_network`]). @@ -3123,13 +3150,17 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // Leg B responder half (#1532/#1536): drain the introduced relay circuits the reservation surfaces // and serve each — over THIS node's persistent identity — exactly like a direct inbound. Wired only // when the relay is enabled (else `relay_inbound` is `None`). This runs alongside the direct mTLS - // listener below so a NAT'd peer that could only reach us over a relay circuit is now ACCEPTED. + // listener below so a NAT'd peer that could only reach us over a relay circuit is now ACCEPTED — + // and, since dig_ecosystem#3124's relay leg, COUNTED in the same pool the direct listener uses + // (`handle_for_pool` cloned again here: it is still needed below, moved into + // `serve_peer_rpc_listener_with` only after this call returns). if let Some(inbound) = relay_inbound { spawn_relay_accept_loop( inbound, identity.clone(), responder.clone(), relay_socket_addr, + Some(handle_for_pool.clone()), ); } @@ -3596,6 +3627,15 @@ pub async fn serve_peer_rpc_listener_with( /// Register an ACCEPTED inbound peer in the dig-gossip connected pool for as long as this node serves /// it, and stop counting it when the serve loop ends (**dig_ecosystem#3124**). /// +/// Shared by BOTH accepted-inbound tiers this node adopts: a direct mTLS accept +/// (`method = TraversalKind::Direct`, called from [`serve_peer_rpc_listener_with`]) and an accepted +/// relayed circuit (`method = TraversalKind::Relayed`, called from [`serve_accepted_relay_conn`]). +/// `method` decides which dig-gossip entry point is called — see the dispatch below — because each +/// types the pool slot's tier, and the tier drives `via`, the per-tier admission cap, and dialability. +/// Reusing the wrong one for a given tier is the mistake this single call site exists to make +/// impossible: see [`GossipHandle::adopt_direct_inbound_handle`](dig_gossip::GossipHandle::adopt_direct_inbound_handle)'s +/// own doc for the three ways a wrong reuse corrupts a downstream decision. +/// /// # Why this exists /// /// The pool is what every subsystem reads to answer "am I connected", and until this call the node @@ -3606,14 +3646,14 @@ pub async fn serve_peer_rpc_listener_with( /// /// The serve loop below needs `&mut PeerSession` to answer the peer's L7 RPC, and `PeerSession` is not /// `Clone`. Handing the session to the pool would buy the count and stop serving the peer — strictly -/// worse than being uncounted. `adopt_direct_inbound_handle` takes a `ClosedHandle` instead, so +/// worse than being uncounted. The handle-taking entry points take a `ClosedHandle` instead, so /// ownership stays here and the peer is both counted and served. /// -/// Adoption is best-effort by design: it is ACCOUNTING, and every refusal the pool can return (the -/// accepted-direct cap, a ban, a full pool, a peer already holding a dialable slot) is a decision this -/// node made on purpose. None of them is a reason to refuse SERVICE to a peer whose handshake already -/// succeeded, so a refusal is logged and the connection is served uncounted — the behaviour that -/// shipped before this call existed. +/// Adoption is best-effort by design: it is ACCOUNTING, and every refusal the pool can return (a +/// per-tier cap, the aggregate inbound cap, a ban, a full pool, a peer already holding a dialable slot) +/// is a decision this node made on purpose. None of them is a reason to refuse SERVICE to a peer whose +/// handshake already succeeded, so a refusal is logged and the connection is served uncounted — the +/// behaviour that shipped before this call existed. /// /// Returns the `PeerId` to deregister once serving ends, or `None` when nothing was registered. async fn adopt_inbound_peer_in_pool( @@ -3658,15 +3698,27 @@ async fn adopt_inbound_peer_in_pool( // // So NO dig-nat adoption site in this repo supplies a broadcast sink today. This one passes `None` // explicitly; `seams/dig_peer/bootstrap.rs` and `seams/dig_peer/pex.rs` adopt through - // `adopt_nat_connection`, which takes no sink parameter at all; and `serve_accepted_relay_conn` - // adopts nothing. Passing `None` makes dig-gossip report such a peer as unreachable for broadcast, which is true; - // supplying a sink that cannot deliver would make it report a delivery that never happens. + // `adopt_nat_connection`, which takes no sink parameter at all. Passing `None` makes dig-gossip + // report such a peer as unreachable for broadcast, which is true; supplying a sink that cannot + // deliver would make it report a delivery that never happens. let broadcast_sink = None; - match gossip - .adopt_direct_inbound_handle(pool_id, remote, method, observed, broadcast_sink) - .await - { + // Dispatch by the traversal tier the connection actually arrived over. `adopt_direct_inbound_handle` + // itself REFUSES `TraversalKind::Relayed` (it belongs to the relayed entry point), so routing a + // relayed connection there is not a silent mistyping — it fails the adoption outright and the peer + // is served uncounted, which is how a wrong reuse here would have been caught rather than shipped + // mislabelled. Every other tier (today, only `Direct`) takes the direct entry point unchanged. + let adopted = if matches!(method, dig_nat::TraversalKind::Relayed) { + gossip + .adopt_relayed_inbound_handle(pool_id, remote, observed, broadcast_sink) + .await + } else { + gossip + .adopt_direct_inbound_handle(pool_id, remote, method, observed, broadcast_sink) + .await + }; + + match adopted { Ok(peer_id) => Some(InboundPoolSlot { peer_id, superseded, @@ -5768,7 +5820,9 @@ pub(crate) mod tests { session: dig_nat::mux::PeerSession::server(tls), }; let responder: Arc = Arc::new(StubResponder); - serve_accepted_relay_conn(conn, responder).await; + // No pool for this test: it proves the RPC still answers, not pool accounting — that is + // `an_accepted_relayed_peer_is_counted_served_and_released`'s job, below. + serve_accepted_relay_conn(conn, responder, None).await; }); let client_dir = tempfile::tempdir().expect("client cert dir"); @@ -5814,6 +5868,149 @@ pub(crate) mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), server).await; } + /// **dig_ecosystem#3124, relay leg: an ACCEPTED relayed circuit becomes a counted pool member, + /// keeps being served, and is released when it leaves — the same three properties the direct + /// leg proved, over the OTHER accepted-inbound tier.** + /// + /// ## Why `via == "relay"` is the assertion that matters here + /// + /// The nearest wrong fix compiles and adopts successfully: call `adopt_direct_inbound_handle` + /// instead of `adopt_relayed_inbound_handle` (or pass `TraversalKind::Direct` instead of + /// `::Relayed` into `adopt_inbound_peer_in_pool`). That mistake would still raise `peer_count` + /// to 1 — a test asserting only the count would pass against it — but it mistypes the slot's + /// TIER, which is what `via`, the per-tier admission cap, and (per + /// [`GossipHandle::adopt_direct_inbound_handle`](dig_gossip::GossipHandle::adopt_direct_inbound_handle)'s + /// own doc) dialability all derive from. `via == "relay"` is the cheapest observable that + /// distinguishes "adopted correctly" from "adopted, mislabelled". The OTHER nearest-wrong + /// fix — reusing `adopt_inbound_peer_in_pool` unchanged, still hardcoded to the direct entry + /// point — is caught by the count itself: `adopt_direct_inbound_handle` REFUSES + /// `TraversalKind::Relayed` outright, so that mistake adopts NOTHING and `peer_count` stays 0. + #[tokio::test] + async fn an_accepted_relayed_peer_is_counted_served_and_released() { + use std::time::Duration; + install_crypto_provider(); + + let (gossip, _gdir) = fresh_pool_handle("relay-inbound-3124", [13u8; 32]).await; + assert_eq!( + gossip.peer_count().await, + 0, + "the pool starts empty, so any count below is caused by the relayed circuit" + ); + + let server_dir = tempfile::tempdir().expect("server cert dir"); + let server_identity = + load_or_generate_node_cert(server_dir.path(), &node_seed("relay-3124-server")) + .expect("server identity"); + let server_peer_id = server_identity.peer_id(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_cert = server_identity.clone(); + let gossip_in_server = gossip.clone(); + let server = tokio::spawn(async move { + // Exactly the handshake `RelayAcceptor::accept` runs over a real introduced circuit + // (§ the sibling test above) — this test's subject is the pool registration that + // happens AROUND that handshake, not the handshake itself. + let (tcp, peer_addr) = listener.accept().await.unwrap(); + let server_tls = + dig_tls::server_config(&server_cert, dig_nat::BindingPolicy::Opportunistic) + .expect("server config"); + let captured = server_tls.captured_peer_id; + let captured_bls = server_tls.captured_bls; + let acceptor = tokio_rustls::TlsAcceptor::from(server_tls.config); + let tls = acceptor.accept(tcp).await.expect("mtls accept"); + let verified = captured.get().expect("client presented a cert"); + let conn = dig_nat::PeerConnection { + peer_id: verified, + method: dig_nat::TraversalKind::Relayed, + remote_addr: peer_addr, + peer_bls_pub: captured_bls.get(), + session: dig_nat::mux::PeerSession::server(tls), + }; + let responder: Arc = Arc::new(StubResponder); + serve_accepted_relay_conn(conn, responder, Some(&gossip_in_server)).await; + }); + + let client_dir = tempfile::tempdir().expect("client cert dir"); + let client_identity = + load_or_generate_node_cert(client_dir.path(), &node_seed("relay-3124-client")) + .expect("client identity"); + let client_peer_id = client_identity.peer_id(); + let target = dig_nat::PeerTarget::with_addr(server_peer_id, addr, "DIG_MAINNET"); + let config = dig_nat::NatConfig::builder() + .enabled_methods(vec![dig_nat::TraversalKind::Direct]) + .per_method_timeout(Duration::from_secs(5)) + .build(); + let mut conn = dig_nat::connect(&target, &client_identity, &config) + .await + .expect("the peer connects over mTLS"); + + // (a) COUNTED — this was zero for every relayed inbound peer (`serve_accepted_relay_conn` + // adopted nothing at all). + let peers = await_any_peer(&gossip).await; + assert_eq!( + peers.len(), + 1, + "the relayed circuit must appear in the pool: {peers:?}" + ); + assert_eq!( + peers[0]["via"], "relay", + "typed as the RELAYED tier, not mislabelled direct: {peers:?}" + ); + assert_eq!(peers[0]["direction"], "inbound"); + + let pool_id = dig_gossip::PeerId::from(*client_peer_id.as_bytes()); + let detailed = gossip.connected_pool_peers_detailed(); + let peer = detailed + .iter() + .find(|p| p.peer_id == pool_id) + .expect("the relayed peer is in the pool, keyed on the CLIENT's certificate identity"); + assert!( + !peer.is_outbound, + "this node never dialed the peer; it must not be charged outbound diversity" + ); + assert_eq!( + peer.dial_addr, None, + "a relayed circuit's remote is the relay's own socket and must never be offered as a \ + dial target" + ); + + // (b) STILL SERVED — a count-only assertion also passes against the shape that buys the + // count and stops answering the peer (the by-value-adoption trap #71 already fixed once). + let resp = tokio::time::timeout(Duration::from_secs(10), async { + let mut stream = conn.session.open_stream().await.expect("open stream"); + write_framed( + &mut stream, + &json!({"jsonrpc":"2.0","id":9,"method":"dig.getNetworkInfo"}), + ) + .await + .unwrap(); + read_framed(&mut stream).await.unwrap().expect("a frame") + }) + .await + .expect("the counted relayed circuit answered within 10s"); + assert_eq!( + resp["result"]["echo_method"], + json!("dig.getNetworkInfo"), + "adoption must not cost the peer its serve loop" + ); + assert_eq!( + gossip.peer_count().await, + 1, + "serving the peer neither duplicates nor drops its slot" + ); + + // (c) RELEASED — bounded-join the server task FIRST (its last line is the release), so this + // assertion cannot race the release the way a bare read after `drop(conn)` would. + drop(conn); + let _ = tokio::time::timeout(Duration::from_secs(5), server).await; + assert_eq!( + gossip.peer_count().await, + 0, + "the pool must stop counting the peer once its serve loop has ended" + ); + } + #[tokio::test] async fn node_responder_returns_method_not_found_for_management_methods() { // End-to-end over the responder: a peer JSON-RPC frame naming a management/mutation From 1ad1c51a7df8531d9f75ecf58fccc5343ee89774 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:11:09 -0700 Subject: [PATCH 03/12] fix(cli): guard the exit-code namespace shared with diga against collisions (#582) * chore: open lane for #3189 * fix(cli): guard the exit-code namespace shared with diga against collisions dign and diga deliberately share one process exit-code numbering (dig-app's outcome.rs says so in its own doc comment), so a number is free only if it is unoccupied ecosystem-wide. dig-node#407 assigned exit 7 to NODE_UNREACHABLE by checking only this repo's own table, where 7 genuinely was free -- and collided with diga's NOT_CONNECTED. A reviewer caught it by hand; nothing failed automatically. Adds scripts/check-exit-code-collisions.sh: parses both enums' code()/name() match arms straight from their own source -- this repo's ExitCode, and a live fetch of dig-app's outcome.rs at its default branch -- and fails if a number carries two different names, or if either side draws a number from the reserved shell signal range (126, 127, 128+N). Ships with an 18-case hermetic test harness (scripts/tests/check-exit-code-collisions.test.sh) covering the actual #407 collision shape, arm-order independence, arm-count mismatch, the reserved-range boundary from both sides, the live-fetch path itself, and fail-closed behaviour on an empty/missing/unreachable table. Wires a real (unstubbed) invocation into ci.yml's existing "Release-script tests" job so a collision introduced by a future PR, on either side, is a red required check on that PR -- not a note a reviewer has to catch. The fetch retries twice (2s backoff) since this becomes a required, network- dependent check; a fetch failure still fails closed after retrying, never silently passing as "diga has no codes". Updates SPEC.md 8.4 to point at the mechanical guard instead of leaving "re-check both tables" as unenforced prose, and records that the extension's WALLET_WS_ERR.NOT_CONNECTED = -33001 is a separate JSON-RPC error-code space, not a rival of this one. Adds a doc-comment to the existing transcribed collision test pointing future readers at the live script as the authoritative check; the transcription remains as a narrower, hermetic regression pin for the #407 shape specifically. No renumbering: every currently-assigned code is unchanged. Refs #3189 Co-Authored-By: Claude --------- Co-authored-by: Claude --- .github/workflows/ci.yml | 10 + SPEC.md | 24 +- crates/dig-node-service/src/cli.rs | 8 + scripts/check-exit-code-collisions.sh | 161 +++++++++++ .../tests/check-exit-code-collisions.test.sh | 267 ++++++++++++++++++ 5 files changed, 468 insertions(+), 2 deletions(-) create mode 100755 scripts/check-exit-code-collisions.sh create mode 100755 scripts/tests/check-exit-code-collisions.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b53028ab..3a14e129 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,16 @@ jobs: echo "::endgroup::" done + # The hermetic tests above only prove the PARSING/collision logic is correct on synthetic + # fixtures -- they never touch dig-app's real table, so a genuine collision introduced by a + # future PR (on EITHER side) would not be caught by them alone. This step runs the actual + # guard for real: this checkout's own ExitCode table against a live fetch of dig-app's + # current main (dig_ecosystem#3189). A fetch failure fails this step CLOSED -- see the + # script's own header comment for why a network hiccup must never be read as "diga has no + # codes" and silently wave a real collision through. + - name: Exit-code collision guard (dign vs diga, live) + run: bash scripts/check-exit-code-collisions.sh + fmt: name: Rustfmt runs-on: ubuntu-latest diff --git a/SPEC.md b/SPEC.md index c9d17ca8..a148f1fa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2863,8 +2863,28 @@ forcing a yank. The full occupied set, measured, is: | 12 | `NODE_UNREACHABLE` | — | 0–11 were therefore taken before this CLI added a code, and 12 is the first free number. A new -code MUST be drawn from 13 upward and MUST re-check BOTH tables first; 126, 127 and 128+n are -reserved by the shell and MUST NOT be used. +code MUST be drawn from 13 upward; 126, 127 and 128+n are reserved by the shell and MUST NOT be +used. + +**This is enforced mechanically, not by re-reading this table by hand (dig_ecosystem#3189).** +`scripts/check-exit-code-collisions.sh` parses BOTH enums' `code()`/`name()` match arms straight +out of their own source -- this file's `ExitCode`, and a live fetch of dig-app's +`crates/dig-app-core/src/gateway/outcome.rs` `ErrorCode` at its default branch -- and fails if any +number carries two different names on the two sides, or if either side draws a number from the +reserved shell range. `.github/workflows/ci.yml`'s `scripts` job runs it for real (no fixtures, a +genuine network fetch) on every PR, so a collision introduced on EITHER side is a red, required CI +check on the PR that introduces it -- not a note a reviewer has to catch by hand, which is exactly +how `NODE_UNREACHABLE`'s first assignment (7, colliding with `NOT_CONNECTED`) was caught the one +time it happened. `cli.rs`'s `no_exit_code_collides_with_the_dig_app_gateway_numbering` test +remains a second, narrower, hermetic pin of the same property: it transcribes `diga`'s table +rather than fetching it, so it is correct only until that table changes without this copy being +updated too. The live script is the authoritative check; the transcribed test is defense-in-depth +specifically for the #407 shape and costs nothing to keep. + +**A third number space exists and is NOT this one.** The extension's `WALLET_WS_ERR.NOT_CONNECTED += -33001` is a JSON-RPC error code, not a process exit code -- a separate space with its own table +and no shared numbering with `dign`/`diga` at all. It is not a rival of the table above; nothing +here should be read as claiming otherwise. --- diff --git a/crates/dig-node-service/src/cli.rs b/crates/dig-node-service/src/cli.rs index 11507122..672f9a18 100644 --- a/crates/dig-node-service/src/cli.rs +++ b/crates/dig-node-service/src/cli.rs @@ -233,6 +233,14 @@ mod tests { /// The `diga` map is transcribed rather than imported: dig-node MUST NOT take a dependency /// on dig-app (it is the engine, not a consumer of its own client). That makes this fixture /// the drift risk, so it names the file it was read from and the SPEC carries the same table. + /// + /// This is now a SECOND, narrower check, not the only one (dig_ecosystem#3189): + /// `scripts/check-exit-code-collisions.sh` reads BOTH tables live -- this file, and a fetch + /// of dig-app's actual current source -- and gates every PR in CI (SPEC.md §8.4). That check + /// catches a collision the moment either side introduces it; this test only catches one + /// introduced on `dign`'s side, and only stays correct until `diga`'s table changes without + /// this transcription being updated. Kept as fast, hermetic, in-process defense-in-depth for + /// the exact #407 shape, not as the authoritative check. #[test] fn no_exit_code_collides_with_the_dig_app_gateway_numbering() { // Read from modules/apps/dig-app/crates/dig-app-core/src/gateway/outcome.rs. diff --git a/scripts/check-exit-code-collisions.sh b/scripts/check-exit-code-collisions.sh new file mode 100755 index 00000000..97f54179 --- /dev/null +++ b/scripts/check-exit-code-collisions.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# check-exit-code-collisions.sh -- refuse a dign exit code that collides with a diga one (or the +# reverse), and refuse either side drawing a code from the reserved shell signal range. +# +# WHY THIS EXISTS (dig_ecosystem#3189) +# +# `dign` (this repo's ExitCode, crates/dig-node-service/src/cli.rs) and `diga` (dig-app's +# ErrorCode, crates/dig-app-core/src/gateway/outcome.rs) deliberately share ONE numbering -- the +# diga source says so in its own doc comment ("share the engine CLI's numbers so the two command +# lines agree") -- which means a number is free only if it is unoccupied ECOSYSTEM-WIDE. Checking +# only this repo's own table proves nothing about the other one. +# +# dig-node#407 assigned exit 7 to NODE_UNREACHABLE by reading only this file's own table, where 7 +# genuinely was the next free number. It was not free: dig-app's gateway already held 7 = +# NOT_CONNECTED. A reviewer caught it by hand; nothing failed automatically. The identical mistake +# already cost a real yank in a different number space (dig-rpc-protocol's JSON-RPC `-32015`, +# chosen as "the next free code" from its own list, collided with a released +# `METADATA_TOO_LARGE`). Twice is a pattern, and this is the gate instead of the third note. +# +# WHAT IT READS -- THE LIVE SOURCE, NEVER A TRANSCRIPTION +# +# Both tables are parsed straight out of each enum's own code()/name() match arms, at the moment +# this runs. This repo's test suite ALSO carries a hand-transcribed copy of the diga table +# (cli.rs's `no_exit_code_collides_with_the_dig_app_gateway_numbering`) -- correct the day it was +# written, and silently stale the moment diga's table changes without that copy being updated +# too. Reading the live source removes that copy instead of merely re-checking it: dig-node MUST +# NOT take a Cargo dependency on dig-app (cli.rs says so -- it is the engine, not a consumer of its +# own client), so "live" here means a CI-time read of dig-app's published source, not a compiled +# one. +# +# A THIRD NUMBER SPACE EXISTS AND IS DELIBERATELY NOT COVERED HERE: the extension's +# `WALLET_WS_ERR.NOT_CONNECTED = -33001` is a JSON-RPC error code, not a process exit code. It is +# not a rival of this namespace -- it is a separate space with no shared table at all, and nothing +# above should be read as claiming otherwise. +# +# Usage: bash scripts/check-exit-code-collisions.sh [dign-cli-rs] [diga-outcome-rs-url] +# dign-cli-rs default: crates/dig-node-service/src/cli.rs +# diga-outcome-rs-url default: dig-app's outcome.rs, raw, at its default branch +# Env: DIGA_FILE read diga's table from this LOCAL file instead of fetching the URL argument +# (the scripts/tests harness uses this to stay hermetic). +# CURL_BIN use this in place of `curl` for the live fetch (tests use this to stub the +# network, including the one input a live network cannot be asked to produce on +# demand: a read failure). +# Exit: 0 = no collision and no reserved-range violation. +# 1 = a collision, a reserved-range violation, an unreadable table, or a fetch failure -- a +# fetch failure MUST NOT be read as "diga has no codes", which would silently disable +# the whole guard the moment GitHub's raw-content endpoint has a bad day. + +set -uo pipefail + +DIGN_FILE="${1:-crates/dig-node-service/src/cli.rs}" +DIGA_URL="${2:-https://raw.githubusercontent.com/DIG-Network/dig-app/main/crates/dig-app-core/src/gateway/outcome.rs}" +UA="dig-node-ci/1.0 (https://github.com/DIG-Network/dig-node; exit-code collision gate)" + +[ -f "$DIGN_FILE" ] || { echo "::error::$DIGN_FILE not found -- dign's own ExitCode table is unreadable"; exit 1; } + +WORK="" +cleanup() { [ -n "$WORK" ] && rm -rf "$WORK"; } +trap cleanup EXIT + +if [ -n "${DIGA_FILE:-}" ]; then + diga_file="$DIGA_FILE" + [ -f "$diga_file" ] || { echo "::error::\$DIGA_FILE=$diga_file not found"; exit 1; } +else + WORK="$(mktemp -d)" + diga_file="$WORK/outcome.rs" + # --retry: this check becomes a REQUIRED status check, so a single transient GitHub outage + # must not be indistinguishable from a real collision -- two retries buys resilience without + # weakening the fail-closed contract (a fetch that still fails after retrying still exits 1). + if ! "${CURL_BIN:-curl}" -fsS --max-time 30 --retry 2 --retry-delay 2 -A "$UA" "$DIGA_URL" -o "$diga_file" 2>/dev/null; then + echo "::error::could not fetch $DIGA_URL -- refusing to pass on an unreadable diga table (a fetch failure must never be read as \"diga has no codes\")" + exit 1 + fi +fi +[ -s "$diga_file" ] || { echo "::error::diga source ($diga_file) is empty"; exit 1; } + +# extract_table +# +# Prints "NUMBER NAME" pairs read from the enum's own code()/name() match arms, joined by VARIANT +# -- not by position -- so the two functions are never required to list their arms in the same +# order. Fails (return 1, message on stderr) rather than silently returning a partial table when +# either function is unreadable or the two disagree on how many variants they cover: an +# enumeration check can only be as complete as the enumeration it read, and a parser that quietly +# dropped an arm would report a false "no collision" for the code it never saw. +extract_table() { + local file="$1" enum="$2" numbers names n_count m_count joined j_count + + numbers="$(sed -n "/pub const fn code(/,/^ }\$/p" "$file" \ + | grep -oE "${enum}::[A-Za-z0-9_]+[[:space:]]*=>[[:space:]]*[0-9]+" \ + | sed -E "s/${enum}::([A-Za-z0-9_]+)[[:space:]]*=>[[:space:]]*([0-9]+)/\1 \2/" \ + | sort)" + names="$(sed -n "/pub const fn name(/,/^ }\$/p" "$file" \ + | grep -oE "${enum}::[A-Za-z0-9_]+[[:space:]]*=>[[:space:]]*\"[A-Z_]+\"" \ + | sed -E "s/${enum}::([A-Za-z0-9_]+)[[:space:]]*=>[[:space:]]*\"([A-Z_]+)\"/\1 \2/" \ + | sort)" + + n_count="$(printf '%s\n' "$numbers" | grep -c .)" + m_count="$(printf '%s\n' "$names" | grep -c .)" + if [ "$n_count" -eq 0 ] || [ "$m_count" -eq 0 ]; then + echo "::error::found no $enum arms in $file (code(): $n_count, name(): $m_count) -- the parser or the file has drifted" >&2 + return 1 + fi + + joined="$(join <(printf '%s\n' "$numbers") <(printf '%s\n' "$names") | awk '{print $2, $3}')" + j_count="$(printf '%s\n' "$joined" | grep -c .)" + if [ "$n_count" -ne "$m_count" ] || [ "$j_count" -ne "$n_count" ]; then + echo "::error::$enum in $file: code() has $n_count arm(s), name() has $m_count, only $j_count joined by variant -- some variant is missing from one function" >&2 + return 1 + fi + + printf '%s\n' "$joined" +} + +DIGN_TABLE="$(extract_table "$DIGN_FILE" ExitCode)" || exit 1 +DIGA_TABLE="$(extract_table "$diga_file" ErrorCode)" || exit 1 + +echo "dign table ($DIGN_FILE):" +printf '%s\n' "$DIGN_TABLE" | sed 's/^/ /' +echo "diga table ($diga_file):" +printf '%s\n' "$DIGA_TABLE" | sed 's/^/ /' + +rc=0 + +# --- collisions: a number occupied on BOTH sides must carry the SAME name ---------------------- +# The merge is symmetric by construction (numbers from both tables are sorted together and +# compared in one pass), so a collision introduced by EITHER side is caught the same way -- there +# is no separate "dign changed" vs "diga changed" code path to keep in sync. +collisions="$({ printf '%s\n' "$DIGN_TABLE" | awk '{print $1, "dign", $2}'; printf '%s\n' "$DIGA_TABLE" | awk '{print $1, "diga", $2}'; } | sort -k1,1n | awk ' + { + num=$1; side=$2; name=$3 + if (num in seen_name && seen_name[num] != name) { + printf "::error::exit %s is %s in %s and %s in %s -- a shared number must carry the SAME meaning on both command lines, or a caller branching on it is reading two different failures as one\n", num, seen_name[num], seen_side[num], name, side + bad=1 + } + seen_name[num]=name; seen_side[num]=side + } + END { exit bad } +')" +if [ -n "$collisions" ]; then + printf '%s\n' "$collisions" + rc=1 +fi + +# --- reserved shell range: 126, 127, 128+N belong to the shell, never to either binary ---------- +check_reserved() { + local label="$1" table="$2" num rest + while read -r num rest; do + [ -n "$num" ] || continue + if [ "$num" -ge 126 ]; then + echo "::error::$label exit $num falls in the shell-reserved range (126, 127, 128+N) -- these are never available to either binary" + rc=1 + fi + done <<<"$table" +} +check_reserved dign "$DIGN_TABLE" +check_reserved diga "$DIGA_TABLE" + +if [ "$rc" -eq 0 ]; then + echo "OK: no exit-code collision and no reserved-range violation between dign and diga." +fi +exit "$rc" diff --git a/scripts/tests/check-exit-code-collisions.test.sh b/scripts/tests/check-exit-code-collisions.test.sh new file mode 100755 index 00000000..3366e618 --- /dev/null +++ b/scripts/tests/check-exit-code-collisions.test.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# +# Tests for scripts/check-exit-code-collisions.sh -- the guard that refuses a dign exit code +# colliding with a diga one (or the reverse), and refuses either side drawing a code from the +# reserved shell signal range. +# +# The gate reads two things it does not own: its OWN file (a plain path, so these tests write real +# fixture files) and diga's outcome.rs (fetched live in production). The fetch is substituted with +# $DIGA_FILE (a local path, bypassing curl entirely) for every case that only needs to exercise the +# parsing/collision logic, and with a stubbed $CURL_BIN for the two cases that exist specifically to +# prove the real network path is wired correctly: a live fetch that succeeds, and one that fails. +# +# Every fixture uses NUMBERS AND NAMES OUT OF BAND of anything either real enum actually assigns +# (in the 40s-50s, spelled ZEROTH/SOLO/etc.) rather than copies of the real tables. This is +# deliberate: if the $DIGA_FILE/$CURL_BIN seam were ever silently bypassed and a case actually hit +# dig-app's live source, an assertion built on REAL numbers/names could coincidentally still pass +# (dig-app really does have 0=OK, 2=USAGE, ...). An assertion built on fictional data can only pass +# by reading the fixture, so a broken seam fails loudly instead of by coincidence. +# +# Each case is built to fail against the nearest WRONG gate, not merely to pass against the right +# one -- named at the case. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GATE="$HERE/../check-exit-code-collisions.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +failures=0 + +# write_fixture =: ... +# +# Writes a minimal Rust snippet shaped exactly like cli.rs/outcome.rs's own impl block: a code() +# match and a name() match, one arm per given variant, IN THE GIVEN ORDER for both functions. +write_fixture() { + local path="$1" enum="$2" + shift 2 + { + echo "impl $enum {" + echo " pub const fn code(self) -> u8 {" + echo " match self {" + local pair var rest num + for pair in "$@"; do + var="${pair%%=*}"; rest="${pair#*=}"; num="${rest%%:*}" + echo " $enum::$var => $num," + done + echo " }" + echo " }" + echo + echo " pub const fn name(self) -> &'static str {" + echo " match self {" + for pair in "$@"; do + var="${pair%%=*}"; rest="${pair#*=}" + local name="${rest#*:}" + echo " $enum::$var => \"$name\"," + done + echo " }" + echo " }" + echo "}" + } >"$path" + echo "$path" +} + +# Writes a stub `curl` that either `cat`s the given fixture (any arguments) or, when fixture is the +# sentinel path "UNREACHABLE", exits 22 -- the code curl's own `-f` returns on an HTTP error, so the +# gate sees the same failure shape a real network outage would produce. +stub_curl() { + local name="$1" fixture="$2" path="$WORK/curl-$1" + { + echo '#!/usr/bin/env bash' + if [ "$fixture" = "UNREACHABLE" ]; then + echo 'exit 22' + else + # The stub is invoked as: curl -fsS --max-time 30 -A "$UA" "$URL" -o "$OUT". Find the -o + # argument and copy the fixture there, exactly as a real fetch would deposit it. + printf 'out=""\nwhile [ "$#" -gt 0 ]; do if [ "$1" = "-o" ]; then out="$2"; fi; shift; done\ncat %q >"$out"\n' "$fixture" + fi + } >"$path" + chmod +x "$path" + echo "$path" +} + +run_gate() { + local dign="$1" diga_mode="$2" diga_arg="$3" + case "$diga_mode" in + file) DIGA_FILE="$diga_arg" bash "$GATE" "$dign" 2>&1 ;; + curl) CURL_BIN="$diga_arg" bash "$GATE" "$dign" "https://example.invalid/outcome.rs" 2>&1 ;; + *) echo "bad diga_mode $diga_mode" >&2; return 2 ;; + esac +} + +# expect [required-output-substring] +# +# The substring is what keeps a case load-bearing -- an exit code alone cannot say WHICH check +# fired, so a gate missing one check entirely could still satisfy an exit-code-only assertion via +# another. +expect() { + local name="$1" want="$2" dign="$3" diga_mode="$4" diga_arg="$5" needle="${6:-}" + local out status + out="$(run_gate "$dign" "$diga_mode" "$diga_arg")" + status=$? + if [ "$status" -ne "$want" ]; then + printf 'FAIL %s: exit %s, want %s\n%s\n' "$name" "$status" "$want" "$out" + failures=$((failures + 1)) + return + fi + if [ -n "$needle" ] && ! printf '%s' "$out" | grep -qF -- "$needle"; then + printf 'FAIL %s: output missing %q\n%s\n' "$name" "$needle" "$out" + failures=$((failures + 1)) + return + fi + printf 'ok %s\n' "$name" +} + +check() { # check <0-or-1> + if [ "$3" -eq 0 ]; then + printf 'ok %s\n' "$1" + else + printf 'FAIL %s: %s\n' "$1" "$2" + failures=$((failures + 1)) + fi +} + +# --- shared control fixtures ----------------------------------------------------------------- +# dign: one number shared-by-design with diga (Nought=40), two dign-exclusive numbers. +DIGN_OK="$(write_fixture "$WORK/dign-ok.rs" ExitCode Nought=40:NOUGHT Solo=41:SOLO Dozen=53:DOZEN)" +# diga: the SAME shared number under the SAME name, plus two diga-exclusive numbers -- no overlap +# with dign's exclusive numbers (41, 53). +DIGA_OK="$(write_fixture "$WORK/diga-ok.rs" ErrorCode Nought=40:NOUGHT Attached=48:ATTACHED Vexed=49:VEXED)" + +# --- the honest control ------------------------------------------------------------------------ +# Today's real shape (some shared-by-design numbers, some exclusive on each side) passes. Without +# this case, a gate that refused everything unconditionally would satisfy every failing case below. +expect 'today-shaped tables with no real collision pass' \ + 0 "$DIGN_OK" file "$DIGA_OK" 'OK: no exit-code collision' + +# --- a shared-by-design number must NOT be flagged ---------------------------------------------- +# Nought=40 appears in BOTH tables under the SAME name. The nearest wrong gate here is one that +# flags any number appearing twice regardless of name -- which would fail on 0=OK/2=USAGE/6=IO_ERROR +# today and make the guard block every real PR. Covered by the same control case above; asserted +# explicitly here so it cannot be satisfied by chance. +out_ok="$(run_gate "$DIGN_OK" file "$DIGA_OK")" +check 'the shared-by-design number 40 is not reported as a collision' \ + 'output mentions exit 40 as a problem' \ + "$(printf '%s' "$out_ok" | grep -q '::error::.*exit 40' && echo 1 || echo 0)" + +# --- a number exclusive to one side must NOT be flagged ----------------------------------------- +# The nearest wrong gate here requires every number to appear in BOTH tables -- which would fail on +# the real state today, where most numbers are exclusive to one binary. +check 'a dign-exclusive number (41) is not reported as a collision' \ + '::error:: mentioned exit 41' \ + "$(printf '%s' "$out_ok" | grep -q '::error::.*exit 41' && echo 1 || echo 0)" +check 'a diga-exclusive number (48) is not reported as a collision' \ + '::error:: mentioned exit 48' \ + "$(printf '%s' "$out_ok" | grep -q '::error::.*exit 48' && echo 1 || echo 0)" + +# --- the actual defect class: the SAME number, a DIFFERENT name on each side -------------------- +# Reproduces the dig-node#407 shape: dign adds 53 = DOZEN, unaware diga already holds 53 under a +# different name. Only ONE thing changes from the control fixture (diga gains one entry at the +# already-dign-occupied number 53) -- everything else stays the honest control, so the failure is +# attributable to that one change and not to some other difference between the fixtures. +DIGA_COLLIDE="$(write_fixture "$WORK/diga-collide.rs" ErrorCode Nought=40:NOUGHT Attached=48:ATTACHED Vexed=49:VEXED Cardinal=53:CARDINAL)" +collide_out="$(run_gate "$DIGN_OK" file "$DIGA_COLLIDE")" +collide_status=$? +check 'a real collision (53 = DOZEN vs 53 = CARDINAL) is refused' \ + "exit $collide_status, want 1" "$([ "$collide_status" -eq 1 ] && echo 0 || echo 1)" +check 'the message names the colliding number' \ + "$collide_out" "$(printf '%s' "$collide_out" | grep -q '::error::.*exit 53' && echo 0 || echo 1)" +check 'the message names BOTH conflicting names' \ + "$collide_out" "$(printf '%s' "$collide_out" | grep -q 'DOZEN' && printf '%s' "$collide_out" | grep -q 'CARDINAL' && echo 0 || echo 1)" + +# --- variant-order independence ----------------------------------------------------------------- +# code() and name() list their arms in DIFFERENT orders. The nearest wrong gate here is a positional +# zip (pairing the Nth code() arm with the Nth name() arm) instead of a join by variant -- which +# would silently cross-wire Solo's number with Nought's name. Written by hand rather than through +# write_fixture, which always emits both functions in the same order. +DIGN_REORDERED="$WORK/dign-reordered.rs" +cat >"$DIGN_REORDERED" <<'RUST' +impl ExitCode { + pub const fn code(self) -> u8 { + match self { + ExitCode::Solo => 41, + ExitCode::Nought => 40, + } + } + + pub const fn name(self) -> &'static str { + match self { + ExitCode::Nought => "NOUGHT", + ExitCode::Solo => "SOLO", + } + } +} +RUST +reordered_out="$(run_gate "$DIGN_REORDERED" file "$DIGA_OK")" +reordered_status=$? +check 'reordered code()/name() arms still pass (join by variant, not position)' \ + "exit $reordered_status, want 0" "$([ "$reordered_status" -eq 0 ] && echo 0 || echo 1)" +check 'the printed table pairs 41 with SOLO, not with NOUGHT' \ + "$reordered_out" "$(printf '%s' "$reordered_out" | grep -q '41 SOLO' && echo 0 || echo 1)" +check 'the printed table pairs 40 with NOUGHT, not with SOLO' \ + "$reordered_out" "$(printf '%s' "$reordered_out" | grep -q '40 NOUGHT' && echo 0 || echo 1)" + +# --- arm-count mismatch inside one file is refused, not silently under-read -------------------- +# code() lists two arms; name() lists only one (Solo's name arm is missing). A parser that silently +# drops the unmatched arm would report a false "no collision" for a code it never actually saw -- +# the same enumeration-blind-spot risk a gate over an incomplete list always has. +DIGN_MISSING_NAME="$WORK/dign-missing-name.rs" +cat >"$DIGN_MISSING_NAME" <<'RUST' +impl ExitCode { + pub const fn code(self) -> u8 { + match self { + ExitCode::Nought => 40, + ExitCode::Solo => 41, + } + } + + pub const fn name(self) -> &'static str { + match self { + ExitCode::Nought => "NOUGHT", + } + } +} +RUST +expect 'an arm missing from name() is refused, not silently dropped' \ + 1 "$DIGN_MISSING_NAME" file "$DIGA_OK" 'missing from one function' + +# --- the reserved shell range, pinned from BOTH sides ------------------------------------------- +# 125 is one below the first reserved value and MUST pass; 126 is the first reserved value and MUST +# fail. A bound tested only from below (only 125, or only some number well above 126) could not +# distinguish the shipped floor of 126 from an off-by-one at 127. +DIGN_125="$(write_fixture "$WORK/dign-125.rs" ExitCode Nought=40:NOUGHT Reach=125:REACH)" +expect 'exit 125 (one below the reserved floor) passes' \ + 0 "$DIGN_125" file "$DIGA_OK" 'OK: no exit-code collision' + +DIGN_126="$(write_fixture "$WORK/dign-126.rs" ExitCode Nought=40:NOUGHT Over=126:OVER)" +expect 'exit 126 (the reserved floor itself) is refused' \ + 1 "$DIGN_126" file "$DIGA_OK" 'shell-reserved range' + +DIGN_200="$(write_fixture "$WORK/dign-200.rs" ExitCode Nought=40:NOUGHT Signalled=200:SIGNALLED)" +expect 'exit 200 (deep in 128+N) is refused -- the rule is a RANGE, not just 126/127' \ + 1 "$DIGN_200" file "$DIGA_OK" 'shell-reserved range' + +# --- the live-fetch path itself, not just the $DIGA_FILE bypass --------------------------------- +# Every case above skips curl entirely via $DIGA_FILE. These two exercise the REAL default code +# path (the -o argument parsing, the URL being passed through) so a bug in the fetch wiring itself +# -- not just the parsing logic -- cannot hide behind the test seam. +expect 'a live fetch that succeeds is read and checked like any other source' \ + 0 "$DIGN_OK" curl "$(stub_curl ok "$DIGA_OK")" 'OK: no exit-code collision' + +expect 'a live fetch that fails closes the gate rather than passing vacuously' \ + 1 "$DIGN_OK" curl "$(stub_curl down UNREACHABLE)" 'could not fetch' + +# --- fail-closed on bad local inputs -------------------------------------------------------------- +EMPTY_DIGA="$WORK/empty-diga.rs" +: >"$EMPTY_DIGA" +expect 'an empty diga fixture is refused, not read as "diga has no codes"' \ + 1 "$DIGN_OK" file "$EMPTY_DIGA" 'empty' + +expect 'a missing dign file is refused' \ + 1 "$WORK/no-such-file.rs" file "$DIGA_OK" 'not found' + +if [ "$failures" -ne 0 ]; then + printf '\n%s case(s) failed\n' "$failures" + exit 1 +fi +printf '\nall cases passed\n' From 194a01634f6384819c44c7580677958fc5288af9 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:12:51 -0700 Subject: [PATCH 04/12] fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings (#3190) (#583) * chore: open lane for #3190 * fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings Replicates dig-node-service::continuation_guard (dig-node#526/#501) into dig-node-core, dig-wallet, dig-runtime and dig-chat-protocol, line-for-line apart from crate-specific constants -- ported rather than reinvented, per dig_ecosystem#3190. Wiring the guard in surfaced 48 pre-existing lost-continuation defects the ticket's own "no measured corruption in these four crates" note did not anticipate: 36 in dig-node-core, 12 in dig-wallet, mostly test-assertion prose where a multi-line message lost its `\` continuation and shipped the source's own indentation as a mid-sentence space run (one as the worse `\n`-plus-indentation variant). All 48 are collapsed to the single space the sentence always meant, with surrounding indentation and wording otherwise untouched. Two lines are real column-alignment, not defects, and get a targeted EXCLUDED_LINE_RANGES entry on dig-node-core instead of a rewrite: download.rs's `claimed(...)` fixture-table trailing comments, and net.rs's `label : value` debug-print alignment. Refs https://github.com/DIG-Network/dig_ecosystem/issues/3190 Refs https://github.com/DIG-Network/dig_ecosystem/issues/3130 Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../src/continuation_guard.rs | 199 +++++++++++++++++ crates/dig-chat-protocol/src/lib.rs | 3 + .../dig-node-core/src/continuation_guard.rs | 209 ++++++++++++++++++ crates/dig-node-core/src/download.rs | 2 +- .../dig-node-core/src/forwarded_ask_tests.rs | 14 +- crates/dig-node-core/src/lib.rs | 10 +- .../src/seams/dig_peer/admission.rs | 4 +- .../src/seams/dig_peer/conduct.rs | 2 +- .../src/seams/dig_peer/forwarded_ask.rs | 10 +- .../src/seams/dig_peer/module_anchor.rs | 2 +- .../src/seams/dig_peer/module_reshare.rs | 14 +- .../src/seams/dig_peer/module_transport.rs | 12 +- .../src/seams/dig_peer/store_melted.rs | 2 +- .../src/seams/dig_peer/union_locator.rs | 2 +- crates/dig-runtime/src/continuation_guard.rs | 199 +++++++++++++++++ crates/dig-runtime/src/lib.rs | 2 + crates/dig-wallet/src/continuation_guard.rs | 202 +++++++++++++++++ crates/dig-wallet/src/lib.rs | 2 + crates/dig-wallet/src/sage/chain.rs | 4 +- .../src/sage/corroborated_source.rs | 2 +- crates/dig-wallet/src/sage/fallback.rs | 2 +- crates/dig-wallet/src/sage/rpc.rs | 4 +- .../src/sage/sync_supervisor/tests.rs | 12 +- 23 files changed, 866 insertions(+), 48 deletions(-) create mode 100644 crates/dig-chat-protocol/src/continuation_guard.rs create mode 100644 crates/dig-node-core/src/continuation_guard.rs create mode 100644 crates/dig-runtime/src/continuation_guard.rs create mode 100644 crates/dig-wallet/src/continuation_guard.rs diff --git a/crates/dig-chat-protocol/src/continuation_guard.rs b/crates/dig-chat-protocol/src/continuation_guard.rs new file mode 100644 index 00000000..36442104 --- /dev/null +++ b/crates/dig-chat-protocol/src/continuation_guard.rs @@ -0,0 +1,199 @@ +//! Test-only guard against the "lost string continuation" defect class (dig_ecosystem#3190). +//! +//! A Rust string literal continued with a trailing `\` renders correctly. When that +//! backslash is lost -- `cargo fmt` rejoining a wrapped literal, or a mechanical regex +//! repair -- the literal keeps the SOURCE's leading indentation, so the emitted text +//! carries a 14-22 space run in the middle of a sentence. It compiles, every other test +//! stays green, and the mangled and correct forms are indistinguishable in a normal +//! diff. The only witness is a person reading the emitted text -- so this scanner reads +//! it instead, on every build. +//! +//! Ported from `dig-node-service::continuation_guard` (dig-node#526/#501), which this +//! module mirrors line-for-line apart from the crate-specific constants below -- this +//! repo's own established idiom, not a rival shape imported from elsewhere. This is one +//! of the smallest crates in the workspace (three source files, ahead of only +//! `dig-runtime`'s one); the floor below is sized for that rather than copied from a +//! larger sibling. +#![cfg(test)] + +use std::path::Path; + +/// No directory under `src` is exempt from the crate-wide scan. +const EXCLUDED_DIRS: &[&str] = &[]; + +/// No fixed-width fixture in this crate is known to need a line-range exemption yet. +const EXCLUDED_LINE_RANGES: &[(&str, u32, u32)] = &[]; + +/// No CLI banner builder lives in this crate. +const CLI_COLUMN_FILES: &[&str] = &[]; + +/// A lost continuation always leaves 14-22 spaces (the source's own indentation); +/// ordinary column-alignment padding measured in the sibling crate never exceeds 8. Ten +/// leaves margin on both sides: comfortably above every legitimate pad, comfortably +/// below the smallest real defect. +const MIN_DEFECT_RUN: usize = 10; + +/// One offending run found by the scan. +struct Offense { + file: String, + line: u32, + fragment: String, +} + +fn is_excluded_line(file_name: &str, line_no: u32) -> bool { + EXCLUDED_LINE_RANGES + .iter() + .any(|(f, start, end)| *f == file_name && line_no >= *start && line_no <= *end) +} + +fn is_excluded_dir(rel_path: &Path) -> bool { + rel_path + .components() + .any(|c| EXCLUDED_DIRS.contains(&c.as_os_str().to_string_lossy().as_ref())) +} + +/// Walks every `.rs` file under `src`, returning `(files_scanned, offenses)`. +fn scan_source_tree() -> (usize, Vec) { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files_scanned = 0usize; + let mut offenses = Vec::new(); + + let mut stack = vec![src_root.clone()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(&src_root).unwrap_or(&path); + if path.is_dir() { + if is_excluded_dir(rel) { + continue; + } + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + if is_excluded_dir(rel) { + continue; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + files_scanned += 1; + + for (idx, raw_line) in contents.lines().enumerate() { + let line_no = (idx + 1) as u32; + if is_excluded_line(&file_name, line_no) { + continue; + } + + // Leading indentation is source layout, not literal content -- ignore it. + let trimmed_start = raw_line.trim_start(); + if trimmed_start.is_empty() { + continue; + } + + // A comment line is never scanned, structurally -- rewording a comment + // (including this guard's own prose) must never dodge the check by + // reformatting it as non-comment text; it stays excluded because it + // starts with `//`, not because of what it says. + if trimmed_start.starts_with("//") { + continue; + } + + // Control characters (excluding the line's own trailing newline, which + // `.lines()` already stripped) are always a defect signature. + if let Some(pos) = trimmed_start.char_indices().find(|(_, c)| c.is_control()) { + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: format!("", pos.0), + }); + continue; + } + + // A line in one of the CLI banner files that carries a literal `\n` + // escape anywhere is deliberate column layout end to end -- see + // CLI_COLUMN_FILES above. + if CLI_COLUMN_FILES.contains(&file_name.as_str()) && trimmed_start.contains(r"\n") { + continue; + } + + // Find every run of 2+ spaces; only a run at or above MIN_DEFECT_RUN is + // a candidate, and only once it clears the trailing-comment check below. + let bytes = trimmed_start.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b' ' { + i += 1; + continue; + } + let run_start = i; + while i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + let run_len = i - run_start; + if run_len < MIN_DEFECT_RUN { + continue; + } + + // A run immediately followed by `//` is aligning a TRAILING + // COMMENT to a fixed column -- structurally never inside a string + // literal's body. + if trimmed_start[i..].starts_with("//") { + continue; + } + + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: trimmed_start.to_string(), + }); + break; + } + } + } + } + + (files_scanned, offenses) +} + +/// This crate has three source files at the time of writing; a scan that reads zero (a +/// wrong `CARGO_MANIFEST_DIR`, a moved `src/`, a walk that silently matched nothing) is +/// a broken guard, not a passing one, and must FAIL rather than vacuously succeed. +const MIN_FILES_SCANNED: usize = 2; + +#[test] +fn no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence() { + let (files_scanned, offenses) = scan_source_tree(); + + assert!( + files_scanned > MIN_FILES_SCANNED, + "scanned {files_scanned} files, expected more than {MIN_FILES_SCANNED} -- a guard \ + that reads zero (or too few) files is not scanning the crate, and a scan that \ + reads nothing must fail rather than pass vacuously" + ); + + if !offenses.is_empty() { + let report: Vec = offenses + .iter() + .map(|o| format!(" {}:{} -> {:?}", o.file, o.line, o.fragment)) + .collect(); + panic!( + "found {} site(s) with a lost string continuation (a run of {}+ spaces mid-line, \ + outside a comment/fixture/CLI-column exemption):\n{}", + offenses.len(), + MIN_DEFECT_RUN, + report.join("\n") + ); + } +} diff --git a/crates/dig-chat-protocol/src/lib.rs b/crates/dig-chat-protocol/src/lib.rs index 9dea6c99..d9e1e590 100644 --- a/crates/dig-chat-protocol/src/lib.rs +++ b/crates/dig-chat-protocol/src/lib.rs @@ -53,3 +53,6 @@ pub use types::{ ChatEnumError, ChatMessage, DeliveryReceipt, DeliveryStatus, Presence, PresenceState, ReadReceipt, TypingIndicator, TypingState, }; + +#[cfg(test)] +mod continuation_guard; diff --git a/crates/dig-node-core/src/continuation_guard.rs b/crates/dig-node-core/src/continuation_guard.rs new file mode 100644 index 00000000..405cae50 --- /dev/null +++ b/crates/dig-node-core/src/continuation_guard.rs @@ -0,0 +1,209 @@ +//! Test-only guard against the "lost string continuation" defect class (dig_ecosystem#3190). +//! +//! A Rust string literal continued with a trailing `\` renders correctly. When that +//! backslash is lost -- `cargo fmt` rejoining a wrapped literal, or a mechanical regex +//! repair -- the literal keeps the SOURCE's leading indentation, so the emitted text +//! carries a 14-22 space run in the middle of a sentence. It compiles, every other test +//! stays green, and the mangled and correct forms are indistinguishable in a normal +//! diff. The only witness is a person reading the emitted text -- so this scanner reads +//! it instead, on every build. +//! +//! Ported from `dig-node-service::continuation_guard` (dig-node#526/#501), which this +//! module mirrors line-for-line apart from the crate-specific constants below -- this +//! repo's own established idiom, not a rival shape imported from elsewhere. +#![cfg(test)] + +use std::path::Path; + +/// No directory under `src` is exempt from the crate-wide scan. +const EXCLUDED_DIRS: &[&str] = &[]; + +/// Two deliberate alignments the crate-wide scan cannot tell from a lost continuation, +/// because the run they create is real content, not string-literal indentation: +/// - `download.rs`'s `claimed(...)` fixture table column-aligns three trailing +/// comments (`// a coin bonding something else -> Unbonded`); the run before `//` +/// is exempt structurally, but the run BETWEEN the comment's own words (before its +/// `->`) is not, so the whole match-table line needs the exemption. +/// - `net.rs`'s two `println!` debug lines column-align their `label : value` +/// prefixes so the colons line up in terminal output -- the same idiom +/// `dig-node-service`'s `CLI_COLUMN_FILES` exempts, but expressed as plain padding +/// with no literal `\n` for that exemption to key off. +const EXCLUDED_LINE_RANGES: &[(&str, u32, u32)] = + &[("download.rs", 4595, 4595), ("net.rs", 2259, 2259)]; + +/// No CLI banner builder lives in this crate (it is the node engine library, not the +/// binary) -- so no file needs the `\n`-plus-alignment exemption `dig-node-service` +/// carries for its summary printers. +const CLI_COLUMN_FILES: &[&str] = &[]; + +/// A lost continuation always leaves 14-22 spaces (the source's own indentation); +/// ordinary column-alignment padding measured in the sibling crate never exceeds 8. Ten +/// leaves margin on both sides: comfortably above every legitimate pad, comfortably +/// below the smallest real defect. +const MIN_DEFECT_RUN: usize = 10; + +/// One offending run found by the scan. +struct Offense { + file: String, + line: u32, + fragment: String, +} + +fn is_excluded_line(file_name: &str, line_no: u32) -> bool { + EXCLUDED_LINE_RANGES + .iter() + .any(|(f, start, end)| *f == file_name && line_no >= *start && line_no <= *end) +} + +fn is_excluded_dir(rel_path: &Path) -> bool { + rel_path + .components() + .any(|c| EXCLUDED_DIRS.contains(&c.as_os_str().to_string_lossy().as_ref())) +} + +/// Walks every `.rs` file under `src`, returning `(files_scanned, offenses)`. +fn scan_source_tree() -> (usize, Vec) { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files_scanned = 0usize; + let mut offenses = Vec::new(); + + let mut stack = vec![src_root.clone()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(&src_root).unwrap_or(&path); + if path.is_dir() { + if is_excluded_dir(rel) { + continue; + } + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + if is_excluded_dir(rel) { + continue; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + files_scanned += 1; + + for (idx, raw_line) in contents.lines().enumerate() { + let line_no = (idx + 1) as u32; + if is_excluded_line(&file_name, line_no) { + continue; + } + + // Leading indentation is source layout, not literal content -- ignore it. + let trimmed_start = raw_line.trim_start(); + if trimmed_start.is_empty() { + continue; + } + + // A comment line is never scanned, structurally -- rewording a comment + // (including this guard's own prose) must never dodge the check by + // reformatting it as non-comment text; it stays excluded because it + // starts with `//`, not because of what it says. + if trimmed_start.starts_with("//") { + continue; + } + + // Control characters (excluding the line's own trailing newline, which + // `.lines()` already stripped) are always a defect signature. + if let Some(pos) = trimmed_start.char_indices().find(|(_, c)| c.is_control()) { + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: format!("", pos.0), + }); + continue; + } + + // A line in one of the CLI banner files that carries a literal `\n` + // escape anywhere is deliberate column layout end to end -- see + // CLI_COLUMN_FILES above. + if CLI_COLUMN_FILES.contains(&file_name.as_str()) && trimmed_start.contains(r"\n") { + continue; + } + + // Find every run of 2+ spaces; only a run at or above MIN_DEFECT_RUN is + // a candidate, and only once it clears the trailing-comment check below. + let bytes = trimmed_start.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b' ' { + i += 1; + continue; + } + let run_start = i; + while i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + let run_len = i - run_start; + if run_len < MIN_DEFECT_RUN { + continue; + } + + // A run immediately followed by `//` is aligning a TRAILING + // COMMENT to a fixed column -- structurally never inside a string + // literal's body. + if trimmed_start[i..].starts_with("//") { + continue; + } + + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: trimmed_start.to_string(), + }); + break; + } + } + } + } + + (files_scanned, offenses) +} + +/// This crate has dozens of source files (77 at the time of writing); a scan that reads +/// zero (a wrong `CARGO_MANIFEST_DIR`, a moved `src/`, a walk that silently matched +/// nothing) is a broken guard, not a passing one, and must FAIL rather than vacuously +/// succeed. +const MIN_FILES_SCANNED: usize = 50; + +#[test] +fn no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence() { + let (files_scanned, offenses) = scan_source_tree(); + + assert!( + files_scanned > MIN_FILES_SCANNED, + "scanned {files_scanned} files, expected more than {MIN_FILES_SCANNED} -- a guard \ + that reads zero (or too few) files is not scanning the crate, and a scan that \ + reads nothing must fail rather than pass vacuously" + ); + + if !offenses.is_empty() { + let report: Vec = offenses + .iter() + .map(|o| format!(" {}:{} -> {:?}", o.file, o.line, o.fragment)) + .collect(); + panic!( + "found {} site(s) with a lost string continuation (a run of {}+ spaces mid-line, \ + outside a comment/fixture/CLI-column exemption):\n{}", + offenses.len(), + MIN_DEFECT_RUN, + report.join("\n") + ); + } +} diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index f8fce505..4e13a34c 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -5463,7 +5463,7 @@ pub(crate) mod tests { assert_eq!( key_a.len(), 136, - "fixture guard: a real module checkpoint key is 136 bytes — a shorter key cannot exhibit the NAME_MAX overflow this test exists to catch" + "fixture guard: a real module checkpoint key is 136 bytes — a shorter key cannot exhibit the NAME_MAX overflow this test exists to catch" ); for (key, total) in [(&key_a, 11u64), (&key_b, 22u64)] { diff --git a/crates/dig-node-core/src/forwarded_ask_tests.rs b/crates/dig-node-core/src/forwarded_ask_tests.rs index cde09f05..f0a6f9f1 100644 --- a/crates/dig-node-core/src/forwarded_ask_tests.rs +++ b/crates/dig-node-core/src/forwarded_ask_tests.rs @@ -1092,7 +1092,7 @@ fn an_inconclusive_outcome_answers_with_its_own_wire_code_and_a_not_found_stays_ assert_eq!( inconclusive["error"]["code"], serde_json::json!(crate::download::content_miss_inconclusive()), - "a caller must be able to tell 'unanswered' from 'not found': the first is worth retrying and the second is not" + "a caller must be able to tell 'unanswered' from 'not found': the first is worth retrying and the second is not" ); assert!( @@ -1250,7 +1250,7 @@ fn budget_ms_keeps_absent_distinct_from_zero_and_from_a_granted_value() { assert_ne!( unbudgeted.time_budget(0, 3), exhausted.time_budget(0, 3), - "absent and exhausted MUST NOT be the same allowance; collapsing them lets a spent budget silently buy a fresh one at every hop" + "absent and exhausted MUST NOT be the same allowance; collapsing them lets a spent budget silently buy a fresh one at every hop" ); } @@ -1285,11 +1285,11 @@ async fn an_exhausted_budget_asks_nobody_and_does_not_claim_the_absence() { assert_eq!( ask.asked().len(), 0, - "a hop granted zero time must not ask onward - relaying on time it was never given is the amplification the budget exists to bound" + "a hop granted zero time must not ask onward - relaying on time it was never given is the amplification the budget exists to bound" ); assert!( !located.establishes_absence(), - "and having asked nobody, it has established nothing: reporting a proven absence here turns one exhausted hop into an authoritative not-found for every reader below it" + "and having asked nobody, it has established nothing: reporting a proven absence here turns one exhausted hop into an authoritative not-found for every reader below it" ); // CONTROL: the same node, the same everything, a budget that is merely SMALL rather than spent. @@ -1305,7 +1305,7 @@ async fn an_exhausted_budget_asks_nobody_and_does_not_claim_the_absence() { assert_eq!( control_ask.asked().len(), 1, - "the node DOES forward when granted time, so the exhausted arm above measured a decision and not a node that simply never asks" + "the node DOES forward when granted time, so the exhausted arm above measured a decision and not a node that simply never asks" ); } @@ -1667,7 +1667,7 @@ async fn a_failed_dht_walk_stays_unproven_through_the_production_locator_chain() .await; assert!( !errored.establishes_absence(), - "the union and the capsule fallback swallowed the walk failure into Ok(vec![]), so the node claimed a proven absence for content it never managed to look for" + "the union and the capsule fallback swallowed the walk failure into Ok(vec![]), so the node claimed a proven absence for content it never managed to look for" ); let honest = crate::download::NodeContent::provider_locator_chain( @@ -1680,7 +1680,7 @@ async fn a_failed_dht_walk_stays_unproven_through_the_production_locator_chain() .await; assert!( negative.establishes_absence(), - "a chain whose every source completed and found nobody STILL establishes the absence - without this the fix above is satisfied by never concluding anything" + "a chain whose every source completed and found nobody STILL establishes the absence - without this the fix above is satisfied by never concluding anything" ); assert!( errored.is_empty() && negative.is_empty(), diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index b9387159..c7ef0e10 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -5263,6 +5263,8 @@ pub(crate) mod test_support { } } +#[cfg(test)] +mod continuation_guard; #[cfg(test)] mod tests { use super::*; @@ -6942,11 +6944,11 @@ mod tests { let modules_budget = cache_budget().modules; assert!( modules_budget > CAPSULE, - "the capsule must fit the modules share ALONE, or it would be evicted whether or not the stray bytes were counted" + "the capsule must fit the modules share ALONE, or it would be evicted whether or not the stray bytes were counted" ); assert!( modules_budget.saturating_sub(STRAY) < CAPSULE, - "charging the stray bytes must push the capsule OVER the share, or counting them changes nothing observable" + "charging the stray bytes must push the capsule OVER the share, or counting them changes nothing observable" ); let store = "9a".repeat(32); @@ -11429,7 +11431,7 @@ mod tests { ); assert!( unsearched.get("absence_established").is_none(), - "a node that consulted nothing must make NO claim - an inserted `false` would tell the caller a search ran and came back incomplete, which never happened" + "a node that consulted nothing must make NO claim - an inserted `false` would tell the caller a search ran and came back incomplete, which never happened" ); // A node that DID search, conclusively: the claim is present and positive. @@ -16346,7 +16348,7 @@ mod tests { )); assert_eq!( started, expect_warm, - "{origin:?}: expected warm-started == {expect_warm} for this origin (Peer must effect nothing; the Local control must prove a warm is observable)" + "{origin:?}: expected warm-started == {expect_warm} for this origin (Peer must effect nothing; the Local control must prove a warm is observable)" ); } std::env::remove_var("DIG_NODE_PIN"); diff --git a/crates/dig-node-core/src/seams/dig_peer/admission.rs b/crates/dig-node-core/src/seams/dig_peer/admission.rs index 2d18c508..631f0ac0 100644 --- a/crates/dig-node-core/src/seams/dig_peer/admission.rs +++ b/crates/dig-node-core/src/seams/dig_peer/admission.rs @@ -459,11 +459,11 @@ mod tests { assert_eq!( node_limits().max_request_units, crate::MAX_AVAILABILITY_ITEMS as u32, - "the admission clamp must equal the advertised batch limit, or the node refuses batches it says it serves" + "the admission clamp must equal the advertised batch limit, or the node refuses batches it says it serves" ); assert!( node_limits().max_request_units > AdmissionLimits::default().max_request_units, - "the crate default is the smaller of the two — if this ever stops holding, the override is doing nothing and the comment above it is false" + "the crate default is the smaller of the two — if this ever stops holding, the override is doing nothing and the comment above it is false" ); } diff --git a/crates/dig-node-core/src/seams/dig_peer/conduct.rs b/crates/dig-node-core/src/seams/dig_peer/conduct.rs index c5c579cc..b5292889 100644 --- a/crates/dig-node-core/src/seams/dig_peer/conduct.rs +++ b/crates/dig-node-core/src/seams/dig_peer/conduct.rs @@ -295,7 +295,7 @@ mod tests { ); assert!( dialable.contains(&slow), - "unverifiable distress must NEVER remove a peer from the dial set, however sustained — otherwise loading an honest holder is enough to evict it" + "unverifiable distress must NEVER remove a peer from the dial set, however sustained — otherwise loading an honest holder is enough to evict it" ); assert!( dialable.contains(&quiet), diff --git a/crates/dig-node-core/src/seams/dig_peer/forwarded_ask.rs b/crates/dig-node-core/src/seams/dig_peer/forwarded_ask.rs index 1c9122c6..cea12446 100644 --- a/crates/dig-node-core/src/seams/dig_peer/forwarded_ask.rs +++ b/crates/dig-node-core/src/seams/dig_peer/forwarded_ask.rs @@ -723,7 +723,7 @@ mod tests { assert_eq!( request["params"]["ask_id"], json!(hex::encode([9u8; 16])), - "the caller's ask_id rides the request as hex; minting a new one per hop, or dropping the field, silently disables the dedup the id exists for" + "the caller's ask_id rides the request as hex; minting a new one per hop, or dropping the field, silently disables the dedup the id exists for" ); } @@ -1050,7 +1050,7 @@ mod tests { && miss_answer(None) .pointer("/result/items/0/absence_established") .is_none(), - "fixture precondition: the three frames must differ in absence_established, and the absent one must OMIT the key rather than carry a null" + "fixture precondition: the three frames must differ in absence_established, and the absent one must OMIT the key rather than carry a null" ); assert_eq!( @@ -1061,7 +1061,7 @@ mod tests { assert_eq!( subtree_claim(&miss_answer(None)), SubtreeClaim::NoClaim, - "a peer that says NOTHING about its search has not said the search succeeded; reading its silence as an establishment is the unwrap_or(true) the taxonomy owner names as wrong" + "a peer that says NOTHING about its search has not said the search succeeded; reading its silence as an establishment is the unwrap_or(true) the taxonomy owner names as wrong" ); assert_eq!( subtree_claim(&miss_answer(Some(false))), @@ -1123,14 +1123,14 @@ mod tests { assert_eq!( subtree_claim(&empty), SubtreeClaim::NoClaim, - "an absence must be ESTABLISHED by an item that carries it; folding [] to the identity hands a responder absence_established for free" + "an absence must be ESTABLISHED by an item that carries it; folding [] to the identity hands a responder absence_established for free" ); assert_eq!( subtree_claim(&json!({"jsonrpc":"2.0","id":1,"result":{"items":[ json!({"available": false, "absence_established": true}) ]}})), SubtreeClaim::Established, - "the control: a real established item still establishes, so the guard above narrowed the empty case and nothing else" + "the control: a real established item still establishes, so the guard above narrowed the empty case and nothing else" ); assert!( !parsed(&empty).is_conclusive(), diff --git a/crates/dig-node-core/src/seams/dig_peer/module_anchor.rs b/crates/dig-node-core/src/seams/dig_peer/module_anchor.rs index d2439d89..2e334b0c 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_anchor.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_anchor.rs @@ -867,7 +867,7 @@ mod tests { assert_eq!( verdict(&module, &hex32(STORE), &hex32(CHAIN_ROOT)), ModuleAnchor::NotAnchored, - "a module committing the peer's root, pulled at the chain root, is evidence against the holder — the arm matters, because only NotAnchored earns a demotion" + "a module committing the peer's root, pulled at the chain root, is evidence against the holder — the arm matters, because only NotAnchored earns a demotion" ); } diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index a8b9022a..aebc7808 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -1430,7 +1430,7 @@ mod tests { }; assert!( reason.contains(REFUSAL), - "the refusal the holder produced must survive into the warm's own outcome, not be discarded by a `let...else`; got: {reason}" + "the refusal the holder produced must survive into the warm's own outcome, not be discarded by a `let...else`; got: {reason}" ); let _ = std::fs::remove_dir_all(&dir); @@ -1994,7 +1994,7 @@ mod tests { let outcome = failed.warm(&store_hex, &root_hex).await; assert!( matches!(outcome, WarmOutcome::Refused(_)), - "the control: this pull must genuinely FAIL, or the report below is the success path in disguise: {outcome:?}" + "the control: this pull must genuinely FAIL, or the report below is the success path in disguise: {outcome:?}" ); assert_eq!( failed_spy @@ -2003,7 +2003,7 @@ mod tests { .expect("lifecycle lock") .as_slice(), &[(store_hex.clone(), root_hex.clone())], - "a FAILED pull must still report its end, naming the capsule — it is the pull most likely to have spent relay budget on hops that delivered nothing" + "a FAILED pull must still report its end, naming the capsule — it is the pull most likely to have spent relay budget on hops that delivered nothing" ); // SUCCEEDING pull: the same report, so the seam is not failure-only either. @@ -2126,12 +2126,12 @@ mod tests { assert!( outcome.is_err(), - "the control: the pull must genuinely PANIC and be caught, or the report below is just the ordinary return path" + "the control: the pull must genuinely PANIC and be caught, or the report below is just the ordinary return path" ); assert_eq!( spy.finished.lock().expect("lifecycle lock").as_slice(), &[(store_hex.clone(), root_hex.clone())], - "an unwinding pull must still report its end — a statement after the await is skipped by the unwind, and the tier-0 catch_unwind then continues with the entry leaked" + "an unwinding pull must still report its end — a statement after the await is skipped by the unwind, and the tier-0 catch_unwind then continues with the entry leaked" ); let _ = std::fs::remove_dir_all(&dir); @@ -2603,14 +2603,14 @@ mod tests { assert!( registry.claim("local:mine".into()).is_none(), - "this node's OWN warm was refused a slot because relays hold them all — the two share one cap, which is the cost of running the relay leg" + "this node's OWN warm was refused a slot because relays hold them all — the two share one cap, which is the cost of running the relay leg" ); // A finished relay hands the slot back; nothing about the claim is relay-specific. drop(relayed.into_iter().next().expect("at least one claim")); assert!( registry.claim("local:mine".into()).is_some(), - "the control: the local warm must succeed once a relay slot frees, or the refusal above proves only that the registry was broken" + "the control: the local warm must succeed once a relay slot frees, or the refusal above proves only that the registry was broken" ); } diff --git a/crates/dig-node-core/src/seams/dig_peer/module_transport.rs b/crates/dig-node-core/src/seams/dig_peer/module_transport.rs index 53ca646d..f5d63938 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_transport.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_transport.rs @@ -1113,7 +1113,7 @@ mod descriptor_ask { ); assert!( started.elapsed() > ladder, - "the fixture must outlast the {ladder:?} descriptor ladder or it proves nothing -- elapsed {:?}", + "the fixture must outlast the {ladder:?} descriptor ladder or it proves nothing -- elapsed {:?}", started.elapsed() ); assert!( @@ -1144,7 +1144,7 @@ mod descriptor_ask { ); assert!( started.elapsed() < RELAY_STALL_WINDOW + RELAY_POLL_INTERVAL * 2, - "a frozen hop must be dropped at the stall window, not held to the ceiling -- elapsed {:?}", + "a frozen hop must be dropped at the stall window, not held to the ceiling -- elapsed {:?}", started.elapsed() ); } @@ -1217,7 +1217,7 @@ mod descriptor_ask { ); assert!( started.elapsed() < nearly_spent + RELAY_POLL_INTERVAL * 2, - "the wait must end at the budget it was GIVEN, not at the per-hop maximum -- elapsed {:?} against a budget of {nearly_spent:?}", + "the wait must end at the budget it was GIVEN, not at the per-hop maximum -- elapsed {:?} against a budget of {nearly_spent:?}", started.elapsed() ); assert!( @@ -1808,7 +1808,7 @@ mod tests { assert_eq!( locator.calls.load(std::sync::atomic::Ordering::SeqCst), 2, - "a refused plain round must be followed by an ESCALATED round inside the same call; one round means the call site is no longer driving the escalation" + "a refused plain round must be followed by an ESCALATED round inside the same call; one round means the call site is no longer driving the escalation" ); } @@ -1842,7 +1842,7 @@ mod tests { assert_eq!( budget.remaining(&store, &id_of(0x77)), full, - "the control: another capsule must be unaffected, or the ledger is global rather than per pull" + "the control: another capsule must be unaffected, or the ledger is global rather than per pull" ); // Overrun saturates instead of wrapping into a fresh allowance. @@ -1889,7 +1889,7 @@ mod tests { assert_eq!( budget.remaining(&store, &root), full, - "pull two must start with the WHOLE budget; a ledger keyed to the capsule for the daemon's lifetime would still read zero here and would refuse the relay path forever, while naming a peer as the cause" + "pull two must start with the WHOLE budget; a ledger keyed to the capsule for the daemon's lifetime would still read zero here and would refuse the relay path forever, while naming a peer as the cause" ); } diff --git a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs index cabefae0..da599caf 100644 --- a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs +++ b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs @@ -1121,7 +1121,7 @@ mod tests { assert_eq!( sent[0], (store(4), MeltPath::Local), - "the melting holder ORIGINATES its announcement, so it takes the dedup-exempt path \n (#3061) and has no sender to exclude" + "the melting holder ORIGINATES its announcement, so it takes the dedup-exempt path (#3061) and has no sender to exclude" ); } diff --git a/crates/dig-node-core/src/seams/dig_peer/union_locator.rs b/crates/dig-node-core/src/seams/dig_peer/union_locator.rs index 0909fec1..1c8cc9c0 100644 --- a/crates/dig-node-core/src/seams/dig_peer/union_locator.rs +++ b/crates/dig-node-core/src/seams/dig_peer/union_locator.rs @@ -285,7 +285,7 @@ mod tests { let union = UnionLocator::new(vec![Arc::new(FailingSource), Arc::new(EmptyLocator)]); assert!( union.find_providers(&cid).await.is_err(), - "a union whose source failed and which found nobody must report the failure; Ok(vec![]) here is what a caller reads as a proven absence" + "a union whose source failed and which found nobody must report the failure; Ok(vec![]) here is what a caller reads as a proven absence" ); // ARM 2 - a healthy leg found a holder: the failure is immaterial, the holder survives. diff --git a/crates/dig-runtime/src/continuation_guard.rs b/crates/dig-runtime/src/continuation_guard.rs new file mode 100644 index 00000000..eab7608e --- /dev/null +++ b/crates/dig-runtime/src/continuation_guard.rs @@ -0,0 +1,199 @@ +//! Test-only guard against the "lost string continuation" defect class (dig_ecosystem#3190). +//! +//! A Rust string literal continued with a trailing `\` renders correctly. When that +//! backslash is lost -- `cargo fmt` rejoining a wrapped literal, or a mechanical regex +//! repair -- the literal keeps the SOURCE's leading indentation, so the emitted text +//! carries a 14-22 space run in the middle of a sentence. It compiles, every other test +//! stays green, and the mangled and correct forms are indistinguishable in a normal +//! diff. The only witness is a person reading the emitted text -- so this scanner reads +//! it instead, on every build. +//! +//! Ported from `dig-node-service::continuation_guard` (dig-node#526/#501), which this +//! module mirrors line-for-line apart from the crate-specific constants below -- this +//! repo's own established idiom, not a rival shape imported from elsewhere. This crate +//! is a single-file cdylib bridging the DIG Browser's `dig_rpc`/`dig_wallet_rpc` C-ABI; +//! the floor below is sized for that (one file, not dozens). +#![cfg(test)] + +use std::path::Path; + +/// No directory under `src` is exempt from the crate-wide scan. +const EXCLUDED_DIRS: &[&str] = &[]; + +/// No fixed-width fixture in this crate is known to need a line-range exemption yet. +const EXCLUDED_LINE_RANGES: &[(&str, u32, u32)] = &[]; + +/// No CLI banner builder lives in this crate. +const CLI_COLUMN_FILES: &[&str] = &[]; + +/// A lost continuation always leaves 14-22 spaces (the source's own indentation); +/// ordinary column-alignment padding measured in the sibling crate never exceeds 8. Ten +/// leaves margin on both sides: comfortably above every legitimate pad, comfortably +/// below the smallest real defect. +const MIN_DEFECT_RUN: usize = 10; + +/// One offending run found by the scan. +struct Offense { + file: String, + line: u32, + fragment: String, +} + +fn is_excluded_line(file_name: &str, line_no: u32) -> bool { + EXCLUDED_LINE_RANGES + .iter() + .any(|(f, start, end)| *f == file_name && line_no >= *start && line_no <= *end) +} + +fn is_excluded_dir(rel_path: &Path) -> bool { + rel_path + .components() + .any(|c| EXCLUDED_DIRS.contains(&c.as_os_str().to_string_lossy().as_ref())) +} + +/// Walks every `.rs` file under `src`, returning `(files_scanned, offenses)`. +fn scan_source_tree() -> (usize, Vec) { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files_scanned = 0usize; + let mut offenses = Vec::new(); + + let mut stack = vec![src_root.clone()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(&src_root).unwrap_or(&path); + if path.is_dir() { + if is_excluded_dir(rel) { + continue; + } + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + if is_excluded_dir(rel) { + continue; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + files_scanned += 1; + + for (idx, raw_line) in contents.lines().enumerate() { + let line_no = (idx + 1) as u32; + if is_excluded_line(&file_name, line_no) { + continue; + } + + // Leading indentation is source layout, not literal content -- ignore it. + let trimmed_start = raw_line.trim_start(); + if trimmed_start.is_empty() { + continue; + } + + // A comment line is never scanned, structurally -- rewording a comment + // (including this guard's own prose) must never dodge the check by + // reformatting it as non-comment text; it stays excluded because it + // starts with `//`, not because of what it says. + if trimmed_start.starts_with("//") { + continue; + } + + // Control characters (excluding the line's own trailing newline, which + // `.lines()` already stripped) are always a defect signature. + if let Some(pos) = trimmed_start.char_indices().find(|(_, c)| c.is_control()) { + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: format!("", pos.0), + }); + continue; + } + + // A line in one of the CLI banner files that carries a literal `\n` + // escape anywhere is deliberate column layout end to end -- see + // CLI_COLUMN_FILES above. + if CLI_COLUMN_FILES.contains(&file_name.as_str()) && trimmed_start.contains(r"\n") { + continue; + } + + // Find every run of 2+ spaces; only a run at or above MIN_DEFECT_RUN is + // a candidate, and only once it clears the trailing-comment check below. + let bytes = trimmed_start.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b' ' { + i += 1; + continue; + } + let run_start = i; + while i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + let run_len = i - run_start; + if run_len < MIN_DEFECT_RUN { + continue; + } + + // A run immediately followed by `//` is aligning a TRAILING + // COMMENT to a fixed column -- structurally never inside a string + // literal's body. + if trimmed_start[i..].starts_with("//") { + continue; + } + + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: trimmed_start.to_string(), + }); + break; + } + } + } + } + + (files_scanned, offenses) +} + +/// This crate is a single `src/lib.rs`; a scan that reads zero files (a wrong +/// `CARGO_MANIFEST_DIR`, a moved `src/`, a walk that silently matched nothing) is a +/// broken guard, not a passing one, and must FAIL rather than vacuously succeed. The +/// floor is sized to this crate's real shape rather than copied from a larger sibling. +const MIN_FILES_SCANNED: usize = 0; + +#[test] +fn no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence() { + let (files_scanned, offenses) = scan_source_tree(); + + assert!( + files_scanned > MIN_FILES_SCANNED, + "scanned {files_scanned} files, expected more than {MIN_FILES_SCANNED} -- a guard \ + that reads zero files is not scanning the crate, and a scan that reads nothing \ + must fail rather than pass vacuously" + ); + + if !offenses.is_empty() { + let report: Vec = offenses + .iter() + .map(|o| format!(" {}:{} -> {:?}", o.file, o.line, o.fragment)) + .collect(); + panic!( + "found {} site(s) with a lost string continuation (a run of {}+ spaces mid-line, \ + outside a comment/fixture/CLI-column exemption):\n{}", + offenses.len(), + MIN_DEFECT_RUN, + report.join("\n") + ); + } +} diff --git a/crates/dig-runtime/src/lib.rs b/crates/dig-runtime/src/lib.rs index f6ceb60c..dcbc0cb4 100644 --- a/crates/dig-runtime/src/lib.rs +++ b/crates/dig-runtime/src/lib.rs @@ -612,6 +612,8 @@ pub unsafe extern "C" fn dig_bytes_free(ptr: *mut u8, len: usize) { drop(unsafe { Box::from_raw(ptr::slice_from_raw_parts_mut(ptr, len)) }); } +#[cfg(test)] +mod continuation_guard; #[cfg(test)] mod tests { use super::*; diff --git a/crates/dig-wallet/src/continuation_guard.rs b/crates/dig-wallet/src/continuation_guard.rs new file mode 100644 index 00000000..20ff2e2e --- /dev/null +++ b/crates/dig-wallet/src/continuation_guard.rs @@ -0,0 +1,202 @@ +//! Test-only guard against the "lost string continuation" defect class (dig_ecosystem#3190). +//! +//! A Rust string literal continued with a trailing `\` renders correctly. When that +//! backslash is lost -- `cargo fmt` rejoining a wrapped literal, or a mechanical regex +//! repair -- the literal keeps the SOURCE's leading indentation, so the emitted text +//! carries a 14-22 space run in the middle of a sentence. It compiles, every other test +//! stays green, and the mangled and correct forms are indistinguishable in a normal +//! diff. The only witness is a person reading the emitted text -- so this scanner reads +//! it instead, on every build. +//! +//! Ported from `dig-node-service::continuation_guard` (dig-node#526/#501), which this +//! module mirrors line-for-line apart from the crate-specific constants below -- this +//! repo's own established idiom, not a rival shape imported from elsewhere. This crate +//! ships its OWN `dig-wallet` CLI binary (`src/main.rs`) beside its library surface, so +//! it is not merely prophylactic library-doc coverage -- it is a second, independent +//! user-facing CLI surface in this repo that nothing scanned until now. +#![cfg(test)] + +use std::path::Path; + +/// No directory under `src` is exempt from the crate-wide scan. +const EXCLUDED_DIRS: &[&str] = &[]; + +/// No fixed-width fixture in this crate is known to need a line-range exemption yet. +const EXCLUDED_LINE_RANGES: &[(&str, u32, u32)] = &[]; + +/// No CLI banner builder using deliberate `\n`-plus-column alignment has been found in +/// this crate yet. Add a file here if a future summary/status printer needs it, the +/// same way `dig-node-service` exempts its own banner builders. +const CLI_COLUMN_FILES: &[&str] = &[]; + +/// A lost continuation always leaves 14-22 spaces (the source's own indentation); +/// ordinary column-alignment padding measured in the sibling crate never exceeds 8. Ten +/// leaves margin on both sides: comfortably above every legitimate pad, comfortably +/// below the smallest real defect. +const MIN_DEFECT_RUN: usize = 10; + +/// One offending run found by the scan. +struct Offense { + file: String, + line: u32, + fragment: String, +} + +fn is_excluded_line(file_name: &str, line_no: u32) -> bool { + EXCLUDED_LINE_RANGES + .iter() + .any(|(f, start, end)| *f == file_name && line_no >= *start && line_no <= *end) +} + +fn is_excluded_dir(rel_path: &Path) -> bool { + rel_path + .components() + .any(|c| EXCLUDED_DIRS.contains(&c.as_os_str().to_string_lossy().as_ref())) +} + +/// Walks every `.rs` file under `src`, returning `(files_scanned, offenses)`. +fn scan_source_tree() -> (usize, Vec) { + let src_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files_scanned = 0usize; + let mut offenses = Vec::new(); + + let mut stack = vec![src_root.clone()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(&src_root).unwrap_or(&path); + if path.is_dir() { + if is_excluded_dir(rel) { + continue; + } + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + if is_excluded_dir(rel) { + continue; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + files_scanned += 1; + + for (idx, raw_line) in contents.lines().enumerate() { + let line_no = (idx + 1) as u32; + if is_excluded_line(&file_name, line_no) { + continue; + } + + // Leading indentation is source layout, not literal content -- ignore it. + let trimmed_start = raw_line.trim_start(); + if trimmed_start.is_empty() { + continue; + } + + // A comment line is never scanned, structurally -- rewording a comment + // (including this guard's own prose) must never dodge the check by + // reformatting it as non-comment text; it stays excluded because it + // starts with `//`, not because of what it says. + if trimmed_start.starts_with("//") { + continue; + } + + // Control characters (excluding the line's own trailing newline, which + // `.lines()` already stripped) are always a defect signature. + if let Some(pos) = trimmed_start.char_indices().find(|(_, c)| c.is_control()) { + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: format!("", pos.0), + }); + continue; + } + + // A line in one of the CLI banner files that carries a literal `\n` + // escape anywhere is deliberate column layout end to end -- see + // CLI_COLUMN_FILES above. + if CLI_COLUMN_FILES.contains(&file_name.as_str()) && trimmed_start.contains(r"\n") { + continue; + } + + // Find every run of 2+ spaces; only a run at or above MIN_DEFECT_RUN is + // a candidate, and only once it clears the trailing-comment check below. + let bytes = trimmed_start.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b' ' { + i += 1; + continue; + } + let run_start = i; + while i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + let run_len = i - run_start; + if run_len < MIN_DEFECT_RUN { + continue; + } + + // A run immediately followed by `//` is aligning a TRAILING + // COMMENT to a fixed column -- structurally never inside a string + // literal's body. + if trimmed_start[i..].starts_with("//") { + continue; + } + + offenses.push(Offense { + file: file_name.clone(), + line: line_no, + fragment: trimmed_start.to_string(), + }); + break; + } + } + } + } + + (files_scanned, offenses) +} + +/// This crate has dozens of source files (42 at the time of writing); a scan that reads +/// zero (a wrong `CARGO_MANIFEST_DIR`, a moved `src/`, a walk that silently matched +/// nothing) is a broken guard, not a passing one, and must FAIL rather than vacuously +/// succeed. +const MIN_FILES_SCANNED: usize = 30; + +#[test] +fn no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence() { + let (files_scanned, offenses) = scan_source_tree(); + + assert!( + files_scanned > MIN_FILES_SCANNED, + "scanned {files_scanned} files, expected more than {MIN_FILES_SCANNED} -- a guard \ + that reads zero (or too few) files is not scanning the crate, and a scan that \ + reads nothing must fail rather than pass vacuously" + ); + + if !offenses.is_empty() { + let report: Vec = offenses + .iter() + .map(|o| format!(" {}:{} -> {:?}", o.file, o.line, o.fragment)) + .collect(); + panic!( + "found {} site(s) with a lost string continuation (a run of {}+ spaces mid-line, \ + outside a comment/fixture/CLI-column exemption):\n{}", + offenses.len(), + MIN_DEFECT_RUN, + report.join("\n") + ); + } +} diff --git a/crates/dig-wallet/src/lib.rs b/crates/dig-wallet/src/lib.rs index 959c5f18..e723038d 100644 --- a/crates/dig-wallet/src/lib.rs +++ b/crates/dig-wallet/src/lib.rs @@ -1180,6 +1180,8 @@ const SETTINGS_HTML: &str = include_str!("settings.html"); /// `/wc-bundle.js`. const WC_BUNDLE_JS: &str = include_str!("wc-bundle.js"); +#[cfg(test)] +mod continuation_guard; #[cfg(test)] mod tests { use super::*; diff --git a/crates/dig-wallet/src/sage/chain.rs b/crates/dig-wallet/src/sage/chain.rs index 14d728b7..a7c07959 100644 --- a/crates/dig-wallet/src/sage/chain.rs +++ b/crates/dig-wallet/src/sage/chain.rs @@ -1033,7 +1033,7 @@ mod tests { ); assert!( super::refusal_is_bundle_intrinsic(&format!("FAILED: {name}")), - "{name} is on the canonical list, but this crate did not recognise it once composed as `FAILED: {name}` — the verdict split has stopped stripping the verdict, and a spend no node will ever admit now holds its inputs for the full TTL" + "{name} is on the canonical list, but this crate did not recognise it once composed as `FAILED: {name}` — the verdict split has stopped stripping the verdict, and a spend no node will ever admit now holds its inputs for the full TTL" ); } @@ -1091,7 +1091,7 @@ mod tests { let stated = super::ChainTransport::stated_rejection(&intrinsic).expect("a stated reason"); assert!( super::refusal_is_bundle_intrinsic(&stated), - "a bundle no node will admit is being held for the full TTL; the translation changed the reason's spelling and every exact match silently stopped matching" + "a bundle no node will admit is being held for the full TTL; the translation changed the reason's spelling and every exact match silently stopped matching" ); // Status 2 = PENDING, the node declining to admit without saying why. diff --git a/crates/dig-wallet/src/sage/corroborated_source.rs b/crates/dig-wallet/src/sage/corroborated_source.rs index 375ea4c3..ec080b5f 100644 --- a/crates/dig-wallet/src/sage/corroborated_source.rs +++ b/crates/dig-wallet/src/sage/corroborated_source.rs @@ -495,7 +495,7 @@ mod tests { assert!( matches!(outcome, Err(ChainSourceError::Transport(_))), - "no peer spoke, so this node does not know where the chain is; reporting that as Ok(None) tells a caller the source HAS no peak, which is a fact it may act on (got {outcome:?})" + "no peer spoke, so this node does not know where the chain is; reporting that as Ok(None) tells a caller the source HAS no peak, which is a fact it may act on (got {outcome:?})" ); } diff --git a/crates/dig-wallet/src/sage/fallback.rs b/crates/dig-wallet/src/sage/fallback.rs index 3486e18c..7c716a1d 100644 --- a/crates/dig-wallet/src/sage/fallback.rs +++ b/crates/dig-wallet/src/sage/fallback.rs @@ -1454,7 +1454,7 @@ mod chain_failure_tests { ); assert!( !matches!(result, Ok(ref v) if v.is_empty()), - "an undatable answer must NEVER be reported as a childless parent — that terminates a lineage walk on a branch the caller never actually read" + "an undatable answer must NEVER be reported as a childless parent — that terminates a lineage walk on a branch the caller never actually read" ); } diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 75b3144d..ace178b7 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -11158,7 +11158,7 @@ mod tests { fixed, Err(super::super::db::ResetRefusal::SpendInFlight { .. }) ), - "the disciplined clock must still see the 60s hold as live seconds after it was taken, jumped wall clock notwithstanding — got {fixed:?}" + "the disciplined clock must still see the 60s hold as live seconds after it was taken, jumped wall clock notwithstanding — got {fixed:?}" ); // THE DEFECT this ticket closes: had `wallet_reset_coin_db` instead fed the raw, jumped @@ -11169,7 +11169,7 @@ mod tests { let undisciplined = be.db.reset_chain_cache(jumped_wall_ms).await.unwrap(); assert!( undisciplined.is_ok(), - "sanity: an undisciplined jumped reading DOES bypass the refusal, which is exactly why the control-plane call site must never use one" + "sanity: an undisciplined jumped reading DOES bypass the refusal, which is exactly why the control-plane call site must never use one" ); } /// A bundle spending exactly the coin `spendable_row(id_byte, amount)` describes, in the hex diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index bc26b1e8..234b0abe 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -2850,7 +2850,7 @@ async fn a_stall_and_its_recovery_are_both_named_in_the_log() { ); assert!( log.contains("observed_behind_for_secs"), - "the stall must say how long it was OBSERVED behind — and say only that, because the freeze may predate the first observation by any amount: {log}" + "the stall must say how long it was OBSERVED behind — and say only that, because the freeze may predate the first observation by any amount: {log}" ); assert!( !log.contains("stalled_for_secs"), @@ -3969,11 +3969,11 @@ async fn a_shutdown_is_honoured_while_a_catch_up_is_running() { fn the_catch_up_deadline_is_a_duration_a_human_waits() { assert!( CATCH_UP_DEADLINE <= Duration::from_secs(4 * 60 * 60), - "a deadline longer than an afternoon cannot end a parked catch-up in any useful sense: {CATCH_UP_DEADLINE:?}" + "a deadline longer than an afternoon cannot end a parked catch-up in any useful sense: {CATCH_UP_DEADLINE:?}" ); assert!( CATCH_UP_DEADLINE >= Duration::from_secs(5 * 60), - "measured catch-ups take tens of milliseconds, but a deadline under five minutes leaves no room for a slow first sync — and aborting one restarts it from genesis: {CATCH_UP_DEADLINE:?}" + "measured catch-ups take tens of milliseconds, but a deadline under five minutes leaves no room for a slow first sync — and aborting one restarts it from genesis: {CATCH_UP_DEADLINE:?}" ); } @@ -4024,12 +4024,12 @@ async fn the_ceiling_derives_from_the_quorum_height_and_the_injected_lifetime() assert_eq!( ceiling.anchor(), SETTLED_HEIGHT, - "the ceiling must be anchored on the height the quorum settled, which the writer cannot inflate — not on anything the writer said" + "the ceiling must be anchored on the height the quorum settled, which the writer cannot inflate — not on anything the writer said" ); assert_eq!( ceiling.limit(), SETTLED_HEIGHT + sync::peak_allowance(lifetime), - "the allowance must be derived from the lifetime this supervisor runs sessions for; a hardcoded one silently becomes too tight when that value moves UP" + "the allowance must be derived from the lifetime this supervisor runs sessions for; a hardcoded one silently becomes too tight when that value moves UP" ); assert!( !ceiling.admits(SETTLED_HEIGHT + 1_000_000), @@ -4879,7 +4879,7 @@ async fn a_frame_on_a_live_session_attributes_through_the_update_loop() { assert_eq!( h.script.catch_up_count(), 1, - "isolation: the row must be attributed while the FIRST session is still live. A second catch-up means the post-catch-up pass could explain the result, and this test would no longer be about `run_update_loop`'s attributor at all" + "isolation: the row must be attributed while the FIRST session is still live. A second catch-up means the post-catch-up pass could explain the result, and this test would no longer be about `run_update_loop`'s attributor at all" ); assert_eq!( From 93fb452814abd81b1b75cfc812e74ce0b51a5fa6 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:44:29 -0700 Subject: [PATCH 05/12] feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL Automatic half (D1-D4) of the daily mirror-URL reconcile: derived personal-day offset, two-observation hysteresis, nine ordered gates with K sized as a self-funding prefix before any reclaim, `submitted` never `completed`, audit lines gain `reclaim_reason` + `trigger`. Gates at b4c09866: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e1 (tree byte-identical, `git diff 175304e1 b4c09866` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately). Refs DIG-Network/dig-node#570 Refs DIG-Network/dig_ecosystem#3203 --- SPEC.md | 311 +++++++- crates/dig-node-core/src/lib.rs | 16 + crates/dig-node-core/src/peer.rs | 25 +- crates/dig-node-service/src/collateral.rs | 19 + crates/dig-node-service/src/control.rs | 2 + .../src/mirror/converge_tests.rs | 3 + crates/dig-node-service/src/mirror/funding.rs | 2 + .../dig-node-service/src/mirror/lifecycle.rs | 28 +- .../dig-node-service/src/mirror/local_bond.rs | 2 + crates/dig-node-service/src/mirror/mod.rs | 3 + crates/dig-node-service/src/mirror/observe.rs | 3 + crates/dig-node-service/src/mirror/pass.rs | 159 +++- crates/dig-node-service/src/mirror/plan.rs | 67 +- .../dig-node-service/src/mirror/reconcile.rs | 720 ++++++++++++++++++ .../src/mirror/reconcile_state.rs | 296 +++++++ .../src/mirror/resolve_tests.rs | 2 + crates/dig-node-service/src/mirror/runner.rs | 159 +++- .../dig-node-service/src/mirror/schedule.rs | 344 +++++++++ crates/dig-node-service/src/mirror/spends.rs | 34 + crates/dig-node-service/src/server.rs | 125 +++ crates/dig-node-service/src/spend_audit.rs | 25 + .../dig-node-service/src/spend_audit_cli.rs | 2 + .../tests/mirror_fee_ceiling.rs | 12 +- .../mirror_funding_reservation_expiry.rs | 2 + .../tests/mirror_l1_genesis.rs | 15 +- .../dig-node-service/tests/spend_audit_e2e.rs | 2 + 26 files changed, 2335 insertions(+), 43 deletions(-) create mode 100644 crates/dig-node-service/src/mirror/reconcile.rs create mode 100644 crates/dig-node-service/src/mirror/reconcile_state.rs create mode 100644 crates/dig-node-service/src/mirror/schedule.rs diff --git a/SPEC.md b/SPEC.md index a148f1fa..96046691 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8253,6 +8253,17 @@ the suffixes a future producer has not invented yet. A consumer MUST read `kind` `amount_mojos` is denominated in the entry's own `asset`; an amount MUST NOT be read without it. +A mirror-coin RECLAIM additionally records WHY it reclaimed and WHAT ASKED, structurally: +`reclaim_reason` ∈ `no_longer_held` | `epoch_ended` | `url_stale` (§25.4's three reasons, rendered +snake_case) and, for `url_stale`, `trigger` ∈ `daily` | `manual`. Both are `#[serde(default)]` +(`None` on records written before they existed) and both are derived by `MirrorSpends::intent` +from the operation, never supplied by a caller, so an entry cannot claim a reason its bundle does +not have. `dign spends` and `control.spends.list` render them. This is what lets an operator +reading their record tell a rollover from a URL reconcile from a lost capsule — three reclaims that +look identical on chain and mean three different things about their node. The obligation this +discharges is §25.7's: the user cannot approve each unattended spend, so they are owed a complete +account of every one, and "complete" now includes which of the two triggers moved it (§25.13). + ### 23.2. Status, and what MUST NOT be claimed `status.state ∈ { pending, submitted, confirmed, failed, unresolved }`. @@ -9113,12 +9124,18 @@ A pass runs: at start-up (once the wallet and a chain source are available), on 2. **Observes chain**: `dig_mirror_coin::list(source, owner_puzzle_hash)` — the coins actually owned. 3. **Plans**, purely (no I/O, no clock — the epoch is a parameter): - | owned coin | its `.dig` held | action | - |---|---|---| - | `epoch == current` | yes | keep | - | `epoch == current` | no | **reclaim** (`NoLongerHeld` — the penalised state; the priority) | - | `epoch < current` | either | **reclaim** (`EpochEnded` — the automatic form of the operation the legacy left to an operator; dig-node has no operator) | - | `epoch > current` | either | **keep** | + | owned coin | its `.dig` held | its URLs vs. §25.13's target | action | + |---|---|---|---| + | `epoch == current` | yes | equal, or no reconcile directive this pass | keep | + | `epoch == current` | yes | DIFFER, and a §25.13 directive names this coin | **reclaim** (`UrlStale` — §25.13; the recreate is owed to a later pass) | + | `epoch == current` | no | — | **reclaim** (`NoLongerHeld` — the penalised state; the priority) | + | `epoch < current` | either | — | **reclaim** (`EpochEnded` — the automatic form of the operation the legacy left to an operator; dig-node has no operator) | + | `epoch > current` | either | — | **keep** | + + The planner stays PURE: it never decides a URL is stale on its own. It acts on a **reconcile + directive** supplied as an input (§25.13.6), naming coins to reclaim by coin id; without a + directive the third column is not consulted and the table is exactly the first, third, fourth + and fifth rows. The last row is a decision, not a gap: the epoch clock is wall-clock with no chain input (§24.3), so a slow local clock reads a legitimately-created next-epoch coin as "future", and @@ -9628,6 +9645,16 @@ The surface MUST hold four properties, each of which is a money statement: * A crash at any point loses at most watcher events; the next pass re-derives the plan from disk and chain, and §23.5's reconcile plus in-flight suppression prevent both double-creates and silent losses. +* A URL reconcile fails CLOSED on every gate: an address the node cannot establish, a requirement it + cannot price, a chain or wallet it cannot read, or a recreate it cannot afford each leave every coin + exactly where it was and spend nothing (§25.13.4). The one direction §25.13 accepts as a cost is + STALENESS — a coin advertising yesterday's address for up to the rest of its epoch — because that is + the same cost as advertising nothing (§25.10) and is recoverable, while a reclaim whose recreate never + comes is a bond destroyed on this machine's word. +* A URL reconcile can leave the node UNBONDED for a bounded window — between a `UrlStale` reclaim + confirming and the ordinary pass re-creating — and §25.13.6 states that window and what widens it. It + is the same window an epoch rollover already opens every seven days; §25.13 adds at most one more per + epoch on the automatic path. ### 25.10. What the node advertises: derived by default, overridden by the operator @@ -9769,8 +9796,13 @@ use, which is the absence of a relay rather than evidence of reachability, and r the gate vacuously true for exactly the nodes it exists to stop advertising. Changing the value affects only coins created after the change. Bringing an existing coin into line -means reclaiming and re-creating it — a round trip and a fee — and the node MUST NOT reclaim in -response to a configuration edit, nor in response to the derived address changing. +means reclaiming and re-creating it — a round trip and a fee — and there is exactly ONE path by which +the node does so: §25.13's reconcile, under §25.13's gates. The node MUST NOT reclaim as a direct +side effect of a configuration edit, of a changed reading, or of `control.config.setMirrorAdvertiseUrls` +being called; an edit makes the drift VISIBLE (§25.8's `url_current`) and §25.13 decides — after +corroboration, after stability where the trigger is automatic, and after the recreate has been priced — +whether to spend. Absent §25.13 the drift closes for free at the next epoch rollover, when §25.4 reclaims +the old-epoch coin and re-creates at whatever the node then advertises. ### 25.11. Funding a create — authentication precedes every figure the operator is told @@ -9872,3 +9904,266 @@ The wallet balance alone overstates what is available towards the unmade creates unauthenticated balance is in addition a figure a stranger chooses (§25.11) — so where the remaining $DIG has not been authenticated, the cost of the unmade creates is reported alone, as an *unmeasured* observation. + +## 25.13. Reconciling existing coins to the current advertised URL (dig_ecosystem#3203, dig-node#570) + +> **Implementation status.** The AUTOMATIC trigger (§25.13.1-§25.13.7, §25.13.9) is implemented: +> `mirror/reconcile.rs` (the pure decision), `mirror/schedule.rs` (the personal-day offset, +> hysteresis, the epoch cap), `mirror/reconcile_state.rs` (persisted state), wired into the round +> loop in `server.rs`. **The MANUAL trigger (§25.13.8) is SPECIFIED, NOT YET IMPLEMENTED** — it +> depends on `dig-node-control-interface` 0.34.0 declaring `control.mirror.reconcile`, tracked on +> dig-node-control-interface#52. Nothing below may be read as claiming a manual button, a +> `control.mirror.reconcile` RPC handler, or a `dign mirror reconcile` CLI verb exists yet. + +### 25.13.1. The drift, and the two things that close it + +A mirror coin's URLs are fixed at create for the whole epoch (§25.10). When what the node advertises +changes — a new public IP, an operator override set or cleared, a corroborated address replacing none — +every existing current-epoch coin advertises an address the node no longer serves from. Its collateral +stays locked; it earns nothing (§25.10's economic enforcement); nothing is slashed. That is the drift. + +Two things close it, and the second exists only because the first is slow: + +1. **The epoch rollover closes it for free.** §25.4 reclaims every prior-epoch coin (`EpochEnded`) and + re-creates at whatever `effective_urls` yields that pass. With `MIRROR_EPOCH_LENGTH_MS` = 7 days, a + drift is therefore at most one epoch old and costs at most one epoch's rewards. +2. **§25.13 closes it INSIDE the epoch**, at the price of two spends per affected coin — one reclaim, + one create — which the user accepted with the cadence (dig-node#570). There is no in-place URL + update; this section MUST NOT be read as leaving room for one. + +**What two spends cost, stated so no surface overstates it.** $DIG collateral is RECLAIMED and +RE-LOCKED, not lost: the reclaim returns exactly what the coin locked and the create locks this epoch's +margined requirement, so the net $DIG movement per coin is zero unless the operator changed the margin +mid-epoch. The real costs are the XCH fees, the funding-reservation windows the spends occupy, and the +unbonded window §25.13.6 names. + +### 25.13.2. One primitive, two triggers + +There is ONE reconcile-to-current-URL decision function, `reconcile::decide`. It is called two ways — +the daily detector (built) and, once wired, `control.mirror.reconcile` (not yet built) — and the two +triggers differ ONLY in what happens BEFORE `decide` is called: the daily trigger additionally requires +hysteresis and the epoch cap to clear (§25.13.7.4-5), neither of which the manual trigger is subject to. +Nothing on the path from `decide`'s output to the signer branches on the trigger except the audit +attribution (§23.1). + +### 25.13.3. The stale set + +For one pass, the **target** is `effective_urls(operator, address).urls` — the same list the pass's +creates publish — and a coin is **stale** exactly when ALL of: + +1. it is in this pass's chain observation with `epoch == current_epoch`; +2. its `(store, root)` is in the settled `Held` set on disk — a coin whose capsule is gone is + `NoLongerHeld`'s business, and its URL is irrelevant; +3. the SET of its memo URLs (`MirrorCoin::urls`, read via `MirrorEffects::observe_bonded_urls`, + `mirror/runner.rs`), compared as exact strings with order ignored, differs from the SET of the + target. + +Order is ignored on purpose: §25.10 publishes an operator's order verbatim and puts derived IPv6 first, +so a reorder is not a change and MUST NOT cost two spends. A coin with `epoch > current_epoch` is never +stale (a slow clock keeps foreign-epoch coins, §25.4). + +The stale set is ordered by the canonical key `(store_id, root)` (`mirror/reconcile.rs`'s `stale_set`) — +deliberately NOT the coin's own id — so "the affordable prefix" names the same coins on every machine. + +### 25.13.4. The gates, in order, and what each refusal means + +A reconcile that cannot complete MUST NOT START. Every gate is evaluated BEFORE any reclaim is built, +and a failed gate is a **refusal**: nothing was signed and nothing was broadcast. + +| # | gate | refusal reason | how it is checked | +|---|---|---|---| +| 1 | `effective_urls(..).state` is `Override` or `Derived` | the §25.10 state label itself: `off`, `no_public_address`, `uncorroborated_address`, or `no_relay` | `reconcile::RefusalReason::AdvertiseNotPublishing(AdvertiseState)` | +| 2 | §25.7's switch `mirror_enabled` is on | `disabled` | explicit | +| 3 | the chain observation is complete | (structural) | `PassRunner::run` only reaches `reconcile::decide` after its own chain read has already succeeded — the same abort §25.4's pass applies | +| 4 | the stale set (§25.13.3) is non-empty | `url_unchanged` when a current-epoch coin exists and none is stale; `no_mirror_coins` when no current-epoch coin is held at all | explicit | +| 5 | this epoch's requirement is `Known` | `requirement_unknown { reason }` | explicit | +| 6 | the node can SPEND (signer open, broadcast enabled) | (folded) | left to the SAME effects-level handling the ordinary create/reclaim paths already degrade through on an unavailable wallet, rather than a second capability check — a failed attempt there costs a log line, never a spend | +| 7 | the operator wallet's spendable $DIG is MEASURED | `funds_unmeasured` | explicit; **NOT separately authenticated (§25.11) from the ordinary pass's own funds-split reading — see the note below** | +| 8 | at least the FIRST stale coin's recreate is affordable (§25.13.5, `K ≥ 1`) | `insufficient_funds { have_dig_base_units, need_dig_base_units }` | explicit | +| 9 | no `UrlStale` reclaim is IN FLIGHT (§25.13.6) | `reconcile_in_progress` | explicit, via `runner::reconcile_in_flight` reusing `SpendRecord::reserves_funding_at` | + +**Gate 7's scoped reading.** The gate uses the SAME `dig_balance_base_units` figure the ordinary create +path already sizes its own funds split against — an unauthenticated address total, per §25.11's own +caveat about that figure. This is consistent with, not a regression from, the ordinary path's existing +behaviour: the money-moving spends themselves (`reclaim`/`create`) still authenticate their own +candidate coins before signing regardless of what balance figure sized the plan, so an inflated sizing +input can widen §25.13.6's unbonded window but cannot itself cause an unauthenticated spend. + +**Gate 1 is the one that outranks the others, and its direction is the whole design.** Reclaim-first is +forced, not preferred — a reclaim returns the collateral that funds the create behind it, so on a wallet +without spare $DIG there is no other order. A reconcile that reclaimed and then could not create would +leave the node with fewer bonds than before it started, having paid to get there. Refusing to start is +therefore the ONLY safe failure. + +### 25.13.5. The plan is sized BEFORE any reclaim, and partial is a normal outcome + +Let the stale set in canonical order be `s₁ … sₙ`, each locking `Rᵢ` base units, let `C` be this +epoch's margined per-coin requirement (the SAME lookup the ordinary create path prices with, +`plan::per_coin_dig_base_units`), and let `W` be the pass's own funds reading. Reclaims are SEPARATE, +SEQUENTIAL spends — each one's proceeds fund the recreate behind it, not the whole set at once — so +`K` cannot be read off a flat total the way an ordinary batch of independent creates can: +**`K` is the length of the longest prefix `s₁ … sₖ` that is self-funding at every step**, walked with a +running balance seeded at `W`: for each `sᵢ` in order, add `Rᵢ`, and if the result is `≥ C` subtract `C` +and continue (`i` is affordable); the first `sᵢ` where the running balance falls short of `C` stops the +walk, and every coin from there on is left unaffordable, however large — a LATER big coin never rescues +an EARLIER one it could not yet afford (greedy, fail-closed: no skipping, no reordering). In the common +case `Rᵢ = C` for every `i`, the running balance never dips between coins and `K = n` whenever the wallet +holds the fee XCH; only a mid-epoch margin change (`Rᵢ ≠ C` for some prefix) can make `K < n` before `n` +coins have been walked. + +Then, and only then: **exactly `K` coins are reclaimed — `s₁ … sₖ`** (as `ReclaimReason::UrlStale` +entries appended to the ordinary pass's reclaim list, `mirror/pass.rs`). Coins `sₖ₊₁ … sₙ` are LEFT AS +THEY ARE: still bonded, still advertising the old URL, reported with `url_current: false` once §25.8's +surface carries that field (not yet built). **`K = 0` is a refusal** (gate 8), never a plan. + +### 25.13.6. Execution: this pass reclaims; a later pass re-creates + +The `K` `UrlStale` reclaims run AFTER every `NoLongerHeld`/`EpochEnded` reclaim and BEFORE any create, +in the SAME ordinary pass (`PassRunner::execute`, unmodified). **The `K` recreates do NOT happen in this +pass**: a reclaim's returned collateral is chain-visible only once the reclaim confirms, and this pass's +chain observation still shows the reclaimed coins as owned, so `pass::decide`'s ordinary create table — +computed from that SAME snapshot — plans no create for these bonds this round. They happen in the first +LATER pass whose chain observation no longer shows the reclaimed coin, through the entirely unmodified +ordinary create path — the bond is still `held` on disk with no current-epoch coin, which is exactly the +condition that path already creates for. + +**The unbonded window.** Between a `UrlStale` reclaim confirming and its recreate being broadcast, the +node holds no coin for that bond — one confirmation plus at most one round in the common case, widening +if the advertise state stops publishing or the requirement becomes unknown in between. This is the SAME +window an epoch rollover already opens every seven days; §25.13 adds at most one more per epoch on the +automatic path (§25.13.7.5's cap). + +**In flight.** A reconcile is in progress from the first `UrlStale` reclaim's `Submitted` audit entry +until every such entry is terminal, bounded by `FUNDING_RESERVATION_WINDOW_MS` from the entry's last +revision (`runner::reconcile_in_flight`, reusing `SpendRecord::reserves_funding_at` — the SAME window +§25.4.6's funding reservation uses, so the two can never disagree about how long an unresolved spend +counts). + +### 25.13.7. The automatic trigger — the daily detector + +#### 25.13.7.1. The personal day, and why it is derived rather than drawn + +Each node checks once per **personal day**: a 24-hour period whose boundary is offset from `00:00 UTC` +by a per-node value derived from its own peer id (`mirror/schedule.rs`): + +``` +offset_secs = u64::from_be_bytes(SHA-256("dig-node/mirror-url-reconcile/personal-day/v1" ‖ peer_id)[0..8]) mod 86_400 +``` + +where `peer_id` is the node's 32-byte peer id (`PeerStatus::peer_id`, hex-decoded). The node MUST NOT +persist the offset — it is a pure function of an identity that is itself persisted. A node with no peer +id (the peer network disabled or not yet up) uses `offset_secs = 0`. + +**Why derived, and why not drawn once and persisted.** A value drawn at each start re-rolls, so a node +that restarts before its slot can go indefinitely without a single check while every log line looks +normal — silence, where a herd is at least visible. A value drawn ONCE and persisted removes the restart +re-roll but keeps the failure behind a rarer trigger: a lost, reset or migrated state file re-rolls it. +The derived offset has no state to lose, is uniform across the network because peer ids are uniform, and +is stable across restarts because the identity is. + +**What deriving from a public value gives away, bounded.** A third party who knows a node's peer id +knows its slot, which lets them time a STUN-tier outage or a flood of dissenting readings at that node's +check — making the check INCONCLUSIVE (spends nothing) and never causing a spend, since a spend requires +agreement across independent source classes (NC-12), which timing does not provide. + +#### 25.13.7.2. When a check is due, and the clock rules + +The detector keeps `last_completed_day: Option` (`reconcile_state.rs`). A check is **due** when +`d(now) > last_completed_day` (or `None`), evaluated on every iteration of the round loop +(`MIRROR_ROUND_LENGTH_MS`), so a check fires within one round of the boundary and needs no timer of its +own. + +* **At most one check per personal day.** +* **A clock that moves BACKWARD never makes a check due** — the node waits for real time to catch up. +* **A clock that jumps FORWARD by `N` days makes exactly ONE check due**, not `N`. + +#### 25.13.7.3. The observation: a fresh gather, then the same decision the pass makes + +A check gathers FRESH readings (`dig_node_core::net::gather_reflexive_readings`, the SAME arguments +bring-up uses) and evaluates `effective_urls` over them. The observation is **conclusive** when the +fresh evaluation yields `Override` or `Derived`; it is **inconclusive** otherwise. + +**A conclusive gather REPLACES the published readings** (`Node::replace_reflexive_readings`), so +`dig.getNetworkInfo`, §25.8's posture and the ordinary pass's creates all see the new address. **An +inconclusive gather MUST NOT replace them**: the node keeps the readings it had. `peer.rs`'s +`PeerStatus.reflexive` doc reads "replaced only by a conclusive re-gather" rather than "never cleared: +there is no periodic re-gather" as of this section. + +**A check that finds the same address is free and silent** — no spend, one state-file write, nothing +logged above `debug`. This is the overwhelmingly common case. + +#### 25.13.7.4. Hysteresis — stability before spending + +The detector keeps the two most recent CONCLUSIVE observations (`schedule::is_stable`). A target `S` is +**stable** when both recorded observations have `urls == S` (set equality) and were taken on two +DISTINCT personal days. Inconclusive days neither confirm nor reset: `A, inconclusive, A` establishes +`A` on the third day. `A, B, A` is not stable — two DIFFERENT readings in three days is the flap this +rule waits out. + +The automatic trigger proceeds to §25.13.4's gates only when the target is stable. **The interim +state — between the first observation of a change and stability — is: spend nothing, leave every coin +exactly where it is.** The node MUST NOT reclaim-without-recreate as an interim measure. + +**What this costs in the worst case.** An address that changes every personal day never becomes stable +and is NEVER automatically reconciled; such a node's coins stay at their create-time URL until rollover. + +#### 25.13.7.5. The epoch cap + +**The automatic trigger MUST NOT reconcile more than `URL_RECONCILE_MAX_AUTO_PER_EPOCH` = 1 time per +mirror epoch** (`schedule::epoch_cap_allows`), counted as a reconcile in which at least one `UrlStale` +reclaim was ACCEPTED by the mempool, persisted as `last_auto_reconcile_epoch`. With this cap the +automatic path adds at most one more reclaim/create pair per capsule per epoch beyond rollover's own — +the lifecycle's unattended spend count is at most DOUBLED, and a pathological network cannot make it +worse than that. + +#### 25.13.7.6. The switch, and the consent it rests on + +`collateral.json` gains `url_reconcile_enabled: bool`, `#[serde(default)]` to **`true`**. The automatic +trigger runs only while it is `true`; the daily GATHER and the drift REPORT run regardless, because +seeing is not spending. Default-on because the user asked for the behaviour by name; a switch because +the spend count can now grow without a person watching. + +### 25.13.8. The manual trigger — SPECIFIED, NOT YET IMPLEMENTED + +The method's wire shape is owned by `dig-node-control-interface` (release-first; +dig-node-control-interface#52). Once adopted, a live call runs §25.13.3-6 exactly — the same +`reconcile::decide` the daily detector calls, with `Trigger::Manual`, which skips §25.13.7.4-5 and +nothing else. Nothing in this repository serves `control.mirror.reconcile` or a `dign mirror reconcile` +CLI verb yet; a reader MUST NOT infer either exists from this section. + +### 25.13.9. Persisted detector state + +`mirror-reconcile.json`, beside `collateral.json` in the node's state directory +(`mirror/reconcile_state.rs`), written atomically (write-then-rename), every field +`#[serde(default)]`: `version`, `last_completed_day`, `observations` (at most two), `last_inconclusive`, +`last_auto_reconcile_epoch`. + +**A missing, unreadable or malformed file reads as "never observed"** — the safe direction: it delays +any automatic spend by at least two personal days and cannot cause one. It is NOT a source of truth for +what is bonded; it is a throttle record, and losing it costs one day. **No coin id is ever persisted +here** — the coin ids a reconcile actually names are read fresh from chain every time +(`MirrorEffects::observe_bonded_urls`), so there is no persisted-candidate-vs-chain-truth question here +the way dig-node#574 raised for mirror-bond ids. + +### 25.13.10. What a reader may NOT conclude + +* That the automatic path cannot leave the node unbonded. It can, for the window §25.13.6 names, once + per epoch at most. +* That a coin's `urls` prove reachability. They are the coin's memo, which §25.10 calls advisory fetch + hints. +* That refusing on `Uncorroborated` is a statement that the new address is wrong. It is a statement + that one source is not agreement. +* That `control.mirror.reconcile`, a dig-app "Reset mirrors" button, or `dign mirror reconcile` exist. + They do not, as of this section (§25.13.8). + +### 25.13.11. Failure directions, stated + +* Every gate fails CLOSED to staleness: coins untouched, nothing spent, the reason recorded (or logged). +* Hysteresis fails toward NOT spending: a flap delays a reconcile; it never causes one. +* The epoch cap fails toward NOT spending: excess drift waits for a rollover that closes it free. +* A lost state file fails toward NOT spending. +* An inconclusive gather fails toward the OLD address: the node may keep advertising an address it has + lost, for at most one epoch. +* The one direction that fails EXPENSIVE is the unbonded window (§25.13.6), bounded to one confirmation + plus one round in the common case, identical in kind to the rollover window the lifecycle already + opens weekly. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index c7ef0e10..9046f0f7 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -4992,6 +4992,22 @@ impl Node { pub fn own_peer_id(&self) -> Option { self.peer_status.peer_id() } + + /// Replace this node's published reflexive-address readings with a FRESH gather's result + /// (dig-node#570 §25.13.7.3's daily re-check). + /// + /// Bring-up calls `PeerStatus::set_reflexive` unconditionally with its one gather; a periodic + /// re-gather must not repeat that blindly, because a worse fresh reading must never overwrite a + /// working one. So this is a narrow, deliberate REPLACE — the caller decides whether to call it + /// at all. + /// + /// The decision of WHETHER a fresh gather is trustworthy enough to publish (`dig_stun::establish` + /// agreement over it) is made one layer up, in `dig-node-service`'s mirror lifecycle, which + /// already runs that same verification every round — `dig-node-core` has no dependency on it and + /// gains none here. This method is only the narrow write access that decision needs. + pub fn replace_reflexive_readings(&self, readings: Vec<(std::net::SocketAddr, String)>) { + self.peer_status.set_reflexive(readings); + } } /// The COMPOSITION-ROOT upcasts (#1285 W1c — the locked "Option A" shape). `Node` stays ONE diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 3b904ca3..4a53e10d 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -236,10 +236,12 @@ pub struct PeerStatus { /// [`crate::Node::network_info`] publishes from this field (dig-node#566). This field only ever /// answers "what did the gather see", never "what may be believed". /// - /// Set ONCE per gather, by [`Self::set_reflexive`], and never cleared: there is no periodic - /// re-probe today, so clearing it on some other signal would trade real readings for a worse - /// state — an unconditional empty `Vec` — rather than a better one. Downstream - /// reachability-over-time is a SEPARATE fact already tracked by `relay_reserved` above. + /// Set at bring-up by [`Self::set_reflexive`], and REPLACED — never merged, never cleared to + /// empty — only by a later CONCLUSIVE re-gather (dig-node#570 §25.13.7.3's daily check, via + /// `Node::replace_reflexive_readings`): the newer gather is the better measurement of this + /// node's CURRENT mapping, and an inconclusive re-gather must leave a working set exactly as it + /// was rather than trading it for a worse one. Downstream reachability-over-time is a SEPARATE + /// fact already tracked by `relay_reserved` above. reflexive: std::sync::Mutex>, } @@ -295,12 +297,15 @@ impl PeerStatus { } /// Record every reading this node's reflexive-address gather collected - /// ([`crate::net::gather_reflexive_readings`]), called once from the peer-network bring-up. - /// A later call REPLACES the whole set — the bring-up runs this exactly once today, so - /// replace-vs-merge is not yet a live question, but replace is the correct choice if a second - /// caller (a periodic re-probe) is ever added: the newer gather is the better measurement of - /// the node's CURRENT mapping, and merging stale readings into a fresh gather would let an old - /// answer keep voting after the world it described has changed. + /// ([`crate::net::gather_reflexive_readings`]). + /// + /// A later call REPLACES the whole set, never merges into it: the newer gather is the better + /// measurement of the node's CURRENT mapping, and merging stale readings into a fresh gather + /// would let an old answer keep voting after the world it described has changed. Called once, + /// unconditionally, from bring-up; called again, but only on a CONCLUSIVE result, by the daily + /// re-check (dig-node#570 §25.13.7.3, via [`crate::Node::replace_reflexive_readings`]) — this + /// method itself does not know the difference and always replaces what it is given, which is + /// why the caller's own conclusive/inconclusive gate is where that distinction is enforced. pub fn set_reflexive(&self, readings: Vec<(std::net::SocketAddr, String)>) { *self.reflexive.lock().unwrap() = readings; } diff --git a/crates/dig-node-service/src/collateral.rs b/crates/dig-node-service/src/collateral.rs index 011d23cc..5e746eca 100644 --- a/crates/dig-node-service/src/collateral.rs +++ b/crates/dig-node-service/src/collateral.rs @@ -89,6 +89,20 @@ pub struct CollateralConfig { /// one setting to turn off. #[serde(default = "default_mirror_enabled")] pub mirror_enabled: bool, + + /// Whether the AUTOMATIC daily URL reconcile may spend (`SPEC.md` §25.13.7.6, dig-node#570). + /// + /// **Gates the automatic trigger only.** The daily GATHER and the drift REPORT run regardless + /// — seeing is not spending — and the manual `control.mirror.reconcile` method ignores this + /// switch entirely (a press IS the human confirmation §25.13.8 rests on). + /// + /// Default-on, for the same consent model as [`Self::mirror_enabled`] and §18.23's auto-tipping: + /// disclosed, bounded (hysteresis + the epoch cap), fully audited (`SPEC.md` §F), and one + /// setting to turn off. It is a switch, rather than always-on, because the spend count here can + /// grow without a person watching in a way an ordinary mirror bond's cannot — the user asked for + /// the behaviour by name, and this is what makes that consent revocable. + #[serde(default = "default_url_reconcile_enabled")] + pub url_reconcile_enabled: bool, } fn default_margin_bp() -> u64 { @@ -99,6 +113,10 @@ fn default_mirror_enabled() -> bool { true } +fn default_url_reconcile_enabled() -> bool { + true +} + impl Default for CollateralConfig { fn default() -> Self { CollateralConfig { @@ -107,6 +125,7 @@ impl Default for CollateralConfig { // choice. retention_epochs: None, mirror_enabled: default_mirror_enabled(), + url_reconcile_enabled: default_url_reconcile_enabled(), } } } diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 58d8a07b..36fe1793 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -5785,6 +5785,8 @@ mod tests { store_id: Some("ee".repeat(32)), bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, initiated_ms: 1_756_000_000_000, updated_ms: 1_756_000_001_000, status, diff --git a/crates/dig-node-service/src/mirror/converge_tests.rs b/crates/dig-node-service/src/mirror/converge_tests.rs index d66ec4ff..f6a560bf 100644 --- a/crates/dig-node-service/src/mirror/converge_tests.rs +++ b/crates/dig-node-service/src/mirror/converge_tests.rs @@ -239,6 +239,7 @@ fn ctx_at(epoch: i64) -> PassContext { margin_bp: 0, creates_enabled: true, can_advertise: true, + reconcile: None, } } @@ -276,6 +277,8 @@ fn create_intent(store: &str, root: &str, epoch: i64) -> SpendIntent { epoch, }), advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 5d1db748..bb90c52a 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -2113,6 +2113,8 @@ mod tests { store_id: None, bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index bd8c695b..9986b832 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -408,6 +408,28 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { Ok(held_mirrors(&inventory)) } + fn observe_bonded_urls(&self) -> Result, PassError> { + // Calling `observe_chain` here rather than re-scanning is what keeps this ONE chain read: + // it populates `self.resolved` as a side effect, and that cache is exactly where each + // coin's own `MirrorCoin` — and therefore its `.urls()` — already lives. + let held = self.observe_chain()?; + let resolved = self.resolved.borrow(); + Ok(held + .into_iter() + .map(|held| { + let urls = resolved + .get(&held.coin_id) + .map(|coin| coin.urls().to_vec()) + // Not reached in practice: `observe_chain` just inserted every coin it + // returned into `resolved` under this same key. Falling back to empty rather + // than panicking keeps a future refactor that breaks this invariant a wrong + // answer instead of a crash on a money-adjacent read path. + .unwrap_or_default(); + super::runner::DeclaredBond { held, urls } + }) + .collect()) + } + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError> { // A malformed id is this node's own bookkeeping being wrong, not the chain being // unreachable, so it is NOT an `Err`: reporting it as one would count a permanent local @@ -498,8 +520,9 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { // `fee = 0` with no fee coins, always. §25.4.4: a zero-fee reclaim may not be admitted under // fee pressure, and the next pass retries it — whereas a reclaim gated on selectable XCH // cannot run at all on the exhausted wallet that needs it most. - let spends = super::spends::build_reclaim(&coin, signer.synthetic_key(), Vec::new(), 0) - .map_err(|e| PassError::Wallet(e.to_string()))?; + let spends = + super::spends::build_reclaim(&coin, signer.synthetic_key(), Vec::new(), 0, reason) + .map_err(|e| PassError::Wallet(e.to_string()))?; tracing::info!( target: "mirror", @@ -1440,6 +1463,7 @@ mod tests { per_coin_dig_base_units: None, locked_dig_base_units: 4_242, funding_alert: None, + reconcile_url_stale_accepted: 0, }; publish(&snapshot, &report, 9); diff --git a/crates/dig-node-service/src/mirror/local_bond.rs b/crates/dig-node-service/src/mirror/local_bond.rs index 43b0cc17..03eea326 100644 --- a/crates/dig-node-service/src/mirror/local_bond.rs +++ b/crates/dig-node-service/src/mirror/local_bond.rs @@ -158,6 +158,8 @@ mod tests { epoch: EPOCH, }), advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, }); journal.submitted( &recorded, diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 9abf68d1..26617f08 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -90,10 +90,13 @@ pub mod pass; pub mod plan; pub mod pointers; pub mod presence; +pub mod reconcile; +pub mod reconcile_state; pub(crate) mod resolve; #[cfg(test)] mod resolve_tests; pub mod runner; +pub mod schedule; pub mod signer; pub mod spends; pub mod states; diff --git a/crates/dig-node-service/src/mirror/observe.rs b/crates/dig-node-service/src/mirror/observe.rs index ed35f0cd..d307bfbf 100644 --- a/crates/dig-node-service/src/mirror/observe.rs +++ b/crates/dig-node-service/src/mirror/observe.rs @@ -115,6 +115,9 @@ pub fn observe( dig_balance_base_units, creates_enabled: ctx.creates_enabled, can_advertise: ctx.can_advertise, + // This is a READ, never a reconcile attempt (see the module doc: it cannot spend). A live + // reconcile directive is `runner::PassRunner::run`'s concern alone. + reconcile: None, }); BondObservation { diff --git a/crates/dig-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 4a08e8c6..a058d1ad 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -29,14 +29,17 @@ //! unfunded, and conflating them produces an out-of-funds alarm about a wallet that is fine //! (dig-app#300). A missed create fails safe — the money stays in the wallet. -use dig_mirror_collateral::margin::apply_safety_margin; - // From the control interface's published contract rather than re-exported through // `crate::collateral`: these are the same types the §25.8 surface serves, and naming their // owner keeps one definition rather than a local alias that could drift from it. use dig_node_control_interface::results::{CollateralRequirementResult, CollateralUnknownReason}; -use super::plan::{plan, Bond, FundingSplit, HeldMirror, MirrorPlan, ReclaimReason}; +#[cfg(test)] +use super::plan::Trigger; +use super::plan::{ + per_coin_dig_base_units, plan, Bond, FundingSplit, HeldMirror, MirrorPlan, ReclaimReason, +}; +use super::reconcile::ReconcileDirective; /// What one pass has decided to do, and what to report for every bond it considered. #[derive(Debug, Clone, PartialEq, Eq)] @@ -229,6 +232,16 @@ pub struct PassInputs<'a> { /// Reclaims ignore it, exactly as they ignore [`Self::creates_enabled`]: money already locked /// must come home whether or not this node can advertise anything today. pub can_advertise: bool, + /// A URL-reconcile directive for THIS pass, when [`super::reconcile::decide`] (dig-node#570) + /// has already sized one — `SPEC.md` §25.13's `ReconcileDirective`, naming coin ids to reclaim + /// because their advertised URL no longer matches what this node advertises now. + /// + /// `None` on every ordinary pass (the overwhelming majority): the daily/manual trigger is what + /// ever produces `Some`, and it has ALREADY sized the affordable prefix and run every one of + /// `SPEC.md` §25.13.4's other gates before this field is populated — `decide` here never + /// re-derives that decision, it only turns an already-sized directive into reclaim entries, the + /// same way the ordinary table turns `NoLongerHeld`/`EpochEnded` coins into them. + pub reconcile: Option<&'a ReconcileDirective>, } /// Decide one pass. @@ -252,25 +265,42 @@ pub fn decide(inputs: &PassInputs<'_>) -> PassDecision { &[] }; - let MirrorPlan { reclaim, create } = plan( + let MirrorPlan { + mut reclaim, + create, + } = plan( desired, inputs.on_chain, inputs.current_epoch, inputs.in_flight, ); + // dig-node#570, `SPEC.md` §B.1: the planner stays PURE and never decides a URL is stale on its + // own. It ACTS on a directive [`super::reconcile::decide`] already sized and gated — every + // coin named here has ALREADY cleared every §25.13.4 gate, including the affordability check, + // so this loop only ever turns an already-priced decision into reclaim entries, exactly as the + // two rows above turn `NoLongerHeld`/`EpochEnded` into them. Appended AFTER the ordinary + // reclaims and BEFORE any create (`SPEC.md` §B.2) — the recreate for a bond reclaimed this way + // is NEVER this pass's: `inputs.on_chain` was snapshotted before this reclaim runs, so `create` + // above still sees the coin as present and plans nothing for its bond; the recreate happens + // automatically, through this SAME ordinary path, on the first later pass whose chain + // observation no longer shows the reclaimed coin (`SPEC.md` §25.13.6). + if let Some(directive) = inputs.reconcile { + for coin_id in &directive.coin_ids { + if let Some(coin) = inputs.on_chain.iter().find(|c| &c.coin_id == coin_id) { + reclaim.push((coin.clone(), ReclaimReason::UrlStale(directive.trigger))); + } + // A named id absent from `on_chain` is not this function's problem to raise: the + // directive was sized against a chain read from the SAME tick (`SPEC.md` §25.13.6), + // so an absence here means the coin left the chain between that read and this one — + // already reconciled, or reclaimed by another path — and reclaiming nothing for it is + // the correct, safe answer either way. + } + } + // Rule 3. The requirement is consulted only to PRICE creates. Note it is read after the plan is // taken, never before it: nothing about an unknown price may reach the reclaim list. - let per_coin = match inputs.requirement { - CollateralRequirementResult::Known { - required_per_store_dig_base_units, - .. - } => Some(apply_safety_margin( - *required_per_store_dig_base_units, - inputs.margin_bp, - )), - CollateralRequirementResult::Unknown { .. } => None, - }; + let per_coin = per_coin_dig_base_units(inputs.requirement, inputs.margin_bp); // Rule 1, continued: the balance is read AFTER the plan too, and an unknown balance prices no // create rather than aborting anything. `reclaim` above is already decided and is untouched by @@ -507,6 +537,7 @@ mod tests { dig_balance_base_units: Some(1_000_000), creates_enabled: true, can_advertise: true, + reconcile: None, } } @@ -661,7 +692,9 @@ mod tests { assert_eq!(d.create, vec![bond("aa", "11")]); assert_eq!( d.per_coin_dig_base_units, - Some(apply_safety_margin(REQUIRED, 500)), + Some(dig_mirror_collateral::margin::apply_safety_margin( + REQUIRED, 500 + )), "the amount is the margined requirement, not the bare one and not a constant" ); assert_ne!( @@ -1067,4 +1100,100 @@ mod tests { "not `Bonded` -- nothing is advertising it yet -- and not `Reclaiming`, which would describe last epoch's money while the question is about this epoch's capsule" ); } + + /// **Proves:** a `UrlStale` reclaim from a live [`ReconcileDirective`] lands AFTER every + /// ordinary reclaim and BEFORE any create (`SPEC.md` §B.2), and is priced/ordered + /// independently of them. + /// + /// **Catches** the defect the reviewer named on dig-node#573: nothing previously constructed a + /// `PassInputs` with `reconcile: Some(_)` ALONGSIDE real ordinary reclaims, so moving the + /// append (or dropping it) changed nothing green. Moving the `if let Some(directive) = + /// inputs.reconcile` block in `decide` to run after `create` is computed makes this fail on + /// order -- `UrlStale` then lands after a create instead of before one (verified by hand while + /// writing this test, then restored). + #[test] + fn a_url_stale_directive_reclaims_after_ordinary_reclaims_and_before_creates() { + let held = [bond("aa", "11"), bond("stale", "99")]; + let req = known(); + let gone_current = coin("gone_current", "bb", "22", NOW_EPOCH, REQUIRED); + let gone_past = coin("gone_past", "cc", "33", NOW_EPOCH - 1, REQUIRED); + let stale_coin = coin("stale_coin", "stale", "99", NOW_EPOCH, REQUIRED); + let on_chain = [gone_current.clone(), gone_past.clone(), stale_coin.clone()]; + let directive = ReconcileDirective { + coin_ids: vec![stale_coin.coin_id.clone()], + left_unaffordable: 0, + trigger: Trigger::Manual, + }; + let i = PassInputs { + reconcile: Some(&directive), + ..inputs(&held, &on_chain, &req) + }; + + let d = decide(&i); + + assert_eq!( + d.reclaim, + vec![ + (gone_current, ReclaimReason::NoLongerHeld), + (gone_past, ReclaimReason::EpochEnded), + (stale_coin, ReclaimReason::UrlStale(Trigger::Manual)), + ], + "ordinary reclaims first, in `plan`'s own order, the directive's `UrlStale` reclaim last" + ); + assert_eq!( + d.create, + vec![bond("aa", "11")], + "the stale coin's own bond is still covered this pass -- `on_chain` was snapshotted before the reclaim above runs, so nothing here recreates it early" + ); + } + + /// **Proves:** a directive naming a coin id absent from `on_chain` reclaims nothing for it and + /// never panics -- the coin already left the chain between the directive's own read and this + /// pass's (`SPEC.md` §25.13.6), so silently reclaiming nothing is the correct answer. + #[test] + fn a_stale_directive_naming_a_coin_no_longer_on_chain_reclaims_nothing_for_it() { + let held = [bond("aa", "11")]; + let req = known(); + let on_chain: [HeldMirror; 0] = []; + let directive = ReconcileDirective { + coin_ids: vec![id("vanished")], + left_unaffordable: 0, + trigger: Trigger::Daily, + }; + let i = PassInputs { + reconcile: Some(&directive), + ..inputs(&held, &on_chain, &req) + }; + + let d = decide(&i); + + assert!( + d.reclaim.is_empty(), + "the named coin is not this function's problem to raise once it has left the chain" + ); + assert_eq!(d.create, vec![bond("aa", "11")]); + } + + /// **Proves:** `reconcile: None` leaves the plan exactly as it was before dig-node#570 -- + /// nothing here regresses an ordinary pass with no directive in flight. + #[test] + fn no_reconcile_directive_leaves_the_ordinary_plan_untouched() { + let held = [bond("aa", "11")]; + let req = known(); + let on_chain = [coin("gone", "bb", "22", NOW_EPOCH, REQUIRED)]; + let i = inputs(&held, &on_chain, &req); + assert!(i.reconcile.is_none()); + + let d = decide(&i); + + assert_eq!( + d.reclaim, + vec![( + coin("gone", "bb", "22", NOW_EPOCH, REQUIRED), + ReclaimReason::NoLongerHeld + )], + "identical to the ordinary plan -- no `UrlStale` entry appears from nowhere" + ); + assert_eq!(d.create, vec![bond("aa", "11")]); + } } diff --git a/crates/dig-node-service/src/mirror/plan.rs b/crates/dig-node-service/src/mirror/plan.rs index 718609b3..b1dbfb21 100644 --- a/crates/dig-node-service/src/mirror/plan.rs +++ b/crates/dig-node-service/src/mirror/plan.rs @@ -18,6 +18,9 @@ use std::collections::BTreeSet; +use dig_mirror_collateral::margin::apply_safety_margin; +use dig_node_control_interface::results::CollateralRequirementResult; + /// A `(store, root)` pair this node holds and is willing to advertise — one prospective mirror. /// /// "Willing to advertise" is not the same as "present on disk". A capsule pulled on a stranger's @@ -67,8 +70,35 @@ impl HeldMirror { } } -/// Why a held coin is being reclaimed. Recorded because the two reasons are very different -/// situations, and an operator reading the audit record needs to know which one they are looking at. +/// Which of the two callers asked [`super::reconcile::decide`] (dig-node#570) to +/// bring this node's mirror coins in line with what it advertises now. +/// +/// Recorded on [`ReclaimReason::UrlStale`] and carried into the audit entry (`SPEC.md` §23, §F) so +/// an operator reading their spend record can tell a scheduled check from a button they pressed — +/// two situations that look identical on chain (a reclaim, then a create) and mean different things +/// about whether a human was watching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Trigger { + /// The personal-day detector (`SPEC.md` §25.13.7). Gated by hysteresis and the epoch cap; + /// nobody was asked. + Daily, + /// `control.mirror.reconcile` (`SPEC.md` §25.13.8), called by a person or the dig-app button. + /// Exempt from hysteresis and the epoch cap — a press IS the human confirmation. + Manual, +} + +impl Trigger { + /// The wire spelling `SPEC.md` §F and `dig-node-control-interface` use. + pub fn label(self) -> &'static str { + match self { + Trigger::Daily => "daily", + Trigger::Manual => "manual", + } + } +} + +/// Why a held coin is being reclaimed. Recorded because the reasons are very different situations, +/// and an operator reading the audit record needs to know which one they are looking at. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ReclaimReason { /// The `.dig` this coin bonds is no longer on disk, and the coin is for the CURRENT epoch. @@ -84,6 +114,15 @@ pub enum ReclaimReason { /// The legacy had this as an operational step a human ran, and dig-node has no operator — so /// leaving it manual would strand one epoch's collateral per store, forever, with nobody to notice. EpochEnded, + /// The coin's own advertised URLs no longer match what this node advertises now + /// (`SPEC.md` §25.13, dig-node#570). Unlike its two siblings, a `UrlStale` reclaim is GATED: it + /// is made only for a coin [`super::reconcile`] has already priced a recreate for, because there + /// is no in-place URL update — reclaiming without being able to recreate would leave this node + /// with fewer bonds than before it started, having paid to get there. + /// + /// Carries which caller asked, for the audit record (`SPEC.md` §F): a scheduled check and an + /// operator's button press look identical on chain and mean different things. + UrlStale(Trigger), } /// What must happen to make the chain agree with the disk. @@ -243,6 +282,30 @@ pub fn split_by_funds(create: &[Bond], balance_dig_base_units: u64, per_coin: u6 } } +/// The CURRENT epoch's create price, in DIG base units — `None` when the requirement is unknown. +/// +/// Lives here rather than in `pass.rs`, even though [`super::pass::decide`] is its main caller, +/// because [`super::reconcile::decide`] (dig-node#570) prices a recreate the SAME way, and `pass.rs` +/// and `reconcile.rs` each need to import the other's directive type — putting the shared lookup in +/// this lower module, which both already depend on, is what keeps that from becoming a cycle. +/// `SPEC.md` §25 owns the pricing model; this is only the lookup, never a restatement of its +/// arithmetic — see [`apply_safety_margin`]. +pub fn per_coin_dig_base_units( + requirement: &CollateralRequirementResult, + margin_bp: u64, +) -> Option { + match requirement { + CollateralRequirementResult::Known { + required_per_store_dig_base_units, + .. + } => Some(apply_safety_margin( + *required_per_store_dig_base_units, + margin_bp, + )), + CollateralRequirementResult::Unknown { .. } => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dig-node-service/src/mirror/reconcile.rs b/crates/dig-node-service/src/mirror/reconcile.rs new file mode 100644 index 00000000..cb6b1a22 --- /dev/null +++ b/crates/dig-node-service/src/mirror/reconcile.rs @@ -0,0 +1,720 @@ +//! The mirror-coin URL reconcile DECISION — `SPEC.md` §25.13, dig-node#570. +//! +//! A mirror coin's advertised URL is fixed in its memos at creation and can never be updated in +//! place — the user decided this explicitly (dig-node#570, dig_ecosystem#3203) rather than leave it +//! an open question. So the only way to change what a bond advertises is to **reclaim the old coin +//! and create a new one**, and this module decides WHETHER to, never how — the actual reclaim rides +//! the ordinary pass's own execution (`super::pass::decide`, `super::runner::PassRunner::execute`), +//! exactly as an ordinary `NoLongerHeld`/`EpochEnded` reclaim does. Keeping this pure is what makes +//! the money-critical cases testable at all: every gate below is a handful of literals against +//! [`decide`], never a chain and a wallet induced into a state. +//! +//! # The invariant that outranks everything else here +//! +//! **Size the plan to the affordable prefix `K` BEFORE any reclaim; reclaim exactly `K`; leave +//! `n − K` bonded as they were.** With no in-place update, a reclaim this module could not price a +//! recreate for would leave the node holding fewer bonds than before it started, having paid to get +//! there — strictly worse than the stale state, which is at least still bonded. So sizing happens +//! entirely BEFORE [`ReconcileDirective`] names a single coin id, and a refusal always means the +//! directive is absent, never present-and-empty. +//! +//! # Two triggers, one function +//! +//! The daily detector and (once wired) `control.mirror.reconcile` both call [`decide`]. Building the +//! decision twice, with two independently-written guards, is the rival-implementation shape +//! CLAUDE.md's "centralize rival implementations" rule exists to prevent — and where two such rivals +//! disagree, one of them is wrong and it is usually the one that shipped. What DOES differ between +//! the triggers — hysteresis and the epoch cap — lives one layer up, in [`super::schedule`], and is +//! applied BEFORE this function is even called: by the time `decide` runs, "should we attempt this +//! at all" has already been answered, and this module only ever answers "can we, right now". + +use std::collections::BTreeSet; + +use dig_node_control_interface::results::{CollateralRequirementResult, CollateralUnknownReason}; + +use super::advertise::{AdvertiseState, Effective}; +use super::plan::{self, Bond}; +use super::runner::DeclaredBond; + +/// Why [`decide`] refused to act. Every variant means the spend count is exactly zero — enforcing +/// that is this module's whole job, and this type is only how it explains itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefusalReason { + /// Gate 1 (`SPEC.md` §25.13.4 row 1). This node has nowhere to advertise from right now — the + /// SAME `SPEC.md` §25.10 state label an ordinary pass logs, carried rather than collapsed into + /// one token: the four states have four different remedies, and a reset onto an + /// [`AdvertiseState::Uncorroborated`] address is a reset onto nothing. + AdvertiseNotPublishing(AdvertiseState), + /// Gate 2. `SPEC.md` §25.7's switch is off. The operator's own choice, never a fault. + Disabled, + /// Gate 4a. This node holds no current-epoch mirror coin for any bond still on disk. + NoMirrorCoins, + /// Gate 4b. Every current-epoch coin already declares the URL set this node would advertise + /// now. The no-op case, and the overwhelmingly common one on a healthy, stable-address node. + UrlUnchanged, + /// Gate 5. This epoch's collateral requirement is not known, so no recreate could be priced. + RequirementUnknown(CollateralUnknownReason), + /// Gate 7. The operator wallet's balance could not be read, so affordability is UNKNOWN — not a + /// shortfall, which would claim evidence this call does not have. + FundsUnmeasured, + /// Gate 8. The wallet cannot fund even the FIRST stale bond's recreate after every stale coin's + /// collateral is folded back in. The SAME two figures `SPEC.md` §25.12 quotes for an ordinary + /// create, so an operator sees one number for "short" everywhere it appears. + InsufficientFunds { + have_dig_base_units: u64, + need_dig_base_units: u64, + }, + /// Gate 9. Another `UrlStale` reclaim from an earlier call has not yet resolved. + ReconcileInProgress, +} + +impl RefusalReason { + /// The wire spelling `SPEC.md` §25.13.4 and `dig-node-control-interface` declare. + pub fn label(&self) -> String { + match self { + RefusalReason::AdvertiseNotPublishing(state) => state.label().to_string(), + RefusalReason::Disabled => "disabled".to_string(), + RefusalReason::NoMirrorCoins => "no_mirror_coins".to_string(), + RefusalReason::UrlUnchanged => "url_unchanged".to_string(), + RefusalReason::RequirementUnknown(_) => "requirement_unknown".to_string(), + RefusalReason::FundsUnmeasured => "funds_unmeasured".to_string(), + RefusalReason::InsufficientFunds { .. } => "insufficient_funds".to_string(), + RefusalReason::ReconcileInProgress => "reconcile_in_progress".to_string(), + } + } +} + +/// What to reclaim this pass, already sized to the affordable prefix (`SPEC.md` §25.13.5). +/// +/// Carries coin ids rather than [`super::plan::HeldMirror`]s: the runner already holds the full +/// records from its own chain observation, and threading ids rather than a second copy of them is +/// what makes it impossible for this directive to disagree with that observation about amounts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconcileDirective { + /// Coin ids to reclaim this pass, in canonical order, exactly `K` of them. + pub coin_ids: Vec, + /// How many stale bonds (`n − K`) are left untouched for want of funds. Zero in the common + /// case (`SPEC.md` §25.13.5: `Rᵢ = C` for every coin created this epoch, so `K = n` whenever + /// the wallet holds the fee XCH). + pub left_unaffordable: usize, + /// Which caller asked — carried through to the audit record (`SPEC.md` §F) so an operator can + /// tell a scheduled check from a button they pressed. Not consulted by any gate above: `decide` + /// answers identically for either trigger (`SPEC.md` §25.13.2's table), and this is echoed + /// straight from [`ReconcileInputs::trigger`] purely for attribution. + pub trigger: plan::Trigger, +} + +/// Everything [`decide`] consults, gathered once so the decision needs no further I/O. +pub struct ReconcileInputs<'a> { + /// Which caller is asking. Not a gate input — see [`ReconcileDirective::trigger`]. + pub trigger: plan::Trigger, + /// What this node would advertise THIS pass — the SAME [`Effective`] an ordinary pass computes, + /// never re-derived here. Its `urls` is §25.13.3's target. + pub advertised: &'a Effective, + /// `SPEC.md` §25.7's switch. Gate 2. + pub mirror_enabled: bool, + /// The settled `Held` bonds on disk — a coin's bond must be in THIS set to be reconciled here; + /// a bond that left disk is `NoLongerHeld`'s business, not this module's (`SPEC.md` §25.13's + /// scope note). + pub held_bonds: &'a [Bond], + /// Every mirror coin this wallet owns, together with the URL set each one declares + /// (`super::runner::MirrorEffects::observe_bonded_urls`). + pub bonded: &'a [DeclaredBond], + /// The epoch in force. + pub current_epoch: i64, + /// This epoch's requirement, or the named reason it is unknown. Gate 5. + pub requirement: &'a CollateralRequirementResult, + /// The local safety margin, in basis points. + pub margin_bp: u64, + /// Spendable $DIG in base units, or `None` when the wallet could not report it. Gates 7 and 8. + pub dig_balance_base_units: Option, + /// Whether an earlier `UrlStale` reclaim from this node has not yet resolved. Gate 9. + pub reconcile_in_progress: bool, +} + +/// The stale set (`SPEC.md` §25.13.3): every bonded coin that is for the CURRENT epoch, whose bond +/// is STILL held on disk, and whose declared URL set differs from `target` — compared as sets, order +/// ignored, because an operator's own order (with a derived IPv6 candidate placed first) is not a +/// change. +/// +/// Ordered by the CANONICAL key `(store_id, root)` — deliberately NOT [`DeclaredBond`]'s derived +/// `Ord`, which would sort by `coin_id` first. "The affordable prefix" must name the same coins on +/// every machine regardless of which coin id a chain happened to assign, so the order is the bond's +/// own identity, not the coin's. +fn stale_set( + bonded: &[DeclaredBond], + held_bonds: &[Bond], + current_epoch: i64, + target: &[String], +) -> Vec { + let held: BTreeSet<&Bond> = held_bonds.iter().collect(); + let target_set: BTreeSet<&String> = target.iter().collect(); + + let mut candidates: Vec = bonded + .iter() + .filter(|d| d.held.epoch == current_epoch) + .filter(|d| held.contains(&Bond::new(&d.held.store_id, &d.held.root))) + .cloned() + .collect(); + candidates + .sort_by(|a, b| (&a.held.store_id, &a.held.root).cmp(&(&b.held.store_id, &b.held.root))); + + candidates + .into_iter() + .filter(|d| { + let urls: BTreeSet<&String> = d.urls.iter().collect(); + urls != target_set + }) + .collect() +} + +/// Decide whether to reconcile, and what. +/// +/// Pure: no clock, no chain, no wallet, no file. Every gate is evaluated in `SPEC.md` §25.13.4's +/// order and the FIRST failure is the reported reason, so a caller sees the most fundamental +/// blocker rather than an incidental one. +/// +/// Three of the nine gates the full spec names are handled OUTSIDE this function, by construction +/// rather than by a redundant check here: gate 3 (the chain observation is complete) is structural +/// — [`super::runner::PassRunner::run`] only reaches this call after its own chain read succeeded, +/// exactly as an ordinary pass's create pricing does; gate 6 (a signer is open and broadcast is +/// enabled) is left to the SAME reclaim/create effects an ordinary pass already degrades through +/// when a wallet is unavailable, rather than a second copy of that capability check; and the +/// automatic-only pre-conditions (the switch, hysteresis, the epoch cap) are `super::schedule`'s, +/// evaluated before this function is even called (`SPEC.md` §25.13.4's own text: they are "not a +/// refusal in the wire sense because nothing was asked"). +pub fn decide(inputs: &ReconcileInputs<'_>) -> Result { + // Gate 1. + if !inputs.advertised.can_advertise() { + return Err(RefusalReason::AdvertiseNotPublishing( + inputs.advertised.state, + )); + } + // Gate 2. + if !inputs.mirror_enabled { + return Err(RefusalReason::Disabled); + } + + // Gate 4a: at least one current-epoch, still-held coin exists at all. + let held: BTreeSet<&Bond> = inputs.held_bonds.iter().collect(); + let candidate_count = inputs + .bonded + .iter() + .filter(|d| d.held.epoch == inputs.current_epoch) + .filter(|d| held.contains(&Bond::new(&d.held.store_id, &d.held.root))) + .count(); + if candidate_count == 0 { + return Err(RefusalReason::NoMirrorCoins); + } + + // Gate 4b: at least one of those candidates is actually stale. + let stale = stale_set( + inputs.bonded, + inputs.held_bonds, + inputs.current_epoch, + &inputs.advertised.urls, + ); + if stale.is_empty() { + return Err(RefusalReason::UrlUnchanged); + } + + // Gate 5. The SAME lookup an ordinary create prices with (`plan::per_coin_dig_base_units`), + // never a second copy of the arithmetic. + let per_coin = match inputs.requirement { + CollateralRequirementResult::Unknown { reason } => { + return Err(RefusalReason::RequirementUnknown(*reason)); + } + CollateralRequirementResult::Known { .. } => { + plan::per_coin_dig_base_units(inputs.requirement, inputs.margin_bp) + .expect("a Known requirement always prices") + } + }; + + // Gate 7. + let Some(balance) = inputs.dig_balance_base_units else { + return Err(RefusalReason::FundsUnmeasured); + }; + + // Gate 8 and §25.13.5's sizing, together. Reclaims are SEPARATE, SEQUENTIAL spends -- each + // one's proceeds fund the recreate behind it, never the whole stale set at once -- so `K` + // cannot be read off a flat total the way `plan::split_by_funds` prices an ordinary batch of + // independent creates (that function stays out of this path for exactly that reason: a flat + // total can call a coin affordable that the actual spend sequence never funds, when + // collateral differs across the stale set — `SPEC.md` §25.13.5's mid-epoch-margin-raise + // case). `K` is instead the longest PREFIX of `stale` (canonical order, unchanged) that is + // self-funding at every step: walk it with a running balance seeded at this pass's own funds + // reading, adding each coin's OWN collateral in turn and only ever crediting a recreate once + // the running balance reaches `per_coin` — PRICING still comes from the SAME lookup an + // ordinary create uses (`plan::per_coin_dig_base_units`, already resolved above as + // `per_coin`); only the SIZING is this hand-written walk. Greedy and fail-closed: no + // skipping, no reordering — a later, richer coin must never rescue an earlier one it could + // not yet afford, or "the affordable prefix" would stop naming the same coins on every run. + let mut running_balance = balance; + let mut k = 0usize; + for d in &stale { + running_balance = running_balance.saturating_add(d.held.collateral_dig_base_units); + if running_balance >= per_coin { + running_balance -= per_coin; + k += 1; + } else { + break; + } + } + if k == 0 { + return Err(RefusalReason::InsufficientFunds { + have_dig_base_units: balance.saturating_add(stale[0].held.collateral_dig_base_units), + need_dig_base_units: per_coin, + }); + } + + // Gate 9. + if inputs.reconcile_in_progress { + return Err(RefusalReason::ReconcileInProgress); + } + + Ok(ReconcileDirective { + coin_ids: stale[..k].iter().map(|d| d.held.coin_id.clone()).collect(), + left_unaffordable: stale.len() - k, + trigger: inputs.trigger, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mirror::plan::HeldMirror; + + const NOW_EPOCH: i64 = 100; + const PER_COIN: u64 = 1_000; + + /// A distinguishable 64-hex id, following this crate's own idiom: real ids are opaque, and a + /// short literal would hide a length assumption a path builder relies on. + fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s + } + + fn bond(store: &str, root: &str) -> Bond { + Bond::new(id(store), id(root)) + } + + fn old_urls() -> Vec { + vec!["https://old.example:9444".to_string()] + } + + fn new_urls() -> Vec { + vec!["https://new.example:9444".to_string()] + } + + fn declared(tag: &str, store: &str, root: &str, urls: Vec) -> DeclaredBond { + declared_at(tag, store, root, urls, PER_COIN) + } + + /// A declared bond whose OWN locked collateral differs from [`PER_COIN`] — the ONLY way `K < n` + /// or an `InsufficientFunds` refusal can arise for a SINGLE-coin case: a stale coin whose own + /// collateral equals the CURRENT price always funds its own recreate on reclaim alone, whatever + /// the rest of the wallet holds (`SPEC.md` §25.13.5's "common case Rᵢ = C" — locked in here so a + /// fixture cannot silently drift back to the case that can never be short). + fn declared_at( + tag: &str, + store: &str, + root: &str, + urls: Vec, + collateral_dig_base_units: u64, + ) -> DeclaredBond { + DeclaredBond { + held: HeldMirror { + coin_id: id(tag), + store_id: id(store), + root: id(root), + epoch: NOW_EPOCH, + collateral_dig_base_units, + }, + urls, + } + } + + fn advertised_at(urls: Vec) -> Effective { + Effective { + urls, + state: AdvertiseState::Derived, + rejected: Vec::new(), + } + } + + fn requirement_known() -> CollateralRequirementResult { + CollateralRequirementResult::Known { + epoch: NOW_EPOCH as u64, + protocol_version: 1, + required_per_store_dig_base_units: PER_COIN, + stores: 1, + owners: 1, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, + } + } + + struct Fixture { + advertised: Effective, + mirror_enabled: bool, + held_bonds: Vec, + bonded: Vec, + requirement: CollateralRequirementResult, + dig_balance_base_units: Option, + reconcile_in_progress: bool, + } + + impl Fixture { + fn holding_one_stale_bond() -> Self { + Fixture { + advertised: advertised_at(new_urls()), + mirror_enabled: true, + held_bonds: vec![bond("s1", "r1")], + bonded: vec![declared("c1", "s1", "r1", old_urls())], + requirement: requirement_known(), + dig_balance_base_units: Some(PER_COIN * 100), + reconcile_in_progress: false, + } + } + + fn inputs(&self) -> ReconcileInputs<'_> { + ReconcileInputs { + trigger: plan::Trigger::Daily, + advertised: &self.advertised, + mirror_enabled: self.mirror_enabled, + held_bonds: &self.held_bonds, + bonded: &self.bonded, + current_epoch: NOW_EPOCH, + requirement: &self.requirement, + margin_bp: 0, + dig_balance_base_units: self.dig_balance_base_units, + reconcile_in_progress: self.reconcile_in_progress, + } + } + } + + // --- refusal table, in gate order ------------------------------------------------------------ + + #[test] + fn gate1_refuses_when_the_address_is_uncorroborated() { + let mut f = Fixture::holding_one_stale_bond(); + f.advertised = Effective { + urls: Vec::new(), + state: AdvertiseState::Uncorroborated, + rejected: Vec::new(), + }; + assert_eq!( + decide(&f.inputs()), + Err(RefusalReason::AdvertiseNotPublishing( + AdvertiseState::Uncorroborated + )) + ); + } + + #[test] + fn gate1_carries_the_actual_state_not_a_collapsed_token() { + // Distinguishes the four §25.10 states from each other -- a refusal that collapsed them + // into one "advertise_off" token would pass a fixture asserting only ONE of these. + for state in [ + AdvertiseState::Off, + AdvertiseState::NoPublicAddress, + AdvertiseState::Uncorroborated, + AdvertiseState::NoRelay, + ] { + let mut f = Fixture::holding_one_stale_bond(); + f.advertised = Effective { + urls: Vec::new(), + state, + rejected: Vec::new(), + }; + assert_eq!( + decide(&f.inputs()), + Err(RefusalReason::AdvertiseNotPublishing(state)) + ); + } + } + + #[test] + fn gate2_refuses_when_the_operator_switch_is_off() { + let mut f = Fixture::holding_one_stale_bond(); + f.mirror_enabled = false; + assert_eq!(decide(&f.inputs()), Err(RefusalReason::Disabled)); + } + + #[test] + fn gate4a_refuses_when_no_current_epoch_coin_is_held() { + let mut f = Fixture::holding_one_stale_bond(); + f.bonded = Vec::new(); + assert_eq!(decide(&f.inputs()), Err(RefusalReason::NoMirrorCoins)); + } + + /// **Distinguishes this property from the nearest wrong implementation**: a filter that forgot + /// the disk-provenance check would see this coin as reconcilable (it exists on chain) — this + /// fixture has a coin whose bond is NOT held on disk at all, so a correct implementation reports + /// `no_mirror_coins`, matching `SPEC.md` §25.13's scope note that a bond off disk is + /// `NoLongerHeld`'s business, never this module's. + #[test] + fn a_coin_whose_bond_left_disk_is_not_this_module_s_business() { + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = Vec::new(); + assert_eq!(decide(&f.inputs()), Err(RefusalReason::NoMirrorCoins)); + } + + #[test] + fn gate4b_refuses_as_a_no_op_when_every_coin_already_matches() { + let mut f = Fixture::holding_one_stale_bond(); + f.bonded = vec![declared("c1", "s1", "r1", new_urls())]; + assert_eq!(decide(&f.inputs()), Err(RefusalReason::UrlUnchanged)); + } + + #[test] + fn a_reordered_url_list_is_url_unchanged_not_stale() { + let mut f = Fixture::holding_one_stale_bond(); + f.advertised = advertised_at(vec!["https://a".into(), "https://b".into()]); + f.bonded = vec![declared( + "c1", + "s1", + "r1", + vec!["https://b".into(), "https://a".into()], + )]; + assert_eq!(decide(&f.inputs()), Err(RefusalReason::UrlUnchanged)); + } + + #[test] + fn gate5_refuses_when_the_requirement_is_unknown() { + let mut f = Fixture::holding_one_stale_bond(); + f.requirement = CollateralRequirementResult::Unknown { + reason: CollateralUnknownReason::NotCensused, + }; + assert_eq!( + decide(&f.inputs()), + Err(RefusalReason::RequirementUnknown( + CollateralUnknownReason::NotCensused + )) + ); + } + + #[test] + fn gate7_refuses_when_the_balance_is_unmeasured() { + let mut f = Fixture::holding_one_stale_bond(); + f.dig_balance_base_units = None; + assert_eq!(decide(&f.inputs()), Err(RefusalReason::FundsUnmeasured)); + } + + /// **The bound pinned from BOTH sides** (CLAUDE.md's fixture-design rule): one base unit short + /// of affording the first recreate must refuse; exactly enough must proceed. A bound tested only + /// from below could pass an implementation that is off by one in the expensive direction. + /// + /// The stale coin's OWN collateral is set BELOW [`PER_COIN`] — the mid-epoch-margin-raise case + /// `SPEC.md` §25.13.5 names — because a coin locked at exactly the current price always funds + /// its own recreate on reclaim alone, whatever the rest of the wallet holds; that case can never + /// exercise this refusal and a fixture that used it would pass for the wrong reason. + #[test] + fn gate8_insufficient_funds_bound_from_below_refuses() { + const OLD_COLLATERAL: u64 = PER_COIN - 200; + let mut f = Fixture::holding_one_stale_bond(); + f.bonded = vec![declared_at("c1", "s1", "r1", old_urls(), OLD_COLLATERAL)]; + f.dig_balance_base_units = Some(199); // augmented = 199 + 800 = 999, one short of 1_000 + assert_eq!( + decide(&f.inputs()), + Err(RefusalReason::InsufficientFunds { + have_dig_base_units: 199 + OLD_COLLATERAL, + need_dig_base_units: PER_COIN, + }) + ); + } + + #[test] + fn gate8_insufficient_funds_bound_from_above_at_exactly_the_requirement_proceeds() { + const OLD_COLLATERAL: u64 = PER_COIN - 200; + let mut f = Fixture::holding_one_stale_bond(); + f.bonded = vec![declared_at("c1", "s1", "r1", old_urls(), OLD_COLLATERAL)]; + f.dig_balance_base_units = Some(200); // augmented = 200 + 800 = 1_000, exactly enough + let directive = decide(&f.inputs()).expect("exactly enough must be affordable"); + assert_eq!(directive.coin_ids, vec![id("c1")]); + assert_eq!(directive.left_unaffordable, 0); + } + + /// The defect this fix closes: `K` sized from the stale set's FLAT total (`balance + + /// Σ Rᵢ`) can size a `K` whose first `K` coins do NOT actually fund each other in sequence, + /// because reclaims are separate spends -- reclaiming `s1` alone returns only `s1`'s own + /// collateral, never `s3`'s. `[100, 100, 1000]` at `balance = 0`, `per_coin = 1_000`: the flat + /// total is `1_200`, so a flat-total split would call one coin affordable; the running-balance + /// walk sees `0 + 100 = 100 < 1_000` at the very first coin and refuses. + #[test] + fn gate8_k_is_a_self_funding_prefix_not_a_flat_total() { + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = vec![bond("s1", "r1"), bond("s2", "r2"), bond("s3", "r3")]; + f.bonded = vec![ + declared_at("c1", "s1", "r1", old_urls(), 100), + declared_at("c2", "s2", "r2", old_urls(), 100), + declared_at("c3", "s3", "r3", old_urls(), 1_000), + ]; + f.dig_balance_base_units = Some(0); + assert_eq!( + decide(&f.inputs()), + Err(RefusalReason::InsufficientFunds { + have_dig_base_units: 100, + need_dig_base_units: PER_COIN, + }) + ); + } + + /// A big FIRST coin's returned collateral funds the smaller recreates walked after it: `[1000, + /// 100, 100]` at `balance = 0`, `per_coin = 500`. Walk: `0+1000=1000 ≥ 500` -> `k=1`, running + /// `500`; `500+100=600 ≥ 500` -> `k=2`, running `100`; `100+100=200 < 500` -> stop. `K = 2`, + /// one coin left unaffordable. + #[test] + fn gate8_a_big_first_coin_funds_the_smaller_ones_behind_it() { + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = vec![bond("s1", "r1"), bond("s2", "r2"), bond("s3", "r3")]; + f.bonded = vec![ + declared_at("c1", "s1", "r1", old_urls(), 1_000), + declared_at("c2", "s2", "r2", old_urls(), 100), + declared_at("c3", "s3", "r3", old_urls(), 100), + ]; + f.requirement = CollateralRequirementResult::Known { + epoch: NOW_EPOCH as u64, + protocol_version: 1, + required_per_store_dig_base_units: 500, + stores: 1, + owners: 1, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, + }; + f.dig_balance_base_units = Some(0); + let directive = decide(&f.inputs()).unwrap(); + assert_eq!(directive.coin_ids, vec![id("c1"), id("c2")]); + assert_eq!(directive.left_unaffordable, 1); + } + + /// The walk never reorders to let a later big coin rescue an earlier unfundable one: `[100, + /// 1000]` at `balance = 0`, `per_coin = 500` stops at the first coin (`0+100=100 < 500`) even + /// though `s2` alone could fund a recreate -- canonical order is fixed, not a knapsack. + #[test] + fn gate8_a_later_big_coin_never_rescues_an_earlier_unfundable_one() { + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = vec![bond("s1", "r1"), bond("s2", "r2")]; + f.bonded = vec![ + declared_at("c1", "s1", "r1", old_urls(), 100), + declared_at("c2", "s2", "r2", old_urls(), 1_000), + ]; + f.requirement = CollateralRequirementResult::Known { + epoch: NOW_EPOCH as u64, + protocol_version: 1, + required_per_store_dig_base_units: 500, + stores: 1, + owners: 1, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, + }; + f.dig_balance_base_units = Some(0); + assert_eq!( + decide(&f.inputs()), + Err(RefusalReason::InsufficientFunds { + have_dig_base_units: 100, + need_dig_base_units: 500, + }) + ); + } + + #[test] + fn gate9_refuses_when_a_reconcile_is_already_in_progress() { + let mut f = Fixture::holding_one_stale_bond(); + f.reconcile_in_progress = true; + assert_eq!(decide(&f.inputs()), Err(RefusalReason::ReconcileInProgress)); + } + + // --- the directive itself -------------------------------------------------------------------- + + #[test] + fn names_every_stale_coin_when_funds_allow() { + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = vec![bond("s1", "r1"), bond("s2", "r2")]; + f.bonded = vec![ + declared("c1", "s1", "r1", old_urls()), + declared("c2", "s2", "r2", old_urls()), + ]; + let directive = decide(&f.inputs()).unwrap(); + assert_eq!( + directive.coin_ids, + vec![id("c1"), id("c2")], + "canonical (store, root) order" + ); + assert_eq!(directive.left_unaffordable, 0); + } + + /// A coin that already matches is left OUT of the directive entirely, even though a sibling in + /// the same call is stale — distinguishes "reconcile the stale set" from "reconcile everything + /// held", which the single-stale-coin fixtures above cannot tell apart. + #[test] + fn a_matching_coin_is_never_named_while_a_sibling_is_stale() { + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = vec![bond("s1", "r1"), bond("s2", "r2")]; + f.bonded = vec![ + declared("c1", "s1", "r1", old_urls()), // stale + declared("c2", "s2", "r2", new_urls()), // already current + ]; + let directive = decide(&f.inputs()).unwrap(); + assert_eq!(directive.coin_ids, vec![id("c1")]); + } + + /// **The property that outranks the rest**: with funds for only ONE of two stale recreates, the + /// directive names exactly the affordable prefix and reports the rest as left, rather than + /// naming all of them (which would reclaim a bond this call cannot afford to recreate). + #[test] + fn names_only_the_affordable_prefix_when_funds_are_short() { + // Both stale coins locked BELOW the current price -- the mid-epoch-margin-raise case + // (SPEC.md §25.13.5): a coin locked at exactly today's price always funds its own recreate + // on reclaim alone, so `K < n` cannot arise unless at least one coin locked less than that. + const OLD_COLLATERAL: u64 = PER_COIN - 200; + let mut f = Fixture::holding_one_stale_bond(); + f.held_bonds = vec![bond("s1", "r1"), bond("s2", "r2")]; + f.bonded = vec![ + declared_at("c1", "s1", "r1", old_urls(), OLD_COLLATERAL), + declared_at("c2", "s2", "r2", old_urls(), OLD_COLLATERAL), + ]; + // augmented = 200 + 2*800 = 1_800 -- funds exactly one recreate at PER_COIN (1_000), not two. + f.dig_balance_base_units = Some(200); + let directive = decide(&f.inputs()).unwrap(); + assert_eq!( + directive.coin_ids, + vec![id("c1")], + "only the affordable prefix is named" + ); + assert_eq!(directive.left_unaffordable, 1); + } + + /// A coin bonding a FUTURE epoch is never stale, whatever its URLs say -- the same "keep" rule + /// an ordinary pass applies to a future-epoch coin (a slow local clock must not burn a fee and + /// destroy a coin that becomes ordinary at the next tick anyway). + #[test] + fn a_future_epoch_coin_is_never_stale() { + let mut f = Fixture::holding_one_stale_bond(); + f.bonded = vec![DeclaredBond { + held: HeldMirror { + coin_id: id("c1"), + store_id: id("s1"), + root: id("r1"), + epoch: NOW_EPOCH + 1, + collateral_dig_base_units: PER_COIN, + }, + urls: old_urls(), + }]; + assert_eq!(decide(&f.inputs()), Err(RefusalReason::NoMirrorCoins)); + } + + /// The directive echoes whichever trigger asked, for the audit record (`SPEC.md` §F) — it is + /// not itself a gate input, so this is the one property no gate-refusal fixture above proves. + #[test] + fn the_directive_carries_the_trigger_that_asked() { + let f = Fixture::holding_one_stale_bond(); + let mut inputs = f.inputs(); + inputs.trigger = plan::Trigger::Manual; + assert_eq!(decide(&inputs).unwrap().trigger, plan::Trigger::Manual); + } +} diff --git a/crates/dig-node-service/src/mirror/reconcile_state.rs b/crates/dig-node-service/src/mirror/reconcile_state.rs new file mode 100644 index 00000000..9672ab31 --- /dev/null +++ b/crates/dig-node-service/src/mirror/reconcile_state.rs @@ -0,0 +1,296 @@ +//! Persisted state for the daily mirror-URL reconcile detector (`SPEC.md` §25.13.9, dig-node#570). +//! +//! # Losing this file costs a day, never a spend — and it is the ONLY direction that is safe +//! +//! `mirror-reconcile.json` is a THROTTLE record, not a source of truth: [`super::plan`] and +//! `dig_mirror_coin::list` are the only steady-state truths about what is bonded (`SPEC.md` §25.1), +//! exactly as the ordinary lifecycle already holds. A missing, unreadable or malformed file reads +//! as **"never observed"** — every field `None` or empty — and is overwritten by the next check. +//! That delays any automatic spend by at least two personal days (hysteresis needs two agreeing +//! observations) and cannot cause one. Contrast a hypothetical design that persisted a COIN ID here +//! as evidence of a completed reclaim: dig-node#574 records why a persisted id must always be +//! re-verified against chain before being believed, and the cheapest way to honour that here is to +//! never persist one at all. + +use serde::{Deserialize, Serialize}; + +use super::schedule::Observation; + +/// The file name, beside `collateral.json` in the node's state directory (`SPEC.md` §25.13.9). +const RECONCILE_STATE_FILE: &str = "mirror-reconcile.json"; + +fn default_version() -> u32 { + 1 +} + +/// The most recent INCONCLUSIVE daily check — recorded so an operator can see WHY the last drift +/// report, if any, did not turn into a plan (`SPEC.md` §C's posture object renders this). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InconclusiveObservation { + /// The personal day the check ran on. + pub personal_day: i64, + /// The `SPEC.md` §25.10 advertise-state label that made the gather inconclusive + /// (`off` / `no_public_address` / `uncorroborated_address` / `no_relay`). + pub state: String, +} + +/// What one daily check concluded — the two outcomes [`ReconcileState::record_check`] accepts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CheckOutcome { + /// The fresh gather established an address (`SPEC.md` §25.13.7.3): `Override` or `Derived`. + Conclusive { + /// The URL set this node would advertise, had it reconciled right then. + urls: Vec, + }, + /// The fresh gather did not establish an address: `Off`, `NoPublicAddress`, `Uncorroborated` + /// or `NoRelay`. The PUBLISHED readings are left exactly as they were — fail closed toward the + /// old address rather than adopting an uncorroborated new one. + Inconclusive { + /// The `SPEC.md` §25.10 label naming which of the four it was. + state: &'static str, + }, +} + +/// Persisted detector state — `mirror-reconcile.json`, `SPEC.md` §25.13.9. +/// +/// Every field `#[serde(default)]` so a file written by an earlier version of this struct still +/// parses: a field that did not exist yet reads as "never observed" for that field alone, which is +/// the same safe direction the whole file falls back to when it is missing entirely. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReconcileState { + /// Format version, for a future migration to detect. `1` today. + #[serde(default = "default_version")] + pub version: u32, + /// The personal-day index of the last COMPLETED check (`SPEC.md` §25.13.7.2) — conclusive or + /// not, completing the day either way so a single STUN blip cannot turn into an hourly retry. + #[serde(default)] + pub last_completed_day: Option, + /// The two most recent CONCLUSIVE observations, oldest first, at most two + /// (`SPEC.md` §25.13.7.4's hysteresis reads exactly this pair). + #[serde(default)] + pub observations: Vec, + /// The most recent INCONCLUSIVE check, if the last completed check was one. + #[serde(default)] + pub last_inconclusive: Option, + /// The epoch of the last automatic reconcile with at least one ACCEPTED reclaim + /// (`SPEC.md` §25.13.7.5's cap). + #[serde(default)] + pub last_auto_reconcile_epoch: Option, +} + +impl Default for ReconcileState { + fn default() -> Self { + ReconcileState { + version: default_version(), + last_completed_day: None, + observations: Vec::new(), + last_inconclusive: None, + last_auto_reconcile_epoch: None, + } + } +} + +impl ReconcileState { + /// Load from the node's own machine-wide state directory. + pub fn load() -> Self { + Self::load_from(&crate::state::state_dir()) + } + + /// Persist to the node's own machine-wide state directory. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&crate::state::state_dir()) + } + + /// Load from an explicit directory. For tests and callers that already own one. + /// + /// A missing file, an unreadable one and a malformed one all fall back to + /// [`ReconcileState::default`] — "never observed" — which is the one direction that cannot + /// itself cause a spend (see the module doc). Unlike [`super::collateral`]'s preference file, + /// nothing here reaches a spend path without ALSO passing the hysteresis and epoch-cap checks + /// that read this state, so the fallback is silent by design: there is no decision an operator + /// made that this loss could silently revert. + pub fn load_from(dir: &std::path::Path) -> Self { + let path = dir.join(RECONCILE_STATE_FILE); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(_) => return Self::default(), + }; + serde_json::from_str(&text).unwrap_or_default() + } + + /// Persist to `dir`, atomically: written beside the real path and renamed over it, so a torn + /// write (a crash mid-save) cannot leave a half-written file that fails to parse. A parse + /// failure would fall back to [`Self::default`] anyway (see [`Self::load_from`]), but the + /// rename is what makes that fallback rare rather than routine on every unclean shutdown. + pub fn save_to(&self, dir: &std::path::Path) -> std::io::Result<()> { + crate::state::ensure_dir_restricted(dir)?; + let path = dir.join(RECONCILE_STATE_FILE); + let temp = path.with_extension("json.tmp"); + let body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(&temp, &body)?; + crate::control::restrict_permissions(&temp); + std::fs::rename(&temp, &path)?; + crate::control::restrict_permissions(&path); + Ok(()) + } + + /// Record that a check completed on `day`, one way or the other. The ONLY writer of + /// [`Self::last_completed_day`], [`Self::observations`] and [`Self::last_inconclusive`] — a + /// single entry point so "record the outcome" and "mark the day completed" can never be done + /// as two separate steps, one of which a caller forgets. + pub fn record_check(&mut self, day: i64, outcome: CheckOutcome) { + match outcome { + CheckOutcome::Conclusive { urls } => { + self.observations.push(Observation { + personal_day: day, + urls, + }); + // Keep only the two most recent -- `SPEC.md` §25.13.7.4 reads exactly this pair. + // A `Vec` rather than a fixed `[Observation; 2]` because a fresh node's first + // observation is a ONE-element state that is not yet stable, and that is a real, + // representable state rather than an error. + while self.observations.len() > 2 { + self.observations.remove(0); + } + self.last_inconclusive = None; + } + CheckOutcome::Inconclusive { state } => { + self.last_inconclusive = Some(InconclusiveObservation { + personal_day: day, + state: state.to_string(), + }); + } + } + self.last_completed_day = Some(day); + } + + /// Record that an automatic reconcile ran in `epoch` with at least one accepted reclaim. + pub fn mark_auto_reconciled(&mut self, epoch: i64) { + self.last_auto_reconcile_epoch = Some(epoch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_file_reads_as_never_observed() { + let dir = tempfile::tempdir().unwrap(); + let state = ReconcileState::load_from(dir.path()); + assert_eq!(state, ReconcileState::default()); + } + + #[test] + fn a_malformed_file_reads_as_never_observed_rather_than_panicking() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(RECONCILE_STATE_FILE), b"{ not json ").unwrap(); + let state = ReconcileState::load_from(dir.path()); + assert_eq!(state, ReconcileState::default()); + } + + #[test] + fn round_trips_through_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let mut state = ReconcileState::default(); + state.record_check( + 5, + CheckOutcome::Conclusive { + urls: vec!["https://a.example:9444".to_string()], + }, + ); + state.mark_auto_reconciled(3); + state.save_to(dir.path()).unwrap(); + + let reloaded = ReconcileState::load_from(dir.path()); + assert_eq!(reloaded, state); + } + + /// **An OLDER file missing a field the struct now has** must still parse, reading the missing + /// field as its safe default — the whole point of `#[serde(default)]` on every field. + #[test] + fn an_older_file_missing_a_field_still_parses() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(RECONCILE_STATE_FILE), + br#"{"version": 1, "last_completed_day": 7}"#, + ) + .unwrap(); + let state = ReconcileState::load_from(dir.path()); + assert_eq!(state.last_completed_day, Some(7)); + assert_eq!(state.observations, Vec::new()); + assert_eq!(state.last_auto_reconcile_epoch, None); + } + + #[test] + fn only_the_two_most_recent_conclusive_observations_are_kept() { + let mut state = ReconcileState::default(); + state.record_check( + 1, + CheckOutcome::Conclusive { + urls: vec!["a".into()], + }, + ); + state.record_check( + 2, + CheckOutcome::Conclusive { + urls: vec!["b".into()], + }, + ); + state.record_check( + 3, + CheckOutcome::Conclusive { + urls: vec!["c".into()], + }, + ); + + assert_eq!( + state.observations.len(), + 2, + "a third observation must evict the oldest" + ); + assert_eq!(state.observations[0].personal_day, 2); + assert_eq!(state.observations[1].personal_day, 3); + } + + /// **An inconclusive check clears the LAST-inconclusive marker's staleness but must NOT erase + /// the conclusive observation history** — a single STUN blip must not reset hysteresis back to + /// zero, which is exactly the flap-tolerance property `SPEC.md` §25.13.7.3's "A, inconclusive, + /// A" example depends on. + #[test] + fn an_inconclusive_check_does_not_erase_prior_conclusive_observations() { + let mut state = ReconcileState::default(); + state.record_check( + 1, + CheckOutcome::Conclusive { + urls: vec!["a".into()], + }, + ); + state.record_check(2, CheckOutcome::Inconclusive { state: "no_relay" }); + + assert_eq!( + state.observations.len(), + 1, + "the day-1 observation must survive" + ); + assert_eq!(state.last_completed_day, Some(2)); + assert!(state.last_inconclusive.is_some()); + } + + /// **A subsequent CONCLUSIVE check clears the inconclusive marker** — it is "the most recent + /// inconclusive check", and once a conclusive one has run, there no longer is one to report. + #[test] + fn a_conclusive_check_after_an_inconclusive_one_clears_the_marker() { + let mut state = ReconcileState::default(); + state.record_check(1, CheckOutcome::Inconclusive { state: "off" }); + assert!(state.last_inconclusive.is_some()); + + state.record_check( + 2, + CheckOutcome::Conclusive { + urls: vec!["a".into()], + }, + ); + assert!(state.last_inconclusive.is_none()); + } +} diff --git a/crates/dig-node-service/src/mirror/resolve_tests.rs b/crates/dig-node-service/src/mirror/resolve_tests.rs index 266d2aee..945f91c9 100644 --- a/crates/dig-node-service/src/mirror/resolve_tests.rs +++ b/crates/dig-node-service/src/mirror/resolve_tests.rs @@ -85,6 +85,8 @@ fn intent(store: &str, root: &str, epoch: i64) -> SpendIntent { epoch, }), advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 31f655fb..9d1d5037 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -64,6 +64,22 @@ pub struct ObservedCapsule { pub provenance: CapsuleProvenance, } +/// One coin [`MirrorEffects::observe_chain`] would also report, together with the URL set its own +/// memos declare (dig-node#570). +/// +/// Wraps [`HeldMirror`] instead of duplicating its fields, and adds exactly the one thing +/// `observe_chain` does not carry. `HeldMirror` stays lean on purpose — no other consumer of it +/// ever needs a URL — so this type exists for the one question that does: +/// [`super::reconcile::decide`] comparing what a coin declares against what this +/// node would advertise NOW, after its public address may have changed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredBond { + /// The coin, exactly as `observe_chain` would report it. + pub held: HeldMirror, + /// The URLs this coin's memos declare it can be fetched at. + pub urls: Vec, +} + /// Why a pass could not complete a step. Carries no key material and no puzzle hash. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PassError { @@ -130,6 +146,24 @@ pub trait MirrorEffects { /// The mirror coins this wallet owns — `dig_mirror_coin::list(source, owner_puzzle_hash)`. fn observe_chain(&self) -> Result, PassError>; + /// Every mirror coin this wallet owns, together with the URL set each one declares + /// (dig-node#570). + /// + /// A superset of [`Self::observe_chain`] for the one caller that must compare a coin's OWN + /// advertisement against what this node would advertise NOW — + /// [`super::reconcile::decide`]. Not folded into `observe_chain` itself: + /// doing so would make every implementor of this trait, including every existing test double, + /// carry a URL list nothing else has any use for. + /// + /// Defaulted to empty so every existing implementor keeps compiling unchanged. + /// [`super::lifecycle::NodeMirrorEffects`] is the only override, and it reads the SAME + /// authenticated scan `observe_chain` already performs rather than taking a second chain read. + /// An implementor that has not overridden this reports no coins at all — the safe direction, + /// since reconcile reads that as "nothing to reconcile" rather than guessing a URL match. + fn observe_bonded_urls(&self) -> Result, PassError> { + Ok(Vec::new()) + } + /// What the chain says about `coin_id`, for resolving a spend this node broadcast in an /// EARLIER pass (dig-node#412 step 6). /// @@ -214,6 +248,25 @@ pub struct PassContext { /// the pass must not plan or price creates it cannot attempt. Read once at bring-up beside the /// advertised URL list itself, because a coin's URLs are fixed at create for the whole epoch. pub can_advertise: bool, + /// A URL-reconcile attempt for THIS pass (`SPEC.md` §25.13, dig-node#570) — `None` on the + /// overwhelming majority of passes. `Some` only when the daily detector or + /// `control.mirror.reconcile` has ALREADY cleared its OWN pre-conditions (the switch, + /// hysteresis, the epoch cap — none of which is [`super::reconcile::decide`]'s to re-check, + /// `SPEC.md` §25.13.4's own text) and is asking this pass to evaluate the remaining gates + /// against what THIS pass observes. + pub reconcile: Option, +} + +/// One caller's request that THIS pass attempt a URL reconcile — the trigger asking, and the +/// address it would reconcile toward. +#[derive(Debug, Clone)] +pub struct ReconcileAttempt { + /// Which caller is asking, carried to the audit record (`SPEC.md` §F). + pub trigger: super::plan::Trigger, + /// What this node would advertise THIS pass — gate 1's input and `SPEC.md` §25.13.3's target. + /// Threaded in rather than re-derived, so this pass's reconcile decision and its ordinary + /// create pricing can never disagree about the address. + pub advertised: super::advertise::Effective, } /// What one pass actually did. @@ -263,6 +316,12 @@ pub struct PassReport { /// tracker is -- a gate rebuilt each round has no memory of having spoken, and would speak every /// round. pub funding_alert: Option, + /// How many `UrlStale` reclaims this pass ACCEPTED (dig-node#570, `SPEC.md` §25.13.7.5). + /// + /// Zero on every ordinary pass. The daily scheduler reads this to decide whether ITS directive + /// actually landed anything and, if so, marks the epoch cap spent — [`HeldMirror`] itself + /// carries no reason, so this is the only place that fact survives past [`Self::execute`]. + pub reconcile_url_stale_accepted: usize, } /// Runs reconcile passes. Long-lived: it owns the presence tracker and the funding alert gate, the @@ -334,6 +393,19 @@ impl PassRunner { self.presence } + /// Hand back the effects THIS runner already built, together with the presence tracker. + /// + /// A single consuming accessor rather than a second [`Self::into_presence`]-shaped one: + /// [`Self::into_presence`] and an effects-only accessor would both need `self` by value, and + /// only one by-value call can ever have it. This exists for dig-node#570's daily reconcile + /// check, which piggybacks on the SAME per-tick effects an ordinary pass just built — reading + /// its already-authenticated coin scan rather than opening a second one, and seeing every + /// funding coin the ordinary pass just committed rather than risking a double-spend against a + /// freshly-built effects that does not know about it. + pub fn into_effects_and_presence(self) -> (E, super::presence::PresenceTracker) { + (self.effects, self.presence) + } + /// Write the audit record through `journal` instead of the default one over this runner's log. /// /// Exists so a test can pin the clock. A journal over a DIFFERENT log would make the runner @@ -432,6 +504,41 @@ impl PassRunner { .map(|c| c.collateral_dig_base_units) .fold(0u64, u64::saturating_add); + // dig-node#570: only when a caller has ALREADY asked for one this pass (the daily + // detector's own hysteresis/cap/switch, or a manual call — neither is this function's to + // re-check, `SPEC.md` §25.13.4's own text). A FRESH bonded-urls read here, not a second + // general chain scan: bounded to at most once per personal day, since `ctx.reconcile` is + // `None` on every ordinary pass. + let reconcile_directive = match &ctx.reconcile { + Some(attempt) => { + let bonded = self.effects.observe_bonded_urls().unwrap_or_default(); + match super::reconcile::decide(&super::reconcile::ReconcileInputs { + trigger: attempt.trigger, + advertised: &attempt.advertised, + mirror_enabled: ctx.creates_enabled, + held_bonds: &held, + bonded: &bonded, + current_epoch: ctx.current_epoch, + requirement: &ctx.requirement, + margin_bp: ctx.margin_bp, + dig_balance_base_units, + reconcile_in_progress: reconcile_in_flight(&self.log, ctx.now_unix_ms), + }) { + Ok(directive) => Some(directive), + Err(reason) => { + tracing::info!( + target: "mirror", + trigger = attempt.trigger.label(), + reason = reason.label(), + "URL reconcile refused; every mirror coin is left exactly as it was" + ); + None + } + } + } + None => None, + }; + let decision = pass::decide(&PassInputs { held: &held, relayed: &relayed, @@ -443,6 +550,7 @@ impl PassRunner { dig_balance_base_units, creates_enabled: ctx.creates_enabled, can_advertise: ctx.can_advertise, + reconcile: reconcile_directive.as_ref(), }); Ok(self.execute(decision, ctx.current_epoch, locked_dig_base_units)) @@ -465,13 +573,23 @@ impl PassRunner { let mut reclaimed = Vec::new(); let mut reclaim_failures = Vec::new(); + // dig-node#570: counted here, before `reason` is dropped, because [`HeldMirror`] itself + // carries no reason and the caller (the daily scheduler) needs to know whether ITS + // directive actually landed anything, to decide whether the epoch cap (`SPEC.md` + // §25.13.7.5) is now spent. + let mut reconcile_url_stale_accepted = 0usize; // EVERY reclaim is attempted, and one that fails does not stop the next. These are the only // spends here that RETURN money, so the cost of skipping one is a round of locked collateral // — whereas the cost of attempting one that fails is a log line. for (mirror, reason) in reclaim { match self.effects.reclaim(&mirror, reason) { - Ok(()) => reclaimed.push(mirror), + Ok(()) => { + if matches!(reason, ReclaimReason::UrlStale(_)) { + reconcile_url_stale_accepted += 1; + } + reclaimed.push(mirror); + } Err(e) => reclaim_failures.push((mirror, e)), } } @@ -593,6 +711,7 @@ impl PassRunner { per_coin_dig_base_units, locked_dig_base_units, funding_alert, + reconcile_url_stale_accepted, } } } @@ -676,6 +795,37 @@ fn in_flight_creates(ledger: &crate::spend_audit::SpendLedger, current_epoch: i6 .collect() } +/// `SPEC.md` §25.13.4 gate 9 (dig-node#570) — is an earlier `url_stale` reclaim from THIS node +/// still unresolved? +/// +/// "In progress" from the first such entry's `Submitted` status until every one is terminal, +/// bounded by [`crate::spend_audit::FUNDING_RESERVATION_WINDOW_MS`] from its last revision — reused +/// via [`crate::spend_audit::SpendRecord::reserves_funding_at`] rather than a second definition of +/// the same window, so gate 9 and the funding reservation can never disagree about how long an +/// unresolved spend counts. +fn reconcile_in_flight(log: &SpendLog, now_unix_ms: u64) -> bool { + let ledger = match log.ledger() { + Ok(ledger) => ledger, + // Same direction as `in_flight_creates`: an unreadable ledger must not be read as "a + // reconcile is in progress" forever -- that would permanently refuse every future reconcile + // with no way to recover short of editing the audit file by hand. + Err(e) => { + tracing::warn!( + target: "mirror", + error = %e, + "the spend audit record could not be read; gate 9 assumes no reconcile is in progress" + ); + return false; + } + }; + + ledger.records.iter().any(|r| { + r.kind.as_str() == crate::spend_audit::kinds::MIRROR_COIN + && r.reclaim_reason.as_deref() == Some("url_stale") + && r.reserves_funding_at(now_unix_ms) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -851,6 +1001,7 @@ mod tests { margin_bp: 0, creates_enabled: true, can_advertise: true, + reconcile: None, } } @@ -1411,6 +1562,8 @@ mod tests { epoch, }), advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } @@ -2043,6 +2196,8 @@ mod tests { epoch: NOW_EPOCH, }), advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, }); journal.submitted( &recorded, @@ -2118,6 +2273,8 @@ mod tests { epoch: NOW_EPOCH, }), advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, }); journal.submitted( &recorded, diff --git a/crates/dig-node-service/src/mirror/schedule.rs b/crates/dig-node-service/src/mirror/schedule.rs new file mode 100644 index 00000000..6ae51e51 --- /dev/null +++ b/crates/dig-node-service/src/mirror/schedule.rs @@ -0,0 +1,344 @@ +//! The **personal day** — dig-node#570's daily reflexive-address check, spread across the network +//! without a synchronised spike, and the hysteresis + epoch cap that keep it from spending on a flap. +//! +//! # Derived, never drawn — the failure this avoids +//! +//! The user asked for a random daily time so the whole network does not hit the STUN tier at once +//! (dig-node#570, 2026-09-05: *"daily at a random time so the whole network doesnt hit the stun +//! server at once"*). The obvious implementation — draw a random offset once, at start-up or once +//! ever, and persist it — has a silent failure mode: a node that restarts before its slot re-rolls +//! (start-up draw) or loses its state file (persisted draw) and can go indefinitely without a single +//! check while every log line looks normal. A THUNDERING HERD IS AT LEAST VISIBLE; A NODE THAT +//! SILENTLY NEVER CHECKS IS NOT. +//! +//! So the offset is a pure function of the node's own `peer_id` (`SPEC.md` §25.13.7.1): +//! +//! ```text +//! offset_secs = u64::from_be_bytes(SHA-256(TAG ‖ peer_id)[0..8]) mod 86_400 +//! ``` +//! +//! This has no state to lose. A restart recomputes the SAME offset from the SAME identity, so a +//! node that restarts daily still checks daily — it just does so within its personal day, wherever +//! that day's boundary already was, rather than re-rolling a coin toss on every boot. It is random +//! ACROSS the network (peer ids are hashes, so offsets are uniform) without being random over TIME +//! for any one node — which is the only kind of randomness the requirement actually needs. +//! +//! # What deriving from a public value gives away, bounded +//! +//! A peer id is not secret, so a third party who knows one knows that node's slot. What that buys +//! is the ability to time a STUN-tier outage or a flood of dissenting readings at a node's check — +//! which makes the check INCONCLUSIVE (fails toward staleness, never toward a spend) and cannot +//! itself cause a reclaim: a spend requires agreement across independent source classes +//! (`dig_stun::establish`, NC-12), which timing does not provide. The offset MUST NOT be derived +//! from any address or reading, precisely so the schedule cannot correlate with what it measures. + +/// One personal day is 24 hours, exactly — the period the offset subdivides `00:00 UTC` into. +const PERSONAL_DAY_SECS: u64 = 86_400; + +/// The domain-separation tag for the offset derivation (`SPEC.md` §25.13.7.1). Versioned so a future +/// change to the derivation is a new tag, never a silent reinterpretation of the old one — which +/// would move every node's slot on the same night without anyone deciding to. +const PERSONAL_DAY_TAG: &[u8] = b"dig-node/mirror-url-reconcile/personal-day/v1"; + +/// `SPEC.md` §25.13.7.5: at most one automatic reconcile per mirror epoch. Rollover already costs +/// every bonded capsule one reclaim and one create per epoch; this cap means the automatic URL +/// reconcile adds at most one more pair — the lifecycle's unattended spend count is at most +/// DOUBLED, never unbounded, however often an address actually changes. +pub const URL_RECONCILE_MAX_AUTO_PER_EPOCH: u32 = 1; + +/// Derive this node's personal-day offset from its 32-byte `peer_id`. +/// +/// Pure and reproducible: the same `peer_id` always yields the same offset, on every machine, on +/// every day — which is what lets `dign mirror bond-states` print it and an operator verify it by +/// hand (`SPEC.md` §C). Never persisted; there is nothing here to lose. +/// +/// A node with no `peer_id` yet (the peer network disabled or not up) hashes the empty slice like +/// any other input — the offset is still deterministic, just meaningless for an identity-less node, +/// which is harmless because such a node gathers no readings and cannot be part of a STUN herd +/// (`SPEC.md` §25.13.7.1), so the offset is never acted on. +pub fn personal_day_offset_secs(peer_id: &[u8]) -> u64 { + let mut hasher = chia_sha2::Sha256::new(); + hasher.update(PERSONAL_DAY_TAG); + hasher.update(peer_id); + let digest = hasher.finalize(); + + let mut first8 = [0u8; 8]; + first8.copy_from_slice(&digest[0..8]); + u64::from_be_bytes(first8) % PERSONAL_DAY_SECS +} + +/// The personal-day index of `now_unix_secs`, floored toward negative infinity. +/// +/// Floor-toward-negative-infinity (not truncation) matters on day zero: an instant that precedes +/// the offset by a few seconds must resolve to day **-1**, not wrap to a huge positive index via +/// unsigned subtraction. `i64` arithmetic throughout keeps that representable. +pub fn personal_day_index(now_unix_secs: i64, offset_secs: u64) -> i64 { + (now_unix_secs - offset_secs as i64).div_euclid(PERSONAL_DAY_SECS as i64) +} + +/// Is a check due? Four consequences fall out of this one comparison (`SPEC.md` §25.13.7.2), each +/// stated because each is easy to get backwards: +/// +/// * **At most one check per personal day** — `d(now) == last_completed` is not due. +/// * **A clock that moves BACKWARD never makes a check due** — `d(now) < last_completed` is not due +/// either; the node waits for real time to catch back up rather than re-checking on the way down. +/// * **A clock that jumps FORWARD by `N` days makes exactly one check due**, not `N`: this predicate +/// only ever answers yes/no for THIS instant, and the caller marks the day completed on any +/// outcome (conclusive or not), so the very next evaluation — even one second later, after a +/// multi-day jump — already reads `d(now) == last_completed` and refuses to double up. +/// * **`None` (never observed) is always due.** +pub fn is_due(now_unix_secs: i64, offset_secs: u64, last_completed_day: Option) -> bool { + let today = personal_day_index(now_unix_secs, offset_secs); + match last_completed_day { + None => true, + Some(last) => today > last, + } +} + +/// One CONCLUSIVE observation of what this node would advertise, taken on one personal day. +/// +/// `urls` is compared as a SET (order ignored) everywhere this type is consulted — `SPEC.md` +/// §25.13.3's reason applies here identically: an operator's URL list is published in the order +/// they set it, with a derived IPv6 candidate placed first, so a reorder is not a change. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Observation { + /// The personal day this observation was taken on. + pub personal_day: i64, + /// The URL set this node would have advertised, had it reconciled right then. + pub urls: Vec, +} + +/// Two URL sets, compared the way every clause in this module compares them: as sets, order +/// ignored. A private helper rather than a `HashSet` at the call site, so every comparison in this +/// module (and nowhere else) shares one definition of "the same address". +fn same_url_set(a: &[String], b: &[String]) -> bool { + let a: std::collections::BTreeSet<&String> = a.iter().collect(); + let b: std::collections::BTreeSet<&String> = b.iter().collect(); + a == b +} + +/// `SPEC.md` §25.13.7.4 — is `target` STABLE against the two most recent conclusive observations? +/// +/// Stable means both recorded observations agree with `target` (set equality) AND were taken on two +/// DISTINCT personal days. A single observation is never enough — that would spend on the first +/// STUN answer after a network blip — and two observations on the SAME day (which [`is_due`] should +/// never produce, but this function does not trust that) are one measurement wearing two dates. +/// +/// Feeding this fewer than two observations, or two that disagree, or two on the same day, all +/// answer `false` — the safe direction: hysteresis fails toward NOT spending. +pub fn is_stable(observations: &[Observation], target: &[String]) -> bool { + let [a, b] = observations else { return false }; + a.personal_day != b.personal_day + && same_url_set(&a.urls, target) + && same_url_set(&b.urls, target) +} + +/// `SPEC.md` §25.13.7.5 — may the automatic trigger reconcile in `current_epoch`? +/// +/// `false` exactly when this epoch already has one accepted automatic reconcile recorded. The cap +/// is keyed on the epoch the reconcile ran in, not on a rolling window, so it resets for free at +/// every rollover — the same boundary that already closes any remaining drift at no cost. +pub fn epoch_cap_allows(last_auto_reconcile_epoch: Option, current_epoch: i64) -> bool { + last_auto_reconcile_epoch != Some(current_epoch) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- personal_day_offset_secs: golden vectors --------------------------------------------- + + /// **Golden vector.** `python3 -c "import hashlib; print(int.from_bytes(hashlib.sha256(b'dig-node/mirror-url-reconcile/personal-day/v1' + bytes(range(32))).digest()[:8],'big') % 86400)"` + /// prints `78322`. Pinned so a change to the derivation (tag, byte order, truncation) is caught + /// as a broken vector rather than silently moving every node's slot on the same night. + #[test] + fn golden_vector_sequential_peer_id() { + let peer_id: Vec = (0u8..32).collect(); + assert_eq!(personal_day_offset_secs(&peer_id), 78_322); + } + + /// **A second vector, with a DIFFERENT identity.** Distinguishes "the derivation depends on + /// `peer_id`" from "the derivation returns a fixed constant that happens to satisfy the first + /// vector" — a fixture the first vector alone cannot rule out. + #[test] + fn golden_vector_all_ff_peer_id_differs_from_sequential() { + let peer_id = [0xFFu8; 32]; + assert_eq!(personal_day_offset_secs(&peer_id), 43_202); + } + + #[test] + fn offset_is_reproducible_for_the_same_identity() { + let peer_id = b"some-32-byte-peer-id-000000000!!".to_vec(); + assert_eq!(peer_id.len(), 32); + assert_eq!( + personal_day_offset_secs(&peer_id), + personal_day_offset_secs(&peer_id), + "a restart must recompute the SAME slot from the SAME identity" + ); + } + + #[test] + fn offset_is_always_within_one_day() { + for seed in 0u8..20 { + let peer_id = [seed; 32]; + assert!(personal_day_offset_secs(&peer_id) < PERSONAL_DAY_SECS); + } + } + + // --- personal_day_index --------------------------------------------------------------------- + + #[test] + fn day_index_at_exactly_the_offset_is_zero() { + assert_eq!(personal_day_index(1_000, 1_000), 0); + } + + /// **The property this fixture exists to prove**: an instant a few seconds BEFORE the offset, + /// on day zero, is day **-1** — not a huge positive index from an unsigned wraparound. A naive + /// `(now - offset) / 86_400` in unsigned arithmetic would panic or wrap here; a naive truncating + /// signed division would round toward zero and report day 0, one day too late. + #[test] + fn day_index_just_before_the_offset_on_day_zero_is_negative_one() { + assert_eq!(personal_day_index(999, 1_000), -1); + } + + #[test] + fn day_index_advances_by_one_per_86400_seconds() { + let offset = 12_345; + let day0_start = offset as i64; + assert_eq!(personal_day_index(day0_start, offset), 0); + assert_eq!(personal_day_index(day0_start + 86_399, offset), 0); + assert_eq!(personal_day_index(day0_start + 86_400, offset), 1); + assert_eq!(personal_day_index(day0_start - 1, offset), -1); + } + + // --- is_due: the four clock consequences, each pinned ------------------------------------- + + #[test] + fn never_observed_is_always_due() { + assert!(is_due(0, 0, None)); + assert!(is_due(1_000_000_000, 54_321, None)); + } + + #[test] + fn same_personal_day_is_not_due_twice() { + let offset = 100; + let today = personal_day_index(50_000, offset); + assert!(!is_due(50_000, offset, Some(today))); + } + + #[test] + fn the_next_personal_day_is_due() { + let offset = 100; + let today = personal_day_index(50_000, offset); + assert!(is_due(50_000 + 86_400, offset, Some(today))); + } + + /// **A clock moving backward must never make a check due.** An NTP correction that steps the + /// clock back must not be read as "a new day arrived" — the node waits for real time to catch + /// back up rather than re-checking (and potentially re-spending) on the way down. + #[test] + fn a_backward_clock_is_never_due() { + let offset = 100; + let today = personal_day_index(200_000, offset); + assert!(!is_due(150_000, offset, Some(today))); + } + + /// **A forward jump of N days is due exactly once, not N times** — evaluating `is_due` again + /// immediately after marking the (post-jump) day completed must read as NOT due, proving the + /// predicate cannot be tricked into re-firing for the days it skipped over. + #[test] + fn a_forward_jump_of_several_days_is_due_exactly_once() { + let offset = 0; + let last = personal_day_index(0, offset); // day 0 + let after_jump = 10 * 86_400; // +10 days + assert!(is_due(after_jump, offset, Some(last))); + + // The caller marks TODAY (the post-jump day) completed, not day 1 -- and the next + // evaluation, even moments later, must not re-fire. + let now_completed = personal_day_index(after_jump, offset); + assert!(!is_due(after_jump + 1, offset, Some(now_completed))); + } + + // --- is_stable: hysteresis -------------------------------------------------------------------- + + fn obs(day: i64, url: &str) -> Observation { + Observation { + personal_day: day, + urls: vec![url.to_string()], + } + } + + #[test] + fn zero_or_one_observation_is_never_stable() { + assert!(!is_stable(&[], &[])); + assert!(!is_stable( + &[obs(1, "https://a")], + &["https://a".to_string()] + )); + } + + /// **The exact property named in the ticket**: `A, B, A` across three days — two DIFFERENT + /// readings among the three — is not stable, because the two most recent (`B`, `A`) disagree. + /// This is the flap the hysteresis rule exists to wait out, and it is the fixture that + /// distinguishes "compares the two most recent" from "compares any two", which a weaker + /// implementation (checking membership in a set of ever-seen addresses) would satisfy. + #[test] + fn a_b_a_across_three_days_is_not_stable() { + let observations = vec![obs(2, "https://b"), obs(3, "https://a")]; + assert!(!is_stable(&observations, &["https://a".to_string()])); + } + + /// **`A, inconclusive, A` establishes `A` on the third day** — an inconclusive day is never + /// RECORDED as an observation at all (the caller only pushes conclusive ones), so the two most + /// recent conclusive observations are `A` (day 1) and `A` (day 3): different days, same set. + #[test] + fn two_conclusive_agreements_on_distinct_days_are_stable_even_with_a_gap() { + let observations = vec![obs(1, "https://a"), obs(3, "https://a")]; + assert!(is_stable(&observations, &["https://a".to_string()])); + } + + #[test] + fn two_observations_on_the_same_day_are_never_stable() { + // Defensive: `is_due` should prevent this from ever being recorded, but `is_stable` does + // not trust that -- a same-day pair is one measurement, not two, however it got here. + let observations = vec![obs(5, "https://a"), obs(5, "https://a")]; + assert!(!is_stable(&observations, &["https://a".to_string()])); + } + + /// A reorder of the operator's own URL list is not a change (`SPEC.md` §25.13.3) -- proven here + /// too, since hysteresis must not see a reorder as instability. + #[test] + fn a_reordered_url_list_is_still_the_same_set() { + let observations = vec![ + Observation { + personal_day: 1, + urls: vec!["https://a".to_string(), "https://b".to_string()], + }, + Observation { + personal_day: 2, + urls: vec!["https://b".to_string(), "https://a".to_string()], + }, + ]; + assert!(is_stable( + &observations, + &["https://a".to_string(), "https://b".to_string()] + )); + } + + // --- epoch_cap_allows --------------------------------------------------------------------- + + #[test] + fn never_reconciled_allows_this_epoch() { + assert!(epoch_cap_allows(None, 42)); + } + + #[test] + fn a_reconcile_already_recorded_this_epoch_refuses_a_second() { + assert!(!epoch_cap_allows(Some(42), 42)); + } + + #[test] + fn a_reconcile_recorded_in_a_different_epoch_allows_this_one() { + assert!(epoch_cap_allows(Some(41), 42)); + } +} diff --git a/crates/dig-node-service/src/mirror/spends.rs b/crates/dig-node-service/src/mirror/spends.rs index 06af15f4..a4618ab4 100644 --- a/crates/dig-node-service/src/mirror/spends.rs +++ b/crates/dig-node-service/src/mirror/spends.rs @@ -71,6 +71,10 @@ pub struct MirrorSpends { /// The URLs a CREATE advertises this bond as fetchable from. Empty for a reclaim, which /// advertises nothing (dig-node#574). advertised_urls: Vec, + /// `Some` only for a RECLAIM (`SPEC.md` §F); `None` for a create, which has no reason to give. + /// Set at build time from the SAME [`super::plan::ReclaimReason`] the runner decided to act on, + /// never invented here — see [`build_reclaim`]. + reclaim_reason: Option, } impl MirrorSpends { @@ -152,10 +156,32 @@ impl MirrorSpends { epoch, }), advertised_urls: self.advertised_urls.clone(), + // `SPEC.md` §F: derived from the SAME `ReclaimReason` the runner decided to act on, + // never supplied by a caller, so an entry cannot claim a reason its bundle does not + // have. `None` on a create (`self.reclaim_reason` is `None` there by construction). + reclaim_reason: self + .reclaim_reason + .map(|r| reclaim_reason_label(r).to_string()), + trigger: self.reclaim_reason.and_then(|r| match r { + super::plan::ReclaimReason::UrlStale(trigger) => Some(trigger.label().to_string()), + super::plan::ReclaimReason::NoLongerHeld + | super::plan::ReclaimReason::EpochEnded => None, + }), } } } +/// The `SPEC.md` §F snake_case spelling of a [`super::plan::ReclaimReason`], ignoring which +/// [`super::plan::Trigger`] a `UrlStale` carries — that half is [`MirrorSpends::intent`]'s +/// `trigger` field, kept separate so a reader can filter on the reason alone. +fn reclaim_reason_label(reason: super::plan::ReclaimReason) -> &'static str { + match reason { + super::plan::ReclaimReason::NoLongerHeld => "no_longer_held", + super::plan::ReclaimReason::EpochEnded => "epoch_ended", + super::plan::ReclaimReason::UrlStale(_) => "url_stale", + } +} + /// Build the spends that lock `collateral_dig_base_units` of $DIG as a mirror for one `(store, root, /// epoch)`. /// @@ -220,6 +246,7 @@ pub fn build_create( epoch, collateral_dig_base_units, advertised_urls, + reclaim_reason: None, }) } @@ -233,11 +260,15 @@ pub fn build_create( /// `fee` may be zero, and a zero-fee reclaim is supported. That matters: a node whose XCH is /// exhausted must still be able to recover $DIG it has locked, which is precisely what the legacy /// could not do. +/// +/// `reason` is recorded on the resulting audit entry verbatim (`SPEC.md` §F) — never re-derived +/// from the coin, so a caller cannot build a bundle and then disagree with itself about why. pub fn build_reclaim( mirror: &MirrorCoin, synthetic_key: PublicKey, fee_coins: Vec, fee: u64, + reason: super::plan::ReclaimReason, ) -> Result { let spends = dig_mirror_coin::reclaim(mirror, synthetic_key, fee_coins, fee)?; @@ -258,6 +289,7 @@ pub fn build_reclaim( collateral_dig_base_units: mirror.collateral(), // A reclaim returns collateral; it advertises nothing. advertised_urls: Vec::new(), + reclaim_reason: Some(reason), }) } @@ -284,6 +316,7 @@ pub(crate) fn empty_for_tests(fee_mojos: u64, owner_puzzle_hash: Bytes32) -> Mir epoch: BigInt::from(0), collateral_dig_base_units: 0, advertised_urls: Vec::new(), + reclaim_reason: None, } } @@ -314,5 +347,6 @@ pub(crate) fn unsignable_for_tests(owner_puzzle_hash: Bytes32) -> MirrorSpends { epoch: BigInt::from(0), collateral_dig_base_units: 0, advertised_urls: Vec::new(), + reclaim_reason: None, } } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index b37013b0..a646f962 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2786,6 +2786,12 @@ fn spawn_mirror_passes( // which is precisely the behaviour the gate exists to prevent. let mut funding_gate = crate::mirror::funding::FundingAlertGate::default(); + // dig-node#570's daily URL-reconcile detector. Loaded once, like the two trackers above: a + // fresh `ReconcileState` every round would forget yesterday's observation and never see + // hysteresis's two agreeing days. A missing/corrupt file reads as "never observed" (its own + // module doc) — the safe direction, since it only ever delays an automatic spend. + let mut reconcile_state = crate::mirror::reconcile_state::ReconcileState::load(); + // The disk-event accelerant (dig-node#465). Strictly a hint about WHEN to run the next // pass: `wait_for_next_pass` never lets an event push the round deadline out, and a `None` // here is a node that waits on the timer alone and converges identically, one round later. @@ -2875,6 +2881,85 @@ fn spawn_mirror_passes( ) .map_err(|e| crate::mirror::runner::PassError::Wallet(e.to_string())); + // dig-node#570 §25.13.7: once per PERSONAL day — a per-node offset derived from this + // node's own peer id, never drawn or persisted (`SPEC.md` §25.13.7.1) — re-gather fresh + // reflexive readings and, only if they CONCLUSIVELY establish an address, publish them. + // Placed BEFORE `advertised` below so a conclusive re-gather is reflected in THIS + // pass's own advertisement, exactly as bring-up's one gather already is. + { + use crate::mirror::reconcile_state::CheckOutcome; + use crate::mirror::schedule as reconcile_schedule; + + let peer_id_bytes = node + .own_peer_id() + .and_then(|hex_id| hex::decode(hex_id).ok()) + .unwrap_or_default(); + let offset_secs = reconcile_schedule::personal_day_offset_secs(&peer_id_bytes); + let now_unix_secs = (lifecycle::now_unix_ms() / 1000) as i64; + + if reconcile_schedule::is_due( + now_unix_secs, + offset_secs, + reconcile_state.last_completed_day, + ) { + let today = reconcile_schedule::personal_day_index(now_unix_secs, offset_secs); + let fresh_readings = dig_node_core::net::gather_reflexive_readings( + dig_node_core::peer::relay_enabled() + .then(dig_node_core::peer::relay_url_from_env), + dig_node_core::peer::peer_port_from_env(), + std::time::Duration::from_secs(2), + ) + .await; + + // Reuse the CURRENT relay-reservation/direct-mapping reading (unrelated to the + // STUN gather and read fresh every pass already) and override only the readings + // half with what THIS gather just collected. + let mut fresh_address = + crate::mirror::advertise::PublicAddress::from_network_info( + &node.network_info(), + ); + fresh_address.reflexive = fresh_readings + .iter() + .map(|r| crate::mirror::advertise::Reflexive { + source: r.class.clone(), + addr: r.addr, + }) + .collect(); + let fresh_effective = + crate::mirror::advertise::effective_urls(&operator_urls, &fresh_address); + + if fresh_effective.can_advertise() { + node.replace_reflexive_readings( + fresh_readings + .into_iter() + .map(|r| (r.addr, r.class)) + .collect(), + ); + reconcile_state.record_check( + today, + CheckOutcome::Conclusive { + urls: fresh_effective.urls.clone(), + }, + ); + } else { + reconcile_state.record_check( + today, + CheckOutcome::Inconclusive { + state: fresh_effective.state.label(), + }, + ); + } + if let Err(e) = reconcile_state.save() { + tracing::warn!( + target: "mirror", + error = %e, + "the URL-reconcile detector's state could not be persisted; today's \ + check will run again next round" + ); + } + } + } + // What this node will advertise THIS pass (SPEC.md §25.10, dig_ecosystem#3197): the // operator's value when they set one, otherwise this node's own reflexive peer address // — gated on a path to it being held. `network_info` reads atomics and touches neither @@ -2883,6 +2968,28 @@ fn spawn_mirror_passes( &operator_urls, &crate::mirror::advertise::PublicAddress::from_network_info(&node.network_info()), ); + + // dig-node#570 §25.13.7.4-6: may the AUTOMATIC trigger attempt a reconcile this pass? + // Hysteresis (two agreeing personal days) and the epoch cap (at most one automatic + // reconcile per epoch) are evaluated HERE, never inside `reconcile::decide` — they are + // pre-conditions of the automatic trigger, not gates the manual one shares (`SPEC.md` + // §25.13.2's table). + let reconcile_attempt = if config.url_reconcile_enabled + && crate::mirror::schedule::is_stable( + &reconcile_state.observations, + &advertised.urls, + ) + && crate::mirror::schedule::epoch_cap_allows( + reconcile_state.last_auto_reconcile_epoch, + epoch, + ) { + Some(crate::mirror::runner::ReconcileAttempt { + trigger: crate::mirror::plan::Trigger::Daily, + advertised: advertised.clone(), + }) + } else { + None + }; if last_advertise_state != Some(advertised.state) { tracing::info!( target: "mirror", @@ -2930,6 +3037,7 @@ fn spawn_mirror_passes( // priced against its operator wallet and reported `unfunded` with a base-unit // figure — a demand for money that would have bonded nothing. can_advertise: advertised.can_advertise(), + reconcile: reconcile_attempt, }; // `block_in_place` rather than `spawn_blocking`: the runner borrows the signer, // the journal and the chain source, none of which is `'static`, and moving them @@ -2979,6 +3087,22 @@ fn spawn_mirror_passes( match outcome { Ok(report) => { + // dig-node#570 §25.13.7.5: the epoch cap counts a reconcile spent the + // moment at least one `UrlStale` reclaim reaches the mempool, not when + // it was merely attempted — a pass whose directive was entirely refused + // or entirely failed must not consume the epoch's one automatic try. + if report.reconcile_url_stale_accepted > 0 { + reconcile_state.mark_auto_reconciled(epoch); + if let Err(e) = reconcile_state.save() { + tracing::warn!( + target: "mirror", + error = %e, + "the URL-reconcile epoch cap could not be persisted; a \ + restart before the next rollover could permit one extra \ + automatic reconcile this epoch" + ); + } + } lifecycle::publish(&snapshot, &report, epoch); log_mirror_pass(&report, epoch); } @@ -3747,6 +3871,7 @@ mod tests { per_coin_dig_base_units: None, locked_dig_base_units: 0, funding_alert: None, + reconcile_url_stale_accepted: 0, } } diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 16b60048..9d5cce21 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -389,6 +389,17 @@ pub struct SpendIntent { /// that comparison meaningful — the current URL set can change after this coin was created. #[serde(default)] pub advertised_urls: Vec, + /// For a mirror-coin RECLAIM, WHY it reclaimed: `no_longer_held` / `epoch_ended` / `url_stale` + /// (`SPEC.md` §25.4's three reasons, snake_case). `None` for every other kind and for a create, + /// which has no reason to give (`SPEC.md` §F). `#[serde(default)]` so a record written before + /// this field existed still parses, answering `None` — which suppresses nothing on the gate-9 + /// in-flight read, the same safe direction an unreadable ledger already falls back to. + #[serde(default)] + pub reclaim_reason: Option, + /// For a `reclaim_reason: "url_stale"` entry, WHICH caller asked: `daily` / `manual` + /// (`SPEC.md` §F). `None` for every other reclaim reason and for a create. + #[serde(default)] + pub trigger: Option, } /// One entry in the audit record: a full snapshot of one spend at one revision. @@ -426,6 +437,12 @@ pub struct SpendRecord { /// still parses, answering "no URL recorded" rather than refusing to read (dig-node#574). #[serde(default)] pub advertised_urls: Vec, + /// For a mirror-coin RECLAIM, why it reclaimed (`SPEC.md` §F). See [`SpendIntent::reclaim_reason`]. + #[serde(default)] + pub reclaim_reason: Option, + /// For a `reclaim_reason: "url_stale"` entry, which caller asked. See [`SpendIntent::trigger`]. + #[serde(default)] + pub trigger: Option, /// When the node decided to spend (unix ms). pub initiated_ms: u64, /// When this revision was written (unix ms). @@ -981,6 +998,8 @@ impl SpendJournal { store_id: intent.store_id, bond: intent.bond, advertised_urls: intent.advertised_urls, + reclaim_reason: intent.reclaim_reason, + trigger: intent.trigger, initiated_ms: now, updated_ms: now, status: SpendStatus::Pending, @@ -1381,6 +1400,8 @@ mod tests { store_id: Some("store-a".to_string()), bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } @@ -1408,6 +1429,8 @@ mod tests { epoch, }), advertised_urls: urls, + reclaim_reason: None, + trigger: None, } } @@ -1808,6 +1831,8 @@ mod tests { store_id: store.map(str::to_string), bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, initiated_ms, updated_ms: initiated_ms, status: SpendStatus::Pending, diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index 6f4efc47..358c9d46 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -323,6 +323,8 @@ mod tests { store_id: store.map(str::to_string), bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } diff --git a/crates/dig-node-service/tests/mirror_fee_ceiling.rs b/crates/dig-node-service/tests/mirror_fee_ceiling.rs index 1c5087f4..b32a95e3 100644 --- a/crates/dig-node-service/tests/mirror_fee_ceiling.rs +++ b/crates/dig-node-service/tests/mirror_fee_ceiling.rs @@ -16,6 +16,7 @@ mod support; use chia_protocol::{Bytes32, Coin}; use dig_mirror_coin::MirrorCoin; +use dig_node_service::mirror::plan::ReclaimReason; use dig_node_service::mirror::signer::{MirrorSigner, SignError, MIRROR_SPEND_FEE_CEILING_MOJOS}; use dig_node_service::mirror::spends::build_reclaim; use dig_node_service::spend_audit::{SpendJournal, SpendLog}; @@ -103,6 +104,7 @@ fn a_reclaim_built_above_the_ceiling_is_refused_even_though_no_caller_says_so() owner.public_key, fee_coins(&owner, RUINOUS_FEE_MOJOS), RUINOUS_FEE_MOJOS, + ReclaimReason::NoLongerHeld, ) .expect("a reclaim at any fee builds; refusing it is the signer's job"); @@ -138,6 +140,7 @@ fn the_same_reclaim_at_a_legal_fee_signs() { owner.public_key, fee_coins(&owner, MIRROR_SPEND_FEE_CEILING_MOJOS), MIRROR_SPEND_FEE_CEILING_MOJOS, + ReclaimReason::NoLongerHeld, ) .expect("builds"); @@ -167,7 +170,14 @@ fn the_same_reclaim_at_a_legal_fee_signs() { fn a_zero_fee_reclaim_signs() { let owner = signers_own_wallet(); let coin = owned_mirror_coin(&owner); - let spends = build_reclaim(&coin, owner.public_key, fee_coins(&owner, 0), 0).expect("builds"); + let spends = build_reclaim( + &coin, + owner.public_key, + fee_coins(&owner, 0), + 0, + ReclaimReason::NoLongerHeld, + ) + .expect("builds"); let dir = tempfile::tempdir().expect("tempdir"); let (journal, _log) = journal(dir.path()); diff --git a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs index 4b476bd5..ad3c2177 100644 --- a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs +++ b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs @@ -174,6 +174,8 @@ fn intent() -> SpendIntent { store_id: Some("store-a".to_string()), bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } diff --git a/crates/dig-node-service/tests/mirror_l1_genesis.rs b/crates/dig-node-service/tests/mirror_l1_genesis.rs index fca09b36..669ebbdf 100644 --- a/crates/dig-node-service/tests/mirror_l1_genesis.rs +++ b/crates/dig-node-service/tests/mirror_l1_genesis.rs @@ -25,6 +25,7 @@ use chia_protocol::{Bytes32, Coin, CoinSpend}; use chia_sdk_types::MAINNET_CONSTANTS; use dig_mirror_coin::MirrorCoin; use dig_node_service::mirror::lifecycle::mirror_agg_sig_data; +use dig_node_service::mirror::plan::ReclaimReason; use dig_node_service::mirror::spends::build_reclaim; use dig_wallet::operator_wallet::OperatorWallet; use dig_wallet::sage::spend::required_bls_signatures; @@ -80,10 +81,16 @@ fn owned_mirror_coin(owner: &Wallet) -> MirrorCoin { fn reclaim_spends() -> Vec { let owner = fixture_wallet(); let coin = owned_mirror_coin(&owner); - build_reclaim(&coin, owner.public_key, Vec::::new(), 0) - .expect("a zero-fee reclaim builds") - .coin_spends() - .to_vec() + build_reclaim( + &coin, + owner.public_key, + Vec::::new(), + 0, + ReclaimReason::NoLongerHeld, + ) + .expect("a zero-fee reclaim builds") + .coin_spends() + .to_vec() } /// Every BLS signature a reclaim requires under the Chia L1 domain, as `(key, message)`. diff --git a/crates/dig-node-service/tests/spend_audit_e2e.rs b/crates/dig-node-service/tests/spend_audit_e2e.rs index 7096dc63..1a1e189d 100644 --- a/crates/dig-node-service/tests/spend_audit_e2e.rs +++ b/crates/dig-node-service/tests/spend_audit_e2e.rs @@ -47,6 +47,8 @@ fn intent(store: &str) -> SpendIntent { store_id: Some(store.to_string()), bond: None, advertised_urls: Vec::new(), + reclaim_reason: None, + trigger: None, } } From e11434ae7f880ede5ba4212a84f109407a93dd19 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:01:24 -0700 Subject: [PATCH 06/12] feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosystem#3212) Nine commits from the #3212 serve-path lane, gated at 899cc68f (reviewer review 5130425808, security comment 5568662836), plus the single semver bump to 0.255.0 for the develop -> main batch. - store_id/root case normalised at the CapsuleKey boundary; cache delete targets the matched entry - tier-0 occupancy reads the eviction-aware ledger - profile-sync outbound budget in bytes; announcer asked first - melt confirmation depth on the terminal spend, fail-closed - EngineWarming (-32002) while the peer tier attaches, never -32004 - window completeness derived from the bytes read - deps: dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2 Refs DIG-Network/dig_ecosystem#3212 --- Cargo.lock | 49 ++-- Cargo.toml | 2 +- crates/dig-node-core/Cargo.toml | 2 +- crates/dig-node-core/SPEC.md | 3 +- crates/dig-node-core/src/capsule_key.rs | 46 +++- crates/dig-node-core/src/lib.rs | 19 +- crates/dig-node-core/src/peer.rs | 1 + .../src/seams/capsule/capsule_store.rs | 47 +++- .../src/seams/dig_peer/module_serve.rs | 82 ++++++- .../src/seams/dig_peer/profile_sync.rs | 226 +++++++++++++++--- .../src/seams/dig_peer/store_melted.rs | 131 ++++++++-- .../src/seams/dig_rpc/dispatch.rs | 61 ++++- crates/dig-node-core/src/tier0_live.rs | 68 +++++- crates/dig-node-service/Cargo.toml | 2 +- crates/dig-node-service/src/meta.rs | 20 ++ crates/dig-wallet/Cargo.toml | 2 +- 16 files changed, 649 insertions(+), 112 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32c264ba..0a7478c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -911,9 +911,9 @@ dependencies = [ [[package]] name = "chia-query" -version = "0.24.1" +version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82677c6aba0319eafe8808f253bc0aa34539bfcef8b82c9eb35339bfe71dfcc" +checksum = "79770ff86342fba33d9a9384090d4f3f770e769427cd09d442bf1114d924df32" dependencies = [ "async-trait", "chia-bls 0.36.1", @@ -1948,7 +1948,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2830,9 +2830,9 @@ dependencies = [ [[package]] name = "dig-logging" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbdc1dfe4b2588532a64845ef6ee2826d08a7b0e2d624cb3b92564c295f53c8" +checksum = "20caead0416fcbfdc4cf04fae1b137e9ca0f8c74e4f5d8652bee1eb3dfa50406" dependencies = [ "bip39", "clap", @@ -2920,9 +2920,9 @@ dependencies = [ [[package]] name = "dig-nat" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a3fc5d85d0009b2e7d1e07b7e17bb174db17fb12559be3cda8383dee23cefc8" +checksum = "6d9b5c5aec7827412fb596d633718c11c53fcc0b5aa7d1854517c8d4973b92de" dependencies = [ "arc-swap", "async-trait", @@ -2930,6 +2930,7 @@ dependencies = [ "dig-constants 0.11.2", "dig-identity", "dig-ip", + "dig-stun", "dig-tls", "futures", "futures-util", @@ -3040,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.89" +version = "0.255.0" dependencies = [ "async-trait", "axum", @@ -3293,9 +3294,9 @@ dependencies = [ [[package]] name = "dig-stun" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97ff6a1425d399f188ae70978a61cdff48930fd49b122cadc5fd0375a22f3c0" +checksum = "b6a980e520a5ceccc776b01e860bae3e0fe0e64037ee01b97774d20e385fb025" dependencies = [ "ring", "thiserror 2.0.20", @@ -3751,7 +3752,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3899,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4535,7 +4536,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4786,7 +4787,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5168,7 +5169,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5779,7 +5780,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -5817,9 +5818,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6569,7 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7019,7 +7020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7281,7 +7282,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8415,7 +8416,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e785eeb0..1d44df4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ edition = "2021" # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.254.89" +version = "0.255.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard. diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index fdf78c43..c7320e10 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -300,7 +300,7 @@ dig-nat = "0.21" # (`dig-node-service`'s `PublicAddress::corroborated_addresses`) is being retired in favour of this: # a second hand-rolled implementation of a security primitive is exactly the rival CLAUDE.md's # "centralize rival implementations" rule exists to catch. -dig-stun = "0.1" +dig-stun = "0.2" # dig-gossip is the ONE peer-stack exception: it stays a git dependency PINNED to a release commit # (dig_ecosystem#2647, NC-7 exception) — here v0.32.0 (rev 1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee). # It cannot be published to crates.io while its `native-tls` [patch.crates-io] fork stands, because diff --git a/crates/dig-node-core/SPEC.md b/crates/dig-node-core/SPEC.md index b55ab429..1f26a314 100644 --- a/crates/dig-node-core/SPEC.md +++ b/crates/dig-node-core/SPEC.md @@ -273,7 +273,8 @@ ROUTING.md §11` Phase 4). §2.5 + §8 are the normative target the integration | `-32601` | method not found | unknown method, OR a peer/write/control method named on the anonymous read tier | | `-32602` | invalid params | missing/malformed params (bad hex, wrong type, out-of-range) | | `-32000` | server error | upstream failure, chain read failure, file I/O, config write | -| `-32004` | resource unavailable | this node does not hold the content AND located no holder (genuine not-found) | +| `-32002` | `ENGINE_WARMING` | the p2p engine has not yet attached to the HTTP surface (~30s cold-start window) — the peer tier has genuinely not been consulted; retryable (dig_ecosystem#2097) | +| `-32004` | resource unavailable | this node does not hold the content, the peer tier WAS consulted (or there is none), AND located no holder (genuine not-found) — never returned while the peer tier is still attaching; see `-32002` | | `-32005` | `ROOT_NOT_ANCHORED` | served/requested root ≠ chain-anchored root, chain unreachable, or no confirmed generation (§4) — the anchor pin failing closed, UNIFORMLY across the `/s` tier, `dig.getContent` (read), AND `dig.fetchRange` (serve) | | `-32006` | `PEER_UNREACHABLE` | no traversal strategy reached the named peer | | `-32007` | `RANGE_NOT_SATISFIABLE` | `offset ≥ total_length` or the range is otherwise unsatisfiable | diff --git a/crates/dig-node-core/src/capsule_key.rs b/crates/dig-node-core/src/capsule_key.rs index 9db0d3c9..906633b5 100644 --- a/crates/dig-node-core/src/capsule_key.rs +++ b/crates/dig-node-core/src/capsule_key.rs @@ -179,9 +179,13 @@ impl CapsuleKey { /// This is the ONLY boundary at which untrusted key bytes become a usable capsule identity, so it /// is the one place the whitelist has to be right. pub(crate) fn parse(store: &str, root: &str) -> Option { + // dig_ecosystem#2147: lower-case both components at this single construction boundary so the + // same 32 bytes named in two different casings resolve to ONE key — same `Eq`/`Hash`, same + // `Display`. `is_canonical_hex_id` deliberately accepts either case; this is the only place + // case gets normalized, so every derived comparison agrees by construction. (is_canonical_hex_id(store) && is_canonical_hex_id(root)).then(|| CapsuleKey { - store: store.to_string(), - root: root.to_string(), + store: store.to_ascii_lowercase(), + root: root.to_ascii_lowercase(), }) } @@ -491,4 +495,42 @@ mod tests { assert_eq!(names.len(), 2, "only the two `.dig` artifacts remain"); assert!(names.iter().all(|n| n.ends_with(".dig"))); } + + #[test] + fn parse_normalizes_id_case_so_one_capsule_is_one_key() { + // dig_ecosystem#2147: the same capsule named in two different casings must resolve to ONE + // `CapsuleKey` — same store(), same rendering, same hash bucket — never two distinct keys for + // what is the same 32 bytes. + let lower = hex_id(0x7e); + let upper = lower.to_ascii_uppercase(); + // A MIXED-case rendering of the SAME 32 bytes as `lower`/`upper` — alternating the case of + // each hex digit — not a different id. (An earlier draft of this test used an unrelated hex + // string here, which compared two different capsules and could never pass.) + let mixed: String = lower + .chars() + .enumerate() + .map(|(i, c)| { + if i % 2 == 0 { + c.to_ascii_uppercase() + } else { + c + } + }) + .collect(); + + let key_lower = CapsuleKey::parse(&lower, &lower).expect("canonical"); + let key_upper = CapsuleKey::parse(&upper, &upper).expect("canonical"); + let key_mixed = CapsuleKey::parse(&mixed, &mixed).expect("canonical"); + + assert_eq!(key_lower, key_upper, "case must not create a second key"); + assert_eq!(key_lower, key_mixed, "case must not create a second key"); + assert_eq!(key_upper.store(), lower, "store() is always lower-case"); + assert_eq!(key_upper.to_string(), format!("{lower}:{lower}")); + + let mut set = std::collections::HashSet::new(); + set.insert(key_lower); + set.insert(key_upper); + set.insert(key_mixed); + assert_eq!(set.len(), 1, "one capsule must occupy one hash bucket"); + } } diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 9046f0f7..47010d7b 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -3081,6 +3081,7 @@ impl Node { seams::dig_peer::module_serve::read_module_window( &cache_dir, &store, &root, offset, length, ) + .map(|(window, _total)| window) }) .await .unwrap_or(None) @@ -3644,16 +3645,14 @@ impl Node { let cache_dir = self.cache_dir.clone(); let (read_root, echo_root) = (root_hex.clone(), root_hex); let read = tokio::task::spawn_blocking(move || { - let capsule = CapsuleKey::parse(&store_hex, &read_root)?; - // `total_length` comes from the file's METADATA, not from a buffer — the whole point is - // that no buffer of the whole module ever exists. - let total = std::fs::metadata(capsule.resolve_cached_path(&cache_dir)) - .ok()? - .len(); - if total == 0 { - return None; - } - let window = crate::seams::dig_peer::module_serve::read_module_window( + // `total` comes back from the SAME read that produced `window` (dig_ecosystem#2148), + // rather than a separate stat taken before it: a stat-then-read gap lets the module grow + // in between, so a window sized against the FRESHER on-disk length could carry more bytes + // than an earlier, staler `total` would account for — `end >= total` would then answer + // `complete`/`next_offset` one write ahead of what this window actually contains, or a + // `total_length` a client uses to size its reassembly buffer (#2071) could already be + // wrong on arrival. + let (window, total) = crate::seams::dig_peer::module_serve::read_module_window( &cache_dir, &store_hex, &read_root, diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 4a53e10d..e83127bc 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1645,6 +1645,7 @@ impl PeerRpcResponder for NodeResponder { async move { tokio::task::spawn_blocking(move || { module_serve::read_module_window(&cache, &s, &r, offset, length) + .map(|(window, _total)| window) }) .await .unwrap_or(None) diff --git a/crates/dig-node-core/src/seams/capsule/capsule_store.rs b/crates/dig-node-core/src/seams/capsule/capsule_store.rs index 6de87890..10f6fb6c 100644 --- a/crates/dig-node-core/src/seams/capsule/capsule_store.rs +++ b/crates/dig-node-core/src/seams/capsule/capsule_store.rs @@ -81,6 +81,35 @@ pub(crate) fn list_cached_capsules(modules_root: &std::path::Path) -> Vec std::path::PathBuf { + let build = |ext: &str| { + cache_dir + .join("modules") + .join(store_hex) + .join(format!("{root_hex}.{ext}")) + }; + let unified = build(crate::capsule_key::CACHED_MODULE_EXT); + if unified.exists() { + return unified; + } + let legacy = build(crate::capsule_key::LEGACY_MODULE_EXT); + if legacy.exists() { + return legacy; + } + unified +} + /// Seam 6 (capsule management) — the node's on-disk `.dig` capsule cache: list/remove/fetch a held /// capsule, gap-fill a missing chain-confirmed generation, and the self-reference plumbing that lets /// `&self` read handlers spawn an owned background backfill. @@ -249,7 +278,23 @@ impl CapsuleStore for Node { }; // Remove whichever artifact is on disk — the current `.dig` or a legacy `.module` (#1896) — so // a removal on a not-yet-migrated cache still clears the holder claim. - let path = capsule.resolve_cached_path(&self.cache_dir); + // + // Tried in TWO casings (dig_ecosystem#2147/#2090). First the caller's RAW hex casing: on a + // case-sensitive filesystem (dig-node runs on Linux), a directory a pre-#2147 binary wrote in + // mixed case is findable ONLY by the exact casing the held-check (`held_store_ids`, which + // DECODES hex rather than text-comparing) matched — `CapsuleKey`'s identity-normalized + // lower-case path would name a directory that was never on disk, and the delete would + // silently no-op while the node kept serving content it had just announced melted. Then, if + // that misses, `CapsuleKey`'s lower-cased path: a caller may pass hex in a DIFFERENT casing + // than the (post-#2147, canonically lower-case) directory actually on disk, and that case must + // still resolve — `CapsuleKey::parse` is what makes one 32-byte identity match regardless of + // how a caller spells it. + let raw = resolve_cached_path_raw_case(&self.cache_dir, store_id_hex, root_hex); + let path = if raw.exists() { + raw + } else { + capsule.resolve_cached_path(&self.cache_dir) + }; let _guard = self.cache_lock.lock().await; if !path.exists() { diff --git a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs index aa74ef97..f2bc2c2a 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs @@ -178,20 +178,31 @@ pub fn describe_module(cache_dir: &Path, store_hex: &str, root_hex: &str) -> Opt Some(info) } -/// Read the `[offset, offset+length)` window of a locally-held module. +/// Read the `[offset, offset+length)` window of a locally-held module, returning `(window, total)` +/// where `total` is the module's size AT THE TIME OF THIS READ. /// /// Returns `None` when the module is not held. An offset at or past the end yields an EMPTY window /// rather than an error: the window is a byte range over a content-addressed blob, and the caller's own /// chunk-hash check is what decides whether what arrived is what it asked for. /// /// `length` is clamped to [`MAX_MODULE_WINDOW`] — a serve never lets one request size its own work. +/// +/// `total` is returned rather than left for the caller to stat separately (dig_ecosystem#2148): a +/// caller that stats the file BEFORE calling this, then builds `complete`/`next_offset`/ +/// `total_length` from that earlier number, can disagree with the window this function actually read +/// if the module grew in between — the window (sized against a FRESHER stat) can carry more bytes +/// than the caller's stale `total` allows for, so `end >= total` claims completion (or `next_offset` +/// bookkeeping otherwise drifts) one write ahead of what was actually served. This is the same class +/// of defect #2071 was: a client acting on a `total_length`/`complete` pair that does not describe +/// the bytes it was actually just handed. Handing back the stat this read itself used is what keeps +/// caller and window looking at the SAME number. pub fn read_module_window( cache_dir: &Path, store_hex: &str, root_hex: &str, offset: u64, length: u64, -) -> Option> { +) -> Option<(Vec, u64)> { use std::io::{Read, Seek, SeekFrom}; let capsule = CapsuleKey::parse(store_hex, root_hex)?; @@ -201,6 +212,9 @@ pub fn read_module_window( // actually asked for are ever pulled off disk. let mut file = std::fs::File::open(capsule.resolve_cached_path(cache_dir)).ok()?; let total = file.metadata().ok()?.len(); + if total == 0 { + return None; + } let start = offset.min(total); let want = length.min(MAX_MODULE_WINDOW).min(total - start); file.seek(SeekFrom::Start(start)).ok()?; @@ -208,7 +222,7 @@ pub fn read_module_window( file.read_exact(&mut window).ok()?; #[cfg(test)] record_module_bytes_read(root_hex, window.len() as u64); - Some(window) + Some((window, total)) } /// Test-only tally of bytes pulled off disk by [`read_module_window`], keyed by ROOT. @@ -492,9 +506,62 @@ mod tests { let (store, root) = (hex_id(5), hex_id(6)); let bytes: Vec = (0..300u32).map(|i| i as u8).collect(); let dir = cache_with(&bytes, &store, &root); + let (window, total) = read_module_window(dir.path(), &store, &root, 100, 50).expect("held"); + assert_eq!(window, bytes[100..150]); + assert_eq!( + total, + bytes.len() as u64, + "total is the module's real on-disk size" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **Proves:** `total` always describes the SAME read that produced `window` — the module can grow + /// between two calls, and each call's `total` tracks its OWN read, never a value left over from an + /// earlier stat (dig_ecosystem#2148). + /// + /// **Catches:** the shipped defect: `get_capsule` used to stat the file ONCE, up front, then call + /// `read_module_window` (which stats AGAIN, internally) and build `complete`/`next_offset` from the + /// stale, pre-read number. If the module grew in between, the window it actually served could + /// already reach past that stale `total`, so `end >= total` claimed completion (or otherwise + /// mis-set `next_offset`) for content the module had already outgrown — `next_offset == offset`, + /// the shipped symptom. Returning `total` FROM the read that used it removes the second, drifting + /// source of truth: there is no longer an earlier stat left to go stale. + #[test] + fn total_tracks_growth_between_two_reads_of_the_same_module() { + let (store, root) = (hex_id(15), hex_id(16)); + let first_bytes = vec![1u8; 50]; + let dir = cache_with(&first_bytes, &store, &root); + + let (_window, total_before) = + read_module_window(dir.path(), &store, &root, 0, 50).expect("held"); assert_eq!( - read_module_window(dir.path(), &store, &root, 100, 50).expect("held"), - bytes[100..150] + total_before, 50, + "first read sees the module as it was written" + ); + + // The module grows (a peer sync landed a larger generation) BETWEEN the two reads. + let path = module_path(dir.path(), &store, &root); + let grown_bytes = vec![2u8; 200]; + std::fs::write(&path, &grown_bytes).unwrap(); + + let (window_after, total_after) = + read_module_window(dir.path(), &store, &root, 0, 50).expect("held"); + assert_eq!( + total_after, 200, + "the second read's total tracks the module's CURRENT size, not the first read's" + ); + assert_eq!(window_after, grown_bytes[0..50]); + // The old defect: a caller that built `complete`/`next_offset` from `total_before` (50) against + // a window ending at `offset + window.len()` == 50 would read `end >= total_before` as + // complete, reporting `next_offset: null` for a module that is actually 200 bytes long. With + // `total` sourced from THIS read, a caller correctly sees `end (50) < total_after (200)` and + // keeps paging. + let start = 0u64; + let end = start + window_after.len() as u64; + assert!( + end < total_after, + "a fresh total must show more content remains, not falsely claim completion" ); let _ = std::fs::remove_dir_all(&dir); } @@ -510,11 +577,13 @@ mod tests { // Past the end: an empty window, not an error and not a wrapped read. assert!(read_module_window(dir.path(), &store, &root, 1_000, 10) .expect("held") + .0 .is_empty()); // Absurd length: clamped to what exists. assert_eq!( read_module_window(dir.path(), &store, &root, 0, u64::MAX) .expect("held") + .0 .len(), 100 ); @@ -535,7 +604,8 @@ mod tests { let offset = MAX_MODULE_WINDOW * 2 + 17; let want = 4096u64; - let window = read_module_window(dir.path(), &store, &root, offset, want).expect("held"); + let (window, _total) = + read_module_window(dir.path(), &store, &root, offset, want).expect("held"); assert_eq!(window.len(), want as usize); assert_eq!(window, bytes[offset as usize..(offset + want) as usize]); diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index da1a6890..762c261f 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -113,15 +113,22 @@ pub fn profile_sync_enabled() -> bool { /// used to attribute a much later frame to a peer that has since been replaced in the pool. pub const SOLICITATION_TTL: Duration = Duration::from_secs(120); -/// Maximum number of 225 answers this node will emit per inbound-request burst window. +/// Maximum BYTES of 225 answer bodies this node will emit per inbound-request burst window. /// /// A 224 request is cheap to send and expensive to answer (a disk read plus up to /// [`MAX_PROFILE_BODY_BYTES`] on the wire), so an unbudgeted responder is an amplifier. The budget /// is per-window across ALL peers because the scarce resource being protected is this node's own /// upload, not any single link's fairness. -pub const OUTBOUND_BODY_BUDGET: usize = 32; - -/// The window the [`OUTBOUND_BODY_BUDGET`] refills over. +/// +/// Counted in BYTES, not answers (dig_ecosystem#3029, F3): a token-per-answer budget states no real +/// ceiling on upload — one profile body can be anywhere up to [`MAX_PROFILE_BODY_BYTES`], so "32 +/// tokens" could mean 32 bytes or 32 times [`MAX_PROFILE_BODY_BYTES`] depending on what callers +/// actually asked for. Set to the same worst case the old token budget already permitted (`32 * +/// MAX_PROFILE_BODY_BYTES`) so this change preserves behaviour rather than silently tightening or +/// loosening the cap. +pub const OUTBOUND_BODY_BUDGET_BYTES: usize = 32 * MAX_PROFILE_BODY_BYTES; + +/// The window the [`OUTBOUND_BODY_BUDGET_BYTES`] refills over. pub const OUTBOUND_BUDGET_WINDOW: Duration = Duration::from_secs(10); /// File extension of a persisted profile body. @@ -447,7 +454,10 @@ impl Solicitations { } } -/// A refilling token budget bounding how many 225 answers this node emits per window. +/// A refilling BYTE budget bounding how many bytes of 225 answers this node emits per window +/// (dig_ecosystem#3029, F3 — a token-per-answer budget could not state the real upload ceiling since +/// answer bodies vary in size up to [`MAX_PROFILE_BODY_BYTES`]; this type now charges and refills in +/// the unit that actually bounds upload). #[derive(Clone)] pub struct OutboundBudget { inner: Arc>, @@ -457,23 +467,24 @@ pub struct OutboundBudget { impl Default for OutboundBudget { fn default() -> Self { - Self::new(OUTBOUND_BODY_BUDGET, OUTBOUND_BUDGET_WINDOW) + Self::new(OUTBOUND_BODY_BUDGET_BYTES, OUTBOUND_BUDGET_WINDOW) } } impl OutboundBudget { - /// A budget of `capacity` answers per `window`. + /// A budget of `capacity_bytes` bytes served per `window`. #[must_use] - pub fn new(capacity: usize, window: Duration) -> Self { + pub fn new(capacity_bytes: usize, window: Duration) -> Self { Self { - inner: Arc::new(Mutex::new((capacity, Instant::now()))), - capacity, + inner: Arc::new(Mutex::new((capacity_bytes, Instant::now()))), + capacity: capacity_bytes, window, } } - /// Take one token, returning `false` when the window's budget is exhausted. - pub fn take(&self) -> bool { + /// Try to charge `bytes` bytes against the window's remaining budget, returning `false` (the + /// budget left untouched) when `bytes` would exceed what remains. + pub fn take(&self, bytes: usize) -> bool { let mut guard = self .inner .lock() @@ -483,10 +494,10 @@ impl OutboundBudget { *remaining = self.capacity; *since = Instant::now(); } - if *remaining == 0 { + if *remaining < bytes { return false; } - *remaining -= 1; + *remaining -= bytes; true } } @@ -741,10 +752,12 @@ pub enum ServeOutcome { ReadFailed, } -/// Answer one inbound 224 request from `peer`, within the outbound budget. +/// Answer one inbound 224 request from `peer`, within the outbound BYTE budget. /// -/// The budget is taken only once the artifact is known to exist, so a flood of requests for content -/// this node does not hold cannot starve the budget for peers asking about content it does. +/// The budget is charged only once the artifact is known to exist AND read, so a flood of requests +/// for content this node does not hold cannot starve the budget for peers asking about content it +/// does. Charged by the ACTUAL body length (dig_ecosystem#3029, F3) — the byte budget states a real +/// upload ceiling only if what it charges is what actually goes out on the wire. pub async fn serve_body_request( store: &ProfileBodyStore, transport: &dyn ProfileTransport, @@ -757,9 +770,6 @@ pub async fn serve_body_request( if !store.has(&store_id, &root) { return ServeOutcome::NotHeld; } - if !budget.take() { - return ServeOutcome::Throttled; - } let bytes = match store.get(&store_id, &root) { Ok(Some(bytes)) => bytes, // Raced against a prune between `has` and `get` — indistinguishable from not held, and @@ -771,6 +781,9 @@ pub async fn serve_body_request( } }; let len = bytes.len(); + if !budget.take(len) { + return ServeOutcome::Throttled; + } let body = ProfileBody { store_id: request.store_id, root: request.root, @@ -788,8 +801,9 @@ pub async fn serve_body_request( /// Ask one live peer for the body behind a root this node has ALREADY resolved from chain. /// /// `root` MUST come from [`AnchoredRootResolver`] — that is the invariant [`accept_body`]'s gate 4 -/// relies on, and this is the one function that establishes it. `exclude` skips the peer an announce -/// arrived from only when we have somewhere else to ask; otherwise asking the announcer is correct. +/// relies on, and this is the one function that establishes it. `announcer` is asked FIRST — it just +/// told us it has this root, so it is the peer most likely to answer immediately; this node falls +/// back to another live peer only when the announcer is no longer live (dig_ecosystem#3029, F5). /// /// Returns the peer asked, or `None` if there was nobody to ask. pub async fn request_body( @@ -797,9 +811,14 @@ pub async fn request_body( solicitations: &Solicitations, store_id: [u8; 32], root: [u8; 32], + announcer: PeerId, ) -> Option { let peers = transport.live_peers(); - let peer = peers.first().copied()?; + let peer = if peers.contains(&announcer) { + announcer + } else { + peers.first().copied()? + }; let root_ref = ProfileRootRef { store_id: Bytes32::from(store_id), root: Bytes32::from(root), @@ -826,6 +845,7 @@ pub async fn handle_root_announce( resolver: &dyn AnchoredRootResolver, transport: &dyn ProfileTransport, solicitations: &Solicitations, + announcer: PeerId, announce: &ProfileRootRef, ) -> Option { let store_id: [u8; 32] = announce.store_id.into(); @@ -875,7 +895,7 @@ pub async fn handle_root_announce( ); return None; } - let asked = request_body(transport, solicitations, store_id, chain_root).await; + let asked = request_body(transport, solicitations, store_id, chain_root, announcer).await; match asked { Some(peer) => tracing::info!( store = %hex::encode(store_id), @@ -948,6 +968,7 @@ pub async fn run_profile_sync_ingest( &*ctx.resolver, &*ctx.transport, &ctx.solicitations, + sender, &announce, ) .await; @@ -2228,16 +2249,17 @@ mod tests { #[tokio::test] async fn the_outbound_budget_binds_at_capacity_and_refuses_one_over() { - // Pinned from BOTH sides: the second answer within a capacity-2 window must succeed (a - // bound tested only from above would pass for an off-by-one that throttles too early), and - // the third must not. + // Pinned from BOTH sides: a second full-size answer within a two-body-sized window must + // succeed (a bound tested only from above would pass for an off-by-one that throttles too + // early), and a third must not. Capacity is now BYTES (dig_ecosystem#3029, F3): exactly two + // bodies' worth, not "2" answers regardless of size. let dir = tempdir(); let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); let tx = Transport::default(); - let budget = OutboundBudget::new(2, Duration::from_secs(60)); + let budget = OutboundBudget::new(2 * bytes.len(), Duration::from_secs(60)); let req = root_ref(sid, root); let a = serve_body_request(&store, &tx, &budget, peer(9), &req).await; @@ -2250,21 +2272,27 @@ mod tests { ServeOutcome::Served(bytes.len()), "at capacity must pass" ); - assert_eq!(c, ServeOutcome::Throttled, "one over must fail"); + assert_eq!( + c, + ServeOutcome::Throttled, + "one body over the byte budget must fail" + ); } #[tokio::test] async fn requests_for_content_we_do_not_hold_cannot_starve_the_budget() { - // The ORDERING inside `serve_body_request` is the property: the budget is taken only AFTER - // the artifact is known to exist. A capacity of ONE makes the difference observable — under - // the wrong ordering the single token is spent on a miss and the real request throttles. + // The ORDERING inside `serve_body_request` is the property: the budget is charged only AFTER + // the artifact is known to exist AND read. A capacity of exactly one body's worth of bytes + // makes the difference observable — under the wrong ordering the budget would be spent on a + // miss (which has no bytes to charge, but a token-shaped bug could still consume a slot) and + // the real request would throttle. let dir = tempdir(); let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); let tx = Transport::default(); - let budget = OutboundBudget::new(1, Duration::from_secs(60)); + let budget = OutboundBudget::new(bytes.len(), Duration::from_secs(60)); for i in 0..5u8 { let miss = @@ -2276,8 +2304,134 @@ mod tests { assert_eq!(real, ServeOutcome::Served(bytes.len())); } + /// dig_ecosystem#3029 (F3) — the budget is charged by the ACTUAL body size, not a flat token: a + /// budget sized for exactly one BIG body has nothing left after serving it, but the SAME budget + /// serves two SMALL bodies. A flat-token charge (the pre-fix behaviour) could not tell these + /// apart -- either would consume "one answer" regardless of size. + #[tokio::test] + async fn a_larger_body_draws_down_the_byte_budget_by_more_than_a_smaller_one() { + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); + let (small, small_root) = dpb("Ada"); + let sid = store_id(1); + store.put(&sid, &small_root, &small).unwrap(); + let big_root = [0xBBu8; 32]; + let big = vec![0u8; small.len() * 3]; + store.put(&sid, &big_root, &big).unwrap(); + let tx = Transport::default(); + + let budget_after_big = OutboundBudget::new(big.len(), Duration::from_secs(60)); + let served_big = serve_body_request( + &store, + &tx, + &budget_after_big, + peer(9), + &root_ref(sid, big_root), + ) + .await; + let then_small = serve_body_request( + &store, + &tx, + &budget_after_big, + peer(9), + &root_ref(sid, small_root), + ) + .await; + assert_eq!(served_big, ServeOutcome::Served(big.len())); + assert_eq!( + then_small, + ServeOutcome::Throttled, + "a big-body-sized budget has nothing left after one big answer" + ); + + let budget_after_small = OutboundBudget::new(big.len(), Duration::from_secs(60)); + let served_small = serve_body_request( + &store, + &tx, + &budget_after_small, + peer(9), + &root_ref(sid, small_root), + ) + .await; + let then_small_again = serve_body_request( + &store, + &tx, + &budget_after_small, + peer(9), + &root_ref(sid, small_root), + ) + .await; + assert_eq!(served_small, ServeOutcome::Served(small.len())); + assert_eq!( + then_small_again, + ServeOutcome::Served(small.len()), + "two small bodies still fit inside a big-body-sized budget" + ); + } + // -- The 223-driven fetch ----------------------------------------------------------------------- + /// dig_ecosystem#3029 (F5) — the peer that just announced this root is asked FIRST, even when it + /// is not the first entry `live_peers()` happens to return. Before this fix `request_body` always + /// picked `peers.first()`, so an announcer buried anywhere but the front of the live-peer list was + /// never the one asked, despite being the peer most likely to answer immediately. + #[tokio::test] + async fn the_announcer_is_asked_first_even_when_it_is_not_the_first_live_peer() { + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); + let (_, root) = dpb("Ada"); + let sid = store_id(1); + let tx = Transport::with_peers(vec![peer(9), peer(7)]); + let sol = Solicitations::new(); + + let asked = handle_root_announce( + &store, + &Subs(vec![sid]), + &chain_at(root), + &tx, + &sol, + peer(7), + &root_ref(sid, root), + ) + .await; + + assert_eq!( + asked, + Some(peer(7)), + "the announcer must be asked first, not `live_peers().first()`" + ); + } + + /// dig_ecosystem#3029 (F5) — falls back to another live peer when the announcer itself is no + /// longer live (it announced, then dropped before this node could ask it back). + #[tokio::test] + async fn falls_back_to_another_live_peer_when_the_announcer_has_left() { + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); + let (_, root) = dpb("Ada"); + let sid = store_id(1); + let tx = Transport::with_peers(vec![peer(9)]); + let sol = Solicitations::new(); + let departed_announcer = peer(5); + + let asked = handle_root_announce( + &store, + &Subs(vec![sid]), + &chain_at(root), + &tx, + &sol, + departed_announcer, + &root_ref(sid, root), + ) + .await; + + assert_eq!( + asked, + Some(peer(9)), + "a departed announcer must fall back to another live peer, not return None" + ); + } + #[tokio::test] async fn an_announce_the_chain_confirms_solicits_the_body_under_the_chain_root() { let dir = tempdir(); @@ -2293,6 +2447,7 @@ mod tests { &chain_at(root), &tx, &sol, + peer(9), &root_ref(sid, root), ) .await; @@ -2324,6 +2479,7 @@ mod tests { &chain_at(on_chain), &tx, &sol, + peer(9), &root_ref(sid, forged), ) .await; @@ -2348,6 +2504,7 @@ mod tests { &chain_unreachable(), &tx, &sol, + peer(9), &root_ref(sid, root), ) .await; @@ -2384,6 +2541,7 @@ mod tests { &chain, &tx, &sol, + peer(9), &root_ref(sid, root), ) .await; @@ -2422,6 +2580,7 @@ mod tests { &chain_at(first), &tx, &sol, + peer(9), &root_ref(sid, first), ) .await; @@ -2431,6 +2590,7 @@ mod tests { &chain_at(second), &tx, &sol, + peer(9), &root_ref(sid, second), ) .await; diff --git a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs index da599caf..e172a001 100644 --- a/crates/dig-node-core/src/seams/dig_peer/store_melted.rs +++ b/crates/dig-node-core/src/seams/dig_peer/store_melted.rs @@ -10,6 +10,12 @@ //! the store's singleton is closed. A forged/replayed announcement, or a chain the node cannot reach, //! deletes NOTHING (see [`confirm_melt`] / [`MeltStatus`]). //! +//! **A terminal spend must also be [`MELT_CONFIRMATION_DEPTH`]-deep** (dig_ecosystem#2093): a +//! lineage that terminates only a handful of blocks behind the peak could still be reorg-reverted, +//! and unlike a spend, a delete cannot be undone by the reorg that un-does it. [`confirm_melt_via_chain`] +//! answers `Unknown` — never `Melted` — until the terminal spend clears that depth or the peak is +//! unreachable. +//! //! # The wire (`dig_gossip`, opcode 221) is a PUBLIC broadcast — §5.4-EXEMPT //! //! A store deletion is public-by-nature and addressed to everyone (like L2 consensus gossip), so the @@ -348,6 +354,19 @@ pub async fn run_melt_tick( /// walk bounded. const MAX_LINEAGE_HOPS: usize = 10_000; +/// How many blocks deep the terminal spend of a melted lineage must sit before this node treats the +/// melt as final (dig_ecosystem#2093). +/// +/// A melt DELETES hosted content, and a delete cannot be undone by a later reorg the way a spend can +/// be un-confirmed. A terminal spend that is only a handful of blocks deep could still be reverted, +/// which would make the "melt" this node just acted on never have happened. No dig-node-core-reachable +/// canonical reorg-depth constant exists (`dig-wallet`'s `MAX_REORG_DEPTH` is real but dig-node-core +/// must not depend on dig-wallet), so this is declared here: 32 blocks is roughly 10 minutes at +/// Chia's 18.75s block spacing, the depth Chia wallets already treat as reorg-safe. The cost of +/// waiting an extra ~10 minutes before deleting is minutes; the cost of deleting on a reorg-reverted +/// melt is unrecoverable data loss, so the asymmetry favours waiting. +pub const MELT_CONFIRMATION_DEPTH: u32 = 32; + /// The deepest live DataLayer lineage measured on mainnet (all 53 launcher coins surveyed). const DEEPEST_MEASURED_MAINNET_LINEAGE: usize = 599; @@ -438,6 +457,10 @@ pub async fn confirm_melt_via_chain(chain: &dyn ChainReads, store_id: &[u8; 32]) // FACT 2 — follow real parentage from the launcher to the end of the lineage. let mut current = launcher_id; + // The block the CURRENT terminal coin was spent at — tracked as the walk advances so a + // confirmed melt (dig_ecosystem#2093) can be depth-checked without a second chain read for the + // coin the loop already fetched. + let mut terminal_spent_at = launcher.spent_block_index; for hop in 0..MAX_LINEAGE_HOPS { let children = match chain.coin_records_by_parent_ids(&[current], true).await { Ok(children) => children, @@ -458,8 +481,18 @@ pub async fn confirm_melt_via_chain(chain: &dyn ChainReads, store_id: &[u8; 32]) // hop means the answer is untrustworthy, not that the store is gone. MeltStatus::Unknown } else if children.is_empty() { - // The spend created NOTHING — the lineage terminated. - MeltStatus::Melted + // The spend created NOTHING — the lineage terminated. Not yet authoritative: a + // terminal spend this shallow could still be reverted by a reorg, and a melt is an + // IRREVERSIBLE delete (dig_ecosystem#2093). Require confirmation depth, fail CLOSED + // to `Unknown` (deletes nothing) when the peak itself is unreachable. + match chain.peak_height().await { + Ok(peak) + if peak.saturating_sub(terminal_spent_at) >= MELT_CONFIRMATION_DEPTH => + { + MeltStatus::Melted + } + _ => MeltStatus::Unknown, + } } else { // Children exist but none is a singleton. This is where a TRUNCATED page lands: the // query honours a server-side limit, and a page that dropped the odd successor @@ -475,6 +508,7 @@ pub async fn confirm_melt_via_chain(chain: &dyn ChainReads, store_id: &[u8; 32]) if !next.spent { return MeltStatus::Live; } + terminal_spent_at = next.spent_block_index; current = next.coin.coin_id(); } MeltStatus::Unknown @@ -607,13 +641,15 @@ impl MeltCache for Arc { let mut removed = 0; for capsule in self.cache_list_cached().await { // Match on the PARSED 32 bytes, never on the hex TEXT. A capsule id is canonical-hex but - // NOT canonical-case — `CapsuleKey::parse` admits and preserves mixed case, so the - // directory name can be `Ab..cD` while `hex::encode` here would produce lowercase. A - // textual compare therefore matches nothing for such a store while `held_store_ids` - // (which decodes, and so is case-insensitive) still reports it held: the node would - // tombstone the store, announce a melt of `generations: 0`, and go on serving the - // content it just told the network it had deleted. Decoding both sides keeps the - // held-check and the delete looking at the same identity. + // a cached directory is not guaranteed canonical-CASE on disk: `CapsuleKey::parse` now + // lower-cases at construction (dig_ecosystem#2147), but a directory written by a prior + // binary — or by any path that names a cache entry without going through `parse` — can + // still be `Ab..cD` while `hex::encode` here would produce lowercase. A textual compare + // therefore matches nothing for such a store while `held_store_ids` (which decodes, and + // so is case-insensitive) still reports it held: the node would tombstone the store, + // announce a melt of `generations: 0`, and go on serving the content it just told the + // network it had deleted. Decoding both sides keeps the held-check and the delete + // looking at the same identity regardless of how the directory name was cased. if parse_hex32(&capsule.store_id).as_ref() == Some(store_id) && self .cache_remove_cached(&capsule.store_id, &capsule.root) @@ -1199,6 +1235,11 @@ mod tests { /// ceiling can stop a walk over this chain. endless: bool, parent_queries: AtomicUsize, + /// The chain tip height `peak_height()` answers with (dig_ecosystem#2093's confirmation-depth + /// gate). Defaults far deeper than any fixture's `spent_block_index` (11) so every existing + /// `Terminated` fixture reads as confirmed unless a test deliberately shallows it via + /// [`Self::with_peak`]. + peak: Answer, } /// A coin record with an explicit parent + amount, so a real parentage chain can be built. @@ -1251,6 +1292,10 @@ mod tests { unreachable_at: None, endless: false, parent_queries: AtomicUsize::new(0), + // `coin_rec` sets `spent_block_index: 11` for every spent coin, so any peak well + // past `11 + MELT_CONFIRMATION_DEPTH` reads every `Terminated` fixture as confirmed + // unless a test deliberately narrows it via `with_peak`. + peak: Answer::Ok(1_000), } } @@ -1266,6 +1311,12 @@ mod tests { self } + /// Override the chain tip `peak_height()` answers with (dig_ecosystem#2093). + fn with_peak(mut self, peak: Answer) -> Self { + self.peak = peak; + self + } + /// The coin the walk is standing on after `hop` steps (0 = the launcher itself). fn coin_at_hop(&self, store_id: [u8; 32], hop: usize) -> [u8; 32] { let mut parent = store_id; @@ -1374,7 +1425,10 @@ mod tests { unimplemented!("the melt gate must not parse spends (#747-immunity)") } async fn peak_height(&self) -> ChainResult { - unimplemented!("the melt gate must not read the peak") + match &self.peak { + Answer::Ok(peak) => Ok(*peak), + Answer::Unreachable => Err(ChainError::Chain("coinset unreachable".into())), + } } async fn push(&self, _bundle: SpendBundle) -> ChainResult<()> { unimplemented!("the melt gate is read-only") @@ -1396,6 +1450,50 @@ mod tests { ); } + /// CHAIN-1b (dig_ecosystem#2093) — a terminal spend SHALLOWER than + /// [`MELT_CONFIRMATION_DEPTH`] is not yet final: a reorg could still revert it, and a melt is an + /// irreversible delete. `Unknown` deletes nothing, so the holder keeps serving until the depth is + /// met. + #[tokio::test] + async fn a_terminal_spend_shallower_than_the_confirmation_depth_is_not_yet_melted() { + // The terminal spend lands at `spent_block_index: 11` (`coin_rec`); one block short of the + // required depth is the sharpest possible off-by-one probe. + let chain = MockChain::minted(store(1), 2, Lineage::Terminated) + .with_peak(Answer::Ok(11 + MELT_CONFIRMATION_DEPTH - 1)); + assert_eq!( + confirm_melt_via_chain(&chain, &store(1)).await, + MeltStatus::Unknown, + "one block short of the confirmation depth must not authorize a delete" + ); + } + + /// CHAIN-1c (dig_ecosystem#2093) — exactly [`MELT_CONFIRMATION_DEPTH`] blocks deep IS melted + /// (pinned from the other side of CHAIN-1b, so the boundary itself is proven, not just "less + /// than X fails"). + #[tokio::test] + async fn a_terminal_spend_exactly_at_the_confirmation_depth_is_melted() { + let chain = MockChain::minted(store(1), 2, Lineage::Terminated) + .with_peak(Answer::Ok(11 + MELT_CONFIRMATION_DEPTH)); + assert_eq!( + confirm_melt_via_chain(&chain, &store(1)).await, + MeltStatus::Melted, + "exactly the confirmation depth must authorize the delete" + ); + } + + /// CHAIN-1d (dig_ecosystem#2093) — an unreachable peak fails CLOSED, same as every other + /// unreachable chain read in this walk: `Unknown`, never a guessed `Melted`. + #[tokio::test] + async fn an_unreachable_peak_fails_closed_to_unknown() { + let chain = + MockChain::minted(store(1), 2, Lineage::Terminated).with_peak(Answer::Unreachable); + assert_eq!( + confirm_melt_via_chain(&chain, &store(1)).await, + MeltStatus::Unknown, + "an unreachable peak must never be treated as confirming a melt" + ); + } + /// CHAIN-2 — a LIVE store: the walk reaches an UNSPENT successor. Covers the shape 52 of the 53 /// mainnet stores have, including the 29 whose tip is one hop from the launcher. /// @@ -1718,12 +1816,13 @@ mod tests { /// REAL-2 — a MIXED-CASE store directory is deleted, not silently skipped. /// - /// `CapsuleKey::parse` admits and preserves mixed case (`is_canonical_hex_id` accepts any ASCII - /// hex digit, with its own test asserting `Ab..cD` parses), so a mixed-case cache directory is - /// reachable. `held_store_ids` DECODES the hex and so is case-insensitive; a delete that compared - /// the hex TEXT against `hex::encode` (always lowercase) matched nothing. The node would then - /// tombstone the store, broadcast a melt of `generations: 0`, and keep serving the content it had - /// just announced as deleted — a melt reported but not performed. + /// `CapsuleKey::parse` now lower-cases at construction (dig_ecosystem#2147), but a directory + /// written directly (bypassing `parse`) — this fixture, or a cache left by a prior binary — is + /// still reachable in mixed case, so the delete path must not assume every on-disk name is + /// already canonical. `held_store_ids` DECODES the hex and so is case-insensitive; a delete that + /// compared the hex TEXT against `hex::encode` (always lowercase) matched nothing. The node + /// would then tombstone the store, broadcast a melt of `generations: 0`, and keep serving the + /// content it had just announced as deleted — a melt reported but not performed. #[tokio::test] async fn the_real_cache_deletes_a_mixed_case_store_directory() { let (node, td) = crate::test_support::test_node_for_peer_surface(); diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index ca58d63b..1c432b4d 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -26,6 +26,31 @@ use crate::Node; #[allow(unused_imports)] use crate::*; +/// `-32002` (dig_ecosystem#2097): the peer tier has genuinely not been consulted yet — the p2p +/// engine attaches ~30s after the HTTP surface opens. Distinct from [`RESOURCE_NOT_AVAILABLE`] +/// (`-32004`), which means the peer tier WAS consulted (or there is none) and the content is still +/// not found. Declared here rather than in `lib.rs`'s shared catalogue, matching how +/// `dig-node-service`'s `ErrorCode::EngineWarming` mints the SAME numeric code independently on its +/// own surface. +const ENGINE_WARMING: i64 = -32002; + +/// Decide the miss error for a request that fell all the way through with no configured upstream +/// (dig_ecosystem#2097): `(code, message)`. +/// +/// Pulled out as a pure decision so the ordering rule — `-32004` may only ever mean "the peer tier +/// was consulted (or there is none) and the content is still not found", never "the peer tier has +/// not been asked yet" — is unit-testable without a full [`Node`] fixture. +fn no_upstream_miss_error(p2p_attached: bool) -> (i64, &'static str) { + if p2p_attached { + ( + RESOURCE_NOT_AVAILABLE, + "resource not available: this node does not hold it and no peer served it", + ) + } else { + (ENGINE_WARMING, "peer tier not yet attached; retry") + } +} + /// Seam 4 (dig RPC server) — the node's core JSON-RPC dispatch. #[async_trait::async_trait] pub trait RpcDispatch: Send + Sync { @@ -996,12 +1021,8 @@ impl RpcDispatch for Node { // pin — so even on the proxy path the node never serves a generation the // chain did not confirm. if !node.has_upstream() { - return err( - &id, - RESOURCE_NOT_AVAILABLE, - "resource not available: this node does not hold it and no peer served it" - .to_string(), - ); + let (code, msg) = no_upstream_miss_error(node.p2p_content().is_some()); + return err(&id, code, msg.to_string()); } let upstream_req = pinned_root .map(|pin| pin_request_root(&req, &pin.to_hex())) @@ -1192,3 +1213,31 @@ mod holder_claim_tests { ); } } + +#[cfg(test)] +mod engine_warming_tests { + use super::{no_upstream_miss_error, ENGINE_WARMING, RESOURCE_NOT_AVAILABLE}; + + /// dig_ecosystem#2097 — a node with no upstream and no p2p engine attached has genuinely never + /// asked the peer tier about this content: ENGINE_WARMING, never RESOURCE_NOT_AVAILABLE. + #[test] + fn no_p2p_attached_answers_engine_warming_not_resource_not_available() { + let (code, _msg) = no_upstream_miss_error(false); + assert_eq!( + code, ENGINE_WARMING, + "the peer tier was never consulted; -32004 would misreport an unasked question as a miss" + ); + } + + /// dig_ecosystem#2097 — the SAME node, once the p2p engine has attached, answers the ordinary + /// genuine-miss code. This is the other side of the boundary: proves the fix does not turn + /// EVERY no-upstream miss into ENGINE_WARMING forever. + #[test] + fn p2p_attached_and_a_miss_answers_resource_not_available() { + let (code, _msg) = no_upstream_miss_error(true); + assert_eq!( + code, RESOURCE_NOT_AVAILABLE, + "once the peer tier has been consulted, a miss is a genuine -32004" + ); + } +} diff --git a/crates/dig-node-core/src/tier0_live.rs b/crates/dig-node-core/src/tier0_live.rs index d7de9ad6..59d42f3e 100644 --- a/crates/dig-node-core/src/tier0_live.rs +++ b/crates/dig-node-core/src/tier0_live.rs @@ -68,12 +68,6 @@ use dig_sex::{NodeContext, RelevanceWeights}; /// (SPEC §7.10e/f) so a controller can tell "the flywheel is live" from "the seam is inert". static TIER0_WIRED: AtomicBool = AtomicBool::new(false); -/// The count of stores this process's tier-0 loop has landed in the cache — the `cache.stats` -/// `tier0_precache.occupancy` figure. A monotonic land counter, not a live occupancy (an evicted -/// precache store still counts); reported as the best available tier-0 signal until an -/// eviction-aware ledger lands. -static TIER0_LANDED: AtomicU64 = AtomicU64::new(0); - /// The unix-ms timestamp of the most recent inbound serve/demand event, `0` if none yet. The /// [`InboundLoadSignal`] reads it to back off tier-0 while the node is serving real demand. static INBOUND_ACTIVITY_MS: AtomicU64 = AtomicU64::new(0); @@ -142,10 +136,16 @@ pub(crate) fn tier0_wired() -> bool { TIER0_WIRED.load(Ordering::Relaxed) } -/// The number of stores this process's tier-0 loop has landed (`cache.stats` occupancy figure). +/// The number of stores this process's tier-0 loop currently holds landed — the `cache.stats` +/// `tier0_precache.occupancy` figure. Reads the eviction-aware ledger (`mark_tier0_land`/ +/// `forget_tier0_land`) directly, so a store the eviction sweep purges stops counting; this is a LIVE +/// gauge, not a monotonic land counter (dig_ecosystem#2045). #[must_use] pub(crate) fn tier0_occupancy() -> u64 { - TIER0_LANDED.load(Ordering::Relaxed) + tier0_land_ledger() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .len() as u64 } /// The live inbound-load signal: BUSY iff a real inbound-demand event fired within [`BUSY_COOLDOWN_MS`]. @@ -282,7 +282,6 @@ impl Tier0Fetcher for NodeTier0Fetcher { // the tier-aware size-cap eviction so the self-driven loop PLATEAUS at the cache cap // instead of growing `/modules` to disk-exhaustion. mark_tier0_land(&hex::encode(preimage.store_id)); - TIER0_LANDED.fetch_add(1, Ordering::Relaxed); self.evictor.evict_if_needed().await; FetchOutcome::Cached(bytes) } @@ -752,6 +751,57 @@ mod tests { ); } + #[tokio::test] + async fn occupancy_falls_when_a_tier0_land_is_evicted() { + // dig_ecosystem#2045: `tier0_occupancy()` backs the `cache.stats` occupancy figure, which must + // read as a LIVE gauge. The old `TIER0_LANDED` monotonic counter never fell, so a node that + // landed then evicted a store kept reporting the evicted store as occupied — a lie about how + // much tier-0 content is actually held. Occupancy must instead track the eviction-aware + // ledger (`mark_tier0_land`/`forget_tier0_land`), so a forgotten land is un-counted. + // + // A store id distinct from `preimage()`'s (used by sibling tests sharing this process's + // global ledger) so this test's forget cannot un-count another test's concurrent land. + let unique_store = [0x77u8; 32]; + let store_hex = hex::encode(unique_store); + + let warm = Arc::new(SpyWarm { + verdict: WarmVerdict::Cached(4096), + seen: Mutex::new(Vec::new()), + }); + let f = NodeTier0Fetcher { + lookup: Arc::new(FixedLookup(Some(Preimage { + store_id: unique_store, + root: [0x22; 32], + size_bytes: 4096, + }))), + gate: Arc::new(FixedGate(true)), + warm, + evictor: SpyEvictor::new(), + }; + + let before = tier0_occupancy(); + let outcome = f.fetch_and_cache([0x02; 32], 8192).await; + assert_eq!(outcome, FetchOutcome::Cached(4096)); + assert_eq!( + tier0_occupancy(), + before + 1, + "landing increments the eviction-aware occupancy count" + ); + assert!(is_tier0_precache(&store_hex), "the land is tagged tier-0"); + + forget_tier0_land(&store_hex); + + assert_eq!( + tier0_occupancy(), + before, + "occupancy must fall back once the eviction sweep forgets the land" + ); + assert!( + !is_tier0_precache(&store_hex), + "a forgotten land is no longer tagged tier-0" + ); + } + // -- size_bytes hard-cap ------------------------------------------------------------------------ #[tokio::test] diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 56b11a50..3666c163 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -114,7 +114,7 @@ dig-mirror-coin = "0.9" # classes, global-unicast) before `mirror::advertise::PublicAddress` will let one reach a coin. # Replaces this crate's own pairwise `corroborated_addresses` check, which could not fail closed on # a dissenting THIRD source and did not distinguish source CLASSES from bare source strings. -dig-stun = "0.1" +dig-stun = "0.2" # The canonical `ChainSource` trait `dig-mirror-coin`'s census is generic over. Declared, not # implemented: `chia-query` already provides the implementation this node uses diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 680ef847..8e432c48 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -684,6 +684,14 @@ pub enum ErrorCode { /// passes through. Which layer answered is carried by `data.origin`, never by the /// name — so the name is taken from the shared catalogue rather than restated. ResourceUnavailable, + /// `-32002` — the request arrived before this node's peer tier had finished attaching + /// (dig_ecosystem#2097). The HTTP surface opens ~30s before the p2p engine attaches, so a + /// request in that window has genuinely NOT been checked against the peer network yet — + /// distinct from `RESOURCE_UNAVAILABLE`, which means the peer tier WAS consulted (or there is + /// none) and the content is still not found. Reporting `-32004` here would tell a caller "not + /// found" for content this node simply has not finished asking about; this code tells the + /// caller to retry shortly instead. Transient/retryable. Shell error. + EngineWarming, /// `-32010` — the blind-passthrough relay to the upstream DIG RPC failed /// (unreachable / non-JSON). Dig-node-shell error distinguishing a local /// proxy failure from an upstream-returned JSON-RPC error. @@ -803,6 +811,10 @@ impl ErrorCode { ErrorCode::ResourceUnavailable => { shared(dig_rpc_protocol::ErrorCode::ResourceUnavailable) } + // Not in the shared `dig_rpc_protocol` catalogue: dig-node-service-only, so minted + // as a plain literal like the wallet/control bands below rather than restated from a + // shared source that does not define it. + ErrorCode::EngineWarming => -32002, ErrorCode::UpstreamError => shared(dig_rpc_protocol::ErrorCode::UpstreamError), ErrorCode::Unauthorized => shared(dig_rpc_protocol::ErrorCode::Unauthorized), ErrorCode::NotSupported => shared(dig_rpc_protocol::ErrorCode::NotSupported), @@ -844,6 +856,7 @@ impl ErrorCode { ErrorCode::ResourceUnavailable => { dig_rpc_protocol::ErrorCode::ResourceUnavailable.machine_code() } + ErrorCode::EngineWarming => "ENGINE_WARMING", ErrorCode::UpstreamError => dig_rpc_protocol::ErrorCode::UpstreamError.machine_code(), ErrorCode::Unauthorized => dig_rpc_protocol::ErrorCode::Unauthorized.machine_code(), ErrorCode::NotSupported => dig_rpc_protocol::ErrorCode::NotSupported.machine_code(), @@ -878,6 +891,8 @@ impl ErrorCode { | ErrorCode::ControlIngressLimited // The audit record is a node-private FILE read by the shell, not by the node. | ErrorCode::SpendAuditUnreadable + // Minted by the shell's dispatch gate itself, before the read path is ever asked. + | ErrorCode::EngineWarming | ErrorCode::ParseError => "shell", ErrorCode::MethodNotFound => "boundary", // The wallet balance read (#1851) is served by the node-custodied wallet backend. @@ -927,6 +942,10 @@ impl ErrorCode { "relayed upstream did.", ) } + ErrorCode::EngineWarming => { + "Peer tier not yet attached; retry. The request arrived before the p2p engine \ + finished attaching to the HTTP surface." + } ErrorCode::UpstreamError => { "The blind-passthrough relay to the upstream DIG RPC failed." } @@ -984,6 +1003,7 @@ impl ErrorCode { ErrorCode::InvalidParams, ErrorCode::DispatchFailed, ErrorCode::ResourceUnavailable, + ErrorCode::EngineWarming, ErrorCode::UpstreamError, ErrorCode::Unauthorized, ErrorCode::NotSupported, diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index b8cad9fb..922debf4 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -177,7 +177,7 @@ sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runti # affect, so a stale coin-state cache should be checked against #61 before it is treated as a # mystery. Unrelated, also open: chia-query#62, `eject_peer` leaking the ejected peer's reader # task and socket. -chia-query = "0.24.1" +chia-query = "0.24.3" # Diagnostics MUST go through `tracing`, never stderr. dig-node installs the `dig-logging` # subscriber process-globally (dig-node-service::logging), and a Windows service has no stderr # to discard to — an `eprintln!` here reaches nobody, which is why a chain-source failure stayed From bc9767df3507ed045ac29c97e55e7969fb65aaff Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:28:08 -0700 Subject: [PATCH 07/12] chore: untrack gitnexus-generated agent files (#590) * chore: untrack gitnexus-generated agent files These files were generated by `gitnexus analyze` as a side effect of indexing this repository. They are development-loop private tooling output, not product code, and carry no secrets. They are removed from tracking going forward via .gitignore; history is deliberately NOT rewritten. Refs #3177 * chore: drop private-repo reference from gitignore comment The ignore comment named a private repository and an internal issue number in a public file, which is the same disclosure class this change set exists to remove; the reference is dropped and the guidance kept. --- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 82 ------------ .../gitnexus/gitnexus-debugging/SKILL.md | 89 ------------- .../gitnexus/gitnexus-exploring/SKILL.md | 78 ----------- .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 --------- .../gitnexus-impact-analysis/SKILL.md | 97 -------------- .../gitnexus/gitnexus-refactoring/SKILL.md | 121 ------------------ .gitignore | 9 ++ AGENTS.md | 101 --------------- CLAUDE.md | 101 --------------- 9 files changed, 9 insertions(+), 733 deletions(-) delete mode 100644 .claude/skills/gitnexus/gitnexus-cli/SKILL.md delete mode 100644 .claude/skills/gitnexus/gitnexus-debugging/SKILL.md delete mode 100644 .claude/skills/gitnexus/gitnexus-exploring/SKILL.md delete mode 100644 .claude/skills/gitnexus/gitnexus-guide/SKILL.md delete mode 100644 .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md delete mode 100644 .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md deleted file mode 100644 index c9e0af34..00000000 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: gitnexus-cli -description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" ---- - -# GitNexus CLI Commands - -All commands work via `npx` — no global install required. - -## Commands - -### analyze — Build or refresh the index - -```bash -npx gitnexus analyze -``` - -Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. - -| Flag | Effect | -| -------------- | ---------------------------------------------------------------- | -| `--force` | Force full re-index even if up to date | -| `--embeddings` | Enable embedding generation for semantic search (off by default) | - -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. - -### status — Check index freshness - -```bash -npx gitnexus status -``` - -Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. - -### clean — Delete the index - -```bash -npx gitnexus clean -``` - -Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. - -| Flag | Effect | -| --------- | ------------------------------------------------- | -| `--force` | Skip confirmation prompt | -| `--all` | Clean all indexed repos, not just the current one | - -### wiki — Generate documentation from the graph - -```bash -npx gitnexus wiki -``` - -Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). - -| Flag | Effect | -| ------------------- | ----------------------------------------- | -| `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | -| `--base-url ` | LLM API base URL | -| `--api-key ` | LLM API key | -| `--concurrency ` | Parallel LLM calls (default: 3) | -| `--gist` | Publish wiki as a public GitHub Gist | - -### list — Show all indexed repos - -```bash -npx gitnexus list -``` - -Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. - -## After Indexing - -1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded -2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task - -## Troubleshooting - -- **"Not inside a git repository"**: Run from a directory inside a git repo -- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server -- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md deleted file mode 100644 index 9510b97a..00000000 --- a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: gitnexus-debugging -description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" ---- - -# Debugging with GitNexus - -## When to Use - -- "Why is this function failing?" -- "Trace where this error comes from" -- "Who calls this method?" -- "This endpoint returns 500" -- Investigating bugs, errors, or unexpected behavior - -## Workflow - -``` -1. gitnexus_query({query: ""}) → Find related execution flows -2. gitnexus_context({name: ""}) → See callers/callees/processes -3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow -4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed -``` - -> If "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklist - -``` -- [ ] Understand the symptom (error message, unexpected behavior) -- [ ] gitnexus_query for error text or related code -- [ ] Identify the suspect function from returned processes -- [ ] gitnexus_context to see callers and callees -- [ ] Trace execution flow via process resource if applicable -- [ ] gitnexus_cypher for custom call chain traces if needed -- [ ] Read source files to confirm root cause -``` - -## Debugging Patterns - -| Symptom | GitNexus Approach | -| -------------------- | ---------------------------------------------------------- | -| Error message | `gitnexus_query` for error text → `context` on throw sites | -| Wrong return value | `context` on the function → trace callees for data flow | -| Intermittent failure | `context` → look for external calls, async deps | -| Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | - -## Tools - -**gitnexus_query** — find code related to error: - -``` -gitnexus_query({query: "payment validation error"}) -→ Processes: CheckoutFlow, ErrorHandling -→ Symbols: validatePayment, handlePaymentError, PaymentException -``` - -**gitnexus_context** — full context for a suspect: - -``` -gitnexus_context({name: "validatePayment"}) -→ Incoming calls: processCheckout, webhookHandler -→ Outgoing calls: verifyCard, fetchRates (external API!) -→ Processes: CheckoutFlow (step 3/7) -``` - -**gitnexus_cypher** — custom call chain traces: - -```cypher -MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) -RETURN [n IN nodes(path) | n.name] AS chain -``` - -## Example: "Payment endpoint returns 500 intermittently" - -``` -1. gitnexus_query({query: "payment error handling"}) - → Processes: CheckoutFlow, ErrorHandling - → Symbols: validatePayment, handlePaymentError - -2. gitnexus_context({name: "validatePayment"}) - → Outgoing calls: verifyCard, fetchRates (external API!) - -3. READ gitnexus://repo/my-app/process/CheckoutFlow - → Step 3: validatePayment → calls fetchRates (external) - -4. Root cause: fetchRates calls external API without proper timeout -``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md deleted file mode 100644 index 927a4e4b..00000000 --- a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: gitnexus-exploring -description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" ---- - -# Exploring Codebases with GitNexus - -## When to Use - -- "How does authentication work?" -- "What's the project structure?" -- "Show me the main components" -- "Where is the database logic?" -- Understanding code you haven't seen before - -## Workflow - -``` -1. READ gitnexus://repos → Discover indexed repos -2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness -3. gitnexus_query({query: ""}) → Find related execution flows -4. gitnexus_context({name: ""}) → Deep dive on specific symbol -5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow -``` - -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklist - -``` -- [ ] READ gitnexus://repo/{name}/context -- [ ] gitnexus_query for the concept you want to understand -- [ ] Review returned processes (execution flows) -- [ ] gitnexus_context on key symbols for callers/callees -- [ ] READ process resource for full execution traces -- [ ] Read source files for implementation details -``` - -## Resources - -| Resource | What you get | -| --------------------------------------- | ------------------------------------------------------- | -| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | -| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | -| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | - -## Tools - -**gitnexus_query** — find execution flows related to a concept: - -``` -gitnexus_query({query: "payment processing"}) -→ Processes: CheckoutFlow, RefundFlow, WebhookHandler -→ Symbols grouped by flow with file locations -``` - -**gitnexus_context** — 360-degree view of a symbol: - -``` -gitnexus_context({name: "validateUser"}) -→ Incoming calls: loginHandler, apiMiddleware -→ Outgoing calls: checkToken, getUserById -→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) -``` - -## Example: "How does payment processing work?" - -``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes -2. gitnexus_query({query: "payment processing"}) - → CheckoutFlow: processPayment → validateCard → chargeStripe - → RefundFlow: initiateRefund → calculateRefund → processRefund -3. gitnexus_context({name: "processPayment"}) - → Incoming: checkoutHandler, webhookHandler - → Outgoing: validateCard, chargeStripe, saveTransaction -4. Read src/payments/processor.ts for implementation details -``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md deleted file mode 100644 index 937ac73d..00000000 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: gitnexus-guide -description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" ---- - -# GitNexus Guide - -Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. - -## Always Start Here - -For any task involving code understanding, debugging, impact analysis, or refactoring: - -1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness -2. **Match your task to a skill below** and **read that skill file** -3. **Follow the skill's workflow and checklist** - -> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. - -## Skills - -| Task | Skill to read | -| -------------------------------------------- | ------------------- | -| Understand architecture / "How does X work?" | `gitnexus-exploring` | -| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | -| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | -| Rename / extract / split / refactor | `gitnexus-refactoring` | -| Tools, resources, schema reference | `gitnexus-guide` (this file) | -| Index, status, clean, wiki CLI commands | `gitnexus-cli` | - -## Tools Reference - -| Tool | What it gives you | -| ---------------- | ------------------------------------------------------------------------ | -| `query` | Process-grouped code intelligence — execution flows related to a concept | -| `context` | 360-degree symbol view — categorized refs, processes it participates in | -| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | -| `detect_changes` | Git-diff impact — what do your current changes affect | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | -| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | -| `list_repos` | Discover indexed repos | - -## Resources Reference - -Lightweight reads (~100-500 tokens) for navigation: - -| Resource | Content | -| ---------------------------------------------- | ----------------------------------------- | -| `gitnexus://repo/{name}/context` | Stats, staleness check | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | -| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | -| `gitnexus://repo/{name}/processes` | All execution flows | -| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | -| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | - -## Graph Schema - -**Nodes:** File, Function, Class, Interface, Method, Community, Process -**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS - -```cypher -MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) -RETURN caller.name, caller.filePath -``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md deleted file mode 100644 index e19af280..00000000 --- a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: gitnexus-impact-analysis -description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" ---- - -# Impact Analysis with GitNexus - -## When to Use - -- "Is it safe to change this function?" -- "What will break if I modify X?" -- "Show me the blast radius" -- "Who uses this code?" -- Before making non-trivial code changes -- Before committing — to understand what your changes affect - -## Workflow - -``` -1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this -2. READ gitnexus://repo/{name}/processes → Check affected execution flows -3. gitnexus_detect_changes() → Map current git changes to affected flows -4. Assess risk and report to user -``` - -> If "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklist - -``` -- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents -- [ ] Review d=1 items first (these WILL BREAK) -- [ ] Check high-confidence (>0.8) dependencies -- [ ] READ processes to check affected execution flows -- [ ] gitnexus_detect_changes() for pre-commit check -- [ ] Assess risk level and report to user -``` - -## Understanding Output - -| Depth | Risk Level | Meaning | -| ----- | ---------------- | ------------------------ | -| d=1 | **WILL BREAK** | Direct callers/importers | -| d=2 | LIKELY AFFECTED | Indirect dependencies | -| d=3 | MAY NEED TESTING | Transitive effects | - -## Risk Assessment - -| Affected | Risk | -| ------------------------------ | -------- | -| <5 symbols, few processes | LOW | -| 5-15 symbols, 2-5 processes | MEDIUM | -| >15 symbols or many processes | HIGH | -| Critical path (auth, payments) | CRITICAL | - -## Tools - -**gitnexus_impact** — the primary tool for symbol blast radius: - -``` -gitnexus_impact({ - target: "validateUser", - direction: "upstream", - minConfidence: 0.8, - maxDepth: 3 -}) - -→ d=1 (WILL BREAK): - - loginHandler (src/auth/login.ts:42) [CALLS, 100%] - - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] - -→ d=2 (LIKELY AFFECTED): - - authRouter (src/routes/auth.ts:22) [CALLS, 95%] -``` - -**gitnexus_detect_changes** — git-diff based impact analysis: - -``` -gitnexus_detect_changes({scope: "staged"}) - -→ Changed: 5 symbols in 3 files -→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline -→ Risk: MEDIUM -``` - -## Example: "What breaks if I change validateUser?" - -``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) - → d=1: loginHandler, apiMiddleware (WILL BREAK) - → d=2: authRouter, sessionManager (LIKELY AFFECTED) - -2. READ gitnexus://repo/my-app/processes - → LoginFlow and TokenRefresh touch validateUser - -3. Risk: 2 direct callers, 2 processes = MEDIUM -``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md deleted file mode 100644 index f48cc01b..00000000 --- a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: gitnexus-refactoring -description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" ---- - -# Refactoring with GitNexus - -## When to Use - -- "Rename this function safely" -- "Extract this into a module" -- "Split this service" -- "Move this to a new file" -- Any task involving renaming, extracting, splitting, or restructuring code - -## Workflow - -``` -1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents -2. gitnexus_query({query: "X"}) → Find execution flows involving X -3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs -4. Plan update order: interfaces → implementations → callers → tests -``` - -> If "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklists - -### Rename Symbol - -``` -- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits -- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) -- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits -- [ ] gitnexus_detect_changes() — verify only expected files changed -- [ ] Run tests for affected processes -``` - -### Extract Module - -``` -- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs -- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers -- [ ] Define new module interface -- [ ] Extract code, update imports -- [ ] gitnexus_detect_changes() — verify affected scope -- [ ] Run tests for affected processes -``` - -### Split Function/Service - -``` -- [ ] gitnexus_context({name: target}) — understand all callees -- [ ] Group callees by responsibility -- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update -- [ ] Create new functions/services -- [ ] Update callers -- [ ] gitnexus_detect_changes() — verify affected scope -- [ ] Run tests for affected processes -``` - -## Tools - -**gitnexus_rename** — automated multi-file rename: - -``` -gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) -→ 12 edits across 8 files -→ 10 graph edits (high confidence), 2 ast_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] -``` - -**gitnexus_impact** — map all dependents first: - -``` -gitnexus_impact({target: "validateUser", direction: "upstream"}) -→ d=1: loginHandler, apiMiddleware, testUtils -→ Affected Processes: LoginFlow, TokenRefresh -``` - -**gitnexus_detect_changes** — verify your changes after refactoring: - -``` -gitnexus_detect_changes({scope: "all"}) -→ Changed: 8 files, 12 symbols -→ Affected processes: LoginFlow, TokenRefresh -→ Risk: MEDIUM -``` - -**gitnexus_cypher** — custom reference queries: - -```cypher -MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) -RETURN caller.name, caller.filePath ORDER BY caller.filePath -``` - -## Risk Rules - -| Risk Factor | Mitigation | -| ------------------- | ----------------------------------------- | -| Many callers (>5) | Use gitnexus_rename for automated updates | -| Cross-area refs | Use detect_changes after to verify scope | -| String/dynamic refs | gitnexus_query to find them | -| External/public API | Version and deprecate properly | - -## Example: Rename `validateUser` to `authenticateUser` - -``` -1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) - → 12 edits: 10 graph (safe), 2 ast_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... - -2. Review ast_search edits (config.json: dynamic reference!) - -3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) - → Applied 12 edits across 8 files - -4. gitnexus_detect_changes({scope: "all"}) - → Affected: LoginFlow, TokenRefresh - → Risk: MEDIUM — run tests for these flows -``` diff --git a/.gitignore b/.gitignore index 6b1fb310..6288fdef 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,12 @@ config.json # lane-local scratch (never committed) .lane/ + +# gitnexus writes agent-tooling files into the repository it indexes. They are +# development-loop private and must never be tracked here. +# Prefer `gitnexus analyze --skip-agents-md`. +/AGENTS.md +/CLAUDE.md +/.claude/skills/gitnexus/ +/.claude/skills/generated/ +/.gitnexus/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 11a1e554..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,101 +0,0 @@ - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **dn-365-366** (11798 symbols, 32150 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/dn-365-366/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/dn-365-366/context` | Codebase overview, check index freshness | -| `gitnexus://repo/dn-365-366/clusters` | All functional areas | -| `gitnexus://repo/dn-365-366/processes` | All execution flows | -| `gitnexus://repo/dn-365-366/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 11a1e554..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,101 +0,0 @@ - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **dn-365-366** (11798 symbols, 32150 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/dn-365-366/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/dn-365-366/context` | Codebase overview, check index freshness | -| `gitnexus://repo/dn-365-366/clusters` | All functional areas | -| `gitnexus://repo/dn-365-366/processes` | All execution flows | -| `gitnexus://repo/dn-365-366/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - From d562aad8d76f766cee4dfde9c5e0d6eda01286d3 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:26:11 -0700 Subject: [PATCH 08/12] feat(rewards): always-on prover loop engine -- honest liveness, type-enforced self-exclusion, bounded spend (#593) The always-on reward-prover engine: ~2,000 lines under `crates/dig-node-core/src/rewards/`, built against the merged `dig-rewards-coin` SPEC. Library only -- nothing spawns it, and the sole production `RewardsChainPort` refuses every call, so it cannot spend. Wiring the composed system is #3265, which carries its own gate. The epic's premise -- "anytime the process isn't running, rewards are not being distributed" -- is half wrong, and the false half is the dangerous one. `Sync`, `NewEpoch` and `InitiatePayout` need no manager authority, so funder downtime does not stop rewards: it FREEZES THE ENTRY SET while accrual and payouts continue. Peers that stopped mirroring keep earning; peers that started cannot begin. That shaped the whole design. Liveness honesty (SPEC 2.4). The status record carries no `healthy`/`ok`/`up` boolean and no precomputed staleness, because a wedged loop cannot report its own wedging -- whatever it last wrote stays there, so a writer-set flag reads true forever after the failure it exists to reveal. The reader derives staleness from `last_cycle_completed_at` against `observed_at` and its own clock. A recursive JSON-key test enforces the absence at every nesting depth; asserting on keys and never substrings, since `ProverState::Running` legitimately serializes the VALUE "running". The one legal staleness signal is chain-derived (SPEC 12.4: 48 hours AND a non-zero reserve, from the singleton's own spend history) and lives on the distributor read, where a wedged prover cannot fake it. Self-exclusion is a compile error, not a habit. dig-node#261's lesson is that an invariant enforced on some paths is not an invariant. `admit` is the single admission point, checks both SPEC 5.2 coordinates (own peer_id OR a payout puzzle hash this wallet controls), and mints an `AdmittedPeer` with private fields and no public constructor -- so `EntryAction::Add` cannot be built by a path that skipped admission. A prover's own fault can never strike a peer. `GateError` is a distinct type from `GateIneligibleReason` and `record_prover_fault` takes `&self`, so SPEC 3.6 clause 4 is enforced by the borrow checker rather than by comment. Without that, a misconfigured operator -- one missing mirror-collateral epoch ordinal -- would strike every peer at once and evict its entire 250-entry set in three hours, each eviction a fee it pays plus a settlement out of its own reserve. The money bounds are stated where a human reads them (`rewards/mod.rs`): 24 bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard fee, 192 removals/day worst case with 96/day sustained churn. Recorded honestly: SPEC 6.3's rate bound and fee ceiling are ONE control, not two. Three gate rounds, every leg fresh-context. Round 3 at this head: reviewer PASS, adversarial decider RATIFY (leg closed), security CHANGES-REQUIRED on a finding the decider ratified deliberately -- adjudicated in https://github.com/DIG-Network/dig-node/pull/593#issuecomment-5601784815 and carried to #3265 with the remedy corrected, because the proposed fix would have persisted a poison flag to the very store whose writes were failing. Found and fixed under gate: a census ordinal off by one in both directions (SPEC 4.6 requires n-1 exactly); an unreachable grace window leaving a named constant with no reader; a missing `NewEpoch` spend; an absent-ordinal path attributing a prover fault to peers; and a daily fee ceiling 24x too high because a per-bundle fee was consumed as a daily ceiling. Refs DIG-Network/dig_ecosystem#3250 Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 1 + crates/dig-node-core/src/rewards/admission.rs | 297 +++++++ crates/dig-node-core/src/rewards/challenge.rs | 435 ++++++++++ crates/dig-node-core/src/rewards/cycle.rs | 238 ++++++ crates/dig-node-core/src/rewards/gate.rs | 443 ++++++++++ crates/dig-node-core/src/rewards/mod.rs | 49 ++ crates/dig-node-core/src/rewards/port.rs | 169 ++++ .../src/rewards/spec_constants.rs | 76 ++ crates/dig-node-core/src/rewards/staleness.rs | 94 +++ crates/dig-node-core/src/rewards/state.rs | 243 ++++++ crates/dig-node-core/src/rewards/writes.rs | 769 ++++++++++++++++++ 11 files changed, 2814 insertions(+) create mode 100644 crates/dig-node-core/src/rewards/admission.rs 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/gate.rs create mode 100644 crates/dig-node-core/src/rewards/mod.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/staleness.rs create mode 100644 crates/dig-node-core/src/rewards/state.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/admission.rs b/crates/dig-node-core/src/rewards/admission.rs new file mode 100644 index 00000000..0e65d04d --- /dev/null +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -0,0 +1,297 @@ +//! 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::{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 +/// 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, 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) + } +} + +/// 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. + 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. + SelfExcluded, + /// 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 +/// 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, + epoch_ctx: EpochContext, + launcher_id: Bytes32, +) -> AdmissionDecision { + if candidate.peer_id == own.peer_id { + return AdmissionDecision::SelfExcluded; + } + + 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 { + AdmissionDecision::Admit(AdmittedPeer { + payout_puzzle_hash, + launcher_id, + }) + } + } + Ok(GateOutcome::Ineligible(_)) => AdmissionDecision::GateIneligible, + } +} + +#[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; + + 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], + _ctx: EpochContext, + ) -> Result { + match self.eligible.get(&peer_id) { + Some(ph) => Ok(GateOutcome::Eligible { + payout_puzzle_hash: *ph, + }), + 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], + controlled_puzzle_hashes: vec![[0xBB; 32]], + } + } + + 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() { + 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, ctx(), LAUNCHER).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, ctx(), LAUNCHER).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, ctx(), LAUNCHER).await; + assert_eq!( + decision, + AdmissionDecision::Admit(AdmittedPeer::for_test(honest_payout, LAUNCHER)), + "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, ctx(), LAUNCHER).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(), 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 new file mode 100644 index 00000000..f8fb9667 --- /dev/null +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -0,0 +1,435 @@ +//! 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. +/// +/// `% 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 +/// — 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. +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 +} + +/// 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>, +} + +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 + }) + }) + } + + /// 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, + launcher_id: Bytes32, + resource_id: Bytes32, + 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() + .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)); + } + + /// `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(); + 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 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..98ce6329 --- /dev/null +++ b/crates/dig-node-core/src/rewards/cycle.rs @@ -0,0 +1,238 @@ +//! 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::spec_constants::{ + PROVER_CYCLE_DEADLINE_SECONDS, PROVER_CYCLE_PERIOD_SECONDS, PROVER_HEARTBEAT_SECONDS, +}; +use super::state::{Clock, ProverState, StatusHandle}; +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)); + + // 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; + + 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 new file mode 100644 index 00000000..f78f6121 --- /dev/null +++ b/crates/dig-node-core/src/rewards/gate.rs @@ -0,0 +1,443 @@ +//! The mirror-coin gate (SPEC §4, §10). A candidate is admitted only when all three §4.3 calls +//! 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 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` / +/// `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, 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], + census_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, 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`. + 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), +} + +/// 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], + ctx: EpochContext, + ) -> Result; +} + +/// 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 census ordinal is also + /// accepted, and a rollover mismatch MUST NOT strike (the caller enforces the "no strike" half; + /// 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], + census_epoch: u64, + in_grace_window: bool, + ) -> bool { + if self + .reader + .advertises(coin_id, self.store_id, self.root, census_epoch) + .await + { + return true; + } + if in_grace_window && census_epoch > 0 { + return self + .reader + .advertises(coin_id, self.store_id, self.root, census_epoch - 1) + .await; + } + false + } +} + +#[async_trait] +impl MirrorCoinGatePort for SpecMirrorCoinGate { + 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 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, + )); + } + + if !self.reader.declares_peer(hint, peer_id).await { + 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, + )), + } + } +} + +#[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()) + } + + 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, 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_a_gate_error_not_an_ineligibility_verdict() { + let g = gate(FakeReader::default(), Some(COIN)); + assert_eq!( + 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 + )) + ); + } + + #[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, 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 census_epoch_n_minus_1_is_admitted() { + let mut reader = FakeReader::default(); + 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, 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); // 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, 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); // 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, 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); // 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, ctx(Some(2))).await, + Ok(GateOutcome::Eligible { + payout_puzzle_hash: OWNER + }) + ); + } + + /// 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 grace_window_makes_previous_census_ordinal_admissible_via_evaluate() { + let mut reader = FakeReader::default(); + 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)); + 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 + }) + ); + } + + /// D2 regression: outside the grace window the same mismatch is simply ineligible (never a + /// strike — enforced at the cycle layer). + #[tokio::test] + 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)); + 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..d80a21e7 --- /dev/null +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -0,0 +1,49 @@ +//! 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. +//! +//! # 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**: 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; +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 new file mode 100644 index 00000000..d9844fe9 --- /dev/null +++ b/crates/dig-node-core/src/rewards/port.rs @@ -0,0 +1,169 @@ +//! 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 super::admission::AdmittedPeer; +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 { + /// 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, + }, +} + +/// 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>; + + /// 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 +/// [`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) + } + + async fn spend_new_epoch(&self, _launcher_id: Bytes32) -> 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) + ); + assert_eq!( + port.spend_new_epoch([0u8; 32]).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/staleness.rs b/crates/dig-node-core/src/rewards/staleness.rs new file mode 100644 index 00000000..43bf7b49 --- /dev/null +++ b/crates/dig-node-core/src/rewards/staleness.rs @@ -0,0 +1,94 @@ +//! 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/state.rs b/crates/dig-node-core/src/rewards/state.rs new file mode 100644 index 00000000..60204bc2 --- /dev/null +++ b/crates/dig-node-core/src/rewards/state.rs @@ -0,0 +1,243 @@ +//! 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::*; + + /// 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(); + assert_no_forbidden_health_keys(&json); + } + + #[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); + } +} 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..7f5874eb --- /dev/null +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -0,0 +1,769 @@ +//! 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; + +/// 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); + +/// 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: 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 { + // `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) + } + + fn roll_if_new_day(&mut self, now: u64) { + if now.saturating_sub(self.day_started_at) >= SECONDS_PER_DAY { + 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, + }; + } + + 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, + } + } + + /// 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, + /// 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, + poisoned: std::cell::Cell::new(false), + } + } + + /// 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. + /// + /// `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, + 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), + }; + + if now.saturating_sub(state.day_started_at) >= SECONDS_PER_DAY { + 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), + ); + } + + 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`, 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> { + let result = self.store.save(launcher_id, state); + if result.is_err() { + self.poisoned.set(true); + } + result + } +} + +/// 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(super::super::admission::AdmittedPeer::for_test( + 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, + ); + 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 + // 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)); + } + + /// 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)); + assert!(!is_entry_set_full(MAX_ENTRIES_PER_DISTRIBUTOR as usize - 1)); + 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 } + ); + } + + /// 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" + ); + } + + /// 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()); + } +} From 8573ecf53a64d09e719d0c4c53a972e0ac53ed6b Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:47:40 -0700 Subject: [PATCH 09/12] feat: serve dig.getRewardProverStatus at Tier::Control (#595) * chore: open lane for #3269 Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and the other reward RPC methods to the wire). Co-Authored-By: Claude Sonnet 5 * test(rewards): fail-closed Reward-tier guard + dig-rpc-protocol 0.11 line assertion - dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10); documents the known-red two-version state pending the dig-peer 0.14.0 / dig-download 0.23.0 cascade (#3269). - reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL catalogue -- every Reward-named method must be Tier::Control and not peer-reachable, so a fifth reward method added later is caught at the wrong tier automatically rather than inheriting a wrong default (binds #3261's rule node-side). - peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method, since an external integration test cannot see it -- same guard, executed against this node's own allowlist rather than only the shared crate's. Refs #3269 Co-Authored-By: Claude Sonnet 5 * style: remove trailing blank line in reward_methods_tier_guard.rs * feat(rpc): serve dig.getRewardProverStatus at Tier::Control Adds the missing handler for PR#595: a new reward_prover_statuses registry + accessors on Node (empty until #3265 spawns a prover loop, so the registry read is real, not a stub), a dispatch.rs arm inside the Method enum match (never the string pre-match), and a field-for-field mapping from dig-node-core's internal rewards::state::RewardProverStatus (camelCase-tagged) onto dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged struct, camelCase-tagged ProverState value), widening entry_count u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity fields. An all-zero launcher_id (what an uninitialised registry slot hex-encodes to) is omitted at this boundary rather than rendered as a real distributor with a plausible-looking id -- the money-hole class the dig-rewards-coin driver's adversarial gates found three times. Tests (in dig-node-core::lib.rs's existing test module, where the pub(crate) registry accessors are visible) drive the real dispatch entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm) and assert field-for-field on the serialized JSON body: populated registry, empty registry (-> {"statuses": []}), zero-id omission, tier/peer-reachability, enum-match-not-string-prematch, and launcher_id filtering. The no-health-boolean / no-staleness assertion is by key set, not substring. Co-Authored-By: Claude Sonnet 5 * style: rustfmt the reward-prover-status registry + tests Co-Authored-By: Claude Sonnet 5 * chore(deps): bump dig-download 0.23, dig-peer 0.14, dig-rpc-protocol 0.11 Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0 and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's two 0.10 lines (main dep + dev-dependency restatement for openrpc_drift_guard.rs) move to 0.11 to match. Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires dig-peer = "^0.13" in every published version through 0.11.1, so the tree cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14. CI will stay red on this commit for that reason, which is expected. Co-Authored-By: Claude Sonnet 5 * feat(rpc): close dig-rpc-protocol 0.11 cascade + attribute payout figures Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer ^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0, alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0. Add a subject-attribution test and doc comments to reward_prover_status_to_wire: total_paid_out_base_units and reserve_base_units are per-distributor totals (this distributor's payout to ALL its mirrors, and this distributor's own reserve), never the querying node's own earnings and never summed/cross-attributed across distributors. This is the defect class a sibling adversarial gate found in dig-app#403's rewards pane, which rendered a distributor total as one mirror operator's personal earnings and overstated by up to 250x. Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a persisted EligiblePayoutHash verdict. Refs #3269 Co-Authored-By: Claude Sonnet 5 * fix(rpc): silence dead_code on register_reward_prover_status pending #3265 Clippy's non-test lib target has no production caller for register_reward_prover_status yet, because #3265 (the always-on prover loop that would call it from bring-up) has not landed -- only tests call it today. cfg_attr(not(test), allow(dead_code)) stands in for that missing caller until #3265 wires a real one. Refs #3269 Co-Authored-By: Claude Sonnet 5 * fix(rpc): make the all-zero identity guard non-silent and cover all three fields Security (blocking) and the adversarial leg both found the same defect in the zero-launcher_id filter: it checked only launcher_id, so a registration bug that zeroed store_id or root beside a valid launcher_id would pass through as a plausible record, and dropping the bad record silently destroyed the evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact prohibition. zeroed_identity_fields() now checks launcher_id, store_id AND root. The dispatch filter still excludes a record with any zeroed field (never renders an uninitialised slot as a real distributor), but first fires a tracing::warn! naming which field(s) were zero, so a bad registration is observable rather than swallowed. Kept isolated in dispatch.rs rather than woven into the wire mapping, since this belongs at #3265's writer once that lands. Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which proved the omission but not the observability, and never exercised a zeroed store_id/root beside a valid launcher_id) with get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id, and asserting the tracing::warn! output via the crate's existing capture_sync_logs test utility. Fixed a now-false "Known-red" doc comment on tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate: the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green, not red. Assertion itself untouched -- still exact-version. Refs #3269 Co-Authored-By: Claude Sonnet 5 * fix(rpc): correct a born-false "shipped dig-app 15.5.0" doc claim dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN and unmerged. Point the doc comment at the real, unmerged consumer instead so a future reader doesn't take this as evidence a shipped consumer depends on the guard, which would wrongly discourage relocating it to #3265's writer. Refs #3269 Co-Authored-By: Claude Sonnet 5 * fix(rpc): correct doc placement, assert the zeroed field by name, treat root as an observation Three findings from the correctness gate on PR#595 at 134864a9. 1. The zeroed-identity helper's doc block was spliced onto the end of reward_prover_status_to_wire's block with no separator, so the wire-mapping rationale documented a boolean predicate and the mapping function was left with no doc at all. Each doc block now sits above the item it describes. 2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn emits launcher_id as a structured field on every fire, so the property the guard exists to add -- naming which field was zeroed -- was unasserted. Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green. The test now asserts the zeroed_fields value itself, which the fixture makes exact and disjoint across cases. 3. `root` is an observation, not an identity. A registered prover that has not completed its first cycle plausibly has no root, and a writer that zero-inits it would have made a healthy prover invisible. A zeroed launcher_id or store_id still excludes the record; a zeroed root alone warns and returns. Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) * fix(rpc): restore zeroed_fields structured field dropped from the pushed warn The previous commit (080be3df) landed with `zeroed_fields = ?zeroed` missing from the tracing::warn! call in the GetRewardProverStatus filter -- a one-line regression introduced while proving the new log assertion goes red without it, never restored before the commit was made. Without this field the log line never names WHICH field was zero, so an operator sees only that something was excluded, and the test asserting `zeroed_fields=[...]` per case would fail. Restored; all 7 reward-prover-status tests green. Refs #3269 Co-Authored-By: Claude Sonnet 5 * fix(rpc): split zeroed-field logging by level -- WARN for a missing identity, DEBUG for a zeroed root A zeroed launcher_id or store_id is a real registration bug: the record is excluded and now logs at WARN, naming the exact field(s) via `zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle state, not a fault: the record is still returned, and now logs at DEBUG instead of WARN, so an operator polling this endpoint sees warn-level volume proportional to real registration bugs, not to every not-yet-cycled prover on every poll. Updated the doc comments on `zeroed_fields`, the dispatch filter and the test to describe the level split, and extended the regression test to assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value. Proved both directions: flipping the DEBUG branch back to WARN turns the test red on the level assertion; flipping the field-name assertion back to a bare `contains("launcher_id")` would have passed unconditionally (the prior tautology) and is no longer possible since the assertions now pin `zeroed_fields=[...]` plus the level string. Refs #3269 Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- Cargo.lock | 46 +- crates/dig-node-core/Cargo.toml | 23 +- crates/dig-node-core/src/lib.rs | 461 ++++++++++++++++++ crates/dig-node-core/src/peer.rs | 28 ++ .../src/seams/dig_rpc/dispatch.rs | 181 +++++++ crates/dig-node-core/tests/dependency_tree.rs | 19 +- .../tests/reward_methods_tier_guard.rs | 70 +++ crates/dig-node-service/Cargo.toml | 10 +- 8 files changed, 802 insertions(+), 36 deletions(-) create mode 100644 crates/dig-node-core/tests/reward_methods_tier_guard.rs diff --git a/Cargo.lock b/Cargo.lock index 0a7478c6..058fad93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1948,7 +1948,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a85a94865f946f608c06bf1b2259b894c4100f14cb75fa5f0065b8e439fc0f93" +checksum = "b1f9a6e23899a1a58ff8f070307897799b0142b3d9447652676ffae7c131da7c" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3144,9 +3144,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5cf9690e3e31508b092cfb72fc92b62f241b479920d7f830d514a5fd4e8cdc" +checksum = "d6d28173f5ac2fb725d70d81491918bb9dbdc1691745bf044524aee8484333ef" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", @@ -3184,9 +3184,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "946dee72de59dbe5c9ac00e700899e1a0f258930080e0b22de6019732d0b7389" +checksum = "1ac1005c43d63ca61d3ca6391cf6d3ff08b7138bb22e237ae76c5157d7677652" dependencies = [ "dig-dht", "dig-nat", @@ -3210,9 +3210,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.10.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c46a32c3fc6203773b6f551b21e5475b81b60d8b2c72a3b71c74694b149ade" +checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" dependencies = [ "serde", "serde_json", @@ -3752,7 +3752,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4536,7 +4536,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4787,7 +4787,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5169,7 +5169,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5780,7 +5780,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -5818,9 +5818,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6570,7 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7020,7 +7020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7282,7 +7282,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8416,7 +8416,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index c7320e10..e4ad1616 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -191,7 +191,7 @@ serde_json = "1" # per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). -dig-rpc-protocol = "0.10.2" +dig-rpc-protocol = "0.11.0" # The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope # the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 # envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. @@ -457,7 +457,11 @@ dig-pex = "0.1.1" # checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), # because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no # filename at all) and the one `FileStateStore` test used a 3-character key. -dig-download = "0.22" +# +# Moved to 0.23 (dig_ecosystem#3269): 0.23.0 is the release that re-exports `dig-rpc-protocol` 0.11's +# `ModuleInfo`, closing the two-shapes split this crate's own `dig-rpc-protocol = "0.11.0"` line above +# opened against dig-download's prior 0.22-line dependency on `dig-rpc-protocol` 0.10.3. +dig-download = "0.23" # -- The shared peer client (#1283/#1576) ------------------------------------------------------------- # `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed # RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the @@ -469,7 +473,10 @@ dig-download = "0.22" # module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on # dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by # `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.13" +# +# Moved to 0.14 (dig_ecosystem#3269), alongside dig-download's move to 0.23 above, for the same +# reason: 0.14.0 is on `dig-rpc-protocol` 0.11, keeping exactly one version resolving. +dig-peer = "0.14" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation @@ -496,7 +503,13 @@ dig-peer = "0.13" # above (dig-node#422). Its predecessor 0.10.0 required `^0.13`, and because this crate passes # dig-dht values into the selector, that requirement is what held dig-dht at 0.13; see the dig-dht # entry above. -dig-peer-selector = "0.11" +# +# Moved to 0.12 (dig_ecosystem#3269): 0.12.0 is the release that moves onto `dig-peer ^0.14`, the +# last of the four links in the `dig-rpc-protocol` 0.11 cascade (dig-peer 0.14.0, dig-download +# 0.23.0, dig-peer-selector 0.12.0). Every prior `dig-peer-selector` release — through 0.11.1 — +# stayed on `dig-peer ^0.13`, which is what pinned this crate's `dig-peer` line above at 0.13 and +# kept two `dig-rpc-protocol` versions resolving simultaneously. +dig-peer-selector = "0.12" # The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity # is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in # the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the @@ -580,7 +593,7 @@ rcgen = "0.13" # # Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which # fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.22", features = ["testkit"] } +dig-download = { version = "0.23", features = ["testkit"] } # Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the # serve-observability tests (#1595) assert what an operator would actually see in the node log — # and that no payload byte or proof ever reaches it. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 3d4702df..d582f173 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -568,6 +568,45 @@ pub struct Node { /// announces exactly as it always did, and a verifier that cannot fetch a pointer withholds /// credit rather than demoting the holder. mirror_pointers: OnceLock>, + /// Registry of this node's live reward-prover [`rewards::state::StatusHandle`]s, read by + /// `dig.getRewardProverStatus` (dig_ecosystem#3269). Nothing spawns a prover loop yet + /// (dig_ecosystem#3265, not landed), so this stays empty and the handler's + /// `{"statuses": []}` answer is a REAL, currently-empty read — SPEC §2.4 clause 1's + /// "not distributing" render — not a hardcoded stub. The day #3265 registers a handle via + /// [`Node::register_reward_prover_status`], the same read starts returning it with no + /// dispatch-side change. + reward_prover_statuses: Arc>>, +} + +impl Node { + /// Register a live reward-prover status handle (dig_ecosystem#3269/#3265) so + /// `dig.getRewardProverStatus` can read it. Additive — registering a second handle for the + /// same distributor is the registrar's mistake to avoid, not this method's to dedupe. + /// + /// Only called from tests today: #3265 (the always-on prover loop that would call this from + /// production bring-up) has not landed, so clippy's non-test lib target sees no production + /// caller yet. `allow(dead_code)` here is a stand-in for that missing caller, not a claim the + /// registry itself is unused — remove this attribute the moment #3265 lands and wires a real + /// call site. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn register_reward_prover_status(&self, handle: rewards::state::StatusHandle) { + self.reward_prover_statuses + .write() + .expect("reward prover status registry lock poisoned") + .push(handle); + } + + /// Snapshot every registered reward-prover status, in registration order. Empty when nothing + /// has registered — a REAL read of a real (currently empty) registry, see the field doc on + /// `reward_prover_statuses`. + pub(crate) fn reward_prover_status_snapshots(&self) -> Vec { + self.reward_prover_statuses + .read() + .expect("reward prover status registry lock poisoned") + .iter() + .map(rewards::state::StatusHandle::snapshot) + .collect() + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4815,6 +4854,7 @@ impl Node { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }) } @@ -5154,6 +5194,7 @@ pub(crate) mod test_support { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; (Arc::new(node), td) } @@ -5949,6 +5990,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; (node, td) } @@ -6083,6 +6125,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; // Missing before the pull. @@ -6151,6 +6194,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6250,6 +6294,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6327,6 +6372,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -8995,6 +9041,414 @@ mod tests { } } + // -- dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md §2.3/§2.4) ----- + + /// A populated SPEC §2.3 status record with every field a distinct, checkable value — + /// distinguishes a mapping bug (e.g. two fields swapped, or a widening dropped) from an + /// accidental match against a zeroed/default record. + fn sample_reward_prover_status( + launcher_id: [u8; 32], + ) -> crate::rewards::state::RewardProverStatus { + crate::rewards::state::RewardProverStatus { + launcher_id, + store_id: [0x22u8; 32], + root: [0x33u8; 32], + prover_state: crate::rewards::state::ProverState::ChainSourceUnavailable, + prover_state_since: 1_000, + last_cycle_started_at: Some(1_100), + last_cycle_completed_at: Some(1_200), + next_cycle_due_at: Some(1_300), + last_entry_write_at: Some(1_400), + consecutive_cycle_failures: 3, + pending_entry_writes: 5, + observed_at: 1_500, + counters: crate::rewards::state::ProverCounters { + mirrors_seen: 11, + challenges_issued: 22, + challenges_passed: 33, + challenges_failed: 44, + entries_added: 55, + entries_removed: 66, + // Deliberately in the upper half of `u32`'s range (> 2^31) — the internal field IS + // `u32`, so this cannot exceed `u32::MAX` (that would not compile), but a value + // this large would not survive a mistaken re-narrowing (e.g. an accidental + // `as u32 as u64` round-trip through a signed/other-width type) intact, unlike a + // small value that would pass such a bug undetected. + entry_count: 3_000_000_000, + reserve_base_units: 77, + total_paid_out_base_units: 88, + }, + } + } + + /// **Proves:** `dig.getRewardProverStatus` answers through the REAL dispatch entry point + /// (`handle_rpc` → `RpcDispatch::dispatch` → the `Method::GetRewardProverStatus` arm) with a + /// registered handle's values, asserted field-for-field on the SERIALIZED JSON body (snake_case + /// wire keys, camelCase `prover_state` enum value) — not a Rust struct, so a serde rename or a + /// dropped field would be caught. Also asserts the wire body's key set carries none of + /// `alive`/`healthy`/`ok`/`up`/`running` and no staleness field, by KEY SET rather than + /// substring (a substring check would pass under the defect it exists to catch). + /// **Catches:** a field swap, a dropped `entry_count` widening, a reintroduced health boolean. + #[test] + fn get_reward_prover_status_answers_a_real_request_with_real_values() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let launcher_id = [0x11u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(launcher_id), + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + let statuses = resp["result"]["statuses"] + .as_array() + .expect("result.statuses is an array"); + assert_eq!(statuses.len(), 1, "one registered handle: {resp}"); + let s = &statuses[0]; + + assert_eq!(s["launcher_id"], json!(hex::encode(launcher_id))); + assert_eq!(s["store_id"], json!(hex::encode([0x22u8; 32]))); + assert_eq!(s["root"], json!(hex::encode([0x33u8; 32]))); + // camelCase VALUE for the enum, on an otherwise snake_case-keyed wire struct (confirmed at + // v0.11.0: only `ProverState` carries `rename_all = "camelCase"`). + assert_eq!(s["prover_state"], json!("chainSourceUnavailable")); + assert_eq!(s["prover_state_since"], json!(1_000)); + assert_eq!(s["last_cycle_started_at"], json!(1_100)); + assert_eq!(s["last_cycle_completed_at"], json!(1_200)); + assert_eq!(s["next_cycle_due_at"], json!(1_300)); + assert_eq!(s["last_entry_write_at"], json!(1_400)); + assert_eq!(s["consecutive_cycle_failures"], json!(3)); + assert_eq!(s["pending_entry_writes"], json!(5)); + assert_eq!(s["observed_at"], json!(1_500)); + + let counters = &s["counters"]; + assert_eq!(counters["mirrors_seen"], json!(11)); + assert_eq!(counters["challenges_issued"], json!(22)); + assert_eq!(counters["challenges_passed"], json!(33)); + assert_eq!(counters["challenges_failed"], json!(44)); + assert_eq!(counters["entries_added"], json!(55)); + assert_eq!(counters["entries_removed"], json!(66)); + // The value proving the widening ran: > u32::MAX, so a truncating cast would not equal this. + assert_eq!(counters["entry_count"], json!(3_000_000_000u64)); + assert_eq!(counters["reserve_base_units"], json!(77)); + assert_eq!(counters["total_paid_out_base_units"], json!(88)); + + // No health boolean, no precomputed staleness (SPEC §2.4) — by KEY SET, not substring. + let keys: std::collections::BTreeSet<&str> = s + .as_object() + .expect("status is an object") + .keys() + .map(String::as_str) + .collect(); + for banned in [ + "alive", + "healthy", + "ok", + "up", + "running", + "stale", + "seconds_since_last_run", + ] { + assert!( + !keys.contains(banned), + "banned key {banned:?} present: {keys:?}" + ); + } + } + + /// **Proves:** with nothing registered, `dig.getRewardProverStatus` answers + /// `{"statuses": []}` — SPEC §2.4 clause 1's "not distributing" render — never blank, `null`, + /// or an omitted `result`. **Catches:** an absent-record case that renders as nothing rather + /// than an explicit empty list a UI can render deterministically. + #[test] + fn get_reward_prover_status_with_no_registered_handle_is_explicit_empty() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!( + resp["result"], + json!({"statuses": []}), + "explicit empty list: {resp}" + ); + } + + /// **Proves:** a zeroed `launcher_id` OR `store_id` — what an uninitialised/never-assigned + /// registry slot hex-encodes to — is never rendered as a real distributor with a + /// plausible-looking id, AND that dropping it is never silent: a `tracing::warn!` fires + /// naming the SPECIFIC zeroed field(s), so a registration bug is observable rather than + /// swallowed. This is the money-hole class the `dig-rewards-coin` driver's adversarial gates + /// found three times (an unset field that reads fine and costs the operator), plus the SPEC + /// §2.4 clause 1 defect a security + adversarial gate found in the first version of this + /// filter: an all-zero-`launcher_id`-only check that silently destroyed the evidence of a bad + /// registration, and never checked `store_id` at all. + /// + /// Distinguishes IDENTITY fields (`launcher_id`, `store_id` — a record missing either cannot + /// be attributed to any distributor, so it is EXCLUDED and logged at `WARN`) from the + /// OBSERVATION field (`root` — legitimately zero before a prover's first cycle, so it is + /// logged at `DEBUG`, never `WARN`, and never causes exclusion on its own; see the third case + /// below). The level split matters, not just the exclusion split: security measured that an + /// undifferentiated `warn!` for both cases turns steady-state log volume into (uncycled + /// provers) x (poll rate) lines an operator cannot distinguish from a real registration bug. + /// + /// **Catches:** (1) a boundary that lets an uninitialised slot answer as if it were a real + /// distributor; (2) a filter that only checks `launcher_id`, missing a registration bug that + /// zeroes `store_id` beside an otherwise-valid `launcher_id` (the exact gap security named); + /// (3) a fix that goes back to dropping the bad record with no log line at all; (4) a fix + /// that over-corrects by excluding on a zeroed `root` too, which would make a healthy, + /// just-not-yet-cycled prover invisible; (5) a fix that returns the zeroed-root record but logs + /// it at the SAME level (`warn!`) as a real identity fault, defeating the operator's ability to + /// tell the two apart; (6) a log assertion that only checks the field NAME `launcher_id` + /// appears somewhere in the log line — true unconditionally, since the log always includes + /// `launcher_id = %hex::encode(...)` as a structured field regardless of which field was + /// actually zero — rather than checking the `zeroed_fields=[...]` value AND the level. + #[test] + fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + // Case 1: launcher_id itself is zeroed (the original, narrower gap). + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0u8; 32]), + )); + + // Case 2: launcher_id is VALID, but store_id is zeroed — the gap security named, which + // the launcher_id-only filter would have let straight through as a plausible record. + let valid_but_zeroed_store = [0xccu8; 32]; + let mut zeroed_store_status = sample_reward_prover_status(valid_but_zeroed_store); + zeroed_store_status.store_id = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_store_status, + )); + + // Case 3: launcher_id AND store_id are both valid, but root is zeroed — a plausible + // "registered, not yet cycled" prover. Must still be RETURNED (root is not an identity + // field), and a DEBUG (never WARN) still fires naming `root` so the state stays + // observable without polluting warn-level volume with an ordinary, expected state. + let valid_but_zeroed_root = [0xbbu8; 32]; + let mut zeroed_root_status = sample_reward_prover_status(valid_but_zeroed_root); + zeroed_root_status.root = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_root_status, + )); + + // A real, fully-valid entry alongside all three, to prove the guard is selective, not a + // by-product of the registry being otherwise empty. + let real_id = [0xaau8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(real_id), + )); + + let (resp, logs) = rt.block_on(capture_sync_logs(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + ))); + + let statuses = resp["result"]["statuses"] + .as_array() + .expect("result.statuses is an array"); + assert_eq!( + statuses.len(), + 2, + "the fully-valid entry AND the zeroed-root-only entry are both returned; only the \ + zeroed-launcher_id and zeroed-store_id entries are excluded: {resp}" + ); + let returned_ids: std::collections::BTreeSet = statuses + .iter() + .map(|s| s["launcher_id"].as_str().unwrap().to_string()) + .collect(); + assert!(returned_ids.contains(&hex::encode(real_id))); + assert!(returned_ids.contains(&hex::encode(valid_but_zeroed_root))); + + // The observable signal: a warning naming the SPECIFIC zeroed field(s), for EACH bad + // registration — asserted on the actual `zeroed_fields=[...]` value, not merely on the + // field NAME `launcher_id` appearing somewhere (that would pass even for the store_id or + // root cases, since the warn always logs `launcher_id = ...` as a structured field + // regardless of which field was actually zero — the exact tautology a correctness gate + // found in an earlier version of this assertion). + assert!( + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), + "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" + ); + assert!( + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["store_id"]"#), + "expected a WARN naming exactly store_id as zeroed, got: {logs}" + ); + // A zeroed root alone must be DEBUG, not WARN — it is an ordinary pre-first-cycle state, + // not a registration bug, and sharing warn-level volume with a real identity fault would + // make an operator polling this endpoint unable to tell them apart (the exact security + // finding that split these into two levels). + assert!( + logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), + "expected a DEBUG line naming exactly root as zeroed, distinct from the WARN level \ + used for a missing identity field, even though the record is still returned: {logs}" + ); + assert_eq!( + logs.matches("missing an identity field").count(), + 2, + "expected exactly one WARN per identity-missing registration (2 here: launcher_id, \ + store_id) — the zeroed-root-only case must never count as one: {logs}" + ); + assert_eq!( + logs.matches("zeroed root").count(), + 1, + "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" + ); + } + + /// **Proves:** `dig.getRewardProverStatus` is NOT peer-reachable (CONTROL plane — loopback + /// admin / in-process FFI only), matching `reward_methods_tier_guard.rs`'s enumeration-based + /// guard with a direct, single-method assertion. + /// **Catches:** the method being accidentally allowlisted for the mTLS peer surface. + #[test] + fn get_reward_prover_status_is_not_peer_reachable() { + assert!(!peer::is_peer_reachable_method("dig.getRewardProverStatus")); + } + + /// **Proves:** `dig.getRewardProverStatus` goes through the `Method` enum match (`Tier::Control` + /// per dig-rpc-protocol 0.11), not the string pre-match ahead of it — calling it over the SAME + /// dispatch entry point with no special-casing still resolves to the handler, so a future + /// refactor that moved it back to the pre-match string block would be the only way to break + /// this test's premise, not silently bypass the tier guard. + /// **Catches:** a reintroduction of the method into the pre-`Method::from_name` string match. + #[test] + fn get_reward_prover_status_is_served_via_the_method_enum_not_the_string_prematch() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.getRewardProverStatus"), + Some(Method::GetRewardProverStatus) + ); + assert_eq!( + Method::GetRewardProverStatus.tier(), + dig_rpc_protocol::Tier::Control + ); + } + + /// **Proves:** `dig.getRewardProverStatus` restricts to the requested `launcher_id` when the + /// caller supplies one, and returns every registered status when it does not. + /// **Catches:** a filter that ignores the param, or one that requires it. + #[test] + fn get_reward_prover_status_filters_by_launcher_id_when_given() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let a = [0xaau8; 32]; + let b = [0xbbu8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(a), + )); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(b), + )); + + let filtered = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params":{"launcher_id": hex::encode(a)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let filtered = filtered["result"]["statuses"].as_array().unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0]["launcher_id"], json!(hex::encode(a))); + + let all = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); + } + + /// **Proves:** `total_paid_out_base_units`/`reserve_base_units` stay attributed to the + /// `launcher_id` (distributor) that reported them — never summed across distributors, never + /// cross-attributed to the other one. **Catches:** the class of defect a sibling adversarial + /// gate found in dig-app#403's rewards pane (dig_ecosystem#3269): a per-distributor total + /// rendered/returned as if it were a single subject's (there, one mirror operator's personal + /// earnings), overstating by however many other mirrors that distributor pays. Proving the + /// VALUE survives the wire hop (`get_reward_prover_status_answers_a_real_request_with_real_values`) + /// does not prove whose money it describes — this test does, with two distributors carrying + /// deliberately different, distinguishable totals. + #[test] + fn get_reward_prover_status_attributes_payout_figures_to_their_own_distributor() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let distributor_a = [0xaau8; 32]; + let distributor_b = [0xbbu8; 32]; + + let mut status_a = sample_reward_prover_status(distributor_a); + status_a.counters.reserve_base_units = 10_000; + status_a.counters.total_paid_out_base_units = 999_000; + + let mut status_b = sample_reward_prover_status(distributor_b); + status_b.counters.reserve_base_units = 42; + status_b.counters.total_paid_out_base_units = 7; + + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new(status_a)); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new(status_b)); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let statuses = resp["result"]["statuses"].as_array().unwrap(); + assert_eq!(statuses.len(), 2); + + let find = |launcher_id: [u8; 32]| { + statuses + .iter() + .find(|s| s["launcher_id"] == json!(hex::encode(launcher_id))) + .unwrap_or_else(|| panic!("no status for launcher_id {}", hex::encode(launcher_id))) + }; + let a = find(distributor_a); + let b = find(distributor_b); + + // Each distributor's own figures, untouched. + assert_eq!(a["counters"]["reserve_base_units"], json!(10_000)); + assert_eq!(a["counters"]["total_paid_out_base_units"], json!(999_000)); + assert_eq!(b["counters"]["reserve_base_units"], json!(42)); + assert_eq!(b["counters"]["total_paid_out_base_units"], json!(7)); + + // Never summed across distributors (999_000 + 7) and never cross-attributed (swapped). + let combined = 999_000 + 7; + assert_ne!(a["counters"]["total_paid_out_base_units"], json!(combined)); + assert_ne!(b["counters"]["total_paid_out_base_units"], json!(combined)); + assert_ne!( + a["counters"]["total_paid_out_base_units"], + b["counters"]["total_paid_out_base_units"] + ); + } + /// **Proves:** `gap_fill_generation` is a cheap no-op when the generation is already held (no /// network, `Ok(())`). **Catches:** a gap-fill that re-pulls an already-held generation. #[tokio::test] @@ -9399,6 +9853,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; let before = handle_rpc( @@ -16554,6 +17009,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; // A holder for this EXACT content is known via the DHT. @@ -16605,6 +17061,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -16655,6 +17112,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; @@ -16687,6 +17145,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -16728,6 +17187,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -16771,6 +17231,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index e83127bc..f85f954b 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5633,6 +5633,34 @@ pub(crate) mod tests { } } + /// **Proves** (dig_ecosystem#3269, binding #3261's rule node-side): every `Method` whose wire + /// name contains `Reward` is absent from THIS node's `is_peer_reachable_method` allowlist — + /// exercising the real `pub(crate)` function, not the crate-level `Method::is_peer_reachable` + /// it delegates to, so a future special-case added HERE (the way `dig.getProviderSnapshot` and + /// `cache.pushCapsule` are special-cased above) is caught too. + /// **Catches:** a reward method reaching a remote peer over mTLS — a money-adjacent read no + /// unauthenticated peer should get, regardless of whether the wrapper's crate-delegation path + /// or a local special-case is what would have let it through. + #[test] + fn reward_methods_are_absent_from_the_node_peer_allowlist() { + let reward_methods: Vec = dig_rpc_protocol::Method::ALL + .iter() + .copied() + .filter(|m| m.name().contains("Reward")) + .collect(); + assert!( + !reward_methods.is_empty(), + "expected at least one Reward-named method in Method::ALL; found none" + ); + for m in reward_methods { + assert!( + !is_peer_reachable_method(m.name()), + "{} must be absent from is_peer_reachable_method", + m.name() + ); + } + } + /// **Proves:** `dig.getProviderSnapshot` is peer-reachable as the ONE deliberate dig-node-LOCAL /// addition beyond the shared `dig-rpc-protocol` allowlist (epic #1934 child 4a) — it is not (yet) /// in that crate's set, so the wrapper allowlists it explicitly, and this test records that as an diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 1c432b4d..85be4b2e 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -143,6 +143,117 @@ async fn resolve_enforced_pin( } } +/// Names which of a reward-prover status record's fields are all-zero, if any, across +/// `launcher_id`, `store_id` (both IDENTITY — an all-zero value is never a real distributor's or +/// module's id; it's what an unassigned/uninitialised registry slot hex-encodes to, which reads +/// exactly like a valid 64-hex id to every consumer, including the dig-app consumer in +/// dig-app#403 (unmerged)) and `root` (an OBSERVATION, not an identity — a registered prover that +/// has not completed its first cycle yet plausibly has no root, so a zeroed `root` alone is not a +/// registration bug the way a zeroed identity field is). +/// +/// Callers decide what to DO with a zeroed field; this only names which ones are zero, so the +/// same detection drives both the exclusion decision (identity fields only, see the +/// `GetRewardProverStatus` filter below) and the log level split there: a zeroed identity field +/// is a `tracing::warn!` (a real registration bug, record excluded), while a zeroed `root` alone +/// is a `tracing::debug!` (an ordinary pre-first-cycle state, record still returned) — the two +/// outcomes are opposite, so they must never share one undifferentiated log line or level. +/// +/// Isolated on purpose (dig_ecosystem#3269 security/adversarial gate): this is a +/// registration-bug DETECTOR that belongs, longer-term, at #3265's writer (the code that will +/// actually populate this registry) rather than woven into the wire mapping below — kept here, +/// small and easy to relocate, only because #3265 has not landed yet. +fn zeroed_fields(s: &crate::rewards::state::RewardProverStatus) -> Vec<&'static str> { + let mut zeroed = Vec::new(); + if s.launcher_id == [0u8; 32] { + zeroed.push("launcher_id"); + } + if s.store_id == [0u8; 32] { + zeroed.push("store_id"); + } + if s.root == [0u8; 32] { + zeroed.push("root"); + } + zeroed +} + +/// Whether a record's zeroed fields (from [`zeroed_fields`]) include an IDENTITY field +/// (`launcher_id` or `store_id`). A record failing this cannot be attributed to any distributor +/// or module, so it must never be presented as one — unlike a zeroed `root` alone, which is a +/// legitimate "no cycle observed yet" state for an otherwise-real, otherwise-attributable prover. +fn is_missing_identity(zeroed: &[&str]) -> bool { + zeroed.contains(&"launcher_id") || zeroed.contains(&"store_id") +} + +/// Map dig-node-core's internal (`camelCase`-tagged) reward-prover status onto +/// `dig-rpc-protocol` 0.11's wire type (snake_case-tagged struct; only its `ProverState` VALUE is +/// camelCase) — field by field, explicit and widening where the shapes differ, never a +/// same-name struct-to-struct copy. This subsystem has already shipped a 24x-too-high fee +/// ceiling and a 2x-understated eviction count that a correctness gate passed twice, so every +/// non-identical field below is called out rather than assumed. +fn reward_prover_status_to_wire( + s: crate::rewards::state::RewardProverStatus, +) -> dig_rpc_protocol::types::RewardProverStatus { + dig_rpc_protocol::types::RewardProverStatus { + launcher_id: hex::encode(s.launcher_id), + store_id: hex::encode(s.store_id), + root: hex::encode(s.root), + prover_state: reward_prover_state_to_wire(s.prover_state), + prover_state_since: s.prover_state_since, + last_cycle_started_at: s.last_cycle_started_at, + last_cycle_completed_at: s.last_cycle_completed_at, + next_cycle_due_at: s.next_cycle_due_at, + last_entry_write_at: s.last_entry_write_at, + consecutive_cycle_failures: s.consecutive_cycle_failures, + pending_entry_writes: s.pending_entry_writes, + observed_at: s.observed_at, + counters: dig_rpc_protocol::types::ProverCounters { + mirrors_seen: s.counters.mirrors_seen, + challenges_issued: s.counters.challenges_issued, + challenges_passed: s.counters.challenges_passed, + challenges_failed: s.counters.challenges_failed, + entries_added: s.counters.entries_added, + entries_removed: s.counters.entries_removed, + // Internal `entry_count` is `u32`; the wire field is `u64` — widen explicitly rather + // than a same-name copy, so a future wire narrowing fails to compile instead of + // silently truncating. + entry_count: u64::from(s.counters.entry_count), + // SUBJECT, not just value (dig_ecosystem#3269, found by a sibling adversarial gate on + // dig-app#403's rewards pane): `reserve_base_units` and `total_paid_out_base_units` are + // per-DISTRIBUTOR figures — this distributor's own reserve, and the total THIS + // distributor has paid out in total to ALL of its mirrors combined. Neither is the + // querying node's own earnings, and `total_paid_out_base_units` is never one mirror's + // share; a caller rendering either as "your earnings" for the operator running this + // node overstates by however many other mirrors this distributor pays (the dig-app + // pane rendered it as personal earnings and overstated by up to 250x). This function + // passes both through unmodified and unaggregated (SPEC §2.4) — it is the caller's job + // to label them as the distributor's totals, never the operator's. + reserve_base_units: s.counters.reserve_base_units, + total_paid_out_base_units: s.counters.total_paid_out_base_units, + }, + } +} + +/// The SPEC §2.3 nine-variant closed set is identical between the internal and wire +/// `ProverState`; mapped explicitly (never `transmute`d) so an internal-only variant added +/// without a matching wire variant is a compile error here, not a silent wire mismatch. +fn reward_prover_state_to_wire( + s: crate::rewards::state::ProverState, +) -> dig_rpc_protocol::types::ProverState { + use crate::rewards::state::ProverState as Internal; + use dig_rpc_protocol::types::ProverState as Wire; + match s { + Internal::Idle => Wire::Idle, + Internal::Running => Wire::Running, + Internal::LocalCopyMissing => Wire::LocalCopyMissing, + Internal::ChainSourceUnavailable => Wire::ChainSourceUnavailable, + Internal::Unfunded => Wire::Unfunded, + Internal::FeeBudgetExhausted => Wire::FeeBudgetExhausted, + Internal::EntrySetFull => Wire::EntrySetFull, + Internal::Paused => Wire::Paused, + Internal::Stopped => Wire::Stopped, + } +} + #[async_trait::async_trait] impl RpcDispatch for Node { async fn dispatch( @@ -659,6 +770,76 @@ impl RpcDispatch for Node { "subscriptions": set.stores(), "count": set.len()}}); } + // dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md + // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY, NEVER over the + // mTLS peer surface (absent from `is_peer_reachable_method`; + // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live + // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover + // loop) — a REAL read of a real, currently-empty registry, so `{"statuses": []}` + // means "this node runs no prover loops" and stays true right up until #3265 + // registers one, at which point this same read starts returning it with no dispatch + // change. Never serializes the internal `rewards::state::RewardProverStatus` + // directly (it is `camelCase`-tagged; the wire struct is snake_case) — every field is + // mapped explicitly by `reward_prover_status_to_wire`. + Some(Method::GetRewardProverStatus) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let filter_launcher_id = params + .get("launcher_id") + .and_then(Value::as_str) + .map(str::to_ascii_lowercase); + let statuses: Vec = node + .reward_prover_status_snapshots() + .into_iter() + // A zeroed `launcher_id` or `store_id` is never a real distributor's or + // module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. Excluding + // such a record rather than presenting it as a real one avoids the money-hole + // class the driver's gates found three times (an unset field that reads fine + // and costs the operator), BUT exclusion alone would silently destroy the + // evidence that a registration bug happened — the exact §2.4 clause 1 + // violation a security + adversarial gate found in the first version of this + // filter (dig-node#595 review round). So this is never a silent drop: a + // `tracing::warn!` fires naming which field(s) were zero, making a bad + // registration observable, and the record is excluded. + // + // A zeroed `root` alone is different: it is an OBSERVATION (the prover's most + // recent cycle), not an identity, and a freshly-registered prover that has not + // completed its first cycle plausibly has a zero `root` legitimately. Excluding + // it on that basis alone would make a healthy, just-not-yet-cycled prover + // invisible — worse than the defect this guard exists to prevent. So this case + // is `tracing::debug!`, not `warn!`: an ordinary, expected state rather than a + // fault, kept out of `warn!`-level volume so an operator polling this endpoint + // is never shown (uncycled provers) x (poll rate) lines indistinguishable from + // a real registration bug. The record is still returned either way. + .filter(|s| { + let zeroed = zeroed_fields(s); + if is_missing_identity(&zeroed) { + tracing::warn!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status registration is missing an identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" + ); + } else if !zeroed.is_empty() { + tracing::debug!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status has a zeroed root; likely no cycle observed yet, returning it anyway" + ); + } + !is_missing_identity(&zeroed) + }) + .filter(|s| match &filter_launcher_id { + Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), + None => true, + }) + .map(reward_prover_status_to_wire) + .collect(); + let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } Some(Method::CacheSetCapBytes) => { let requested = req .get("params") diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 82418b16..54b40044 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,10 +96,11 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.10 line that -/// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`) AND the +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.11 line that +/// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`), the /// recursive-ask contract this node adopted (`GetAvailabilityParams::budget_ms` / `::ask_id`, -/// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`). +/// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`), AND (#3269) the +/// reward RPC surface (`Method::GetRewardProverStatus` et al., all `Tier::Control`). /// /// **Catches:** the obligation-8 skew directly. Before the #1576 cascade, dig-download consumed /// dig-rpc-protocol 0.5 while dig-peer 0.4 pulled 0.3.1, so a tree containing both held TWO `ModuleInfo` @@ -107,6 +108,12 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// that drive the entire pull plan. Asserting the TRANSITIVE lock entry (not the caret dep in a manifest) /// is the point: a consumer's own lock can pin an old patch even when every caret dep and every /// higher-layer bump looks correct. +/// +/// **Cascade closed (#3269):** `dig-node-core` depends on 0.11.0 directly; `dig-peer` (0.14.0), +/// `dig-download` (0.23.0) and `dig-peer-selector` (0.12.0) all now resolve `dig-rpc-protocol` +/// 0.11 too, so `cargo metadata` resolves exactly one line. This assertion is deliberately left at +/// exactly-one/0.11 (never widened to accept a set — see #836/#1576); if a future dependency bump +/// reopens the split, this test goes red again on purpose. #[test] fn the_workspace_carries_exactly_one_module_wire_crate() { let versions = locked_versions("dig-rpc-protocol"); @@ -117,8 +124,10 @@ fn the_workspace_carries_exactly_one_module_wire_crate() { majors means two `ModuleInfo` shapes across the module pull's trust boundary" ); assert!( - versions[0].starts_with("0.10."), - "the availability contract this node adopted ships in dig-rpc-protocol 0.10; the workspace resolved {} — on an earlier line the canonical items simply do not exist and this node would be back to declaring its own", + versions[0].starts_with("0.11."), + "the availability contract plus the #3269 reward RPC surface this node adopted ship in \ + dig-rpc-protocol 0.11; the workspace resolved {} — on an earlier line the canonical items \ + simply do not exist and this node would be back to declaring its own", versions[0] ); } diff --git a/crates/dig-node-core/tests/reward_methods_tier_guard.rs b/crates/dig-node-core/tests/reward_methods_tier_guard.rs new file mode 100644 index 00000000..37393051 --- /dev/null +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -0,0 +1,70 @@ +//! Fail-closed guard (dig_ecosystem#3269, binding #3261's rule node-side): every `Method` variant +//! whose wire name contains `Reward` MUST be `Tier::Control` and MUST NOT be peer-reachable. +//! +//! The companion check — absence from dig-node's OWN peer dispatch allowlist +//! (`is_peer_reachable_method`, `pub(crate)` in `src/peer.rs`, unreachable from an external +//! integration test) — is a sibling unit test inside `peer.rs`'s own `#[cfg(test)] mod tests`: +//! `reward_methods_are_absent_from_the_node_peer_allowlist`. +//! +//! #3261 (a `dig-rpc-protocol` ticket, not this crate's work) replaces that crate's four-member +//! reward-method enumeration with a prefix guard — but the enumeration it replaces lists exactly the +//! four methods that exist TODAY, so a FIFTH reward method added later would pass an enumeration test +//! simply by not being in the list: an enumeration test only proves the enumeration. This test proves +//! the RULE instead, over the live `Method::ALL` catalogue: it does not name any reward method, so a +//! reward method added after this test is written is caught automatically, at the wrong tier, the +//! moment it appears — rather than silently inheriting a wrong default. +//! +//! Promotion (widening a method's reach) is additive and reversible; demotion is breaking and breaks +//! exactly the anonymous callers nobody can enumerate. That asymmetry is why this fails closed: a +//! reward method that is NOT `Tier::Control`, or IS peer-reachable, fails loudly instead of quietly +//! granting a remote peer a money-adjacent read. + +use dig_rpc_protocol::{Method, Tier}; + +/// Every catalogue member whose wire name contains `"Reward"` (case-sensitive — the wire is +/// camelCase, e.g. `dig.getRewardProverStatus`). +fn reward_methods() -> Vec { + Method::ALL + .iter() + .copied() + .filter(|m| m.name().contains("Reward")) + .collect() +} + +#[test] +fn reward_methods_exist_and_are_found_by_the_prefix_scan() { + // A guard that silently matched zero methods would pass on a catalogue where every reward + // method had been renamed out of its `Reward` name, proving nothing. Assert the scan actually + // finds the surface it exists to police. + let methods = reward_methods(); + assert!( + !methods.is_empty(), + "expected at least one Reward-prefixed method in Method::ALL; found none — the prefix scan \ + itself may be broken, or the wire naming convention changed" + ); +} + +#[test] +fn every_reward_method_is_tier_control() { + for method in reward_methods() { + assert_eq!( + method.tier(), + Tier::Control, + "{} must be Tier::Control (dig_ecosystem#3269) — a reward RPC reachable at a lower tier \ + is a money hole", + method.name() + ); + } +} + +#[test] +fn no_reward_method_is_peer_reachable() { + for method in reward_methods() { + assert!( + !method.is_peer_reachable(), + "{} must NOT be peer-reachable — reachable ONLY from the loopback admin / in-process FFI \ + dispatch, never over the mTLS peer surface", + method.name() + ); + } +} diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 3666c163..e340a965 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -172,7 +172,11 @@ getrandom = "0.2" # longer has — the shell would omit `dig.getModuleInfo` / `dig.fetchModuleRange` while the engine served # them. Two majors in one workspace also duplicates the wire TYPES; pinned by # `dig-node-core/tests/dependency_tree.rs`. -dig-rpc-protocol = "0.10" +# +# Moved to 0.11 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.11.0 — the engine's +# `dig.getRewardProverStatus` handler needs the 0.11 line's reward types, and this line staying at +# 0.10 would be the exact drift the paragraph above warns against. +dig-rpc-protocol = "0.11" # The Sage-parity wallet engine (crate `dig_wallet`) — the node-custodied wallet DB + dual-transport # dispatch + seed custody. This shell WIRES it into bring-up (#368): it builds one live @@ -314,9 +318,9 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] # `openrpc_drift_guard.rs` compares the shell's error catalogue against the shared contract # crate name-for-name. Already a normal dependency above; restated here only so the -# integration-test crate can name it, and pinned to the SAME "0.10" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.11" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.10" +dig-rpc-protocol = "0.11" # The `never_log` battery (#277) drives the real seed bootstrap against a temp layout so its # sentinels are the ACTUAL minted phrase and device key rather than invented strings. Already a # normal dependency above; restated here only so the integration-test crate can name it. From e9f07c41fdcf295d525344722572de92a6d8efe2 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:32:18 -0700 Subject: [PATCH 10/12] feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rewards): peer claim loop skeleton -- discovery, cadence, config, claim port * test(rewards): write all twelve acceptance tests for the peer claim loop * feat(rewards): wire the seven rewards_claim submodules into the crate mod.rs declared no submodules, so types.rs/port.rs/config.rs/cadence.rs/ parser.rs/engine.rs/hints.rs (1,435 lines, 25 tests) were never part of the crate and never compiled. Declare them and re-export the public surface. * style(rewards): cargo fmt the rewards_claim submodules * chore(deps): bump dig-node-control-interface 0.33->0.35, dig-rpc-protocol 0.10->0.11.0 dig-rpc-protocol 0.11.0 is merged and tagged upstream; the other dig-*/chia-* deps of dig-node-service were already at the latest permitted-by-caret version in Cargo.lock. crates/dig-node-core/Cargo.toml is untouched (#3250's file set). * chore(deps): revert dig-node-control-interface and dig-rpc-protocol bumps Both create a duplicate-version split in this PR's scope and neither can be closed without editing a sibling crate's manifest this lane does not own: - dig-rpc-protocol 0.11.0 duplicates against dig-node-core/Cargo.toml:194 ("0.10.2"), which is #3250's live file set (dig-node#593). - dig-node-control-interface 0.35.0 duplicates against dig-wallet/Cargo.toml:81 ("0.33"), a sibling crate this lane does not own; the observed Clippy break (BalanceAsset/Asset type-identity mismatch, missing url_reconcile/url_current/urls fields) came from THIS duplicate, not from dig-rpc-protocol. Both belong to their own sequenced dep-bump unit of work, not this ticket. * fix(rewards-claim): fault laundering, permanent no-entry blacklist, fee ceiling magnitude Three independent gates on dig-node#594 (51516e62) found four logic defects; this addresses A, B and C per the corrected fix brief (D is documented only, not fixed here per the brief's own instruction). Defect A -- the anti-silence surface laundered every real fault into `Nominal`: - A1: `fault_reported` had no fault-bearing ClaimLoopState to fall through to, so a chain adapter erroring every cycle read `Nominal` forever. Added `ClaimLoopState::Faulted { cycles }`, outranking Nominal/ClaimableButNotClaiming, under ChainSourceUnavailable. - A2: inverted the test that asserted A1's bug as correct behaviour. - A3: `ClaimableButNotClaiming` compared a per-cycle snapshot (`distributors_claimable`) against a lifetime-cumulative counter (`claims_submitted`), so it latched healthy forever after one lifetime success. Added `claims_submitted_this_cycle` (per-cycle) as the correct comparand; kept `claims_submitted` as a cumulative counter. - A4: `last_discovery_at`/`last_cycle_at` were stamped even on a failed discovery or an all-faulted cycle, destroying the staleness signal a reader depends on. Now only stamped on success; added `last_attempt_at` to prove liveness separately. `fault_reported` and `distributors_faulted` now reset per cycle instead of latching for the process's lifetime. Defect B -- "terminal, stop retrying" was implemented as a process-lifetime blacklist (`terminal_no_entry: HashSet`, never cleared). That blocked SPEC 12.5 clause 2's re-entry path (evicted, re-challenged, re-admitted never claims again) and permanently punished a peer that discovered a distributor before the funder's AddEntry landed. Removed the blacklist entirely -- `own_entry` is a cheap chain read, re-issued every cycle for every candidate, matching clause 3's "never cache across cycles". `NoEntrySlot` is now a per-cycle observation, not a lifetime sentence. Defect C -- the fee ceiling didn't bind anything and there was no aggregate cap: - C1: default `CLAIM_FEE_CEILING_MOJOS_DEFAULT` lowered from 1_000_000_000 (transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`, sized for a mirror-coin spend) to 200_000 -- 2x the observed routine Chia fee range (5,000-100,000 mojos), so it actually binds instead of leaving 4-5 orders of magnitude of slack. - C2: added a per-cycle aggregate fee budget (`max_cycle_fee_budget_mojos`, default 10x the per-claim ceiling) checked across all claims in a cycle, closing the attacker-cost gap where funding K distributors could force a victim to spend K x the per-claim ceiling per cycle. New `ClaimOutcome::SkippedCycleBudgetExhausted`. Tests: rewards_claim test count 26 -> 37 (11 new: repeated_discovery_faults_never_ read_as_nominal, failed_discovery_leaves_last_discovery_at_unchanged, a_reported_ fault_surfaces_as_faulted_not_nominal, a_lifetime_submission_does_not_mask_a_ later_cycle_that_submits_nothing, no_entry_slot_then_re_admitted_produces_a_claim_ on_the_later_cycle, distributors_each_under_ceiling_do_not_collectively_exceed_ the_cycle_budget, the_default_per_claim_ceiling_actually_binds_a_routine_fee, plus renamed/rewritten no_entry_slot_is_non_terminal_and_re_checked_every_cycle). Refs #3251 * fix(rewards-claim): refuse a claim entry for the wrong payout puzzle hash CI fix: cadence.rs's RewardsClaimConfig literal was missing the max_cycle_fee_budget_mojos field added in the previous commit (E0063, caught by CI's Clippy/Test jobs -- the local cargo check for this workspace is too slow to use as the compiler here). Defect E (security-gate finding, folded in before this pass closes): submit_initiate_payout was called with entry.payout_puzzle_hash -- whatever the chain port handed back -- with no check against this node's own own_payout_puzzle_hash. UnavailableClaimChainPort is the only production adapter today so nothing can exploit this yet, but the whole point of the ClaimChainPort seam is that #3249 swaps in a real adapter with nothing above it changing, so deferring this would ship the landmine live with no review pass watching for it. Added an equality guard before the spend: a mismatch refuses to submit, counts (ClaimStatus::claims_refused_payout_mismatch), surfaces its own named outcome (ClaimOutcome::PayoutPuzzleHashMismatch), and is reported as a fault (a divergent entry means the port is confused or hostile, not that there is nothing to claim) -- never corrected by substituting our own hash and proceeding. Defect D: documented, not wired, per instruction -- added the "not yet wired into node startup" paragraph to mod.rs's module doc (the PR body carries the same paragraph) so the next reader arrives at the caveat in the code, not only in a merged PR description. Refs #3251 * fix(rewards-claim): B1 -- ClaimableButNotClaiming is a magnitude comparison, not a zero-test submitted_this_cycle < claimable_this_cycle now fires the anti-silence state, carrying the shortfall as ClaimableButNotClaiming { claimable, submitted }. The previous submitted_this_cycle == 0 zero-test let one submission mask any number of same-cycle skips (claimable=10, submitted=1 read Nominal). Also folds in B3's precedence fix (ChainSourceUnavailable > Faulted > ClaimableButNotClaiming > Idle > Nominal) and the per-distributor payout_hash_mismatches_this_cycle counter so a per-distributor fault can no longer pin the cycle-wide Faulted state, plus R2's rename of terminal_no_entry_slot to no_entry_slot_this_cycle now that it is no longer terminal. * fix(rewards-claim): B2/B3 -- value-ordered budget with rotation, per-distributor fault isolation B2: run_cycle now splits into a pre-budget phase (asset/entry/hash/threshold checks, producing the claimable set) and a budget phase, ordering the claimable set by accrued value descending before applying the fee ceiling and cycle budget. Dust distributors (low accrued value regardless of attacker-controlled fee) now sort last and are the ones the budget drops, closing the claim-suppression attack where ten high-fee dust distributors could consume the whole cycle budget ahead of a victim's real earnings. A rotation_cursor tie-breaks only WITHIN equal-accrued-value tiers so a genuinely tied honest tail that exceeds one cycle's budget every cycle still rotates through and is eventually served, rather than dropping the same tail forever. B3: the payout-hash mismatch check in evaluate_pre_budget now increments the per-distributor payout_hash_mismatches_this_cycle counter instead of setting fault_reported, so one hostile or buggy entry can no longer pin the cycle-wide Faulted state and bury ClaimableButNotClaiming for every other healthy distributor. R2: terminal_no_entry_slot -> no_entry_slot_this_cycle throughout. * fix(rewards-claim): R5 -- document not-yet-wired loop; persist B2 rotation cursor An operator reading their own rewards-claim.json and seeing enabled: true has no way to know from that file alone that no startup path constructs a ClaimEngine yet (#3268) -- mod.rs said so, but a config file reader does not arrive at a module doc. Also gives RewardsClaimConfig a rotation_cursor: Option field so B2's tie-break cursor survives a save/load round-trip -- an in-memory-only cursor resets on every restart, which would starve a legitimately tied honest tail forever on any node that restarts daily. * fix(rewards-claim): clippy collapsible-match + SPEC v0.1.3 wording refresh Collapses the nested if into the outer match arm in run_cycle (clippy::collapsible_match). Also refreshes NoEntrySlot / no_entry_slot_this_cycle doc comments now that dig-rewards-coin v0.1.3's SPEC §12.5 amendment is merged and tagged: absence is terminal for one claim attempt only, never for the distributor, must not be cached, and must not accumulate into a permanent exclusion set -- confirming rather than diverging from the re-read-every-cycle behaviour already implemented. * fix(rewards-claim): add missing rotation_cursor field in cadence.rs test literal Struct literal in the cadence test module was not updated when RewardsClaimConfig gained rotation_cursor (R5 commit) -- CI's Clippy/Test jobs caught the missing field (E0063) that a local cargo check could not (killed by memory pressure before this workspace-wide build completed). * fix(rewards-claim): F1 -- ChainSourceUnavailable is per-cycle, never a latch compute_state() compared against self.state -- last cycle's OWN computed output -- so once any cycle took an Unavailable port path, every later cycle re-asserted ChainSourceUnavailable forever, even after the chain came back and real claims were submitting. A node still syncing, or one dropped connection, was enough to trip this permanently. Add ClaimStatus::chain_unavailable_this_cycle, reset to false at the top of every run_cycle and set true only on a cycle that actually took the Unavailable path; compute_state now reads that flag instead of self.state, so the reading is live again. Test: a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process (engine.rs) drives cycle 1 unavailable, cycle 2 healthy with a submission, and asserts cycle 2 reads Nominal. Plus a compute_state-level regression in types.rs. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): F3/F4/F5 -- fix stale counters, dedup candidates, correct version doc F3: reset EVERY per-cycle counter (distributors_known/with_own_entry/ claimable/faulted, claims_submitted_this_cycle, no_entry_slot_this_cycle) at the TOP of run_cycle, before any early return. The three ChainUnavailable early-return paths skip the end-of-function assignment block entirely, so a cycle that hit one used to leave the PRIOR cycle's counts sitting on self.status while last_attempt_at stamped fresh for THIS cycle -- a stale count under a fresh timestamp, exactly what SPEC §2.4's staleness reasoning forbids. types.rs's doc sentence for no_entry_slot_this_cycle now correctly says it is dated by last_attempt_at (the field stamped unconditionally every cycle), not last_cycle_at. F4: dedup `candidates` by launcher id before phase 2. A real adapter scanning §1.3 launch comments across every (store_id, root) this node mirrors can plausibly return the same launcher id twice; without dedup phase 2 would evaluate it twice and submit InitiatePayout twice against one entry slot in one cycle -- the second spend is invalid but the fee is paid anyway. F5: dig-rewards-coin is v0.1.3, published on crates.io -- correct the stale "v0.1.1" module-doc claim. Tests: a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale (F3), a_duplicated_launcher_id_submits_exactly_once (F4). Co-Authored-By: Claude Sonnet 5 * fix(rewards_claim): fold payout-hash mismatches into the shortfall predicate (F2) A payout-hash mismatch never enters the eligible set, so it was counted in NEITHER distributors_claimable NOR claims_submitted_this_cycle -- the shortfall lived in neither term of compute_state's magnitude comparison. All-K-distributors mismatching therefore read Nominal (falsely healthy). Fold payout_hash_mismatches_this_cycle into the comparison's denominator: submitted < claimable + mismatches. The result is ClaimableButNotClaiming (a shortfall), never the cycle-wide Faulted -- Defect B3 stays fixed. Inverts the assertion at what was engine.rs:1305 (a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors): it previously asserted ClaimLoopState::Nominal across three cycles of an ongoing mismatch, which pinned the defect as intended behaviour (an A2-class test). It now asserts ClaimableButNotClaiming { claimable: 1, submitted: 1 }. Adds all_distributors_mismatching_is_a_shortfall_not_nominal, covering the brief's exact "what if every distributor refuses for the same reason" case. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): F1/F3 -- AtomicU32 in fake ports, not Mutex, keeps discovery Send CI's Clippy job (the compiler for this crate, per brief) caught it: holding a std::sync::MutexGuard across the .await in FlakyThenHealthyPort and HealthyThenUnavailablePort's discover_distributors made the returned future not Send, which #[async_trait]'s generated trait signature requires. Neither fake needs a lock -- each holds one call counter, incremented once per call, never read-modify-written across an await point. AtomicU32's fetch_add removes the guard (and the Send bound violation) entirely. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): F7 -- persist the fee window and cadence gate across restart The per-cycle aggregate fee budget and the 24h cadence clock both lived only in memory: `spent_this_cycle_mojos` was a `run_cycle` local and nothing on disk recorded a completed cycle. Every fresh process got a full `max_cycle_fee_budget_mojos` and an empty cadence clock, so a node stuck in a crash-restart loop could spend unbounded XCH on fees, one full budget per restart. Adds three `#[serde(default)]` fields to `RewardsClaimConfig` (`fee_window_start_unix`, `fee_spent_in_window_mojos`, `last_cycle_completed_at`) and a new opt-in `ClaimEngine::with_persisted_fee_ window(dir, cadence_seconds)` that: - restores the window/cadence state from `dir` at construction, - refuses to start a cycle until the cadence has elapsed since the last completed one, - rolls a fresh budget window only once the cadence has elapsed since it opened, otherwise keeps enforcing the budget against the persisted spend, - persists the spend BEFORE every chain submission (write-then-spend), never batched to cycle end, and persists the completed-cycle timestamp when a cycle finishes. Engines that never call `with_persisted_fee_window` (every pre-F7 test) are unaffected -- this is additive, opt-in state beside the existing rotation cursor, not a change to B2's value-ordering or rotation mechanism. `ClaimStatus`'s own counters stay in-memory on purpose (observability, meant to reset on restart); only the spend bound and the cadence gate persist. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): F7 -- update cadence.rs test literal for new persisted fields The three new persisted RewardsClaimConfig fields (fee_window_start_unix, fee_spent_in_window_mojos, last_cycle_completed_at) broke this crate's only remaining full struct literal outside config.rs/engine.rs's own test modules -- E0063 missing fields, caught by CI's Clippy job. Switched to ..RewardsClaimConfig::default() so the next added field cannot break this literal again, the same fix already applied once before for rotation_cursor. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): F8/F14 -- atomic state write, fail closed on a corrupt window Salvaged from a lane killed by a weekly cap before it could commit. Uncompiled at commit time; CI is the compile signal. Covers the fourth gate pass findings on the F7 persisted spend bound: - F8: RewardsClaimConfig::save_to now writes atomically (temp file + rename in the same directory), reusing the pattern already used by mirror/reconcile_state.rs for the same class of state. load_from distinguishes an ABSENT file (clean first run, defaults are correct) from a PRESENT but unparsable one, which fails CLOSED: the window is treated as fully spent and nothing is submitted. Never Default, and never a silent clamp downward, which would hand back the budget the corruption was hiding. - F14: the budget comparison uses saturating arithmetic so a corrupt disk-seeded fee_spent_in_window_mojos cannot panic under the release profile's overflow-checks. - F9/F10/F12/F13 in progress in the same files. Refs #3251 * fix(rewards-claim): negate with ! rather than the unimported Not trait Co-Authored-By: Claude Haiku 4.5 * fix(rewards-claim): F13 -- correct stale test literals to the folded shortfall compute_state (types.rs) already reported the folded shortfall denominator (distributors_claimable + payout_hash_mismatches_this_cycle) as `claimable` -- that part of F13 landed in f478516a. The two engine.rs tests asserting this state were written against the pre-fold, un-folded numbers and never updated, so CI showed the implementation producing the correct folded value (`claimable: 2`, `claimable: 1`) while the test literals still expected the stale un-folded one (`claimable: 1`, `claimable: 0`). Update both literals -- and the comments describing them -- to the folded values the F13 fix actually produces. No production code change; compute_state's predicate and payload were already correct. Co-Authored-By: Claude Sonnet 5 * feat(rewards-claim): add ClaimOutcome::Faulted variant Add the seventh ClaimOutcome variant: the type could only say a peer was legitimately not paid, never that a chain call failed. Carries the launcher id, a bounded (200 char) copy of the chain port's error text, and whether a pre-committed fee was reversed, so a reader can tell no money moved. Engine wiring at the two fault arms (engine.rs:332, :377) follows in the next commit. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): both fault arms now push ClaimOutcome::Faulted engine.rs:332 and :377 used to increment `faulted` and discard the outcome, leaving a definitively-failed claim absent from the outcome stream -- indistinguishable from a cycle that never touched that distributor. Both PreBudgetResult::Fault and BudgetPhaseResult::Fault now carry the chain port's (bounded) error text, and the submit_initiate_payout failure path also carries the fee it reversed, so a reader can tell no money moved. The counter stays; it is not a substitute for the outcome. 7 call sites needed updating: 3 PreBudgetResult::Fault constructions (reserve_asset_id, own_entry, payout_threshold), 2 BudgetPhaseResult::Fault constructions (required_fee_mojos, submit_initiate_payout), and the 2 consuming match arms -- exactly the set that was silently discarding a failure before this change. Co-Authored-By: Claude Sonnet 5 * test(rewards-claim): a failed submission produces a Faulted outcome Regression for the rework: reuses F12's fixture (a submission that definitely never broadcast) to prove both facts from one cycle -- the outcome exists and carries the reversed fee, and the persisted window still reflects zero net spend. Also fixes a rustfmt diff on the PreBudgetResult::Fault variant Clippy's Rustfmt job flagged. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): delete fee_window_poisoned, stop latching self-healing state Finding 1 (dig-node#594 pass 6): a future-dated clock is self-healing by construction (`t > now` goes false the moment real time passes it), but the engine ORed it into `self.fee_window_poisoned` and set that field `true` permanently -- an RTC glitch or VM resume froze the claim loop forever instead of until the skew passed. This is the third instance of one mechanism (pass 3 latched ChainSourceUnavailable, pass 4 left a stale cadence-gate `state`), so the fix removes the FIELD, not just the bug: with no `fee_window_poisoned` on `ClaimEngine`, `self.fee_window_poisoned = true` is a compile error, not a convention to remember. Per-cycle conditions (corrupt + future-dated-clock) now live in a `CycleConditions` value built fresh at the top of every `run_cycle` from `now` plus a freshly reloaded `RewardsClaimConfig`, used, and dropped -- never stored on the engine. `corrupt` is now re-read from disk every cycle too (it previously latched at construction only), matching what `ClaimLoopState::PersistedStateCorrupt`'s doc already claimed but the code never did. Rewrites the single-cycle f10 regression into a two-cycle test: cycle 1 with a future-dated clock refuses; cycle 2, after the clock catches up and the cadence elapses, MUST claim. The old one-cycle version was green whether the latch bug was present or not. Refs #594 * fix(rewards-claim): satisfy clippy doc-list indent and rustfmt Clippy failed with 3x doc_lazy_continuation on the PersistedStateCorrupt doc comment (types.rs:165-167): continuation lines of a `-` bullet must be indented under the marker, not left flush. Indent them. Rustfmt failed on the new fail_reserve_asset_for early-return in FakeChainPort::reserve_asset_id (engine.rs:888): the Err(...) call exceeded the line-length limit unwrapped. Let rustfmt wrap it. Refs #594 Co-Authored-By: Claude Sonnet 5 * test(rewards-claim): red proof for corrupt-then-repaired stale read Cycle 1 refuses a corrupt fee-window file; the file is then repaired to valid values with a fully-spent window and a recent completed-cycle time. Cycle 2 must neither grant a fresh budget nor skip the cadence gate. Fails against current `with_persisted_fee_window`, which loads the three fee-window fields once at construction and never refreshes them from the per-cycle `cfg` -- see engine.rs:149-157, #594. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): resync fee-window fields from disk every cycle `with_persisted_fee_window` only loaded fee_window_start_unix, fee_spent_in_window_mojos and last_cycle_completed_at once, at construction. Once the now-deleted fee_window_poisoned latch stopped masking it, a file corrupt at construction and repaired later left those three fields stuck on poisoned()'s None/0/None placeholders -- a fresh budget and a skipped cadence gate, and persist_fee_window then overwrote the repaired disk values with them. CycleConditions now carries the three fields from the SAME freshly reloaded cfg it already used for the corrupt/future-dated check, and run_cycle copies them onto self before the cadence gate or window-roll logic runs, but only on a read that is neither corrupt nor future- dated. This also fixes Finding 2b: future_dated_clock now reads cfg's own clocks instead of self's stale ones. Corrects the doc claim at the old lines 236-238 to describe what the code now does for both halves. Closes #594. Co-Authored-By: Claude Sonnet 5 * refactor(rewards-claim): make disk the sole store for the fee window Delete `fee_window_start_unix`, `fee_spent_in_window_mojos` and `last_cycle_completed_at` from `ClaimEngine`. `run_cycle` already re-reads `RewardsClaimConfig` fresh every cycle for the corrupt/future-dated check, so caching a copy on the engine bought nothing and cost exactly the stale-read defect class F16 just fixed. A local `FeeWindowState`, scoped to one `run_cycle` call, now threads the in-flight values through `evaluate_budget_phase`/`uncommit_fee`/`persist_fee_window` instead. With no field left to cache into, a future `self.fee_window_start_unix = ...` outside this file is an E0609 compile error, the same enforcement `fee_window_poisoned`'s removal already has. No behaviour change: every early return, the corrupt/future-dated fail- closed path, the cadence gate, the window roll, write-then-spend pre-commit/uncommit and the per-claim ceiling are unchanged -- only where the three values live changed. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- crates/dig-node-service/src/lib.rs | 6 + .../src/rewards_claim/cadence.rs | 85 + .../src/rewards_claim/config.rs | 490 +++ .../src/rewards_claim/engine.rs | 2946 +++++++++++++++++ .../src/rewards_claim/hints.rs | 43 + .../dig-node-service/src/rewards_claim/mod.rs | 72 + .../src/rewards_claim/parser.rs | 102 + .../src/rewards_claim/port.rs | 142 + .../src/rewards_claim/types.rs | 566 ++++ 9 files changed, 4452 insertions(+) create mode 100644 crates/dig-node-service/src/rewards_claim/cadence.rs create mode 100644 crates/dig-node-service/src/rewards_claim/config.rs create mode 100644 crates/dig-node-service/src/rewards_claim/engine.rs create mode 100644 crates/dig-node-service/src/rewards_claim/hints.rs create mode 100644 crates/dig-node-service/src/rewards_claim/mod.rs create mode 100644 crates/dig-node-service/src/rewards_claim/parser.rs create mode 100644 crates/dig-node-service/src/rewards_claim/port.rs create mode 100644 crates/dig-node-service/src/rewards_claim/types.rs diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 906ff93d..7d240feb 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -103,6 +103,12 @@ pub mod peers; /// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an /// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. pub mod relay; +/// The node's PEER-SIDE reward claim loop (DIG-Network/dig_ecosystem#3251): discovers the +/// reward distributors covering the `(store_id, root)`s this node mirrors and submits +/// `InitiatePayout` on a jittered cadence, default 24h. The other half of the reward-distributor +/// lifecycle from `dig_node_core::rewards` (#3250, the funder-side prover, a sibling lane). See +/// [`rewards_claim`]. +pub mod rewards_claim; pub mod rpc; /// The offline `wallet export-seed` rescue command: a local read of this node's /// encrypted seed file. Adds no network surface, and is removed with node-side custody. diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs new file mode 100644 index 00000000..ec9cc540 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -0,0 +1,85 @@ +//! Claim cadence + jitter (SPEC §8.6): the peer's own setting, never curried on the distributor, +//! and jittered so a network of peers on the default does not converge on one minute of the day. +//! +//! The jitter SOURCE is injected — never a global RNG or clock read directly — so the schedule is +//! deterministic under test. + +/// SPEC §8.6: the minimum jitter spread every peer MUST apply. +pub const CLAIM_JITTER_SECONDS_DEFAULT: u64 = 3_600; + +/// Supplies the jitter offset for one scheduling decision. A production implementation draws from +/// the OS CSPRNG; tests inject a fixed or sequenced value. +pub trait JitterSource: Send + Sync { + /// An offset in `0..=bound` seconds. + fn jitter_seconds(&self, bound: u64) -> u64; +} + +/// A jitter source that always returns the same value — for deterministic tests. +pub struct FixedJitter(pub u64); + +impl JitterSource for FixedJitter { + fn jitter_seconds(&self, bound: u64) -> u64 { + self.0.min(bound) + } +} + +/// The next cadence interval, in seconds: `cadence_seconds + jitter`, where `jitter` is drawn from +/// `[0, jitter_seconds]` via the injected source (SPEC §8.6). `jitter_seconds` is a lower bound on +/// the SPREAD available to the source, not a fixed addition — a source that always returns `0` +/// still produces a schedule within the required bound, just at its floor. +#[must_use] +pub fn next_interval_seconds( + cadence_seconds: u64, + jitter_seconds: u64, + source: &dyn JitterSource, +) -> u64 { + let offset = source.jitter_seconds(jitter_seconds); + cadence_seconds.saturating_add(offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interval_is_cadence_plus_a_bounded_jitter() { + let cadence = 86_400; + let jitter_bound = 3_600; + + let at_floor = next_interval_seconds(cadence, jitter_bound, &FixedJitter(0)); + assert_eq!(at_floor, cadence); + + let at_ceiling = next_interval_seconds(cadence, jitter_bound, &FixedJitter(jitter_bound)); + assert_eq!(at_ceiling, cadence + jitter_bound); + + // A source that tries to exceed the bound is clamped by the source contract itself + // (FixedJitter here), and the composed interval never exceeds cadence + jitter_seconds. + let over = next_interval_seconds(cadence, jitter_bound, &FixedJitter(jitter_bound * 10)); + assert!(over <= cadence + jitter_bound); + assert!(over >= cadence); + } + + /// ACCEPTANCE 8 (part) — a config setting a cadence OTHER than the default is honoured by the + /// scheduling function, not silently overridden back to `CLAIM_CADENCE_SECONDS_DEFAULT`. + #[test] + fn a_non_default_configured_cadence_is_honoured() { + let cfg = super::super::config::RewardsClaimConfig { + enabled: true, + cadence_seconds: 12_000, + jitter_seconds: 500, + max_fee_mojos: 1, + max_cycle_fee_budget_mojos: 10, + rotation_cursor: None, + ..super::super::config::RewardsClaimConfig::default() + }; + let interval = + next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(0)); + assert_eq!( + interval, 12_000, + "configured cadence, not the 86_400 default" + ); + let interval_at_ceiling = + next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(500)); + assert_eq!(interval_at_ceiling, 12_500); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs new file mode 100644 index 00000000..8e0a77bd --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -0,0 +1,490 @@ +//! This node's peer-side claim-loop preferences (requirement 5) — persisted the same way +//! `crate::collateral::CollateralConfig` is: a dedicated JSON file in the node's state dir, every +//! field `#[serde(default = "...")]` so a config written before a field existed loads that field's +//! DEFAULT, never a fabricated deliberate choice. + +use std::path::Path; + +use chia_protocol::Bytes32; +use serde::{Deserialize, Serialize}; + +use super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; + +/// SPEC §8.6: the peer-side claim cadence default. +pub const CLAIM_CADENCE_SECONDS_DEFAULT: u64 = 86_400; + +/// The max fee ceiling this node will spend on ONE claim (requirement 2). Not a floor: a true +/// "net > 0" floor is not computable here — the fee is XCH mojos, the reward is $DIG base units, +/// and the node holds no exchange rate between them. SPEC §8.3 clause 2 already asserts +/// `payout_threshold` (1 $DIG) is "above any plausible fee", so the threshold IS the economic floor +/// by construction; this constant only caps what the node will pay to collect it. +/// +/// # Defect C1: the magnitude, not the reasoning, was wrong +/// This constant originally reused `crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS` +/// (1_000_000_000 mojos = 0.001 XCH) — a number sized for a mirror-coin spend, not a per-distributor +/// claim repeated daily. Against a routine Chia transaction fee of 5,000-100,000 mojos, that ceiling +/// was four to five orders of magnitude too loose to ever bind a real fee: a peer could still lose +/// money inside it whenever 1 $DIG is worth less than 0.001 XCH, and the ceiling would never notice. +/// 200,000 mojos is 2x the top of the observed routine-fee range — enough headroom to survive a +/// congested mempool without giving up the one computable control this loop has. +pub const CLAIM_FEE_CEILING_MOJOS_DEFAULT: u64 = 200_000; + +/// The per-cycle AGGREGATE fee budget (Defect C2): a cap on what this node will spend across ALL +/// claims in one cycle, independent of [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s per-claim cap. +/// +/// `required_fee_mojos(launcher_id)` is per-distributor state that anyone may create: a DIG-asset +/// distributor may be launched over any widely mirrored store. Without an aggregate cap, an +/// attacker funding K such distributors and getting a victim peer's payout puzzle hash admitted to +/// each could force that peer to spend up to `K * CLAIM_FEE_CEILING_MOJOS_DEFAULT` of its own XCH +/// per cycle, at a cost to the attacker of only K $DIG. Defaulting this to 10x the per-claim ceiling +/// bounds a single cycle to roughly 10 distributors' worth of fees before the loop stops claiming +/// for the rest of that cycle and reports it by name +/// (`ClaimOutcome::SkippedCycleBudgetExhausted`) — configurable for an operator who mirrors more +/// than that many distributors' worth of stores. +pub const CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT: u64 = CLAIM_FEE_CEILING_MOJOS_DEFAULT * 10; + +/// F10 (§8.6 floor): the lowest `cadence_seconds` this config will honour. SPEC §8.6 sets the +/// default at `86_400` but never floors an operator-supplied override, so an unvalidated `0` (or a +/// handful of seconds) would hot-loop `ClaimEngine::run_cycle` — a chain read on every tick with no +/// cadence protection at all, the same class of unbounded-work defect F7 closed for spend. One +/// minute is short enough to never bind a legitimate operator (SPEC's own default is a full day) +/// and long enough that a degenerate value cannot turn this loop into a busy-poll. +pub const CLAIM_CADENCE_FLOOR_SECONDS: u64 = 60; + +const REWARDS_CLAIM_CONFIG_FILE: &str = "rewards-claim.json"; + +/// This node's peer-side claim-loop preferences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RewardsClaimConfig { + /// Whether the claim loop runs at all. Default-on: a peer earning rewards and never claiming + /// them is the silent-failure case this ticket exists to prevent, so opting IN by default is + /// the honest posture — see [`crate::rewards_claim`]'s module doc. + /// + /// # R5: `true` here does not mean the loop is running yet + /// Nothing in this codebase constructs a [`super::ClaimEngine`] outside this module's own tests + /// (DIG-Network/dig_ecosystem#3268, not yet landed) — see [`crate::rewards_claim`]'s module doc, + /// "Not yet wired into node startup". An operator who reads their own `rewards-claim.json` and + /// sees `enabled: true` is exactly the person who needs to know that; the module doc alone does + /// not reach them. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// SPEC §8.6: base cadence between claim cycles, before jitter. + #[serde(default = "default_cadence_seconds")] + pub cadence_seconds: u64, + + /// SPEC §8.6: the jitter spread applied on top of `cadence_seconds` (see + /// [`super::cadence::next_interval_seconds`]). + #[serde(default = "default_jitter_seconds")] + pub jitter_seconds: u64, + + /// The PER-CLAIM fee ceiling (requirement 2) — see [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s doc for + /// why this is a ceiling, not a floor, and for the magnitude reasoning (Defect C1). + #[serde(default = "default_max_fee_mojos")] + pub max_fee_mojos: u64, + + /// The PER-CYCLE aggregate fee budget (Defect C2) — see + /// [`CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT`]'s doc for the attacker-cost reasoning. + #[serde(default = "default_max_cycle_fee_budget_mojos")] + pub max_cycle_fee_budget_mojos: u64, + + /// Defect B2: the tie-break cursor [`super::ClaimEngine::order_for_budget`] uses to rotate a + /// legitimately starved tail (a set of equal-accrual honest distributors whose combined fee + /// exceeds one cycle's budget every cycle) so the SAME distributors are not dropped every + /// cycle forever. Persisted here — not just held in the in-memory [`super::ClaimEngine`] — so + /// a node that restarts daily does not reset the rotation and starve the tail permanently. + /// `None` until the first cycle defers something; absent from a config written before this + /// field existed, which is the same as `None` (no rotation history yet). + #[serde(default)] + pub rotation_cursor: Option, + + /// F7: the start (unix seconds) of the CURRENT aggregate-fee-budget window. Read alongside + /// [`Self::fee_spent_in_window_mojos`] to decide, on each cycle, whether the window has rolled + /// over (`now - fee_window_start_unix >= cadence_seconds`) or whether spend must keep + /// accumulating into it. `None` until the first cycle ever runs; absent from a config written + /// before this field existed, which is the same as `None` (no window has started yet, so the + /// next cycle starts one fresh rather than reading a fabricated "already spent" history). + #[serde(default)] + pub fee_window_start_unix: Option, + + /// F7: fee mojos already spent inside [`Self::fee_window_start_unix`]'s window. This is the + /// field that actually bounds a crash-restart loop: without it, every fresh process starts + /// this at zero and re-grants a full [`Self::max_cycle_fee_budget_mojos`] on every restart, no + /// matter how many restarts happen inside one cadence period. Defaults to `0` — a config + /// written before this field existed had spent nothing in a window that did not exist either. + #[serde(default)] + pub fee_spent_in_window_mojos: u64, + + /// F7: the unix-second timestamp of the last cycle that ran to completion. The cadence gate + /// (`now - last_cycle_completed_at < cadence_seconds`) refuses to START a new cycle at all + /// until the cadence has genuinely elapsed since this time, so a crash-restart loop cannot + /// immediately re-run a cycle that already ran, independent of the fee-window check above. + /// `None` until the first cycle ever completes; absent from a config written before this field + /// existed is the same as `None` (no completed cycle on record, so the next cycle is allowed to + /// run immediately -- the honest reading for a node that has never run this loop before). + #[serde(default)] + pub last_cycle_completed_at: Option, + + /// F8: set by [`Self::load_from`] (never persisted, never read from the file itself) when the + /// file was present but unparsable, unreadable, or carried a `fee_spent_in_window_mojos` + /// exceeding its own `max_cycle_fee_budget_mojos` (F14) — corrupt state, not a fresh peer. + /// `ClaimEngine` reads this to fail CLOSED (treat the window as fully spent, submit nothing) + /// rather than the old behaviour of falling back to [`Self::default`], which re-granted a full + /// budget through the exact crash-restart loop F7 exists to bound. `#[serde(skip)]` because a + /// value read off disk can never itself declare "I am corrupt" — that fact lives only in + /// *how* the read failed, decided once, here, at load time. + #[serde(skip)] + pub corrupt: bool, +} + +fn default_enabled() -> bool { + true +} + +fn default_cadence_seconds() -> u64 { + CLAIM_CADENCE_SECONDS_DEFAULT +} + +fn default_jitter_seconds() -> u64 { + CLAIM_JITTER_SECONDS_DEFAULT +} + +fn default_max_fee_mojos() -> u64 { + CLAIM_FEE_CEILING_MOJOS_DEFAULT +} + +fn default_max_cycle_fee_budget_mojos() -> u64 { + CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT +} + +impl Default for RewardsClaimConfig { + fn default() -> Self { + RewardsClaimConfig { + enabled: default_enabled(), + cadence_seconds: default_cadence_seconds(), + jitter_seconds: default_jitter_seconds(), + max_fee_mojos: default_max_fee_mojos(), + max_cycle_fee_budget_mojos: default_max_cycle_fee_budget_mojos(), + rotation_cursor: None, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, + corrupt: false, + } + } +} + +impl RewardsClaimConfig { + /// Load from the node's own machine-wide state directory (production entry point). + pub fn load() -> Self { + RewardsClaimConfig::load_from(&crate::state::state_dir()) + } + + /// Persist to the node's own machine-wide state directory. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&crate::state::state_dir()) + } + + /// F8: the fail-CLOSED reading for a file this process could not trust — present but + /// unparsable, unreadable, or carrying a spend that exceeds its own budget (F14). Deliberately + /// NOT [`Self::default`]: a missing file is a clean first run and defaults are the honest + /// reading for it, but a corrupt one must never be treated the same way, because `default()` + /// re-grants a full spend budget into exactly the crash-restart loop F7 exists to bound. + /// `corrupt: true` is the only signal a caller needs — every other field here is a placeholder + /// `ClaimEngine` must not act on, and [`Self::save_to`] must never be called with this value + /// (see [`super::engine::ClaimEngine::persist_fee_window`]'s corrupt-file guard). + fn poisoned() -> Self { + RewardsClaimConfig { + corrupt: true, + ..Self::default() + } + } + + /// Load from an explicit directory. + /// + /// A MISSING file is a clean first run: [`Self::default`] is the honest reading, because + /// nothing has ever been decided or spent yet. + /// + /// A file this process cannot trust — unreadable, unparsable, or (F14) carrying a persisted + /// spend larger than its own budget — is a DIFFERENT fact and must never share `default()`'s + /// code path (F8): it becomes [`Self::poisoned`], visibly logged, and never fatal to node + /// start over one preferences file, but never silently re-granting a budget either. + pub fn load_from(dir: &Path) -> Self { + let path = dir.join(REWARDS_CLAIM_CONFIG_FILE); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + tracing::error!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be read; failing closed, not \ + using defaults" + ); + return Self::poisoned(); + } + }; + match serde_json::from_str::(&text) { + Ok(mut cfg) => { + // F10 (§8.6 floor): an operator-supplied cadence below the floor is clamped, not + // corrupt -- see `CLAIM_CADENCE_FLOOR_SECONDS`'s doc for why this is the one F7 + // field that is safe to correct upward rather than fail closed over. + if cfg.cadence_seconds < CLAIM_CADENCE_FLOOR_SECONDS { + tracing::warn!( + path = %path.display(), + cadence_seconds = cfg.cadence_seconds, + floor = CLAIM_CADENCE_FLOOR_SECONDS, + "rewards-claim cadence_seconds below the §8.6 floor; clamping up" + ); + cfg.cadence_seconds = CLAIM_CADENCE_FLOOR_SECONDS; + } + // F14: a persisted spend exceeding the budget it is measured against is not a big + // number to clamp down -- clamping would hand back exactly the budget the + // corruption was hiding. It is corrupt state: fail closed instead. + if cfg.fee_spent_in_window_mojos > cfg.max_cycle_fee_budget_mojos { + tracing::error!( + path = %path.display(), + spent = cfg.fee_spent_in_window_mojos, + budget = cfg.max_cycle_fee_budget_mojos, + "persisted rewards-claim spend exceeds its own budget; failing closed, \ + not clamping" + ); + return Self::poisoned(); + } + cfg + } + Err(e) => { + tracing::error!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be parsed; failing closed, not \ + using defaults" + ); + Self::poisoned() + } + } + } + + /// Persist to `dir`, ATOMICALLY: written to a temp file beside the real path, then renamed + /// over it — the same pattern `crate::mirror::reconcile_state::ReconcileState::save_to` uses + /// for the same class of state in this crate (F8). Without this, a crash mid-`write` can leave + /// a torn file that [`Self::load_from`] would previously have read as [`Self::default`] and + /// re-granted a full budget into — the exact restart-loop F7 was written to close, reopened + /// through F7's own persist path. A rename is atomic on the same filesystem, so the file this + /// process's crash leaves behind is always either the old complete contents or the new + /// complete contents, never a half-write. + pub fn save_to(&self, dir: &Path) -> std::io::Result<()> { + crate::state::ensure_dir_restricted(dir)?; + let path = dir.join(REWARDS_CLAIM_CONFIG_FILE); + let temp = path.with_extension("json.tmp"); + let body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(&temp, &body)?; + crate::control::restrict_permissions(&temp); + std::fs::rename(&temp, &path)?; + crate::control::restrict_permissions(&path); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec_8_6_and_the_fee_ceiling() { + let cfg = RewardsClaimConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.cadence_seconds, 86_400); + assert_eq!(cfg.jitter_seconds, 3_600); + assert_eq!(cfg.max_fee_mojos, 200_000); + assert_eq!(cfg.max_cycle_fee_budget_mojos, 2_000_000); + } + + /// Defect C1 regression: the ceiling must actually bind a routine Chia fee — the old default + /// (1_000_000_000, transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`) was 4-5 orders of + /// magnitude looser than the observed 5,000-100,000 mojo range and never rejected a real fee. + #[test] + fn the_default_per_claim_ceiling_actually_binds_a_routine_fee() { + let cfg = RewardsClaimConfig::default(); + assert!( + cfg.max_fee_mojos < 1_000_000, + "the default ceiling must be within striking distance of a routine fee, not 1e9" + ); + assert!( + cfg.max_fee_mojos >= 100_000, + "the default ceiling must not reject the top of the routine fee range outright" + ); + } + + #[test] + fn save_then_load_round_trips_and_survives_restart() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-test-") + .tempdir() + .expect("a scratch dir"); + + let cfg = RewardsClaimConfig { + enabled: false, + cadence_seconds: 43_200, + jitter_seconds: 1_800, + max_fee_mojos: 150_000, + max_cycle_fee_budget_mojos: 900_000, + rotation_cursor: None, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, + corrupt: false, + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, cfg); + } + + /// Defect B2: a rotation cursor left in memory only resets on every restart, which starves a + /// legitimately-tied honest tail forever on any node that restarts daily. It must round-trip + /// through save/load exactly like every other field. + #[test] + fn the_rotation_cursor_survives_a_save_load_round_trip() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-cursor-test-") + .tempdir() + .expect("a scratch dir"); + + let cursor = Bytes32::from([7u8; 32]); + let cfg = RewardsClaimConfig { + rotation_cursor: Some(cursor), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded.rotation_cursor, Some(cursor)); + assert_eq!(loaded, cfg); + } + + /// F7: the persisted fee-window fields must round-trip through save/load exactly like every + /// other field -- this is the state a restart reads back to avoid re-granting a fresh budget. + #[test] + fn the_fee_window_fields_survive_a_save_load_round_trip() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-fee-window-test-") + .tempdir() + .expect("a scratch dir"); + + let cfg = RewardsClaimConfig { + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: 1_500_000, + last_cycle_completed_at: Some(1_000), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, cfg); + } + + #[test] + fn a_config_written_before_a_field_existed_loads_that_fields_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-legacy-test-") + .tempdir() + .expect("a scratch dir"); + std::fs::write(dir.path().join(REWARDS_CLAIM_CONFIG_FILE), b"{}").expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, RewardsClaimConfig::default()); + } + + #[test] + fn a_missing_file_yields_the_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-missing-test-") + .tempdir() + .expect("a scratch dir"); + assert_eq!( + RewardsClaimConfig::load_from(dir.path()), + RewardsClaimConfig::default() + ); + } + + /// F8 regression: a present-but-unparsable file must NOT load as [`RewardsClaimConfig::default`] + /// — that is exactly the fail-OPEN bug (a torn write reads as a clean first run and re-grants a + /// full spend budget). Must go red with only the `Err(e) => ... Self::poisoned()` branch of + /// [`RewardsClaimConfig::load_from`]'s parse-failure arm reverted to `Self::default()`. + #[test] + fn a_corrupt_file_fails_closed_not_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-corrupt-test-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + b"{ this is not json, or a torn write mid-object", + ) + .expect("write garbage"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert!( + loaded.corrupt, + "a present-but-unparsable file must be reported as corrupt, never silently defaulted" + ); + assert_ne!( + loaded, + RewardsClaimConfig::default(), + "corrupt state must be distinguishable from a clean first run" + ); + } + + /// F8: a MISSING file is the opposite fact from a corrupt one -- still a clean first run. + #[test] + fn a_missing_file_is_not_corrupt() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-missing-not-corrupt-") + .tempdir() + .expect("a scratch dir"); + assert!(!RewardsClaimConfig::load_from(dir.path()).corrupt); + } + + /// F10 (§8.6 floor) regression: an operator (or corrupt/hostile) config with `cadence_seconds: + /// 0` must not be honoured verbatim -- it would hot-loop `run_cycle` with no cadence + /// protection at all. Must go red with only the floor-clamp removed from `load_from`. + #[test] + fn a_cadence_below_the_floor_is_clamped_up_on_load() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-cadence-floor-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + br#"{"cadence_seconds": 0}"#, + ) + .expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded.cadence_seconds, CLAIM_CADENCE_FLOOR_SECONDS); + assert!(!loaded.corrupt, "a low cadence is clamped, not corrupt"); + } + + /// F14 regression: a persisted spend larger than its own budget is corrupt state, not a large + /// number to clamp down -- clamping down would hand back exactly the budget the corruption was + /// hiding. Must go red with only that branch removed (i.e. the field loaded verbatim). + #[test] + fn a_spend_exceeding_its_own_budget_fails_closed() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-spend-overflow-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + br#"{"fee_spent_in_window_mojos": 999999999999, "max_cycle_fee_budget_mojos": 2000000}"#, + ) + .expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert!( + loaded.corrupt, + "a spend exceeding its own budget must fail closed, never be clamped down" + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs new file mode 100644 index 00000000..2d098da2 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -0,0 +1,2946 @@ +//! The claim loop's one tick: discover, evaluate, claim — driven against [`ClaimChainPort`] and +//! [`DistributorHintSource`], never against a concrete chain client (see the module doc's "chain +//! seam" section). + +use std::path::{Path, PathBuf}; + +use chia_protocol::Bytes32; + +use super::config::RewardsClaimConfig; +use super::hints::DistributorHintSource; +use super::port::{ClaimChainPort, ClaimPortError}; +use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; + +/// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] +/// and the anti-silence status surface across calls to [`Self::run_cycle`]. +/// +/// # No permanent "no entry slot" blacklist (Defect B) +/// An earlier version of this engine cached a launcher id in a process-lifetime `terminal_no_entry` +/// set the first time `own_entry` returned `None`, and never re-checked it. That is wrong in two +/// reachable cases: SPEC §12.5 clause 2's re-entry path (a peer evicted, re-challenged and +/// legitimately re-admitted would never claim again until the process restarted), and a peer that +/// discovers a distributor before the funder's `AddEntry` lands (blacklisted on its very first +/// cycle, never paid at all). SPEC §12.5 clause 3 — re-read the entry slot before every claim, +/// never cache one across cycles — argues directly against caching an absence forever too. The fix: +/// no blacklist at all. `own_entry` is a cheap chain READ, so it is re-issued every cycle for every +/// candidate; `ClaimOutcome::NoEntrySlot` stays the reported outcome (still non-error, still no +/// spend, still no chain fault), but it is now a per-cycle observation, not a lifetime sentence. +pub struct ClaimEngine { + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + /// Defect C2: the per-cycle aggregate fee budget — bounds what this node will spend across ALL + /// claims in one cycle, independent of the per-claim ceiling. See [`super::config`]'s module doc + /// for the attacker-cost reasoning that makes this necessary in addition to `max_fee_mojos`. + cycle_fee_budget_mojos: u64, + dig_asset_id: Bytes32, + status: ClaimStatus, + /// Defect B2: which launcher id the per-cycle budget cut off LAST, so the next cycle gives that + /// one first crack instead of it being permanently outranked. This only breaks TIES among + /// candidates with equal accrued value (see [`Self::order_for_budget`]) — it can never let a + /// lower-accrued distributor (an attacker's dust) jump ahead of a genuinely higher-earning one, + /// because accrued value is always the primary sort key. `None` until a cycle first defers + /// someone for budget. Persisted alongside [`super::config::RewardsClaimConfig`] (via + /// [`Self::with_rotation_cursor`] / [`Self::rotation_cursor`]) so a restart does not re-arm a + /// fresh queue and starve the tail forever. + rotation_cursor: Option, + + /// F7: when `Some`, this engine persists the aggregate-fee-budget window + /// (`fee_window_start_unix`, `fee_spent_in_window_mojos`, `last_cycle_completed_at`) into + /// [`RewardsClaimConfig`] in this directory -- see [`Self::with_persisted_fee_window`]. `None` + /// keeps the engine purely in-memory, the behaviour every test before F7 relies on. + /// + /// F18: the three fields above are DELIBERATELY NOT stored on this struct. Every one of them + /// is re-read fresh from [`RewardsClaimConfig::load_from`] at the top of every + /// [`Self::run_cycle`] regardless (see F16), so caching a copy on `Self` bought nothing and + /// cost exactly the class of defect F16 fixed: a value seeded once, at construction or at the + /// last successful read, going stale the moment an operator repairs the file underneath it. + /// With no field to go stale, there is nothing left to resynchronize -- `run_cycle`'s local + /// `CycleConditions` (built fresh from `cfg`, used, and dropped before the function returns) + /// is now the ONLY place any of these three values are held in memory, mirroring exactly how + /// [`Self::run_cycle`]'s doc already describes `fee_window_poisoned`'s removal: a future + /// `self.fee_window_start_unix = ...` outside `run_cycle`/`persist_fee_window` is now an + /// `E0609` compile error (no such field), not a convention to remember. + fee_window_state_dir: Option, + /// F7: the cadence length the persisted budget window and the cadence gate are measured + /// against. Deliberately a constructor argument of [`Self::with_persisted_fee_window`], never + /// read from [`RewardsClaimConfig::cadence_seconds`] directly -- the engine has no other + /// dependency on the rest of that config, and the caller (which already loaded it) is the one + /// place that should decide what "the cadence" means. + cadence_seconds: u64, + // F16: there used to be a `fee_window_poisoned: bool` field here, set `true` by a corrupt + // load or a future-dated clock and never cleared. That is the THIRD instance of one + // mechanism -- a per-cycle condition stored as process-lifetime state (pass 3: + // `ChainSourceUnavailable` latched forever; pass 4: a cadence gate's early return left a + // stale `state` standing) -- and it is the one place where latching was actively wrong: a + // future-dated clock is SELF-HEALING (`t > now` goes false the moment real time passes it), + // so ORing it into a field that is then set permanently `true` turned a transient RTC glitch + // into a permanent refusal to claim. The fix removes the field rather than the bug: with no + // `fee_window_poisoned` field on this struct, `self.fee_window_poisoned = true` is a COMPILE + // ERROR (E0609, no such field), not a convention a future pass has to remember. See + // [`Self::run_cycle`]'s `CycleConditions` -- built fresh at the top of every cycle from `now` + // plus a freshly reloaded [`RewardsClaimConfig`], used, and dropped before the function + // returns; there is nowhere on `Self` to write it back into. +} + +impl ClaimEngine { + pub fn new( + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + cycle_fee_budget_mojos: u64, + dig_asset_id: Bytes32, + ) -> Self { + ClaimEngine { + port, + hints, + own_payout_puzzle_hash, + max_fee_mojos, + cycle_fee_budget_mojos, + dig_asset_id, + status: ClaimStatus::default(), + rotation_cursor: None, + fee_window_state_dir: None, + cadence_seconds: 0, + } + } + + /// Restores the per-cycle budget rotation cursor (Defect B2) from persisted state — the + /// production wiring (DIG-Network/dig_ecosystem#3268) loads it from + /// [`super::config::RewardsClaimConfig`] alongside the rest of this loop's preferences. + #[must_use] + pub fn with_rotation_cursor(mut self, cursor: Option) -> Self { + self.rotation_cursor = cursor; + self + } + + /// The current budget rotation cursor (Defect B2) — persist this after every `run_cycle` so a + /// restart resumes the rotation instead of restarting it and re-starving the same tail. + #[must_use] + pub fn rotation_cursor(&self) -> Option { + self.rotation_cursor + } + + /// F7: restores the persisted aggregate-fee-budget window and cadence clock from `dir` and + /// arms this engine to keep persisting them there after every submission and every completed + /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). + /// + /// `cadence_seconds` is both the window length and the cadence gate's threshold: the same + /// number [`super::config::RewardsClaimConfig::cadence_seconds`] carries, passed in explicitly + /// because this engine has no other dependency on the rest of that config. + /// + /// Without this call, the engine is exactly as it was before F7: a fresh + /// [`Self::cycle_fee_budget_mojos`] and no cadence gate on every construction. That is + /// deliberately still true for a caller that has not opted in (every pre-F7 test), but it is + /// also the defect this method exists to close for production use: nothing here is wired into + /// node startup yet (`crate::rewards_claim`'s module doc, "Not yet wired into node startup"), + /// so the production wiring (#3268) is the one place expected to call this. + /// F10 (§8.6 floor): also applied here, not just in [`RewardsClaimConfig::load_from`] -- + /// this is a constructor argument, independent of whatever the config file says, and the same + /// hot-loop hazard applies to whatever caller passes it a degenerate value directly. + /// + /// F16: this no longer latches `cfg.corrupt` into a field. [`Self::run_cycle`] re-reads + /// [`RewardsClaimConfig::load_from`] fresh at the top of every cycle instead, so a file an + /// operator fixes or removes between cycles is observed on the VERY NEXT cycle, not only on + /// the next process restart -- see that method's `CycleConditions`. + /// + /// F18: this no longer seeds `fee_window_start_unix` / `fee_spent_in_window_mojos` / + /// `last_cycle_completed_at` from a construction-time read either -- there is nowhere left on + /// `Self` to seed them into. [`Self::run_cycle`] reads [`RewardsClaimConfig`] fresh at the top + /// of every cycle unconditionally (its `CycleConditions`), so a construction-time copy was + /// pure overhead: it was never trusted past the first cycle anyway once F16 landed, and now it + /// is never even taken. + #[must_use] + pub fn with_persisted_fee_window(mut self, dir: &Path, cadence_seconds: u64) -> Self { + self.fee_window_state_dir = Some(dir.to_path_buf()); + self.cadence_seconds = cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self + } + + /// F7: read-modify-write the fee-window fields into whatever `RewardsClaimConfig` currently + /// sits on disk at [`Self::fee_window_state_dir`], leaving every other field (including + /// [`Self::rotation_cursor`], which this engine does not own writing to disk for) exactly as + /// it was read. A failed write is logged, never fatal — the same survivable-degradation + /// posture [`super::config::RewardsClaimConfig::load_from`] already uses for a read. + /// + /// # F8: never overwrites a corrupt file with defaults + /// If the file on disk has gone corrupt SINCE this engine last read it (a concurrent write, or + /// disk damage between calls), the fresh `load_from` above returns [`RewardsClaimConfig`] with + /// `corrupt: true` -- writing our in-memory fee-window fields into that value and saving it + /// would silently paper over the corruption with a value that looks clean (defaulted `enabled`, + /// a dropped `rotation_cursor`, exactly the "worse" half of the F8 finding). Refuse instead: + /// leave the corrupt file exactly as it is on disk and let the NEXT `run_cycle` observe + /// `corrupt` itself and report [`ClaimLoopState::PersistedStateCorrupt`]. + /// + /// F18: `window` is the caller's in-flight view of the three fee-window values -- this engine + /// no longer holds them itself (see the `fee_window_state_dir` doc), so every caller ( + /// [`Self::run_cycle`], [`Self::evaluate_budget_phase`], [`Self::uncommit_fee`]) threads its + /// own local [`FeeWindowState`] through instead of reading `self`. + fn persist_fee_window(&self, window: &FeeWindowState) { + let Some(dir) = &self.fee_window_state_dir else { + return; + }; + let mut cfg = RewardsClaimConfig::load_from(dir); + if cfg.corrupt { + tracing::warn!( + path = %dir.display(), + "the rewards-claim preference file is corrupt on disk; refusing to overwrite it \ + with a fee-window update" + ); + return; + } + cfg.fee_window_start_unix = window.start_unix; + cfg.fee_spent_in_window_mojos = window.spent_mojos; + cfg.last_cycle_completed_at = window.last_completed_at; + if let Err(e) = cfg.save_to(dir) { + tracing::warn!( + path = %dir.display(), + error = %e, + "the rewards-claim fee-budget window could not be persisted" + ); + } + } + + #[must_use] + pub fn status(&self) -> ClaimStatus { + self.status + } + + /// Run one cycle: discover candidates (chain + re-derived hints), evaluate each against SPEC + /// §9.3/§8.3/§12.5, then claim from the above-threshold set in DESCENDING ACCRUED-VALUE ORDER + /// (Defect B2) within the per-claim ceiling AND the per-cycle aggregate fee budget. Returns + /// every outcome, one per evaluated distributor. + pub async fn run_cycle(&mut self, now: u64) -> Vec { + // Defect A1/A4/F1/F3: EVERY per-cycle field is reset here, at the TOP, before any early + // return — a fault, a claim count or a stale distributor tally from a PAST cycle must never + // leak into this cycle's reading, including on the `ChainUnavailable` early-return paths + // below that skip the end-of-function assignment block entirely (F3: those paths used to + // leave last cycle's `distributors_claimable` / `claims_submitted_this_cycle` / + // `distributors_faulted` / `no_entry_slot_this_cycle` sitting stale under this cycle's + // freshly-stamped `last_attempt_at`). + self.status.fault_reported = false; + self.status.chain_unavailable_this_cycle = false; + self.status.payout_hash_mismatches_this_cycle = 0; + self.status.distributors_known = 0; + self.status.distributors_with_own_entry = 0; + self.status.distributors_claimable = 0; + self.status.distributors_faulted = 0; + self.status.claims_submitted_this_cycle = 0; + self.status.no_entry_slot_this_cycle = 0; + self.status.last_attempt_at = Some(now); + + // F18: this engine's own view of the persisted fee window for this cycle -- there is no + // longer a field on `Self` to hold it across cycles; it lives here, for the lifetime of + // this call, and nowhere else. See `fee_window_state_dir`'s doc. + let mut window = FeeWindowState { + start_unix: None, + spent_mojos: 0, + last_completed_at: None, + }; + + // F7: the cadence gate and the persisted budget window -- both keyed off + // `self.fee_window_state_dir`, so a caller that never opted in via + // `with_persisted_fee_window` sees no change at all (every pre-F7 test). + if let Some(dir) = self.fee_window_state_dir.clone() { + // F16: `CycleConditions` is built HERE, at the top of this cycle, from `now` plus a + // freshly reloaded `RewardsClaimConfig` -- and dropped at the end of this `if let` + // block. It is never a field on `Self`, so there is nowhere to latch it: a + // future-dated clock is self-healing by construction (`t > now` goes false the + // moment real time passes it) and is now recomputed, never remembered, every cycle. + // An actually-corrupt file (unreadable, unparsable, or F14's spend-exceeds-budget) is + // re-read from disk on every cycle too, so a file an operator fixes or removes is + // observed on the VERY NEXT cycle rather than only after a process restart -- see + // `RewardsClaimConfig::load_from`'s doc for why re-reading here is cheap and safe + // (`persist_fee_window` below already re-reads the same file for the same reason). + // + // F16/F18 (stale-read fix): the fee-window values carried here come from THIS `cfg` + // -- the freshly reloaded value -- never from a cached copy on `Self` (there is none + // left to read; F18 deleted the fields entirely). `with_persisted_fee_window` no + // longer seeds anything at construction either, so a file that was corrupt then and + // gets repaired later is judged only against what is actually on disk now. + struct CycleConditions { + corrupt: bool, + future_dated_clock: bool, + fee_window_start_unix: Option, + fee_spent_in_window_mojos: u64, + last_cycle_completed_at: Option, + } + let conditions = { + let cfg = RewardsClaimConfig::load_from(&dir); + CycleConditions { + corrupt: cfg.corrupt, + future_dated_clock: cfg.last_cycle_completed_at.is_some_and(|t| t > now) + || cfg.fee_window_start_unix.is_some_and(|t| t > now), + fee_window_start_unix: cfg.fee_window_start_unix, + fee_spent_in_window_mojos: cfg.fee_spent_in_window_mojos, + last_cycle_completed_at: cfg.last_cycle_completed_at, + } + }; + // F8/F10: a corrupt persisted file, or either persisted clock reading AFTER `now` (a + // future-dated clock is corrupt state exactly the same way a torn write is -- an + // ordinary NTP step or clock glitch would otherwise freeze the window forever, F10), + // must never be treated as a fresh start. Fail CLOSED: submit nothing, report it by + // name, and -- critically -- return BEFORE the cadence gate and the window-roll logic + // below, which would otherwise happily manufacture a brand-new zeroed window out of + // untrustworthy state. Critically, disk is left UNTOUCHED here -- absent, corrupt and + // valid are three different facts, and only a valid read below is ever adopted into + // `window`, so a corrupt cycle can never write `poisoned()`'s placeholders over a + // still-good persisted value. + if conditions.corrupt || conditions.future_dated_clock { + self.status.state = ClaimLoopState::PersistedStateCorrupt; + return Vec::new(); + } + // F16/F18: THE fix -- adopt this cycle's fresh, valid, non-future-dated read into + // `window` before the cadence gate or the window-roll logic below ever consults it. A + // file repaired since the last cycle that read a valid, non-corrupt file is now + // observed from DISK, not from whatever a cache happened to hold going in -- closing + // both the stale-read defect (a corrupt-then-cleared file no longer resumes from + // `poisoned()`'s zeros) and Finding 2b (the clocks below now come from the same fresh + // `cfg` the corrupt/future-dated check just used). + window.start_unix = conditions.fee_window_start_unix; + window.spent_mojos = conditions.fee_spent_in_window_mojos; + window.last_completed_at = conditions.last_cycle_completed_at; + // Refuse to START a cycle until the cadence has elapsed since the last one that ran + // to completion -- stops a restart loop from immediately re-running a cycle that + // already ran, independent of whether the fee window below has room left. + // + // F9: this is a DELIBERATE skip, not a fault and not silence -- name it, so it can + // never read as "healthy and idle" (a stale `state` from whatever cycle last computed + // one would otherwise stand here forever, since this path never reaches + // `compute_state` below). + if let Some(last_completed) = window.last_completed_at { + if now.saturating_sub(last_completed) < self.cadence_seconds { + self.status.state = ClaimLoopState::CadenceNotElapsed; + return Vec::new(); + } + } + // The aggregate budget is enforced against this window, never a per-`run_cycle` + // local: roll a fresh window only once the cadence has elapsed since it opened, + // otherwise keep accumulating into what is already spent in it. + let window_still_open = window + .start_unix + .is_some_and(|start| now.saturating_sub(start) < self.cadence_seconds); + if !window_still_open { + window.start_unix = Some(now); + window.spent_mojos = 0; + self.persist_fee_window(&window); + } + } + let mut spent_this_cycle_mojos = if self.fee_window_state_dir.is_some() { + window.spent_mojos + } else { + 0 + }; + let mut budget_exhausted = false; + + let mut discovery_failed = false; + let discovered = match self.port.discover_distributors().await { + Ok(v) => v, + Err(ClaimPortError::Unavailable) => { + // F1: per-cycle only — never a latch. See `ClaimStatus::chain_unavailable_this_cycle`. + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return Vec::new(); + } + Err(ClaimPortError::Other(_)) => { + // Defect A4: do NOT stamp `last_discovery_at` here — a reader relies on this + // timestamp going stale to notice a wedged discovery path. + self.status.fault_reported = true; + discovery_failed = true; + Vec::new() + } + }; + if !discovery_failed { + self.status.last_discovery_at = Some(now); + } + + let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); + // F4: a real adapter can plausibly return the same launcher id twice (one distributor + // reachable via two of the §1.3 launch comments this node scans, across the + // `(store_id, root)` pairs it mirrors). Without this, phase 2 would evaluate it twice and + // submit `InitiatePayout` twice against one entry slot in one cycle -- the second spend is + // invalid (counter already incremented) but the fee is paid anyway, double-charging the + // cycle budget for a single distributor. + candidates.sort_unstable(); + candidates.dedup(); + + // SPEC §13.2: a hint only ADDS a candidate; every property is re-derived from chain before + // it counts, and a hint that fails re-derivation is dropped, never trusted. + for hint in self.hints.hints().await { + if candidates.contains(&hint.launcher_id) { + continue; + } + match self.port.resolve_launch_comment(hint.launcher_id).await { + Ok(Some(_)) => candidates.push(hint.launcher_id), + Ok(None) => {} + Err(ClaimPortError::Unavailable) => {} + Err(ClaimPortError::Other(_)) => self.status.fault_reported = true, + } + } + + self.status.distributors_known = candidates.len() as u32; + let any_candidates = !candidates.is_empty(); + + let mut outcomes = Vec::new(); + let mut with_entry = 0u32; + let mut faulted = 0u32; + let mut submitted_this_cycle = 0u64; + let mut no_entry_this_cycle = 0u32; + let mut eligible: Vec = Vec::new(); + + // Phase 1: everything up to (and including) the payout-threshold check, for every + // candidate — none of this touches the per-cycle budget. Above-threshold candidates become + // `Eligible` and move to phase 2 instead of being decided here. + for launcher_id in candidates { + // Defect B: no permanent blacklist skip here — every candidate is re-evaluated every + // cycle, including one that reported `NoEntrySlot` on a prior cycle. + match self.evaluate_pre_budget(launcher_id).await { + PreBudgetResult::Fault { reason } => { + faulted += 1; + outcomes.push(ClaimOutcome::Faulted { + launcher_id, + reversed_fee_mojos: None, + reason, + }); + } + PreBudgetResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + PreBudgetResult::Eligible { + launcher_id, + accrued_base_units, + } => { + with_entry += 1; + eligible.push(EligibleClaim { + launcher_id, + accrued_base_units, + }); + } + PreBudgetResult::Outcome(outcome, entry_seen) => { + if entry_seen { + with_entry += 1; + } + if let ClaimOutcome::NoEntrySlot { .. } = &outcome { + no_entry_this_cycle += 1; + } + outcomes.push(outcome); + } + } + } + + // Defect B1/E: every `Eligible` candidate is claimable regardless of what phase 2 later + // decides for it (submitted, ceiling-skipped or budget-skipped all count) — matching what + // `distributors_claimable` always meant here. + let claimable = u32::try_from(eligible.len()).unwrap_or(u32::MAX); + + // Phase 2: order by accrued value DESCENDING (Defect B2) — an attacker's dust distributors + // (our own entry there accrues little to nothing) always sort behind a victim's genuine + // earnings, regardless of the fee the attacker sets. The persisted rotation cursor only + // breaks TIES within an accrued-value tier, so it can never let a lower-value distributor + // displace a higher-value one; see `Self::order_for_budget`. + let ordered = self.order_for_budget(eligible); + let mut first_deferred_this_cycle: Option = None; + for claim in &ordered { + match self + .evaluate_budget_phase( + claim, + &mut spent_this_cycle_mojos, + &mut budget_exhausted, + &mut window, + ) + .await + { + BudgetPhaseResult::Fault { + reason, + reversed_fee_mojos, + } => { + faulted += 1; + outcomes.push(ClaimOutcome::Faulted { + launcher_id: claim.launcher_id, + reversed_fee_mojos, + reason, + }); + } + BudgetPhaseResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + BudgetPhaseResult::Outcome(outcome) => { + match &outcome { + ClaimOutcome::Submitted { .. } => submitted_this_cycle += 1, + ClaimOutcome::SkippedCycleBudgetExhausted { .. } + if first_deferred_this_cycle.is_none() => + { + first_deferred_this_cycle = Some(claim.launcher_id); + } + _ => {} + } + outcomes.push(outcome); + } + } + } + // Defect B2: advance the rotation cursor to whoever the budget cut off FIRST this cycle, so + // that one gets first crack next cycle instead of the same tail being dropped every time. + if let Some(deferred) = first_deferred_this_cycle { + self.rotation_cursor = Some(deferred); + } + + // Defect A4: an all-faulted cycle (candidates existed, discovery succeeded, but every one of + // them faulted) must not stamp `last_cycle_at` either — same staleness reasoning as above. + // + // F17: this used to be `outcomes.is_empty() && self.status.fault_reported` — a predicate + // over the OUTCOME STREAM's emptiness. The authorized `ClaimOutcome::Faulted` rework then + // started pushing an outcome at every one of the five per-candidate fault sites, so + // `outcomes` is never empty when a per-candidate fault occurs and this predicate silently + // went permanently false, letting `last_cycle_at` get stamped on a cycle where every + // candidate faulted and nothing was submitted. Never test a stream for emptiness to infer + // a property of its contents — ask what actually happened instead: no submissions this + // cycle, and at least one fault reported. + let all_faulted_cycle = + any_candidates && submitted_this_cycle == 0 && self.status.fault_reported; + + self.status.distributors_with_own_entry = with_entry; + self.status.distributors_claimable = claimable; + self.status.distributors_faulted = faulted; + self.status.claims_submitted += submitted_this_cycle; + self.status.claims_submitted_this_cycle = submitted_this_cycle; + self.status.no_entry_slot_this_cycle = no_entry_this_cycle; + self.status.consecutive_faulted_cycles = if self.status.fault_reported { + self.status.consecutive_faulted_cycles + 1 + } else { + 0 + }; + if !discovery_failed && !all_faulted_cycle { + self.status.last_cycle_at = Some(now); + } + // F7: this cycle ran to completion (every early return above -- ChainUnavailable -- skips + // this line, which is exactly right: those never reached the cadence gate's definition of + // "ran" -- F9: neither does the `CadenceNotElapsed` / `PersistedStateCorrupt` early + // returns above, for the same reason: none of these ever reached the point where a cycle + // is considered to have run). Stamp and persist unconditionally, including a fault-only or + // all-faulted cycle -- an operator restarting to work around a wedged cycle must still get + // the cadence gate's protection, not a loophole that lets a fault re-arm an immediate + // retry. + if self.fee_window_state_dir.is_some() { + window.last_completed_at = Some(now); + self.persist_fee_window(&window); + } + // F1: unconditional now -- `compute_state` reads `chain_unavailable_this_cycle` (reset at + // the top of this function), never `self.state`, so the old "don't overwrite a latch" guard + // is gone along with the latch itself. + self.status.state = self.status.compute_state(); + outcomes + } + + /// Everything up to and including the payout-threshold check (SPEC §9.3, §12.5, §8.6) — none of + /// it depends on, or affects, the per-cycle budget. An above-threshold, hash-matching entry + /// becomes `Eligible` and is decided in [`Self::evaluate_budget_phase`] instead. + async fn evaluate_pre_budget(&mut self, launcher_id: Bytes32) -> PreBudgetResult { + let asset = match self.port.reserve_asset_id(launcher_id).await { + Ok(a) => a, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + if asset != self.dig_asset_id { + // SPEC §9.3: not ours, dropped — not counted as known/claimable. + return PreBudgetResult::Outcome(ClaimOutcome::NotOurs { launcher_id }, false); + } + + // SPEC §12.5 clause 3: re-read the entry slot fresh on EVERY call — never cached. + let entry = match self + .port + .own_entry(launcher_id, self.own_payout_puzzle_hash) + .await + { + Ok(Some(e)) => e, + Ok(None) => { + return PreBudgetResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false); + } + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + + if entry.payout_puzzle_hash != self.own_payout_puzzle_hash { + // Defect E: the port handed back an entry for a puzzle hash that is not this node's own. + // Submitting against it would pay someone else. Refuse -- never substitute our own hash + // and proceed. + // + // Defect B3: this is a PER-DISTRIBUTOR problem, not a cycle-wide one -- it must never + // set `fault_reported` (that pins the whole surface at `Faulted`, permanently, since the + // refusal is deliberately non-terminal and recurs every cycle). Count it instead, both + // lifetime and per-cycle, and let `ClaimableButNotClaiming` (or `Nominal`, if everything + // else claimed) surface it. + self.status.claims_refused_payout_mismatch += 1; + self.status.payout_hash_mismatches_this_cycle += 1; + return PreBudgetResult::Outcome( + ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }, + true, + ); + } + + let threshold = match self.port.payout_threshold(launcher_id).await { + Ok(t) => t, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + + if entry.accrued_base_units < threshold { + self.status.claims_skipped_below_threshold += 1; + return PreBudgetResult::Outcome( + ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: entry.accrued_base_units, + threshold, + }, + true, + ); + } + + PreBudgetResult::Eligible { + launcher_id, + accrued_base_units: entry.accrued_base_units, + } + } + + /// Orders the above-threshold candidates for the budget pass (Defect B2): primarily by accrued + /// value DESCENDING, so an attacker's dust distributors — where this node's own entry accrues + /// little to nothing — always sort behind a victim's genuine earnings no matter what fee the + /// attacker sets. The persisted [`Self::rotation_cursor`] only breaks ties WITHIN an equal-value + /// tier: it rebuilds a byte-order canonical ranking of the candidates present this cycle, then + /// rotates that ranking so the cursor's own launcher id sorts first — guaranteeing a genuinely + /// tied, budget-exceeding honest tail eventually reaches the front, without ever letting a + /// lower-value candidate outrank a higher-value one. + fn order_for_budget(&self, mut eligible: Vec) -> Vec { + let mut canonical: Vec = eligible.iter().map(|c| c.launcher_id).collect(); + canonical.sort(); + let cursor_index = self + .rotation_cursor + .and_then(|cursor| canonical.iter().position(|id| *id == cursor)) + .unwrap_or(0); + let len = canonical.len(); + let rotation_key = |id: &Bytes32| -> usize { + let pos = canonical.iter().position(|x| x == id).unwrap_or(0); + if len == 0 { + 0 + } else { + (pos + len - cursor_index) % len + } + }; + eligible.sort_by(|a, b| { + b.accrued_base_units + .cmp(&a.accrued_base_units) + .then_with(|| rotation_key(&a.launcher_id).cmp(&rotation_key(&b.launcher_id))) + }); + eligible + } + + /// The fee ceiling, per-cycle budget and submission for one already-`Eligible` candidate (SPEC + /// §8.3, Defect C1/C2). The payout puzzle hash is `self.own_payout_puzzle_hash` unconditionally + /// — [`Self::evaluate_pre_budget`] already refused any entry that diverged from it. + async fn evaluate_budget_phase( + &mut self, + claim: &EligibleClaim, + spent_this_cycle_mojos: &mut u64, + budget_exhausted: &mut bool, + window: &mut FeeWindowState, + ) -> BudgetPhaseResult { + let launcher_id = claim.launcher_id; + let fee = match self.port.required_fee_mojos(launcher_id).await { + Ok(f) => f, + Err(ClaimPortError::Unavailable) => return BudgetPhaseResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: None, + }; + } + }; + + if fee > self.max_fee_mojos { + self.status.claims_skipped_fee_ceiling += 1; + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: fee, + ceiling_mojos: self.max_fee_mojos, + }); + } + + // Defect C2: the per-claim ceiling alone does not bound what K distributors can collectively + // force this node to spend in one cycle. Once the cycle budget is gone, every remaining + // candidate is skipped the same way, not spent past it. + // + // F14: `saturating_add`, never a bare `+` -- `spent_this_cycle_mojos` is seeded from a + // persisted value (`RewardsClaimConfig::fee_spent_in_window_mojos`) on the very first + // candidate of a cycle. `config::RewardsClaimConfig::load_from` now rejects a spend + // exceeding its own budget at load time (fails closed, see F8), but this comparison must + // not ALSO be able to panic on a `u64` overflow if that guard is ever bypassed -- the + // workspace enables `overflow-checks` in release, so an unchecked add here is a live + // panic-on-corrupt-input path, not just a debug-build lint. + if *budget_exhausted + || spent_this_cycle_mojos.saturating_add(fee) > self.cycle_fee_budget_mojos + { + *budget_exhausted = true; + self.status.claims_skipped_cycle_budget += 1; + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: fee, + budget_mojos: self.cycle_fee_budget_mojos, + }); + } + + // F7: write-then-spend, never spend-then-write. If persistence is armed, the fee this + // submission is about to cost is committed to disk BEFORE the chain call, not after -- + // so a crash between "we decided to spend" and the chain call returning can never leave + // an unpersisted spend that a restart would repeat. This pre-commit is deliberately + // conservative: a genuine crash mid-`await` never returns to the `match` below at all, so + // the only way to protect against THAT case is to have already written the spend before + // making the call. + if self.fee_window_state_dir.is_some() { + window.spent_mojos = window.spent_mojos.saturating_add(fee); + self.persist_fee_window(window); + } + + match self + .port + .submit_initiate_payout(launcher_id, self.own_payout_puzzle_hash, fee) + .await + { + Ok(()) => { + *spent_this_cycle_mojos += fee; + BudgetPhaseResult::Outcome(ClaimOutcome::Submitted { launcher_id }) + } + // F12: the call HAS resolved here, with a definite answer -- unlike the crash case + // above, "no" means the fee was never broadcast (`ClaimPortError::Unavailable`: never + // even reached the network; `Other(_)`: the network is reachable but the submission + // was rejected). Charging the persisted window for a fee that never left would let an + // attacker exhaust this node's per-cycle budget for free with K always-failing + // submissions, suppressing a victim's real claims for the rest of the window at zero + // cost -- reverse the pre-commit now that we know it did not consume a fee. + Err(ClaimPortError::Unavailable) => { + self.uncommit_fee(fee, window); + BudgetPhaseResult::ChainUnavailable + } + Err(ClaimPortError::Other(message)) => { + self.uncommit_fee(fee, window); + self.status.fault_reported = true; + BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: Some(fee), + } + } + } + } + + /// F12: reverses a pre-committed persisted spend once [`Self::evaluate_budget_phase`]'s + /// submission call has DEFINITELY returned without broadcasting -- see that method's "F12" + /// doc comment for why the pre-commit itself must stay conservative for a genuine crash + /// mid-call, which never reaches this method at all. + fn uncommit_fee(&mut self, fee: u64, window: &mut FeeWindowState) { + if self.fee_window_state_dir.is_some() { + window.spent_mojos = window.spent_mojos.saturating_sub(fee); + self.persist_fee_window(window); + } + } +} + +/// An above-threshold, hash-matching candidate waiting for the budget pass (Defect B2). +struct EligibleClaim { + launcher_id: Bytes32, + accrued_base_units: u64, +} + +/// F18: [`ClaimEngine::run_cycle`]'s own, call-scoped view of the three persisted fee-window +/// values (`fee_window_start_unix`, `fee_spent_in_window_mojos`, `last_cycle_completed_at`). +/// These used to be cached fields on [`ClaimEngine`] itself; they are not any more (see that +/// struct's `fee_window_state_dir` doc) -- disk, via [`RewardsClaimConfig`], is the only place +/// they persist across cycles. This type exists only to thread the in-flight values through one +/// `run_cycle` call, into [`ClaimEngine::evaluate_budget_phase`] and +/// [`ClaimEngine::uncommit_fee`], and into [`ClaimEngine::persist_fee_window`]'s write. +struct FeeWindowState { + /// See [`super::config::RewardsClaimConfig::fee_window_start_unix`]. + start_unix: Option, + /// See [`super::config::RewardsClaimConfig::fee_spent_in_window_mojos`]. + spent_mojos: u64, + /// See [`super::config::RewardsClaimConfig::last_cycle_completed_at`]. + last_completed_at: Option, +} + +/// The outcome of [`ClaimEngine::evaluate_pre_budget`]. +enum PreBudgetResult { + /// `(outcome, entry_slot_was_present)`. + Outcome(ClaimOutcome, bool), + /// Above threshold, hash matches — proceeds to [`ClaimEngine::evaluate_budget_phase`]. + Eligible { + launcher_id: Bytes32, + accrued_base_units: u64, + }, + /// A chain read (`reserve_asset_id`, `own_entry` or `payout_threshold`) returned + /// `ClaimPortError::Other`. None of these ever reads a fee, so [`ClaimOutcome::Faulted`] built + /// from this is always `reversed_fee_mojos: None`. + Fault { + reason: String, + }, + ChainUnavailable, +} + +/// The outcome of [`ClaimEngine::evaluate_budget_phase`]. +enum BudgetPhaseResult { + Outcome(ClaimOutcome), + /// A chain call (`required_fee_mojos` or `submit_initiate_payout` itself) returned + /// `ClaimPortError::Other`. `reversed_fee_mojos` is `Some` only for the latter, where a fee was + /// already pre-committed and [`ClaimEngine::uncommit_fee`] has already reversed it. + Fault { + reason: String, + reversed_fee_mojos: Option, + }, + ChainUnavailable, +} + +/// Bounds a chain port's error text before it is carried into [`ClaimOutcome::Faulted`] or logged — +/// it originates from a chain port and is therefore attacker-adjacent, the same 200-char discipline +/// `service::summarize_stderr` applies to a spawned tool's own stderr. +fn bound_port_error_text(message: &str) -> String { + message.chars().take(200).collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Mutex; + + use async_trait::async_trait; + + use super::*; + use crate::rewards_claim::hints::{DistributorHint, NoHintSource}; + use crate::rewards_claim::parser::parse_launch_comment; + use crate::rewards_claim::types::DiscoveredDistributor; + + const DIG_ASSET_ID: Bytes32 = Bytes32::new([9u8; 32]); + const OUR_PAYOUT_PUZZLE_HASH: Bytes32 = Bytes32::new([1u8; 32]); + const FEE_CEILING: u64 = 1_000_000_000; + const CYCLE_BUDGET: u64 = 1_000_000_000; + + #[derive(Clone)] + struct FakeDistributor { + launcher_id: Bytes32, + store_id: Bytes32, + root: Bytes32, + reserve_asset_id: Bytes32, + payout_threshold: u64, + entry: Option, + fee_mojos: u64, + } + + /// A full in-memory fake standing in for the real chain adapter (see the module doc's "chain + /// seam" section) — the ONLY thing #3249 landing changes is which struct implements this trait. + struct FakeChainPort { + distributors: Mutex>, + submitted: Mutex>, + own_entry_reads: Mutex, + /// F12: launcher ids whose `submit_initiate_payout` must return + /// `Err(ClaimPortError::Other(_))` -- simulates a submission that definitely never + /// broadcast. + fail_submit_for: Mutex>, + /// F17: launcher ids whose `reserve_asset_id` must return `Err(ClaimPortError::Other(_))` + /// -- simulates a per-candidate chain-read fault reached during discovery success, the + /// scenario `repeated_discovery_faults_never_read_as_nominal` does NOT cover (that test + /// fails discovery itself, a different and already-correct path). + fail_reserve_asset_for: Mutex>, + /// F15: when set, `submit_initiate_payout` snapshots the persisted spend at this + /// directory into `submit_snapshots` BEFORE returning -- proving the write already + /// landed on disk before the chain call resolves, not just before `run_cycle` returns. + submit_snapshot_dir: Mutex>, + submit_snapshots: Mutex>, + } + + impl FakeChainPort { + fn new(distributors: Vec) -> Self { + FakeChainPort { + distributors: Mutex::new( + distributors + .into_iter() + .map(|d| (d.launcher_id, d)) + .collect(), + ), + submitted: Mutex::new(Vec::new()), + own_entry_reads: Mutex::new(0), + fail_submit_for: Mutex::new(std::collections::HashSet::new()), + fail_reserve_asset_for: Mutex::new(std::collections::HashSet::new()), + submit_snapshot_dir: Mutex::new(None), + submit_snapshots: Mutex::new(Vec::new()), + } + } + + /// F12: makes `submit_initiate_payout` for `id` return `Err(Other(_))` instead of `Ok`. + fn fail_submit_for(&self, id: Bytes32) { + self.fail_submit_for.lock().unwrap().insert(id); + } + + /// F17: makes `reserve_asset_id` for `id` return `Err(Other(_))` instead of `Ok` -- `id` + /// still appears in `discover_distributors`' output (discovery itself succeeds), so this + /// simulates a per-candidate fault reached AFTER discovery, not a discovery failure. + fn fail_reserve_asset_for(&self, id: Bytes32) { + self.fail_reserve_asset_for.lock().unwrap().insert(id); + } + + /// F15: arms the pre-submit snapshot hook against `dir`. + fn arm_submit_snapshot(&self, dir: std::path::PathBuf) { + *self.submit_snapshot_dir.lock().unwrap() = Some(dir); + } + } + + #[async_trait] + impl ClaimChainPort for FakeChainPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(self + .distributors + .lock() + .unwrap() + .values() + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + }) + .collect()) + } + + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(self + .distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + })) + } + + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + if self + .fail_reserve_asset_for + .lock() + .unwrap() + .contains(&launcher_id) + { + return Err(ClaimPortError::Other( + "simulated reserve_asset_id fault".into(), + )); + } + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.reserve_asset_id) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.payout_threshold) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn own_entry( + &self, + launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + *self.own_entry_reads.lock().unwrap() += 1; + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.entry) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.fee_mojos) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + if let Some(dir) = self.submit_snapshot_dir.lock().unwrap().clone() { + let snapshot = RewardsClaimConfig::load_from(&dir).fee_spent_in_window_mojos; + self.submit_snapshots.lock().unwrap().push(snapshot); + } + if self.fail_submit_for.lock().unwrap().contains(&launcher_id) { + return Err(ClaimPortError::Other("simulated submission failure".into())); + } + self.submitted + .lock() + .unwrap() + .push((launcher_id, payout_puzzle_hash, fee_mojos)); + Ok(()) + } + } + + fn one_distributor( + entry: Option, + payout_threshold: u64, + fee_mojos: u64, + ) -> FakeDistributor { + FakeDistributor { + launcher_id: Bytes32::new([2u8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold, + entry, + fee_mojos, + } + } + + fn engine(port: FakeChainPort) -> ClaimEngine { + ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + } + + /// ACCEPTANCE 1 — the anti-green test. A loop that runs and claims nothing MUST fail this. + #[tokio::test] + async fn one_tick_submits_exactly_one_claim_for_an_above_threshold_entry() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!(outcomes, vec![ClaimOutcome::Submitted { launcher_id }]); + assert_eq!(e.status().claims_submitted, 1); + assert_eq!(e.port.submitted.lock().unwrap().len(), 1); + let (submitted_launcher, submitted_ppz, _fee) = e.port.submitted.lock().unwrap()[0]; + assert_eq!(submitted_launcher, launcher_id); + assert_eq!(submitted_ppz, OUR_PAYOUT_PUZZLE_HASH); + } + + /// ACCEPTANCE 3 — below threshold is skipped, never an error, never a spend. + #[tokio::test] + async fn below_threshold_is_skipped_not_failed_and_spends_nothing() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 500, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: 500, + threshold: 1_000, + }] + ); + assert_eq!(e.status().claims_submitted, 0); + assert_eq!(e.status().claims_skipped_below_threshold, 1); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// ACCEPTANCE 4 — the threshold is read from chain, not hardcoded. + #[tokio::test] + async fn threshold_other_than_1000_is_honoured() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 2_500, + }), + 5_000, + 10, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: 2_500, + threshold: 5_000, + }] + ); + } + + /// ACCEPTANCE 5 — a required fee above the ceiling is skipped, zero submissions. + #[tokio::test] + async fn fee_above_ceiling_is_skipped() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + FEE_CEILING + 1, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: FEE_CEILING + 1, + ceiling_mojos: FEE_CEILING, + }] + ); + assert_eq!(e.status().claims_submitted, 0); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// Defect B regression: `NoEntrySlot` is non-error and reports neither a chain fault nor a lost + /// payment, but it is NO LONGER a permanent blacklist — the second tick re-checks the same + /// distributor (SPEC §12.5 clause 3: re-read fresh before every claim, never cache). + #[tokio::test] + async fn no_entry_slot_is_non_terminal_and_re_checked_every_cycle() { + let d = one_distributor(None, 1_000, 10); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let first = e.run_cycle(1_000).await; + assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); + assert_eq!(e.status().no_entry_slot_this_cycle, 1); + assert!(!e.status().fault_reported, "no chain fault reported"); + assert_eq!(e.status().claims_submitted, 0, "no lost payment claimed"); + + let reads_after_first = *e.port.own_entry_reads.lock().unwrap(); + let second = e.run_cycle(2_000).await; + assert_eq!( + second, + vec![ClaimOutcome::NoEntrySlot { launcher_id }], + "still no entry, so still reported -- but re-evaluated, not silently skipped" + ); + assert_eq!( + *e.port.own_entry_reads.lock().unwrap(), + reads_after_first + 1, + "the second tick re-reads the entry slot rather than trusting a cached absence" + ); + } + + /// Defect B — the fix's whole point: SPEC §12.5 clause 2's re-entry path. A distributor with no + /// entry slot on cycle 1 (never admitted yet, or evicted) that gains one before cycle 2 (legit + /// re-admission, or a discovery-vs-`AddEntry` race resolving) must produce a claim on cycle 2 — + /// the old process-lifetime blacklist made this permanently unreachable. + #[tokio::test] + async fn no_entry_slot_then_re_admitted_produces_a_claim_on_the_later_cycle() { + let d = one_distributor(None, 1_000, 10); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let first = e.run_cycle(1_000).await; + assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); + + // The distributor admits our entry between cycle 1 and cycle 2. + e.port + .distributors + .lock() + .unwrap() + .get_mut(&launcher_id) + .unwrap() + .entry = Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }); + + let second = e.run_cycle(2_000).await; + assert_eq!(second, vec![ClaimOutcome::Submitted { launcher_id }]); + assert_eq!(e.status().claims_submitted, 1); + } + + /// ACCEPTANCE 7 — two consecutive ticks perform two fresh entry-slot reads; no cached slot. + #[tokio::test] + async fn consecutive_ticks_re_read_the_entry_slot_fresh() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 500, + }), + 1_000, + 10, + ); + let mut e = engine(FakeChainPort::new(vec![d])); + + e.run_cycle(1_000).await; + e.run_cycle(2_000).await; + + assert_eq!(*e.port.own_entry_reads.lock().unwrap(), 2); + } + + /// ACCEPTANCE 10 — SPEC §9.3: a distributor whose reserve asset is not DIG_ASSET_ID is dropped. + #[tokio::test] + async fn non_dig_reserve_asset_distributor_is_dropped() { + let mut d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + d.reserve_asset_id = Bytes32::new([0xFFu8; 32]); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!(outcomes, vec![ClaimOutcome::NotOurs { launcher_id }]); + assert_eq!(e.status().claims_submitted, 0); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// ACCEPTANCE 11a/11c — a hint ADDS a candidate the chain sweep did not already return, and + /// `NoHintSource` changes no outcome versus the chain-only path (every other test here uses + /// `NoHintSource` already; this test is the direct A/B). + #[tokio::test] + async fn a_hint_adds_a_candidate_the_chain_sweep_alone_would_miss() { + struct OneHint(Bytes32); + #[async_trait] + impl DistributorHintSource for OneHint { + async fn hints(&self) -> Vec { + vec![DistributorHint { + launcher_id: self.0, + }] + } + } + + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + + // Chain-only sweep never returns this distributor -- only resolve_launch_comment does, + // simulating "known to exist on chain but not enumerated by the discovery sweep yet". + struct HintOnlyPort(FakeChainPort); + #[async_trait] + impl ClaimChainPort for HintOnlyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.0.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, l: Bytes32) -> Result { + self.0.reserve_asset_id(l).await + } + async fn payout_threshold(&self, l: Bytes32) -> Result { + self.0.payout_threshold(l).await + } + async fn own_entry( + &self, + l: Bytes32, + p: Bytes32, + ) -> Result, ClaimPortError> { + self.0.own_entry(l, p).await + } + async fn required_fee_mojos(&self, l: Bytes32) -> Result { + self.0.required_fee_mojos(l).await + } + async fn submit_initiate_payout( + &self, + l: Bytes32, + p: Bytes32, + f: u64, + ) -> Result<(), ClaimPortError> { + self.0.submit_initiate_payout(l, p, f).await + } + } + + let port = HintOnlyPort(FakeChainPort::new(vec![d])); + let mut e = ClaimEngine::new( + port, + OneHint(launcher_id), + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!(outcomes, vec![ClaimOutcome::Submitted { launcher_id }]); + } + + /// ACCEPTANCE 11b — a hint whose chain re-derivation fails (resolve_launch_comment -> None) is + /// dropped, never becomes a candidate, never a claim's authority. + #[tokio::test] + async fn a_hint_that_fails_chain_rederivation_is_dropped() { + struct BogusHint; + #[async_trait] + impl DistributorHintSource for BogusHint { + async fn hints(&self) -> Vec { + vec![DistributorHint { + launcher_id: Bytes32::new([0xEEu8; 32]), + }] + } + } + + let port = FakeChainPort::new(Vec::new()); + let mut e = ClaimEngine::new( + port, + BogusHint, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty()); + assert_eq!(e.status().distributors_known, 0); + } + + /// A port whose discovery call always returns a real (non-`Unavailable`) chain fault, every + /// cycle -- the failure Defect A1 describes. + struct AlwaysFaultingDiscoveryPort; + #[async_trait] + impl ClaimChainPort for AlwaysFaultingDiscoveryPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Other("simulated chain fault".into())) + } + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(None) + } + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Err(ClaimPortError::Other("unreachable".into())) + } + } + + /// Defect A1/A2 regression -- THE anti-green test for this defect: a port that errors on + /// discovery every cycle must NEVER read `Nominal`. Before the fix, `fault_reported` had no + /// fault-bearing state to fall through to and this laundered into `Nominal` forever. + #[tokio::test] + async fn repeated_discovery_faults_never_read_as_nominal() { + let mut e = ClaimEngine::new( + AlwaysFaultingDiscoveryPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + for cycle in 1..=3u32 { + e.run_cycle(u64::from(cycle) * 1_000).await; + assert_ne!( + e.status().state, + ClaimLoopState::Nominal, + "cycle {cycle}: a reported fault must never read as Nominal" + ); + assert_eq!( + e.status().state, + ClaimLoopState::Faulted { cycles: cycle }, + "cycle {cycle}: consecutive fault count must track the streak" + ); + } + } + + /// Finding 2 regression: discovery SUCCEEDS (unlike + /// `repeated_discovery_faults_never_read_as_nominal`, which fails discovery itself -- a + /// different and already-correct path), every candidate faults on a per-candidate chain read, + /// and `last_cycle_at` must NOT be stamped. Must go red with `all_faulted_cycle` restored to + /// its old `outcomes.is_empty() && self.status.fault_reported` proxy -- a `ClaimOutcome::Faulted` + /// IS an outcome, so `outcomes` is never empty here and the old proxy silently stamped + /// `last_cycle_at` on a cycle where nothing was actually claimed. + #[tokio::test] + async fn all_candidates_faulted_does_not_stamp_last_cycle_at() { + let launcher_id = Bytes32::new([2u8; 32]); + let port = FakeChainPort::new(vec![one_distributor(None, 1_000, 10)]); + port.fail_reserve_asset_for(launcher_id); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Faulted { + launcher_id, + reversed_fee_mojos: None, + reason: "simulated reserve_asset_id fault".to_string(), + }], + "discovery succeeded (the candidate was found) but its only chain read faulted" + ); + assert_eq!(e.status().claims_submitted_this_cycle, 0); + assert_eq!( + e.status().last_cycle_at, + None, + "an all-faulted cycle (candidates existed, discovery succeeded, nothing submitted) \ + must not stamp last_cycle_at -- never infer 'nothing happened' from outcomes being \ + empty, because a Faulted outcome is still an outcome" + ); + } + + /// Defect A4 regression: a failed discovery must leave `last_discovery_at` unchanged (a reader + /// depends on that timestamp going stale to notice a wedged discovery path). + #[tokio::test] + async fn failed_discovery_leaves_last_discovery_at_unchanged() { + let mut e = ClaimEngine::new( + AlwaysFaultingDiscoveryPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + e.run_cycle(1_000).await; + assert_eq!(e.status().last_discovery_at, None); + e.run_cycle(2_000).await; + assert_eq!( + e.status().last_discovery_at, + None, + "still unchanged after a second failed discovery" + ); + assert_eq!( + e.status().last_attempt_at, + Some(2_000), + "last_attempt_at still proves the loop is alive" + ); + } + + /// Defect C2 regression: K distributors each individually under the per-claim ceiling must NOT + /// collectively spend past the per-cycle aggregate budget. + #[tokio::test] + async fn distributors_each_under_ceiling_do_not_collectively_exceed_the_cycle_budget() { + const PER_CLAIM_FEE: u64 = 10; + const BUDGET: u64 = 25; // only 2 of 4 distributors can be paid out of this budget + let distributors: Vec = (0..4u8) + .map(|i| FakeDistributor { + launcher_id: Bytes32::new([i + 10; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: PER_CLAIM_FEE, + }) + .collect(); + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, // each individual fee (10) is far under the per-claim ceiling + BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + let submitted = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(); + let budget_skipped = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::SkippedCycleBudgetExhausted { .. })) + .count(); + assert_eq!( + submitted, 2, + "only 2 claims fit inside the 25-mojo budget at 10 each" + ); + assert_eq!( + budget_skipped, 2, + "the remaining 2 are skipped, not spent past the budget" + ); + assert_eq!(e.status().claims_submitted, 2); + assert_eq!(e.status().claims_skipped_cycle_budget, 2); + } + + /// **Defect B2 (blocking) -- the anti-suppression test.** Ten attacker-funded dust distributors + /// (our own entry there accrues almost nothing, but each demands a fee big enough that ONE of + /// them alone exhausts the cycle budget) must NOT prevent a genuinely high-accrual distributor + /// from being claimed in the same cycle, no matter what order the chain sweep happens to return + /// them in (`FakeChainPort` stores candidates in a `HashMap`, so discovery order here is exactly + /// as arbitrary as a real chain sweep's). + #[tokio::test] + async fn dust_distributors_do_not_suppress_a_high_accrual_claim_in_the_same_cycle() { + const DUST_FEE: u64 = 100; + let victim = FakeDistributor { + launcher_id: Bytes32::new([0xFFu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 100_000, // genuinely high accrual + }), + fee_mojos: DUST_FEE, + }; + let victim_id = victim.launcher_id; + let mut distributors = vec![victim]; + for i in 0..10u8 { + distributors.push(FakeDistributor { + launcher_id: Bytes32::new([i; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 1_100, // just above threshold -- dust, not zero + }), + fee_mojos: DUST_FEE, // funder-controlled: attacker sets this at will + }); + } + // The budget fits exactly ONE distributor's fee -- first-come order would let any dust + // distributor that sorts ahead of the victim consume it entirely. + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + DUST_FEE, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { launcher_id } if *launcher_id == victim_id)) + .count(), + 1, + "the high-accrual victim must be the one claimed, regardless of discovery order" + ); + assert_eq!( + e.status().claims_submitted, + 1, + "the budget fits exactly one claim" + ); + assert_eq!( + e.status().claims_skipped_cycle_budget, + 10, + "every dust distributor is deferred, never the victim" + ); + } + + /// **Defect B2 (blocking) -- the fairness half.** A persisted rotation cursor must advance + /// across cycles so a genuinely tied, budget-exceeding honest tail is not the same distributor + /// dropped every cycle forever. + #[tokio::test] + async fn the_rotation_cursor_advances_so_a_tied_starved_tail_is_eventually_served() { + const FEE: u64 = 10; + const BUDGET: u64 = 20; // only 2 of 3 equal-value distributors fit per cycle + let distributors: Vec = (0..3u8) + .map(|i| FakeDistributor { + launcher_id: Bytes32::new([i + 1; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, // EQUAL for all three -- a genuine tie + }), + fee_mojos: FEE, + }) + .collect(); + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + BUDGET, + DIG_ASSET_ID, + ); + + let mut deferred_across_cycles: std::collections::HashSet = + std::collections::HashSet::new(); + for cycle in 1..=3u32 { + let outcomes = e.run_cycle(u64::from(cycle) * 1_000).await; + for outcome in &outcomes { + if let ClaimOutcome::SkippedCycleBudgetExhausted { launcher_id, .. } = outcome { + deferred_across_cycles.insert(*launcher_id); + } + } + } + + assert!( + deferred_across_cycles.len() > 1, + "the same distributor must not be the only one ever deferred across cycles -- got {deferred_across_cycles:?}" + ); + assert!( + e.rotation_cursor().is_some(), + "the cursor must have advanced at least once" + ); + } + + /// Defect B2: `with_rotation_cursor` / `rotation_cursor` are the seam a persisted config uses + /// to survive a restart -- proves the getter reflects what the setter installed before any + /// cycle has run. + #[test] + fn rotation_cursor_round_trips_through_the_engine_accessors() { + let cursor = Bytes32::new([0x42u8; 32]); + let e = engine(FakeChainPort::new(Vec::new())).with_rotation_cursor(Some(cursor)); + assert_eq!(e.rotation_cursor(), Some(cursor)); + } + + /// F7: a distributor whose required fee alone equals the whole cycle budget, so ONE submitted + /// claim exhausts it completely -- makes every F7 test below unambiguous about whether a + /// SECOND full budget was granted. + fn budget_consuming_distributor(launcher_id: Bytes32, fee_mojos: u64) -> FakeDistributor { + FakeDistributor { + launcher_id, + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos, + } + } + + /// **F7 (blocking) — the restart reproducer.** Before the fix, a fresh [`ClaimEngine`] has an + /// empty in-memory budget and cadence clock no matter what a PRIOR process already spent, so + /// this must FAIL before the fix: the second engine submits its claim too, spending a second + /// full [`CYCLE_BUDGET`] inside the same window a prior process already exhausted. + #[tokio::test] + async fn f7_restart_reproducer_a_second_engine_from_the_same_directory_refuses_to_overspend() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-reproducer-") + .tempdir() + .expect("a scratch dir"); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x10u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x10u8; 32]) + }], + "the first cycle must actually spend the whole budget, or this reproduces nothing" + ); + drop(first); + + // A NEW process, seconds later — nowhere near CADENCE_SECONDS away — reconstructs the + // engine from the SAME directory and faces a DIFFERENT distributor that also costs the + // whole budget. + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x20u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_010).await; + + let second_submitted = second_outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(); + assert_eq!( + second_submitted, 0, + "a restart inside the same budget window must not be able to spend a second full \ + cycle budget -- a process restart is not a fresh peer" + ); + } + + /// F7: ten simulated restarts inside ONE window must not collectively exceed the aggregate + /// budget, however many of those restarts each try to spend a full budget's worth. + #[tokio::test] + async fn f7_ten_restarts_inside_one_window_never_collectively_exceed_the_budget() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-ten-restarts-") + .tempdir() + .expect("a scratch dir"); + + let mut total_submitted_mojos = 0u64; + for i in 0..10u8 { + let launcher_id = Bytes32::new([0x30 + i; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + launcher_id, + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let outcomes = e.run_cycle(1_000 + u64::from(i)).await; + if outcomes + .iter() + .any(|o| matches!(o, ClaimOutcome::Submitted { .. })) + { + total_submitted_mojos += CYCLE_BUDGET; + } + } + + assert!( + total_submitted_mojos <= CYCLE_BUDGET, + "ten restarts inside one window spent {total_submitted_mojos} mojos, over the \ + {CYCLE_BUDGET}-mojo budget" + ); + } + + /// F7: once the window has genuinely elapsed, a restart MUST be allowed a fresh budget — the + /// fix bounds a crash-restart loop, it does not starve a node that legitimately restarts + /// between cadence periods. + #[tokio::test] + async fn f7_a_restart_after_the_window_elapsed_gets_a_fresh_budget() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-window-elapsed-") + .tempdir() + .expect("a scratch dir"); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x40u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x40u8; 32]) + }] + ); + drop(first); + + // Well past both the window AND the cadence gate. + let later = 1_000 + CADENCE_SECONDS + 1; + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x50u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(later).await; + + assert_eq!( + second_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x50u8; 32]) + }], + "a restart after the window elapsed must be granted a fresh budget" + ); + } + + /// F7: a restart immediately after a completed cycle must not even START another cycle before + /// the cadence elapses — independent of the fee-window check, this stops a fast restart loop + /// from re-running full cycles (with their own chain reads) back to back. + #[tokio::test] + async fn f7_a_restart_immediately_after_a_completed_cycle_does_not_run_another() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-cadence-gate-") + .tempdir() + .expect("a scratch dir"); + let launcher_id = Bytes32::new([0x60u8; 32]); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "the first cycle must complete normally, or this proves nothing about a restart" + ); + drop(first); + + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_050).await; + + assert_eq!( + second_outcomes, + Vec::new(), + "a restart 50 seconds after a completed cycle must not run another before the \ + 86,400-second cadence elapses" + ); + } + + /// F7: a crash after a submission but before the cycle finishes must still leave that spend + /// recorded on disk — proves the write happens PER SUBMISSION, never batched to cycle end. + /// Simulated by reading the persisted config directly after a cycle that submits more than one + /// claim, rather than waiting for `run_cycle` to return. + #[tokio::test] + async fn f7_a_spend_is_persisted_per_submission_not_batched_to_cycle_end() { + const CYCLE_BUDGET: u64 = 30; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-per-submission-") + .tempdir() + .expect("a scratch dir"); + + let distributors = vec![ + budget_consuming_distributor(Bytes32::new([0x70u8; 32]), 10), + budget_consuming_distributor(Bytes32::new([0x71u8; 32]), 10), + ]; + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + let submitted: u64 = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count() as u64 + * 10; + assert_eq!(submitted, 20, "both distributors must have been submitted"); + + // Read the file directly rather than through `e` -- proves the write already landed on + // disk, not just in the engine's own in-memory mirror. + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 20, + "each submission must persist its own spend immediately, not wait for cycle end" + ); + } + + /// F15: `f7_a_spend_is_persisted_per_submission_not_batched_to_cycle_end` above only reads the + /// file AFTER `run_cycle` returns, which a cycle-end-batched persist would also satisfy -- + /// exactly the vacuous-test class F11 named. This test snapshots the file DURING each + /// submission's own chain call, before that call (or `run_cycle`) has returned: the second + /// distributor's snapshot can only show the first distributor's 10-mojo spend already on disk + /// if persistence genuinely happens per submission. Must go red with the pre-commit in + /// `evaluate_budget_phase` moved to after the `.await` (or to cycle end). + #[tokio::test] + async fn f15_a_spend_is_visible_on_disk_before_the_submission_call_resolves() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f15-") + .tempdir() + .expect("a scratch dir"); + + let distributors = vec![ + budget_consuming_distributor(Bytes32::new([0x72u8; 32]), 10), + budget_consuming_distributor(Bytes32::new([0x73u8; 32]), 10), + ]; + let port = FakeChainPort::new(distributors); + port.arm_submit_snapshot(dir.path().to_path_buf()); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!( + outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(), + 2, + "both distributors must have been submitted, or this proves nothing" + ); + + let snapshots = e.port.submit_snapshots.lock().unwrap().clone(); + assert_eq!( + snapshots, + vec![10, 20], + "the first submission's own snapshot must already see ITS OWN pre-committed 10-mojo \ + spend (write-then-spend), and the second must see BOTH -- a cycle-end batch would \ + show 0 for both, since neither had landed on disk yet when these calls ran" + ); + } + + /// F11: the two restart tests above (`f7_restart_reproducer_...` and `f7_ten_restarts_...`) + /// advance the clock by ≤10s, so the CADENCE GATE alone makes them pass -- delete the window + /// enforcement entirely and they still go green. This test satisfies the gate (no prior + /// completed cycle at all, so it never even runs) and instead binds the window accumulator + /// directly: a cycle the gate permits, entering a window that already carries a full persisted + /// spend, must still be refused by the budget. Must go red with only the window-seeding line + /// in `with_persisted_fee_window` (`self.fee_spent_in_window_mojos = cfg.fee_spent_in_window_ + /// mojos`) reverted to always start at `0`. + #[tokio::test] + async fn f11_a_gate_permitted_cycle_is_still_refused_by_an_already_full_persisted_window() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f11-window-binds-") + .tempdir() + .expect("a scratch dir"); + + // Simulate a crash mid-window: a prior process opened this window and spent it in full, + // but never recorded a completed cycle (a real crash never gets that far either). + let seeded = RewardsClaimConfig { + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: CYCLE_BUDGET, + last_cycle_completed_at: None, // no completed cycle on record -- the gate is satisfied + ..RewardsClaimConfig::default() + }; + seeded.save_to(dir.path()).expect("seed the window"); + + let launcher_id = Bytes32::new([0x74u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the + // gate cannot be what refuses this -- only the window accumulator can. + let outcomes = e.run_cycle(1_005).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: 10, + budget_mojos: CYCLE_BUDGET, + }], + "a window seeded as already fully spent must refuse every claim, even though the \ + cadence gate itself was satisfied" + ); + } + + /// F9 regression: a deliberately-skipped cycle must report its OWN named condition, never a + /// stale reading left over from the last cycle that actually ran. Must go red with only the + /// `self.status.state = ClaimLoopState::CadenceNotElapsed;` assignment on the cadence-gate + /// early return removed. + #[tokio::test] + async fn f9_a_cadence_skipped_cycle_reports_its_own_state_not_a_stale_one() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f9-cadence-state-") + .tempdir() + .expect("a scratch dir"); + let launcher_id = Bytes32::new([0x75u8; 32]); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "the first cycle must actually run and claim, or its state proves nothing to skip past" + ); + assert_eq!( + first.status().state, + ClaimLoopState::Nominal, + "sanity: the first cycle's OWN state must be something other than CadenceNotElapsed" + ); + drop(first); + + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_010).await; + + assert_eq!( + second_outcomes, + Vec::new(), + "the cadence gate must still refuse to run" + ); + assert_eq!( + second.status().state, + ClaimLoopState::CadenceNotElapsed, + "a deliberately-skipped cycle must name itself, never read as the previous cycle's \ + Nominal (or any other stale) state" + ); + } + + /// F10/F16 regression -- THE anti-latch test for Finding 1. A future-dated + /// `last_cycle_completed_at` (an NTP step, a clock glitch) must (a) refuse cycle 1, reported as + /// `PersistedStateCorrupt`, never silent, and (b) — this is the part the ONE-cycle version of + /// this test could never prove — self-heal the moment real time catches up: cycle 2, run after + /// the clock has caught up AND the cadence has elapsed, MUST claim. A single-cycle version of + /// this test is green whether the latch bug is present or not, because it never gives the + /// latch a second cycle to prove it never clears. Must go red against the pre-F16 engine (the + /// `fee_window_poisoned` field latching `future_dated_clock` permanently `true`), and green + /// once that field is gone and `future_dated_clock` is recomputed fresh every cycle. + #[tokio::test] + async fn f10_a_future_dated_clock_refuses_then_self_heals_next_cycle() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f10-future-clock-") + .tempdir() + .expect("a scratch dir"); + + let far_future = 9_999_999_999u64; + let seeded = RewardsClaimConfig { + last_cycle_completed_at: Some(far_future), + ..RewardsClaimConfig::default() + }; + seeded + .save_to(dir.path()) + .expect("seed a future-dated clock"); + + let launcher_id = Bytes32::new([0x76u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, + // and must refuse. + let cycle1 = e.run_cycle(1_000).await; + assert_eq!( + cycle1, + Vec::new(), + "a future-dated clock must submit nothing this cycle" + ); + assert_eq!( + e.status().state, + ClaimLoopState::PersistedStateCorrupt, + "a future-dated clock must be its own reported condition, never silent, and never \ + read as CadenceNotElapsed (which is what the old unvalidated saturating_sub bug \ + would produce once F9 is fixed)" + ); + + // Cycle 2: real time has now passed `far_future` (self-healing the clock condition) AND + // the cadence has elapsed since `far_future` (satisfying the cadence gate too) -- a + // genuinely healthy cycle that a permanent latch would still refuse forever. + let caught_up = far_future + CADENCE_SECONDS + 1; + let cycle2 = e.run_cycle(caught_up).await; + assert_eq!( + cycle2, + vec![ClaimOutcome::Submitted { launcher_id }], + "once the clock has genuinely caught up, the next cycle MUST claim -- a latched \ + `fee_window_poisoned` would refuse this cycle forever, long after the glitch that \ + caused it stopped being true" + ); + assert_ne!( + e.status().state, + ClaimLoopState::PersistedStateCorrupt, + "a self-healed clock must not still read as corrupt" + ); + } + + /// F16 regression: a corrupt file, repaired mid-run to VALID values carrying a large + /// already-spent amount and a recent completed-cycle time, must resume from those DISK + /// values on the very next cycle -- never from `poisoned()`'s `None`/`0`/`None` placeholders + /// that `with_persisted_fee_window` copied into the engine while the file was still corrupt. + /// Cycle 2 must neither get a fresh budget (the repaired file says the window is already + /// fully spent) nor skip the cadence gate (the repaired file names a `last_cycle_completed_at` + /// only 10 seconds before cycle 2's `now`, far short of the cadence). Must go red against the + /// pre-fix engine, which loads the fee-window fields ONCE at construction + /// (`with_persisted_fee_window`) and never refreshes them from the freshly-reloaded `cfg` + /// inside `run_cycle`'s `CycleConditions` -- so cycle 2 sees its own construction-time `None`s + /// for `last_cycle_completed_at` and `fee_window_start_unix`, skips the cadence gate entirely, + /// rolls a brand-new zeroed window and submits. + #[tokio::test] + async fn f16_a_repaired_file_resumes_from_disk_values_not_placeholders() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f16-repair-mid-run-") + .tempdir() + .expect("a scratch dir"); + + std::fs::write( + dir.path().join("rewards-claim.json"), + b"{ this is not json, or a torn write mid-object", + ) + .expect("seed a corrupt file"); + + let launcher_id = Bytes32::new([0x79u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Cycle 1: the file is corrupt -- must refuse, submit nothing. + let cycle1 = e.run_cycle(1_000).await; + assert_eq!( + cycle1, + Vec::new(), + "a corrupt file must submit nothing this cycle" + ); + assert_eq!(e.status().state, ClaimLoopState::PersistedStateCorrupt); + + // The operator's remedy: repair the file with VALID values -- a window already fully + // spent, and a completed cycle only 10 seconds ago. + let repaired = RewardsClaimConfig { + cadence_seconds: CADENCE_SECONDS, + max_cycle_fee_budget_mojos: CYCLE_BUDGET, + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: CYCLE_BUDGET, + last_cycle_completed_at: Some(1_000), + ..RewardsClaimConfig::default() + }; + repaired.save_to(dir.path()).expect("repair the file"); + + // Cycle 2, 10 seconds later -- far short of the 86_400s cadence, and the window the + // repaired file names is already fully spent. Must neither claim nor roll a fresh window. + let cycle2 = e.run_cycle(1_010).await; + assert_eq!( + cycle2, + Vec::new(), + "a just-repaired file must resume from its OWN disk values, not the placeholders \ + `with_persisted_fee_window` saw while the file was still corrupt -- a fresh budget \ + or a skipped cadence gate here is the F16 stale-read defect" + ); + assert_eq!( + e.status().state, + ClaimLoopState::CadenceNotElapsed, + "the repaired file's own last_cycle_completed_at must still gate this cycle" + ); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, CYCLE_BUDGET, + "a cycle that never ran must never overwrite the repaired disk values with a fresh \ + zeroed window" + ); + } + + /// F12 regression: a submission that DEFINITELY failed (the call returned `Err`, so it never + /// broadcast) must not permanently inflate the persisted window -- that is free denial-of- + /// service for an attacker running K always-failing submissions. Must go red with the + /// `uncommit_fee` calls on the `Err` branches of `evaluate_budget_phase`'s `match` removed. + #[tokio::test] + async fn f12_a_failed_submission_does_not_inflate_the_persisted_window() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f12-failed-submit-") + .tempdir() + .expect("a scratch dir"); + + let failing = Bytes32::new([0x78u8; 32]); + let d = budget_consuming_distributor(failing, 10); + let port = FakeChainPort::new(vec![d]); + port.fail_submit_for(failing); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!( + outcomes.len(), + 1, + "the one candidate must have been evaluated, or this proves nothing about its fee" + ); + assert!(!matches!( + outcomes[0], + ClaimOutcome::PayoutPuzzleHashMismatch { .. } + )); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 0, + "a submission that definitely never broadcast must leave the persisted window \ + exactly as it was, not charged for a fee that was never spent" + ); + } + + /// #3251 rework: a failed submission must produce `ClaimOutcome::Faulted`, not just increment + /// `distributors_faulted` and vanish from the outcome stream -- the exact silence this ticket + /// exists to close. Reuses F12's own fixture (a submission that DEFINITELY failed) so both + /// facts are proven from the SAME cycle: the outcome exists AND the fee it reversed is not + /// left charged against the persisted window. + #[tokio::test] + async fn a_failed_submission_produces_a_faulted_outcome_with_the_fee_it_reversed() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + const FEE_MOJOS: u64 = 10; + let dir = tempfile::Builder::new() + .prefix("dig-node-faulted-outcome-") + .tempdir() + .expect("a scratch dir"); + + let failing = Bytes32::new([0x79u8; 32]); + let d = budget_consuming_distributor(failing, FEE_MOJOS); + let port = FakeChainPort::new(vec![d]); + port.fail_submit_for(failing); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Faulted { + launcher_id: failing, + reversed_fee_mojos: Some(FEE_MOJOS), + reason: "simulated submission failure".to_string(), + }], + "a definitely-failed submission must be reported, not silently absorbed into the \ + `faulted` counter alone" + ); + assert_eq!( + e.status().distributors_faulted, + 1, + "the counter stays; it is not a substitute for the outcome" + ); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 0, + "the fee `Faulted` reports as reversed must actually be reversed in the persisted \ + window, not merely claimed reversed in the outcome" + ); + } + + /// F14 regression: the per-cycle budget comparison must never panic on a corrupted or + /// otherwise near-`u64::MAX` in-cycle spend total -- the workspace enables `overflow-checks` + /// in release, so a bare `+` here is a live panic-on-corrupt-input path, not just a debug + /// lint. Must go red (panic) with `saturating_add` reverted to a bare `+` in + /// `evaluate_budget_phase`'s budget comparison. + #[tokio::test] + async fn f14_a_near_max_spent_value_does_not_panic_the_budget_comparison() { + let d = budget_consuming_distributor(Bytes32::new([0x80u8; 32]), 10); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + let mut spent_this_cycle_mojos = u64::MAX - 5; + let mut budget_exhausted = false; + let claim = EligibleClaim { + launcher_id, + accrued_base_units: 5_000, + }; + let mut window = FeeWindowState { + start_unix: None, + spent_mojos: 0, + last_completed_at: None, + }; + + let result = e + .evaluate_budget_phase( + &claim, + &mut spent_this_cycle_mojos, + &mut budget_exhausted, + &mut window, + ) + .await; + + assert!( + matches!( + result, + BudgetPhaseResult::Outcome(ClaimOutcome::SkippedCycleBudgetExhausted { .. }) + ), + "a near-overflow spent value must read as budget-exhausted, never panic and never \ + submit" + ); + } + + /// Defect E regression: a port returning an entry whose `payout_puzzle_hash` diverges from this + /// node's own must produce ZERO submissions -- never pay whoever the port named instead -- + /// counted both lifetime and per-cycle. + /// + /// Defect B3 regression: this used to also assert `fault_reported`, which set the CYCLE-WIDE + /// `Faulted` state for a PER-DISTRIBUTOR problem -- see `a_payout_mismatch_never_sets_the_cycle_ + /// wide_fault_or_masks_other_distributors` below for the exploit this enabled. + #[tokio::test] + async fn entry_for_a_different_payout_puzzle_hash_is_refused_not_paid() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + assert_ne!(wrong_hash, OUR_PAYOUT_PUZZLE_HASH); + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }] + ); + assert_eq!(e.status().claims_submitted, 0, "never paid the wrong hash"); + assert!(e.port.submitted.lock().unwrap().is_empty()); + assert!( + !e.status().fault_reported, + "Defect B3: a per-distributor mismatch must never set the cycle-wide fault" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 1); + assert_eq!(e.status().payout_hash_mismatches_this_cycle, 1); + } + + /// **Defect B3 (blocking) -- the exploit the review found.** A single hostile/buggy entry row + /// (a payout-hash mismatch on one launcher) must NOT pin the whole surface at `Faulted` and + /// must NOT bury the `ClaimableButNotClaiming` signal for every OTHER, healthy distributor. + #[tokio::test] + async fn a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + let mismatched = FakeDistributor { + launcher_id: Bytes32::new([0xAAu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + // A second, healthy distributor whose claim would exceed the budget alongside the + // mismatched one's fee, so a fault-flag leak would be free to hide behind + // `ClaimableButNotClaiming` too -- proving the precedence fix, not just the flag. + let healthy = FakeDistributor { + launcher_id: Bytes32::new([0xBBu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + let mut e = engine(FakeChainPort::new(vec![mismatched, healthy])); + + for cycle in 1..=3u32 { + e.run_cycle(u64::from(cycle) * 1_000).await; + assert!( + !matches!(e.status().state, ClaimLoopState::Faulted { .. }), + "cycle {cycle}: a per-distributor mismatch must never read as the cycle-wide Faulted" + ); + } + // F2 inversion: this assertion used to read `ClaimLoopState::Nominal` (an A2-class test + // pinning the defect as intended behaviour). A live payout-hash mismatch is a real, + // per-cycle shortfall exactly like an unmet `claimable` -- the healthy distributor + // claiming does NOT make the surface healthy while the mismatched one is still refused + // every cycle. `distributors_claimable` counts only the healthy one (1); the mismatch + // never enters `eligible` so it is not in `claimable` either, but it IS folded into the + // shortfall predicate's denominator, so `submitted (1) < claimable (1) + mismatches (1)`. + // + // F13: the payload reports that same folded denominator (2), not the un-folded + // `distributors_claimable` (1) alone -- a state named `ClaimableButNotClaiming` whose + // numbers said "0 short" would contradict its own name. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 2, + submitted: 1 + }, + "an ongoing payout-hash mismatch is a real, per-cycle shortfall -- it must never read \ + as Nominal just because the OTHER distributor claimed" + ); + assert_eq!( + e.status().claims_submitted, + 3, + "the healthy one claimed all 3 cycles" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 3); + } + + /// **F2 -- all-K-distributors mismatching must read as a shortfall, never `Nominal`.** Before + /// the fix, a mismatch never entered `eligible`, so `claims_submitted_this_cycle` (0) and + /// `distributors_claimable` (0) were BOTH zero and the magnitude comparison read healthy -- + /// the exact case the F2 brief calls out: "what if every distributor refuses for the same + /// reason." This must be a shortfall (`ClaimableButNotClaiming`), and it must NOT reintroduce + /// Defect B3 by setting the cycle-wide `Faulted`. + #[tokio::test] + async fn all_distributors_mismatching_is_a_shortfall_not_nominal() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + let mismatched = FakeDistributor { + launcher_id: Bytes32::new([0xAAu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + let mut e = engine(FakeChainPort::new(vec![mismatched])); + + e.run_cycle(1_000).await; + + assert_eq!( + e.status().distributors_claimable, + 0, + "the mismatched distributor never enters eligible" + ); + assert_eq!(e.status().claims_submitted_this_cycle, 0); + assert!( + !matches!(e.status().state, ClaimLoopState::Faulted { .. }), + "a per-distributor mismatch must never set the cycle-wide Faulted (Defect B3)" + ); + // F13: `distributors_claimable` (the un-folded term) is 0, but the payload reports the + // folded shortfall denominator -- `distributors_claimable (0) + mismatches (1)` -- so an + // all-mismatching cycle carries a nonzero `claimable` instead of a reassuring zero. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + }, + "all-K-distributors mismatching is a real, systemic shortfall -- it must never read \ + as Nominal just because nothing entered `eligible`" + ); + } + + /// ACCEPTANCE 12 — with `UnavailableClaimChainPort` wired, the engine reports the named state + /// `ChainSourceUnavailable` and runs zero cycles: no discovery outcome, no fault flag, no + /// claim, never a silent no-op (see the module doc's "chain seam" + "HONESTY" sections). + #[tokio::test] + async fn unavailable_port_reports_chain_source_unavailable_and_runs_zero_cycles() { + let mut e = ClaimEngine::new( + crate::rewards_claim::port::UnavailableClaimChainPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert!(outcomes.is_empty(), "zero cycles ran"); + assert_eq!(e.status().state, ClaimLoopState::ChainSourceUnavailable); + assert_eq!(e.status().claims_submitted, 0); + assert_eq!(e.status().distributors_known, 0); + assert!(e.status().last_cycle_at.is_none(), "no cycle completed"); + } + + /// A discovery port that answers `Unavailable` on its FIRST call only, then delegates every + /// call (including later `discover_distributors` calls) to a healthy inner `FakeChainPort` -- + /// modelling a node still syncing, or one dropped connection, exactly as F1 describes. + struct FlakyThenHealthyPort { + // An atomic counter, not a `Mutex` -- a guard held across the `.await` below would + // make this port's future not `Send`, which `#[async_trait]`'s generated signature + // requires. Nothing here needs a lock: it is a single counter, never held past its own + // increment. + calls: AtomicU32, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for FlakyThenHealthyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { + return Err(ClaimPortError::Unavailable); + } + self.inner.discover_distributors().await + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.inner.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.inner.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.inner.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.inner + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F1 regression -- the anti-latch test.** `ChainSourceUnavailable` must be a PER-CYCLE + /// reading, never a process-lifetime latch. Cycle 1 hits the transient `Unavailable` port path + /// and must report it honestly; cycle 2, once the chain answers again, MUST read `Nominal` -- + /// not the stale `ChainSourceUnavailable` from cycle 1 -- because a real claim submits. + #[tokio::test] + async fn a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = distributor.launcher_id; + let port = FlakyThenHealthyPort { + calls: AtomicU32::new(0), + inner: FakeChainPort::new(vec![distributor]), + }; + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty(), "cycle 1: no chain, no outcomes"); + assert_eq!( + e.status().state, + ClaimLoopState::ChainSourceUnavailable, + "cycle 1: the transient unavailability must be reported honestly" + ); + + let outcomes = e.run_cycle(2_000).await; + assert_eq!( + outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "cycle 2: the chain is healthy and a real claim is submitted" + ); + assert_eq!( + e.status().state, + ClaimLoopState::Nominal, + "cycle 2 MUST NOT still read ChainSourceUnavailable -- that is a process-lifetime \ + latch on the very state whose whole point is to be a live reading" + ); + } + + /// A discovery port that answers healthily on its FIRST call, then `Unavailable` on every call + /// after that -- the inverse of `FlakyThenHealthyPort`, for F3's staleness scenario. + struct HealthyThenUnavailablePort { + // Atomic, not `Mutex` -- see `FlakyThenHealthyPort`'s comment: a guard held across + // the `.await` below would make this port's future not `Send`. + calls: AtomicU32, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for HealthyThenUnavailablePort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { + return self.inner.discover_distributors().await; + } + Err(ClaimPortError::Unavailable) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.inner.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.inner.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.inner.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.inner + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F3 regression -- staleness under a fresh timestamp.** Cycle 1 is healthy and submits a + /// real claim (`distributors_claimable == 1`, `claims_submitted_this_cycle == 1`). Cycle 2 hits + /// the `ChainUnavailable` early-return path, which skips the end-of-function assignment block + /// entirely. Before the fix, cycle 1's counts stayed on `self.status` while `last_attempt_at` + /// was stamped fresh for cycle 2 -- exactly the stale-count-under-a-fresh-timestamp §2.4 + /// forbids. Every per-cycle counter must read as this cycle's true zero. + #[tokio::test] + async fn a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let port = HealthyThenUnavailablePort { + calls: AtomicU32::new(0), + inner: FakeChainPort::new(vec![distributor]), + }; + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + e.run_cycle(1_000).await; + assert_eq!( + e.status().distributors_claimable, + 1, + "cycle 1: healthy and claimable" + ); + assert_eq!( + e.status().claims_submitted_this_cycle, + 1, + "cycle 1: submitted" + ); + + e.run_cycle(2_000).await; + assert_eq!(e.status().state, ClaimLoopState::ChainSourceUnavailable); + assert_eq!( + e.status().distributors_claimable, + 0, + "F3: cycle 1's claimable count must not survive under cycle 2's fresh last_attempt_at" + ); + assert_eq!( + e.status().claims_submitted_this_cycle, + 0, + "F3: cycle 1's submission count must not survive into cycle 2" + ); + assert_eq!(e.status().distributors_faulted, 0); + assert_eq!(e.status().no_entry_slot_this_cycle, 0); + } + + /// The launch-comment parser wired end-to-end: what `resolve_launch_comment` would produce for + /// a real chain reply, confirming the two modules compose (not a duplicate of parser.rs's own + /// table-driven unit tests). + #[test] + fn parser_output_feeds_discovered_distributor_shape() { + let store = "a".repeat(64); + let root = "b".repeat(64); + let comment = format!("dig-rewards:v1:{store}:{root}"); + let d = parse_launch_comment(Bytes32::new([5u8; 32]), &comment).expect("parses"); + assert_eq!(d.launcher_id, Bytes32::new([5u8; 32])); + } + /// A discovery port that returns the SAME launcher id twice from one `discover_distributors` + /// call -- plausible for a real adapter scanning §1.3 launch comments across every + /// `(store_id, root)` pair this node mirrors, when one distributor is reachable via two of + /// them. + struct DuplicatingDiscoveryPort(FakeChainPort); + + #[async_trait] + impl ClaimChainPort for DuplicatingDiscoveryPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let mut v = self.0.discover_distributors().await?; + let doubled = v.clone(); + v.extend(doubled); + Ok(v) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.0.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.0.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.0.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.0.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.0.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.0 + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F4 (non-blocking, cheap) -- a duplicated launcher id must submit EXACTLY ONCE.** Without + /// dedup, phase 2 evaluates the same candidate twice and pays the fee twice against one entry + /// slot in one cycle; the second spend is invalid (`counter` already incremented) but the fee + /// is spent anyway. + #[tokio::test] + async fn a_duplicated_launcher_id_submits_exactly_once() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = distributor.launcher_id; + let port = DuplicatingDiscoveryPort(FakeChainPort::new(vec![distributor])); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "exactly one submission for one distributor, even though discovery reported it twice" + ); + assert_eq!(e.status().claims_submitted, 1); + assert_eq!( + e.status().distributors_known, + 1, + "dedup collapses the duplicate" + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/hints.rs b/crates/dig-node-service/src/rewards_claim/hints.rs new file mode 100644 index 00000000..6a2a6a53 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/hints.rs @@ -0,0 +1,43 @@ +//! The DIG-Network/dig_ecosystem#3252 seam — defined here, wired to nothing (SPEC §13.2). + +use async_trait::async_trait; +use chia_protocol::Bytes32; + +/// An UNTRUSTED pointer to a distributor (SPEC §13.2 clause 1) — exactly like +/// `unverified_mirror_coin_id`. It MUST NOT admit an entry, MUST NOT rank a candidate and MUST NOT +/// be a claim's authority. Every property is re-derived from the chain via +/// [`super::port::ClaimChainPort::resolve_launch_comment`] before this hint's launcher id becomes a +/// candidate. DIG-Network/dig_ecosystem#3252 supplies the dig-gossip implementation by extending the +/// holdings-announce wire (opcode 222). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DistributorHint { + pub launcher_id: Bytes32, +} + +/// A source of untrusted distributor pointers (SPEC §13.2). +#[async_trait] +pub trait DistributorHintSource: Send + Sync { + async fn hints(&self) -> Vec; +} + +/// The MVP wiring: no hints. SPEC §13.2 clause 2 — a peer that never hears a hint MUST still find +/// and claim via §13.1, so this changes no outcome; it only removes a latency shortcut this lane +/// does not build. +pub struct NoHintSource; + +#[async_trait] +impl DistributorHintSource for NoHintSource { + async fn hints(&self) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn no_hint_source_yields_nothing() { + assert!(NoHintSource.hints().await.is_empty()); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs new file mode 100644 index 00000000..5d5d4a57 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -0,0 +1,72 @@ +//! The node's PEER-SIDE reward claim loop (DIG-Network/dig_ecosystem#3251). +//! +//! This is the other half of the reward-distributor lifecycle from +//! `dig_node_core::rewards` (DIG-Network/dig_ecosystem#3250, a sibling lane): that crate proves a +//! FUNDER's distributors are honest and writes entries; this module discovers the distributors that +//! cover the `(store_id, root)`s THIS node mirrors, watches its own entry slot, and submits +//! `InitiatePayout` on a jittered cadence. It lives in `dig-node-service`, not `dig-node-core`, +//! because `dig-mirror-coin` (the on-chain peer<->payout binding this loop reuses, SPEC §10.1) is a +//! dependency of this crate and not of `dig-node-core`. +//! +//! # No rival copy of a shared type +//! +//! `chia_protocol::Bytes32` is the one canonical 32-byte type — never a locally declared +//! `type Bytes32 = [u8; 32]`. This module's own types (`DiscoveredDistributor`, `OwnEntry`, the +//! [`ClaimChainPort`] trait) are named differently from #3250's `port.rs` (`DistributorRef`, +//! `EntrySlot`, `RewardsChainPort`) because they carry different behaviour: #3250 reads the FUNDER's +//! whole entry set and writes entries; this module reads only THIS node's own entry slot and submits +//! payout claims. Same protocol, other side, not a duplicate. +//! +//! # The chain seam +//! +//! `dig-rewards-coin` is v0.1.3, published on crates.io, and still SPEC-only (`src/` is +//! `error.rs` + `lib.rs`); its driver is +//! DIG-Network/dig_ecosystem#3249, still open. So the whole engine here is built against the narrow +//! [`ClaimChainPort`] trait derived from the SPEC's described surface, tested with a full in-memory +//! fake, and the production adapter — until #3249 ships — is [`UnavailableClaimChainPort`], which +//! reports the named state `ChainSourceUnavailable` and runs zero cycles. This mirrors #3250's own +//! `UnavailableChainPort` exactly. When #3249 lands, one adapter is written against +//! `ClaimChainPort` and nothing above this seam changes. +//! +//! A silent no-op that reported progress instead would be the exact defect this ticket exists to +//! prevent (SPEC §2.4): with the unavailable adapter wired, zero claims IS the true state, so the +//! status surface must say so by name, not by omission. +//! +//! # Not yet wired into node startup (Defect D — stated, not fixed here) +//! Nothing in this codebase constructs a [`ClaimEngine`] outside this module's own tests: there is +//! no scheduler that drives [`ClaimEngine::run_cycle`] on a cadence, and no RPC method exposes +//! [`ClaimStatus`] to an operator, even though [`RewardsClaimConfig::enabled`] defaults to `true`. +//! Wiring this into node startup — picking a concrete [`ClaimChainPort`] adapter, starting the +//! cadence loop, and exposing `ClaimStatus` over RPC — is a separate unit of work with its own +//! review surface, deferred out of this PR on purpose: the only production adapter available today +//! is [`UnavailableClaimChainPort`], and the real one arrives with +//! DIG-Network/dig_ecosystem#3249. Until that wiring lands, this module compiles, is fully tested +//! against the fake chain port, and does nothing in a running node. + +mod cadence; +mod config; +mod engine; +mod hints; +mod parser; +mod port; +mod types; + +pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +pub use config::{ + RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, + CLAIM_FEE_CEILING_MOJOS_DEFAULT, +}; +pub use engine::ClaimEngine; +pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; +pub use parser::parse_launch_comment; +pub use port::{ClaimChainPort, ClaimPortError, UnavailableClaimChainPort}; +pub use types::{ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, OwnEntry}; + +#[cfg(test)] +mod tests { + #[test] + fn module_compiles_and_loads() { + // Skeleton checkpoint (kernel invariant 3): a compiling module with one passing test, + // pushed before any design work. Superseded by the real engine tests as they land. + } +} diff --git a/crates/dig-node-service/src/rewards_claim/parser.rs b/crates/dig-node-service/src/rewards_claim/parser.rs new file mode 100644 index 00000000..aebfaade --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/parser.rs @@ -0,0 +1,102 @@ +//! The launch-comment parser (SPEC §1.3) — the only place a distributor's launch spend is tied to +//! content, so a wrong parse here is a wrong claim everywhere downstream. +//! +//! `dig-rewards:v1::`, each half exactly 64 lowercase hex characters. A +//! writer MUST emit lowercase; a reader MUST accept either case and compare the 32 BYTES, never the +//! text (SPEC §1.3). A comment that does not parse is "not a DIG rewards distributor" — not an +//! error (SPEC §1.3 clause 3). + +use chia_protocol::Bytes32; + +use super::types::DiscoveredDistributor; + +const PREFIX: &str = "dig-rewards:v1:"; + +/// Parse a launch comment into the `(store_id, root)` it names, or `None` if it is not a DIG +/// rewards distributor's comment. `launcher_id` is threaded through unchanged — this function only +/// interprets the comment string. +#[must_use] +pub fn parse_launch_comment(launcher_id: Bytes32, comment: &str) -> Option { + let rest = comment.strip_prefix(PREFIX)?; + let (store_hex, root_hex) = rest.split_once(':')?; + let store_id = parse_hex32(store_hex)?; + let root = parse_hex32(root_hex)?; + Some(DiscoveredDistributor { + launcher_id, + store_id, + root, + }) +} + +/// Exactly 64 hex characters (either case), compared as the 32 bytes they denote — never as text. +fn parse_hex32(hex: &str) -> Option { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut bytes = [0u8; 32]; + hex::decode_to_slice(hex, &mut bytes).ok()?; + Some(Bytes32::from(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lid() -> Bytes32 { + Bytes32::from([7u8; 32]) + } + + #[test] + fn table_driven_launch_comment_parsing() { + let store = "a".repeat(64); + let root = "b".repeat(64); + let store_upper = "A".repeat(64); + + let cases: &[(&str, bool)] = &[ + ("valid lowercase", true), + ("valid uppercase halves", true), + ("wrong prefix", false), + ("wrong version", false), + ("short store half", false), + ("long store half", false), + ("non-hex store half", false), + ("empty comment", false), + ("empty halves", false), + ]; + + let comments: &[String] = &[ + format!("dig-rewards:v1:{store}:{root}"), + format!("dig-rewards:v1:{store_upper}:{root}"), + format!("dig-mirror:v1:{store}:{root}"), + format!("dig-rewards:v2:{store}:{root}"), + format!("dig-rewards:v1:{}:{root}", &store[..63]), + format!("dig-rewards:v1:{store}a:{root}"), + format!("dig-rewards:v1:{}:{root}", "z".repeat(64)), + String::new(), + "dig-rewards:v1::".to_string(), + ]; + + for ((name, expect_some), comment) in cases.iter().zip(comments.iter()) { + let got = parse_launch_comment(lid(), comment); + assert_eq!(got.is_some(), *expect_some, "case: {name} ({comment:?})"); + } + } + + #[test] + fn parse_compares_bytes_not_text_case() { + let store = "ab".repeat(32); + let root = "cd".repeat(32); + let lower = parse_launch_comment(lid(), &format!("dig-rewards:v1:{store}:{root}")).unwrap(); + let upper = parse_launch_comment( + lid(), + &format!( + "dig-rewards:v1:{}:{}", + store.to_uppercase(), + root.to_uppercase() + ), + ) + .unwrap(); + assert_eq!(lower.store_id, upper.store_id); + assert_eq!(lower.root, upper.root); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs new file mode 100644 index 00000000..f5988f52 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -0,0 +1,142 @@ +//! The claim-side chain port — the seam this engine is built against instead of +//! `dig-rewards-coin` (see the module doc's "chain seam" section for why). +//! +//! Deliberately a DIFFERENT trait from #3250's `RewardsChainPort`: that one reads a funder's whole +//! entry set and writes entries; this one reads only THIS node's own entry slot and submits its own +//! payout. + +use async_trait::async_trait; +use chia_protocol::Bytes32; + +use super::types::{DiscoveredDistributor, OwnEntry}; + +/// Why a claim-chain call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimPortError { + /// No chain source is wired yet — [`UnavailableClaimChainPort`]'s only answer, and what any + /// real adapter should answer for an unreachable chain too. + Unavailable, + /// A chain answered but the call failed for a reason worth a message (bounded before logging). + Other(String), +} + +/// The narrow surface the claim engine needs from the reward-distributor chain state, derived from +/// SPEC's described surface (§1.3 discovery, §8.3/§9.3 evaluation, §10.2/§12.5 the peer's own entry, +/// §10.2 clause 3 the claim write) — not from `dig-rewards-coin`'s internals. +#[async_trait] +pub trait ClaimChainPort: Send + Sync { + /// SPEC §13.1: every CHIP-0051 distributor on chain whose launch comment parses per §1.3 — + /// before the §9.3 reserve-asset filter, which the engine applies via [`Self::reserve_asset_id`]. + async fn discover_distributors(&self) -> Result, ClaimPortError>; + + /// Re-derive one launcher id's launch comment from chain (SPEC §13.2 clause 1: a gossip hint is + /// untrusted, so it is verified through this same on-chain path, never trusted directly). + /// `Ok(None)` means the comment does not parse — "not a DIG rewards distributor", not an error + /// (SPEC §1.3). + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError>; + + /// SPEC §9.1/§9.3: the distributor's on-chain `reserve_asset_id`. + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §8.3: the distributor's own chain-curried `payout_threshold` — never hardcoded here. + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §10.2/§12.5: this node's own entry slot, re-read fresh on EVERY call, EVERY cycle — the + /// engine MUST NOT cache the result across cycles and MUST NOT treat one `Ok(None)` as + /// permanent (Defect B): SPEC §12.5 clause 2 describes a legitimate re-entry path (evicted, + /// re-challenged, re-admitted), and this call cannot tell "never admitted yet" apart from + /// "evicted" from the absence alone — nor does it need to, since SPEC §6.4 clause 1 means + /// nothing is owed either way. `Ok(None)` means only "no claim this cycle", never "no claim + /// ever again". + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError>; + + /// The network fee, in mojos, an `InitiatePayout` for this launcher id would need. + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §10.2: submit ONE `InitiatePayout` for `payout_puzzle_hash` at `fee_mojos`. + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError>; +} + +/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// [`ClaimPortError::Unavailable`] on every call and runs zero cycles — the named state +/// `ChainSourceUnavailable` (see the module doc), never a silent no-op. +pub struct UnavailableClaimChainPort; + +#[async_trait] +impl ClaimChainPort for UnavailableClaimChainPort { + async fn discover_distributors(&self) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Err(ClaimPortError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unavailable_adapter_never_reports_a_cycle_ran() { + let port = UnavailableClaimChainPort; + assert_eq!( + port.discover_distributors().await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.own_entry(Bytes32::from([0u8; 32]), Bytes32::from([0u8; 32])) + .await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.submit_initiate_payout(Bytes32::from([0u8; 32]), Bytes32::from([0u8; 32]), 0) + .await, + Err(ClaimPortError::Unavailable) + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs new file mode 100644 index 00000000..dfe22737 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -0,0 +1,566 @@ +//! The data shapes the claim loop moves — deliberately named apart from #3250's `port.rs` +//! (`DistributorRef` / `EntrySlot`) because this side carries discovery provenance the funder side +//! has no concept of. + +use chia_protocol::Bytes32; + +/// A distributor this node has located on-chain and confirmed is ours (SPEC §1.3, §9.3): its launch +/// comment parsed and its reserve asset is `dig_constants::DIG_ASSET_ID`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiscoveredDistributor { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, +} + +/// This node's own entry slot on one distributor (SPEC §10.2): keyed by a payout PUZZLE HASH, never +/// a pubkey, re-read fresh before every claim (SPEC §12.5 clause 3) and never cached across cycles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnEntry { + pub payout_puzzle_hash: Bytes32, + /// The slot's replay guard; `InitiatePayout` writes `counter + 1` (SPEC §10.2 clause 3). + pub counter: u64, + /// What this entry has accrued and not yet claimed, in $DIG base units. + pub accrued_base_units: u64, +} + +/// What one distributor's evaluation this cycle produced — never silently nothing. +/// +/// Not `Copy` since [`Self::Faulted`] carries a `String` (the chain port's own bounded error text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimOutcome { + /// `InitiatePayout` was submitted for this launcher id. + Submitted { launcher_id: Bytes32 }, + /// SPEC §8.6 final sentence: skipped, not failed — no spend, no fee. + SkippedBelowThreshold { + launcher_id: Bytes32, + accrued: u64, + threshold: u64, + }, + /// The fee ceiling (`crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS`-derived, see + /// [`super::config`]) would be exceeded — skipped, not failed. + SkippedFeeAboveCeiling { + launcher_id: Bytes32, + fee_mojos: u64, + ceiling_mojos: u64, + }, + /// SPEC v0.1.3 §12.5: no entry slot for our puzzle hash this cycle — terminal for THIS claim + /// attempt only, never for the distributor. Eviction already settled everything owed (SPEC + /// §6.4), but §12.5 forbids caching an absence any more than a value and forbids a permanent + /// per-distributor exclusion set: the loop keeps observing this distributor on §8.6's cadence, + /// because a peer can re-enter after eviction (§12.5 clause 2's re-entry path). + NoEntrySlot { launcher_id: Bytes32 }, + /// SPEC §9.3: the distributor's reserve asset is not `DIG_ASSET_ID` — not ours, dropped. + NotOurs { launcher_id: Bytes32 }, + /// Defect C2: the per-cycle aggregate fee budget (`RewardsClaimConfig::max_cycle_fee_budget_mojos`) + /// is exhausted — skipped, not failed, and every later candidate this cycle is skipped the same + /// way rather than spent past the budget. Bounds what an attacker funding many distributors over + /// a widely mirrored store can force this node to spend in one cycle. + SkippedCycleBudgetExhausted { + launcher_id: Bytes32, + fee_mojos: u64, + budget_mojos: u64, + }, + /// Defect E: the port's `own_entry` returned an entry whose `payout_puzzle_hash` does not equal + /// THIS node's own (`ClaimEngine::own_payout_puzzle_hash`). Paying it would send funds to + /// somewhere that is not this node, so the claim is REFUSED — not corrected by substituting our + /// own hash and proceeding. A mismatch means the port is confused or hostile, so it counts as a + /// fault, never a routine skip. + PayoutPuzzleHashMismatch { launcher_id: Bytes32 }, + /// The seventh case, added because the other six could only say a peer was legitimately not + /// paid, never that something went wrong: a chain call for this launcher id returned + /// `ClaimPortError::Other(_)` this cycle -- the chain answered but the call itself failed. + /// Distinct from `ClaimPortError::Unavailable` (no chain reached at all -- a cycle-wide + /// condition, surfaced as [`ClaimLoopState::ChainSourceUnavailable`], never per-launcher). Every + /// one of `evaluate_pre_budget`'s three chain reads and `evaluate_budget_phase`'s two can + /// produce this outcome; only the last of those five (`submit_initiate_payout` itself) is a + /// genuine "we tried to pay you and the chain said no" -- the earlier four never got far enough + /// to read a fee or attempt a spend. For a peer's money this is still the one fact worth + /// reporting either way: nothing legitimate happened to this distributor this cycle, and unlike + /// every variant above, it is not a deliberate, correct non-payment. + /// + /// The current [`super::port::ClaimPortError`] shape cannot distinguish "definitely never + /// landed" from "landed, fate unknown" any further than this: `Other(_)` IS the chain giving a + /// resolved answer (see `evaluate_budget_phase`'s "F12" doc comment), so every site that + /// produces this outcome already knows the call did not succeed and, by construction, that no + /// fee is left committed for it (either none was ever read, or it was read, pre-committed to + /// the persisted window, and reversed by `ClaimEngine::uncommit_fee` before this outcome was + /// built). There is no "fate unknown" case reachable today; if one is ever added (e.g. a + /// request that times out with no chain answer at all), it needs its own variant rather than + /// being folded in here, because it could not carry the same "no money moved" guarantee. + Faulted { + launcher_id: Bytes32, + /// `Some(fee)` only when a fee was pre-committed to the persisted fee window and then + /// reversed before this outcome was produced (the `submit_initiate_payout` failure path) -- + /// proof the fee did not stay spent despite the pre-commit. `None` means no fee was ever + /// read for this attempt, so there was nothing to commit or reverse. Either way the + /// persisted window reflects zero net spend for this launcher id this cycle (see + /// `f12_a_failed_submission_does_not_inflate_the_persisted_window`). + reversed_fee_mojos: Option, + /// The chain port's own words for why (`ClaimPortError::Other`'s payload), bounded to 200 + /// chars before it is stored or logged -- it originates from a chain port and so is + /// attacker-adjacent, the same discipline `service::summarize_stderr` applies to a tool's + /// own stderr. + reason: String, + }, +} + +/// The closed set of states this loop can be in. Never a health boolean (SPEC §2.4) — each name +/// maps to a different fact an operator can act on. +/// +/// # Precedence: `ChainSourceUnavailable` > `Faulted` > `ClaimableButNotClaiming` > `Idle` > +/// `Nominal` (Defect A1, refined by Defect B3) +/// `ChainSourceUnavailable` outranks everything (no chain at all). Next, `Faulted` outranks +/// `Nominal` and `ClaimableButNotClaiming`: a cycle where a chain call returned +/// `ClaimPortError::Other(_)` is never allowed to read as healthy just because nothing else in +/// the cycle happened to be claimable. Only once no fault is live can `ClaimableButNotClaiming` +/// or `Nominal` apply. +/// +/// # F1: `ChainSourceUnavailable` is a per-cycle reading, never a latch +/// This used to be decided by comparing against `self.state` -- LAST cycle's computed reading -- +/// so once any cycle took an `Unavailable` port path, every later cycle's `compute_state` saw its +/// own prior verdict and re-asserted it forever, even after the chain came back and real claims +/// were submitting. [`ClaimStatus::chain_unavailable_this_cycle`] fixes this: reset to `false` at +/// the top of every `run_cycle`, set `true` only on a cycle that actually took the `Unavailable` +/// path this cycle. `compute_state` reads that flag, never `self.state`. +/// +/// # Defect B3: a per-distributor problem must never set the cycle-wide fault +/// `Faulted` used to also fire on [`ClaimOutcome::PayoutPuzzleHashMismatch`] — a single hostile or +/// buggy ENTRY ROW pinned the whole surface at `Faulted` indefinitely (non-terminal, so it recurred +/// every cycle) and buried the `ClaimableButNotClaiming` signal this ticket exists to produce. A +/// payout-hash mismatch is now a per-distributor COUNTED refusal (see +/// [`ClaimStatus::payout_hash_mismatches_this_cycle`] and +/// [`Self::claims_refused_payout_mismatch`]), never [`Self::fault_reported`]. `Faulted` is reserved +/// for a genuinely cycle-wide failure: discovery itself failing, or a chain-port call returning +/// `ClaimPortError::Other(_)`. +/// +/// # F8/F9/F10: `PersistedStateCorrupt` and `CadenceNotElapsed` are assigned DIRECTLY, never via +/// [`ClaimStatus::compute_state`] +/// Both are written by [`super::engine::ClaimEngine::run_cycle`] on an early return that happens +/// BEFORE any of this cycle's own numbers exist to compute a reading from — there is no +/// "claimable" or "faulted" count to rank against `compute_state`'s ladder, because no candidate +/// was ever evaluated. F9's finding was exactly this gap: an early return that assigned NEITHER a +/// direct state NOR fell through to `compute_state` left whatever `self.state` a PAST cycle +/// computed sitting there, stamped with a fresh `last_attempt_at` that made a deliberate skip read +/// as "healthy and idle". Every exit out of `run_cycle` now sets `state` one of these two ways — +/// directly here, or through `compute_state` at the bottom — never neither. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClaimLoopState { + /// No cycle has ever been attempted yet. + #[default] + Idle, + /// The chain seam reported [`super::port::ClaimPortError::Unavailable`] — see the module doc's + /// "chain seam" section. Zero cycles ran; this is the true state, not a silent no-op. + ChainSourceUnavailable, + /// F8/F10/F16, fund-safety: the persisted rewards-claim state (`RewardsClaimConfig`) was + /// unreadable, unparsable, carried a spend exceeding its own budget (F14), or carried a + /// future-dated clock (F10) — corrupt state, not a fresh peer. The engine treats the window as + /// fully spent and submits nothing THIS CYCLE. What happens next differs by cause, and both are + /// re-checked fresh on every cycle (F16), never latched: + /// - an unreadable/unparsable file or an over-budget spend needs an operator to fix or remove + /// it, and stays `PersistedStateCorrupt` until they do; + /// - a future-dated clock is SELF-HEALING — `t > now` goes false the moment real time passes + /// the stored timestamp, so the very next cycle after catch-up reads as whatever + /// `compute_state` decides (typically `Nominal`), never stuck here. + /// This state exists so that refusal is visible rather than a silent, permanent freeze that + /// reads as `Nominal` (the pre-F9 shape of the F10 defect) — or, before F16, a permanent freeze + /// of its OWN under a different name once the clock had already caught up. + PersistedStateCorrupt, + /// F9: the cadence has not yet elapsed since the last cycle that ran to completion — a + /// DELIBERATE skip, its own named condition rather than the absence of one. Without this, the + /// gate's early return left a stale `self.state` from whatever a PAST cycle computed standing + /// under this cycle's freshly-stamped `last_attempt_at`, indistinguishable from a healthy idle + /// loop (the fourth relocation of this error class — see [`super::engine::ClaimEngine`]'s + /// module doc for the first three). + CadenceNotElapsed, + /// A chain call this cycle returned `ClaimPortError::Other(_)` — a real fault, distinct from + /// `ChainSourceUnavailable` (no chain at all). `cycles` is the number of CONSECUTIVE cycles a + /// fault has now been observed on, so an operator can tell a one-off blip from a wedged loop. + /// Defect A1: this state exists precisely so a reported fault can never be laundered into + /// `Nominal` for lack of anywhere else to fall through to. + Faulted { cycles: u32 }, + /// The silent-failure case this ticket exists to prevent: fewer distributors were claimed THIS + /// CYCLE than were claimable, and no fault is live. Carries both numbers so a reader sees the + /// SIZE of the gap, not just its existence. Computed, never asserted by a writer about itself — + /// see [`ClaimStatus::compute_state`]. + /// + /// # Defect B1: a zero-test masked a partial shortfall + /// This used to fire only when `claims_submitted_this_cycle == 0` — a magnitude comparison + /// disguised as an existence check. `claimable = 10, submitted_this_cycle = 1` read `Nominal`: + /// one submission (e.g. a distributor whose fee happened to sort first) masked nine same-cycle + /// skips. Reachable precisely because [`super::engine::ClaimEngine`]'s per-cycle budget + /// (Defect C2) is the first thing that can skip a claimable distributor while another one + /// submits in the same cycle. Fixed to a true magnitude comparison: fires whenever + /// `submitted < claimable`, whatever the non-zero submitted count is. + ClaimableButNotClaiming { claimable: u32, submitted: u32 }, + /// A cycle completed, nothing above is true. + Nominal, +} + +/// The anti-silence status surface (requirement 4): what an operator or a monitor reads to know +/// whether this loop is actually doing anything, never a boolean. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimStatus { + /// F1: set when THIS cycle actually took an `Unavailable` port path -- reset to `false` at the + /// top of every `run_cycle`, never latched. See [`ClaimLoopState`]'s "F1" doc section. + pub chain_unavailable_this_cycle: bool, + pub distributors_known: u32, + pub distributors_with_own_entry: u32, + /// Computed independently of whether a submission actually happened this cycle — an + /// entry that accrued at least `payout_threshold` with a fee at or under the ceiling. THIS + /// CYCLE's snapshot, overwritten every `run_cycle`, and compared against + /// [`Self::claims_submitted_this_cycle`] (also per-cycle) — never against the cumulative + /// [`Self::claims_submitted`], which only ever grows and would let one success in the process's + /// life mask every later broken cycle (Defect A3). + pub distributors_claimable: u32, + /// Defect C3/A3: distributors whose evaluation THIS cycle returned `ClaimPortError::Other(_)`. + /// A faulted distributor is not counted in [`Self::distributors_claimable`] — a fault must never + /// silently shrink that denominator into looking healthier than it is. + pub distributors_faulted: u32, + /// Only stamped on a discovery call that actually SUCCEEDED (Defect A4) — a reader uses this as + /// an independent staleness signal, so refreshing it on a failed discovery would destroy the one + /// reading that would have exposed the fault. See [`Self::last_attempt_at`] for "the loop is + /// still alive" instead. + pub last_discovery_at: Option, + /// Only stamped on a cycle that was not a failed discovery and not all-faulted (Defect A4) — same + /// reasoning as [`Self::last_discovery_at`]. + pub last_cycle_at: Option, + /// Stamped every time `run_cycle` is invoked, success or failure — proves the loop is still + /// running even across a run of all-faulted cycles, without polluting the staleness signal the + /// other two timestamps carry (Defect A4). + pub last_attempt_at: Option, + /// Lifetime total — a useful counter, kept cumulative on purpose. NOT the predicate for + /// [`ClaimLoopState::ClaimableButNotClaiming`]; see [`Self::claims_submitted_this_cycle`]. + pub claims_submitted: u64, + /// THIS CYCLE's submission count, overwritten every `run_cycle` (Defect A3) — the correct half + /// of the `ClaimableButNotClaiming` predicate. + pub claims_submitted_this_cycle: u64, + pub claims_skipped_below_threshold: u64, + pub claims_skipped_fee_ceiling: u64, + /// Defect C2: lifetime count of claims skipped because the per-cycle aggregate fee budget was + /// already exhausted this cycle. + pub claims_skipped_cycle_budget: u64, + /// Defect E: lifetime count of claims REFUSED because the port returned an entry for a puzzle + /// hash other than this node's own — see [`ClaimOutcome::PayoutPuzzleHashMismatch`]. A + /// per-distributor counted fault (Defect B3), never [`Self::fault_reported`]. + pub claims_refused_payout_mismatch: u64, + /// Defect B3: THIS CYCLE's twin of [`Self::claims_refused_payout_mismatch`] — without it a + /// reader could not tell an ONGOING misdirection from an old, no-longer-recurring one, the same + /// per-cycle-vs-lifetime gap Defect A3 named for the other counters. + pub payout_hash_mismatches_this_cycle: u32, + /// THIS CYCLE's count of distributors observed with no entry slot (Defect B) — no longer a + /// lifetime blacklist size, because the engine no longer blacklists a launcher id permanently; + /// see [`super::engine::ClaimEngine`]'s module doc. + /// + /// # Defect R2: renamed from `terminal_no_entry_slot` + /// That name quoted SPEC §12.5 clause 1's "terminal, non-error" language to justify behaviour + /// that is deliberately non-terminal since the Defect B fix — a doc claim born false in the + /// commit that fixed the code. Renamed before #3268 publishes it over RPC. + /// + /// SPEC v0.1.3 §12.5 (the amendment R1 flagged as pending is now merged and tagged) confirms + /// this reading directly: an absent entry slot is terminal for ONE claim attempt, never for the + /// distributor, MUST NOT be cached, and MUST NOT accumulate into a permanent exclusion set — + /// this field satisfies v0.1.3 clause 6's "surfaced, not silently absorbed" requirement without + /// a tenth named [`ClaimLoopState`] variant: it is a per-cycle count, dated by + /// [`Self::last_attempt_at`] -- the field stamped unconditionally every cycle, the true + /// analogue of §2.3's `observed_at` -- and reset at the TOP of every `run_cycle` alongside the + /// other per-cycle counters, before any early return, so a stalled writer can never leave a + /// stale count sitting under a fresh timestamp (never a lifetime latch). + pub no_entry_slot_this_cycle: u32, + /// Set when a chain call THIS CYCLE returned `ClaimPortError::Other(_)` — reset at the start of + /// every `run_cycle` (Defect A1: this used to latch true for the rest of the process's life, + /// which would have permanently suppressed every other state once tripped once). + pub fault_reported: bool, + /// Consecutive cycles (including this one, if `fault_reported`) that have reported a fault — + /// resets to 0 the moment a cycle reports no fault. Surfaced via [`ClaimLoopState::Faulted`]. + pub consecutive_faulted_cycles: u32, + pub state: ClaimLoopState, +} + +impl Default for ClaimStatus { + fn default() -> Self { + ClaimStatus { + chain_unavailable_this_cycle: false, + distributors_known: 0, + distributors_with_own_entry: 0, + distributors_claimable: 0, + distributors_faulted: 0, + last_discovery_at: None, + last_cycle_at: None, + last_attempt_at: None, + claims_submitted: 0, + claims_submitted_this_cycle: 0, + claims_skipped_below_threshold: 0, + claims_skipped_fee_ceiling: 0, + claims_skipped_cycle_budget: 0, + claims_refused_payout_mismatch: 0, + payout_hash_mismatches_this_cycle: 0, + no_entry_slot_this_cycle: 0, + fault_reported: false, + consecutive_faulted_cycles: 0, + state: ClaimLoopState::Idle, + } + } +} + +impl ClaimStatus { + /// Derives [`ClaimLoopState`] from the status fields alone — a pure computation, so a test can + /// assert `ClaimableButNotClaiming` (or `Faulted`) directly against hand-built fields without + /// driving a whole engine cycle, and so a stalled writer can never manufacture a healthier state + /// than its own numbers support (SPEC §2.4's reasoning, applied to this loop's own surface). + /// + /// Precedence, most urgent first: `ChainSourceUnavailable` > `Faulted` > + /// `ClaimableButNotClaiming` > `Idle` > `Nominal`. See [`ClaimLoopState`]'s doc for why a fault + /// must never be absorbed into `Nominal` (Defect A1) and why a per-distributor fault (Defect B3) + /// must never set it. + /// + /// # F1: reads `chain_unavailable_this_cycle`, never `self.state` + /// The old guard compared against `self.state` -- last cycle's OWN computed output -- which + /// made `ChainSourceUnavailable` a process-lifetime latch (see [`ClaimLoopState`]'s "F1" doc + /// section). `chain_unavailable_this_cycle` is reset every cycle, so this reading is live. + #[must_use] + pub fn compute_state(&self) -> ClaimLoopState { + if self.chain_unavailable_this_cycle { + return ClaimLoopState::ChainSourceUnavailable; + } + if self.last_attempt_at.is_none() && self.last_cycle_at.is_none() { + return ClaimLoopState::Idle; + } + if self.fault_reported { + return ClaimLoopState::Faulted { + cycles: self.consecutive_faulted_cycles.max(1), + }; + } + // Defect B1: a magnitude comparison, not a zero-test -- `claims_submitted_this_cycle < 10` + // fires just as much when 1 of 10 claimable was submitted as when 0 were; a partial + // shortfall must never be masked by whichever claims did go through. + // + // F2: `payout_hash_mismatches_this_cycle` folds into the RIGHT side of the comparison. A + // mismatching distributor never enters `eligible`, so it is counted in NEITHER + // `claims_submitted_this_cycle` NOR `distributors_claimable` -- the shortfall was in + // neither term of this comparison. All-K-mismatching used to read `submitted = 0, + // claimable = 0` -> healthy. An ongoing mismatch is a real per-cycle shortfall exactly like + // an unmet `claimable`, so it belongs in the same predicate, never a separate signal + // nothing reads. + let shortfall_denominator = u64::from(self.distributors_claimable) + + u64::from(self.payout_hash_mismatches_this_cycle); + if self.claims_submitted_this_cycle < shortfall_denominator { + // F13: report the SAME quantity the predicate above just used, not the un-folded + // `distributors_claimable` alone. Before this fix, all-K-mismatching produced + // `ClaimableButNotClaiming { claimable: 0, submitted: 0 }` -- the name was right (F2 + // already folded mismatches into firing the state at all) but the payload said + // nothing was wrong, because it reported the term the mismatches were never counted + // in. The payload must carry the full shortfall the name is claiming, or it is a + // state whose numbers contradict its own name. + return ClaimLoopState::ClaimableButNotClaiming { + claimable: u32::try_from(shortfall_denominator).unwrap_or(u32::MAX), + submitted: u32::try_from(self.claims_submitted_this_cycle).unwrap_or(u32::MAX), + }; + } + ClaimLoopState::Nominal + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claimable_but_not_claiming_is_computed_from_fields_alone() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted_this_cycle: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 3, + submitted: 0 + } + ); + } + + /// Defect B1 regression: a magnitude comparison, not a zero-test. `claimable = 10, + /// submitted_this_cycle = 1` used to read `Nominal` because the old predicate only checked + /// `submitted_this_cycle == 0` -- one submission masked nine same-cycle skips. + #[test] + fn a_partial_shortfall_is_claimable_but_not_claiming_not_nominal() { + let status = ClaimStatus { + distributors_claimable: 10, + claims_submitted_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 10, + submitted: 1 + }, + "1 of 10 claimable submitted must still read the shortfall, never Nominal" + ); + } + + /// Defect B1 regression: the other half of the fix -- every claimable distributor submitted + /// must read `Nominal`, not a false-positive shortfall. + #[test] + fn claiming_every_claimable_distributor_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 10, + claims_submitted_this_cycle: 10, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + /// Defect A1/A2: this test used to assert `Nominal` here, encoding the bug (a reported fault + /// was silently absorbed into the healthy state) as intended behaviour. Inverted per the fix + /// brief: a fault must surface its own named state, never masquerade as either + /// `ClaimableButNotClaiming` or `Nominal`. + #[test] + fn a_reported_fault_surfaces_as_faulted_not_nominal() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted_this_cycle: 0, + fault_reported: true, + consecutive_faulted_cycles: 1, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::Faulted { cycles: 1 } + ); + } + + /// Defect A3 regression: `distributors_claimable` is a per-cycle snapshot and + /// `claims_submitted` (cumulative) only ever grows, so comparing the two lets one success in + /// the process's lifetime mask every later cycle where the submit path has since broken. The + /// fix compares against `claims_submitted_this_cycle` instead. + #[test] + fn a_lifetime_submission_does_not_mask_a_later_cycle_that_submits_nothing() { + let status = ClaimStatus { + distributors_claimable: 1, + claims_submitted: 7, // non-zero lifetime total from an earlier successful cycle + claims_submitted_this_cycle: 0, // but THIS cycle submitted nothing + fault_reported: false, + last_cycle_at: Some(2), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + } + ); + } + + #[test] + fn nothing_claimable_and_nothing_submitted_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted_this_cycle: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + #[test] + fn chain_source_unavailable_wins_over_every_other_reading() { + let status = ClaimStatus { + distributors_claimable: 5, + claims_submitted: 0, + fault_reported: false, + last_cycle_at: Some(1), + chain_unavailable_this_cycle: true, + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ChainSourceUnavailable + ); + } + + /// F1 regression at the `compute_state` level: a PAST cycle's `ChainSourceUnavailable` must + /// never leak into THIS cycle's reading once `chain_unavailable_this_cycle` is false again -- + /// proving the fix reads the per-cycle flag, never `self.state` (which this struct literal + /// deliberately still carries as `ChainSourceUnavailable`, simulating what a stale `self.state` + /// would look like if the old guard were still in place). + #[test] + fn a_past_cycles_chain_unavailable_state_does_not_latch_the_next_computation() { + let status = ClaimStatus { + distributors_claimable: 1, + claims_submitted_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(2), + chain_unavailable_this_cycle: false, + state: ClaimLoopState::ChainSourceUnavailable, + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::Nominal, + "chain_unavailable_this_cycle is false this cycle -- a stale self.state must not win" + ); + } + + /// Defect B3 regression: a per-distributor payout-hash mismatch count, with no cycle-wide + /// `fault_reported`, must read the `ClaimableButNotClaiming` shortfall it actually represents, + /// never `Faulted` -- `engine.rs` is the one that decides `fault_reported`, but this proves the + /// state computation itself no longer has any path from "a mismatch happened" to `Faulted`. + #[test] + fn a_payout_mismatch_count_alone_does_not_force_faulted() { + let status = ClaimStatus { + distributors_claimable: 2, + claims_submitted_this_cycle: 1, + payout_hash_mismatches_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + // F13: `claimable` is now the FOLDED shortfall (2 claimable + 1 mismatch = 3), not + // the un-folded `distributors_claimable` alone -- see the F13 regression below for + // the case (all-K-mismatching) that made the un-folded reading actively misleading. + ClaimLoopState::ClaimableButNotClaiming { + claimable: 3, + submitted: 1 + } + ); + } + + /// F13 regression: all-K-mismatching must report the shortfall it actually represents, not a + /// payload that contradicts its own state name. Before the fix, this read `claimable: 0, + /// submitted: 0` -- a name saying something is wrong next to numbers saying nothing is. Must + /// go red with only the `claimable: shortfall_denominator` fix reverted to + /// `claimable: self.distributors_claimable`. + #[test] + fn all_k_mismatching_reports_the_folded_shortfall_not_zero() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted_this_cycle: 0, + payout_hash_mismatches_this_cycle: 4, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 4, + submitted: 0 + }, + "the payload must carry the same shortfall the predicate fired on, never 0" + ); + } +} From 4824621e863f3f8374d892358a7cc4abf7be558b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 23:34:51 -0700 Subject: [PATCH 11/12] chore(release): v0.256.0 Bump dig-node-service to v0.256.0 for release. This release includes: - Reward distributor prover loop (#593) - Peer reward claim loop (#594) - Reward prover status RPC (#595) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 058fad93..0462e8dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.255.0" +version = "0.256.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 1d44df4e..a694e447 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ edition = "2021" # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.255.0" +version = "0.256.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard. From f9e4018538c4d79a0127c8ab464bcd5ee6a277cd Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 23:55:05 -0700 Subject: [PATCH 12/12] ci: scope commitlint to PR-introduced commits, fix title suffix check A develop -> main release-cut PR was linting main..develop, the full inherited commit range, instead of just the commits it introduces. Every commit in that range was already linted at its own PR while it was still mutable; re-linting it at cut time adds no information and cannot be satisfied once merged (gitlinks and rev-pinned deps make history immutable). Use commitDepth: 1 on a main-base PR; keep the full-range lint unchanged for develop-base PRs, where authors can still fix the commits. Also fix the PR-title lint's blind spot: GitHub's squash merge lands "$PR_TITLE (#$PR_NUMBER)" as the commit subject, about eight characters longer than the title alone, so a title that passes header-max-length can still produce an over-limit commit subject that nothing checks. Lint the exact string that will land. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/commitlint.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 9f1d6884..2d0588a7 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -28,7 +28,20 @@ jobs: with: fetch-depth: 0 - - name: Lint PR commits + # A main-base PR is a develop -> main release cut: every commit in main..develop + # was already linted at its own PR, while that commit was still mutable and its + # author could still fix it. Re-linting the whole inherited range at cut time adds + # no information and cannot be satisfied (the commits are now immutable, pinned by + # gitlinks and rev-pinned deps). Lint only what THIS PR introduces: its own commit(s). + - name: Lint PR commits (release cut — this PR's own commits only) + if: github.base_ref == 'main' + uses: wagoid/commitlint-github-action@v6 + with: + configFile: commitlint.config.mjs + commitDepth: 1 + + - name: Lint PR commits (feature branch — full PR range) + if: github.base_ref != 'main' uses: wagoid/commitlint-github-action@v6 with: configFile: commitlint.config.mjs @@ -37,9 +50,13 @@ jobs: if: github.event_name == 'pull_request' env: PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail # Install both @commitlint/cli AND the extended shareable config so the # `extends: ['@commitlint/config-conventional']` in commitlint.config.mjs resolves. - echo "$PR_TITLE" | npx --yes -p @commitlint/cli -p @commitlint/config-conventional \ + # GitHub's squash merge lands "$PR_TITLE (#$PR_NUMBER)" as the commit subject — + # lint that exact string, not the title alone, or a title within the length + # limit can still produce an over-limit commit subject once merged. + printf '%s (#%s)\n' "$PR_TITLE" "$PR_NUMBER" | npx --yes -p @commitlint/cli -p @commitlint/config-conventional \ commitlint --config commitlint.config.mjs