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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 56f398d1641000ebb07974c02f7804870701a038 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:03:06 -0700 Subject: [PATCH 11/29] feat(rewards): chain port + listRewardDistributors (unit 2) (#604) * chore: open lane for #3269 (unit 2 -- rewards chain port + listRewardDistributors) * chore(rewards): add dig-rewards-coin 0.2 dep; record blocked-reader finding dig-rewards-coin 0.2.0 is published but ships no chain reader (its own state.rs module doc: SPEC 12.1's read_distributor is withheld pending DIG-Network/dig_ecosystem#3267). Separately, no registry in this codebase records which distributors this node funds. A "real" RewardsChainPort adapter over 0.2.0 therefore has no honest way to answer any of the four trait methods with live data yet -- reimplementing read_distributor or inventing a funded-distributor registry would be exactly the unreviewed money-shape guess kernel invariant 6 says to escalate instead of build. UnavailableChainPort remains the only production adapter; port.rs records the finding for the next unit. Refs #3269 * docs(rewards): revert dep add, name both blockers with evidence in port.rs Per L1 direction: an unused dig-rewards-coin dep with no consumer is inert weight and would want whichever version ships the reader (0.3.0+, PR#6 open against DIG-Network/dig-rewards-coin), not 0.2 -- so it's reverted here and belongs in the unit that actually consumes it. Expanded the port.rs module doc to name both blockers explicitly with what was read (state.rs:1-31, #3267, the open reader PR) and the negative grep that found no funder-ownership registry anywhere in the tree, plus why serving dig.listRewardDistributors through UnavailableChainPort was considered and rejected (false capability signal; the exact "dispatch surface with no function behind it" pattern dig-node#593 was the last PR allowed to land on). No RewardsChainPort adapter, no Node wiring, no dispatch arm -- all three reward methods stay -32601 pending #3267 and a funder-ownership registry (parallel tickets, both required). Refs #3269 --- DEVELOPMENT_LOG.md | 1 + crates/dig-node-core/src/rewards/port.rs | 66 +++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 53cb6ba6..087610ef 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1647,3 +1647,4 @@ Two things worth carrying forward: 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/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index d9844fe9..7eb2d5a0 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -1,12 +1,64 @@ //! 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. +//! `dig-rewards-coin` was SPEC-only as of the tag this lane first read it: `src/lib.rs` was a +//! documented placeholder and `pub mod distributor {}` was empty. 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 the driver landing), tested with an in-memory fake, and +//! the production adapter reports [`ChainPortError::Unavailable`] and runs no cycles. See +//! [`UnavailableChainPort`] for that adapter. +//! +//! # dig_ecosystem#3269 unit 2 — the driver shipped, but still with no reader (blocking finding) +//! +//! `dig-rewards-coin` 0.2.0 is published and adds real types — `DistributorSnapshot` / +//! `DistributorSlots` (its `state` module) plus `clawback`, `comment`, `constants`, `eligibility`, +//! `entries`, `epoch`, `fund`, `launch`, `payout`. **It still ships no chain reader — blocker 1.** +//! 0.2.0's own `state.rs:1-31` module doc says so directly: SPEC §12.1's `read_distributor` "does not +//! publish one, deliberately" — the implementation that existed applied +//! `RewardDistributor::from_parent_spend` to the eve coin's spend (the launch inner puzzle) instead +//! of `from_eve_coin_spend`, so every read reported `Malformed`; the correct hop additionally needs +//! `reserve_parent_id`/`reserve_lineage_proof` provenance a reader starting from a launcher id cannot +//! currently discover. That is tracked as real design work at +//! — as of this unit, open, with a PR up +//! (`DIG-Network/dig-rewards-coin#6`, `feat/3267-chain-reader`, +950/-80, targeting `0.3.0`) — and +//! 0.2.0's own doc states the rule the future reader must honour: "every `ChainSource` error MUST +//! become `RewardsError::ChainUnavailable` … a distributor whose read failed MUST NOT render as 'no +//! entries' or 'nothing accrued'". Read this adapter against `0.3.0`'s actual reader shape when it +//! ships, not against this description. +//! +//! **Blocker 2, independent of #3267:** nothing in this codebase today records which distributors +//! this node funds. `funded_distributors` (below) needs that identity set as its starting point — +//! there is no chain-wide "list every distributor and filter to mine" call this crate can make. The +//! only adjacent registry is the CLAIM side's `ClaimChainPort::discover_distributors` in +//! `dig-node-service`'s `rewards_claim::port` — a **different trait**, filtering by mirror-admission +//! (which distributors this node might claim FROM), not by funder ownership (which distributors this +//! node funds); it is not a substitute. A repo-wide search for a funder-ownership registry — +//! `grep -rln "funded_launcher_ids\|FundedDistributor\|reward_distributor_registry\|create_distributor\|launch_distributor" crates/ --include=*.rs` +//! — returned **no matches** as of this unit's tip (worth re-running before assuming this is still +//! true; a negative search is a claim about a point in time, not a permanent fact). No launch flow, no +//! config, no persisted launcher-id list exists in this crate or in `dig-node-service` today. Tracked +//! as a separate ticket, parallel to #3267 (not downstream of it): a working reader tells a caller HOW +//! to read one distributor; it does not tell the caller WHICH launcher ids are its own. Both must land +//! before any of `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` / +//! `dig.listRewardDistributors`'s `funded` half can answer honestly. +//! +//! So a "real" `RewardsChainPort` adapter over 0.2.0 cannot honestly answer ANY of the four trait +//! methods with live chain data yet: `funded_distributors` has no identity source, and +//! `distributor_state`/`submit_entry_writes`/`spend_new_epoch` all need the withheld reader (a spend +//! needs the live singleton coin `read_distributor` would supply). Writing one anyway — either by +//! reimplementing `read_distributor` myself or by inventing a funded-distributor registry with no +//! writer — would be exactly the kind of restated, unreviewed money-shape work SPEC §0.1 clause 1 and +//! this crate's own withholding of a broken reader argue against, and is the shape fork this ticket's +//! kernel invariant 6 says to escalate rather than guess. Escalated to the L1, and settled: no new +//! adapter and no dispatch arm land until a reader (0.3.0+) and the funder-ownership registry both +//! exist. **`dig.listRewardDistributors` stays `-32601` deliberately** — serving it through +//! `UnavailableChainPort` was considered and rejected: it would be a false capability signal (a +//! feature-probe or `rpc.discover` reading the method as implemented when it always errors) and the +//! exact "dispatch surface with no function behind it" pattern DIG-Network/dig-node#593 was the last +//! PR allowed to land on. `UnavailableChainPort` remains the only production adapter for now — still +//! correct, since every real call would fail for one of the two reasons above regardless. No +//! `dig-rewards-coin` dependency is added by this unit: an unused dependency with no consumer is +//! inert weight and would want whichever version ships the reader (0.3.0+), not 0.2 — add it in the +//! unit that actually consumes it. use super::admission::AdmittedPeer; use async_trait::async_trait; From 90fff0ca9d4d55c2886d089b5c8742b99eec9e06 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:13:23 -0700 Subject: [PATCH 12/29] feat(rewards): durable funder-ownership registry (identity only) (#606) * feat(rewards): durable funder-ownership registry (identity only) Records WHICH reward distributors this node funds -- launcher id plus the store id when the funding act knew it -- and nothing else. No amount can be recorded: every money figure here is chain-derived and goes stale, and dig_ecosystem#3286's wrapping u64 share multiply means a figure crossing this boundary can already be wrong. Durable storage would make it permanent. Persistence mirrors rewards_claim::engine::ClaimEngine: an optional state directory (absent = inert, so tests and default builds need no disk), atomic write, and a corrupt record is never overwritten. The set is never cached on the registry -- every read re-reads the file -- so no transient state lives on the struct across calls (the engine's F16/F18 discipline). The read outcome is closed and distinguishes funds-nothing from every unknown: NotConfigured (no state dir / dir missing / nothing written yet), PersistedStateCorrupt and IoFailed. A corrupt record is quarantined by COPY and left in place, so the next read is corrupt too rather than decaying into an empty list -- SPEC 2.4 clause 1 in the place it costs most, since an empty dig.listRewardDistributors tells an operator it funds no distributors. Node carries it in a OnceLock slot with pub(crate) accessors, mirroring mirror_pointers and reward_prover_statuses. Nothing installs it in production yet: no dig-node code funds a distributor, and the startup wiring belongs to dig_ecosystem#3268, so the slot is marked the same way register_reward_prover_status is. Refs #3285 Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): drop a duplicated funded_distributors initializer Two test-only `Node` literals got the slot twice (E0062), because the inserted line's own indentation made the wider-indented site match twice. Refs #3285 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 126 +++ crates/dig-node-core/src/rewards/funded.rs | 848 +++++++++++++++++++++ crates/dig-node-core/src/rewards/mod.rs | 1 + 3 files changed, 975 insertions(+) create mode 100644 crates/dig-node-core/src/rewards/funded.rs diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index d582f173..50e211a7 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -576,6 +576,20 @@ pub struct Node { /// [`Node::register_reward_prover_status`], the same read starts returning it with no /// dispatch-side change. reward_prover_statuses: Arc>>, + /// This node's durable record of WHICH reward distributors it funds + /// (dig_ecosystem#3285) — identity only, never an amount; see + /// [`rewards::funded`]'s module doc. + /// + /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is + /// one: the FFI/browser path has no state directory and must keep constructing a `Node` + /// without one. Nothing installs it in production yet — nothing in dig-node funds a + /// distributor today (`rewards::port`'s module doc, blocker 2), and the startup wiring that + /// would call [`Node::install_funded_distributor_registry`] with the node's state directory + /// belongs to dig_ecosystem#3268. Until then the slot stays empty, and + /// [`Node::funded_distributors_read`] answers + /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`] — UNKNOWN, deliberately never an + /// empty funded set. + funded_distributors: OnceLock, } impl Node { @@ -607,6 +621,47 @@ impl Node { .map(rewards::state::StatusHandle::snapshot) .collect() } + + /// Install this node's funder-ownership registry (dig_ecosystem#3285), once. Returns `false` + /// if a registry is already installed, in which case NOTHING changed — a second install must + /// not be able to swap a live registry for an inert one behind a caller's back. + /// + /// Called from tests today: the startup path that would install a real one lives in + /// dig_ecosystem#3268's files, so clippy's non-test lib target sees no production caller yet. + /// `allow(dead_code)` stands in for that missing caller — remove it when #3268 wires the call. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn install_funded_distributor_registry( + &self, + registry: rewards::funded::FundedDistributorRegistry, + ) -> bool { + self.funded_distributors.set(registry).is_ok() + } + + /// The installed funder-ownership registry, or `None` when nothing has installed one. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn funded_distributor_registry( + &self, + ) -> Option<&rewards::funded::FundedDistributorRegistry> { + self.funded_distributors.get() + } + + /// Read which distributors this node funds, through the installed registry. + /// + /// With no registry installed the answer is + /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`], the same UNKNOWN an inert + /// registry reports — never [`rewards::funded::FundedDistributorsRead::FundsNothing`]. A + /// caller rendering `dig.listRewardDistributors` must distinguish the two: an unknown rendered + /// as `[]` tells an operator it funds nothing when it may fund plenty + /// (`dig-rewards-coin` SPEC §2.4 clause 1). + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn funded_distributors_read(&self) -> rewards::funded::FundedDistributorsRead { + match self.funded_distributor_registry() { + Some(registry) => registry.read(), + None => rewards::funded::FundedDistributorsRead::NotConfigured( + rewards::funded::NotConfiguredReason::NoStateDirectory, + ), + } + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4855,6 +4910,7 @@ impl Node { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }) } @@ -5195,6 +5251,7 @@ pub(crate) mod test_support { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }; (Arc::new(node), td) } @@ -5953,6 +6010,63 @@ mod tests { ); } + /// dig_ecosystem#3285: a node with no funder registry installed — which is EVERY production + /// node until #3268 wires one — must read UNKNOWN, never an empty funded set. + /// **Catches:** a `funded_distributors_read` that defaults to `FundsNothing`, or a `Vec`/ + /// `Option` return that a caller would render as `[]`. + #[test] + fn a_node_with_no_installed_funder_registry_reads_not_configured() { + let (node, _td) = test_node(None); + + assert!( + node.funded_distributor_registry().is_none(), + "nothing installs the registry in production yet (see the field doc)" + ); + assert_eq!( + node.funded_distributors_read(), + crate::rewards::funded::FundedDistributorsRead::NotConfigured( + crate::rewards::funded::NotConfiguredReason::NoStateDirectory + ), + "a node with no registry installed must read UNKNOWN, never an empty funded set" + ); + } + + /// dig_ecosystem#3285: the node's read goes through the installed registry all the way to + /// disk, and a second install cannot swap a live registry for an inert one. + /// **Catches:** an accessor reading some other (empty) source, and a `set`-ignoring install. + #[test] + fn a_node_reads_through_the_installed_funder_registry() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x21u8; 32], + store_id: Some([0x22u8; 32]), + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + + assert!( + node.install_funded_distributor_registry(registry), + "the first install must take" + ); + + assert_eq!( + node.funded_distributors_read(), + crate::rewards::funded::FundedDistributorsRead::Funded(vec![funded]), + "the node's read must go through the installed registry to disk" + ); + assert!( + !node.install_funded_distributor_registry( + crate::rewards::funded::FundedDistributorRegistry::disabled() + ), + "a second install must be refused rather than silently replace the live registry" + ); + } + fn test_node(identity_seed: Option<[u8; 32]>) -> (Node, tempfile::TempDir) { test_node_with_resolver(identity_seed, MockResolver::always(Ok(None))) } @@ -5991,6 +6105,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }; (node, td) } @@ -6126,6 +6241,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }; // Missing before the pull. @@ -6195,6 +6311,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6295,6 +6412,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6373,6 +6491,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -9854,6 +9973,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), }; let before = handle_rpc( @@ -17010,6 +17130,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), ..node }; // A holder for this EXACT content is known via the DHT. @@ -17062,6 +17183,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -17113,6 +17235,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), ..node }; @@ -17146,6 +17269,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17188,6 +17312,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17232,6 +17357,7 @@ mod tests { node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + funded_distributors: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/rewards/funded.rs b/crates/dig-node-core/src/rewards/funded.rs new file mode 100644 index 00000000..8f4ea39e --- /dev/null +++ b/crates/dig-node-core/src/rewards/funded.rs @@ -0,0 +1,848 @@ +//! The funder-ownership registry: WHICH reward distributors this node funds (dig_ecosystem#3285). +//! +//! [`port`](super::port)'s module doc names this as blocker 2 of two: +//! `RewardsChainPort::funded_distributors` needs an identity set to start from, because there is no +//! chain-wide "list every distributor and filter to mine" call. This module is that identity set, +//! and nothing else. +//! +//! # Identity only — never an amount +//! +//! A record here is a launcher id plus, when the funding act knew it, the store id it rewards. It +//! carries no reserve balance, no accrued figure and no paid-out total, and there is deliberately +//! nowhere in [`FundedDistributor`] to put one, for two independent reasons: +//! +//! 1. Every money figure in this subsystem is chain-derived (see +//! [`port::DistributorChainState`](super::port::DistributorChainState), which is read, never +//! stored) and goes stale the moment the chain moves. A persisted amount is a wrong number with +//! a convincing timestamp. +//! 2. dig_ecosystem#3286: upstream `chia-sdk-driver`'s `withdraw_incentives` multiplies +//! `rewards * withdrawal_share_bps` in `u64` and wraps in release builds, so a figure crossing +//! this boundary can already be wrong. Durable storage would make such a figure permanent. +//! +//! The store id is identity; the merkle ROOT that +//! [`port::DistributorRef`](super::port::DistributorRef) also carries is not — it names one +//! generation of a store and is superseded on every update, so persisting it would be persisting a +//! value guaranteed to go stale. A caller that needs the current root reads it from the chain. +//! +//! # Persistence mirrors the claim engine, and holds no state between calls +//! +//! An optional directory, exactly like `rewards_claim::engine::ClaimEngine`'s +//! `fee_window_state_dir`: `None` keeps this registry inert, so tests and every default build need +//! no disk. The set itself is NEVER cached on [`FundedDistributorRegistry`] — every read re-reads +//! the file and every write is a read-modify-write of it, the discipline that engine's F16/F18 +//! notes arrived at after one mechanism (a per-call value held as process-lifetime state) produced +//! three separate defects. With no field to go stale, an operator who repairs the file underneath +//! a running node is observed on the very next read rather than only on the next restart. +//! +//! # A corrupt, missing or unreadable record MUST NOT read as "funds nothing" +//! +//! `dig-rewards-coin`'s SPEC §2.4 clause 1 ("absence is not silence") applies here in the place it +//! costs most: `dig.listRewardDistributors` is how an operator sees which distributors it funds, so +//! rendering a failed read as `[]` would make a funded distributor invisible and tell the operator +//! it funds none. [`FundedDistributorsRead`] therefore names the legitimate empty case +//! ([`FundedDistributorsRead::FundsNothing`]) separately from every not-an-answer case, the same +//! way `rewards_claim::types::ClaimOutcome` names its legitimate not-paid cases separately from +//! `Faulted`. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::port::Bytes32; + +/// The record file, inside the registry's state directory. +pub const FUNDED_DISTRIBUTORS_FILE: &str = "funded-distributors.json"; + +/// Where a corrupt record is COPIED for the operator, next to the record itself. A copy, not a +/// move: see [`FundedDistributorRegistry::read`] for why moving it aside would recreate the exact +/// "a funded distributor became invisible" failure this module exists to prevent. +pub const FUNDED_DISTRIBUTORS_QUARANTINE_FILE: &str = "funded-distributors.json.corrupt"; + +/// On-disk format version. A record written by a different version is CORRUPT to this one — +/// unreadable is unreadable, and guessing at a format we do not know is how a wrong answer gets +/// rendered confidently. +const RECORD_FORMAT_VERSION: u32 = 1; + +/// One distributor this node funds — identity only. See the module doc for why there is nowhere +/// here to record an amount. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundedDistributor { + /// The distributor singleton's launcher id: the one identifier that never changes. + pub launcher_id: Bytes32, + /// The store this distributor rewards, when the funding act knew it. `None` means "not + /// recorded", never "no store". + pub store_id: Option, +} + +/// Why a read could not answer with a set. Never a stand-in for "the set is empty". +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotConfiguredReason { + /// The registry has no state directory, so persistence is off and this node has no record to + /// consult. The ordinary state of a default build and of every test that wants no disk. + NoStateDirectory, + /// A state directory is configured but does not exist on disk. The answer lives somewhere this + /// node cannot see, which is unknown, not empty. + StateDirectoryMissing, + /// The state directory exists and holds no record file: nothing has ever been recorded through + /// this registry. Distinct from [`FundedDistributorsRead::FundsNothing`], which is a record + /// that exists and says "none". + NoRecordWritten, +} + +/// The closed outcome of reading the registry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FundedDistributorsRead { + /// A record exists and names these distributors, in recorded order. Never empty — an empty + /// record is [`Self::FundsNothing`]. + Funded(Vec), + /// A record exists, is intact, and names no distributor: this node has funded none. The one + /// outcome a caller may render as an empty list. + FundsNothing, + /// No record could be consulted. The answer is UNKNOWN. + NotConfigured(NotConfiguredReason), + /// The record exists and could not be trusted — unparseable, malformed, or written by a format + /// version this build does not know. `quarantined_to` is where its bytes were copied for the + /// operator, or `None` if even the copy failed (which changes nothing about the verdict). + PersistedStateCorrupt { + path: PathBuf, + quarantined_to: Option, + }, + /// The record could not be read at all. + IoFailed { path: PathBuf, error: String }, +} + +impl FundedDistributorsRead { + /// The funded set when — and only when — this read actually determined one: `Some(&[])` for + /// [`Self::FundsNothing`], `None` for every outcome that did not answer. + /// + /// A caller that renders a list MUST distinguish `None` from `Some(&[])`: `None` is "unknown", + /// and rendering it as an empty list is the failure this module's doc opens with. + #[cfg_attr(not(test), allow(dead_code))] + #[must_use] + pub fn determined(&self) -> Option<&[FundedDistributor]> { + match self { + Self::Funded(set) => Some(set), + Self::FundsNothing => Some(&[]), + Self::NotConfigured(_) | Self::PersistedStateCorrupt { .. } | Self::IoFailed { .. } => { + None + } + } + } +} + +/// The closed outcome of recording a funding act. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordOutcome { + /// A launcher id this record had not seen was appended. + Recorded, + /// This launcher id was already recorded with the same identity; the file is unchanged. + AlreadyRecorded, + /// This launcher id was already recorded and its `store_id` was learned (`None` -> `Some`). + /// Refining identity is allowed; contradicting it is [`Self::IdentityConflict`]. + IdentityRefined, + /// The record already names this launcher id with a DIFFERENT store id. One of the two is wrong + /// and this registry cannot tell which, so it overwrites neither. + IdentityConflict { recorded: Bytes32, offered: Bytes32 }, + /// Persistence is off: nothing was recorded and nothing will be readable later. + NotConfigured(NotConfiguredReason), + /// The record on disk is corrupt, so it was NOT overwritten — the same refusal + /// `rewards_claim::engine::ClaimEngine::persist_fee_window` makes, for the same reason: writing + /// over corruption produces a file that looks clean and has silently lost whatever it held. + PersistedStateCorrupt { + path: PathBuf, + quarantined_to: Option, + }, + /// The record could not be read or written. + IoFailed { path: PathBuf, error: String }, +} + +/// The durable record of which distributors this node funds. +/// +/// Holds a path and nothing else — see the module doc's "holds no state between calls". +#[derive(Debug, Clone)] +pub struct FundedDistributorRegistry { + /// `None` = persistence off; every read answers [`NotConfiguredReason::NoStateDirectory`] and + /// every write records nothing. + state_dir: Option, +} + +impl FundedDistributorRegistry { + /// A registry with persistence off. The default for any build with no state directory to give + /// it, including the FFI/browser path. + #[must_use] + pub fn disabled() -> Self { + Self { state_dir: None } + } + + /// A registry persisting to `dir`. The directory is created on first write, not here, so + /// constructing one is infallible and side-effect free. + #[cfg_attr(not(test), allow(dead_code))] + #[must_use] + pub fn with_state_dir(dir: &Path) -> Self { + Self { + state_dir: Some(dir.to_path_buf()), + } + } + + /// The record file path, when persistence is on. + fn record_path(&self) -> Option { + self.state_dir + .as_ref() + .map(|dir| dir.join(FUNDED_DISTRIBUTORS_FILE)) + } + + /// Read the funded set fresh from disk. + /// + /// # A corrupt record is quarantined by COPY, and stays where it is + /// Moving the corrupt file aside would leave the next read finding no file at all — i.e. + /// reporting [`NotConfiguredReason::NoRecordWritten`] and, one honest-looking render later, an + /// empty list. So the bytes are copied to [`FUNDED_DISTRIBUTORS_QUARANTINE_FILE`] for the + /// operator and the original is left in place, which keeps every subsequent read reporting + /// [`FundedDistributorsRead::PersistedStateCorrupt`] until a human resolves it. That is the + /// same "leave the corrupt file exactly as it is on disk" posture + /// `rewards_claim::engine::ClaimEngine::persist_fee_window` takes, plus a forensic copy. + #[cfg_attr(not(test), allow(dead_code))] + #[must_use] + pub fn read(&self) -> FundedDistributorsRead { + let Some(path) = self.record_path() else { + return FundedDistributorsRead::NotConfigured(NotConfiguredReason::NoStateDirectory); + }; + match self.load(&path) { + Ok(Some(record)) => match record.into_distributors() { + Ok(set) if set.is_empty() => FundedDistributorsRead::FundsNothing, + Ok(set) => FundedDistributorsRead::Funded(set), + Err(reason) => self.report_corrupt(&path, &reason), + }, + Ok(None) => FundedDistributorsRead::NotConfigured(self.absent_record_reason()), + Err(LoadFailure::Corrupt(reason)) => self.report_corrupt(&path, &reason), + Err(LoadFailure::Io(error)) => FundedDistributorsRead::IoFailed { path, error }, + } + } + + /// Record that this node funds `distributor`, creating the state directory and the record file + /// if they do not exist. Idempotent per launcher id. + #[cfg_attr(not(test), allow(dead_code))] + pub fn record(&self, distributor: &FundedDistributor) -> RecordOutcome { + let Some(path) = self.record_path() else { + return RecordOutcome::NotConfigured(NotConfiguredReason::NoStateDirectory); + }; + let mut set = match self.load(&path) { + Ok(Some(record)) => match record.into_distributors() { + Ok(set) => set, + Err(reason) => return self.refuse_corrupt(&path, &reason), + }, + Ok(None) => Vec::new(), + Err(LoadFailure::Corrupt(reason)) => return self.refuse_corrupt(&path, &reason), + Err(LoadFailure::Io(error)) => return RecordOutcome::IoFailed { path, error }, + }; + + let outcome = match merge(&mut set, distributor) { + Ok(outcome) => outcome, + Err(conflict) => return conflict, + }; + if matches!(outcome, RecordOutcome::AlreadyRecorded) { + return outcome; + } + match self.save(&path, &set) { + Ok(()) => outcome, + Err(error) => RecordOutcome::IoFailed { path, error }, + } + } + + /// Whether an absent record file means "directory gone" or "nothing recorded yet" — two + /// different unknowns, and neither of them "funds nothing". + fn absent_record_reason(&self) -> NotConfiguredReason { + match &self.state_dir { + None => NotConfiguredReason::NoStateDirectory, + Some(dir) if !dir.is_dir() => NotConfiguredReason::StateDirectoryMissing, + Some(_) => NotConfiguredReason::NoRecordWritten, + } + } + + /// Read and parse the record. `Ok(None)` = no record file (the directory may or may not exist; + /// [`Self::absent_record_reason`] tells those apart). + fn load(&self, path: &Path) -> Result, LoadFailure> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(LoadFailure::Io(e.to_string())), + }; + let record: PersistedRecord = + serde_json::from_str(&text).map_err(|e| LoadFailure::Corrupt(e.to_string()))?; + if record.version != RECORD_FORMAT_VERSION { + return Err(LoadFailure::Corrupt(format!( + "record format version {} is not {RECORD_FORMAT_VERSION}", + record.version + ))); + } + Ok(Some(record)) + } + + /// Write the set ATOMICALLY: to a temp file beside the record, then renamed over it, so a crash + /// mid-write cannot leave a torn file the next read would have to call corrupt. The same + /// pattern `rewards_claim::config::RewardsClaimConfig::save_to` uses for the claim side's + /// persisted state. + fn save(&self, path: &Path, set: &[FundedDistributor]) -> Result<(), String> { + let dir = path.parent().ok_or_else(|| { + format!( + "the funded-distributor record path {} has no parent directory", + path.display() + ) + })?; + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let record = PersistedRecord::from_distributors(set); + let text = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text.as_bytes()).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, path).map_err(|e| e.to_string()) + } + + /// Log, quarantine-copy, and report a corrupt record to a READER. + fn report_corrupt(&self, path: &Path, reason: &str) -> FundedDistributorsRead { + let quarantined_to = self.quarantine(path); + tracing::error!( + path = %path.display(), + reason, + quarantined_to = ?quarantined_to, + "the funded-distributor record is corrupt; reporting corrupt rather than an empty \ + funded set" + ); + FundedDistributorsRead::PersistedStateCorrupt { + path: path.to_path_buf(), + quarantined_to, + } + } + + /// Log, quarantine-copy, and report a corrupt record to a WRITER, which leaves the file alone. + fn refuse_corrupt(&self, path: &Path, reason: &str) -> RecordOutcome { + let quarantined_to = self.quarantine(path); + tracing::error!( + path = %path.display(), + reason, + quarantined_to = ?quarantined_to, + "the funded-distributor record is corrupt; refusing to overwrite it with a new funding \ + record" + ); + RecordOutcome::PersistedStateCorrupt { + path: path.to_path_buf(), + quarantined_to, + } + } + + /// Copy the corrupt record beside itself for the operator, leaving the original in place. + /// `None` when the copy failed — the corrupt verdict does not depend on it. + fn quarantine(&self, path: &Path) -> Option { + let target = path.with_file_name(FUNDED_DISTRIBUTORS_QUARANTINE_FILE); + match std::fs::copy(path, &target) { + Ok(_) => Some(target), + Err(e) => { + tracing::warn!( + path = %path.display(), + target = %target.display(), + error = %e, + "the corrupt funded-distributor record could not be copied to quarantine" + ); + None + } + } + } +} + +/// Add `distributor` to `set`, or refine the identity already there. `Err` carries the conflict +/// outcome, so a caller cannot forget to stop. +fn merge( + set: &mut Vec, + distributor: &FundedDistributor, +) -> Result { + let Some(existing) = set + .iter_mut() + .find(|d| d.launcher_id == distributor.launcher_id) + else { + set.push(distributor.clone()); + return Ok(RecordOutcome::Recorded); + }; + match (existing.store_id, distributor.store_id) { + (Some(recorded), Some(offered)) if recorded != offered => { + Err(RecordOutcome::IdentityConflict { recorded, offered }) + } + (None, Some(offered)) => { + existing.store_id = Some(offered); + Ok(RecordOutcome::IdentityRefined) + } + _ => Ok(RecordOutcome::AlreadyRecorded), + } +} + +/// Why [`FundedDistributorRegistry::load`] could not hand back a record. +enum LoadFailure { + /// The file exists and cannot be trusted. + Corrupt(String), + /// The file could not be read. + Io(String), +} + +/// The on-disk shape: a version plus hex-string ids, so an operator can read and repair the file by +/// hand. `[u8; 32]` would serialize as 32 JSON numbers, which nobody can check by eye. +#[derive(Debug, Serialize, Deserialize)] +struct PersistedRecord { + version: u32, + distributors: Vec, +} + +/// One record line: hex ids, `store_id` omitted entirely when it was never learned. +#[derive(Debug, Serialize, Deserialize)] +struct PersistedDistributor { + launcher_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + store_id: Option, +} + +impl PersistedRecord { + fn from_distributors(set: &[FundedDistributor]) -> Self { + Self { + version: RECORD_FORMAT_VERSION, + distributors: set + .iter() + .map(|d| PersistedDistributor { + launcher_id: hex::encode(d.launcher_id), + store_id: d.store_id.map(hex::encode), + }) + .collect(), + } + } + + /// `Err` carries why the record is corrupt. A malformed id is corruption, never an entry to + /// skip: silently dropping one would under-report the funded set, which is the same lie as + /// reporting it empty, only harder to notice. + fn into_distributors(self) -> Result, String> { + self.distributors + .into_iter() + .map(|d| { + Ok(FundedDistributor { + launcher_id: parse_id(&d.launcher_id, "launcher_id")?, + store_id: d + .store_id + .as_deref() + .map(|s| parse_id(s, "store_id")) + .transpose()?, + }) + }) + .collect() + } +} + +/// Parse one 32-byte hex id, naming the field in the error so a corrupt-record log points at the +/// thing to fix. +fn parse_id(text: &str, field: &str) -> Result { + let bytes = hex::decode(text).map_err(|e| format!("{field} is not hex: {e}"))?; + let len = bytes.len(); + bytes + .try_into() + .map_err(|_| format!("{field} is {len} bytes, not 32")) +} + +#[cfg(test)] +mod tests { + //! Every persistence test round-trips against a REAL temporary directory + //! (`tempfile::TempDir`, removed on drop), never a mock: the thing under test is what survives + //! a restart, and a mock filesystem cannot answer that. "Restart" is simulated the only way it + //! can be without spawning a process — by dropping the registry that wrote and constructing a + //! FRESH one over the same directory, which is exactly the state a new process starts from, + //! since [`FundedDistributorRegistry`] caches nothing. + + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + + /// A distinguishable 32-byte id. + fn id(seed: u8) -> Bytes32 { + [seed; 32] + } + + fn record_path(dir: &TempDir) -> PathBuf { + dir.path().join(FUNDED_DISTRIBUTORS_FILE) + } + + /// **Catches:** a write that persists nothing, or a read that drops the store id. + #[test] + fn records_then_reads_back_the_same_identity() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + let funded = FundedDistributor { + launcher_id: id(1), + store_id: Some(id(2)), + }; + + assert_eq!(registry.record(&funded), RecordOutcome::Recorded); + + assert_eq!( + registry.read(), + FundedDistributorsRead::Funded(vec![funded]), + "a recorded distributor must read back with its identity intact" + ); + } + + /// The ticket's actual requirement: the set survives the process that recorded it. + /// **Catches:** an in-memory-only registry, or a write that never reached disk. + #[test] + fn a_fresh_registry_over_the_same_directory_still_sees_the_set() { + let dir = TempDir::new().expect("temp dir"); + let with_store = FundedDistributor { + launcher_id: id(3), + store_id: Some(id(4)), + }; + let without_store = FundedDistributor { + launcher_id: id(5), + store_id: None, + }; + { + // Scoped so the writing registry is dropped before the reading one exists: nothing but + // the directory carries information across the boundary, which is what a restart is. + let writer = FundedDistributorRegistry::with_state_dir(dir.path()); + assert_eq!(writer.record(&with_store), RecordOutcome::Recorded); + assert_eq!(writer.record(&without_store), RecordOutcome::Recorded); + } + + let after_restart = FundedDistributorRegistry::with_state_dir(dir.path()); + + assert_eq!( + after_restart.read(), + FundedDistributorsRead::Funded(vec![with_store, without_store]), + "the funded set must survive the process that recorded it, in recorded order" + ); + } + + /// **Catches:** an amount finding its way into the durable record, and ids persisted as raw + /// byte arrays no operator can check by eye. + #[test] + fn the_persisted_record_is_hex_and_carries_no_amount() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + registry.record(&FundedDistributor { + launcher_id: id(0xab), + store_id: Some(id(0xcd)), + }); + + let text = std::fs::read_to_string(record_path(&dir)).expect("record readable"); + + assert!( + text.contains(&"ab".repeat(32)) && text.contains(&"cd".repeat(32)), + "ids must persist as operator-readable hex, got: {text}" + ); + for money in [ + "amount", + "mojos", + "base_units", + "reserve", + "accrued", + "paid_out", + "balance", + ] { + assert!( + !text.contains(money), + "the record must carry no money figure, found {money:?} in: {text}" + ); + } + } + + /// **Catches:** a duplicate record line per funding act, and a refinement that is silently + /// dropped instead of persisted. + #[test] + fn recording_the_same_launcher_twice_is_idempotent_and_refines_identity() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + let unknown_store = FundedDistributor { + launcher_id: id(7), + store_id: None, + }; + let learned_store = FundedDistributor { + launcher_id: id(7), + store_id: Some(id(8)), + }; + + assert_eq!(registry.record(&unknown_store), RecordOutcome::Recorded); + assert_eq!( + registry.record(&unknown_store), + RecordOutcome::AlreadyRecorded + ); + assert_eq!( + registry.record(&learned_store), + RecordOutcome::IdentityRefined + ); + assert_eq!( + registry.record(&learned_store), + RecordOutcome::AlreadyRecorded + ); + + assert_eq!( + registry.read(), + FundedDistributorsRead::Funded(vec![learned_store]), + "one launcher id must occupy one record line, with the identity it refined to" + ); + } + + /// **Catches:** a second, contradicting store id overwriting recorded identity. + #[test] + fn a_contradicting_store_id_is_a_conflict_and_overwrites_nothing() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + let recorded = FundedDistributor { + launcher_id: id(9), + store_id: Some(id(10)), + }; + registry.record(&recorded); + + let outcome = registry.record(&FundedDistributor { + launcher_id: id(9), + store_id: Some(id(11)), + }); + + assert_eq!( + outcome, + RecordOutcome::IdentityConflict { + recorded: id(10), + offered: id(11), + } + ); + assert_eq!( + registry.read(), + FundedDistributorsRead::Funded(vec![recorded]), + "a conflicting offer must leave the recorded identity exactly as it was" + ); + } + + /// The requirement everything else serves. + /// **Catches:** a corrupt read rendering as an empty funded set, a quarantine that does not + /// preserve the bytes, and a quarantine that MOVES the record so the next read reads empty. + #[test] + fn a_corrupt_record_reports_corrupt_and_quarantines_and_is_never_empty() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + registry.record(&FundedDistributor { + launcher_id: id(12), + store_id: None, + }); + let path = record_path(&dir); + let corrupt_bytes = b"{\"version\": 1, \"distributors\": [ truncated"; + std::fs::write(&path, corrupt_bytes).expect("corrupt the record"); + + let read = registry.read(); + + let FundedDistributorsRead::PersistedStateCorrupt { + path: reported, + quarantined_to, + } = &read + else { + panic!("a corrupt record must report PersistedStateCorrupt, got {read:?}"); + }; + assert_eq!(reported, &path); + let quarantine = quarantined_to + .as_ref() + .expect("the corrupt record must be quarantined"); + assert_eq!( + quarantine, + &dir.path().join(FUNDED_DISTRIBUTORS_QUARANTINE_FILE) + ); + assert_eq!( + std::fs::read(quarantine).expect("quarantine readable"), + corrupt_bytes, + "quarantine must preserve the corrupt bytes verbatim" + ); + assert_eq!( + std::fs::read(&path).expect("original still readable"), + corrupt_bytes, + "the original must stay in place so the NEXT read is corrupt too, not empty" + ); + assert_eq!( + read.determined(), + None, + "corrupt must never present as a determined (and therefore renderable) set" + ); + assert_ne!(read, FundedDistributorsRead::FundsNothing); + assert!( + matches!( + registry.read(), + FundedDistributorsRead::PersistedStateCorrupt { .. } + ), + "quarantining must not let the following read decay into an empty answer" + ); + } + + /// **Catches:** a parser that skips a malformed entry, which under-reports the funded set. + #[test] + fn a_malformed_id_inside_a_parseable_record_is_corrupt_not_a_skipped_entry() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + std::fs::write( + record_path(&dir), + r#"{"version": 1, "distributors": [{"launcher_id": "beef"}]}"#, + ) + .expect("write a short id"); + + let read = registry.read(); + + assert!( + matches!(read, FundedDistributorsRead::PersistedStateCorrupt { .. }), + "a 2-byte launcher id must be corruption, not an entry to drop, got {read:?}" + ); + assert_eq!(read.determined(), None); + } + + /// **Catches:** a future format version read as an empty set by a version-blind parser. + #[test] + fn an_unknown_format_version_is_corrupt_not_empty() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + std::fs::write(record_path(&dir), r#"{"version": 2, "distributors": []}"#) + .expect("write a future record"); + + let read = registry.read(); + + assert!( + matches!(read, FundedDistributorsRead::PersistedStateCorrupt { .. }), + "a version this build cannot read must be corrupt, got {read:?}" + ); + assert_eq!(read.determined(), None); + } + + /// **Catches:** a write that papers over corruption with a clean-looking file, losing whatever + /// the corrupt record held. + #[test] + fn a_corrupt_record_is_never_overwritten_by_a_new_funding_record() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + let path = record_path(&dir); + let corrupt_bytes = b"not json at all"; + std::fs::write(&path, corrupt_bytes).expect("seed a corrupt record"); + + let outcome = registry.record(&FundedDistributor { + launcher_id: id(13), + store_id: None, + }); + + assert!( + matches!(outcome, RecordOutcome::PersistedStateCorrupt { .. }), + "recording over corruption must refuse, got {outcome:?}" + ); + assert_eq!( + std::fs::read(&path).expect("original still readable"), + corrupt_bytes, + "the corrupt record must be left exactly as it was found" + ); + } + + /// **Catches:** persistence-off reading as an empty funded set, and an inert registry claiming + /// it recorded something. + #[test] + fn no_state_directory_reports_not_configured_never_empty() { + let registry = FundedDistributorRegistry::disabled(); + + let read = registry.read(); + + assert_eq!( + read, + FundedDistributorsRead::NotConfigured(NotConfiguredReason::NoStateDirectory) + ); + assert_eq!( + read.determined(), + None, + "persistence off is unknown, not an empty funded set" + ); + assert_ne!(read, FundedDistributorsRead::FundsNothing); + assert_eq!( + registry.record(&FundedDistributor { + launcher_id: id(14), + store_id: None, + }), + RecordOutcome::NotConfigured(NotConfiguredReason::NoStateDirectory), + "an inert registry must say it recorded nothing rather than pretend it did" + ); + } + + /// **Catches:** a vanished state directory rendering as an empty funded set. + #[test] + fn a_missing_state_directory_reports_not_configured_distinctly_from_empty() { + let dir = TempDir::new().expect("temp dir"); + let gone = dir.path().join("never-created"); + let registry = FundedDistributorRegistry::with_state_dir(&gone); + + let read = registry.read(); + + assert_eq!( + read, + FundedDistributorsRead::NotConfigured(NotConfiguredReason::StateDirectoryMissing), + "a directory this node cannot see is unknown, not empty" + ); + assert_eq!(read.determined(), None); + assert_ne!(read, FundedDistributorsRead::FundsNothing); + } + + /// **Catches:** "nothing written yet" collapsed into the one renderable empty answer. + #[test] + fn an_existing_directory_with_no_record_is_not_configured_not_empty() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + + let read = registry.read(); + + assert_eq!( + read, + FundedDistributorsRead::NotConfigured(NotConfiguredReason::NoRecordWritten), + "nothing ever written is a different unknown from a record that says none" + ); + assert_eq!(read.determined(), None); + assert_ne!(read, FundedDistributorsRead::FundsNothing); + } + + /// **Catches:** a genuinely-empty record that cannot be told apart from a failure, which would + /// make an honest empty list unrenderable. + #[test] + fn a_genuinely_empty_record_reports_funds_nothing_and_is_renderable() { + let dir = TempDir::new().expect("temp dir"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + std::fs::write(record_path(&dir), r#"{"version": 1, "distributors": []}"#) + .expect("write an empty record"); + + let read = registry.read(); + + assert_eq!( + read, + FundedDistributorsRead::FundsNothing, + "an intact record naming nobody is the ONE legitimate empty answer" + ); + assert_eq!( + read.determined(), + Some(&[][..]), + "funds-nothing is the only outcome a caller may render as an empty list" + ); + } + + /// **Catches:** a future variant added to the not-an-answer half of + /// [`FundedDistributorsRead`] that `determined` reports as a renderable set. + #[test] + fn every_not_an_answer_outcome_is_undetermined() { + let undetermined = [ + FundedDistributorsRead::NotConfigured(NotConfiguredReason::NoStateDirectory), + FundedDistributorsRead::NotConfigured(NotConfiguredReason::StateDirectoryMissing), + FundedDistributorsRead::NotConfigured(NotConfiguredReason::NoRecordWritten), + FundedDistributorsRead::PersistedStateCorrupt { + path: PathBuf::from("x"), + quarantined_to: None, + }, + FundedDistributorsRead::IoFailed { + path: PathBuf::from("x"), + error: "denied".to_owned(), + }, + ]; + + for read in undetermined { + assert_eq!( + read.determined(), + None, + "{read:?} must not be renderable as a funded set" + ); + } + } +} diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs index d80a21e7..787d0c7e 100644 --- a/crates/dig-node-core/src/rewards/mod.rs +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -41,6 +41,7 @@ pub mod admission; pub mod challenge; pub mod cycle; +pub mod funded; pub mod gate; pub mod port; pub mod spec_constants; From 49ae2c6035899b2ec93c6bad5bde7608030d026e Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:48:53 -0700 Subject: [PATCH 13/29] feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) (#605) * feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) The peer reward-claim engine shipped complete and tested in #594 but INERT: nothing constructed it, so the 86400s cadence never fired while `rewards_claim.enabled` defaulted to `true` -- a config asserting a subsystem is on while nothing runs. `rewards_claim/driver.rs` is a SCHEDULER, not a chain adapter: it derives this node's own payout puzzle hash, loads `RewardsClaimConfig`, builds a `ClaimEngine` against the only production port that exists (`UnavailableClaimChainPort`, until #3249 lands a real one) and drives `run_cycle` every `cadence_seconds + jitter`, jitter drawn from the OS CSPRNG. `server.rs`'s `serve_with_shutdown` makes exactly one call into it, beside `self_heal::spawn_driver_if_service()`. `enabled = true` now means: a background task exists, drives a counted cycle per interval, and its outcome is readable in-process as a NAMED state. With `UnavailableClaimChainPort` every cycle honestly reports `ChainSourceUnavailable` -- the gap is loud instead of silent. Anti-silence: `ClaimLoopHandle` carries a monotonic `cycles_driven` counter alongside the status, because `Idle` before the first cycle is correct and honest, so status alone cannot tell "scheduler never fired" from "nothing was claimable". The gate takes an INJECTED handle rather than reading the process-wide singleton, so `ClaimDriverRefusal::{Disabled, ChainSyncDisabled, NoOperatorWallet}` and "spawned but never ticked" are four pairwise-distinct readings a test asserts in-process. Nothing goes on the wire: no RPC method, dispatch row, handler or OpenRPC entry. `ClaimStatus` stays off the wire until #3249's real adapter lets the status surface be re-derived against it. Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): silence the deliberately-ignored fake-port argument (#3268) `OneDistributorPort::own_entry` ignores the puzzle hash the engine passes in on purpose -- the fake always returns the entry keyed to `entry_keyed_to` so the ENGINE's own comparison is what decides claimable vs. refused. Named it `_payout_puzzle_hash` (clippy `-D unused-variables`) and moved the rationale onto the parameter, where the next reader meets it. Co-Authored-By: Claude Opus 5 (1M context) * test(rewards): close the untested joint between the claim gate and the drive loop (#3268) `decide_claim_driver` was tested and `drive` was tested, but the production body joining them -- load the config from the state dir, derive the engine, reach `drive` -- was exercised by nothing. That is the exact shape of #594, which shipped a complete, fully-tested and entirely inert claim engine: had this body returned early, built the engine wrong, or never reached `drive`, every test on this change would still have passed and a real node would still never claim. Split `run_claim_driver` on the same `load` / `load_from` pattern the config itself uses: `run_claim_driver_in(state_dir, own_payout_puzzle_hash, port, handle)` holds the whole body and is generic over the port, and `run_claim_driver` is reduced to the wallet-derivation adapter that cannot be reached from a test. Adds two tests through the real body: counted cycles from a written config (zero before the interval, exactly one per interval after), and `UnavailableClaimChainPort` reporting `ChainSourceUnavailable` by name on a driven cycle -- proving the production adapter path is reached, not only a fake. No behaviour change: same config, same engine construction, same port. Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): settle before advancing, and keep the wrapped assertion out of rustfmt's reach (#3268) Two repairs to the new composition tests: - The `ChainSourceUnavailable` test advanced the paused clock before the spawned body had reached its first `sleep`, so the timer was not yet registered and the advance bought no cycle at all -- it read zero cycles, not a driven one. A `settle()` first, mirroring the counted-cycles test. - rustfmt rejoined a `\`-continued assertion message into one line, leaving 14 literal spaces mid-sentence and tripping the repo's own `continuation_guard`. `concat!` states the wrap explicitly, so no formatter pass can reintroduce the run. Co-Authored-By: Claude Opus 5 (1M context) * feat(rewards): emit a per-cycle event so the claim loop has a reader (#3268) The adversarial gate blocked #605 on this: the PR justified itself by making an inert subsystem loud, but nothing in the shipped binary could hear it. ClaimLoopHandle had no caller outside driver.rs tests, drive() emitted no event, and all three tracing calls fired only on paths where the loop does NOT run -- so on the default path (enabled=true, chain sync on) the observable output was identical to before the PR: silence. Today that silence covers a permanent ChainSourceUnavailable; after #3249 it would also cover Faulted, PersistedStateCorrupt and ClaimableButNotClaiming. log_cycle() now names the state and the cycle count after every cycle -- info for Nominal, warn for everything else, because "this peer is earning nothing and here is why" is a warning, not routine chatter. Tested by capturing the subscriber output rather than asserting the call site exists, since this ticket exists because a guarantee that cannot be observed in a running node is not a guarantee. Refs DIG-Network/dig_ecosystem#3268 Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): stop a u64::MAX jitter bound panicking the claim driver `OsJitter::jitter_seconds` computed `bound + 1` for its modulus. `jitter_seconds` comes from the node's persisted `rewards_claim` config and is not clamped, so a config carrying `u64::MAX` overflow-panicked inside the detached claim-driver task -- which has no restart and emits no further log output, so the claim loop would die silently for the rest of the process lifetime. `saturating_add(1)` keeps the draw within `0..=bound` for every input; the composed `next_interval_seconds` range is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): sanitize the claim schedule so no config value silently disables the loop `next_interval_seconds` saturates instead of panicking, so a persisted `jitter_seconds = u64::MAX` no longer crashes the driver -- it schedules the next cycle ~585 billion years out. The claim loop then never fires again: no cycle, no `log_cycle` line, and a permanent, reassuring `0` cycle count. That is #594's inert-but-green shape reopened one level up, in the config file. `run_claim_driver_in` now sanitizes both schedule fields where it reads them, before either reaches the engine's fee window or `drive`: - `CLAIM_SCHEDULE_SECONDS_MAX = 31 * 24 * 60 * 60` (31 days) -- above every documented default (86,400s cadence, 3,600s jitter) and above "claim monthly", while excluding everything that means never. - out of range (or a zero cadence, which would busy-loop) substitutes the published default and emits `tracing::warn!` naming the field, the rejected value and the substituted one. Nothing is accepted silently. `config.rs` is untouched: it keeps reporting what is on disk. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/dig-node-service/Cargo.toml | 6 + .../src/rewards_claim/driver.rs | 1381 +++++++++++++++++ .../dig-node-service/src/rewards_claim/mod.rs | 24 +- crates/dig-node-service/src/server.rs | 11 + 5 files changed, 1413 insertions(+), 10 deletions(-) create mode 100644 crates/dig-node-service/src/rewards_claim/driver.rs diff --git a/Cargo.lock b/Cargo.lock index 058fad93..a8839fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3079,6 +3079,7 @@ dependencies = [ "notify", "num-bigint", "reqwest", + "ring", "rpassword", "rustls", "serde", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index e340a965..ed7e3f94 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -116,6 +116,12 @@ dig-mirror-coin = "0.9" # a dissenting THIRD source and did not distinguish source CLASSES from bare source strings. dig-stun = "0.2" +# The claim loop's cadence jitter (DIG-Network/dig_ecosystem#3268, SPEC §8.6) draws from the OS +# CSPRNG, never a global/thread RNG -- `ring::rand::SystemRandom` is the same primitive this +# crate's own signing paths already use for randomness (`seams::dig_peer::holdings`, in +# `dig-node-core`), so this is not a second RNG choice entering the tree. +ring = "0.17" + # The canonical `ChainSource` trait `dig-mirror-coin`'s census is generic over. Declared, not # implemented: `chia-query` already provides the implementation this node uses # (`ChiaQueryProvider`), reached through `dig-wallet`'s one shared chain transport. Caret-matched diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs new file mode 100644 index 00000000..858b978f --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -0,0 +1,1381 @@ +//! Wires the claim engine onto a real background cadence, reachable from the node's actual +//! startup path (DIG-Network/dig_ecosystem#3268). Mirrors [`crate::self_heal`]'s split exactly: +//! a private injected-tick [`drive`] (testable under `#[tokio::test(start_paused = true)]`) behind +//! a pure, tested gate ([`decide_claim_driver`] / [`spawn_claim_driver_if`]) that `server.rs` calls +//! exactly once. +//! +//! # `enabled = true` must stop being a false statement +//! Before this module, nothing in the codebase ever constructed a [`super::ClaimEngine`] outside +//! its own tests (see [`super`]'s module doc, now updated). After it, a background task always +//! exists whenever `rewards_claim.enabled` and `enable_chain_sync` are both true, drives a cycle +//! every `cadence_seconds + jitter`, and its outcome is readable in-process via [`handle`] as a +//! NAMED [`super::ClaimLoopState`] — see [`ClaimLoopHandle`]. +//! +//! # Diverges from `self_heal::drive` on purpose: the FIRST pass waits for the interval +//! `self_heal::drive` fires its pass immediately, then once per fixed tick — right for a +//! maintenance sweep with no anti-silence surface. This driver's whole point (A2, the ticket's +//! headline acceptance item) is that "scheduler running, zero cycles ever fired" must be +//! DISTINGUISHABLE from "it ran" via a monotonic cycle counter that reads `0` before any interval +//! has elapsed. Running a pass at spawn, before the counter could ever read `0` under observation, +//! would defeat that on every startup. So [`drive`] sleeps `cadence_seconds + jitter` FIRST, then +//! runs a cycle, then repeats — the counter is genuinely `0` until the first interval elapses. +//! +//! # No RPC surface here (SCOPE) +//! [`handle`] is an IN-PROCESS accessor only — a future RPC (blocked on DIG-Network/dig_ecosystem#3249 +//! re-deriving the `ClaimStatus` wire semantics) can read it; this module puts nothing on the wire +//! and adds no RPC method, dispatch-table row or handler. +//! +//! # The only production adapter is [`super::UnavailableClaimChainPort`] +//! #3249 has not landed, so every real cycle this driver runs reports [`super::ClaimLoopState::ChainSourceUnavailable`] +//! and submits nothing — the honest state, not an invented adapter. + +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chia_protocol::Bytes32; + +use super::cadence::{next_interval_seconds, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; +use super::engine::ClaimEngine; +use super::hints::{DistributorHintSource, NoHintSource}; +use super::port::{ClaimChainPort, UnavailableClaimChainPort}; +use super::types::{ClaimLoopState, ClaimStatus}; + +/// The in-process accessor onto the running claim loop (SCOPE: never exposed over the wire here). +/// Cheap to clone -- every field is an `Arc`-backed handle onto the same shared state. +/// +/// # A2, the anti-silence test +/// [`Self::cycles_driven`] is the counter a reader compares against [`Self::status`]'s +/// [`super::ClaimLoopState`] to tell "constructed and spawned but never drove a cycle" (`0`, +/// `Idle`) apart from "ran and reported a real outcome" (`> 0`, whatever [`super::ClaimEngine`] +/// computed). Neither field alone would do it: `status()` before the first cycle is already +/// `Idle` BY DESIGN (see [`super::types::ClaimLoopState::Idle`]'s doc, "no cycle has ever been +/// attempted yet") -- that is the correct, honest reading, not a defect, and a test that only +/// checked `status()` for `Idle` could not tell a scheduler that never fires apart from one that +/// correctly reports nothing pending. The count is the only thing here that is monotonic and can +/// never be read as "healthy" by a writer describing itself. +#[derive(Clone, Default)] +pub struct ClaimLoopHandle { + status: std::sync::Arc>, + cycles_driven: std::sync::Arc, + refusal: std::sync::Arc>>, +} + +/// Why the driver never reached [`drive`]'s loop at all -- distinct from anything +/// [`super::ClaimLoopState`] can say, because every one of ITS states presupposes an engine that +/// exists and a cycle that was at least attempted. Without this, "disabled", "chain sync is off" +/// and "no operator wallet, so there is nothing to build an engine with" all collapse into the +/// same reassuring `Idle` + zero-count reading -- three different truths about whether this peer +/// is being paid, indistinguishable to an operator or a future `dig.getRewardClaimStatus`. Kept on +/// the DRIVER's own handle, never added to [`super::types::ClaimStatus`] (read-only, and it is the +/// wrong home: it is a fact about whether an engine exists, not about a cycle one ran). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimDriverRefusal { + /// `rewards_claim.enabled = false` -- the ordinary, deliberate off state. + Disabled, + /// `enabled = true` but `enable_chain_sync = false` -- see [`ClaimDriverDecision::ChainSyncDisabled`]'s doc. + ChainSyncDisabled, + /// `enabled = true`, chain sync is on, but this node has no operator wallet to derive + /// [`own_payout_puzzle_hash`] from -- there is no puzzle hash to build an engine with at all. + NoOperatorWallet, +} + +impl ClaimLoopHandle { + /// The most recent [`ClaimStatus`] any cycle has produced, or [`ClaimStatus::default`]'s + /// `Idle` state before the first one ever runs. + #[must_use] + pub fn status(&self) -> ClaimStatus { + *self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// How many times [`super::ClaimEngine::run_cycle`] has been invoked through this handle -- + /// incremented on EVERY invocation, whatever it returned (a refused, faulted or empty cycle + /// still counts: A1 requires an OBSERVED CYCLE COUNT, never "the task was spawned"). + #[must_use] + pub fn cycles_driven(&self) -> u64 { + self.cycles_driven.load(Ordering::SeqCst) + } + + /// Why no engine was ever built for this handle, or `None` when one was (whether or not it has + /// driven a cycle yet -- see [`ClaimDriverRefusal`]'s doc for the three-way collapse this + /// exists to prevent). + #[must_use] + pub fn refusal(&self) -> Option { + *self + .refusal + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Record why no engine will ever be built on this handle. Called only from + /// [`spawn_claim_driver_if`]'s non-`Spawn` branches and [`run_claim_driver`]'s + /// no-operator-wallet path. + fn set_refusal(&self, reason: ClaimDriverRefusal) { + *self + .refusal + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(reason); + } + + /// Record that one cycle was driven and publish its resulting status. Called only from + /// [`drive`], once per cycle, after `run_cycle` returns. + fn record(&self, status: ClaimStatus) { + *self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = status; + self.cycles_driven.fetch_add(1, Ordering::SeqCst); + } +} + +/// The process-wide handle to the running (or never-spawned) claim loop -- one per node process, +/// mirroring how [`crate::state::state_dir`] and friends are process-wide singletons. Initialized +/// lazily to its `Idle`/zero default so a reader (a future RPC, a test) never has to handle +/// "not spawned yet" as a THIRD state distinct from `Idle` -- it is the same state, honestly. +static HANDLE: OnceLock = OnceLock::new(); + +/// The in-process accessor a future RPC (blocked on #3249) reads. Never wired onto the wire here. +#[must_use] +pub fn handle() -> ClaimLoopHandle { + HANDLE.get_or_init(ClaimLoopHandle::default).clone() +} + +/// Drive the claim cadence: sleep `cadence_seconds + jitter` (drawn from `jitter`), run one cycle, +/// record it on `handle`, repeat forever. `now` and `jitter` are injected -- never a global RNG or +/// the clock read directly here -- so the schedule is deterministic and falsifiable under +/// `#[tokio::test(start_paused = true)]` (see this module's doc for why the FIRST pass waits +/// rather than firing immediately, unlike [`crate::self_heal::drive`]). +async fn drive( + mut engine: ClaimEngine, + cadence_seconds: u64, + jitter_seconds: u64, + jitter: &dyn JitterSource, + mut now: impl FnMut() -> u64, + handle: ClaimLoopHandle, +) where + P: ClaimChainPort, + H: DistributorHintSource, +{ + loop { + let interval = next_interval_seconds(cadence_seconds, jitter_seconds, jitter); + tokio::time::sleep(Duration::from_secs(interval)).await; + let t = now(); + engine.run_cycle(t).await; + let status = engine.status(); + handle.record(status); + log_cycle(&status, handle.cycles_driven()); + } +} + +/// Emit the ONE record that makes a driven cycle observable in a running node. +/// +/// Without this, the whole status surface has no reader in a shipped binary: [`handle`] is +/// in-process only and deliberately carries no RPC (deferred to DIG-Network/dig_ecosystem#3249), +/// so a node whose claim loop can never claim a single reward would produce output IDENTICAL to a +/// healthy one -- silence. A status nobody can read is a doc claim, not a measurement. +/// +/// [`ClaimLoopState::Nominal`] is the routine case (`info`). Every other state means this peer is +/// earning nothing and names why, which on a money surface is a warning, not chatter. +fn log_cycle(status: &ClaimStatus, cycles_driven: u64) { + if status.state == ClaimLoopState::Nominal { + tracing::info!( + target: "rewards_claim", + state = ?status.state, + cycles_driven, + distributors_known = status.distributors_known, + distributors_claimable = status.distributors_claimable, + claims_submitted = status.claims_submitted, + "claim cycle complete" + ); + } else { + tracing::warn!( + target: "rewards_claim", + state = ?status.state, + cycles_driven, + distributors_known = status.distributors_known, + distributors_claimable = status.distributors_claimable, + claims_submitted = status.claims_submitted, + concat!( + "claim cycle complete but this node is NOT claiming rewards -- see the named ", + "state for why" + ) + ); + } +} + +/// A jitter source drawing from the OS CSPRNG (`ring::rand::SystemRandom`, the same primitive +/// [`crate::mirror`]'s signing paths use for randomness in this crate) -- never a global/thread +/// RNG. A CSPRNG failure (the underlying OS call erroring) fails to jitter `0` rather than +/// panicking the driver: the worst case is every node's cadence landing exactly on +/// `cadence_seconds` with no spread, not a crashed claim loop. +struct OsJitter; + +impl JitterSource for OsJitter { + fn jitter_seconds(&self, bound: u64) -> u64 { + if bound == 0 { + return 0; + } + use ring::rand::SecureRandom; + let rng = ring::rand::SystemRandom::new(); + let mut buf = [0u8; 8]; + if rng.fill(&mut buf).is_err() { + return 0; + } + // `bound.saturating_add(1)` rather than `bound + 1`: `jitter_seconds` comes from the + // persisted config unclamped, so `u64::MAX` reaches here and `+ 1` would overflow-panic + // inside the detached driver task -- killing the claim loop silently for the process + // lifetime. Saturating keeps the draw in `0..=bound` for every input. + u64::from_le_bytes(buf) % bound.saturating_add(1) + } +} + +fn unix_now_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A6, money-correctness: this node's own payout puzzle hash as the claim-chain entry slot +/// compares it. `$DIG` is a CAT, so the operator's coins -- and therefore the puzzle hash an +/// `InitiatePayout` should be admitted under -- sit at the canonical CAT wrapping of the owner's +/// inner puzzle hash, never the bare inner hash itself: the SAME derivation +/// [`crate::mirror::lifecycle`]'s `reclaimed_coin_id` and [`crate::mirror::funding::dig_cat_puzzle_hash`]'s +/// own doc use ("the operator's ordinary $DIG coins... sit at the canonical CAT wrapping... never +/// the bare owner puzzle hash"). Picking the unwrapped hash here would make every distributor +/// refuse this node's claims (`claims_refused_payout_mismatch`) while [`super::ClaimLoopState::compute_state`] +/// still reads `Nominal` when nothing is claimable at all -- exactly the misdirection this epic has +/// already measured. See this module's tests for the two-sided proof (a distributor paying to this +/// derivation claims; one paying the unwrapped hash is refused). +#[must_use] +pub fn own_payout_puzzle_hash(owner_inner_puzzle_hash: Bytes32) -> Bytes32 { + crate::mirror::funding::dig_cat_puzzle_hash(owner_inner_puzzle_hash) +} + +/// The real, detached claim-loop task: derive this node's own payout puzzle hash from its operator +/// wallet (the same public, no-unseal-required derivation [`crate::server::spawn_mirror_passes`] +/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the only +/// production adapter that exists ([`UnavailableClaimChainPort`] -- see this module's doc), and +/// drive it forever. +/// +/// Never called directly by `server.rs` -- see [`spawn_claim_driver_if`], the tested gate that +/// decides WHETHER to call this. `handle` is INJECTED (never the [`handle`] singleton read +/// directly) so a test can drive this against a private, non-shared handle instead of the +/// process-wide one. +async fn run_claim_driver(handle: ClaimLoopHandle) { + let paths = dig_wallet::autoseed::default_paths(); + let Some(owner_inner_puzzle_hash) = dig_wallet::operator_wallet::operator_puzzle_hash(&paths) + else { + tracing::warn!( + target: "rewards_claim", + "no operator wallet is available, so this node has no payout puzzle hash to claim \ + against; the claim loop is NOT started -- rewards_claim.enabled stays true but no \ + cycle will ever run until an operator wallet exists" + ); + handle.set_refusal(ClaimDriverRefusal::NoOperatorWallet); + return; + }; + let own_payout_puzzle_hash = own_payout_puzzle_hash(owner_inner_puzzle_hash); + + run_claim_driver_in( + &crate::state::state_dir(), + own_payout_puzzle_hash, + UnavailableClaimChainPort, + handle, + ) + .await; +} + +/// The whole production body of the claim loop, with every process global it used to read taken as +/// an argument: the state directory it loads [`RewardsClaimConfig`] from, this node's own payout +/// puzzle hash, and the chain `port`. Split out of [`run_claim_driver`] on the same `load` / +/// `load_from` pattern [`RewardsClaimConfig`] itself uses, for one reason: the joint between the +/// tested gate and the tested [`drive`] loop was previously the only UNTESTED link in the chain, +/// and an untested joint is exactly how #594's claim engine shipped complete and inert. +/// +/// Generic over `P` so a test can drive this real body against a fake port; production always +/// passes [`UnavailableClaimChainPort`] (see the module doc -- there is deliberately no second +/// production adapter until #3249 lands). +/// The largest schedule value this driver will honour, in seconds: 31 days. Chosen to sit +/// comfortably above every documented default -- [`CLAIM_CADENCE_SECONDS_DEFAULT`] is 86,400s +/// (1 day) and [`CLAIM_JITTER_SECONDS_DEFAULT`] is 3,600s (1 hour) -- and above any plausible +/// operator choice ("claim monthly" is 30 days), while excluding every value that means NEVER. +/// +/// # Why a ceiling exists at all +/// [`next_interval_seconds`] saturates rather than panicking, so a persisted +/// `jitter_seconds = u64::MAX` (or a cadence of the same shape) no longer crashes the node -- it +/// schedules the next cycle roughly 585 billion years out. That is strictly WORSE than a panic for +/// this ticket: the claim loop never fires again, so no cycle, no `log_cycle` line, and the +/// cycle counter reads a permanent, reassuring `0`. #594 shipped an engine that was inert and +/// green; a config value must not be able to put this driver back in that state silently. +const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; + +/// Replace a schedule value that would switch the loop off (or spin it) with its documented +/// default, saying so at `WARN` -- never silently accept it, and never silently accept the +/// default either. Returns `(cadence_seconds, jitter_seconds)` fit to schedule with. +/// +/// A zero cadence is rejected for the opposite reason to a huge one: it would busy-loop the claim +/// engine as fast as the runtime can poll it. A zero JITTER is legitimate (it means "no jitter") +/// and is left alone. +fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64) { + let cadence = if cadence_seconds == 0 || cadence_seconds > CLAIM_SCHEDULE_SECONDS_MAX { + tracing::warn!( + target: "rewards_claim", + field = "cadence_seconds", + rejected = cadence_seconds, + substituted = CLAIM_CADENCE_SECONDS_DEFAULT, + max = CLAIM_SCHEDULE_SECONDS_MAX, + "{}", + concat!( + "rewards_claim.cadence_seconds is outside the honoured range and was IGNORED; ", + "the documented default is used instead -- a value that large would stop the ", + "claim loop from ever firing again, and zero would busy-loop it" + ) + ); + CLAIM_CADENCE_SECONDS_DEFAULT + } else { + cadence_seconds + }; + + let jitter = if jitter_seconds > CLAIM_SCHEDULE_SECONDS_MAX { + tracing::warn!( + target: "rewards_claim", + field = "jitter_seconds", + rejected = jitter_seconds, + substituted = CLAIM_JITTER_SECONDS_DEFAULT, + max = CLAIM_SCHEDULE_SECONDS_MAX, + "{}", + concat!( + "rewards_claim.jitter_seconds is outside the honoured range and was IGNORED; ", + "the documented default is used instead -- a value that large saturates the ", + "next interval and the claim loop would never fire again" + ) + ); + CLAIM_JITTER_SECONDS_DEFAULT + } else { + jitter_seconds + }; + + (cadence, jitter) +} + +async fn run_claim_driver_in

( + state_dir: &Path, + own_payout_puzzle_hash: Bytes32, + port: P, + handle: ClaimLoopHandle, +) where + P: ClaimChainPort, +{ + let cfg = RewardsClaimConfig::load_from(state_dir); + // A4/F8: a corrupt config is not a reason to refuse to SPAWN -- `ClaimEngine::run_cycle` + // already fails closed and reports `PersistedStateCorrupt` by name on every cycle until an + // operator fixes or removes the file (see `engine.rs`'s `run_cycle` doc). Refusing to spawn + // here instead would report NOTHING at all, which is the exact silent failure this ticket + // exists to prevent -- a corrupt file must stay visible, not vanish into "never started". + + // The config is operator-writable and unclamped at rest (`config.rs` deliberately reports what + // is on disk). Sanitize HERE, at the read, before either value can reach the scheduler. + let (cadence_seconds, jitter_seconds) = + sanitized_schedule(cfg.cadence_seconds, cfg.jitter_seconds); + + let engine = ClaimEngine::new( + port, + NoHintSource, + own_payout_puzzle_hash, + cfg.max_fee_mojos, + cfg.max_cycle_fee_budget_mojos, + dig_mirror_coin::DIG_ASSET_ID, + ) + .with_rotation_cursor(cfg.rotation_cursor) + .with_persisted_fee_window(state_dir, cadence_seconds); + + drive( + engine, + cadence_seconds, + jitter_seconds, + &OsJitter, + unix_now_seconds, + handle, + ) + .await; +} + +/// Spawn the real claim-loop task, detached, against `handle` -- injected, never the [`handle`] +/// singleton read from inside, so the only place the process-wide singleton is named is +/// [`spawn_claim_driver_from_config`]. +fn spawn_claim_driver(handle: ClaimLoopHandle) { + tokio::spawn(run_claim_driver(handle)); +} + +/// Why [`spawn_claim_driver_if`] declined to spawn -- named so the caller can log a reason instead +/// of silence (A3). `Disabled` is the ordinary, expected off state (`rewards_claim.enabled = +/// false`); `ChainSyncDisabled` is the one that matters most, because it is reachable with +/// `enabled = true` -- exactly the shape this ticket exists to close: an operator who reads +/// `enabled: true` and believes claims are running, on a node where `enable_chain_sync` is off. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClaimDriverDecision { + Spawn, + Disabled, + ChainSyncDisabled, +} + +/// The pure decision behind [`spawn_claim_driver_if`] -- no I/O, no logging, so a test can assert +/// every branch directly. `enabled` gates on its own (SPEC-level opt-out); `enable_chain_sync` is +/// gated the same way `spawn_collateral_census` and `mirror::bond_verify::spawn_bond_verifier_install` +/// already are in `server.rs` -- that flag already means "this node talks to the Chia network", and +/// an integration harness sets it false precisely so nothing dials. +fn decide_claim_driver(enabled: bool, enable_chain_sync: bool) -> ClaimDriverDecision { + if !enabled { + return ClaimDriverDecision::Disabled; + } + if !enable_chain_sync { + return ClaimDriverDecision::ChainSyncDisabled; + } + ClaimDriverDecision::Spawn +} + +/// The single wiring seam `server.rs`'s `serve_with_shutdown` calls (A1's "exact precedent": +/// `self_heal::spawn_driver_if`). `spawn` is invoked exactly when [`decide_claim_driver`] returns +/// `Spawn`; every other branch logs its reason instead of spawning silently (A3) and leaves +/// `handle` at its already-honest `Idle` default -- never a third, undocumented state. +/// +/// `handle` is INJECTED rather than read from the process-wide [`handle`] singleton, so a test can +/// assert the recorded [`ClaimDriverRefusal`] of each branch in-process, on a private handle, with +/// no cross-test interference from a `OnceLock` that outlives the test that touched it. +fn spawn_claim_driver_if( + enabled: bool, + enable_chain_sync: bool, + handle: &ClaimLoopHandle, + spawn: impl FnOnce(), +) { + match decide_claim_driver(enabled, enable_chain_sync) { + ClaimDriverDecision::Spawn => spawn(), + ClaimDriverDecision::Disabled => { + tracing::debug!( + target: "rewards_claim", + "rewards_claim.enabled=false; the claim loop is not started" + ); + handle.set_refusal(ClaimDriverRefusal::Disabled); + } + ClaimDriverDecision::ChainSyncDisabled => { + tracing::warn!( + target: "rewards_claim", + "rewards_claim.enabled=true but enable_chain_sync=false; the claim loop is NOT \ + started -- rewards_claim.enabled is a false statement on this node until chain \ + sync is enabled" + ); + handle.set_refusal(ClaimDriverRefusal::ChainSyncDisabled); + } + } +} + +/// Reads [`RewardsClaimConfig::load`] (the node's own state-dir config) and `enable_chain_sync`, +/// and calls [`spawn_claim_driver_if`] -- the exact one call `serve_with_shutdown` makes. +pub fn spawn_claim_driver_from_config(enable_chain_sync: bool) { + let cfg = RewardsClaimConfig::load(); + // The ONE place the process-wide singleton is read: everything below it takes an injected + // handle so it stays testable in-process. + let process_handle = handle(); + let driver_handle = process_handle.clone(); + spawn_claim_driver_if(cfg.enabled, enable_chain_sync, &process_handle, move || { + spawn_claim_driver(driver_handle); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::sync::atomic::AtomicUsize; + use std::sync::Arc; + + use super::super::port::ClaimPortError; + use super::super::types::{DiscoveredDistributor, OwnEntry}; + + // ---- decide_claim_driver / spawn_claim_driver_if (A3) ---------------------------------- + + #[test] + fn disabled_never_spawns() { + assert_eq!( + decide_claim_driver(false, true), + ClaimDriverDecision::Disabled + ); + assert_eq!( + decide_claim_driver(false, false), + ClaimDriverDecision::Disabled + ); + } + + #[test] + fn enabled_but_chain_sync_off_refuses_named() { + assert_eq!( + decide_claim_driver(true, false), + ClaimDriverDecision::ChainSyncDisabled + ); + } + + #[test] + fn enabled_and_chain_sync_on_spawns() { + assert_eq!(decide_claim_driver(true, true), ClaimDriverDecision::Spawn); + } + + #[test] + fn gate_invokes_spawn_only_on_the_spawn_decision() { + let spawned = Arc::new(AtomicUsize::new(0)); + let handle = ClaimLoopHandle::default(); + + let s = spawned.clone(); + spawn_claim_driver_if(false, true, &handle, || { + s.fetch_add(1, Ordering::SeqCst); + }); + assert_eq!(spawned.load(Ordering::SeqCst), 0, "enabled=false: no spawn"); + + let s = spawned.clone(); + spawn_claim_driver_if(true, false, &handle, || { + s.fetch_add(1, Ordering::SeqCst); + }); + assert_eq!( + spawned.load(Ordering::SeqCst), + 0, + "enabled=true, chain sync off: no spawn" + ); + + let s = spawned.clone(); + spawn_claim_driver_if(true, true, &handle, || { + s.fetch_add(1, Ordering::SeqCst); + }); + assert_eq!( + spawned.load(Ordering::SeqCst), + 1, + "enabled+chain sync: spawns" + ); + } + + /// ACCEPTANCE A3: the gate's two refusals are READABLE off the injected handle and distinct + /// from each other -- not both collapsed into the same zero-cycle `Idle` reading. The third + /// refusal (`NoOperatorWallet`) is proven in + /// `a_missing_operator_wallet_is_a_distinct_named_refusal` below; the fourth truth, + /// "spawned and running but never ticked", is `refusal() == None` with `cycles_driven() == 0`, + /// asserted here and driven past zero in + /// `zero_cycles_before_the_interval_elapses_then_a_counted_number_after`. + #[test] + fn each_refusal_is_readable_and_distinct_on_the_injected_handle() { + let disabled = ClaimLoopHandle::default(); + assert_eq!( + disabled.refusal(), + None, + "nothing refused before the gate runs" + ); + spawn_claim_driver_if(false, true, &disabled, || {}); + assert_eq!(disabled.refusal(), Some(ClaimDriverRefusal::Disabled)); + + let chain_off = ClaimLoopHandle::default(); + spawn_claim_driver_if(true, false, &chain_off, || {}); + assert_eq!( + chain_off.refusal(), + Some(ClaimDriverRefusal::ChainSyncDisabled) + ); + + let spawned = ClaimLoopHandle::default(); + spawn_claim_driver_if(true, true, &spawned, || {}); + assert_eq!( + spawned.refusal(), + None, + "a spawned driver has refused nothing: the fourth truth, `refusal() == None` with a zero cycle count" + ); + assert_eq!(spawned.cycles_driven(), 0); + + // The four readings are pairwise distinct, which is the whole point of A3: three refusals + // plus "running but never ticked" are four different answers to "is this peer being paid". + let readings = [ + disabled.refusal(), + chain_off.refusal(), + Some(ClaimDriverRefusal::NoOperatorWallet), + spawned.refusal(), + ]; + for (i, a) in readings.iter().enumerate() { + for b in &readings[i + 1..] { + assert_ne!(a, b, "two driver refusals must never read the same"); + } + } + } + + /// ACCEPTANCE A3 (third refusal): the no-operator-wallet path records its OWN named reason on + /// the handle rather than leaving `Idle` + zero cycles, and drives no cycle. `run_claim_driver` + /// reads the real default wallet paths, so this asserts the refusal only when this machine + /// genuinely has no operator wallet; where one exists the driver legitimately proceeds and the + /// refusal stays `None` -- either way the reading is a NAMED one, never a silent `Idle`. + #[tokio::test] + async fn a_missing_operator_wallet_is_a_distinct_named_refusal() { + let paths = dig_wallet::autoseed::default_paths(); + if dig_wallet::operator_wallet::operator_puzzle_hash(&paths).is_some() { + return; // this machine HAS an operator wallet; the refusal branch is unreachable here + } + let handle = ClaimLoopHandle::default(); + run_claim_driver(handle.clone()).await; + assert_eq!( + handle.refusal(), + Some(ClaimDriverRefusal::NoOperatorWallet), + "no operator wallet must be a named refusal, not a reassuring Idle" + ); + assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); + } + + // ---- A1 + A2: the anti-silence cycle counter through the real drive() loop ------------- + + /// A fake port whose every call succeeds with an empty/zero answer -- enough to let + /// `run_cycle` reach `Nominal` every time, so the driven-cycle counter is exercised against a + /// REAL completed cycle, not just an early `ChainSourceUnavailable` return. + struct EmptyPort; + + #[async_trait] + impl ClaimChainPort for EmptyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(None) + } + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Ok(Bytes32::from([0u8; 32])) + } + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Ok(0) + } + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Ok(None) + } + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Ok(0) + } + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Ok(()) + } + } + + fn empty_engine() -> ClaimEngine { + ClaimEngine::new( + EmptyPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ) + } + + async fn settle() { + for _ in 0..8 { + tokio::task::yield_now().await; + } + } + + /// ACCEPTANCE A1 + A2 (the anti-silence test): a scheduler that is running but whose interval + /// has never elapsed must read `cycles_driven() == 0` -- NOT "spawn returned", an observed + /// count. Advancing the clock past `cadence_seconds + jitter` must then drive `run_cycle` a + /// counted number of times. + #[tokio::test(start_paused = true)] + async fn zero_cycles_before_the_interval_elapses_then_a_counted_number_after() { + let cadence = 100u64; + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let driver = tokio::spawn(async move { + drive( + empty_engine(), + cadence, + 0, + &super::super::cadence::FixedJitter(0), + { + let mut t = 0u64; + move || { + t += cadence; + t + } + }, + h, + ) + .await; + }); + + settle().await; + assert_eq!( + handle.cycles_driven(), + 0, + "THE ANTI-SILENCE TEST: a scheduler that is running but has never fired a cycle must \ + report a driven-cycle count of 0, not silence and not a false 'ran' reading" + ); + + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 1, + "one interval elapsed, one cycle driven" + ); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "the driven cycle's real outcome is readable, not just its count" + ); + + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "a second interval drives a second cycle" + ); + + driver.abort(); + } + + // ---- A3: enabled=false vs. enabled=true+gate-refused are both zero-cycle, named states - + + #[tokio::test(start_paused = true)] + async fn disabled_config_never_drives_a_cycle_via_the_configured_seam() { + let dir = tempfile::tempdir().unwrap(); + let cfg = RewardsClaimConfig { + enabled: false, + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).unwrap(); + // The gate itself (not the full production seam, which reads the process-wide state dir) + // is what's under test here -- see `gate_invokes_spawn_only_on_the_spawn_decision` above + // for the direct proof that `enabled=false` never calls `spawn`. + assert_eq!( + decide_claim_driver(cfg.enabled, true), + ClaimDriverDecision::Disabled + ); + } + + // ---- A6: own_payout_puzzle_hash is the CAT-wrapped hash, proven against the engine ------ + + /// A distributor whose recorded entry is keyed to THIS node's own payout derivation is + /// claimable; one keyed to the bare, unwrapped owner puzzle hash is refused + /// (`PayoutPuzzleHashMismatch`) -- proving `own_payout_puzzle_hash` computes the CAT-wrapped + /// hash the engine's `own_entry` comparison expects, not the raw inner hash. + struct OneDistributorPort { + entry_keyed_to: Bytes32, + dig_asset_id: Bytes32, + } + + #[async_trait] + impl ClaimChainPort for OneDistributorPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(vec![DiscoveredDistributor { + launcher_id: Bytes32::from([9u8; 32]), + store_id: Bytes32::from([0u8; 32]), + root: Bytes32::from([0u8; 32]), + }]) + } + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(None) + } + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Ok(self.dig_asset_id) + } + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Ok(1) + } + async fn own_entry( + &self, + _launcher_id: Bytes32, + // Ignored ON PURPOSE (see the NOTE below): this fake always hands back the entry keyed + // to `entry_keyed_to`, so the ENGINE's own comparison decides claimable vs. refused. + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Ok(Some(OwnEntry { + payout_puzzle_hash: self.entry_keyed_to, + counter: 0, + accrued_base_units: 1_000, + })) + // NOTE: `_payout_puzzle_hash` (the argument the engine passed in, this node's own + // derivation) is ignored on purpose -- this fake always hands back the entry keyed to + // `entry_keyed_to`, so the engine's OWN comparison (`entry.payout_puzzle_hash != + // self.own_payout_puzzle_hash`) is what decides claimable vs. refused, exactly the + // real chain behaviour this proves against. + } + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Ok(0) + } + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Ok(()) + } + } + + #[tokio::test] + async fn a_distributor_paying_this_nodes_derivation_is_claimable() { + let owner_inner = Bytes32::from([7u8; 32]); + let wrapped = own_payout_puzzle_hash(owner_inner); + let asset_id = Bytes32::from([3u8; 32]); + let mut engine = ClaimEngine::new( + OneDistributorPort { + entry_keyed_to: wrapped, + dig_asset_id: asset_id, + }, + NoHintSource, + wrapped, + 1_000_000, + 10_000_000, + asset_id, + ); + let outcomes = engine.run_cycle(1).await; + assert_eq!(outcomes.len(), 1); + assert!( + matches!( + outcomes[0], + super::super::types::ClaimOutcome::Submitted { .. } + ), + "a distributor keyed to the CAT-wrapped derivation must be claimable, got {:?}", + outcomes[0] + ); + } + + #[tokio::test] + async fn a_distributor_paying_the_unwrapped_hash_is_refused() { + let owner_inner = Bytes32::from([7u8; 32]); + let asset_id = Bytes32::from([3u8; 32]); + let wrapped = own_payout_puzzle_hash(owner_inner); + let mut engine = ClaimEngine::new( + OneDistributorPort { + // Keyed to the RAW inner hash -- the wrong derivation -- not the wrapped one. + entry_keyed_to: owner_inner, + dig_asset_id: asset_id, + }, + NoHintSource, + wrapped, + 1_000_000, + 10_000_000, + asset_id, + ); + let outcomes = engine.run_cycle(1).await; + assert_eq!(outcomes.len(), 1); + assert!( + matches!( + outcomes[0], + super::super::types::ClaimOutcome::PayoutPuzzleHashMismatch { .. } + ), + "a distributor keyed to the unwrapped hash must be refused, got {:?}", + outcomes[0] + ); + } + + // ---- A5: restart safety + clock movement, through the real persisted-config path -------- + + #[tokio::test] + async fn restart_with_a_recent_completion_skips_via_cadence_not_elapsed() { + let dir = tempfile::tempdir().unwrap(); + let cfg = RewardsClaimConfig { + cadence_seconds: 1_000, + last_cycle_completed_at: Some(500), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).unwrap(); + + let mut engine = ClaimEngine::new( + EmptyPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ) + .with_persisted_fee_window(dir.path(), cfg.cadence_seconds); + + let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 + assert!(outcomes.is_empty()); + assert_eq!( + engine.status().state, + super::super::types::ClaimLoopState::CadenceNotElapsed, + "a crash-restart loop must not immediately re-run a cycle that already ran" + ); + } + + #[tokio::test] + async fn restart_with_an_elapsed_completion_runs_a_cycle() { + let dir = tempfile::tempdir().unwrap(); + let cfg = RewardsClaimConfig { + cadence_seconds: 1_000, + last_cycle_completed_at: Some(500), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).unwrap(); + + let mut engine = ClaimEngine::new( + EmptyPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ) + .with_persisted_fee_window(dir.path(), cfg.cadence_seconds); + + let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 + assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); + assert_eq!( + engine.status().state, + super::super::types::ClaimLoopState::Nominal + ); + } + + #[tokio::test] + async fn a_future_dated_completion_fails_closed_not_underflowed() { + let dir = tempfile::tempdir().unwrap(); + let cfg = RewardsClaimConfig { + cadence_seconds: 1_000, + last_cycle_completed_at: Some(10_000), // in the future relative to `now` below + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).unwrap(); + + let mut engine = ClaimEngine::new( + EmptyPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ) + .with_persisted_fee_window(dir.path(), cfg.cadence_seconds); + + let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at + assert!(outcomes.is_empty()); + assert_eq!( + engine.status().state, + super::super::types::ClaimLoopState::PersistedStateCorrupt, + "a future-dated clock must fail CLOSED, never compute a negative/underflowed interval" + ); + } + + // ---- A4: corrupt = true (via a torn file through load_from), never engine::corrupt set -- + + #[tokio::test] + async fn a_torn_config_file_never_runs_a_cycle() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("rewards-claim.json"), b"{ not json").unwrap(); + + let cfg = RewardsClaimConfig::load_from(dir.path()); + assert!( + cfg.corrupt, + "load_from must observe the torn file as corrupt" + ); + + let mut engine = ClaimEngine::new( + EmptyPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ) + .with_persisted_fee_window(dir.path(), 1_000); + + let outcomes = engine.run_cycle(1).await; + assert!(outcomes.is_empty()); + assert_eq!( + engine.status().state, + super::super::types::ClaimLoopState::PersistedStateCorrupt + ); + } + // ---- The JOINT: the production body itself, not the halves around it ------------------- + + /// Write a `rewards-claim.json` with a fixed cadence and NO jitter, so a composition test can + /// advance the clock by an exact number of seconds and know precisely how many cycles that + /// buys. + fn write_config(dir: &Path, cadence_seconds: u64) { + RewardsClaimConfig { + enabled: true, + cadence_seconds, + jitter_seconds: 0, + ..RewardsClaimConfig::default() + } + .save_to(dir) + .unwrap(); + } + + /// THE COMPOSITION TEST. `decide_claim_driver` was tested, `drive` was tested -- and the + /// production body that joins them (`run_claim_driver_in`: load the config from the state dir, + /// construct the engine, reach `drive`) was tested by NOTHING. That is the same shape as #594, + /// which shipped a complete, fully-tested, entirely INERT claim engine: if this body returned + /// early, built the engine wrong, or never reached `drive`, every other test on this change + /// would still pass and a real node would still never claim. + /// + /// So this drives the REAL body -- the one production calls -- and asserts the anti-silence + /// property through it: zero cycles before the configured interval elapses, then an exactly + /// COUNTED number after. + #[tokio::test(start_paused = true)] + async fn the_production_body_drives_counted_cycles_from_a_written_config() { + let cadence = 100u64; + let dir = tempfile::tempdir().unwrap(); + write_config(dir.path(), cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h).await; + }); + + settle().await; + assert_eq!( + handle.cycles_driven(), + 0, + "the production body must honour the configured interval: no cycle before it elapses" + ); + + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 1, + concat!( + "one configured interval elapsed: the production body drove exactly one cycle, ", + "proving the joint between the tested gate and the tested drive loop is live" + ) + ); + + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "and it keeps driving, one cycle per configured interval" + ); + + driver.abort(); + } + + /// The same production body against the port production ACTUALLY passes it + /// ([`UnavailableClaimChainPort`], the only adapter until #3249) reports + /// [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven -- the + /// honest state of a real node today. Proves the real adapter path is reached, not only a fake + /// one: a counted cycle whose outcome names the missing chain source, never a reassuring + /// `Nominal` and never silence. + #[tokio::test(start_paused = true)] + async fn the_production_adapter_reports_chain_source_unavailable_by_name() { + let cadence = 100u64; + let dir = tempfile::tempdir().unwrap(); + write_config(dir.path(), cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in( + &state_dir, + Bytes32::from([1u8; 32]), + UnavailableClaimChainPort, + h, + ) + .await; + }); + + // Let the spawned body reach its first `sleep` before advancing: under paused time an + // `advance` that lands before the timer is registered buys no cycle at all. + settle().await; + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 1, "one cycle was driven"); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::ChainSourceUnavailable, + "with no chain adapter wired, the driven cycle must name ChainSourceUnavailable" + ); + assert_eq!( + handle.refusal(), + None, + "the loop RAN: an unavailable chain source is a cycle outcome, not a refusal to start" + ); + + driver.abort(); + } + + // ---- the cycle log: the only reader of the status surface in a shipped binary ---------- + + /// An in-memory sink a `tracing_subscriber::fmt` layer renders records into, so a test can + /// assert what a running node would actually print (the same pattern `never_log.rs` and + /// `server.rs` use for their log assertions). + #[derive(Clone)] + struct CapturedLogs(Arc>>); + + impl std::io::Write for CapturedLogs { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("the capture buffer") + .extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs { + type Writer = CapturedLogs; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + impl CapturedLogs { + fn rendered(&self) -> String { + String::from_utf8(self.0.lock().expect("the capture buffer").clone()) + .expect("the rendered lines are utf-8") + } + } + + /// Install a capturing subscriber for the duration of the returned guard. `set_default` is + /// thread-local, and `#[tokio::test]` runs a current-thread runtime, so the driver task + /// spawned below is polled on this very thread and its records land in the buffer. + fn capture_logs() -> (CapturedLogs, tracing::subscriber::DefaultGuard) { + let buffer = CapturedLogs(Arc::new(Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_writer(buffer.clone()) + .with_ansi(false) + .without_time() + .finish(); + let guard = tracing::subscriber::set_default(subscriber); + (buffer, guard) + } + + /// THE ACCEPTANCE BAR: a driven cycle is OBSERVABLE, not merely readable through an + /// in-process handle nothing in the shipped binary calls. A healthy cycle says so at `INFO`, + /// naming its state and its cycle count. + #[tokio::test(start_paused = true)] + async fn a_driven_cycle_emits_an_event_naming_its_state() { + let cadence = 100u64; + let (logs, _guard) = capture_logs(); + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let driver = tokio::spawn(async move { + drive( + empty_engine(), + cadence, + 0, + &super::super::cadence::FixedJitter(0), + { + let mut t = 0u64; + move || { + t += cadence; + t + } + }, + h, + ) + .await; + }); + + settle().await; + assert_eq!( + logs.rendered(), + "", + "no interval has elapsed, so there is nothing to report yet" + ); + + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + driver.abort(); + + let rendered = logs.rendered(); + assert!( + rendered.contains("Nominal"), + "the event must NAME the state a reader has to act on; got: {rendered}" + ); + assert!( + rendered.contains("cycles_driven=1"), + "and the cycle count that distinguishes a running loop from a stalled one; got: {}", + rendered + ); + assert!( + rendered.contains("rewards_claim"), + "under the module's own target, so it can be filtered on; got: {rendered}" + ); + } + + /// A cycle that CANNOT claim -- today's real production path, with no chain adapter wired -- + /// must be a WARNING naming the state, not an `INFO` line that reads like health. This is the + /// defect the whole ticket exists to remove: silence covering a permanent inability to earn. + #[tokio::test(start_paused = true)] + async fn a_cycle_that_cannot_claim_warns_and_names_why() { + let cadence = 100u64; + let (logs, _guard) = capture_logs(); + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let driver = tokio::spawn(async move { + drive( + ClaimEngine::new( + UnavailableClaimChainPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ), + cadence, + 0, + &super::super::cadence::FixedJitter(0), + { + let mut t = 0u64; + move || { + t += cadence; + t + } + }, + h, + ) + .await; + }); + + settle().await; + tokio::time::advance(Duration::from_secs(cadence)).await; + settle().await; + driver.abort(); + + let rendered = logs.rendered(); + assert!( + rendered.contains("WARN"), + "earning nothing is a warning, not routine chatter; got: {rendered}" + ); + assert!( + rendered.contains("ChainSourceUnavailable"), + "and it must name WHY this node is not claiming; got: {rendered}" + ); + } + + /// `jitter_seconds` is read from the persisted config WITHOUT a clamp, so the maximum `u64` + /// reaches `OsJitter`. An overflowing `bound + 1` there panics the detached driver task, + /// which never restarts -- the claim loop would die silently for the process lifetime. + #[test] + fn an_unclamped_max_jitter_bound_does_not_panic_the_driver() { + let bound = std::hint::black_box(u64::MAX); + let offset = OsJitter.jitter_seconds(bound); + assert!( + offset <= bound, + "the draw must stay within 0..=bound; got {offset}" + ); + } + + // ---- the config-read sanitizer: a value must not be able to switch the loop off --------- + + /// Write a config with BOTH schedule fields chosen by the caller, so a test can persist a + /// value production would otherwise honour to the letter. + fn write_schedule_config(dir: &Path, cadence_seconds: u64, jitter_seconds: u64) { + RewardsClaimConfig { + enabled: true, + cadence_seconds, + jitter_seconds, + ..RewardsClaimConfig::default() + } + .save_to(dir) + .unwrap(); + } + + /// An out-of-range `cadence_seconds` must be REPLACED by the documented default, and the + /// substitution must be visible: a value this large means "never fire again", and silently + /// honouring it reopens #594's inert-but-green shape one level up, in the config file. + #[test] + fn an_out_of_range_cadence_is_replaced_by_the_default_and_warned() { + let (logs, _guard) = capture_logs(); + + let rejected = std::hint::black_box(u64::MAX); + let (cadence, jitter) = sanitized_schedule(rejected, 0); + + assert_eq!( + cadence, CLAIM_CADENCE_SECONDS_DEFAULT, + "an out-of-range cadence must fall back to the documented default" + ); + assert_eq!(jitter, 0, "a legitimate zero jitter is left alone"); + + let rendered = logs.rendered(); + assert!( + rendered.contains("WARN"), + "ignoring a configured value is a warning, not routine chatter; got: {rendered}" + ); + assert!( + rendered.contains("cadence_seconds"), + "the warning must name the FIELD that was ignored; got: {rendered}" + ); + assert!( + rendered.contains(&rejected.to_string()) + && rendered.contains(&CLAIM_CADENCE_SECONDS_DEFAULT.to_string()), + "and both the rejected and the substituted value; got: {rendered}" + ); + } + + /// The same for `jitter_seconds` -- and, through the REAL production body, that the loop still + /// drives counted cycles instead of never firing again. Without the sanitizer, + /// `next_interval_seconds` saturates on this value and no cycle is ever driven: green, silent + /// and unpaid. + #[tokio::test(start_paused = true)] + async fn an_out_of_range_jitter_is_replaced_and_the_loop_still_drives_cycles() { + let (logs, _guard) = capture_logs(); + + let cadence = 100u64; + let dir = tempfile::tempdir().unwrap(); + write_schedule_config(dir.path(), cadence, std::hint::black_box(u64::MAX)); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h).await; + }); + + settle().await; + // The substituted jitter is the DEFAULT hour, so one interval is at most cadence + 3600s. + tokio::time::advance(Duration::from_secs(cadence + CLAIM_JITTER_SECONDS_DEFAULT)).await; + settle().await; + + assert!( + handle.cycles_driven() >= 1, + concat!( + "an out-of-range jitter must not switch the claim loop off: with the default ", + "substituted, at least one cycle is driven within cadence + the default jitter" + ) + ); + + let rendered = logs.rendered(); + assert!( + rendered.contains("WARN") && rendered.contains("jitter_seconds"), + "the ignored jitter field must be named at WARN; got: {rendered}" + ); + assert!( + rendered.contains(&CLAIM_JITTER_SECONDS_DEFAULT.to_string()), + "and the substituted default must be readable; got: {rendered}" + ); + + driver.abort(); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index 5d5d4a57..7804be4c 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -32,19 +32,22 @@ //! 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. +//! # Wired into node startup (DIG-Network/dig_ecosystem#3268) +//! [`driver::spawn_claim_driver_from_config`] is the one call `dig-node-service::server`'s +//! `serve_with_shutdown` makes: it is gated on `RewardsClaimConfig::enabled` AND +//! `Config::enable_chain_sync` (the same flag `spawn_collateral_census` and +//! `mirror::bond_verify::spawn_bond_verifier_install` already gate on), and when both are true it +//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever. +//! [`driver::handle`] is the IN-PROCESS accessor a future RPC can read once DIG-Network/dig_ecosystem#3249 +//! lands a real [`ClaimChainPort`] adapter and the `ClaimStatus` wire semantics are re-derived +//! against it — this module puts nothing on the wire itself (see `driver`'s own module doc for +//! why). Until #3249 lands, the only production adapter is still [`UnavailableClaimChainPort`], so +//! every real cycle reports [`ClaimLoopState::ChainSourceUnavailable`] and submits nothing — the +//! honest state, not a silent no-op. mod cadence; mod config; +mod driver; mod engine; mod hints; mod parser; @@ -56,6 +59,7 @@ pub use config::{ RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, }; +pub use driver::{handle, spawn_claim_driver_from_config, ClaimDriverRefusal, ClaimLoopHandle}; pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; pub use parser::parse_launch_comment; diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index a646f962..cec18b25 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2204,6 +2204,17 @@ where // (a tested unit, #1864) so it cannot be silently flipped to always- or never-spawn. crate::self_heal::spawn_driver_if_service(); + // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251): drives + // `rewards_claim::ClaimEngine::run_cycle` on a jittered cadence so + // `RewardsClaimConfig::enabled = true` stops being a false statement. Gated the same way the + // census and bond-verifier spawns above are -- `enable_chain_sync` already means "this node + // talks to the Chia network", and a harness sets it false precisely so nothing dials. The + // service-gate lives inside the seam (a tested unit, mirroring `self_heal::spawn_driver_if`) + // so it cannot be silently flipped to always- or never-spawn. The only production chain + // adapter until DIG-Network/dig_ecosystem#3249 lands is `UnavailableClaimChainPort`, so every + // real cycle reports `ChainSourceUnavailable` and submits nothing -- the honest state. + crate::rewards_claim::spawn_claim_driver_from_config(config.enable_chain_sync); + // Best-effort wallet mTLS listener (#368, Sage byte-parity, node-class clients, §5.3). Binds // loopback only on [`DEFAULT_MTLS_PORT`], which is deliberately NOT Sage's own RPC port // (dig-node#260). A bind failure is NON-FATAL — the wallet stays reachable over the plain-HTTP From 3d2d55b737d17324e4209a9cf09829b038f82c08 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:02:36 -0700 Subject: [PATCH 14/29] chore: record main in develop after the v0.257.0 cut (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): v0.256.0 -- reward distributor prover, peer claim loop, prover-status RPC (#602) * 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 * 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 * 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 * 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 * 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 * 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 * 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. * 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) * 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 * feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594) * 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 * 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) * 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 --------- Co-authored-by: Claude * chore(release): v0.257.0 -- the reward distributor lifecycle starts running (#607) * 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 * 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 * 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 * 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 * 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 * 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 * 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. * 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) * 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 * feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594) * 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 * feat(rewards): chain port + listRewardDistributors (unit 2) (#604) * chore: open lane for #3269 (unit 2 -- rewards chain port + listRewardDistributors) * chore(rewards): add dig-rewards-coin 0.2 dep; record blocked-reader finding dig-rewards-coin 0.2.0 is published but ships no chain reader (its own state.rs module doc: SPEC 12.1's read_distributor is withheld pending DIG-Network/dig_ecosystem#3267). Separately, no registry in this codebase records which distributors this node funds. A "real" RewardsChainPort adapter over 0.2.0 therefore has no honest way to answer any of the four trait methods with live data yet -- reimplementing read_distributor or inventing a funded-distributor registry would be exactly the unreviewed money-shape guess kernel invariant 6 says to escalate instead of build. UnavailableChainPort remains the only production adapter; port.rs records the finding for the next unit. Refs #3269 * docs(rewards): revert dep add, name both blockers with evidence in port.rs Per L1 direction: an unused dig-rewards-coin dep with no consumer is inert weight and would want whichever version ships the reader (0.3.0+, PR#6 open against DIG-Network/dig-rewards-coin), not 0.2 -- so it's reverted here and belongs in the unit that actually consumes it. Expanded the port.rs module doc to name both blockers explicitly with what was read (state.rs:1-31, #3267, the open reader PR) and the negative grep that found no funder-ownership registry anywhere in the tree, plus why serving dig.listRewardDistributors through UnavailableChainPort was considered and rejected (false capability signal; the exact "dispatch surface with no function behind it" pattern dig-node#593 was the last PR allowed to land on). No RewardsChainPort adapter, no Node wiring, no dispatch arm -- all three reward methods stay -32601 pending #3267 and a funder-ownership registry (parallel tickets, both required). Refs #3269 * feat(rewards): durable funder-ownership registry (identity only) (#606) * feat(rewards): durable funder-ownership registry (identity only) Records WHICH reward distributors this node funds -- launcher id plus the store id when the funding act knew it -- and nothing else. No amount can be recorded: every money figure here is chain-derived and goes stale, and dig_ecosystem#3286's wrapping u64 share multiply means a figure crossing this boundary can already be wrong. Durable storage would make it permanent. Persistence mirrors rewards_claim::engine::ClaimEngine: an optional state directory (absent = inert, so tests and default builds need no disk), atomic write, and a corrupt record is never overwritten. The set is never cached on the registry -- every read re-reads the file -- so no transient state lives on the struct across calls (the engine's F16/F18 discipline). The read outcome is closed and distinguishes funds-nothing from every unknown: NotConfigured (no state dir / dir missing / nothing written yet), PersistedStateCorrupt and IoFailed. A corrupt record is quarantined by COPY and left in place, so the next read is corrupt too rather than decaying into an empty list -- SPEC 2.4 clause 1 in the place it costs most, since an empty dig.listRewardDistributors tells an operator it funds no distributors. Node carries it in a OnceLock slot with pub(crate) accessors, mirroring mirror_pointers and reward_prover_statuses. Nothing installs it in production yet: no dig-node code funds a distributor, and the startup wiring belongs to dig_ecosystem#3268, so the slot is marked the same way register_reward_prover_status is. Refs #3285 Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): drop a duplicated funded_distributors initializer Two test-only `Node` literals got the slot twice (E0062), because the inserted line's own indentation made the wider-indented site match twice. Refs #3285 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) * feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) (#605) * feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) The peer reward-claim engine shipped complete and tested in #594 but INERT: nothing constructed it, so the 86400s cadence never fired while `rewards_claim.enabled` defaulted to `true` -- a config asserting a subsystem is on while nothing runs. `rewards_claim/driver.rs` is a SCHEDULER, not a chain adapter: it derives this node's own payout puzzle hash, loads `RewardsClaimConfig`, builds a `ClaimEngine` against the only production port that exists (`UnavailableClaimChainPort`, until #3249 lands a real one) and drives `run_cycle` every `cadence_seconds + jitter`, jitter drawn from the OS CSPRNG. `server.rs`'s `serve_with_shutdown` makes exactly one call into it, beside `self_heal::spawn_driver_if_service()`. `enabled = true` now means: a background task exists, drives a counted cycle per interval, and its outcome is readable in-process as a NAMED state. With `UnavailableClaimChainPort` every cycle honestly reports `ChainSourceUnavailable` -- the gap is loud instead of silent. Anti-silence: `ClaimLoopHandle` carries a monotonic `cycles_driven` counter alongside the status, because `Idle` before the first cycle is correct and honest, so status alone cannot tell "scheduler never fired" from "nothing was claimable". The gate takes an INJECTED handle rather than reading the process-wide singleton, so `ClaimDriverRefusal::{Disabled, ChainSyncDisabled, NoOperatorWallet}` and "spawned but never ticked" are four pairwise-distinct readings a test asserts in-process. Nothing goes on the wire: no RPC method, dispatch row, handler or OpenRPC entry. `ClaimStatus` stays off the wire until #3249's real adapter lets the status surface be re-derived against it. Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): silence the deliberately-ignored fake-port argument (#3268) `OneDistributorPort::own_entry` ignores the puzzle hash the engine passes in on purpose -- the fake always returns the entry keyed to `entry_keyed_to` so the ENGINE's own comparison is what decides claimable vs. refused. Named it `_payout_puzzle_hash` (clippy `-D unused-variables`) and moved the rationale onto the parameter, where the next reader meets it. Co-Authored-By: Claude Opus 5 (1M context) * test(rewards): close the untested joint between the claim gate and the drive loop (#3268) `decide_claim_driver` was tested and `drive` was tested, but the production body joining them -- load the config from the state dir, derive the engine, reach `drive` -- was exercised by nothing. That is the exact shape of #594, which shipped a complete, fully-tested and entirely inert claim engine: had this body returned early, built the engine wrong, or never reached `drive`, every test on this change would still have passed and a real node would still never claim. Split `run_claim_driver` on the same `load` / `load_from` pattern the config itself uses: `run_claim_driver_in(state_dir, own_payout_puzzle_hash, port, handle)` holds the whole body and is generic over the port, and `run_claim_driver` is reduced to the wallet-derivation adapter that cannot be reached from a test. Adds two tests through the real body: counted cycles from a written config (zero before the interval, exactly one per interval after), and `UnavailableClaimChainPort` reporting `ChainSourceUnavailable` by name on a driven cycle -- proving the production adapter path is reached, not only a fake. No behaviour change: same config, same engine construction, same port. Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): settle before advancing, and keep the wrapped assertion out of rustfmt's reach (#3268) Two repairs to the new composition tests: - The `ChainSourceUnavailable` test advanced the paused clock before the spawned body had reached its first `sleep`, so the timer was not yet registered and the advance bought no cycle at all -- it read zero cycles, not a driven one. A `settle()` first, mirroring the counted-cycles test. - rustfmt rejoined a `\`-continued assertion message into one line, leaving 14 literal spaces mid-sentence and tripping the repo's own `continuation_guard`. `concat!` states the wrap explicitly, so no formatter pass can reintroduce the run. Co-Authored-By: Claude Opus 5 (1M context) * feat(rewards): emit a per-cycle event so the claim loop has a reader (#3268) The adversarial gate blocked #605 on this: the PR justified itself by making an inert subsystem loud, but nothing in the shipped binary could hear it. ClaimLoopHandle had no caller outside driver.rs tests, drive() emitted no event, and all three tracing calls fired only on paths where the loop does NOT run -- so on the default path (enabled=true, chain sync on) the observable output was identical to before the PR: silence. Today that silence covers a permanent ChainSourceUnavailable; after #3249 it would also cover Faulted, PersistedStateCorrupt and ClaimableButNotClaiming. log_cycle() now names the state and the cycle count after every cycle -- info for Nominal, warn for everything else, because "this peer is earning nothing and here is why" is a warning, not routine chatter. Tested by capturing the subscriber output rather than asserting the call site exists, since this ticket exists because a guarantee that cannot be observed in a running node is not a guarantee. Refs DIG-Network/dig_ecosystem#3268 Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): stop a u64::MAX jitter bound panicking the claim driver `OsJitter::jitter_seconds` computed `bound + 1` for its modulus. `jitter_seconds` comes from the node's persisted `rewards_claim` config and is not clamped, so a config carrying `u64::MAX` overflow-panicked inside the detached claim-driver task -- which has no restart and emits no further log output, so the claim loop would die silently for the rest of the process lifetime. `saturating_add(1)` keeps the draw within `0..=bound` for every input; the composed `next_interval_seconds` range is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): sanitize the claim schedule so no config value silently disables the loop `next_interval_seconds` saturates instead of panicking, so a persisted `jitter_seconds = u64::MAX` no longer crashes the driver -- it schedules the next cycle ~585 billion years out. The claim loop then never fires again: no cycle, no `log_cycle` line, and a permanent, reassuring `0` cycle count. That is #594's inert-but-green shape reopened one level up, in the config file. `run_claim_driver_in` now sanitizes both schedule fields where it reads them, before either reaches the engine's fee window or `drive`: - `CLAIM_SCHEDULE_SECONDS_MAX = 31 * 24 * 60 * 60` (31 days) -- above every documented default (86,400s cadence, 3,600s jitter) and above "claim monthly", while excluding everything that means never. - out of range (or a zero cadence, which would busy-loop) substitutes the published default and emits `tracing::warn!` naming the field, the rejected value and the substituted one. Nothing is accepted silently. `config.rs` is untouched: it keeps reporting what is on disk. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) * chore(release): v0.257.0 -- the reward distributor lifecycle starts running Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude --------- Co-authored-by: Claude From 24cde57463312203d0406554c6f4ecfc587557c0 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:17:53 -0700 Subject: [PATCH 15/29] feat(rpc): serve the five reward-distributor RPC methods (#3269) Serves all five reward-distributor RPC methods, all Tier::Control and none peer-reachable: dig.listRewardDistributors, dig.getRewardDistributor, dig.listRewardDistributorCommitments, dig.getRewardProverStatus and dig.getPayeeRewardClaimStatus. Migrates the partial-knowledge results to Half/ClaimLogObservation so an unread half is NotConsulted rather than a reassuring zero, and refuses getRewardProverStatus on a zeroed identity instead of silently dropping the record. The chain ADAPTER remains dig_ecosystem#3310's: install_reward_chain_port has no non-test caller, so port-backed methods answer REWARD_CHAIN_UNAVAILABLE in production and the claimable half is always NotConsulted. Bumps dig-rpc-protocol 0.11 -> 0.12 across dig-node-core and dig-node-service. Gates at head 95ca5f9d: 5/5 required checks SUCCESS by name, 3414 tests passed, coverage 90.17%, security PASS, 0 unresolved review threads. Follow-ups: #3327 (mutation probe), #3328 (range-check comment). Refs #3268 Refs #3269 Refs #3246 Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 16 +- crates/dig-node-core/Cargo.toml | 26 +- crates/dig-node-core/src/lib.rs | 1342 +++++++++++++++-- crates/dig-node-core/src/peer.rs | 17 + crates/dig-node-core/src/rewards/mod.rs | 15 +- crates/dig-node-core/src/rewards/port.rs | 184 ++- .../src/rewards/spec_constants.rs | 8 +- crates/dig-node-core/src/rewards/writes.rs | 3 +- .../src/seams/dig_rpc/dispatch.rs | 402 ++++- crates/dig-node-core/tests/dependency_tree.rs | 19 +- .../tests/reward_methods_tier_guard.rs | 15 + crates/dig-node-service/Cargo.toml | 12 +- 12 files changed, 1855 insertions(+), 204 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8839fe2..b68e9d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9a6e23899a1a58ff8f070307897799b0142b3d9447652676ffae7c131da7c" +checksum = "d705dda562d63c03a1a481087c3b9f632b90982676ea8bb4e55f2cd04c4465fc" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3145,9 +3145,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6d28173f5ac2fb725d70d81491918bb9dbdc1691745bf044524aee8484333ef" +checksum = "01657d5ef42a4ebf038d53b3b398997057417558cd6c059f4f58716e68676f34" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", @@ -3185,9 +3185,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac1005c43d63ca61d3ca6391cf6d3ff08b7138bb22e237ae76c5157d7677652" +checksum = "a3be02a4acba35b9580f3655c95cf4e127031e100e684069a17fb2829bb4e68b" dependencies = [ "dig-dht", "dig-nat", @@ -3211,9 +3211,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" +checksum = "5eb22a4741239303c9558b00d20c0cb6d9afad965382656bece3d4bbe9641f24" dependencies = [ "serde", "serde_json", diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index e4ad1616..656af50a 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -191,7 +191,14 @@ 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.11.0" +# +# Moved to 0.12 (dig_ecosystem#3269, final leg): 0.12.0 is the release this node adopts the reward +# RPC surface from (`Method::ListRewardDistributors`, `Method::GetPayeeRewardClaimStatus`, and the +# `GetRewardProverStatusResult.statuses` shape change to `Half`). `dig-peer` +# (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) below all moved onto this line +# in the same batch, so exactly one `dig-rpc-protocol` still resolves (asserted by +# `crates/dig-node-core/tests/dependency_tree.rs`). +dig-rpc-protocol = "0.12" # 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. @@ -461,7 +468,10 @@ dig-pex = "0.1.1" # 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" +# +# Moved to 0.24 (dig_ecosystem#3269, final leg): 0.24.0 is on `dig-rpc-protocol` 0.12, matching this +# crate's own move to 0.12 above. +dig-download = "0.24" # -- 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 @@ -476,7 +486,10 @@ dig-download = "0.23" # # 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" +# +# Moved to 0.15 (dig_ecosystem#3269, final leg): 0.15.0 is on `dig-rpc-protocol` 0.12, matching this +# crate's own move to 0.12 above. +dig-peer = "0.15" # -- 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 @@ -509,7 +522,10 @@ dig-peer = "0.14" # 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" +# +# Moved to 0.13 (dig_ecosystem#3269, final leg): 0.13.0 is on `dig-peer ^0.15`, matching this crate's +# own move to `dig-peer = "0.15"` above and closing the cascade at `dig-rpc-protocol` 0.12. +dig-peer-selector = "0.13" # 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 @@ -593,7 +609,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.23", features = ["testkit"] } +dig-download = { version = "0.24", 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 50e211a7..9578bba1 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -583,13 +583,29 @@ pub struct Node { /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is /// one: the FFI/browser path has no state directory and must keep constructing a `Node` /// without one. Nothing installs it in production yet — nothing in dig-node funds a - /// distributor today (`rewards::port`'s module doc, blocker 2), and the startup wiring that - /// would call [`Node::install_funded_distributor_registry`] with the node's state directory - /// belongs to dig_ecosystem#3268. Until then the slot stays empty, and + /// distributor today (`rewards::port`'s module doc, blocker 2). WHICH ticket owns the startup + /// wiring that would call [`Node::install_funded_distributor_registry`] with the node's state + /// directory is tracked separately, and it is NOT dig_ecosystem#3268, whose scope is the claim + /// loop and `ClaimStatus` and which names neither this registry nor that call. Until a ticket + /// wires it the slot stays empty, and /// [`Node::funded_distributors_read`] answers /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`] — UNKNOWN, deliberately never an /// empty funded set. funded_distributors: OnceLock, + /// The chain seam `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` + /// (dig_ecosystem#3269 units 1-2) read through — [`rewards::port::RewardsChainPort`]. + /// + /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is + /// one. Nothing installs a real adapter yet: the one that calls `dig-rewards-coin` lives in + /// `dig-node-service`, a sibling unit this crate never depends on (see `rewards::port`'s + /// module doc). WHICH ticket carries that adapter is an OPEN question — it is tracked + /// separately from dig_ecosystem#3268, whose scope is the claim loop and `ClaimStatus` and + /// which names neither `distributor_report` nor this installer. Do not read a ticket number + /// into this comment that nobody has verified. Until the adapter is installed, both handlers + /// answer + /// [`rewards::port::ChainPortError::Unavailable`] — a real "no chain source is wired yet", + /// never a silent zero or empty list. + reward_chain_port: OnceLock>, } impl Node { @@ -626,9 +642,11 @@ impl Node { /// if a registry is already installed, in which case NOTHING changed — a second install must /// not be able to swap a live registry for an inert one behind a caller's back. /// - /// Called from tests today: the startup path that would install a real one lives in - /// dig_ecosystem#3268's files, so clippy's non-test lib target sees no production caller yet. - /// `allow(dead_code)` stands in for that missing caller — remove it when #3268 wires the call. + /// Called from tests today: no production startup path installs one, so clippy's non-test + /// lib target sees no production caller and `allow(dead_code)` stands in for it. Remove the + /// attribute when that wiring lands. Its owning ticket is tracked separately and is NOT + /// dig_ecosystem#3268 (claim loop + `ClaimStatus`), which names neither this registry nor this + /// call — do not read the attribute as a claim about #3268's scope. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn install_funded_distributor_registry( &self, @@ -662,6 +680,34 @@ impl Node { ), } } + + /// Install this node's reward-distributor chain-read adapter (dig_ecosystem#3269 units 1-2), + /// once. Returns `false` if one is already installed, in which case NOTHING changed — mirrors + /// [`Node::install_funded_distributor_registry`]'s same one-shot discipline. + /// + /// `pub` because this is the INJECTION POINT, and the adapter is built one crate UP: + /// `dig-node-service` constructs it over `dig-rewards-coin` and injects it downward + /// (dig_ecosystem#3310, which names both `distributor_report` and this function). A + /// `pub(crate)` setter made that architecture unbuildable while its own doc described it, and + /// the `#[cfg_attr(not(test), allow(dead_code))]` that used to sit here was hiding the + /// unreachability rather than standing in for a merely-absent caller. + /// + /// Being callable from outside does NOT relax the single-install discipline: a second install + /// must not be able to swap a live adapter for an inert one behind a caller's back, so the + /// second call returns `false` and changes nothing. + pub fn install_reward_chain_port( + &self, + port: Arc, + ) -> bool { + self.reward_chain_port.set(port).is_ok() + } + + /// The installed reward-distributor chain-read adapter, or `None` when nothing has installed + /// one — `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` must treat `None` + /// exactly like [`rewards::port::ChainPortError::Unavailable`], never a zero or empty answer. + pub(crate) fn reward_chain_port(&self) -> Option<&Arc> { + self.reward_chain_port.get() + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4911,6 +4957,7 @@ impl Node { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }) } @@ -5252,6 +5299,7 @@ pub(crate) mod test_support { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; (Arc::new(node), td) } @@ -5390,9 +5438,18 @@ mod tests { /// ([`RESOURCE_UNAVAILABLE`] and [`RESOURCE_NOT_AVAILABLE`]) are correctly read as one condition /// under two names rather than as a collision. /// - /// Deliberately NOT exhaustive yet: the chat band (`-32050`..`-32052`) is undeclared upstream - /// entirely. That is pre-existing and out of this change; adding it is a follow-up that has to - /// resolve the condition, not the table. + /// Holds the numbers this crate emits that `dig_rpc_protocol::ErrorCode::ALL` does NOT declare, + /// plus the locally-named constants for numbers it DOES declare (so a local re-spelling of a + /// canonical condition cannot drift from the owner's name). What makes it COMPLETE is not this + /// list: it is [`every_wire_code_this_crate_mentions_is_classified`], which scans these sources + /// and requires every `-32xxx` it finds to be canonically declared or listed here. Forgetting to + /// register a number is therefore what fails - the previous version of this guard asserted + /// `len() >= 10`, a measure of SIZE rather than completeness, and stayed green at 1134/3402 + /// while this crate emitted `-32033`, already `dig-node-service`'s `ControlIngressLimited`. + /// + /// The chat band (`-32050`..`-32052`) is no longer a gap: `dig-rpc-protocol` 0.11 declares + /// `NoIdentity`/`NoPeerNetwork`/`SendFailed` for exactly those numbers, so the taxonomy answers + /// the collision question for them and the scan classifies them canonically. /// /// `content_serve::SERVE_UNREADABLE` used to be named here as a second `-32000` gap. It was not /// one: its code field's only sink answered `502` from the message and never read the number, so @@ -5417,6 +5474,21 @@ mod tests { (CONTROL_UNAUTHORIZED, "UNAUTHORIZED"), (CONTROL_NOT_SUPPORTED, "NOT_SUPPORTED"), (CONTROL_ERROR, "CONTROL_ERROR"), + // The two below are LITERALS because each lives behind a constant in a private module + // (`seams::capsule::push_capsule`, `seams::dig_rpc::dispatch`) this test module cannot + // name. Both are undeclared upstream, so the canonical leg has nothing to compare them + // against and the condition string exists only to make a local collision visible. + // + // `-32001`: the push surface's authorization refusal. `seams::dig_rpc::errors` deliberately + // emits it with NO `data.code` (an invented machine name is worse than an absent one), so + // this condition name is internal to this guard and is not a wire name. + ( + -32001, + "PUSH_AUTHORITY_REFUSED (local, undeclared upstream)", + ), + // `-32002`: `ENGINE_WARMING` - the peer tier has genuinely not been consulted yet. Distinct + // from `-32004`, which means it WAS consulted and the content is still not found. + (-32002, "ENGINE_WARMING (local, undeclared upstream)"), ]; /// **Proves:** no number this node emits is already spoken for — neither by @@ -5443,9 +5515,13 @@ mod tests { fn no_local_wire_code_collides_with_a_different_canonical_code() { // Side effects first: a table that has silently shrunk to nothing, or lost the code under // review, would make every assertion below vacuously true. - assert!( - LOCAL_WIRE_CODES.len() >= 10, - "the local wire-code table lost entries; a shrinking table makes this guard vacuous" + // An EXACT count, not a floor: a floor cannot see a table that grew by an entry nobody + // checked, and `>= 10` is what let a real collision through. Changing this number is a + // deliberate act that says the table below was re-read. + assert_eq!( + LOCAL_WIRE_CODES.len(), + 12, + "the local wire-code table changed size; re-read it and update this count" ); // `CONTENT_MISS_INCONCLUSIVE` deliberately LEFT this table: `dig-rpc-protocol` 0.10 declares // it, so it is no longer a local number and the owner answers the collision question for it. @@ -5488,6 +5564,124 @@ mod tests { } } + /// Every `-32xxx` wire number that appears anywhere in this crate's own sources, DISCOVERED by + /// reading them rather than by hand-listing them - the list is derived from the emitting sites, + /// so it cannot fall behind them. + /// + /// Comment-only lines are skipped: the docs in this crate legitimately DISCUSS numbers it does + /// not emit (another implementation's assignment, a rejected proposal), and requiring those to + /// be registered would push the guard towards being weakened rather than kept. + /// + /// A code mentioned in live code but never actually emitted - a test asserting one, say - is + /// still required to be classified. That is deliberate: classification is cheap, and the + /// alternative is teaching the scanner to recognise an "emitting site", which is exactly the + /// judgement call a forgotten registration hides behind. + fn wire_codes_mentioned_in_this_crate() -> std::collections::BTreeSet { + fn scan_line(line: &str, out: &mut std::collections::BTreeSet) { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") { + return; + } + // Scanned as BYTES, not by slicing the &str: these sources carry non-ASCII prose, and + // an arbitrary byte index into a `&str` is not guaranteed to be a char boundary. + let bytes = line.as_bytes(); + for start in 0..bytes.len().saturating_sub(5) { + if &bytes[start..start + 3] != b"-32" { + continue; + } + let digits = &bytes[start + 3..start + 6]; + // Exactly three digits: `-32000`..`-32999` is the band. A longer digit run is some + // other number that merely begins this way and is not a wire code. + if !digits.iter().all(u8::is_ascii_digit) + || bytes.get(start + 6).is_some_and(u8::is_ascii_digit) + { + continue; + } + let text = std::str::from_utf8(&bytes[start..start + 6]) + .expect("six ASCII bytes are valid UTF-8"); + if let Ok(code) = text.parse::() { + out.insert(code); + } + } + } + + fn walk(dir: &std::path::Path, out: &mut std::collections::BTreeSet) { + let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {path:?}: {e}")); + for line in text.lines() { + scan_line(line, out); + } + } + } + } + + let mut found = std::collections::BTreeSet::new(); + walk( + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")), + &mut found, + ); + found + } + + /// **Proves:** every wire number these sources contain is accounted for - either declared by + /// the taxonomy owner (`dig_rpc_protocol::ErrorCode::ALL`, resolved through + /// `seams::dig_rpc::errors::taxonomy_code` so the taxonomy is never restated here) or listed in + /// [`LOCAL_WIRE_CODES`] as a number this crate occupies that upstream does not declare. + /// + /// **Catches:** the defect that defeated the collision guard beside it - a number nobody + /// registered is invisible to a guard built over a registry. `-32033` was emitted by this crate + /// while already being `dig-node-service`'s `ControlIngressLimited`, and the old + /// `LOCAL_WIRE_CODES.len() >= 10` assertion stayed green because the number was never added. + /// Under THIS test it would have gone red without anyone remembering to register it. + #[test] + fn every_wire_code_this_crate_mentions_is_classified() { + let found = wire_codes_mentioned_in_this_crate(); + + // Scanner-liveness first: a walk that read nothing, or a comment filter that ate every + // line, would make the loop below vacuously true - the same failure mode being fixed here. + assert!( + found.len() >= 25, + "the source scan found only {} wire codes; it is not reading these sources", + found.len() + ); + for known in [CONTROL_ERROR, RESOURCE_NOT_AVAILABLE, -32602] { + assert!( + found.contains(&known), + "the scan missed {known}, which is emitted in these sources; it is not reading what it claims" + ); + } + + for number in &found { + let canonical = crate::seams::dig_rpc::errors::taxonomy_code(*number); + let local = LOCAL_WIRE_CODES.iter().find(|(n, _)| n == number); + assert!( + canonical.is_some() || local.is_some(), + "wire code {number} is in these sources but is neither declared by dig-rpc-protocol nor registered in LOCAL_WIRE_CODES" + ); + } + + // Non-vacuity, without writing the number as a literal: writing `-32033` here would itself + // be a mention this scan must classify, which is the mechanism having teeth. Built by + // arithmetic instead, it shows the classifier REJECTS the number that slipped through, so + // re-introducing it anywhere in these sources fails the loop above. + let slipped_through = CONTROL_ERROR - 1; + assert!( + crate::seams::dig_rpc::errors::taxonomy_code(slipped_through).is_none() + && !LOCAL_WIRE_CODES.iter().any(|(n, _)| *n == slipped_through), + "{slipped_through} must be unclassified, or this test cannot fail on it" + ); + assert!( + !found.contains(&slipped_through), + "{slipped_through} is back in these sources; it is already another surface's code" + ); + } + /// A per-THREAD counting allocator, installed process-wide only for the test binary. /// /// The #2160 acceptance bar is MEASURED, not reasoned: the peak-RSS test drives one cold decode @@ -6011,7 +6205,8 @@ mod tests { } /// dig_ecosystem#3285: a node with no funder registry installed — which is EVERY production - /// node until #3268 wires one — must read UNKNOWN, never an empty funded set. + /// node today, since no startup path installs one and the ticket that will is tracked + /// separately (it is not #3268) — must read UNKNOWN, never an empty funded set. /// **Catches:** a `funded_distributors_read` that defaults to `FundsNothing`, or a `Vec`/ /// `Option` return that a caller would render as `[]`. #[test] @@ -6106,6 +6301,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; (node, td) } @@ -6242,6 +6438,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; // Missing before the pull. @@ -6312,6 +6509,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6413,6 +6611,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6492,6 +6691,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -9227,9 +9427,14 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); - let statuses = resp["result"]["statuses"] + assert_eq!( + resp["result"]["statuses"]["outcome"], + json!("consulted"), + "the in-process registry read always succeeds: {resp}" + ); + let statuses = resp["result"]["statuses"]["items"] .as_array() - .expect("result.statuses is an array"); + .expect("result.statuses.items is an array"); assert_eq!(statuses.len(), 1, "one registered handle: {resp}"); let s = &statuses[0]; @@ -9302,68 +9507,28 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); + assert_eq!(resp["result"]["statuses"]["outcome"], json!("consulted")); assert_eq!( - resp["result"], - json!({"statuses": []}), + resp["result"]["statuses"]["items"], + json!([]), "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. + /// **Proves:** a zeroed `root` alone (an OBSERVATION field, not an identity field) never + /// causes exclusion or a whole-call refusal — a freshly-registered prover that has not + /// completed its first cycle is a legitimate state, and a `tracing::debug!` (never `warn!`) + /// fires naming `root`, distinct from the missing-identity refusal covered by the two tests + /// below. **Catches:** an over-correction that starts refusing on a zeroed `root` too, which + /// would make a healthy, just-not-yet-cycled prover invisible. #[test] - fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { + fn get_reward_prover_status_returns_a_zeroed_root_record_with_a_debug_log() { 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]; @@ -9371,8 +9536,6 @@ mod tests { 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), @@ -9385,55 +9548,105 @@ mod tests { 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}" + resp["result"]["statuses"]["outcome"], + json!("consulted"), + "a zeroed root alone must never trigger the missing-identity refusal: {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). + let statuses = resp["result"]["statuses"]["items"] + .as_array() + .expect("result.statuses.items is an array"); + assert_eq!(statuses.len(), 2, "both entries are returned: {resp}"); assert!( - logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), - "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" + logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), + "expected a DEBUG line naming exactly root as zeroed: {logs}" ); assert!( - logs.contains("WARN") && logs.contains(r#"zeroed_fields=["store_id"]"#), - "expected a WARN naming exactly store_id as zeroed, got: {logs}" + !logs.contains("WARN"), + "a zeroed root alone must never log at WARN: {logs}" + ); + } + + /// **Proves (dig_ecosystem#3269 fix939 — the silent-drop defect):** a record with a missing + /// identity field (`launcher_id` or `store_id`), when the caller narrows the request with + /// `params.launcher_id` naming exactly that record, makes `dig.getRewardProverStatus` refuse + /// the WHOLE call with `REWARD_ZERO_IDENTITY` — never `{"outcome":"consulted","items":[]}`, + /// which `dig-rpc-protocol` 0.12.0's `Half::Consulted` contracts as "the complete answer" + /// (`types.rs:1666`). A filtered empty list under `Consulted` reads as "I looked, there is + /// nothing" when the truth is "I looked, found it, and discarded it" — exactly the defect + /// class this epic exists to kill. Matches `range_checked_report`'s whole-call refusal for + /// the three sibling reward-distributor handlers, asserted on the SERIALIZED JSON body + /// through the real `handle_rpc` -> `handle_rpc_as` -> `RpcDispatch::dispatch` path. + /// **Catches:** a `.filter()` that silently drops the record instead of refusing the call. + #[test] + fn get_reward_prover_status_refuses_whole_call_when_filtered_record_has_missing_identity() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + // launcher_id is VALID (so it can be named by the filter), but store_id is zeroed. + let launcher_id = [0xccu8; 32]; + let mut zeroed_store_status = sample_reward_prover_status(launcher_id); + zeroed_store_status.store_id = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_store_status, + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "expected a whole-call refusal naming REWARD_ZERO_IDENTITY, got: {resp}" ); - // 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}" + resp.get("result").is_none(), + "a refusal must carry no result at all, not an empty/consulted one: {resp}" ); + } + + /// **Proves (dig_ecosystem#3269 fix939):** the same whole-call refusal fires with NO filter + /// applied, against a MIXED registry (one missing-identity record beside an otherwise-valid + /// one) — the survivors must never be returned as a "complete" `Consulted` answer just + /// because at least one record was fine. **Catches:** a fix that only refuses when the + /// missing-identity record is the ONLY one present, still silently dropping it out of a + /// mixed set. + #[test] + fn get_reward_prover_status_refuses_whole_call_for_a_mixed_set_unfiltered() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0u8; 32]), // launcher_id itself zeroed + )); + let real_id = [0xaau8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(real_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, + )); + assert_eq!( - logs.matches("zeroed root").count(), - 1, - "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "a mixed set with one missing-identity record must refuse the WHOLE call, not \ + return the valid survivor as a complete Consulted answer: {resp}" ); } @@ -9491,7 +9704,7 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - let filtered = filtered["result"]["statuses"].as_array().unwrap(); + let filtered = filtered["result"]["statuses"]["items"].as_array().unwrap(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0]["launcher_id"], json!(hex::encode(a))); @@ -9501,7 +9714,909 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); + assert_eq!( + all["result"]["statuses"]["items"].as_array().unwrap().len(), + 2 + ); + } + + /// An in-memory `RewardsChainPort` for `dig.getRewardDistributor` / + /// `dig.listRewardDistributorCommitments` dispatch tests (dig_ecosystem#3269 unit 2) — keyed + /// per launcher id so two distinct distributors can be driven through the SAME dispatch path + /// with distinct answers, exactly the shape the money-figure subject test needs. + struct FakeRewardsChainPort { + reports: std::collections::HashMap< + [u8; 32], + Result, + >, + } + + #[async_trait::async_trait] + impl crate::rewards::port::RewardsChainPort for FakeRewardsChainPort { + async fn funded_distributors( + &self, + ) -> Result, crate::rewards::port::ChainPortError> + { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn distributor_state( + &self, + _launcher_id: crate::rewards::port::Bytes32, + ) -> Result + { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn submit_entry_writes( + &self, + _bundle: crate::rewards::port::EntryWriteBundle, + ) -> Result<(), crate::rewards::port::ChainPortError> { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn spend_new_epoch( + &self, + _launcher_id: crate::rewards::port::Bytes32, + ) -> Result<(), crate::rewards::port::ChainPortError> { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn distributor_report( + &self, + launcher_id: crate::rewards::port::Bytes32, + ) -> Result + { + self.reports + .get(&launcher_id) + .cloned() + .unwrap_or(Err(crate::rewards::port::ChainPortError::Unavailable)) + } + } + + /// Derives `entry_set_stale` the way a production adapter MUST (SPEC §12.4): from the + /// CHAIN-derived last-entry-write time measured against the exported + /// [`crate::rewards::spec_constants::STALE_ENTRY_SET_SECONDS`], through the same + /// [`crate::rewards::staleness::is_entry_set_stale`] rule the engine uses — never a hardcoded + /// `172_800`, and never a prover's own self-report of its freshness. + fn derive_entry_set_stale( + reserve_base_units: u64, + last_entry_write_at: Option, + observed_at: u64, + distributor_created_at: u64, + ) -> bool { + crate::rewards::staleness::is_entry_set_stale( + &crate::rewards::port::DistributorChainState { + reserve_base_units, + entries: Vec::new(), + current_distributor_epoch: 0, + last_entry_write_at, + total_paid_out_base_units: 0, + }, + observed_at, + distributor_created_at, + ) + } + + /// A `DistributorReport` with every field distinctly derived from `seed`, so two reports built + /// from two different seeds can never accidentally collide on a real field. + /// + /// `entry_set_stale` is the one field that is NOT a free function of `seed`: it is derived from + /// this report's own `last_entry_write_at`/`observed_at` against the exported staleness bound, + /// so no fixture can carry a staleness flag its own chain-derived times contradict. With the + /// times below, every seed's report is FRESH (its write is 100_000s old, inside the bound); a + /// test that needs the stale side uses [`distributor_report_with_write_age`]. + fn sample_distributor_report( + seed: u8, + commitments: Vec, + ) -> crate::rewards::port::DistributorReport { + let first_epoch_start = 1_700_000_000 + seed as u64; + let reserve_base_units = 10_000_000 + seed as u64 * 1_000; + let last_entry_write_at = Some(1_700_100_000 + seed as u64); + let observed_at = 1_700_200_000 + seed as u64; + crate::rewards::port::DistributorReport { + launcher_id: [seed; 32], + store_id: [seed.wrapping_add(1); 32], + root: [seed.wrapping_add(2); 32], + epoch_seconds: 604_800 + seed as u64, + first_epoch_start, + payout_threshold: 1_000_000 + seed as u64, + fee_bps: 100 + seed as u16, + withdrawal_share_bps: 9_000 + seed as u16, + reserve_base_units, + entry_count: 3 + seed as u64, + current_distributor_epoch: 5 + seed as u64, + last_entry_write_at, + entry_set_stale: derive_entry_set_stale( + reserve_base_units, + last_entry_write_at, + observed_at, + first_epoch_start, + ), + commitments, + observed_at, + } + } + + /// [`sample_distributor_report`] with the chain-derived last entry write placed exactly + /// `write_age_seconds` before `observed_at`, and `entry_set_stale` re-derived from that age. + /// Lets one test drive both sides of the staleness bound without writing the bound's numeric + /// value down anywhere. + fn distributor_report_with_write_age( + seed: u8, + write_age_seconds: u64, + ) -> crate::rewards::port::DistributorReport { + let mut report = sample_distributor_report(seed, vec![]); + report.last_entry_write_at = Some(report.observed_at - write_age_seconds); + report.entry_set_stale = derive_entry_set_stale( + report.reserve_base_units, + report.last_entry_write_at, + report.observed_at, + report.first_epoch_start, + ); + report + } + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + /// **Proves:** `dig.getRewardDistributor` answers with the port's real values through the + /// REAL dispatch path (`handle_rpc` -> `handle_rpc_as` -> `RpcDispatch::dispatch`), asserted on + /// the serialized JSON body's key SET, not a Rust struct (a struct assertion cannot see a + /// serde rename or an extra field — dig_ecosystem#3269's evidence bar). + /// **Catches:** a handler that drops a field, mis-cases a key, or answers from a stub instead + /// of the port. + #[test] + fn get_reward_distributor_answers_with_real_values_through_dispatch() { + let (node, _td) = test_node(None); + let launcher_id = [0x11u8; 32]; + let report = sample_distributor_report(0x11, vec![]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "launcher_id", + "store_id", + "root", + "epoch_seconds", + "first_epoch_start", + "payout_threshold", + "fee_bps", + "withdrawal_share_bps", + "reserve_base_units", + "entry_count", + "current_distributor_epoch", + "last_entry_write_at", + "entry_set_stale", + "observed_at", + ]), + "the wire body's key SET must be exactly this — a struct assertion cannot see a wrong \ + key name or an extra field" + ); + assert_eq!( + result["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!(result["store_id"], json!(hex::encode(report.store_id))); + assert_eq!(result["root"], json!(hex::encode(report.root))); + assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); + assert_eq!(result["first_epoch_start"], json!(report.first_epoch_start)); + assert_eq!(result["payout_threshold"], json!(report.payout_threshold)); + assert_eq!(result["fee_bps"], json!(report.fee_bps)); + assert_eq!( + result["withdrawal_share_bps"], + json!(report.withdrawal_share_bps) + ); + assert_eq!( + result["reserve_base_units"], + json!(report.reserve_base_units) + ); + assert_eq!(result["entry_count"], json!(report.entry_count)); + assert_eq!( + result["current_distributor_epoch"], + json!(report.current_distributor_epoch) + ); + assert_eq!( + result["last_entry_write_at"], + json!(report.last_entry_write_at) + ); + assert_eq!(result["entry_set_stale"], json!(report.entry_set_stale)); + assert_eq!(result["observed_at"], json!(report.observed_at)); + } + + /// **Proves:** `dig.listRewardDistributorCommitments` answers with the port's real values + /// through the real dispatch path, asserted on the serialized body's key set and per-slot + /// values — including the legitimate empty-`commitments` case being distinguishable from an + /// error (it is a real `result`, not an `error`). + /// **Catches:** a handler that restates `recoverable_base_units` itself instead of echoing the + /// port's pre-computed figure, or mis-cases a key. + #[test] + fn list_reward_distributor_commitments_answers_with_real_values_through_dispatch() { + let (node, _td) = test_node(None); + let launcher_id = [0x22u8; 32]; + let slot = crate::rewards::port::CommitmentSlot { + epoch_start: 42, + clawback_puzzle_hash: [0x33u8; 32], + rewards_base_units: 1_000, + recoverable_base_units: 900, + }; + let report = sample_distributor_report(0x22, vec![slot.clone()]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "launcher_id", + "withdrawal_share_bps", + "epoch_seconds", + "commitments", + "observed_at", + ]) + ); + assert_eq!( + result["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!( + result["withdrawal_share_bps"], + json!(report.withdrawal_share_bps) + ); + assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); + assert_eq!(result["observed_at"], json!(report.observed_at)); + let commitments = result["commitments"].as_array().unwrap(); + assert_eq!(commitments.len(), 1); + let row_keys: std::collections::BTreeSet<&str> = commitments[0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + row_keys, + std::collections::BTreeSet::from([ + "epoch_start", + "clawback_puzzle_hash", + "rewards_base_units", + "recoverable_base_units", + ]) + ); + assert_eq!(commitments[0]["epoch_start"], json!(42)); + assert_eq!( + commitments[0]["clawback_puzzle_hash"], + json!(hex::encode([0x33u8; 32])) + ); + assert_eq!(commitments[0]["rewards_base_units"], json!(1_000)); + assert_eq!(commitments[0]["recoverable_base_units"], json!(900)); + } + + /// **Proves:** `dig.listRewardDistributors` is CONTROL-tier and NOT peer-reachable, and is + /// dispatched through the `Method` enum match rather than the pre-`Method::from_name` string + /// block (dig_ecosystem#3269 unit 2; #3261's money-hole rule). + #[test] + fn list_reward_distributors_is_control_tier_and_not_peer_reachable() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.listRewardDistributors"), + Some(Method::ListRewardDistributors) + ); + assert_eq!( + Method::ListRewardDistributors.tier(), + dig_rpc_protocol::Tier::Control + ); + assert!(!peer::is_peer_reachable_method( + "dig.listRewardDistributors" + )); + } + + /// **Proves:** with no funder registry installed, `dig.listRewardDistributors` answers + /// `NotConsulted` for BOTH halves — never `Consulted { items: [] }`, which would claim this + /// node checked and found nothing (SPEC §12.5 clause 6's "reassuring zero"). + /// **Catches:** a handler defaulting an unconfigured registry to an empty, "consulted" list. + #[test] + fn list_reward_distributors_with_no_registry_is_not_consulted_on_both_halves() { + let (node, _td) = test_node(None); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + resp["result"]["funded"]["outcome"], + json!("not_consulted"), + "unconfigured registry must read UNKNOWN, never an empty funded set: {resp}" + ); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted"), + "no claimable tracking exists in this crate yet — must never fabricate a checked zero: {resp}" + ); + } + + /// **Proves:** a registry that genuinely funds nothing (`FundsNothing`, the one legitimate + /// empty case) renders `funded` as `Consulted { items: [] }` — a real, checked empty list, not + /// `NotConsulted` — while `claimable` still reads `NotConsulted` since nothing here tracks it. + #[test] + fn list_reward_distributors_with_a_genuinely_empty_registry_is_consulted_empty() { + let state_dir = tempfile::tempdir().unwrap(); + // An intact record naming nobody is the ONE legitimate empty answer (`FundsNothing`) — + // distinct from no record ever written at all, which reads `NotConfigured`. + std::fs::write( + state_dir + .path() + .join(crate::rewards::funded::FUNDED_DISTRIBUTORS_FILE), + r#"{"version": 1, "distributors": []}"#, + ) + .unwrap(); + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + assert_eq!(resp["result"]["funded"]["items"], json!([])); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + + /// **Proves:** a funded identity resolved through the chain port renders as a real + /// `RewardDistributorRef` inside `funded.items`, through the REAL dispatch path. + /// **Catches:** a handler that fabricates `store_id`/`root` instead of resolving them via + /// `RewardsChainPort::distributor_report`. + #[test] + fn list_reward_distributors_resolves_a_funded_identity_through_the_chain_port() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x55u8; 32], + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let report = sample_distributor_report(0x55, vec![]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([([0x55u8; 32], Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + let items = resp["result"]["funded"]["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!( + items[0]["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!(items[0]["store_id"], json!(hex::encode(report.store_id))); + assert_eq!(items[0]["root"], json!(hex::encode(report.root))); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + + /// **Proves:** a funded identity whose per-item chain report fails refuses the WHOLE call + /// (the same `ChainPortError` response the sibling reward-distributor handlers use), rather + /// than emitting a partial list or a fabricated ref (dig_ecosystem#3308/#3309). + #[test] + fn list_reward_distributors_refuses_the_whole_call_on_a_chain_report_failure() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x66u8; 32], + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::new(), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "a per-item chain failure must refuse the whole call, not answer a partial result: {resp}" + ); + assert!( + resp.get("error").is_some(), + "expected an error response: {resp}" + ); + } + + /// **Proves:** `dig.getPayeeRewardClaimStatus` is CONTROL-tier, NOT peer-reachable, dispatched + /// through the `Method` enum match, and its exact serialized JSON body: `subject` is the + /// literal `"payee"`, `claim_log` is `NotConsulted` (no claim log exists in this crate yet), + /// and there is never a monetary amount or payout puzzle hash anywhere in the body. + #[test] + fn get_payee_reward_claim_status_answers_the_exact_wire_shape() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.getPayeeRewardClaimStatus"), + Some(Method::GetPayeeRewardClaimStatus) + ); + assert_eq!( + Method::GetPayeeRewardClaimStatus.tier(), + dig_rpc_protocol::Tier::Control + ); + assert!(!peer::is_peer_reachable_method( + "dig.getPayeeRewardClaimStatus" + )); + + let (node, _td) = test_node(None); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getPayeeRewardClaimStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from(["subject", "claim_log"]), + "no monetary amount, no payout puzzle hash — ever: {resp}" + ); + assert_eq!(result["subject"], json!("payee")); + assert_eq!(result["claim_log"]["outcome"], json!("not_consulted")); + assert!( + result["claim_log"].get("claims_submitted_count").is_none(), + "claims_submitted_count must live INSIDE Consulted only, never beside NotConsulted: {resp}" + ); + } + + /// **Proves:** dig_ecosystem#3269 unit 5 — a chain-derived report with a ZEROED `launcher_id` + /// (never a real distributor's identity, only what an uninitialised slot hex-encodes to, and + /// `dig-rewards-coin@v0.4.0` refuses to create one this way — #3308/#3309) refuses the WHOLE + /// call for every reward-distributor read method, rather than rendering the zero as though it + /// were real. + /// **Catches:** a handler that hex-encodes whatever the port returns with no identity check. + #[test] + fn reward_distributor_methods_refuse_a_zeroed_identity_rather_than_render_it() { + let launcher_id = [0u8; 32]; + let report = sample_distributor_report(0, vec![]); + assert_eq!( + report.launcher_id, [0u8; 32], + "fixture premise: seed 0 zeroes launcher_id" + ); + + let (node, _td) = test_node(None); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method} must refuse a zeroed identity, not render it: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "{method}: {resp}" + ); + } + + // Same guard for the new list method, which resolves identity through the SAME + // `distributor_report` + `range_checked_report` path. + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id, + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + assert!(node.install_funded_distributor_registry(registry)); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "dig.listRewardDistributors must refuse a zeroed identity, not render it: {resp}" + ); + assert_eq!(resp["error"]["data"]["code"], json!("REWARD_ZERO_IDENTITY")); + } + + /// **Proves:** with no chain-read adapter installed, BOTH reward-distributor methods answer a + /// distinct error — never a zero, never an empty list — and it is NOT the same machine code as + /// the `withdrawal_share_bps` refusal (so a caller can tell "try again later" apart from "this + /// distributor's own constant is broken"). + /// **Catches:** a handler that defaults to `Default::default()` or an empty result on a `None` + /// port instead of erroring. + #[test] + fn reward_distributor_methods_chain_unavailable_is_a_distinct_error_never_a_zero_or_empty() { + let (node, _td) = test_node(None); + let launcher_id = [0x44u8; 32]; + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: must not answer a result at all" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE") + ); + assert_ne!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE"), + "{method}: chain-unavailable must not share a machine code with the \ + withdrawal-share refusal" + ); + } + } + + /// **Proves:** when the port refuses because `withdrawal_share_bps` is out of range (either + /// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own + /// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code + /// — never a `0`, never an empty `commitments` list standing in for the refusal. + /// **Mutation-probe, re-runnable from this repo alone:** in `seams::dig_rpc::dispatch`, + /// replace `reward_chain_port_error_response`'s `InvalidWithdrawalShare` arm with a `result` + /// carrying `withdrawal_share_bps: 0`, then run + /// `cargo test -p dig-node-core --lib reward_distributor_methods_`. This test fails at its + /// FIRST assertion, `resp.get("result").is_none()`, for `dig.getRewardDistributor`: a refusal + /// has become an answer. Restoring the arm returns it to green with the rest of the suite + /// untouched. That is the defect it exists to catch, so it is not vacuously green. + #[test] + fn reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share() { + let (node, _td) = test_node(None); + let launcher_id = [0x55u8; 32]; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([( + launcher_id, + Err(crate::rewards::port::ChainPortError::InvalidWithdrawalShare), + )]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: must refuse the whole call" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE") + ); + assert_ne!(resp["error"]["code"], json!(0)); + } + } + + /// **Proves:** a `withdrawal_share_bps` ABOVE the legitimate `0..=10_000` range refuses the + /// WHOLE call on BOTH reward-distributor methods, and the out-of-range figure reaches no + /// response body. The fake port hands back an `Ok` report carrying `10_001` - exactly what a + /// wrapped narrowing (`74_536 as u16` = `9_000`) or a buggy adapter would look like from this + /// seam - so the refusal proved here is the HANDLER's, with no adapter cooperation + /// (dig_ecosystem#3284). + /// **Catches:** the handler emitting the figure verbatim, and the tempting "safe" fix of + /// clamping it to `10_000`, which would report a confident 100% recoverable share for a + /// distributor whose real constant is nonsense. Both are asserted against by name. + #[test] + fn reward_distributor_methods_refuse_a_withdrawal_share_above_the_legitimate_range() { + let (node, _td) = test_node(None); + let launcher_id = [0x78u8; 32]; + let mut report = sample_distributor_report(0x78, vec![]); + report.withdrawal_share_bps = 10_001; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: an out-of-range withdrawal share must refuse the whole call: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE"), + "{method}: {resp}" + ); + // Matched in a JSON VALUE position (`:10001`), not anywhere in the body: the + // refusal MESSAGE legitimately names the bound it enforces, and asserting on the + // bare digits would fail on the honest text while saying nothing about the figure. + let body = resp.to_string(); + assert!( + !body.contains(":10001"), + "{method}: the out-of-range figure must not reach the wire: {body}" + ); + assert!( + !body.contains(":10000"), + "{method}: a clamp to 100% is a money lie, not a safe default: {body}" + ); + } + } + + /// **Proves:** the boundary is `> 10_000`, not `>= 10_000`: 10,000 basis points IS a + /// legitimate 100% withdrawal share, and both methods still ANSWER for it. + /// **Catches:** the refusal above widening into a blanket refusal - an off-by-one that would + /// blind every distributor whose funder takes the whole share, while the refusal test above + /// stayed green. Neither test alone shows the guard is selective. + #[test] + fn reward_distributor_methods_answer_at_the_ten_thousand_bps_boundary() { + let (node, _td) = test_node(None); + let launcher_id = [0x79u8; 32]; + let mut report = sample_distributor_report(0x79, vec![]); + // Stated as a LITERAL, not read from the dispatch module's constant: the bound is a + // contract figure (10,000 bps = 100%), so a change to that constant must fail here. + report.withdrawal_share_bps = 10_000; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("error").is_none(), + "{method}: a 100% share is legitimate and must be answered: {resp}" + ); + assert_eq!( + resp["result"]["withdrawal_share_bps"], + json!(10_000), + "{method}: the boundary value must be reported verbatim: {resp}" + ); + } + } + + /// **Proves:** `entry_set_stale` is threaded through, both `true` and `false`, straight from + /// the port's chain-derived figure — never hardcoded, never inverted — with BOTH cases served + /// by ONE port installed ONCE on ONE node, answered by the REQUESTED launcher id. Each + /// expectation is derived from that report's own last-entry-write age against the exported + /// `STALE_ENTRY_SET_SECONDS`; the bound's numeric value appears nowhere in this test. + /// **Catches:** a handler that hardcodes or inverts the flag, and one that answers a DIFFERENT + /// distributor's staleness for the requested launcher id. + #[test] + fn get_reward_distributor_threads_entry_set_stale_both_ways() { + let (node, _td) = test_node(None); + let bound = crate::rewards::spec_constants::STALE_ENTRY_SET_SECONDS; + // SPEC §12.4 compares with `>=`: exactly at the bound is stale, one second inside is not. + let stale_report = distributor_report_with_write_age(0x60, bound); + let fresh_report = distributor_report_with_write_age(0x61, bound - 1); + assert!( + stale_report.entry_set_stale && !fresh_report.entry_set_stale, + "the fixtures must straddle the staleness bound, or this test proves nothing" + ); + + let cases = [ + (stale_report.launcher_id, stale_report.entry_set_stale), + (fresh_report.launcher_id, fresh_report.entry_set_stale), + ]; + // ONE install for both cases: `install_reward_chain_port` is single-shot deliberately, and + // loosening it so a test could install twice would let a real double-install through. + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (stale_report.launcher_id, Ok(stale_report)), + (fresh_report.launcher_id, Ok(fresh_report)), + ]), + })) + ); + + for (launcher_id, expect_stale) in cases { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + resp["result"]["entry_set_stale"], + json!(expect_stale), + "launcher {} must report its OWN chain-derived staleness", + hex::encode(launcher_id) + ); + } + } + + /// **Proves:** two distinct distributors' money figures never cross-contaminate — the class of + /// defect dig-app#403's rewards pane shipped (a per-distributor total silently summed or + /// swapped). Reads BOTH distributors' `dig.listRewardDistributorCommitments` in the same test + /// and asserts neither the summed nor the swapped figure appears in either response. + /// **Mutation-probe, re-runnable from this repo alone:** swap the two + /// `FakeRewardsChainPort` entries' `recoverable_base_units` (give `slot_a` `slot_b`'s figure + /// and vice versa) and run `cargo test -p dig-node-core --lib commitment_money_figures`. This + /// test fails on the assertion that distributor A's response does not carry B's figure; + /// undoing the swap returns it to green. No other test notices the swap, which is why this + /// one exists. + #[test] + fn commitment_money_figures_stay_attributed_to_their_own_distributor() { + let (node, _td) = test_node(None); + let launcher_a = [0x70u8; 32]; + let launcher_b = [0x71u8; 32]; + let slot_a = crate::rewards::port::CommitmentSlot { + epoch_start: 1, + clawback_puzzle_hash: [0xaau8; 32], + rewards_base_units: 5_000, + recoverable_base_units: 4_500, + }; + let slot_b = crate::rewards::port::CommitmentSlot { + epoch_start: 2, + clawback_puzzle_hash: [0xbbu8; 32], + rewards_base_units: 7_000, + recoverable_base_units: 6_300, + }; + let report_a = sample_distributor_report(0x70, vec![slot_a]); + let report_b = sample_distributor_report(0x71, vec![slot_b]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (launcher_a, Ok(report_a)), + (launcher_b, Ok(report_b)), + ]), + })) + ); + + let resp_a = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_a)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let resp_b = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_b)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + let recoverable_a = resp_a["result"]["commitments"][0]["recoverable_base_units"] + .as_u64() + .unwrap(); + let recoverable_b = resp_b["result"]["commitments"][0]["recoverable_base_units"] + .as_u64() + .unwrap(); + assert_eq!(recoverable_a, 4_500); + assert_eq!(recoverable_b, 6_300); + let summed = 4_500 + 6_300; + let swapped_a = 6_300; + let swapped_b = 4_500; + assert_ne!(recoverable_a, summed); + assert_ne!(recoverable_b, summed); + assert_ne!(recoverable_a, swapped_a); + assert_ne!(recoverable_b, swapped_b); } /// **Proves:** `total_paid_out_base_units`/`reserve_base_units` stay attributed to the @@ -9540,7 +10655,7 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - let statuses = resp["result"]["statuses"].as_array().unwrap(); + let statuses = resp["result"]["statuses"]["items"].as_array().unwrap(); assert_eq!(statuses.len(), 2); let find = |launcher_id: [u8; 32]| { @@ -9974,6 +11089,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; let before = handle_rpc( @@ -17131,6 +18247,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; // A holder for this EXACT content is known via the DHT. @@ -17184,6 +18301,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -17236,6 +18354,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; @@ -17270,6 +18389,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17313,6 +18433,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17358,6 +18479,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::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 f85f954b..31fb22cd 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5652,6 +5652,23 @@ pub(crate) mod tests { !reward_methods.is_empty(), "expected at least one Reward-named method in Method::ALL; found none" ); + // dig_ecosystem#3269 unit 3: a non-empty check alone would still pass if the catalogue + // grew a sixth reward method that the filter silently stopped matching (or a variant were + // renamed out from under `.contains("Reward")`) — this pins the count so the guard cannot + // start policing FEWER methods than actually exist without failing loudly. Update this + // number, deliberately, the moment `dig-rpc-protocol` adds or removes a reward method. + // + // Bumped 4 -> 5 (dig_ecosystem#3269, final leg): dig-rpc-protocol 0.12.0 added + // `dig.getPayeeRewardClaimStatus`, whose name also contains "Reward". + assert_eq!( + reward_methods.len(), + 5, + "expected exactly 5 Reward-named methods in Method::ALL (dig.listRewardDistributors, \ + dig.getRewardProverStatus, dig.getRewardDistributor, \ + dig.listRewardDistributorCommitments, dig.getPayeeRewardClaimStatus); got {}: a \ + catalogue change must update this guard deliberately, not silently narrow it", + reward_methods.len() + ); for m in reward_methods { assert!( !is_peer_reachable_method(m.name()), diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs index 787d0c7e..3a372794 100644 --- a/crates/dig-node-core/src/rewards/mod.rs +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -8,13 +8,20 @@ //! ([`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 +//! DIG-Network/dig_ecosystem#3310. What is missing is an ADAPTER, not a reader: +//! `dig-rewards-coin` 0.4.1 already ships `state::read_distributor(&impl ChainSource, +//! launcher_id) -> Result, RewardsError>` plus `ChainObservation` and +//! `clawback::recoverable_base_units`, but that reader does no socket I/O of its own — it takes a +//! caller-supplied `ChainSource` — so something must hold the chain source, drive the reader and +//! map its answers onto this trait. That adapter is built in `dig-node-service` and injected down +//! through [`crate::Node::install_reward_chain_port`] (#3310); the claim-side chain adapter is +//! tracked separately in #3307. Until then 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. +//! clause, so should the crate ever publish these prover-side numbers itself — 0.4.1's +//! `constants` module carries distributor-side values only, not these — the migration is a +//! single, deliberate one rather than a scattered one. //! //! # The worst-case spend, stated where a human reads it //! diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 7eb2d5a0..0fae5124 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -7,11 +7,27 @@ //! the production adapter reports [`ChainPortError::Unavailable`] and runs no cycles. See //! [`UnavailableChainPort`] for that adapter. //! -//! # dig_ecosystem#3269 unit 2 — the driver shipped, but still with no reader (blocking finding) +//! # The reader HAS shipped (corrected: the text here was written against 0.2.0) //! -//! `dig-rewards-coin` 0.2.0 is published and adds real types — `DistributorSnapshot` / -//! `DistributorSlots` (its `state` module) plus `clawback`, `comment`, `constants`, `eligibility`, -//! `entries`, `epoch`, `fund`, `launch`, `payout`. **It still ships no chain reader — blocker 1.** +//! `dig-rewards-coin` publishes **0.4.1** (latest on the crates.io index as this was written; 0.4.0 +//! read first-hand from the local registry cache, since this crate deliberately does not depend on +//! it) and it ships the chain reader the paragraph below said it withheld: +//! `state::read_distributor(&impl ChainSource, launcher_id) -> Result, +//! RewardsError>`, plus `clawback::recoverable_base_units(rewards_base_units, +//! withdrawal_share_bps) -> Option`, which refuses above `10_000` bps exactly as this seam's +//! own range check does. **Blocker 1 is CLOSED**, and what follows it described a crate two +//! releases old; it is kept only because the SHAPE argument it makes still holds. +//! +//! What dig-node still lacks is an ADAPTER, which is a different thing from a reader: +//! `read_distributor` takes a caller-supplied `ChainSource` and does no socket I/O of its own, so +//! something must hold the chain source, call the reader and map its answers onto this trait. That +//! is dig_ecosystem#3310's job, in `dig-node-service`, injected down through +//! [`crate::Node::install_reward_chain_port`]. It is NOT #3249, the driver ticket: a crate that +//! does no I/O can never be the adapter, so every "until #3249 lands" written about an adapter was +//! a pointer at a ticket that structurally cannot ship it — and a blocker filed on such a ticket +//! is never read. +//! +//! The historical 0.2.0 finding, for the reasoning it carries: //! 0.2.0's own `state.rs:1-31` module doc says so directly: SPEC §12.1's `read_distributor` "does not //! publish one, deliberately" — the implementation that existed applied //! `RewardDistributor::from_parent_spend` to the eve coin's spend (the launch inner puzzle) instead @@ -41,24 +57,28 @@ //! before any of `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` / //! `dig.listRewardDistributors`'s `funded` half can answer honestly. //! -//! So a "real" `RewardsChainPort` adapter over 0.2.0 cannot honestly answer ANY of the four trait -//! methods with live chain data yet: `funded_distributors` has no identity source, and -//! `distributor_state`/`submit_entry_writes`/`spend_new_epoch` all need the withheld reader (a spend -//! needs the live singleton coin `read_distributor` would supply). Writing one anyway — either by +//! So no adapter can honestly live HERE, which is a narrower claim than the one this paragraph used +//! to make: `funded_distributors` still has no identity source (blocker 2, below, and still true), +//! and the reader 0.4 does ship needs a `ChainSource` that `dig-node-core` deliberately does not +//! hold — wiring one up in this crate would re-add the `dig-rewards-coin` dependency unit 0 removed. Writing one anyway — either by //! reimplementing `read_distributor` myself or by inventing a funded-distributor registry with no //! writer — would be exactly the kind of restated, unreviewed money-shape work SPEC §0.1 clause 1 and //! this crate's own withholding of a broken reader argue against, and is the shape fork this ticket's //! kernel invariant 6 says to escalate rather than guess. Escalated to the L1, and settled: no new -//! adapter and no dispatch arm land until a reader (0.3.0+) and the funder-ownership registry both -//! exist. **`dig.listRewardDistributors` stays `-32601` deliberately** — serving it through -//! `UnavailableChainPort` was considered and rejected: it would be a false capability signal (a -//! feature-probe or `rpc.discover` reading the method as implemented when it always errors) and the -//! exact "dispatch surface with no function behind it" pattern DIG-Network/dig-node#593 was the last -//! PR allowed to land on. `UnavailableChainPort` remains the only production adapter for now — still -//! correct, since every real call would fail for one of the two reasons above regardless. No +//! ADAPTER lands here until #3310 wires one in `dig-node-service`. The DISPATCH half of that +//! settlement has since been revisited and has landed: **`dig.listRewardDistributors` is served**, +//! by `Some(Method::ListRewardDistributors)` in `seams/dig_rpc/dispatch.rs`, and it is honest about +//! what it knows — the `funded` half reads this node's own funded-distributor identities, and the +//! `claimable` half is always `NotConsulted` because no claim-side tracking exists here to consult. +//! What is still missing is the adapter: `install_reward_chain_port` has no non-test caller +//! (dig_ecosystem#3310), so `UnavailableChainPort` remains the only production adapter and every +//! port-backed method answers `REWARD_CHAIN_UNAVAILABLE` in production. That is a truthful +//! unavailability signal from a method that exists, not the "dispatch surface with no function +//! behind it" pattern DIG-Network/dig-node#593 was the last PR allowed to land on: the dispatch arm +//! does real reads and names precisely what it could not reach. No //! `dig-rewards-coin` dependency is added by this unit: an unused dependency with no consumer is -//! inert weight and would want whichever version ships the reader (0.3.0+), not 0.2 — add it in the -//! unit that actually consumes it. +//! inert weight; the version it will want is whatever is current when #3310 adds it in +//! `dig-node-service`, the unit that actually consumes it. Do not add it here. use super::admission::AdmittedPeer; use async_trait::async_trait; @@ -128,12 +148,108 @@ 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, + /// dig_ecosystem#3269/#3284/#3303: the distributor's `withdrawal_share_bps` (a `u64` on the + /// puzzle) either does not fit the wire's `u16` domain or exceeds the legitimate `0..=10_000` + /// bps range. The adapter MUST refuse the WHOLE [`RewardsChainPort::distributor_report`] call + /// rather than silently narrowing (`as u16` would wrap `65_536` to `0`) or omitting the figure: + /// `withdrawal_share_bps` is curried once per distributor (launch-time, immutable), so an + /// invalid value can never affect one row of a caller's answer and not another. Refusing the + /// whole call here therefore blinds zero good rows and needs no wire change — see the module + /// doc on [`DistributorReport`]. + InvalidWithdrawalShare, + /// dig_ecosystem#3269 unit 5: the report's `launcher_id` or `store_id` is the all-zero + /// `Bytes32` — never a real distributor's or module's identity, only what an + /// uninitialised/never-assigned slot hex-encodes to. `dig-rewards-coin@v0.4.0` itself refuses + /// to create a distributor with a zero identity (#3308/#3309), so a chain-derived report + /// naming one is not a legitimate "young distributor" case the way a zeroed `root` alone can + /// be — it is a malformed answer, and every reward-distributor read handler (`GetRewardDistributor`, + /// `ListRewardDistributorCommitments`, `ListRewardDistributors`) refuses the whole call rather + /// than render it, matching the zeroed-identity guard `GetRewardProverStatus` already applies. + ZeroIdentity, /// 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), } +/// One clawback commitment slot, as `dig.listRewardDistributorCommitments` (SPEC §7.4 clause 5) +/// needs it. +/// +/// `recoverable_base_units` is the adapter's PRE-COMPUTED share — never restated by a caller of +/// this port, and never recomputed by `dig-node-core` itself. NO production adapter exists yet, +/// and no crate in this seam depends on `dig-rewards-coin` today: the adapter that WILL compute +/// this figure is dig_ecosystem#3310's, in `dig-node-service` (that ticket names both +/// `distributor_report` and `Node::install_reward_chain_port` explicitly), and as of this writing +/// that crate's manifest declares no such dependency. When it lands it will be the one crate in +/// this seam that depends on `dig-rewards-coin` (dig_ecosystem#3269 unit 0 removed that dependency +/// from THIS crate deliberately), and it will compute this figure with +/// `dig_rewards_coin::recoverable_base_units` — that crate's own tested, simulator-bound +/// restatement of the puzzle's share arithmetic (u128 intermediate, multiply-then-divide, +/// truncated; see that function's doc for the equality proof against `chia-sdk-driver`). If +/// `withdrawal_share_bps` does not fit `u16` or exceeds `10_000`, that adapter must refuse the +/// WHOLE [`RewardsChainPort::distributor_report`] call with +/// [`ChainPortError::InvalidWithdrawalShare`] instead of returning a `CommitmentSlot` with a +/// wrong, zeroed or omitted `recoverable_base_units` — see that variant's doc for why a +/// per-distributor curried value makes a whole-call refusal the correct shape. The same range is +/// ALSO enforced at the dispatch seam (`seams::dig_rpc::dispatch`'s `range_checked_report`, +/// dig_ecosystem#3284), so an adapter that forgets cannot put an out-of-range share on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitmentSlot { + /// The distributor epoch this commitment slot funds. + pub epoch_start: u64, + /// The chain's `clawback_ph`: the puzzle hash whose key holder alone may claw this slot back + /// (SPEC §7.4 clause 3) — an entitlement fact, never a display label. + pub clawback_puzzle_hash: Bytes32, + /// The committed amount, in base units, as the puzzle records it. + pub rewards_base_units: u64, + /// The amount actually recoverable on clawback, in base units. See the type doc: always + /// pre-computed by the adapter, never by a caller of this trait. + pub recoverable_base_units: u64, +} + +/// One distributor's chain-derived report — everything `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` (dig_ecosystem#3269 units 1-2) need for one launcher id, +/// from the ONE port call [`RewardsChainPort::distributor_report`] designs once so neither handler +/// can diverge from the other's view of the same distributor. +/// +/// Deliberately a NEW type, not a widened [`DistributorChainState`]: that type is the prover cycle +/// engine's own shape (SPEC §2.3, §8, §12.4, dig_ecosystem#3250) and widening it would reach into +/// that ticket's territory for a need this one does not share (`fee_bps`, `withdrawal_share_bps`, +/// commitments, and the distributor's launch constants are irrelevant to the prover cycle). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorReport { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, + /// The payout epoch length, in seconds — a launch-curried, immutable distributor constant. + pub epoch_seconds: u64, + /// Unix seconds the first epoch started. + pub first_epoch_start: u64, + /// The reserve threshold, in base units, that triggers a payout. + pub payout_threshold: u64, + /// The distributor's fee, in basis points. + pub fee_bps: u16, + /// Already narrowed to the wire's `u16` domain and validated `<= 10_000` by the adapter — see + /// [`ChainPortError::InvalidWithdrawalShare`] for what happens when the chain's raw `u64` + /// constant fails either check. + pub withdrawal_share_bps: u16, + pub reserve_base_units: u64, + pub entry_count: u64, + pub current_distributor_epoch: u64, + /// Unix seconds of the most recent entry-set write on chain, if any. `None` is a positive + /// fact (no write has ever happened since launch), never "unknown" — SPEC §2.4 clause 1. + pub last_entry_write_at: Option, + /// SPEC §12.4: computed by the adapter from the chain-derived write history against + /// `dig_rewards_coin::STALE_ENTRY_SET_SECONDS` at read time — never self-reported by a + /// possibly-wedged prover loop, and never a hardcoded constant in this crate or its callers. + pub entry_set_stale: bool, + /// One entry per outstanding commitment slot. Empty is legitimate (SPEC §7.4 clause 5): a + /// distributor funded only via `AddIncentives` has no clawback-eligible slots at all. + pub commitments: Vec, + /// Unix seconds this report was assembled. + pub observed_at: u64, +} + /// 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] @@ -156,15 +272,30 @@ pub trait RewardsChainPort: Send + Sync { /// 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>; + + /// SPEC §2.6/§7.4/§12.4, dig_ecosystem#3269 units 1-2: one distributor's full chain-derived + /// report, feeding both `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` + /// from a single call — see [`DistributorReport`]'s doc for why this is a new type rather than + /// a widened [`DistributorChainState`], and [`ChainPortError::InvalidWithdrawalShare`] for the + /// one refusal path this call can produce beyond [`ChainPortError::Unavailable`]. + async fn distributor_report( + &self, + launcher_id: Bytes32, + ) -> Result; } -/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// The production adapter until dig_ecosystem#3310 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. +/// reported progress would be the exact honesty violation §2.4 forbids. #3310 replaces it with an +/// adapter built in `dig-node-service` over `dig-rewards-coin`'s reader and injected through +/// [`crate::Node::install_reward_chain_port`]; nothing above this seam changes. +/// +/// This used to cite #3249, the `dig-rewards-coin` DRIVER ticket. That was a dead pointer: the +/// driver crate does no socket I/O — `read_distributor` takes a caller-supplied `ChainSource` — so +/// it can never be this adapter, and #3310 is the ticket that owns it (it names both +/// `distributor_report` and the install call). pub struct UnavailableChainPort; #[async_trait] @@ -187,6 +318,13 @@ impl RewardsChainPort for UnavailableChainPort { async fn spend_new_epoch(&self, _launcher_id: Bytes32) -> Result<(), ChainPortError> { Err(ChainPortError::Unavailable) } + + async fn distributor_report( + &self, + _launcher_id: Bytes32, + ) -> Result { + Err(ChainPortError::Unavailable) + } } #[cfg(test)] @@ -217,5 +355,9 @@ mod tests { port.spend_new_epoch([0u8; 32]).await, Err(ChainPortError::Unavailable) ); + assert_eq!( + port.distributor_report([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 index 69a60b3b..fe10513a 100644 --- a/crates/dig-node-core/src/rewards/spec_constants.rs +++ b/crates/dig-node-core/src/rewards/spec_constants.rs @@ -3,9 +3,11 @@ //! # 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 +//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` does +//! not publish these prover-side numbers: 0.4.1 ships the reader (`state::read_distributor`) and +//! the share arithmetic (`clawback::recoverable_base_units`), and its `constants` module carries +//! distributor-side values only. Should the crate ever publish 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. diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index 7f5874eb..89bfcd16 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -241,7 +241,8 @@ pub trait WriteBoundStore: Send + Sync { /// 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 +/// itself is `UnavailableChainPort` until #3310 lands the `RewardsChainPort` adapter (it names +/// both `distributor_report` and `install_reward_chain_port`), so this adapter costs nothing operationally /// yet and closes the money hole the moment either seam is wired. pub struct NoPersistence; 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 85be4b2e..46352a0d 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -17,6 +17,7 @@ use serde_json::{json, Value}; +use crate::rewards::port::{ChainPortError, DistributorReport}; use crate::Node; // The relocated body below calls a number of crate-root private helpers (`rpc_err`, // `parse_store_id_arg`, `pin_request_root`, …) UNQUALIFIED, exactly as it did when it lived in @@ -34,6 +35,112 @@ use crate::*; /// own surface. const ENGINE_WARMING: i64 = -32002; +/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is +/// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all). +/// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`] below — a caller must be able to tell +/// "ask me again once the adapter lands" apart from "this distributor's own constant is out of +/// range". Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, +/// `-32032`), but carries its own `data.code` machine string so the two are still distinguishable +/// in the body. +const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; + +/// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's +/// `withdrawal_share_bps` does not fit the wire's `u16` domain or exceeds the legitimate +/// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s +/// doc for why a per-distributor curried value makes that the correct shape, never a `0` or an +/// omitted field. +/// +/// Discriminated by `data.code` on [`CONTROL_ERROR`]'s `-32032`, exactly like +/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`]: the shared wire taxonomy (`lib.rs`'s canonical catalogue) +/// registers no code beyond `-32032`, and minting a fresh number locally would put this node +/// outside the byte-identical contract it declares it follows — another implementation would have +/// no way to read it. The next number is not even free: `dig-node-service` already spends it on an +/// unrelated ingress refusal. +const REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE: &str = "REWARD_INVALID_WITHDRAWAL_SHARE"; + +/// dig_ecosystem#3269 unit 5: the machine code for [`ChainPortError::ZeroIdentity`] — a +/// zeroed `launcher_id`/`store_id` refused rather than rendered, matching +/// `REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`'s sibling shape. +const REWARD_ZERO_IDENTITY_MACHINE: &str = "REWARD_ZERO_IDENTITY"; + +/// Maps a [`ChainPortError`] to the JSON-RPC error response for both reward-distributor read +/// methods (dig_ecosystem#3269 unit 2) — one mapping so `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` can never disagree about how a given port failure reads +/// on the wire. +fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value { + match error { + ChainPortError::Unavailable => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } + }}), + ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", + "data": { "code": REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE, "origin": "control" } + }}), + ChainPortError::ZeroIdentity => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "distributor report carries a zeroed launcher_id or store_id, which is never a real identity", + "data": { "code": REWARD_ZERO_IDENTITY_MACHINE, "origin": "control" } + }}), + ChainPortError::Other(msg) => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": format!("reward-distributor chain read failed: {msg}"), + "data": { "code": "CONTROL_ERROR", "origin": "control" } + }}), + } +} + +/// The largest legitimate `withdrawal_share_bps`: 10,000 basis points IS 100%, so this is an +/// inclusive bound and `10_000` itself is a valid distributor constant, not an error. +const MAX_WITHDRAWAL_SHARE_BPS: u16 = 10_000; + +/// Refuses a report whose `withdrawal_share_bps` is outside the legitimate `0..=10_000` range +/// before any part of it reaches the wire (dig_ecosystem#3284). +/// +/// The port's own adapter is contracted to refuse this (`ChainPortError::InvalidWithdrawalShare`), +/// but that contract is enforced NOWHERE at this seam unless it is checked here: a chain constant +/// of `74_536` narrowed into the wire's `u16` renders as `9_000` — an ordinary-looking 90% share — +/// and `65_536` renders as `0`. Either is a figure a funder reads before deciding whether to claw +/// back, and neither looks wrong, so the handler cannot delegate the check to the thing it is +/// reading from. +/// +/// Refuses rather than CLAMPS deliberately. Clamping `74_536` to `10_000` would hand back a +/// confident "100% recoverable" for a distributor whose real constant is nonsense — a money lie +/// dressed as a safe default. Refusing the WHOLE call costs no good data either: +/// `withdrawal_share_bps` is curried once per distributor at launch, so every commitment slot in +/// one response shares the one invalid value and there is no honest row to keep. +fn range_checked_report(report: DistributorReport) -> Result { + if report.withdrawal_share_bps > MAX_WITHDRAWAL_SHARE_BPS { + return Err(ChainPortError::InvalidWithdrawalShare); + } + // dig_ecosystem#3269 unit 5: a zeroed `launcher_id` or `store_id` is never a real distributor's + // or module's identity (see `ChainPortError::ZeroIdentity`'s doc) — refuse rather than render. + if report.launcher_id == [0u8; 32] || report.store_id == [0u8; 32] { + return Err(ChainPortError::ZeroIdentity); + } + Ok(report) +} + +/// Parses `params.launcher_id` (64-hex) into the port's own `[u8; 32]` shape +/// (`rewards::port::Bytes32`) for `dig.getRewardDistributor` / +/// `dig.listRewardDistributorCommitments`. +fn parse_launcher_id_arg(params: &Value) -> Result<[u8; 32], String> { + let s = params + .get("launcher_id") + .and_then(Value::as_str) + .ok_or_else(|| "params.launcher_id must be a 64-hex string".to_string())?; + let h = s.trim_start_matches("0x"); + if h.len() != 64 { + return Err(format!("launcher_id must be 64-hex: {s}")); + } + let bytes = hex::decode(h).map_err(|_| format!("launcher_id is not hex: {s}"))?; + bytes + .try_into() + .map_err(|_: Vec| format!("launcher_id must be 32 bytes (64 hex): {s}")) +} + /// Decide the miss error for a request that fell all the way through with no configured upstream /// (dig_ecosystem#2097): `(code, message)`. /// @@ -775,10 +882,11 @@ impl RpcDispatch for Node { // 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` + // loop) — a REAL read of a real, currently-empty registry, so + // `{"statuses":{"outcome":"consulted","observed_at":N,"items":[]}}` means "this node + // runs no prover loops", genuinely read rather than assumed: an empty `items` under a + // `consulted` outcome. It 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) => { @@ -787,40 +895,49 @@ impl RpcDispatch for Node { .get("launcher_id") .and_then(Value::as_str) .map(str::to_ascii_lowercase); - let statuses: Vec = node - .reward_prover_status_snapshots() + let snapshots = node.reward_prover_status_snapshots(); + // dig_ecosystem#3269 fix939: a zeroed `launcher_id` or `store_id` is never a real + // distributor's or module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. + // An earlier version of this handler EXCLUDED such a record with a `warn!` and + // still answered `Half::Consulted` with the survivors — but `Consulted`'s + // contract (dig-rpc-protocol 0.12.0 `types.rs:1666`) is "the complete answer", so + // that exclusion was itself the silent-drop defect this epic exists to kill: the + // wire said "I looked, there is nothing" when the truth was "I looked, found it, + // and discarded it." This now REFUSES THE WHOLE CALL instead — matching + // `range_checked_report`'s whole-call refusal for the three sibling + // reward-distributor handlers above — checked before the `launcher_id` filter is + // applied, so a caller who narrows to exactly the bad record is refused too, not + // handed a reassuring empty list. + for s in &snapshots { + 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; refusing dig.getRewardProverStatus rather than answering a Consulted result with it silently dropped" + ); + return reward_chain_port_error_response( + &id, + &ChainPortError::ZeroIdentity, + ); + } + } + let statuses: Vec = 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. + // A zeroed `root` alone is different from a missing identity: 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. Refusing on that basis 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!`, and the + // record is still returned. (Every record reaching this point has already + // passed the missing-identity check above, so `zeroed_fields` here can only + // ever name `root`.) .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() { + if !zeroed.is_empty() { tracing::debug!( launcher_id = %hex::encode(s.launcher_id), store_id = %hex::encode(s.store_id), @@ -829,7 +946,7 @@ impl RpcDispatch for Node { "reward-prover status has a zeroed root; likely no cycle observed yet, returning it anyway" ); } - !is_missing_identity(&zeroed) + true }) .filter(|s| match &filter_launcher_id { Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), @@ -837,7 +954,218 @@ impl RpcDispatch for Node { }) .map(reward_prover_status_to_wire) .collect(); - let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; + // dig-rpc-protocol 0.12.0 migration: `statuses` moved from a bare `Vec` to + // `Half` (SPEC §12.5 clause 6's "reassuring zero" rule). + // This registry read never fails — it is an in-process `RwLock` read, not a + // fallible chain or disk read — so it is always `Consulted`, dated at the + // moment this response was assembled. + use crate::rewards::state::Clock as _; + let result = dig_rpc_protocol::types::GetRewardProverStatusResult { + statuses: dig_rpc_protocol::types::Half::Consulted { + observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + items: statuses, + }, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL + // plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method` + // (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY — + // never the local prover loop's self-reported state (see `GetRewardProverStatus` + // above for that). Goes entirely through `rewards::port::RewardsChainPort`: this + // crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0). + Some(Method::GetRewardDistributor) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let launcher_id = match parse_launcher_id_arg(¶ms) { + Ok(id) => id, + Err(msg) => return rpc_err(&id, -32602, &msg), + }; + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + // Range-check at THIS seam, not only in the adapter: see + // `range_checked_report` for why an out-of-range share must refuse here. + let report = match port + .distributor_report(launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + let result = dig_rpc_protocol::types::GetRewardDistributorResult { + launcher_id: hex::encode(report.launcher_id), + store_id: hex::encode(report.store_id), + root: hex::encode(report.root), + epoch_seconds: report.epoch_seconds, + first_epoch_start: report.first_epoch_start, + payout_threshold: report.payout_threshold, + fee_bps: report.fee_bps, + withdrawal_share_bps: report.withdrawal_share_bps, + reserve_base_units: report.reserve_base_units, + entry_count: report.entry_count, + current_distributor_epoch: report.current_distributor_epoch, + last_entry_write_at: report.last_entry_write_at, + entry_set_stale: report.entry_set_stale, + observed_at: report.observed_at, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5) + // — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments` + // empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot + // is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see + // `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here). + Some(Method::ListRewardDistributorCommitments) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let launcher_id = match parse_launcher_id_arg(¶ms) { + Ok(id) => id, + Err(msg) => return rpc_err(&id, -32602, &msg), + }; + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + // Range-check at THIS seam, not only in the adapter: see + // `range_checked_report` for why an out-of-range share must refuse here. + let report = match port + .distributor_report(launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + let commitments: Vec = report + .commitments + .iter() + .map(|c| dig_rpc_protocol::types::RewardDistributorCommitment { + epoch_start: c.epoch_start, + clawback_puzzle_hash: hex::encode(c.clawback_puzzle_hash), + rewards_base_units: c.rewards_base_units, + recoverable_base_units: c.recoverable_base_units, + }) + .collect(); + let result = dig_rpc_protocol::types::ListRewardDistributorCommitmentsResult { + launcher_id: hex::encode(report.launcher_id), + withdrawal_share_bps: report.withdrawal_share_bps, + epoch_seconds: report.epoch_seconds, + commitments, + observed_at: report.observed_at, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — CONTROL plane, + // same guard shape as the other reward handlers above. Two independently-consulted + // halves (`funded` / `claimable`), each a `Half` — SPEC §12.5 + // clause 6's "reassuring zero" rule applies to EACH half separately. + // + // `funded`: this node's OWN identity registry + // (`rewards::funded::FundedDistributorRegistry`, dig_ecosystem#3285) says WHICH + // launcher ids this node funds; `Node::funded_distributors_read` is matched with no + // wildcard arm so a future variant added to `FundedDistributorsRead` fails this match + // at compile time instead of silently falling into the wrong half. Once the identity + // set answers, each launcher id's CURRENT `(store_id, root)` is resolved through the + // same chain-read port `dig.getRewardDistributor` uses + // (`RewardsChainPort::distributor_report`) — `FundedDistributor` carries identity + // only (see that module's doc), never a root, so a ref cannot be assembled from the + // registry alone. A per-item chain-report failure refuses the WHOLE call with the + // same `ChainPortError` response the sibling handlers use, rather than emitting a + // partial list or a fabricated store_id/root (dig_ecosystem#3308/#3309: a zero hash + // rendered as though real is the same defect family as an empty list standing in for + // an error). + // + // `claimable`: distributors this node holds a MIRROR claim to but does not fund. No + // such tracking exists anywhere in this crate today (see the module search backing + // this comment — dig_ecosystem#3269 unit 2 wires only the funder side), so this half + // is honestly `NotConsulted`: nothing looked, because nothing here can look yet. It + // is NOT `Consulted { items: [] }` — that would claim this node checked and found no + // claimable distributor, which is not true; the truth is no check exists. + Some(Method::ListRewardDistributors) => { + use crate::rewards::state::Clock as _; + let now = crate::rewards::state::SystemClock.now_unix_seconds(); + + let identities: Vec = + match node.funded_distributors_read() { + crate::rewards::funded::FundedDistributorsRead::Funded(v) => v, + crate::rewards::funded::FundedDistributorsRead::FundsNothing => Vec::new(), + crate::rewards::funded::FundedDistributorsRead::NotConfigured(_) + | crate::rewards::funded::FundedDistributorsRead::PersistedStateCorrupt { + .. + } + | crate::rewards::funded::FundedDistributorsRead::IoFailed { .. } => { + return json!({"jsonrpc":"2.0","id":id,"result": + dig_rpc_protocol::types::ListRewardDistributorsResult { + funded: dig_rpc_protocol::types::Half::NotConsulted { + observed_at: now, + }, + claimable: dig_rpc_protocol::types::Half::NotConsulted { + observed_at: now, + }, + } + }); + } + }; + + let mut funded_refs = Vec::with_capacity(identities.len()); + for identity in identities { + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + let report = match port + .distributor_report(identity.launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + funded_refs.push(dig_rpc_protocol::types::RewardDistributorRef { + launcher_id: hex::encode(report.launcher_id), + store_id: hex::encode(report.store_id), + root: hex::encode(report.root), + }); + } + + let result = dig_rpc_protocol::types::ListRewardDistributorsResult { + funded: dig_rpc_protocol::types::Half::Consulted { + observed_at: now, + items: funded_refs, + }, + claimable: dig_rpc_protocol::types::Half::NotConsulted { observed_at: now }, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.getPayeeRewardClaimStatus (dig_ecosystem#3268/#3269 unit 3, SPEC §12.5) — + // CONTROL plane: loopback admin / in-process FFI ONLY, absent from + // `is_peer_reachable_method` (`reward_methods_tier_guard.rs` fails closed on that). + // Dispatched through `Method::from_name(..)` like every other reward method — never + // the string pre-match above the enum, which bypasses this tier guard entirely + // (dig_ecosystem#3261: a reward RPC reachable by a peer is a money hole). + // + // `subject` is always the literal `PayeeSubject::Payee` (SPEC §12.5: this node + // answers as a payee, never as a funder — see `PayeeClaimStatus`'s doc for the + // 250x-overstatement defect that shipped when a renderer inferred the subject from + // the endpoint instead of reading it off the payload). + // + // `claim_log`: this crate holds no claim log anywhere — the claim-submission adapter + // is dig_ecosystem#3310's, in `dig-node-service`, injected downward (see + // `rewards::port`'s module doc; #3249 is a dead pointer for it, see the doc there). + // So `claims_submitted_count` has never been read here and the honest answer is + // `NotConsulted`, dated at the moment this responder established it has no log to + // read — never `Consulted { claims_submitted_count: 0 }`, which would be exactly the + // "reassuring zero" SPEC §12.5 clause 6 forbids: a confident zero beside a fresh + // timestamp, indistinguishable from "read the log, found nothing". + // + // No monetary amount, ever, and no payout puzzle hash — see `PayeeClaimStatus`'s doc. + // No params type: this call takes none. + Some(Method::GetPayeeRewardClaimStatus) => { + use crate::rewards::state::Clock as _; + let result = dig_rpc_protocol::types::PayeeClaimStatus { + subject: dig_rpc_protocol::types::PayeeSubject::Payee, + claim_log: dig_rpc_protocol::types::ClaimLogObservation::NotConsulted { + observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + }, + }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } Some(Method::CacheSetCapBytes) => { diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 54b40044..2665d69f 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,11 +96,12 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.11 line that +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.12 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`), AND (#3269) the -/// reward RPC surface (`Method::GetRewardProverStatus` et al., all `Tier::Control`). +/// reward RPC surface (`Method::GetRewardProverStatus`, `Method::ListRewardDistributors`, +/// `Method::GetPayeeRewardClaimStatus`, 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` @@ -109,11 +110,11 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// 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. +/// **Cascade closed (#3269, final leg):** `dig-node-core` depends on 0.12 directly; `dig-peer` +/// (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) all now resolve +/// `dig-rpc-protocol` 0.12 too, so `cargo metadata` resolves exactly one line. This assertion is +/// deliberately left at exactly-one/0.12 (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"); @@ -124,9 +125,9 @@ 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.11."), + versions[0].starts_with("0.12."), "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 \ + dig-rpc-protocol 0.12; 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 index 37393051..34e79565 100644 --- a/crates/dig-node-core/tests/reward_methods_tier_guard.rs +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -42,6 +42,21 @@ fn reward_methods_exist_and_are_found_by_the_prefix_scan() { "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" ); + // dig_ecosystem#3269 unit 3: pins the count so the guard cannot silently start policing FEWER + // methods than actually exist (a non-empty check alone would still pass on 4 of 5, or on a + // renamed variant the filter stopped matching). Update this number deliberately when + // `dig-rpc-protocol` adds or removes a reward method. + // + // Bumped 4 -> 5 (dig_ecosystem#3269, final leg): dig-rpc-protocol 0.12.0 added + // `dig.getPayeeRewardClaimStatus`, whose name also contains "Reward". + assert_eq!( + methods.len(), + 5, + "expected exactly 5 Reward-prefixed methods (dig.listRewardDistributors, \ + dig.getRewardProverStatus, dig.getRewardDistributor, \ + dig.listRewardDistributorCommitments, dig.getPayeeRewardClaimStatus); got {}", + methods.len() + ); } #[test] diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index ed7e3f94..2497e893 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -179,10 +179,10 @@ getrandom = "0.2" # them. Two majors in one workspace also duplicates the wire TYPES; pinned by # `dig-node-core/tests/dependency_tree.rs`. # -# 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" +# Moved to 0.12 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.12.0 — 0.12 renamed +# `RewardSubject` -> `PayeeSubject` and replaced `HalfObservation` with `Half`, and this line +# staying at 0.11 would duplicate the wire types the paragraph above warns against. +dig-rpc-protocol = "0.12" # 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 @@ -324,9 +324,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.11" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.12" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.11" +dig-rpc-protocol = "0.12" # 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 8b81583187962ee42d2b42b55c0867253ee901d4 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:49:54 -0700 Subject: [PATCH 16/29] chore(release): bump workspace version to 0.258.0 Develop sat at 0.255.0 while main was at 0.257.0, so the develop -> main release PR could not satisfy the required Check Version Increment gate. Cargo.lock synced so --locked is satisfied. Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b68e9d46..369aff7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.255.0" +version = "0.258.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 1d44df4e..d351d375 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.258.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 0ae3ef4bce16b904da40413ac772edb8bf541351 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:55:10 -0700 Subject: [PATCH 17/29] ci: propagate main's release-cut commitlint depth to develop On pull_request GitHub runs the workflow from the PR's HEAD branch, so a develop -> main release PR runs develop's copy of commitlint.yml. main's commitDepth: 1 release-cut branch was therefore never consulted at a cut. The file never reached develop because the -s ours reconciliation at cd8ce7b8 records ancestry without bringing content across. Refs #3298 Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) --- .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 From 7902ad7b08dd599ecad4a7b9944efdee17a807b9 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:01:22 -0700 Subject: [PATCH 18/29] fix(rewards): clamp an out-of-range claim schedule to the max (#611) * fix(rewards): clamp an out-of-range claim schedule to the max instead of substituting the default sanitized_schedule() previously replaced any above-maximum cadence/jitter with the documented 1-day/1-hour default, so a 60-day configured cadence ran every 1 day -- 31x more often than the operator asked. Above-max values now clamp to CLAIM_SCHEDULE_SECONDS_MAX (31 days) instead. A zero cadence still substitutes the default (it has no clamp direction and would busy-loop the engine) -- that branch is unchanged and its WARN reads "substituted", distinct from the clamp branches' "clamped". The adjustment (configured vs effective) is now threaded call-scoped (ScheduleAdjustment, never an engine field) from sanitized_schedule() into drive() and onto the per-cycle log line, so an operator reading any single cycle can see the schedule actually in force -- previously only a once-per-process WARN said so. Refs #3306 Co-Authored-By: Claude Sonnet 5 * fix(test): assert for CLAMPED warning in uppercase, not lowercase Production tracing::warn! at sanitized_schedule emits "CLAMPED" in uppercase in both cadence and jitter clamping cases; test assertions must match. Fixes test: a_sixty_day_cadence_is_clamped_to_thirty_one_days_not_replaced_by_the_default Co-Authored-By: Claude Haiku 4.5 * fix(clippy): remove unused import CLAIM_JITTER_SECONDS_DEFAULT The jitter branch no longer substitutes the default, so the constant is not used at module level. Test module accesses it through use super::*. Fixes clippy error at crates/dig-node-service/src/rewards_claim/driver.rs:39 Co-Authored-By: Claude Haiku 4.5 * style(fmt): rustfmt formatting of driver.rs Applied cargo fmt to maintain code style consistency across new and modified test code. Fixes rustfmt check on PR #611 Co-Authored-By: Claude Haiku 4.5 * fix(test): add CLAIM_JITTER_SECONDS_DEFAULT import to test module The constant is used by the test at line 1446 but was removed from module-level imports. Add it to the test module's own use statements to fix the compilation error. Fixes clippy and test compilation errors on PR #611 Co-Authored-By: Claude Haiku 4.5 * fix(import): correct CLAIM_JITTER_SECONDS_DEFAULT import path in tests Changed from super::cadence to super::super::cadence to match the module nesting level. mod tests is inside driver, so super references driver. Requires super::super to reach cadence. Also applied rustfmt formatting. Verified with: cargo check -p dig-node-service --lib --tests Co-Authored-By: Claude Haiku 4.5 * fix(rewards): assert per-cycle field names, size fee window from raw cadence Gate F1: the per-cycle log test asserted bare numbers against the whole captured log buffer, which the once-per-spawn clamp WARN already contains -- deleting every per-cycle field from log_cycle would still pass it. Filter to "claim cycle complete" lines, require at least two, and assert the actual field names (configured_cadence_seconds / effective_cadence_seconds). Gate F2: with_persisted_fee_window was sized from the CLAMPED cadence, doubling the fee-budget windows a long-cadence operator sized (60-day config -> ~12 windows/year instead of ~6). Pass the raw cfg.cadence_seconds instead; the scheduler still uses the clamped value. Added a regression test showing the two configurations roll the persisted fee window at different ticks. Gate F3/F4: corrected the "unclamped at rest" and "would busy-loop" doc claims -- config.rs already floors cadence_seconds to CLAIM_CADENCE_FLOOR_SECONDS before the driver ever sees it, so the zero-cadence branch is defence in depth, not the primary guard. Removed the vacuous `!rendered.contains("clamped")` assertion (every clamp string is uppercase CLAMPED) and replaced it with a real one. Co-Authored-By: Claude Sonnet 5 * style(fmt): rustfmt driver.rs Co-Authored-By: Claude Sonnet 5 * fix(rewards): split the overloaded cadence field so the clamp governs the gate `ClaimEngine::cadence_seconds` was a single field feeding two policies: the restart-safety cadence gate (`run_cycle`'s `CadenceNotElapsed` check) and the persisted aggregate fee-budget window length. Passing the raw, unclamped configured cadence into `with_persisted_fee_window` set the GATE to the raw 60-day value while the driver's scheduler kept ticking on the clamped 31-day interval -- the loop woke every 31 days and was refused every time, turning the previous clamp fix into a complete no-op with every test green. Split the field into `gate_cadence_seconds` (CLAMPED -- the schedule the driver's background loop actually sleeps on; used by the `CadenceNotElapsed` gate at `run_cycle`) and `fee_window_seconds` (RAW -- the operator's configured cadence; sizes how long the persisted fee-budget window stays open before rolling). Reusing the clamped value for the window would double the number of fee-budget windows a long-cadence operator sized; reusing the raw value for the gate reproduces the exact silent-non-claiming defect this split exists to close. Updated `with_persisted_fee_window`'s and both fields' doc comments to describe the two-value split (the prior doc described the single field as a deliberate overload, which is now false); added an explicit mutation-tested proof (`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`) that drives the real `drive()` loop with an injected clock and asserts the gate opens on the clamped interval while the window rolls only on the raw one; and documented, on the existing `run_claim_driver_in` composition test, why that test cannot make the same state assertion (that body hardcodes `unix_now_seconds`, real wall-clock, which `tokio::time::advance` cannot drive). Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .../src/rewards_claim/driver.rs | 469 +++++++++++++++--- .../src/rewards_claim/engine.rs | 94 ++-- 2 files changed, 466 insertions(+), 97 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 858b978f..4d4bdd52 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -36,7 +36,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use chia_protocol::Bytes32; -use super::cadence::{next_interval_seconds, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; use super::engine::ClaimEngine; use super::hints::{DistributorHintSource, NoHintSource}; @@ -154,6 +154,7 @@ async fn drive( mut engine: ClaimEngine, cadence_seconds: u64, jitter_seconds: u64, + adjustment: ScheduleAdjustment, jitter: &dyn JitterSource, mut now: impl FnMut() -> u64, handle: ClaimLoopHandle, @@ -168,7 +169,7 @@ async fn drive( engine.run_cycle(t).await; let status = engine.status(); handle.record(status); - log_cycle(&status, handle.cycles_driven()); + log_cycle(&status, handle.cycles_driven(), &adjustment); } } @@ -181,7 +182,20 @@ async fn drive( /// /// [`ClaimLoopState::Nominal`] is the routine case (`info`). Every other state means this peer is /// earning nothing and names why, which on a money surface is a warning, not chatter. -fn log_cycle(status: &ClaimStatus, cycles_driven: u64) { +/// +/// `adjustment` is the call-scoped fact [`sanitized_schedule`] produced for THIS spawn (A4): when +/// the configured cadence or jitter was not honoured as-is, its configured/effective pair is +/// named on every single cycle line, not just in the once-per-spawn WARN `sanitized_schedule` +/// itself emits. Without this, an operator reading any one cycle log line has no way to learn the +/// schedule in force differs from the one they configured. +fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdjustment) { + let (configured_cadence_seconds, effective_cadence_seconds) = adjustment + .cadence + .map_or((None, None), |(c, e)| (Some(c), Some(e))); + let (configured_jitter_seconds, effective_jitter_seconds) = adjustment + .jitter + .map_or((None, None), |(c, e)| (Some(c), Some(e))); + if status.state == ClaimLoopState::Nominal { tracing::info!( target: "rewards_claim", @@ -190,6 +204,10 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64) { distributors_known = status.distributors_known, distributors_claimable = status.distributors_claimable, claims_submitted = status.claims_submitted, + configured_cadence_seconds, + effective_cadence_seconds, + configured_jitter_seconds, + effective_jitter_seconds, "claim cycle complete" ); } else { @@ -200,6 +218,10 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64) { distributors_known = status.distributors_known, distributors_claimable = status.distributors_claimable, claims_submitted = status.claims_submitted, + configured_cadence_seconds, + effective_cadence_seconds, + configured_jitter_seconds, + effective_jitter_seconds, concat!( "claim cycle complete but this node is NOT claiming rewards -- see the named ", "state for why" @@ -315,29 +337,74 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// green; a config value must not be able to put this driver back in that state silently. const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; -/// Replace a schedule value that would switch the loop off (or spin it) with its documented -/// default, saying so at `WARN` -- never silently accept it, and never silently accept the -/// default either. Returns `(cadence_seconds, jitter_seconds)` fit to schedule with. +/// [`sanitized_schedule`]'s call-scoped report of what it changed, threaded into [`drive`] and +/// on into [`log_cycle`] -- NEVER stored on [`ClaimEngine`] as a field (see this module's SHAPE +/// note: `FeeWindowState` in `engine.rs` is call-scoped for exactly this reason, and putting a +/// value of this class back as an engine field caused three separate latching defects before +/// that was closed structurally). `None` in either field means the configured value was honoured +/// unchanged; `Some((configured, effective))` means it was not, and both values are named so a +/// reader of a single cycle log line can see the schedule actually in force (A4) -- the +/// once-per-spawn WARN this same call emits is invisible to that reader. +#[derive(Debug, Clone, Copy, Default)] +struct ScheduleAdjustment { + /// `(configured, effective)` cadence, when [`sanitized_schedule`] changed it. + cadence: Option<(u64, u64)>, + /// `(configured, effective)` jitter, when [`sanitized_schedule`] changed it. + jitter: Option<(u64, u64)>, +} + +impl ScheduleAdjustment { + /// Both values honoured as configured -- the ordinary case. + fn none() -> Self { + Self::default() + } +} + +/// Sanitize a persisted schedule pair, at the read, before either value can reach the scheduler. +/// Returns `(cadence_seconds, jitter_seconds, ScheduleAdjustment)`. /// -/// A zero cadence is rejected for the opposite reason to a huge one: it would busy-loop the claim -/// engine as fast as the runtime can poll it. A zero JITTER is legitimate (it means "no jitter") -/// and is left alone. -fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64) { - let cadence = if cadence_seconds == 0 || cadence_seconds > CLAIM_SCHEDULE_SECONDS_MAX { +/// A zero cadence has no direction to clamp toward -- if it ever reached the claim engine it +/// would busy-loop it as fast as the runtime can poll -- so it is SUBSTITUTED with the documented +/// default, same as before, at `WARN`. F3: in production this branch is defence in depth, not the +/// primary guard -- `RewardsClaimConfig::load_from` (`config.rs`) already floors +/// `cadence_seconds` to `CLAIM_CADENCE_FLOOR_SECONDS` (60) at the read, before this function ever +/// sees the value, so zero only reaches here if that floor is removed or bypassed. An +/// in-range-but-too-large cadence, or an out-of-range jitter, has an obvious direction: down. +/// Those are CLAMPED to [`CLAIM_SCHEDULE_SECONDS_MAX`] instead of replaced by an unrelated default +/// -- a 60-day configured cadence must run every 31 days, the closest the operator's intent can be +/// honoured, not every 1 day (the default). A zero JITTER is legitimate (it means "no jitter") and +/// is left alone. +fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64, ScheduleAdjustment) { + let mut adjustment = ScheduleAdjustment::none(); + + let cadence = if cadence_seconds == 0 { tracing::warn!( target: "rewards_claim", field = "cadence_seconds", - rejected = cadence_seconds, + configured = cadence_seconds, substituted = CLAIM_CADENCE_SECONDS_DEFAULT, - max = CLAIM_SCHEDULE_SECONDS_MAX, "{}", concat!( - "rewards_claim.cadence_seconds is outside the honoured range and was IGNORED; ", - "the documented default is used instead -- a value that large would stop the ", - "claim loop from ever firing again, and zero would busy-loop it" + "rewards_claim.cadence_seconds is zero, which would busy-loop the claim engine; ", + "the documented default was substituted instead" ) ); + adjustment.cadence = Some((cadence_seconds, CLAIM_CADENCE_SECONDS_DEFAULT)); CLAIM_CADENCE_SECONDS_DEFAULT + } else if cadence_seconds > CLAIM_SCHEDULE_SECONDS_MAX { + tracing::warn!( + target: "rewards_claim", + field = "cadence_seconds", + configured = cadence_seconds, + max = CLAIM_SCHEDULE_SECONDS_MAX, + "{}", + concat!( + "rewards_claim.cadence_seconds is above the honoured maximum and was CLAMPED to ", + "it -- a value that large would stop the claim loop from ever firing again" + ) + ); + adjustment.cadence = Some((cadence_seconds, CLAIM_SCHEDULE_SECONDS_MAX)); + CLAIM_SCHEDULE_SECONDS_MAX } else { cadence_seconds }; @@ -346,22 +413,22 @@ fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64) { tracing::warn!( target: "rewards_claim", field = "jitter_seconds", - rejected = jitter_seconds, - substituted = CLAIM_JITTER_SECONDS_DEFAULT, + configured = jitter_seconds, max = CLAIM_SCHEDULE_SECONDS_MAX, "{}", concat!( - "rewards_claim.jitter_seconds is outside the honoured range and was IGNORED; ", - "the documented default is used instead -- a value that large saturates the ", - "next interval and the claim loop would never fire again" + "rewards_claim.jitter_seconds is above the honoured maximum and was CLAMPED to ", + "it -- a value that large saturates the next interval and the claim loop would ", + "never fire again" ) ); - CLAIM_JITTER_SECONDS_DEFAULT + adjustment.jitter = Some((jitter_seconds, CLAIM_SCHEDULE_SECONDS_MAX)); + CLAIM_SCHEDULE_SECONDS_MAX } else { jitter_seconds }; - (cadence, jitter) + (cadence, jitter, adjustment) } async fn run_claim_driver_in

( @@ -379,9 +446,12 @@ async fn run_claim_driver_in

( // here instead would report NOTHING at all, which is the exact silent failure this ticket // exists to prevent -- a corrupt file must stay visible, not vanish into "never started". - // The config is operator-writable and unclamped at rest (`config.rs` deliberately reports what - // is on disk). Sanitize HERE, at the read, before either value can reach the scheduler. - let (cadence_seconds, jitter_seconds) = + // F4: the config is operator-writable, and `jitter_seconds` is unclamped at rest (`config.rs` + // deliberately reports what is on disk for it). `cadence_seconds` is the one exception -- + // `config.rs` already floors it to `CLAIM_CADENCE_FLOOR_SECONDS` before it reaches this + // function -- but it is still unclamped at the TOP end here, so sanitize HERE, at the read, + // before either value can reach the scheduler. + let (cadence_seconds, jitter_seconds, adjustment) = sanitized_schedule(cfg.cadence_seconds, cfg.jitter_seconds); let engine = ClaimEngine::new( @@ -393,12 +463,22 @@ async fn run_claim_driver_in

( dig_mirror_coin::DIG_ASSET_ID, ) .with_rotation_cursor(cfg.rotation_cursor) - .with_persisted_fee_window(state_dir, cadence_seconds); + // F2: two DIFFERENT cadence values, deliberately -- `cadence_seconds` (CLAMPED, already + // bounded to `CLAIM_SCHEDULE_SECONDS_MAX`) gates WHEN a cycle may run, tracking the same + // schedule the driver below actually sleeps on. `cfg.cadence_seconds` (RAW, unclamped) sizes + // the persisted fee-budget window -- reusing the clamped value there would double the number + // of budget windows a long-cadence operator sized (a 60-day config would get ~12 windows/year + // instead of the ~6 its cadence implies -- 2x the fee ceiling they configured). Conflating the + // two into one value in either direction is wrong: clamped-for-both doubles the fee ceiling, + // raw-for-both can silently starve the gate (an unbounded-above raw cadence would stop cycles + // from ever running while the scheduler keeps ticking on the clamped interval). + .with_persisted_fee_window(state_dir, cadence_seconds, cfg.cadence_seconds); drive( engine, cadence_seconds, jitter_seconds, + adjustment, &OsJitter, unix_now_seconds, handle, @@ -495,6 +575,7 @@ mod tests { use std::sync::atomic::AtomicUsize; use std::sync::Arc; + use super::super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; use super::super::port::ClaimPortError; use super::super::types::{DiscoveredDistributor, OwnEntry}; @@ -704,6 +785,7 @@ mod tests { empty_engine(), cadence, 0, + ScheduleAdjustment::none(), &super::super::cadence::FixedJitter(0), { let mut t = 0u64; @@ -910,7 +992,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds); + .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 assert!(outcomes.is_empty()); @@ -921,6 +1003,123 @@ mod tests { ); } + /// F2, MONEY: the gate that decides WHEN a cycle may run must track the CLAMPED schedule (the + /// same interval [`drive`] actually ticks on), while the persisted fee-budget window must + /// track the RAW configured cadence -- conflating them into one value either doubles the + /// operator's fee ceiling (clamped-for-both) or can silently starve the gate for an + /// unbounded-above raw cadence (raw-for-both), which is the exact "all checks green, node + /// stops claiming" shape F1 exists to catch elsewhere in this module. + /// + /// Driven through the REAL production loop, [`drive`] (the same function + /// [`run_claim_driver_in`] calls), with an INJECTED clock -- [`run_claim_driver_in`] itself + /// hardcodes [`unix_now_seconds`] (real wall-clock), which cannot be driven deterministically + /// under `#[tokio::test(start_paused = true)]` (only the *sleep* is virtual, not + /// `SystemTime::now()`), so this test injects a clock that advances in lockstep with the + /// scheduler's ticks instead, exactly as [`zero_cycles_before_the_interval_elapses_then_a_counted_number_after`] + /// already does for A1/A2. A 60-day config (`configured_cadence = 5_184_000`) clamped to the + /// 31-day ceiling (`effective_cadence = 2_678_400`, [`CLAIM_SCHEDULE_SECONDS_MAX`]): + /// - the GATE must open on every tick (cycles 1, 2, 3 all actually run, never + /// `CadenceNotElapsed`) -- it tracks the clamped 31-day schedule the loop ticks on; + /// - the WINDOW must NOT roll at tick 2 (elapsed since it opened is one clamped interval, + /// 2_678_400s, well under the raw 5_184_000s the operator configured) but MUST have rolled + /// by tick 3 (elapsed is 2 clamped intervals, 5_356_800s, past the raw boundary). + #[tokio::test(start_paused = true)] + async fn the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one() { + let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. + let effective_cadence = 31 * 24 * 60 * 60u64; // 2_678_400, CLAMPED -- sizes the gate. + assert_eq!(configured_cadence, 5_184_000); + assert_eq!(effective_cadence, 2_678_400); + + let dir = tempfile::tempdir().unwrap(); + let engine = ClaimEngine::new( + EmptyPort, + NoHintSource, + Bytes32::from([1u8; 32]), + 1, + 10, + Bytes32::from([2u8; 32]), + ) + .with_persisted_fee_window(dir.path(), effective_cadence, configured_cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let driver = tokio::spawn(async move { + drive( + engine, + effective_cadence, + 0, + ScheduleAdjustment::none(), + &super::super::cadence::FixedJitter(0), + { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }, + h, + ) + .await; + }); + + settle().await; + assert_eq!(handle.cycles_driven(), 0, "no interval has elapsed yet"); + + // Tick 1 (t = 2_678_400): first cycle ever, no last_completed_at yet -- the gate cannot + // refuse it, and the window opens for the first time. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 1); + assert_ne!( + handle.status().state, + super::super::types::ClaimLoopState::CadenceNotElapsed, + "the very first cycle has no prior completion to gate against" + ); + let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_1, + Some(effective_cadence), + "the window opens on tick 1" + ); + + // Tick 2 (t = 5_356_800): exactly one clamped interval since tick 1 -- the GATE must open + // (it tracks the clamped 2_678_400s schedule), but the WINDOW must NOT roll (only + // 2_678_400s of its raw 5_184_000s have elapsed). + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "F2: the gate must track the CLAMPED cadence -- a cycle sized from the raw 5_184_000 \ + cadence would still be refused here, reproducing the silent-non-claiming defect" + ); + assert_ne!( + handle.status().state, + super::super::types::ClaimLoopState::CadenceNotElapsed, + "F2: the gate opened one clamped interval after the last completion -- it must not \ + still be waiting on the raw 5_184_000s cadence" + ); + let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_2, after_tick_1, + "F2: the window must NOT have rolled yet -- only one clamped interval (2_678_400s) \ + has elapsed against its raw 5_184_000s length" + ); + + // Tick 3 (t = 8_035_200): two clamped intervals (5_356_800s) since the window opened -- + // past its raw 5_184_000s length. The window must finally roll. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 3); + let after_tick_3 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_ne!( + after_tick_3, after_tick_1, + "F2: the window must have rolled once the RAW 5_184_000s cadence elapsed" + ); + + driver.abort(); + } + #[tokio::test] async fn restart_with_an_elapsed_completion_runs_a_cycle() { let dir = tempfile::tempdir().unwrap(); @@ -939,7 +1138,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds); + .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); @@ -967,7 +1166,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds); + .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at assert!(outcomes.is_empty()); @@ -999,7 +1198,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), 1_000); + .with_persisted_fee_window(dir.path(), 1_000, 1_000); let outcomes = engine.run_cycle(1).await; assert!(outcomes.is_empty()); @@ -1184,6 +1383,7 @@ mod tests { empty_engine(), cadence, 0, + ScheduleAdjustment::none(), &super::super::cadence::FixedJitter(0), { let mut t = 0u64; @@ -1245,6 +1445,7 @@ mod tests { ), cadence, 0, + ScheduleAdjustment::none(), &super::super::cadence::FixedJitter(0), { let mut t = 0u64; @@ -1302,21 +1503,30 @@ mod tests { .unwrap(); } - /// An out-of-range `cadence_seconds` must be REPLACED by the documented default, and the - /// substitution must be visible: a value this large means "never fire again", and silently - /// honouring it reopens #594's inert-but-green shape one level up, in the config file. + /// ACCEPTANCE A1 (the ticket's headline defect, #3306): a 60-day configured cadence must be + /// CLAMPED to the 31-day maximum, not replaced by the 1-day default -- that substitution was + /// the defect, running the loop 31x more often than the operator asked. Asserting the exact + /// literal (not merely "!= default") is deliberate: a negation passes under a third wrong + /// value just as easily as under the old defect. #[test] - fn an_out_of_range_cadence_is_replaced_by_the_default_and_warned() { + fn a_sixty_day_cadence_is_clamped_to_thirty_one_days_not_replaced_by_the_default() { let (logs, _guard) = capture_logs(); - let rejected = std::hint::black_box(u64::MAX); - let (cadence, jitter) = sanitized_schedule(rejected, 0); + let sixty_days = 60 * 24 * 60 * 60; + let (cadence, jitter, adjustment) = sanitized_schedule(sixty_days, 0); assert_eq!( - cadence, CLAIM_CADENCE_SECONDS_DEFAULT, - "an out-of-range cadence must fall back to the documented default" + cadence, 2_678_400, + "a 60-day cadence must clamp to the 31-day maximum (2_678_400s), not the 1-day \ + default (86_400s) -- that substitution ran the loop 31x too often" ); assert_eq!(jitter, 0, "a legitimate zero jitter is left alone"); + assert_eq!( + adjustment.cadence, + Some((sixty_days, 2_678_400)), + "the call-scoped adjustment must carry BOTH the configured and effective cadence" + ); + assert_eq!(adjustment.jitter, None, "jitter was not adjusted"); let rendered = logs.rendered(); assert!( @@ -1325,26 +1535,121 @@ mod tests { ); assert!( rendered.contains("cadence_seconds"), - "the warning must name the FIELD that was ignored; got: {rendered}" + "the warning must name the FIELD that was clamped; got: {rendered}" + ); + assert!( + rendered.contains("CLAMPED"), + "the above-max branch must say CLAMPED, not substituted; got: {rendered}" + ); + assert!( + rendered.contains(&sixty_days.to_string()) && rendered.contains("2678400"), + "and both the configured and the effective value; got: {rendered}" + ); + } + + /// ACCEPTANCE A2: an out-of-range `jitter_seconds` clamps to the same maximum, never the + /// 1-hour default -- the same reasoning as the cadence clamp above. + #[test] + fn an_out_of_range_jitter_is_clamped_to_the_max_not_replaced_by_the_default() { + let (logs, _guard) = capture_logs(); + + let rejected = std::hint::black_box(u64::MAX); + let (cadence, jitter, adjustment) = sanitized_schedule(100, rejected); + + assert_eq!(cadence, 100, "an in-range cadence is left alone"); + assert_eq!( + jitter, CLAIM_SCHEDULE_SECONDS_MAX, + "an out-of-range jitter must clamp to the maximum, never the 1-hour default" + ); + assert_eq!(adjustment.cadence, None, "cadence was not adjusted"); + assert_eq!( + adjustment.jitter, + Some((rejected, CLAIM_SCHEDULE_SECONDS_MAX)) + ); + + let rendered = logs.rendered(); + assert!( + rendered.contains("WARN") && rendered.contains("jitter_seconds"), + "the clamped jitter field must be named at WARN; got: {rendered}" ); assert!( - rendered.contains(&rejected.to_string()) - && rendered.contains(&CLAIM_CADENCE_SECONDS_DEFAULT.to_string()), - "and both the rejected and the substituted value; got: {rendered}" + rendered.contains("CLAMPED"), + "the above-max branch must say CLAMPED, not substituted; got: {rendered}" + ); + assert!( + !rendered.contains(&CLAIM_JITTER_SECONDS_DEFAULT.to_string()), + "the 1-hour default must never appear -- it is not what was substituted; got: {rendered}" ); } - /// The same for `jitter_seconds` -- and, through the REAL production body, that the loop still - /// drives counted cycles instead of never firing again. Without the sanitizer, - /// `next_interval_seconds` saturates on this value and no cycle is ever driven: green, silent - /// and unpaid. + /// ACCEPTANCE A3: `cadence_seconds == 0` has no direction to clamp toward (if it ever reached + /// this function it would busy-loop the engine), so it keeps substituting the documented + /// default -- decided by the parent lane, not re-litigated here. F3: this branch is defence in + /// depth in production -- `config.rs`'s `RewardsClaimConfig::load_from` already floors + /// `cadence_seconds` to `CLAIM_CADENCE_FLOOR_SECONDS` (60) before this function ever sees it, + /// so `sanitized_schedule(0, _)` is exercised directly here, not through the floored + /// production path. The zero-branch WARN must say "substituted", distinctly from the + /// above-max branch's "CLAMPED", so the two are separately assertable. + #[test] + fn a_zero_cadence_still_substitutes_the_default_and_says_so() { + let (logs, _guard) = capture_logs(); + + let (cadence, jitter, adjustment) = sanitized_schedule(0, 0); + + assert_eq!( + cadence, CLAIM_CADENCE_SECONDS_DEFAULT, + "zero has no clamp direction and must keep substituting the default" + ); + assert_eq!(jitter, 0); + assert_eq!(adjustment.cadence, Some((0, CLAIM_CADENCE_SECONDS_DEFAULT))); + + let rendered = logs.rendered(); + assert!( + rendered.contains("WARN") && rendered.contains("cadence_seconds"), + "got: {rendered}" + ); + assert!( + rendered.contains("substituted"), + "the zero-cadence branch must say SUBSTITUTED, not clamped; got: {rendered}" + ); + // F3: `!rendered.contains("clamped")` (lowercase) was vacuously true -- every clamp + // message in this module uses uppercase `CLAMPED`, so that assertion could never fail and + // protected nothing. Assert against the actual string the above-max branch uses instead, + // which genuinely distinguishes the two branches. + assert!( + !rendered.contains("CLAMPED"), + "the zero-cadence branch is a substitution, not a clamp; got: {rendered}" + ); + } + + /// A `u64::MAX` cadence must still land at the 31-day ceiling, never at "never fires again" -- + /// the reason [`CLAIM_SCHEDULE_SECONDS_MAX`] exists at all (see its doc comment). + #[test] + fn a_u64_max_cadence_still_clamps_to_the_ceiling_not_saturating_the_schedule() { + let (cadence, _jitter, adjustment) = sanitized_schedule(std::hint::black_box(u64::MAX), 0); + assert_eq!(cadence, CLAIM_SCHEDULE_SECONDS_MAX); + assert_eq!( + adjustment.cadence, + Some((u64::MAX, CLAIM_SCHEDULE_SECONDS_MAX)) + ); + } + + /// ACCEPTANCE A4 + A5, through the REAL production body (`run_claim_driver_in`), against a + /// `tempfile::tempdir()` state dir: a 60-day configured cadence clamps to 31 days, the + /// PER-CYCLE log line (not just the once-per-spawn WARN) names both the configured 5_184_000 + /// and the effective 2_678_400 on every cycle it renders, and the loop keeps driving counted + /// cycles -- 0 -> 1 -> 2 -- rather than a clamp silently making it inert-and-green (A5). #[tokio::test(start_paused = true)] - async fn an_out_of_range_jitter_is_replaced_and_the_loop_still_drives_cycles() { + async fn a_clamped_cadence_is_named_on_every_cycle_log_line_and_the_loop_keeps_driving() { let (logs, _guard) = capture_logs(); - let cadence = 100u64; + let configured_cadence = 60 * 24 * 60 * 60u64; + let effective_cadence = 31 * 24 * 60 * 60u64; + assert_eq!(configured_cadence, 5_184_000); + assert_eq!(effective_cadence, 2_678_400); + let dir = tempfile::tempdir().unwrap(); - write_schedule_config(dir.path(), cadence, std::hint::black_box(u64::MAX)); + write_schedule_config(dir.path(), configured_cadence, 0); let handle = ClaimLoopHandle::default(); let h = handle.clone(); @@ -1354,28 +1659,60 @@ mod tests { }); settle().await; - // The substituted jitter is the DEFAULT hour, so one interval is at most cadence + 3600s. - tokio::time::advance(Duration::from_secs(cadence + CLAIM_JITTER_SECONDS_DEFAULT)).await; + assert_eq!(handle.cycles_driven(), 0, "no interval has elapsed yet"); + + tokio::time::advance(Duration::from_secs(effective_cadence)).await; settle().await; + assert_eq!( + handle.cycles_driven(), + 1, + "one clamped interval drove one cycle" + ); - assert!( - handle.cycles_driven() >= 1, - concat!( - "an out-of-range jitter must not switch the claim loop off: with the default ", - "substituted, at least one cycle is driven within cadence + the default jitter" - ) + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "A5: a clamp must never make the scheduler quietly stop driving cycles" ); + // NOTE: this test cannot also assert `handle.status().state != CadenceNotElapsed` here -- + // `run_claim_driver_in` hardcodes `unix_now_seconds` (real wall-clock) for the gate's + // `now`, and `tokio::time::advance` only fast-forwards the tokio sleep, never + // `SystemTime::now()`; real elapsed time between these two ticks is near-zero regardless + // of the virtual advance, so the gate legitimately reads `CadenceNotElapsed` on tick 2 + // even under the correct, fixed split. `the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one` + // below proves the actual clamped-vs-raw gate/window split with an INJECTED clock instead, + // which is the only way to make that assertion deterministic. + driver.abort(); + + // F1: assert against the PER-CYCLE lines specifically, not the whole shared buffer -- the + // once-per-spawn `sanitized_schedule` WARN already contains both bare numbers, so a bare + // `rendered.contains(...)` over the full buffer would pass even with every per-cycle field + // deleted from `log_cycle`. Filtering to `claim cycle complete` lines and requiring at + // least two of them, then asserting the FIELD NAMES `log_cycle` actually emits, makes this + // test about the per-cycle surface (this ticket's H4 acceptance item), not the spawn-time + // WARN. let rendered = logs.rendered(); + let cycle_lines: Vec<&str> = rendered + .lines() + .filter(|line| line.contains("claim cycle complete")) + .collect(); assert!( - rendered.contains("WARN") && rendered.contains("jitter_seconds"), - "the ignored jitter field must be named at WARN; got: {rendered}" - ); - assert!( - rendered.contains(&CLAIM_JITTER_SECONDS_DEFAULT.to_string()), - "and the substituted default must be readable; got: {rendered}" + cycle_lines.len() >= 2, + "one cycle line is not a per-cycle surface; got {} line(s): {rendered}", + cycle_lines.len() ); - - driver.abort(); + for line in &cycle_lines { + assert!( + line.contains(&format!("configured_cadence_seconds={configured_cadence}")), + "A4: every per-cycle line must name the CONFIGURED cadence by field name; got: {line}" + ); + assert!( + line.contains(&format!("effective_cadence_seconds={effective_cadence}")), + "A4: and the EFFECTIVE cadence by field name; got: {line}" + ); + } } } diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 2d098da2..b570920f 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -63,12 +63,26 @@ pub struct ClaimEngine { /// `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, + /// F2 (money): the threshold the restart-safety cadence gate (`run_cycle`'s + /// `CadenceNotElapsed` check) is measured against. This is the CLAMPED scheduling value -- + /// the same one the driver's background loop actually sleeps on -- never the raw configured + /// cadence, because the gate decides WHEN a cycle is allowed to run, and that must track the + /// schedule actually in force, not the operator's unclamped preference. 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. + gate_cadence_seconds: u64, + /// F2 (money): the window LENGTH the persisted aggregate fee budget is measured against. + /// This is the RAW configured cadence, deliberately NOT the clamped value + /// [`Self::gate_cadence_seconds`] carries -- reusing the clamped value here would double the + /// number of fee-budget windows a long-cadence operator sized (a 60-day config would get + /// ~12 windows/year instead of the ~6 its cadence implies, doubling the fee ceiling they + /// configured). [`Self::with_persisted_fee_window`] floors this to + /// [`super::config::CLAIM_CADENCE_FLOOR_SECONDS`] the same way [`RewardsClaimConfig::load_from`] + /// does, but applies NO upper clamp -- a huge raw value only widens the spend window, which is + /// the conservative direction for spend, never the dangerous one. + fee_window_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: @@ -103,7 +117,8 @@ impl ClaimEngine { status: ClaimStatus::default(), rotation_cursor: None, fee_window_state_dir: None, - cadence_seconds: 0, + gate_cadence_seconds: 0, + fee_window_seconds: 0, } } @@ -127,9 +142,14 @@ impl ClaimEngine { /// 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. + /// F2 (money): takes TWO cadence values, deliberately not one -- `gate_cadence_seconds` (the + /// CLAMPED value the driver's schedule actually runs on) gates WHEN a cycle is allowed to + /// start; `fee_window_seconds` (the RAW configured value) sizes how long the persisted + /// fee-budget window stays open. Conflating them into a single cadence (the pre-F2 shape) + /// either doubled the operator's fee ceiling (reusing the clamped value for the window) or + /// silently starved the gate to the raw value -- for an unbounded-above raw cadence, the gate + /// could stop opening at all while the scheduler kept ticking on the clamped interval. See + /// [`Self::gate_cadence_seconds`] and [`Self::fee_window_seconds`]'s field docs. /// /// 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 @@ -139,7 +159,9 @@ impl ClaimEngine { /// 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. + /// hot-loop hazard applies to whatever caller passes it a degenerate value directly. Applied + /// to BOTH values -- the gate must never be floored below the schedule's own floor, and the + /// fee window must never collapse to a near-zero length either. /// /// 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 @@ -153,9 +175,17 @@ impl ClaimEngine { /// 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 { + pub fn with_persisted_fee_window( + mut self, + dir: &Path, + gate_cadence_seconds: u64, + fee_window_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.gate_cadence_seconds = + gate_cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.fee_window_seconds = + fee_window_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -311,7 +341,8 @@ impl ClaimEngine { // 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 { + // F2: the CLAMPED value -- the gate tracks the schedule actually in force. + if now.saturating_sub(last_completed) < self.gate_cadence_seconds { self.status.state = ClaimLoopState::CadenceNotElapsed; return Vec::new(); } @@ -319,9 +350,10 @@ impl ClaimEngine { // 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. + // F2: the RAW configured value -- the window's length is what the operator sized. let window_still_open = window .start_unix - .is_some_and(|start| now.saturating_sub(start) < self.cadence_seconds); + .is_some_and(|start| now.saturating_sub(start) < self.fee_window_seconds); if !window_still_open { window.start_unix = Some(now); window.spent_mojos = 0; @@ -1742,7 +1774,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1767,7 +1799,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let second_outcomes = second.run_cycle(1_010).await; let second_submitted = second_outcomes @@ -1806,7 +1838,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes .iter() @@ -1846,7 +1878,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1869,7 +1901,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let second_outcomes = second.run_cycle(later).await; assert_eq!( @@ -1902,7 +1934,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1919,7 +1951,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let second_outcomes = second.run_cycle(1_050).await; assert_eq!( @@ -1955,7 +1987,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let outcomes = e.run_cycle(1_000).await; let submitted: u64 = outcomes @@ -2004,7 +2036,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2062,7 +2094,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, 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. @@ -2102,7 +2134,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -2124,7 +2156,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let second_outcomes = second.run_cycle(1_010).await; assert_eq!( @@ -2176,7 +2208,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, // and must refuse. @@ -2249,7 +2281,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); // Cycle 1: the file is corrupt -- must refuse, submit nothing. let cycle1 = e.run_cycle(1_000).await; @@ -2321,7 +2353,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2369,7 +2401,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); let outcomes = e.run_cycle(1_000).await; From 22ba90be22bab7820ab1f876ee49d06500a4e8ca Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:46:05 -0700 Subject: [PATCH 19/29] feat(rewards): construct and install a real RewardsChainPort over dig-rewards-coin (#614) * chore(deps): pin chia-sdk-driver/chia-sdk-types/chia-puzzle-types, add dig-rewards-coin Money-bug containment for dig_ecosystem#3303/#3286: exact `=` pins on dig-node-service's direct edge stop the resolved chia-sdk-driver/chia-sdk-types/ chia-puzzle-types versions moving under us. This constrains OUR compile target only -- Cargo.lock still carries a second, transitive chia-sdk-driver 0.30.0 and chia-puzzle-types 0.26.0 line via other crates, and this pin does not evict those. Containment, not correction. chia-protocol/chia-bls/chia-sha2 stay caret: an `=` on a wire type poisons every crate that depends on chia-protocol directly. dig-rewards-coin = "0.5" (not the ticket's stale "0.4"): 0.5.0 is the release that already refuses `epoch_seconds == 0` inside `read_distributor` itself. Refs #3310 Co-Authored-By: Claude Sonnet 5 * feat(rewards): construct and install a real RewardsChainPort over dig-rewards-coin Adds RealRewardsChainPort (crates/dig-node-service/src/rewards/): distributor_report served for real over dig-wallet's CorroboratedChainSource and dig_rewards_coin::state::read_distributor, via a guarded read (read_distributor_guarded) that refuses launch constants carrying epoch_seconds == 0 BEFORE calling into dig-rewards-coin at all -- defense in depth over that crate's own identical refusal (state.rs:1015), since chia-sdk-driver-0.36.0's commit_incentives backfill loop never terminates on that value and has no await point a timeout could interrupt. store_id/root are recovered from the launcher's creating spend's CREATE_COIN memo (chain_source.rs), following dig-mirror-coin's read_parent_outputs pattern: authenticate the puzzle reveal against the coin's puzzle hash before running it, never trust an unauthenticated memo alone. The other four RewardsChainPort methods (funded_distributors, distributor_state, submit_entry_writes, spend_new_epoch) answer Unavailable -- out of this ticket's scope (funder-registry and prover-cycle work tracked separately). Installed once from server.rs's enable_chain_sync-gated block, logging a false (already-installed) return at WARN. Refs #3310 Co-Authored-By: Claude Sonnet 5 * fix(rewards): satisfy rustfmt and regenerate the workspace lockfile CI's `cargo fmt --all -- --check` and `--locked` builds both failed on the prior commit: rustfmt wanted several closures/return-types reformatted, and Cargo.lock was missing the `chia-sdk-test`, `clvm-traits` and `clvmr` entries the new adapter's Cargo.toml lines require, which `--locked` refuses to backfill. Refs #3310 Co-Authored-By: Claude Sonnet 5 * fix(rewards): read LaunchCommentError payloads, scope containment test, fix constants fixture - LaunchCommentError::ChainSource/Malformed tuple payloads were flagged dead_code by clippy: a derived Debug impl does not count as reading a private field. Add a manual Display impl that formats each variant's payload, and switch chain_port.rs's build_report call site from {error:?} to {error} so the reason reaches a log reader. - adapter_source_never_imports_withdraw_committed_incentives was self-defeating: it include_str!s its own file and the test's own name and assertion messages contain the literal string it searches for, so it could never pass. Scope the scan to production_region(), everything before the file's own #[cfg(test)] marker. - launcher_spend_with's RewardDistributorConstants fixture set reserve_inner_puzzle_hash/reserve_full_puzzle_hash to Bytes32::default() without calling .with_launcher_id(launcher_id), which recomputes both fields from curried tree hashes. chia-sdk-driver's RewardDistributor::from_launcher_solution requires constants == constants.with_launcher_id(launcher_id), so this fixture deterministically failed to decode on every invocation (not flaky/timing-dependent). Co-Authored-By: Claude Sonnet 5 * test(rewards): finish A3 -- real-simulator distributor_report + install-path evidence Drives RealRewardsChainPort::distributor_report end to end against a distributor launched by dig_rewards_coin::launch_dig_distributor in chia-sdk-test's peer simulator, over a MockChainSource loaded from the simulator's real coin records/spends. store_id/root are asserted against the values launched with -- recoverable only by actually running the launcher's parent spend and decoding its CLVM memo, so no fixture shortcut can pass this test. Also covers install_reward_chain_port's single-install refusal (true then false, with the WARN server.rs's own call site logs). Node exposes no lighter test constructor to an external integration test crate, so the install-path test uses Node::from_env(), the same constructor openrpc_drift_guard.rs's own test uses. launch_fixture returns Box rather than pulling in anyhow for one test file. Co-Authored-By: Claude Sonnet 5 * fix(rewards): stop reporting a missing parent spend as a distributor-identity verdict ChainSource::parent_spend returning Ok(None) is a chain-source gap (the source does not yet hold the launcher's creating spend), not a genuine "this is not a DIG distributor" classification. Give it its own LaunchCommentError::ParentSpendUnavailable variant so chain_port.rs can map it to ChainPortError::Unavailable, agreeing with the other absence path (read_distributor_guarded's own Ok(None)), instead of rendering a transport lag as a definitive negative identity claim. Co-Authored-By: Claude Sonnet 5 * feat(rewards): observable degradation and a testable epoch computation - Add one bounded tracing::warn! on distributor_report's error path, firing once per failure->success transition (not per call, via an AtomicBool), so an installed-but-degraded chain source is no longer indistinguishable from "no port installed." - Extract current_distributor_epoch's arithmetic into a pure epoch_ordinal fn and unit-test it directly (non-zero multi-epoch case, the saturating_sub clock-skew branch, and the bare-launch zero case) -- the only non-trivial computed field in the report mapping, previously unexercised by anything but a zero-valued default. - Add launch_comment_error_to_port_error, wiring chain_source.rs's new ParentSpendUnavailable variant to ChainPortError::Unavailable, with a regression test, and a companion test confirming GuardedReadError::NonTerminatingEpochSeconds still maps to the named refusal (Other), never to Unavailable. - Add a unit test proving a failing chain source surfaces as a named ChainPortError::Unavailable, never Ok(_) with a default-valued report. Co-Authored-By: Claude Sonnet 5 * test(rewards): de-circularize the install-refusal warn evidence install_reward_chain_port_refuses_a_second_install_with_a_warn previously captured and asserted against a warn it emitted itself inside the test's own closure -- deleting server.rs's real warn line would have left it green. Replace the self-emission with a source-text check against server.rs's own production region (the same shape adapter_source_never_imports_withdraw_committed_incentives already uses), so the assertion can only be satisfied by what server.rs actually ships. Also softens the A3 module doc's overstated "no fixture value can produce the right store_id/root" claim: true of this file today, not a structural guarantee. Co-Authored-By: Claude Sonnet 5 * style(rewards): rustfmt, and note the install-warn test asserts source text not runtime behaviour Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- Cargo.lock | 26 ++ crates/dig-node-service/Cargo.toml | 49 +- crates/dig-node-service/src/lib.rs | 6 + .../src/rewards/chain_port.rs | 424 +++++++++++++++++ .../src/rewards/chain_source.rs | 432 ++++++++++++++++++ crates/dig-node-service/src/rewards/mod.rs | 27 ++ crates/dig-node-service/src/server.rs | 27 ++ .../tests/rewards_chain_port_a3.rs | 395 ++++++++++++++++ 8 files changed, 1380 insertions(+), 6 deletions(-) create mode 100644 crates/dig-node-service/src/rewards/chain_port.rs create mode 100644 crates/dig-node-service/src/rewards/chain_source.rs create mode 100644 crates/dig-node-service/src/rewards/mod.rs create mode 100644 crates/dig-node-service/tests/rewards_chain_port_a3.rs diff --git a/Cargo.lock b/Cargo.lock index 369aff7c..6999284f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3048,13 +3048,18 @@ dependencies = [ "axum-server", "base64", "chia-bls 0.36.1", + "chia-consensus 0.36.1", "chia-protocol 0.36.1", "chia-puzzle-types 0.36.1", + "chia-puzzles", "chia-sdk-driver 0.36.0", + "chia-sdk-test 0.36.0", "chia-sdk-types 0.36.0", "chia-sha2 0.36.1", "clap", + "clvm-traits 0.36.1", "clvm-utils 0.36.1", + "clvmr 0.16.4", "dig-cert", "dig-chainsource-interface 0.3.3", "dig-constants 0.13.1", @@ -3064,6 +3069,7 @@ dependencies = [ "dig-node-control-interface", "dig-node-core", "dig-node-service", + "dig-rewards-coin", "dig-rpc-protocol", "dig-stun", "dig-urn-resolver", @@ -3209,6 +3215,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "dig-rewards-coin" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bb94b1f02239b4ad8072c5b1d37a00cca366cfeca73b34fcecd94a2470fdd0" +dependencies = [ + "chia-bls 0.36.1", + "chia-consensus 0.36.1", + "chia-protocol 0.36.1", + "chia-puzzle-types 0.36.1", + "chia-sdk-driver 0.36.0", + "chia-sdk-types 0.36.0", + "clvm-traits 0.36.1", + "clvmr 0.16.4", + "dig-chainsource-interface 0.3.3", + "dig-constants 0.13.1", + "hex", + "thiserror 2.0.20", +] + [[package]] name = "dig-rpc-protocol" version = "0.12.0" diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 2497e893..4cc7b516 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -143,20 +143,26 @@ chia-protocol = "0.36.1" # and `Copy` against an attacker-chosen string. Same 0.36.1 line as every other chia primitive here # -- a second line would be a second `Sha256`. chia-sha2 = "0.36.1" -chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +chia-sdk-driver = { version = "=0.36.0", features = ["chip-0035", "action-layer"] } # `MAINNET_CONSTANTS` -- the Chia L1 `AGG_SIG_ME` domain every mirror-coin spend is signed under # (`mirror::lifecycle::mirror_agg_sig_data`, dig-node#447). This is the crate `chia_wallet_sdk::types` # re-exports, named directly so this crate does not pull the whole SDK for one constant. Already a # dev-dependency at this exact version, so no second line enters the tree. -chia-sdk-types = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +chia-sdk-types = { version = "=0.36.0", features = ["chip-0035", "action-layer"] } # The CAT puzzle-hash currying, for deriving the coin a mirror RECLAIM creates before it exists. # Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned # coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. -chia-puzzle-types = "0.36.1" +chia-puzzle-types = "=0.36.1" # `ToTreeHash`, to re-derive an owner puzzle hash from the key a mirror create is built for, so the # signer can refuse spends that are not its own wallet's rather than trusting the call site to have # passed the right key. Part of the same chia set above -- `chia-sdk-driver` already resolves it. clvm-utils = "0.36.1" +# `rewards/chain_source.rs`'s launch-comment recovery (dig_ecosystem#3310) decodes a `CoinSpend`'s +# puzzle reveal, solution and run output as raw CLVM: `FromClvm`/`ToClvm` for `Conditions`/`Memos` +# and the `Allocator`/`NodePtr` they run over. Same 0.36.1/0.16 line every chia crate above already +# resolves -- `chia-sdk-driver`/`dig-rewards-coin` both depend on exactly these versions. +clvm-traits = "0.36.1" +clvmr = "0.16" # The epoch term of a mirror hint is a `BigInt`, not a `u64`: the morph is arithmetic over # 32-byte values and the crate's API says so. Same line as `dig-mirror-coin`'s own. num-bigint = "0.4.6" @@ -191,6 +197,17 @@ dig-rpc-protocol = "0.12" # bidirectional WS wallet+control transport (#369) onto it. dig-wallet = { path = "../dig-wallet" } +# The chain-side reader for the reward-distributor SPEC surface (dig_ecosystem#3310): +# `state::read_distributor` + `clawback::recoverable_base_units`, over a caller-supplied +# `ChainSource`. This is the ONE crate in this seam that depends on it — `dig-node-core` +# deliberately does not (see `dig_node_core::rewards::port`'s module doc) because the +# reader takes a `ChainSource` it has no seam to hold; this crate does (`dig-wallet`'s +# `CorroboratedChainSource`), so the adapter lives here. `0.5`, not the `0.4` the ticket +# names (stale): 0.5.0 is the release that already refuses `epoch_seconds == 0` inside +# `read_distributor` itself (see `rewards/chain_source.rs`'s module doc for the exact +# line, and why this crate ALSO refuses at its own edge as defence in depth). +dig-rewards-coin = "0.5" + # HTTP stack: the same axum/tokio the node itself uses, so there is one async runtime # and one server framework across the node and the service shell. `ws` enables # `axum::extract::ws` for the `GET /ws/status` liveness endpoint (#239). @@ -357,9 +374,29 @@ chia-protocol = "0.36.1" # Every version here is the one `dig-mirror-coin` 0.9 itself compiles against, and the whole set # moves together: the chia ceiling is not one number, and a crate split across two chia lines # compiles only until something crosses a public signature. -chia-puzzle-types = "0.36.1" -chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } -chia-sdk-types = { version = "0.36.0", features = ["chip-0035", "action-layer"] } +chia-puzzle-types = "=0.36.1" +chia-sdk-driver = { version = "=0.36.0", features = ["chip-0035", "action-layer"] } +chia-sdk-types = { version = "=0.36.0", features = ["chip-0035", "action-layer"] } +# `rewards/chain_port.rs`'s A3 acceptance test (dig_ecosystem#3310): a real distributor launched +# via `dig_rewards_coin::launch_dig_distributor` against `Simulator`'s peer-simulator, so +# `distributor_report` is exercised over authentic coin records/spends rather than a hand-built +# double. Same version + feature `dig-wallet` already builds its own simulator-backed tests on. +chia-sdk-test = { version = "0.36.0", features = ["peer-simulator"] } +# The SAME `dig-chainsource-interface` line as the normal dependency above, with its `testing` +# feature turned on for THIS crate's own tests only: Cargo unions features across a crate's +# normal- and dev-dependency edges rather than resolving two copies, so this cannot split the +# version the normal dependency already pins. `MockChainSource` is the published, in-memory +# `ChainSource` double `dig-rewards-coin`'s own upstream simulator tests use for the identical +# substitution (replace the network transport, not the reader) -- reused here rather than +# hand-rolled, so the A3 test's double is the ecosystem's one sanctioned mock, not a second one. +dig-chainsource-interface = { version = "0.3", features = ["testing"] } +# `chia-consensus`/`chia-puzzles`/`clvm-traits` for the A3 test's own launch bundle: the exact +# `RewardDistributorConstants`/`Offer`/CAT-issuance calls `dig-rewards-coin`'s own upstream +# simulator tests use, at the SAME 0.36.1/0.20.3 lines `chia-sdk-driver`/`chia-sdk-types` (both +# already pinned above) compile against -- already present in `Cargo.lock` transitively, so this +# only opens a direct edge for the test file to name them, it splits no version. +chia-consensus = "=0.36.1" +chia-puzzles = "=0.20.3" clvm-utils = "0.36.1" num-bigint = "0.4.6" hex = "0.4" diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 7d240feb..14172290 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 FUNDER-SIDE `RewardsChainPort` adapter (DIG-Network/dig_ecosystem#3310): the +/// production implementation of `dig_node_core::rewards::port::RewardsChainPort` over +/// `dig-wallet`'s `CorroboratedChainSource` and `dig-rewards-coin`'s `state::read_distributor`, +/// installed once at startup via `dig_node_core::Node::install_reward_chain_port`. See +/// [`rewards::chain_port::RealRewardsChainPort`]. +pub mod rewards; /// 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 diff --git a/crates/dig-node-service/src/rewards/chain_port.rs b/crates/dig-node-service/src/rewards/chain_port.rs new file mode 100644 index 00000000..3afca571 --- /dev/null +++ b/crates/dig-node-service/src/rewards/chain_port.rs @@ -0,0 +1,424 @@ +//! [`RealRewardsChainPort`] -- the production `RewardsChainPort` (dig_ecosystem#3310): serves +//! `distributor_report` for real, over `dig-wallet`'s `CorroboratedChainSource` and this module's +//! [`super::chain_source::read_distributor_guarded`]. The other four methods on the trait are out +//! of this ticket's named scope (`funded_distributors`/#3269's funder-registry blocker, +//! `distributor_state`/`submit_entry_writes`/`spend_new_epoch`/#3250's prover cycle) and answer +//! [`ChainPortError::Unavailable`], exactly as `UnavailableChainPort` does for all five -- this +//! adapter narrows that surface by one call, it does not widen it. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use dig_chainsource_interface::ChainSource; +use dig_node_core::rewards::port::{ + Bytes32 as PortBytes32, ChainPortError, CommitmentSlot, DistributorChainState, DistributorRef, + DistributorReport, EntryWriteBundle, RewardsChainPort, +}; +use dig_rewards_coin::clawback::recoverable_base_units; +use dig_rewards_coin::state::DistributorSnapshot; +use dig_rewards_coin::RewardsError; +use dig_wallet::sage::corroborated_source::CorroboratedChainSource; + +use super::chain_source::{ + read_distributor_guarded, read_launch_comment, read_launch_constants, GuardedReadError, + LaunchCommentError, +}; + +/// The funder-side `RewardsChainPort` over a [`ChainSource`] -- `dig-wallet`'s +/// `CorroboratedChainSource` in production (the default `S`, and the only type `server.rs`'s +/// `enable_chain_sync` install site ever names). Construct with [`RealRewardsChainPort::new`] and +/// install once via `dig_node_core::Node::install_reward_chain_port`. +/// +/// Generic over `S` (rather than hard-wired to `CorroboratedChainSource`) so `distributor_report` +/// -- the real adapter body, over the real `read_distributor_guarded` -- can be driven directly in +/// tests by a source whose coin records/spends came from a real `chia-sdk-test` simulator launch +/// (dig_ecosystem#3310 acceptance A3, `tests/rewards_chain_port_a3.rs`), without also having to +/// fake `dig-wallet`'s peer-corroboration transport. That double replaces the socket only: every +/// byte the adapter reads still comes from `dig_rewards_coin::state::read_distributor` parsing a +/// genuine, simulator-produced `CoinSpend`. +pub struct RealRewardsChainPort { + source: Arc, + /// Set once `distributor_report` fails, cleared on its next success -- so the WARN in + /// `distributor_report` below fires once per failure->success transition, not once per call + /// (dig_ecosystem#3310 gate leg 3, R4). A caller may poll this every few seconds; without this + /// a degraded chain source would either log nothing (the defect the gate found) or flood the + /// log on every single poll -- neither of which an operator can act on. + report_degraded: std::sync::atomic::AtomicBool, +} + +impl RealRewardsChainPort { + /// Wraps an already-constructed chain source (`ChainTransport::corroborated_chain_source` in + /// production). Takes ownership via `Arc` rather than borrowing: the port trait's + /// `install_reward_chain_port` stores `Arc` for the process's remaining + /// life, so the source must outlive it too. + #[must_use] + pub fn new(source: Arc) -> Self { + Self { + source, + report_degraded: std::sync::atomic::AtomicBool::new(false), + } + } +} + +#[async_trait] +impl RewardsChainPort for RealRewardsChainPort { + async fn funded_distributors(&self) -> Result, ChainPortError> { + // dig_node_core::rewards::port's own module doc, "Blocker 2": no funder-ownership + // registry exists anywhere in this codebase yet. Answering here would mean inventing one + // unreviewed, which is exactly the shape fork this ticket's brief says to leave alone. + Err(ChainPortError::Unavailable) + } + + async fn distributor_state( + &self, + _launcher_id: PortBytes32, + ) -> Result { + // #3250's prover-cycle surface, a sibling ticket -- not this one. + Err(ChainPortError::Unavailable) + } + + async fn submit_entry_writes(&self, _bundle: EntryWriteBundle) -> Result<(), ChainPortError> { + Err(ChainPortError::Unavailable) + } + + async fn spend_new_epoch(&self, _launcher_id: PortBytes32) -> Result<(), ChainPortError> { + Err(ChainPortError::Unavailable) + } + + async fn distributor_report( + &self, + launcher_id: PortBytes32, + ) -> Result { + let source = Arc::clone(&self.source); + // `ChainSource` is synchronous and does its own socket I/O underneath; running it on a + // blocking-pool thread keeps a slow read from stalling the async runtime it is called + // from, without using `spawn_blocking` as a hang REMEDY (the guard, not this, is what + // prevents the hang itself -- see `chain_source.rs`'s module doc). + let result = + tokio::task::spawn_blocking(move || build_report(source.as_ref(), launcher_id)) + .await + .map_err(|join_error| { + ChainPortError::Other(format!("report task panicked: {join_error}")) + })?; + + // R4 (dig_ecosystem#3310 gate leg 3, §4): a failing chain source must be observable, not + // only correctly typed. `swap` both reads and sets `report_degraded` atomically, so the + // warn fires exactly once per failure->success transition even under concurrent callers. + match &result { + Ok(_) => { + self.report_degraded + .store(false, std::sync::atomic::Ordering::Relaxed); + } + Err(port_error) => { + let was_already_degraded = self + .report_degraded + .swap(true, std::sync::atomic::Ordering::Relaxed); + if !was_already_degraded { + tracing::warn!( + launcher_id = %hex::encode(launcher_id), + error = ?port_error, + "distributor_report failed; reward-distributor reads for this launcher \ + stay refused until the chain source recovers" + ); + } + } + } + + result + } +} + +/// The synchronous body of `distributor_report`, run off the async runtime by `spawn_blocking`. +fn build_report( + source: &S, + launcher_id: PortBytes32, +) -> Result +where + S: ChainSource, +{ + let launcher_id = chia_protocol::Bytes32::new(launcher_id); + + let snapshot = read_distributor_guarded(source, launcher_id) + .map_err(guarded_read_error_to_port_error)? + .ok_or(ChainPortError::Unavailable)?; + + let comment = + read_launch_comment(source, launcher_id).map_err(launch_comment_error_to_port_error)?; + + let (_constants, first_epoch_state) = + read_launch_constants(source, launcher_id).ok_or_else(|| { + ChainPortError::Other( + "launch constants unreadable after a successful guarded read".to_string(), + ) + })?; + let first_epoch_start = first_epoch_state.round_time_info.last_update; + + report_from_snapshot(&snapshot, launcher_id, comment, first_epoch_start) +} + +/// Maps a [`DistributorSnapshot`] plus the launch comment onto the port's [`DistributorReport`]. +/// Every field's source is named in its own comment so this mapping can be re-checked field by +/// field against the port's doc (dig_ecosystem#3310 acceptance A5). +fn report_from_snapshot( + snapshot: &DistributorSnapshot, + launcher_id: chia_protocol::Bytes32, + comment: dig_rewards_coin::comment::LaunchComment, + first_epoch_start: u64, +) -> Result { + let distributor = snapshot.distributor(); + let constants = distributor.info.constants; + + if launcher_id == chia_protocol::Bytes32::default() + || comment.store_id == chia_protocol::Bytes32::default() + { + return Err(ChainPortError::ZeroIdentity); + } + + let withdrawal_share_bps: u16 = constants + .withdrawal_share_bps + .try_into() + .map_err(|_| ChainPortError::InvalidWithdrawalShare)?; + if withdrawal_share_bps > 10_000 { + return Err(ChainPortError::InvalidWithdrawalShare); + } + let fee_bps: u16 = constants.fee_bps.try_into().map_err(|_| { + ChainPortError::Other(format!("fee_bps {} does not fit u16", constants.fee_bps)) + })?; + + let epoch_seconds = constants.epoch_seconds; + let epoch_end = distributor.info.state.round_time_info.epoch_end; + // `epoch_seconds != 0` is guaranteed here: `build_report` only reaches this function after + // `read_distributor_guarded` succeeded, and that call refuses `epoch_seconds == 0` twice over + // (this crate's own guard, then `dig-rewards-coin`'s `state.rs:1015`) before ever returning + // `Ok(Some(..))`. + let current_distributor_epoch = epoch_ordinal(epoch_end, first_epoch_start, epoch_seconds); + + let commitments = snapshot + .slots() + .commitments + .iter() + .map(|commitment| { + let recoverable = recoverable_base_units(commitment.rewards, withdrawal_share_bps) + .ok_or(ChainPortError::InvalidWithdrawalShare)?; + Ok(CommitmentSlot { + epoch_start: commitment.epoch_start, + clawback_puzzle_hash: commitment.clawback_ph.into(), + rewards_base_units: commitment.rewards, + recoverable_base_units: recoverable, + }) + }) + .collect::, ChainPortError>>()?; + + let observed_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + + Ok(DistributorReport { + launcher_id: launcher_id.into(), + store_id: comment.store_id.into(), + root: comment.root.into(), + epoch_seconds, + first_epoch_start, + payout_threshold: constants.payout_threshold, + fee_bps, + withdrawal_share_bps, + reserve_base_units: snapshot.reserve_base_units(), + entry_count: snapshot.entry_count() as u64, + current_distributor_epoch, + last_entry_write_at: snapshot.observed().last_entry_write_unix(), + entry_set_stale: snapshot.entry_set_stale(), + commitments, + observed_at, + }) +} + +/// The report's only non-trivial computed field, pulled out of [`report_from_snapshot`] so it can +/// be unit-tested directly (dig_ecosystem#3310 gate leg 3, R3): a gut that replaces +/// `current_distributor_epoch` with a constant leaves every test in this crate green unless this +/// function's own tests catch it, because at a bare launch `epoch_end == first_epoch_start` and +/// the CORRECT answer is already `0` -- indistinguishable from the gutted value on that one case +/// alone. `epoch_seconds == 0` never reaches here -- see the caller's comment. +fn epoch_ordinal(epoch_end: u64, first_epoch_start: u64, epoch_seconds: u64) -> u64 { + epoch_end.saturating_sub(first_epoch_start) / epoch_seconds +} + +/// Maps [`GuardedReadError`] onto [`ChainPortError`] -- see the module's own mapping table +/// (dig_ecosystem#3310 brief): `RewardsError::ChainUnavailable` is the ONLY variant that becomes +/// [`ChainPortError::Unavailable`]; `RewardsError::UnreadableDistributorConstants` becomes +/// [`ChainPortError::InvalidWithdrawalShare`] (the port's own name for the identical refusal); +/// every other reader error, and this crate's own [`GuardedReadError::NonTerminatingEpochSeconds`], +/// become [`ChainPortError::Other`] -- never `Unavailable`, so a caller cannot mistake a refused +/// read for a merely offline chain. +fn guarded_read_error_to_port_error(error: GuardedReadError) -> ChainPortError { + match error { + GuardedReadError::NonTerminatingEpochSeconds => ChainPortError::Other( + "distributor launch constants carry epoch_seconds == 0, a non-terminating replay \ + hazard (chia-sdk-driver-0.36.0's commit_incentives backfill loop never terminates on \ + this value); refusing to read rather than hang" + .to_string(), + ), + GuardedReadError::Reader(reward_error) => reader_error_to_port_error(reward_error), + } +} + +/// Maps [`LaunchCommentError`] onto [`ChainPortError`] (dig_ecosystem#3310 gate leg 3, R5). +/// `ParentSpendUnavailable` is a chain-source GAP (the source does not yet hold the launcher's +/// parent spend), not a classification of the distributor's identity -- it maps onto the same +/// `Unavailable` `read_distributor_guarded`'s own `Ok(None)` already answers with, not `Other`, +/// which would render it to a caller as a definitive "not a DIG distributor". Every other variant +/// genuinely is a refused/malformed read, or a real classification, so it stays `Other`. +fn launch_comment_error_to_port_error(error: LaunchCommentError) -> ChainPortError { + match error { + LaunchCommentError::ParentSpendUnavailable => ChainPortError::Unavailable, + other => ChainPortError::Other(format!("launch comment unreadable: {other}")), + } +} + +/// The `RewardsError` half of [`guarded_read_error_to_port_error`]'s mapping table. +fn reader_error_to_port_error(error: RewardsError) -> ChainPortError { + match error { + RewardsError::ChainUnavailable(_) => ChainPortError::Unavailable, + RewardsError::UnreadableDistributorConstants { .. } => { + ChainPortError::InvalidWithdrawalShare + } + other => ChainPortError::Other(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dig_chainsource_interface::{ChainSourceError, MockChainSource}; + use dig_node_core::rewards::port::{ChainPortError, RewardsChainPort}; + + use super::RealRewardsChainPort; + + /// R3 (dig_ecosystem#3310 gate leg 3, §2/§3.2): a bare launch has `epoch_end == + /// first_epoch_start`, so `0` is the CORRECT answer there, not just what a gutted + /// implementation would also return -- this test proves the non-zero, multi-epoch case + /// instead, which a `0`-returning gut cannot pass. + #[test] + fn epoch_ordinal_counts_whole_epochs_elapsed_since_first_epoch_start() { + let first_epoch_start = 1_000; + let epoch_seconds = 100; + let epoch_end = first_epoch_start + 3 * epoch_seconds + 40; // partway into epoch 3 + + assert_eq!( + super::epoch_ordinal(epoch_end, first_epoch_start, epoch_seconds), + 3 + ); + } + + /// The `saturating_sub` branch: a clock-skewed or not-yet-advanced read can have + /// `epoch_end < first_epoch_start`. Must refuse to underflow and answer epoch `0`, not panic + /// or wrap. + #[test] + fn epoch_ordinal_saturates_to_zero_when_epoch_end_precedes_first_epoch_start() { + assert_eq!(super::epoch_ordinal(500, 1_000, 100), 0); + } + + /// The bare-launch case itself: `epoch_end == first_epoch_start` -- correctly `0`, and named + /// here so the two tests above are read as a PAIR, not as this single (weak, gut-indistinct) + /// case alone. + #[test] + fn epoch_ordinal_is_zero_at_a_bare_launch() { + assert_eq!(super::epoch_ordinal(1_234, 1_234, 100), 0); + } + + /// This ticket's own filed complaint (dig_ecosystem#3310): a failing chain source must + /// surface as a NAMED error, never as reassuring emptiness -- an `Ok` carrying a + /// zero/default-valued report. `MockChainSource::fail_with` forces every read `Err` + /// (`ChainSourceError::Timeout`), which `dig_rewards_coin::state::read_distributor` maps to + /// `RewardsError::ChainUnavailable`, which this adapter's own `reader_error_to_port_error` + /// maps to `ChainPortError::Unavailable` -- checked here end to end through the public + /// `RewardsChainPort::distributor_report` call, not just the internal mapping function, so a + /// future refactor of `build_report`'s plumbing cannot silently reopen the gap. + #[tokio::test] + async fn a_failing_chain_source_reports_a_named_unavailable_never_an_ok_default() { + let source = MockChainSource::new().fail_with(ChainSourceError::Timeout); + let port = RealRewardsChainPort::::new(Arc::new(source)); + + let result = port.distributor_report([0x11; 32]).await; + + assert_eq!( + result, + Err(ChainPortError::Unavailable), + "a failing chain source must report the named Unavailable variant, never Ok(_) with \ + a default-valued report, got {result:?}" + ); + } + + /// The adjacent guard this crate's own `epoch_seconds == 0` refusal must keep: that refusal is + /// a NAMED distributor-level refusal (`ChainPortError::Other`), never conflated with + /// `ChainPortError::Unavailable` -- which must mean the CHAIN SOURCE could not answer, not + /// that a read was refused for a reason unrelated to reachability. Regresses a case where + /// `GuardedReadError::NonTerminatingEpochSeconds` maps onto the same variant an offline chain + /// would report, which would let a caller mistake "chain source is fine, this distributor + /// carries a replay hazard" for "the chain source itself is unreachable". + #[test] + fn non_terminating_epoch_seconds_is_not_reported_as_chain_unavailable() { + let mapped = super::guarded_read_error_to_port_error( + super::super::chain_source::GuardedReadError::NonTerminatingEpochSeconds, + ); + + assert_ne!( + mapped, + ChainPortError::Unavailable, + "epoch_seconds == 0 is a named refusal, not an absent/unreachable chain, got {mapped:?}" + ); + } + + /// R5's regression (dig_ecosystem#3310 gate leg 3): a chain-source GAP on the launcher's + /// parent spend must never be reported as the definitive "not a DIG distributor" verdict -- + /// it must agree with the OTHER absence path (`read_distributor_guarded`'s own `Ok(None)`), + /// which answers `Unavailable`. + #[test] + fn parent_spend_gap_is_reported_as_unavailable_not_as_a_distributor_identity_verdict() { + let mapped = super::launch_comment_error_to_port_error( + super::super::chain_source::LaunchCommentError::ParentSpendUnavailable, + ); + + assert_eq!( + mapped, + ChainPortError::Unavailable, + "a missing parent spend is a chain-source gap, not an identity verdict, got {mapped:?}" + ); + } + + /// dig_ecosystem#3310 acceptance A4: neither adapter file in this module may import the + /// withdraw-incentives driver call -- that is #3250's prover-cycle surface, not this ticket's. + /// A literal-string check rather than a compile-time one because the point is to catch the + /// import even if it compiled (e.g. via a re-export or a fully qualified path elsewhere). + /// + /// Scoped to the NON-TEST region of each file (everything before its own `#[cfg(test)]` + /// marker): this very test's name and assertion messages contain the literal string, so an + /// unscoped `contains` over the whole file (this one included) can never pass -- it would be + /// self-defeating, not a real containment check. + #[test] + fn adapter_source_never_imports_withdraw_committed_incentives() { + let chain_port_production_src = production_region(include_str!("chain_port.rs")); + let chain_source_production_src = production_region(include_str!("chain_source.rs")); + + assert!( + !chain_port_production_src.contains("withdraw_committed_incentives"), + "chain_port.rs must not reference withdraw_committed_incentives (out of #3310's scope)" + ); + assert!( + !chain_source_production_src.contains("withdraw_committed_incentives"), + "chain_source.rs must not reference withdraw_committed_incentives (out of #3310's scope)" + ); + } + + /// The slice of a source file before its own `#[cfg(test)]` module -- i.e. what actually + /// ships. Falls back to the whole file if there is no such marker (there always is one here, + /// but a missing marker should widen the scan, not silently skip it). + fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } + } +} diff --git a/crates/dig-node-service/src/rewards/chain_source.rs b/crates/dig-node-service/src/rewards/chain_source.rs new file mode 100644 index 00000000..a708c9f1 --- /dev/null +++ b/crates/dig-node-service/src/rewards/chain_source.rs @@ -0,0 +1,432 @@ +//! The ONE guarded chain read every adapter in this module funnels through +//! (dig_ecosystem#3310) -- `read_distributor_guarded`, plus the launch-comment recovery +//! `distributor_report` needs for `store_id`/`root`. +//! +//! # The guard: `epoch_seconds == 0` is the non-terminating case, not the finite one +//! +//! `chia-sdk-driver-0.36.0`'s `commit_incentives.rs` (~lines 101-111) runs +//! `while end_epoch_time > start_epoch_time { start_epoch_time += epoch_seconds; }`. At +//! `epoch_seconds == 0` the induction variable never advances -- an infinite, non-yielding CPU +//! loop, with no `.await` anywhere in that file. `tokio::time::timeout` cannot rescue a caller from +//! it: with no await point the timeout future is never polled and the worker thread hangs +//! regardless (upstream report: `xch-dev/chia-wallet-sdk#436`). `epoch_seconds` is curried into +//! the action puzzle at launch, so an attacker picks it, and the resulting puzzle hash is still a +//! legitimately recognised member of `dig-rewards-coin`'s eleven action hashes -- a distributor +//! carrying this value is not malformed by that measure, only hazardous to replay. +//! +//! The only remedy that is real: refuse `epoch_seconds == 0` BEFORE ever reaching that loop, read +//! from the launch constants alone (`RewardDistributor::from_launcher_solution`), which is cheap +//! and does not touch the hazardous code path at all. +//! +//! `dig-rewards-coin` 0.5.0 already performs exactly this refusal, INSIDE +//! `state::read_distributor` itself, before any generation is walked -- +//! `RewardsError::UnreadableEpochSeconds` at `state.rs:1015`. The check in this file is therefore +//! **defence in depth at this crate's own edge, not a substitute for theirs**: it exists so a +//! future `dig-rewards-coin` regression, or any other function this crate might one day call over +//! the same hazardous constant, does not silently reopen the hang. +//! +//! This is a DIFFERENT bound from `dig_rewards_coin::state::MAX_COMMIT_INCENTIVES_BACKFILL_SLOTS` +//! (`state.rs:73`, enforced ~`:659-670`): that one refuses a backfill that is large but FINITE. +//! This one refuses the case that never finishes at all. +//! +//! # Recovering `store_id`/`root`: the launch comment is a memo, not a reader field +//! +//! `dig_rewards_coin::state::read_distributor` reports everything the distributor's own puzzle +//! state carries, but the `(store_id, root)` a distributor pays out for is not part of that state +//! at all -- it is a CLVM memo `chia-sdk-driver` attaches to the `CREATE_COIN` that creates the +//! LAUNCHER coin (`launch_drivers.rs:643-644`: +//! `Launcher::with_memos(security_coin.coin_id(), 1, ctx.memos(&(reward_distributor_hint, +//! (comment, ())))?)`), rendered with `dig_rewards_coin::comment::LaunchComment::to_string()` +//! (`dig-rewards:v1::`). No `dig-rewards-coin` function reads it back off +//! chain; only `LaunchComment::parse(&str)` on an already-obtained string exists. Recovering it is +//! this crate's own job: read the launcher's CREATING spend (its parent's spend, not its own), +//! run that parent's puzzle, find the `CREATE_COIN` that creates the launcher coin, and decode its +//! memos. This mirrors `dig-mirror-coin`'s `MirrorCoin::from_creating_spend`/`read_parent_outputs` +//! (same crate family, same shape of problem: an unauthenticated memo declaring which generation a +//! chain object is about). + +use chia_protocol::{Bytes, Bytes32, Coin, CoinSpend}; +use chia_puzzle_types::Memos; +use chia_sdk_driver::{Puzzle, RewardDistributor, SpendContext}; +use chia_sdk_types::{run_puzzle, Condition, Conditions}; +use clvm_traits::FromClvm; +use clvm_utils::tree_hash; +use clvmr::{Allocator, NodePtr}; +use dig_chainsource_interface::ChainSource; +use dig_rewards_coin::comment::LaunchComment; +use dig_rewards_coin::state::DistributorSnapshot; +use dig_rewards_coin::RewardsError; + +/// Why the guarded read could not produce a [`DistributorSnapshot`]. +/// +/// Never confused with an absence: both variants mean the read could not be trusted, not that the +/// distributor does not exist (that remains `Ok(None)` from `read_distributor` itself). +#[derive(Debug)] +pub(crate) enum GuardedReadError { + /// THIS crate's own edge refusal -- see the module doc's "the guard" section. Distinct from + /// [`GuardedReadError::Reader`] carrying `dig-rewards-coin`'s OWN identical refusal: this + /// variant is produced by code in *this* file and never reaches `read_distributor` at all. + NonTerminatingEpochSeconds, + /// `dig_rewards_coin::state::read_distributor` itself returned an error -- including its own + /// `epoch_seconds == 0` refusal (`state.rs:1015`), a chain-source failure, malformed chain + /// data, or an out-of-domain `withdrawal_share_bps`. + Reader(RewardsError), +} + +/// Reads distributor `launcher_id` over `source`, refusing the non-terminating +/// `epoch_seconds == 0` hazard BEFORE calling `dig_rewards_coin::state::read_distributor` at all. +/// +/// Every adapter in this crate MUST call this function rather than `read_distributor` directly -- +/// see the module doc for why, and [`GuardedReadError`] for the two ways it can refuse. +pub(crate) fn read_distributor_guarded( + source: &S, + launcher_id: Bytes32, +) -> Result, GuardedReadError> +where + S: ChainSource, +{ + refuse_non_terminating_epoch_seconds(source, launcher_id)?; + dig_rewards_coin::state::read_distributor(source, launcher_id).map_err(GuardedReadError::Reader) +} + +/// Refuses `launcher_id` when its launch constants carry `epoch_seconds == 0`, reading ONLY the +/// launcher's own spend and its `key_value_list` -- never a generation walk, so this cannot itself +/// reach the hazard it exists to screen for. +/// +/// Deliberately permissive on every read it cannot complete (unspent/unknown launcher, an +/// undecodable solution, launch terms `RewardDistributor::from_launcher_solution` itself refuses): +/// this guard's only job is to catch the ONE named hazard early. Every other outcome -- including +/// absence and chain-source failure -- is `dig_rewards_coin::state::read_distributor`'s to answer +/// honestly, and it does. +fn refuse_non_terminating_epoch_seconds( + source: &S, + launcher_id: Bytes32, +) -> Result<(), GuardedReadError> +where + S: ChainSource, +{ + match read_launch_constants(source, launcher_id) { + Some((constants, _state)) if constants.epoch_seconds == 0 => { + Err(GuardedReadError::NonTerminatingEpochSeconds) + } + _ => Ok(()), + } +} + +/// Reads `launcher_id`'s launch constants and initial state directly off the launcher's own +/// spend, via [`RewardDistributor::from_launcher_solution`] -- the same cheap, generation-walk-free +/// read [`refuse_non_terminating_epoch_seconds`] uses, exposed so [`super::chain_port`] can recover +/// [`chia_sdk_driver::RewardDistributorState::initial`]'s `first_epoch_start` +/// (`round_time_info.last_update`, equivalently `.epoch_end`, at this point) without a second +/// design for the same read. `None` for anything that could not be read or decoded -- absence and +/// chain-source failure are `read_distributor`'s to answer, not this helper's. +pub(crate) fn read_launch_constants( + source: &S, + launcher_id: Bytes32, +) -> Option<( + chia_sdk_driver::RewardDistributorConstants, + chia_sdk_driver::RewardDistributorState, +)> +where + S: ChainSource, +{ + let spend = source.coin_spend(launcher_id).ok()??; + + let mut ctx = SpendContext::new(); + let solution_ptr = ctx.alloc(&spend.solution).ok()?; + + let (constants, state, _eve_coin) = + RewardDistributor::from_launcher_solution(&mut ctx, spend.coin, solution_ptr).ok()??; + Some((constants, state)) +} + +/// Why `launcher_id`'s launch comment (`store_id`/`root`) could not be recovered. +#[derive(Debug)] +pub(crate) enum LaunchCommentError { + /// The source could not answer the launcher's creating (parent) spend at all -- an actual + /// transport error from `ChainSource::parent_spend`. + ChainSource(String), + /// The creating spend was read, but its puzzle reveal, solution, or emitted conditions could + /// not be interpreted -- the read is untrustworthy, so this fails closed rather than treating + /// the comment as absent. + Malformed(String), + /// `ChainSource::parent_spend` answered `Ok(None)`: the source does not (yet) hold the + /// launcher's creating spend. This is a GAP, not a classification -- every launcher coin + /// created by a security coin has a parent spend on a complete chain, so `Ok(None)` here means + /// the source is lagging or pruned, never that the distributor is definitively not DIG's + /// (dig_ecosystem#3310 gate leg 3, R5). Maps to `ChainPortError::Unavailable` in + /// `chain_port.rs`, the same variant `read_distributor_guarded`'s own `Ok(None)` already + /// produces, so both absence paths agree. + ParentSpendUnavailable, + /// The creating spend was read and understood, and it simply carries no DIG rewards launch + /// comment: a CHIP-0051 distributor legitimately launched for a purpose other than DIG's own + /// (`dig_rewards_coin::comment`'s module doc), so this is a genuine classification, not a + /// failure to read. + NotADigDistributor, +} + +/// Manual (not derived) `Display`: reads the `String` payload of `ChainSource`/`Malformed` into +/// the message a caller logs. A derived `Debug` alone does not count, to rustc's own dead-code +/// analysis, as a genuine read of a private tuple field -- see `chain_port.rs`'s +/// `build_report`, the only caller, which now formats via `{error}` rather than `{error:?}`. +impl std::fmt::Display for LaunchCommentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ChainSource(reason) => write!(f, "chain source unavailable: {reason}"), + Self::Malformed(reason) => write!(f, "malformed launch comment data: {reason}"), + Self::ParentSpendUnavailable => write!( + f, + "chain source does not (yet) hold the launcher's parent spend" + ), + Self::NotADigDistributor => { + write!( + f, + "not a DIG rewards distributor (no matching launch comment)" + ) + } + } + } +} + +/// Recovers `launcher_id`'s `(store_id, root)` from the `CREATE_COIN` memo on the spend that +/// CREATES the launcher coin -- see the module doc's second section for why this cannot come from +/// `dig_rewards_coin` itself. +pub(crate) fn read_launch_comment( + source: &S, + launcher_id: Bytes32, +) -> Result +where + S: ChainSource, +{ + let creating_spend = source + .parent_spend(launcher_id) + .map_err(|error| LaunchCommentError::ChainSource(error.to_string()))? + .ok_or(LaunchCommentError::ParentSpendUnavailable)?; + + parse_launch_comment(&creating_spend, launcher_id) +} + +/// Runs `creating_spend`'s puzzle once and reads the launch comment off the `CREATE_COIN` that +/// creates `launcher_id`. +fn parse_launch_comment( + creating_spend: &CoinSpend, + launcher_id: Bytes32, +) -> Result { + let mut allocator = Allocator::new(); + + let puzzle_ptr = + program_to_node(&mut allocator, &creating_spend.puzzle_reveal).map_err(|error| { + LaunchCommentError::Malformed(format!("undecodable puzzle reveal: {error}")) + })?; + let solution_ptr = program_to_node(&mut allocator, &creating_spend.solution) + .map_err(|error| LaunchCommentError::Malformed(format!("undecodable solution: {error}")))?; + + // The reveal must be the parent's ACTUAL puzzle -- nothing below re-derives that, so a + // substituted reveal must be caught here, before it is run. + let revealed: Bytes32 = tree_hash(&allocator, puzzle_ptr).into(); + if revealed != creating_spend.coin.puzzle_hash { + return Err(LaunchCommentError::Malformed( + "puzzle reveal does not hash to the creating coin's puzzle hash".to_string(), + )); + } + let parent_puzzle = Puzzle::parse(&allocator, puzzle_ptr); + let _ = parent_puzzle; // parsed only to prove `puzzle_ptr` is a real puzzle tree; unused otherwise. + + let output = run_puzzle(&mut allocator, puzzle_ptr, solution_ptr).map_err(|error| { + LaunchCommentError::Malformed(format!("parent puzzle did not run: {error}")) + })?; + let conditions = Conditions::::from_clvm(&allocator, output).map_err(|error| { + LaunchCommentError::Malformed(format!("undecodable conditions: {error}")) + })?; + + let parent_id = creating_spend.coin.coin_id(); + for condition in conditions { + let Condition::CreateCoin(created) = condition else { + continue; + }; + let candidate = Coin::new(parent_id, created.puzzle_hash, created.amount); + if candidate.coin_id() != launcher_id { + continue; + } + + return match memo_comment(&allocator, created.memos) { + Some(comment) => Ok(comment), + None => Err(LaunchCommentError::NotADigDistributor), + }; + } + + Err(LaunchCommentError::Malformed(format!( + "creating spend of coin {parent_id} emitted no CREATE_COIN for launcher {launcher_id}" + ))) +} + +/// Decodes a `CREATE_COIN`'s memos as `[hint, comment_utf8, ..]` and parses the second entry as a +/// [`LaunchComment`] -- the layout `launch_drivers.rs` writes: +/// `ctx.memos(&(reward_distributor_hint, (comment, ())))`. `None` for anything that does not +/// match: absent memos, a memo list that is not at least two entries, or a second entry that is +/// not a valid launch comment string. +fn memo_comment(allocator: &Allocator, memos: Memos) -> Option { + let Memos::Some(node) = memos else { + return None; + }; + + let entries = Vec::::from_clvm(allocator, node).ok()?; + let comment_bytes = entries.get(1)?; + let comment_str = std::str::from_utf8(comment_bytes.as_ref()).ok()?; + LaunchComment::parse(comment_str) +} + +/// Deserializes a [`chia_protocol::Program`] into an allocated [`NodePtr`]. +fn program_to_node( + allocator: &mut Allocator, + program: &chia_protocol::Program, +) -> Result { + clvmr::serde::node_from_bytes_backrefs(allocator, program.as_ref()) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use chia_puzzle_types::singleton::LauncherSolution; + use chia_sdk_driver::{RewardDistributorConstants, RewardDistributorType, SpendContext}; + use dig_chainsource_interface::{ChainSourceError, CoinRecord, SingletonLineage}; + + use super::*; + + /// A `ChainSource` double answering exactly the `coin_spend`s it was built with, and + /// `Ok(None)`/`Ok(empty)` for everything else -- the A1 acceptance test (dig_ecosystem#3310) + /// only needs the guard's ONE read, `coin_spend`. + struct StubSource { + spends: HashMap, + } + + impl ChainSource for StubSource { + type Error = ChainSourceError; + + fn coin_record(&self, _coin_id: Bytes32) -> Result, Self::Error> { + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.spends.get(&coin_id).cloned()) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(None) + } + } + + /// Builds a launcher `CoinSpend` whose `key_value_list` decodes to `(first_epoch_start, + /// constants)`, exactly the shape `RewardDistributor::from_launcher_solution` extracts -- + /// letting a test drive the guard without a live chain or a real distributor launch. + fn launcher_spend_with(epoch_seconds: u64) -> (Bytes32, CoinSpend) { + let launcher_coin = Coin::new(Bytes32::from([7u8; 32]), Bytes32::from([9u8; 32]), 1); + let launcher_id = launcher_coin.coin_id(); + + // `.with_launcher_id(launcher_id)` is not a formality: it recomputes + // `reserve_inner_puzzle_hash`/`reserve_full_puzzle_hash` from `launcher_id` and + // `reserve_asset_id` (curried tree hashes). `from_launcher_solution` rejects any + // constants for which `constants != constants.with_launcher_id(launcher_id)` -- leaving + // those two fields zeroed here made that comparison fail on every call, deterministically + // (never actually flaky), which sent every read down the guard's deliberately-permissive + // "could not decode" path instead of exercising the epoch_seconds check at all. + let constants = RewardDistributorConstants { + launcher_id, + reward_distributor_type: RewardDistributorType::Managed { + manager_singleton_launcher_id: Bytes32::default(), + }, + fee_payout_puzzle_hash: Bytes32::default(), + epoch_seconds, + precision: 1, + max_seconds_offset: 0, + payout_threshold: 0, + require_payout_approval: false, + fee_bps: 0, + withdrawal_share_bps: 0, + reserve_asset_id: Bytes32::default(), + reserve_inner_puzzle_hash: Bytes32::default(), + reserve_full_puzzle_hash: Bytes32::default(), + } + .with_launcher_id(launcher_id); + + let mut ctx = SpendContext::new(); + let solution = ctx + .serialize(&LauncherSolution { + singleton_puzzle_hash: Bytes32::default(), + amount: 1, + key_value_list: (0u64, constants), + }) + .expect("a plain LauncherSolution always serializes"); + + let spend = CoinSpend::new(launcher_coin, chia_protocol::Program::default(), solution); + (launcher_id, spend) + } + + /// A1 (dig_ecosystem#3310): a distributor whose launch constants carry `epoch_seconds == 0` + /// is refused by THIS crate's own guard, before `dig_rewards_coin::state::read_distributor` + /// is ever called -- proven here by never wiring a real reader into `StubSource` at all: if + /// the guard did not refuse first, this test would panic somewhere else entirely (a + /// `read_distributor` call against a source with no reward-distributor coin state), not + /// cleanly return the expected error. + #[test] + fn guard_refuses_epoch_seconds_zero_before_reading_the_distributor() { + let (launcher_id, spend) = launcher_spend_with(0); + let source = StubSource { + spends: HashMap::from([(launcher_id, spend)]), + }; + + let result = read_distributor_guarded(&source, launcher_id); + + assert!( + matches!(result, Err(GuardedReadError::NonTerminatingEpochSeconds)), + "expected NonTerminatingEpochSeconds, got {result:?}" + ); + } + + /// The guard's inverse: a legitimate, non-zero `epoch_seconds` is NOT refused by this guard -- + /// the read proceeds to `dig_rewards_coin::state::read_distributor`, which then answers on its + /// own terms (here, `RewardsError::ChainUnavailable`, since `StubSource` holds no reward-slot + /// state at all -- the guard's job is only to not be the reason this call failed). + #[test] + fn guard_passes_through_a_legitimate_epoch_seconds() { + let (launcher_id, spend) = launcher_spend_with(3600); + let source = StubSource { + spends: HashMap::from([(launcher_id, spend)]), + }; + + let result = read_distributor_guarded(&source, launcher_id); + + assert!( + !matches!(result, Err(GuardedReadError::NonTerminatingEpochSeconds)), + "a legitimate epoch_seconds must not be refused by this crate's own guard, got {result:?}" + ); + } +} diff --git a/crates/dig-node-service/src/rewards/mod.rs b/crates/dig-node-service/src/rewards/mod.rs new file mode 100644 index 00000000..049189f2 --- /dev/null +++ b/crates/dig-node-service/src/rewards/mod.rs @@ -0,0 +1,27 @@ +//! The funder-side `RewardsChainPort` adapter (dig_ecosystem#3310). +//! +//! `dig_node_core::rewards::port::RewardsChainPort` exists and its single-install site +//! (`Node::install_reward_chain_port`) exists, but nothing constructs a real implementation: the +//! only adapter shipped so far is `UnavailableChainPort`, which answers `ChainPortError::Unavailable` +//! forever. This module is what makes `dig.getRewardDistributor` and +//! `dig.listRewardDistributorCommitments` answer for real. +//! +//! Two files, one job each: +//! - [`chain_source`] — the guarded chain read: `read_distributor_guarded`, which refuses a +//! distributor whose launch constants carry `epoch_seconds == 0` BEFORE calling +//! `dig_rewards_coin::state::read_distributor`, and the store_id/root recovery from the +//! launcher's creating spend (the launch comment is a CLVM memo, not a field +//! `dig-rewards-coin` reads for you). +//! - [`chain_port`] — [`chain_port::RealRewardsChainPort`], the `RewardsChainPort` implementation +//! that serves `distributor_report` over the guarded reader and answers `Unavailable` for the +//! four methods this ticket does not build (`funded_distributors`, `distributor_state`, +//! `submit_entry_writes`, `spend_new_epoch` — SPEC surfaces owned by other tickets, #3249/#3250). +//! +//! Lives in `dig-node-service`, never `dig-node-core`: `dig-wallet` holds the only production +//! `ChainSource` and itself depends on `dig-node-core`, so consuming it from core would be +//! circular (see `dig_node_core::rewards::port`'s module doc). + +pub mod chain_port; +pub mod chain_source; + +pub use chain_port::RealRewardsChainPort; diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index cec18b25..ecb5b3bf 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2140,6 +2140,33 @@ where state.mirror_bonds.clone(), config.enable_live_broadcast, ); + + // The funder-side `RewardsChainPort` (DIG-Network/dig_ecosystem#3310): the only + // production caller of `Node::install_reward_chain_port`, so `UnavailableChainPort` + // stops being the sole adapter once `enable_chain_sync` is true. Gated the same way the + // census and mirror passes above are — a harness with `enable_chain_sync: false` gets no + // chain source to build this over, and `install_reward_chain_port`'s `OnceLock` means a + // second call here (there is none) would simply be refused, not double-installed. + match state + .wallet_chain + .corroborated_chain_source(tokio::runtime::Handle::current()) + { + Ok(source) => { + let port: std::sync::Arc = + std::sync::Arc::new(crate::rewards::RealRewardsChainPort::new( + std::sync::Arc::new(source), + )); + if !state.node.install_reward_chain_port(port) { + tracing::warn!( + "install_reward_chain_port declined a second install: a reward chain \ + port was already installed on this Node" + ); + } + } + Err(error) => { + tracing::warn!(%error, "could not build a CorroboratedChainSource for the reward chain port; reward-distributor reads stay Unavailable"); + } + } } // §14 autonomous sync (#213): bring up the L7 peer network — the connected peer diff --git a/crates/dig-node-service/tests/rewards_chain_port_a3.rs b/crates/dig-node-service/tests/rewards_chain_port_a3.rs new file mode 100644 index 00000000..01ad05b7 --- /dev/null +++ b/crates/dig-node-service/tests/rewards_chain_port_a3.rs @@ -0,0 +1,395 @@ +//! dig_ecosystem#3310 acceptance A3: `RealRewardsChainPort::distributor_report` — the real +//! adapter, over the real `read_distributor_guarded`, over real serialized launcher/eve/singleton +//! spends — driven end to end against a distributor launched by +//! `dig_rewards_coin::launch_dig_distributor` in `chia-sdk-test`'s peer simulator. +//! +//! # The one substitution, and why +//! +//! This double replaces the network transport, nothing else. Every coin record and coin spend +//! `MockChainSource` answers with here was produced by a real `Simulator::spend_coins` call — +//! copied verbatim from `dig-rewards-coin` 0.5.0's own `tests/simulator.rs` (`chain_source_with_gaps`), +//! the SAME sanctioned double `dig-rewards-coin`'s own upstream tests use for the identical +//! purpose. There is no second, hand-rolled mock in this crate. +//! +//! A DIG distributor's `reserve_asset_id` is `dig_constants::DIG_ASSET_ID`, a fixed real asset id +//! a simulator cannot mint. So — again mirroring `dig-rewards-coin`'s own tests — the constants +//! table here is built directly via `RewardDistributorConstants::without_launcher_id` using the +//! SIMULATOR's own freshly minted CAT's asset id, not the production `dig_distributor_constants` +//! helper (which hardcodes the unmintable real asset id). +//! +//! The manager singleton launcher id is a fixed dummy `Bytes32`: it is curried metadata on the +//! constants table only, never read back off chain by `read_distributor`, so launching a real test +//! singleton for it (as `dig-rewards-coin`'s own tests do, for a DIFFERENT reason — driving manager +//! actions) would be machinery this test never exercises. +//! +//! # What proves the parse is real +//! +//! `store_id`/`root` are not part of the distributor's own puzzle state — they are a CLVM memo on +//! the `CREATE_COIN` that creates the launcher coin (`chain_source.rs`'s module doc). Recovering +//! them requires reading the launcher's PARENT (creating) spend, running that puzzle for real, and +//! decoding its memos, asserted below against the exact values this test launched with. +//! +//! One caveat on that claim: the asserted `store_id`/`root` (`[0xaa; 32]`, `[0xbb; 32]`) are +//! test-chosen low-entropy constants. They are unguessable-by-accident TODAY only because no +//! rival code path in this file could produce them — not because of any entropy in the fixture +//! itself. That is true of this test as written; it is not a structural guarantee, and would stop +//! being true the moment a second source of those fields is added here. + +use std::sync::Arc; + +use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; +use chia_puzzle_types::CoinProof; +use chia_puzzle_types::Memos; +use chia_puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_sdk_driver::{ + sign_standard_transaction, Cat, Offer, RewardDistributorConstants, RewardDistributorType, + SingleCatSpend, Spend, SpendContext, SpendWithConditions, StandardLayer, +}; +use chia_sdk_test::Simulator; +use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; +use clvm_traits::{clvm_quote, ToClvm}; +use clvmr::NodePtr; +use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_node_core::rewards::port::RewardsChainPort; +use dig_node_core::Node; +use dig_node_service::rewards::RealRewardsChainPort; +use dig_rewards_coin::comment::LaunchComment; +use dig_rewards_coin::constants::{ + MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, +}; +use dig_rewards_coin::launch::launch_dig_distributor; + +/// Small on purpose: the simulator's clock starts at zero. +const FIRST_EPOCH_START: u64 = 1_234; +/// A short epoch; this test is about the report's fields, not the epoch length. +const TEST_EPOCH_SECONDS: u64 = 1_000; +/// $DIG the funder mints for itself. +const MINTED_BASE_UNITS: u64 = 10_000_000_000; + +/// A fixed, never-launched manager singleton launcher id: curried into the constants table for +/// shape only, never read back off chain by `read_distributor`. +const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); + +/// Everything a real launch produced, named rather than positional. +struct LaunchedFixture { + sim: Simulator, + launcher_id: Bytes32, + security_coin_id: Bytes32, + distributor_coin_id: Bytes32, + reserve_coin_id: Bytes32, + reserve_launch_id: Bytes32, + reserve_parent_id: Bytes32, + launch_comment: LaunchComment, + constants: RewardDistributorConstants, +} + +/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via +/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from +/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. +fn launch_fixture() -> Result> { + let ctx = &mut SpendContext::new(); + let mut sim = Simulator::new(); + + let funder = sim.bls(MINTED_BASE_UNITS); + let funder_p2 = StandardLayer::new(funder.pk); + let (issue_cat, source_cats) = Cat::single_issuance( + ctx, + funder.coin.coin_id(), + None, + MINTED_BASE_UNITS, + Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), + )?; + funder_p2.spend(ctx, funder.coin, issue_cat)?; + let source_cat = source_cats[0]; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + + let offer_amount = 1; + let launcher_bls = sim.bls(offer_amount); + let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + + let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + SETTLEMENT_PAYMENT_HASH.into(), + source_cat.coin.amount, + Memos::None + )) + .to_clvm(ctx)?; + let cat_inner_spend = funder_p2.delegated_inner_spend( + ctx, + Spend { + puzzle: cat_inner_puzzle, + solution: NodePtr::NIL, + }, + )?; + source_cat.spend( + ctx, + SingleCatSpend { + prev_coin_id: source_cat.coin.coin_id(), + next_coin_proof: CoinProof { + parent_coin_info: source_cat.coin.parent_coin_info, + inner_puzzle_hash: funder.puzzle_hash, + amount: source_cat.coin.amount, + }, + prev_subtotal: 0, + extra_delta: 0, + p2_spend: cat_inner_spend, + revoke: false, + }, + )?; + + let spends = ctx.take(); + let cat_offer_spend = spends + .iter() + .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) + .expect("the CAT offer spend") + .clone(); + for spend in spends { + if spend.coin.coin_id() != source_cat.coin.coin_id() { + ctx.insert(spend); + } + } + + let signature = sign_standard_transaction( + ctx, + launcher_bls.coin, + offer_spend, + &launcher_bls.sk, + &TESTNET11_CONSTANTS, + )?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle { + coin_spends: vec![ + CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), + cat_offer_spend, + ], + aggregated_signature: signature, + }, + )?; + + let constants = RewardDistributorConstants::without_launcher_id( + RewardDistributorType::Managed { + manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, + }, + funder.puzzle_hash, + TEST_EPOCH_SECONDS, + u64::MAX, + MAX_SECONDS_OFFSET, + PAYOUT_THRESHOLD_BASE_UNITS, + false, + 0, + WITHDRAWAL_SHARE_BPS, + source_cat.info.asset_id, + ); + + let launch_comment = LaunchComment::new(Bytes32::new([0xaa; 32]), Bytes32::new([0xbb; 32])); + + let launched = launch_dig_distributor( + ctx, + &offer, + FIRST_EPOCH_START, + constants, + &TESTNET11_CONSTANTS, + launch_comment, + // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. + 0, + )?; + + sim.spend_coins( + ctx.take(), + &[ + launcher_bls.sk.clone(), + launched.security_coin_secret_key.clone(), + funder.sk.clone(), + ], + )?; + + let launcher_id = launched.distributor.info.constants.launcher_id; + let distributor_coin_id = launched.distributor.coin.coin_id(); + let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); + let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; + let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); + + // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. + // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record + // up after the fact, rather than tracking the security coin id through the launch machinery + // by hand. + let security_coin_id = sim + .coin_state(launcher_id) + .expect("the launcher coin was confirmed by the launch spend") + .coin + .parent_coin_info; + + Ok(LaunchedFixture { + sim, + launcher_id, + security_coin_id, + distributor_coin_id, + reserve_coin_id, + reserve_launch_id, + reserve_parent_id, + launch_comment, + constants: launched.distributor.info.constants, + }) +} + +/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what +/// `read_distributor_guarded`/`read_launch_comment` read — mirrors +/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. +fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { + let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; + + // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it + // named directly. + let eve_coin_id = fixture + .sim + .children(fixture.launcher_id) + .first() + .map(|state| state.coin.coin_id()); + + let extra_ids = [ + fixture.security_coin_id, + fixture.reserve_launch_id, + fixture.reserve_parent_id, + fixture.reserve_coin_id, + ]; + + let mut source = MockChainSource::new(); + for id in singleton_members + .iter() + .copied() + .chain(extra_ids.iter().copied()) + .chain(eve_coin_id) + { + if let Some(state) = fixture.sim.coin_state(id) { + source = source.with_coin(id, CoinRecord::from_coin_state(state)); + } + if let Some(spend) = fixture.sim.coin_spend(id) { + source = source.with_spend(id, spend); + } + } + + source = source.with_lineage( + fixture.launcher_id, + SingletonLineage::new( + fixture.distributor_coin_id, + singleton_members.iter().copied(), + ), + ); + + let peak = fixture.sim.height(); + for height in 0..=peak { + source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + } + source.with_peak(peak) +} + +/// A3: `RealRewardsChainPort::distributor_report` — the real production adapter, driven by a +/// `MockChainSource` loaded from a real simulator launch — reports the values launched with, +/// including `store_id`/`root`, which can only be right if the launcher's parent spend was +/// actually run and its memo actually decoded. +#[tokio::test(flavor = "multi_thread")] +async fn distributor_report_reflects_a_real_simulator_launch() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + + let port = RealRewardsChainPort::::new(Arc::new(source)); + + let report = port + .distributor_report(fixture.launcher_id.into()) + .await + .expect("the report must be built from a real, freshly launched distributor"); + + assert_eq!(report.launcher_id, fixture.launcher_id.to_bytes()); + // These two fields come from nowhere but the launcher's PARENT spend's decoded CLVM memo -- + // no fixture shortcut produces the right bytes without that parse actually running. + assert_eq!( + report.store_id, + fixture.launch_comment.store_id.to_bytes(), + "store_id must be recovered by actually running the security coin's puzzle and decoding \ + its CREATE_COIN memo -- this is the field a stub-over-a-stub could not get right" + ); + assert_eq!(report.root, fixture.launch_comment.root.to_bytes()); + + assert_eq!(report.epoch_seconds, TEST_EPOCH_SECONDS); + assert_eq!(report.first_epoch_start, FIRST_EPOCH_START); + assert_eq!(report.payout_threshold, PAYOUT_THRESHOLD_BASE_UNITS); + assert_eq!(report.fee_bps, 0); + assert_eq!( + report.withdrawal_share_bps, + u16::try_from(WITHDRAWAL_SHARE_BPS).unwrap() + ); + assert_eq!( + report.reserve_base_units, 0, + "a bare launch has committed nothing to the reserve yet" + ); + assert_eq!( + report.entry_count, 0, + "a bare launch has added no entries yet" + ); + + // Sanity: the constants this test launched with are the ones the fixture actually curried, + // not a value this test invented independently. + assert_eq!(fixture.constants.epoch_seconds, TEST_EPOCH_SECONDS); +} + +/// A3's install-path clause: `install_reward_chain_port` returns `true` the first time and +/// `false` the second, with a WARN logged on the second call the way `server.rs`'s own call site +/// logs it -- constructed identically (`Arc` wrapping +/// `RealRewardsChainPort::new(Arc::new(source))`). +#[test] +fn install_reward_chain_port_refuses_a_second_install_with_a_warn() { + // `Node` exposes no lighter test constructor to an external integration-test crate -- + // `Node::from_env()` is the same constructor `openrpc_drift_guard.rs`'s own integration test + // uses for the identical reason. Only its `install_reward_chain_port` OnceLock is read below. + let node = Node::from_env(); + let source = MockChainSource::new(); + let port: Arc = Arc::new(RealRewardsChainPort::::new( + Arc::new(source), + )); + + let first_install = node.install_reward_chain_port(Arc::clone(&port)); + assert!(first_install, "the first install must be accepted"); + + let second_install = node.install_reward_chain_port(Arc::clone(&port)); + assert!(!second_install, "the second install must be refused"); + + // R2 (dig_ecosystem#3310 gate leg 3, remedy R2): this test must not capture and assert + // against a warn it emits ITSELF -- that is self-certifying (deleting `server.rs`'s real + // warn would leave this test green, proving nothing about the production call site). + // Instead it reads `server.rs`'s own shipped source and checks the warn string this + // refusal is supposed to surface actually lives there, in the non-test region -- the same + // shape `adapter_source_never_imports_withdraw_committed_incentives` already uses in + // `chain_port.rs`. Mutation-proved: deleting `server.rs`'s warn line turns this assertion + // red; restoring it turns it green again (see the PR description's RED/GREEN observation). + // + // NOTE this is a SOURCE-TEXT assertion, not a behavioural one: it proves the warn string is + // written in `server.rs`, not that it is actually emitted when the second `if` branch runs at + // runtime. A behavioural assertion (capturing tracing output from the real call site) is not + // reachable here without either restructuring `server.rs`'s install block to be independently + // callable from an integration test, or duplicating production control flow into the test -- + // both are new production surface this ticket does not need. Read this test as "the warn this + // refusal depends on has not silently rotted out of the source", not as proof the call site + // fires it on every run. + let server_source = production_region(include_str!("../src/server.rs")); + assert!( + server_source.contains("declined a second install"), + "server.rs's production install-path warn must contain \"declined a second install\", \ + so this refused-install path is not silently unlogged" + ); +} + +/// The slice of a source file before its own `#[cfg(test)]` module -- i.e. what actually ships. +/// Mirrors `chain_port.rs`'s identical helper; duplicated here because this integration test is +/// a separate compilation unit and cannot import a private `#[cfg(test)]` helper from the crate +/// under test. +fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } +} From 98b29eb1ac21eb6a5eed24ec9cbebd7bb3611d14 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:54:39 -0700 Subject: [PATCH 20/29] test(peer): lock inbound dial+accept counted once (#591) Characterization test locking the #870 refusal rule: a peer identity holding both a dialled (outbound) pool slot and an accepted (inbound) mTLS connection is counted exactly once, the dialled slot survives, and the refused inbound peer is still served. Refs DIG-Network/dig_ecosystem#3124 Gates at 2d46d9a1: loop-reviewer verdict APPROVED (review 5228183672), loop-security PASS (issuecomment-5704107401), 14/14 checks green, 0 unresolved threads. Co-Authored-By: Claude Fable 5.1 --- .../tests/inbound_pool_membership.rs | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/crates/dig-node-core/tests/inbound_pool_membership.rs b/crates/dig-node-core/tests/inbound_pool_membership.rs index 996e0e0d..523b6959 100644 --- a/crates/dig-node-core/tests/inbound_pool_membership.rs +++ b/crates/dig-node-core/tests/inbound_pool_membership.rs @@ -493,3 +493,222 @@ async fn the_accepted_direct_cap_still_binds_after_a_supersede_and_stale_release server.abort(); service.stop().await.expect("stop"); } + +/// Build a `NatPeerConnection` over a loopback duplex with a chosen `peer_id`, remote address and +/// traversal tier -- the same pattern `peer.rs`'s own unit tests use to exercise `adopt_nat_connection` +/// (the single outbound-adoption entry point, called in production from `bootstrap.rs`/`pex.rs`) +/// without a real socket. The `peer_id` is passed in explicitly rather than derived from a TLS +/// handshake here, so the caller can make it byte-identical to a real mTLS identity used elsewhere -- +/// which is exactly how the test below gets the SAME identity into both an outbound and an inbound +/// slot. Returns the server `PeerSession` half; drop it to end the session, hold it to keep the +/// outbound slot's session alive. +fn loopback_nat_conn( + peer_id_bytes: [u8; 32], + remote: std::net::SocketAddr, + method: dig_nat::TraversalKind, +) -> (dig_gossip::NatPeerConnection, dig_nat::PeerSession) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let inner = dig_nat::PeerConnection { + peer_id: dig_nat::PeerId::from_bytes(peer_id_bytes), + method, + remote_addr: remote, + peer_bls_pub: None, + session: dig_nat::PeerSession::client(client_io), + }; + ( + dig_gossip::NatPeerConnection::new(inner), + dig_nat::PeerSession::server(server_io), + ) +} + +/// **dig_ecosystem#3124 -- the one unmeasured property: a peer that is BOTH dialled (outbound) and +/// accepted (inbound) is counted exactly ONCE, and both directions keep being served.** +/// +/// `adopt_inbound_peer_in_pool`'s own doc (`peer.rs:3658-3662`) lists "a peer already holding a +/// dialable slot" among dig-gossip's refusals -- meaning the de-duplication this test proves lives in +/// dig-gossip, not dig-node, and has never before been exercised FROM dig-node. Nothing here builds a +/// pool entry directly: the outbound slot is created through the real `adopt_nat_connection` adoption +/// path (the single outbound entry point), and the inbound slot is created by a real mTLS dial against +/// `serve_peer_rpc_listener_with`'s listener, exactly like every other test in this file. +#[tokio::test] +async fn a_peer_that_is_both_dialled_and_accepted_is_counted_once() { + dig_node_core::peer::install_crypto_provider(); + + let (service, gossip_a, _gdir) = running_gossip().await; + assert_eq!(gossip_a.peer_count().await, 0, "the pool starts empty"); + + let server_identity = test_identity("3124-dualslot-server"); + let server_peer_id = server_identity.peer_id(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let listen_addr = listener.local_addr().expect("local addr"); + + let responder: Arc = Arc::new(TestResponder); + let server = tokio::spawn(serve_peer_rpc_listener_with( + listener, + server_identity, + responder, + None, + Some(gossip_a.clone()), + )); + + // Identity B -- used for BOTH the outbound and the inbound slot below. + let b_identity = test_identity("3124-dualslot-peer-b"); + let b_peer_id = b_identity.peer_id(); + let b_bytes = *b_peer_id.as_bytes(); + + // -- Step 1: B occupies an OUTBOUND (dialled) slot, via the real adoption path ----------------- + let fake_dial_addr: std::net::SocketAddr = "198.51.100.9:9444".parse().expect("addr"); + let (outbound_conn, _outbound_server_session) = + loopback_nat_conn(b_bytes, fake_dial_addr, dig_nat::TraversalKind::Direct); + let adopted = gossip_a + .adopt_nat_connection(outbound_conn) + .await + .expect("B's outbound slot is uncontested"); + assert_eq!(adopted, dig_gossip::PeerId::from(b_bytes)); + + // Precondition: exactly one DIALABLE slot for B, before the inbound leg touches anything. + assert_eq!( + gossip_a.peer_count().await, + 1, + "the outbound adoption must land before the inbound leg is driven" + ); + let pool_id_b = dig_gossip::PeerId::from(b_bytes); + let outbound_detail = gossip_a + .connected_pool_peers_detailed() + .into_iter() + .find(|p| p.peer_id == pool_id_b) + .expect("B's outbound slot exists"); + assert!( + outbound_detail.is_outbound, + "step 1 must produce a DIALLED slot, or this test measures the wrong thing" + ); + assert!( + !gossip_a.dialable_pool_peers().is_empty(), + "the outbound slot must be dialable before the inbound leg is driven" + ); + + // -- Step 2: the SAME identity B now dials A INBOUND over real mTLS ----------------------------- + let target = dig_nat::PeerTarget::with_addr(server_peer_id, listen_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 inbound_conn = dig_nat::connect(&target, &b_identity, &config) + .await + .expect("B's transport-level connect succeeds even if the pool refuses to adopt it"); + // (No same-identity assertion here: `b_bytes` was derived FROM `b_identity` two lines above, so + // comparing them back is true by construction and proves nothing about the server side. The + // property that matters -- that the server admitted B as the SAME identity, not a distinct one -- + // is carried by `peer_count() == 1` below: a real identity mismatch would create a SECOND slot and + // the count would read 2. That is the assertion doing the real work.) + + // -- Step 3 (moved ahead of Step 4's count/row assertions): the RPC round-trip IS the ordering + // barrier, not a courtesy check. `adopt_inbound_peer_in_pool` is called at `peer.rs:3602`, + // strictly BEFORE `serve_peer_session_from_with` starts answering RPC on the accepted session + // (peer.rs:3614) -- so a successful `dig.getNetworkInfo` response over `inbound_conn` proves the + // server has already reached and returned from the adoption attempt. A bare `sleep` before the + // count assertion below cannot make that promise: on a slow CI box the accept task may simply not + // have run yet, and `peer_count() == 1` would be trivially true for the wrong reason (the inbound + // leg never having been driven at all), not because the pool correctly refused it. This is also + // why the RPC assertion moved ahead of the "still served" comment it used to sit under -- it now + // does double duty as both the serve-path proof AND the happens-before proof for step 2/3. + { + let mut stream = inbound_conn + .session + .open_stream() + .await + .expect("open stream"); + let req = json!({"jsonrpc":"2.0","id":21,"method":"dig.getNetworkInfo"}); + write_framed(&mut stream, &req).await.expect("write"); + let resp = read_one_frame(&mut stream).await; + assert_eq!( + resp["result"]["served_method"], "dig.getNetworkInfo", + "the un-adopted inbound peer must still be served -- refusing adoption must not refuse service" + ); + } + + // The RPC round-trip above already proves the adoption attempt ran and returned, so this count + // read needs no sleep to be meaningful: it must NOT go to 2. + assert_eq!( + gossip_a.peer_count().await, + 1, + "a peer that is both dialled and accepted must be counted ONCE, not twice" + ); + + // -- Step 4: exactly ONE row for B's peer_id among connected_pool_peers() ----------------------- + // (`connected_peers_json` is `pub(crate)` inside dig-node-core and unreachable from this + // integration-test crate; `connected_pool_peers()` is its public dig-gossip source, so counting + // matching rows here proves the same property `connected_peers_json` would report.) + let matching_rows = gossip_a + .connected_pool_peers() + .into_iter() + .filter(|(peer_id, _addr, _outbound)| *peer_id == pool_id_b) + .count(); + assert_eq!( + matching_rows, 1, + "exactly one row must carry B's peer_id -- a de-duplication failure would emit two" + ); + // The surviving row is the DIALLED slot: `adopt_direct_inbound_handle` + // (dig-gossip `service/gossip_handle.rs`, admission section) refuses outright -- it does not + // supersede -- whenever the held slot's `dial_addr()` is `Some`: "an accepted connection NEVER + // supersedes a slot this node can dial" (the #870 rule). `adopt_inbound_peer_in_pool`'s own doc + // (peer.rs:3658-3662) names the same refusal. So the pre-existing outbound slot is kept and the + // inbound accept is the one turned away -- this is a REFUSAL, not a supersede-by-newer-connection. + let surviving = gossip_a + .connected_pool_peers_detailed() + .into_iter() + .find(|p| p.peer_id == pool_id_b) + .expect("B still has exactly one slot"); + assert!( + surviving.is_outbound, + "the surviving slot must be the pre-existing DIALLED one, per the documented refusal" + ); + assert_eq!( + gossip_a.peer_count().await, + 1, + "serving the refused inbound peer must not perturb the count" + ); + + // -- Step 5: released cleanly, SESSION-scoped ------------------------------------------------------ + // Drop the inbound session first: the outbound slot must survive, because releasing it was never + // the inbound session's to release (it never held the slot). + // + // This checks a NON-event -- that no release happens -- so a single instant read right after + // `drop` cannot prove it: dropping the CLIENT side does not synchronously run the SERVER's + // teardown. The server must first observe the closed transport in its own accept/serve task and + // only then run `release_inbound_pool_slot`; an instant read fires before the server has had a + // chance to act, which passes just as well under the defect this step exists to catch (an + // erroneous release of the outbound slot) as it does under correct behaviour -- it cannot tell + // the two apart. Poll across a bounded window instead: if the inbound leg's teardown incorrectly + // released the OUTBOUND slot it never owned, the count drops to 0 at some point inside the + // window and this loop catches it; if the release is correctly a no-op, the count simply stays + // at 1 for the whole window. + // + // There is no cheap positive signal available here that the server has specifically finished + // processing THIS disconnect (the RPC-round-trip trick Step 3 uses needs a live stream, which + // `drop` just closed) -- so the window is the whole proof, not a supplement to one. + drop(inbound_conn); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert_eq!( + gossip_a.peer_count().await, + 1, + "the inbound session ending released the outbound slot it never owned -- a \ + session-scoped release defect: the refused inbound leg tore down B's dialled slot when \ + its own transport closed" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Now release the outbound slot itself and confirm the count reaches zero. + gossip_a + .disconnect(&pool_id_b) + .await + .expect("release the outbound slot"); + await_peer_count(&gossip_a, 0, "after the outbound slot is released").await; + + server.abort(); + service.stop().await.expect("stop"); +} From a2d5c386e4d4f97867dd178f6944cd5b86425adc Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:56:39 -0700 Subject: [PATCH 21/29] fix(rewards): split the three conditions behind REWARD_CHAIN_UNAVAILABLE (#618) * chore: open lane for #3342 Refs #3342 Co-Authored-By: Claude Opus 5 (1M context) * test(rewards): red test -- an answering chain with no distributor is an absence `build_report` maps `read_distributor_guarded`'s `Ok(None)` -- the chain answering "no distributor at this launcher" -- onto the same `ChainPortError::Unavailable` an unreachable chain produces. A funder deciding whether to claw back cannot tell "you have nothing there" from "we cannot see the chain", and one wire shape for both makes that call a guess on a money surface. This test is RED at this commit, deliberately: it is the acceptance evidence for the split that follows, written before the fix so the fix has something to turn green. Refs #3342 Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): split absence from outage behind REWARD_CHAIN_UNAVAILABLE `ChainPortError::Unavailable` stood for three conditions a caller must tell apart: 1. no chain-read adapter installed on this Node at all; 2. an installed adapter could not reach the chain -- a real outage; 3. the chain ANSWERED and holds no distributor at that launcher id (`read_distributor_guarded` -> `Ok(None)`). Case 3 is the money-surface defect: an absence rendered as an outage. A funder deciding whether to claw back cannot distinguish "you have nothing there" from "we cannot see the chain", and one wire shape for both makes that call a guess. `Ok(None)` now gets its own `ChainPortError::NotADistributor` and its own machine code, so the two can never share a shape again. The message "no chain-read adapter is wired yet" was also false at the site that emitted it -- on a default install an adapter IS wired and IS answering. It is not reworded; it is confined to `reward_chain_port_absent_response`, the one case where it is true, and the `Unavailable` arm now names its real cause. A diagnostic that misnames its own cause sends the next reader to the wrong subsystem. Turns green the red test added in the previous commit. Refs #3342 Refs #3246 Co-Authored-By: Claude Opus 5 (1M context) * fix(rewards): pin the wire split and keep an absence off the degraded latch Two pre-merge gate findings, both proven by mutation. H1 -- the wire-level distinction was unpinned. No test asserted any REWARD_* string on a JSON-RPC response body, so pointing the `NotADistributor` arm's `data.code` at `REWARD_CHAIN_UNAVAILABLE_MACHINE` re-collapsed the split, compiled clean (the match stays exhaustive) and left all 3431 tests green. The port-level test was also too loose: `assert_ne!(.., Unavailable)` passes if the variant is later re-mapped to `Other`. Now a dispatch test installs a port answering `Err(NotADistributor)` and asserts `data.code` by value for both methods, and the port test asserts the variant by name. H2 -- an absence armed the degradation latch. Every `Err`, including `NotADistributor`, set the adapter-wide `report_degraded` latch and warned that reads "stay refused until the chain source recovers". The warn fires only on a false->true transition, so one probe for a launcher id that simply is not a distributor silenced the warning for the next GENUINE outage. That is worse than the defect this branch set out to fix: it trades a misleading error for missing telemetry on a money surface. `NotADistributor` is now excluded from both the latch and the warn; `Ok` still clears it, matching a real recovery. Refs #3342 Refs #3246 Co-Authored-By: Claude Opus 5 (1M context) * style(rewards): rustfmt the H1 dispatch test Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 119 +++++++++++ crates/dig-node-core/src/rewards/port.rs | 6 + .../src/seams/dig_rpc/dispatch.rs | 49 ++++- .../src/rewards/chain_port.rs | 184 +++++++++++++++++- 4 files changed, 338 insertions(+), 20 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 9578bba1..c30c1d58 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -10353,6 +10353,125 @@ mod tests { } } + /// **Proves (dig_ecosystem#3342, gate H1):** the wire actually carries the not-a-distributor / + /// chain-unavailable split, through the REAL dispatch path — not just the port-level enum. An + /// installed port answering `Err(NotADistributor)` must reach `data.code == + /// "REWARD_NOT_A_DISTRIBUTOR"`; an installed port answering `Err(Unavailable)` must reach + /// `data.code == "REWARD_CHAIN_UNAVAILABLE"`; the two must differ; and neither response body + /// may contain the substring `"adapter is wired"` — that sentence is reserved for the ONE case + /// where no port is installed at all. + /// **Mutation-probe:** in `seams::dig_rpc::dispatch::reward_chain_port_error_response`, point + /// the `NotADistributor` arm's `data.code` at `REWARD_CHAIN_UNAVAILABLE_MACHINE` (re-collapsing + /// the split) and this test's `assert_ne!` on the two codes fails. + /// **Catches:** a future edit that re-merges the two wire codes while the port-level enum + /// variant, and everything else, stays green. Tests BOTH `dig.getRewardDistributor` and + /// `dig.listRewardDistributorCommitments` -- separate handlers that could drift independently. + #[test] + fn reward_distributor_methods_pin_the_not_a_distributor_wire_code_distinct_from_unavailable() { + let absent_launcher_id = [0x90u8; 32]; + let missing_launcher_id = [0x91u8; 32]; + let outage_launcher_id = [0x92u8; 32]; + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + // A fresh node per method: `install_reward_chain_port` is once-only (backed by a + // `OnceLock`), and the "no port installed" case below must be true independently for + // each method, not just the first one through the loop. + let (node, _td) = test_node(None); + + // Case 1: no port installed at all -- the ONE case allowed to say "adapter is wired". + let absent_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(absent_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + absent_resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method}" + ); + assert!( + absent_resp["error"]["message"] + .as_str() + .unwrap() + .contains("adapter is wired"), + "{method}: no-port-installed case must say so: {absent_resp}" + ); + + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + ( + missing_launcher_id, + Err(crate::rewards::port::ChainPortError::NotADistributor), + ), + ( + outage_launcher_id, + Err(crate::rewards::port::ChainPortError::Unavailable), + ), + ]), + })) + ); + + // Case 2: the chain answered -- no distributor there. + let missing_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":method, + "params":{"launcher_id": hex::encode(missing_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + missing_resp.get("result").is_none(), + "{method}: {missing_resp}" + ); + assert_eq!( + missing_resp["error"]["data"]["code"], + json!("REWARD_NOT_A_DISTRIBUTOR"), + "{method}" + ); + assert!( + !missing_resp.to_string().contains("adapter is wired"), + "{method}: an installed adapter's own answer must never claim none is wired: \ + {missing_resp}" + ); + + // Case 3: the chain source itself could not be reached. + let outage_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":3,"method":method, + "params":{"launcher_id": hex::encode(outage_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + outage_resp.get("result").is_none(), + "{method}: {outage_resp}" + ); + assert_eq!( + outage_resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method}" + ); + assert!( + !outage_resp.to_string().contains("adapter is wired"), + "{method}: an installed adapter's own outage must never claim none is wired: \ + {outage_resp}" + ); + + // The wire distinction actually exists: these two must differ. + assert_ne!( + missing_resp["error"]["data"]["code"], outage_resp["error"]["data"]["code"], + "{method}: not-a-distributor and chain-unavailable must be distinguishable on \ + the wire" + ); + } + } + /// **Proves:** when the port refuses because `withdrawal_share_bps` is out of range (either /// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own /// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 0fae5124..1a98205e 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -148,6 +148,12 @@ 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, + /// dig_ecosystem#3342: the chain source ANSWERED, and no reward distributor exists at the + /// requested `launcher_id`. This is deliberately NOT [`ChainPortError::Unavailable`]: a funder + /// deciding whether to claw back must be able to tell "you have nothing there" (this variant) + /// apart from "we cannot see the chain" (`Unavailable`) — collapsing both onto one shape turns + /// that decision into a guess on a money surface. + NotADistributor, /// dig_ecosystem#3269/#3284/#3303: the distributor's `withdrawal_share_bps` (a `u64` on the /// puzzle) either does not fit the wire's `u16` domain or exceeds the legitimate `0..=10_000` /// bps range. The adapter MUST refuse the WHOLE [`RewardsChainPort::distributor_report`] call 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 46352a0d..c5ac95b5 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -35,15 +35,26 @@ use crate::*; /// own surface. const ENGINE_WARMING: i64 = -32002; -/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is -/// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all). +/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269, corrected by dig_ecosystem#3342): the +/// reward-distributor chain read could not complete — either no adapter is installed at all (the +/// `let Some(port) = … else` arms below, via [`reward_chain_port_absent_response`]), or an +/// installed adapter's `ChainPortError::Unavailable` means the chain source itself could not +/// answer. It no longer means "the chain answered and there is nothing there" — that is +/// [`ChainPortError::NotADistributor`], reported under [`REWARD_NOT_A_DISTRIBUTOR_MACHINE`]. /// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`] below — a caller must be able to tell -/// "ask me again once the adapter lands" apart from "this distributor's own constant is out of -/// range". Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, -/// `-32032`), but carries its own `data.code` machine string so the two are still distinguishable -/// in the body. +/// "the chain could not be reached" apart from "this distributor's own constant is out of range". +/// Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, `-32032`), but +/// carries its own `data.code` machine string so the two are still distinguishable in the body. const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; +/// `REWARD_NOT_A_DISTRIBUTOR` (dig_ecosystem#3342): the chain source answered, and no reward +/// distributor exists at the requested launcher id. Kept distinct from +/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`] on purpose — see [`ChainPortError::NotADistributor`]'s own +/// doc for why collapsing the two is a money-surface defect, not a cosmetic one. Reuses +/// [`CONTROL_ERROR`]'s numeric code, matching every other reward-distributor machine code here; no +/// wire-protocol change is needed since `data.code` alone carries the distinction. +const REWARD_NOT_A_DISTRIBUTOR_MACHINE: &str = "REWARD_NOT_A_DISTRIBUTOR"; + /// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's /// `withdrawal_share_bps` does not fit the wire's `u16` domain or exceeds the legitimate /// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s @@ -71,9 +82,14 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value match error { ChainPortError::Unavailable => json!({"jsonrpc":"2.0","id":id,"error":{ "code": CONTROL_ERROR, - "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "message": "reward-distributor chain read is unavailable: the chain source could not answer", "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } }}), + ChainPortError::NotADistributor => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "no reward distributor exists at this launcher id on chain", + "data": { "code": REWARD_NOT_A_DISTRIBUTOR_MACHINE, "origin": "control" } + }}), ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{ "code": CONTROL_ERROR, "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", @@ -92,6 +108,19 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value } } +/// The response for the ONE case where "no chain-read adapter is wired yet" is actually true: no +/// `rewards::port::RewardsChainPort` has been installed on this `Node` at all +/// (dig_ecosystem#3342). Kept separate from [`reward_chain_port_error_response`] so that +/// function's `Unavailable` arm never has to carry a sentence that is false whenever an installed +/// adapter reports its own `Unavailable` for a chain-source outage. +fn reward_chain_port_absent_response(id: &Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } + }}) +} + /// The largest legitimate `withdrawal_share_bps`: 10,000 basis points IS 100%, so this is an /// inclusive bound and `10_000` itself is a valid distributor constant, not an error. const MAX_WITHDRAWAL_SHARE_BPS: u16 = 10_000; @@ -981,7 +1010,7 @@ impl RpcDispatch for Node { Err(msg) => return rpc_err(&id, -32602, &msg), }; let Some(port) = node.reward_chain_port() else { - return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + return reward_chain_port_absent_response(&id); }; // Range-check at THIS seam, not only in the adapter: see // `range_checked_report` for why an out-of-range share must refuse here. @@ -1023,7 +1052,7 @@ impl RpcDispatch for Node { Err(msg) => return rpc_err(&id, -32602, &msg), }; let Some(port) = node.reward_chain_port() else { - return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + return reward_chain_port_absent_response(&id); }; // Range-check at THIS seam, not only in the adapter: see // `range_checked_report` for why an out-of-range share must refuse here. @@ -1109,7 +1138,7 @@ impl RpcDispatch for Node { let mut funded_refs = Vec::with_capacity(identities.len()); for identity in identities { let Some(port) = node.reward_chain_port() else { - return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + return reward_chain_port_absent_response(&id); }; let report = match port .distributor_report(identity.launcher_id) diff --git a/crates/dig-node-service/src/rewards/chain_port.rs b/crates/dig-node-service/src/rewards/chain_port.rs index 3afca571..e1f6b49d 100644 --- a/crates/dig-node-service/src/rewards/chain_port.rs +++ b/crates/dig-node-service/src/rewards/chain_port.rs @@ -61,6 +61,17 @@ impl RealRewardsChainPort { } } +#[cfg(test)] +impl RealRewardsChainPort { + /// Test-only read of the degradation latch (dig_ecosystem#3342, gate H2) -- a direct load of + /// the real field, not a re-derivation, so a test can prove the latch's actual state rather + /// than scraping it back out of `tracing`'s output. + fn is_degraded(&self) -> bool { + self.report_degraded + .load(std::sync::atomic::Ordering::Relaxed) + } +} + #[async_trait] impl RewardsChainPort for RealRewardsChainPort { async fn funded_distributors(&self) -> Result, ChainPortError> { @@ -105,11 +116,20 @@ impl RewardsChainPort for RealRewardsCha // R4 (dig_ecosystem#3310 gate leg 3, §4): a failing chain source must be observable, not // only correctly typed. `swap` both reads and sets `report_degraded` atomically, so the // warn fires exactly once per failure->success transition even under concurrent callers. + // + // `NotADistributor` (dig_ecosystem#3342, gate H2) is excluded from BOTH the latch and the + // warn: the chain answered fine and simply holds nothing at this launcher id, which is not + // a degradation of the chain source at all. Arming the latch on it would (a) blind a + // GENUINE outage that follows -- the warn only fires on a false->true transition, so the + // real failure would log nothing until some later `Ok` reset it -- and (b) misreport an + // ordinary "not mine" probe as chain trouble. `Ok` still clears the latch as before, + // matching a real recovery. match &result { Ok(_) => { self.report_degraded .store(false, std::sync::atomic::Ordering::Relaxed); } + Err(ChainPortError::NotADistributor) => {} Err(port_error) => { let was_already_degraded = self .report_degraded @@ -141,7 +161,7 @@ where let snapshot = read_distributor_guarded(source, launcher_id) .map_err(guarded_read_error_to_port_error)? - .ok_or(ChainPortError::Unavailable)?; + .ok_or(ChainPortError::NotADistributor)?; let comment = read_launch_comment(source, launcher_id).map_err(launch_comment_error_to_port_error)?; @@ -264,11 +284,16 @@ fn guarded_read_error_to_port_error(error: GuardedReadError) -> ChainPortError { } /// Maps [`LaunchCommentError`] onto [`ChainPortError`] (dig_ecosystem#3310 gate leg 3, R5). -/// `ParentSpendUnavailable` is a chain-source GAP (the source does not yet hold the launcher's -/// parent spend), not a classification of the distributor's identity -- it maps onto the same -/// `Unavailable` `read_distributor_guarded`'s own `Ok(None)` already answers with, not `Other`, -/// which would render it to a caller as a definitive "not a DIG distributor". Every other variant -/// genuinely is a refused/malformed read, or a real classification, so it stays `Other`. +/// `ParentSpendUnavailable` is a chain-source GAP: the source does not yet hold the launcher's +/// parent spend, so this call cannot say anything about the distributor's identity at all -- that +/// is an outage of the read, not an answer from it, so it maps onto [`ChainPortError::Unavailable`] +/// on its own merit (dig_ecosystem#3342: it no longer piggybacks on +/// `read_distributor_guarded`'s `Ok(None)` path, which now reports +/// [`ChainPortError::NotADistributor`] instead -- a chain source that never reached the parent +/// spend is a different failure from one that reached the chain and found no distributor there). +/// It is not `Other`, which would render it to a caller as a definitive "not a DIG distributor". +/// Every other variant genuinely is a refused/malformed read, or a real classification, so it +/// stays `Other`. fn launch_comment_error_to_port_error(error: LaunchCommentError) -> ChainPortError { match error { LaunchCommentError::ParentSpendUnavailable => ChainPortError::Unavailable, @@ -351,6 +376,144 @@ mod tests { ); } + /// dig_ecosystem#3342, the money-surface defect: a chain source that ANSWERS and holds no + /// reward distributor at `launcher_id` is an ABSENCE, never an OUTAGE. An empty + /// `MockChainSource` answers every read successfully with `None`, which + /// `dig_rewards_coin::state::read_distributor` reports as `Ok(None)` -- the chain saying + /// "nothing here", not "I could not look". A funder deciding whether to claw back must be + /// able to tell that apart from an unreachable chain, so it must NOT be `Unavailable`. + #[tokio::test] + async fn an_answering_chain_with_no_distributor_is_an_absence_not_an_outage() { + let source = MockChainSource::new(); + let port = RealRewardsChainPort::::new(Arc::new(source)); + + let result = port.distributor_report([0x22; 32]).await; + + assert_eq!( + result, + Err(ChainPortError::NotADistributor), + "an answering chain that holds no distributor is an absence, not an unreachable \ + chain, got {result:?}" + ); + } + + /// A `ChainSource` that starts answering like an empty chain (every read `Ok` with nothing + /// found -- the `NotADistributor` shape) and can be flipped, mid-test, to fail every read (the + /// `Unavailable` shape). Lets [`a_not_a_distributor_result_never_arms_the_latch_and_never_blinds_a_later_outage`] + /// drive a SINGLE `RealRewardsChainPort` instance through the exact absence-then-outage + /// sequence dig_ecosystem#3342 gate H2 is about, instead of two instances that could never + /// prove the exclusion is scoped to `NotADistributor` alone. + #[derive(Default)] + struct SwitchableChainSource { + failing: std::sync::atomic::AtomicBool, + } + + impl SwitchableChainSource { + fn switch_to_failing(&self) { + self.failing + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + fn guard(&self) -> Result<(), ChainSourceError> { + if self.failing.load(std::sync::atomic::Ordering::SeqCst) { + Err(ChainSourceError::Timeout) + } else { + Ok(()) + } + } + } + + impl dig_chainsource_interface::ChainSource for SwitchableChainSource { + type Error = ChainSourceError; + + fn coin_record( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: chia_protocol::Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + self.guard()?; + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(Vec::new()) + } + + fn coin_spend( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + } + + /// **Proves (dig_ecosystem#3342, gate H2):** a `NotADistributor` result must not arm the + /// degradation latch -- so a genuine outage that follows still transitions the latch + /// false->true and still would warn, exactly as if the `NotADistributor` call had never + /// happened. Before the fix, EVERY `Err(_)` armed the latch, so the outage below would find it + /// already `true` and treat itself as a no-op continuation of an existing degradation. + /// **Mutation-probe:** in `RealRewardsChainPort::distributor_report`, delete the + /// `Err(ChainPortError::NotADistributor) => {}` arm (folding it back into the general `Err` + /// arm) and this test's first `assert!(!port.is_degraded())` goes red. + #[tokio::test] + async fn a_not_a_distributor_result_never_arms_the_latch_and_never_blinds_a_later_outage() { + let source = Arc::new(SwitchableChainSource::default()); + let port = RealRewardsChainPort::::new(Arc::clone(&source)); + + // Phase 1: the chain answers, no distributor here -- an absence, not a degradation. + let absence_result = port.distributor_report([0x33; 32]).await; + assert_eq!(absence_result, Err(ChainPortError::NotADistributor)); + assert!( + !port.is_degraded(), + "a NotADistributor result must never arm the degradation latch" + ); + + // Phase 2: the chain source itself now fails -- a genuine outage. + source.switch_to_failing(); + let outage_result = port.distributor_report([0x33; 32]).await; + assert_eq!( + outage_result, + Err(ChainPortError::Unavailable), + "a failing chain source must still report Unavailable after a prior absence" + ); + assert!( + port.is_degraded(), + "a genuine outage must still arm the latch even after a preceding NotADistributor \ + result -- that is exactly the blinding H2 guards against" + ); + } + /// The adjacent guard this crate's own `epoch_seconds == 0` refusal must keep: that refusal is /// a NAMED distributor-level refusal (`ChainPortError::Other`), never conflated with /// `ChainPortError::Unavailable` -- which must mean the CHAIN SOURCE could not answer, not @@ -371,10 +534,11 @@ mod tests { ); } - /// R5's regression (dig_ecosystem#3310 gate leg 3): a chain-source GAP on the launcher's - /// parent spend must never be reported as the definitive "not a DIG distributor" verdict -- - /// it must agree with the OTHER absence path (`read_distributor_guarded`'s own `Ok(None)`), - /// which answers `Unavailable`. + /// R5's regression (dig_ecosystem#3310 gate leg 3, corrected by dig_ecosystem#3342): a + /// chain-source GAP on the launcher's parent spend must never be reported as the definitive + /// "not a DIG distributor" verdict -- it stands on its own merit as an unreachable read + /// (`Unavailable`), independent of `read_distributor_guarded`'s `Ok(None)` path, which since + /// #3342 answers `ChainPortError::NotADistributor` instead: a genuine absence, not an outage. #[test] fn parent_spend_gap_is_reported_as_unavailable_not_as_a_distributor_identity_verdict() { let mapped = super::launch_comment_error_to_port_error( From 7c9f46b076d83cd9d8ff55b72a6a8b5ccff31e4c Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:56:49 -0700 Subject: [PATCH 22/29] fix(rewards): reward-distributor reads are OPEN on POST /, doc + pin (dig_ecosystem#3351) (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dig_ecosystem#3351: dig.getRewardDistributor / dig.listRewardDistributorCommitments were documented "CONTROL plane: loopback admin / in-process FFI ONLY" and answer on anonymous POST /. Decision (option 2, posted on the ticket): Tier::Control is a transport-path class (local dispatch, never the mTLS peer surface), not a token gate; both reads disclose only public on-chain state keyed by the caller's launcher_id; zero live RPC callers exist; SPEC §5.5 L1080 requires every non-control.* method to be requires_auth: false. The doc now says what the code does, and two tests pin it: POST / with no token reaches the reward handler (not -32030); /ws does not route the reads (ok:false, not Unauthorized in either numeric or string form). Mutation proofs M1/M2 run and RED (PR comment 5724073551). Node-local reward reads -> #3352; rewards-coin SPEC §2.6 tier sentence -> #3354; rate bound on the open chain reads -> #3355. Gates on head 262551c6: reviewer PASS (5243744057), security PASS (5243754264), adversarial PASS (5724353432). Zero production code-path change. Refs DIG-Network/dig_ecosystem#3351 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../src/seams/dig_rpc/dispatch.rs | 28 +++-- crates/dig-node-service/tests/server.rs | 109 ++++++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) 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 c5ac95b5..6d6e1ded 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -907,7 +907,8 @@ impl RpcDispatch for Node { "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 + // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY (the token tier of + // this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's), 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 @@ -997,12 +998,22 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } - // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL - // plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method` - // (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY — - // never the local prover loop's self-reported state (see `GetRewardProverStatus` - // above for that). Goes entirely through `rewards::port::RewardsChainPort`: this - // crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0). + // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — `Tier::Control` + // in dig-rpc-protocol's sense: served ONLY by the local `handle_rpc` dispatch (the + // service's `POST /` and the in-process FFI), NEVER over the mTLS peer surface (absent + // from `is_peer_reachable_method`; `reward_methods_tier_guard.rs` fails closed on that). + // NOT token-gated (dig_ecosystem#3351): an OPEN read of public on-chain state keyed by + // the caller's `launcher_id`, answered to any caller that reaches `POST /` with no token: + // only the `control.` prefix is token-gated (SPEC §7.2 `is_control_method`; §5.5 + // `requires_auth: false` for every non-`control.*` method), and the read passes §7.2's + // WHO-NAMES-THE-SUBJECT test the same way `control.wallet.balance` does (#1851, + // `control::is_open_control_read`): the subject arrives in the request, so the answer + // discloses no node-local association. + // Pinned by `reward_distributor_reads_answer_on_post_slash_without_a_token` in + // dig-node-service `tests/server.rs`. Chain-derived state ONLY — never the local prover + // loop's self-reported state (see `GetRewardProverStatus` above for that). Goes entirely + // through `rewards::port::RewardsChainPort`: this crate never calls `dig-rewards-coin` + // itself (dig_ecosystem#3269 unit 0). Some(Method::GetRewardDistributor) => { let params = req.get("params").cloned().unwrap_or(json!({})); let launcher_id = match parse_launcher_id_arg(¶ms) { @@ -1041,7 +1052,8 @@ impl RpcDispatch for Node { return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5) - // — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments` + // — same guard shape as `GetRewardDistributor` above (`Tier::Control`, not token-gated, + // OPEN on `POST /`; dig_ecosystem#3351). `commitments` // empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot // is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see // `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here). diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index d383f7af..92b63f44 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,6 +1535,62 @@ async fn cache_list_cached_is_not_routable_over_ws() { ); } +/// **Proves (dig_ecosystem#3351, WS parity):** `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` are OPEN reads on the HTTP transport (no token required), +/// but that openness must not accidentally widen into a SECOND, WS-reachable path. The `ws_dispatch` +/// fall-through routes an unrecognized method to `WalletBackend::dispatch`, whose match has no +/// `dig.*` arm, so both methods come back as an unknown-method error over `/ws` -- never as +/// `UNAUTHORIZED` (that would mean WS gates them where HTTP does not, which is its own bug) and +/// never as a real result (that would mean the reward-chain answer leaked over an unaudited +/// transport). +/// +/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing `dig.*` reward reads over +/// `/ws` without the tier decision being revisited. +#[tokio::test] +async fn reward_distributor_reads_are_not_routable_over_ws() { + use tokio_tungstenite::tungstenite::Message; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_node_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + let _ = next_ws_json(&mut ws).await; // drain the initial sync_status snapshot + + for (idx, method) in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] + .into_iter() + .enumerate() + { + // No token: these reads are OPEN on HTTP, but that has no bearing on WS routability. + ws.send(Message::Text( + json!({ "id": format!("rd{idx}"), "type": "request", "method": method }).to_string(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!(format!("rd{idx}"))); + assert_eq!( + resp["ok"], + json!(false), + "{method} is not a WS method, got {resp:?}" + ); + // `ws_err` emits `error.code` as the NUMERIC control-plane code (`ErrorCode::code()`, + // -32030 for Unauthorized) while `ws_from_jsonrpc` surfaces the string name; a gate + // added on either path must trip this, so reject BOTH spellings. + let is_unauthorized = resp + .pointer("/error/code") + .is_some_and(|c| c == &json!("UNAUTHORIZED") || c == &json!(-32030)); + assert!( + !is_unauthorized, + "{method} over WS must fail as unknown-method, not UNAUTHORIZED -- \ + a WS gate would contradict the HTTP-side open-read decision, got {resp:?}" + ); + } +} + /// **A person can add, list and remove a trusted Chia peer, end to end over the REAL control plane.** /// /// The whole round trip through the real server, the real token gate, the real wallet backend and @@ -3853,3 +3909,56 @@ async fn a_client_can_register_and_deregister_the_addresses_the_node_follows() { "deregistering one key must stop following exactly it, and leave the other followed" ); } + +/// **Proves:** `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` are answered +/// on `POST /` with NO control token presented — `Tier::Control` in dig-rpc-protocol's sense means +/// "loopback / in-process dispatch only, never over the mTLS peer surface", NOT token-gated +/// (dig_ecosystem#3351). Both requests must pass every ingress gate (no `-32030`/`UNAUTHORIZED`) and +/// reach the reward handler itself, which then reports `REWARD_CHAIN_UNAVAILABLE` because this +/// ephemeral test node has no chain-read adapter wired — proving dispatch, not a passthrough relay +/// or a method-not-found stub, answered the call. +/// **Catches:** a future gate added at the `server.rs` ingress (e.g. folded into the cache-trio +/// token check) that silently demotes these reads to token-gated — breaking the anonymous callers +/// nobody can enumerate — and a doc claiming they are gated when the enforced behaviour is open. +#[tokio::test] +async fn reward_distributor_reads_answer_on_post_slash_without_a_token() { + let (addr, _hold) = start_node("").await; + let launcher_id = "11".repeat(32); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp: Value = client() + .post(format!("http://{addr}/")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": { "launcher_id": launcher_id } + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_ne!( + resp["error"]["code"], + json!(-32030), + "{method} must not be Unauthorized when no token is presented: {resp}" + ); + assert_ne!( + resp["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} must not be gated by the control token: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method} must reach the reward handler (no chain port wired on this ephemeral node), \ + not a passthrough or a method-not-found stub: {resp}" + ); + } +} From 4e102c67e21988711232eddd6a0715253edbe69b Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:26:27 -0700 Subject: [PATCH 23/29] fix(rewards-claim): derive both cadences from one raw config value and inject the clock (#617) * chore: open lane for #3336 Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): newtype the fee-window cadence args + injectable-clock seam - GateCadenceSeconds / FeeWindowCadenceSeconds newtypes on ClaimEngine::with_persisted_fee_window so transposing the two cadence values at the driver.rs call site is a compile error, not a silent money defect (#3336). - run_claim_driver_in_with_clock: the production body with the clock parameterized instead of hardcoded to unix_now_seconds, so the timing behaviour is testable under tokio::time::advance. run_claim_driver_in stays a thin wrapper over it passing the real clock. Refs #3336 Co-Authored-By: Claude Sonnet 5 * test(rewards-claim): drive gate/window split through run_claim_driver_in_with_clock Adds the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window, proving the F2 gate-vs-fee-window distinction through the actual production body (run_claim_driver_in_with_clock) rather than a hand-assembled drive() call, so a transposition of the two cadence arguments at with_persisted_fee_window's call site (#3336) is caught end-to-end. Refs #3336 Co-Authored-By: Claude Sonnet 5 * style: cargo fmt rewards-claim fee-window call sites Co-Authored-By: Claude Sonnet 5 * refactor(rewards-claim): collapse two cadence newtypes into one ClaimCadences value The positional-newtype shape (GateCadenceSeconds/FeeWindowCadenceSeconds as two adjacent with_persisted_fee_window arguments) closed the positional swap but left the value swap open -- GateCadenceSeconds(raw)/FeeWindowCadenceSeconds(clamped) still compiled and still passed the existing test (dig_ecosystem#3336 rework). Bundle both cadences into one ClaimCadences { gate_clamped, fee_window_raw } value instead: there is no longer a pair of adjacent arguments for a swap to target at the call site. Every production and test call site updated. Co-Authored-By: Claude Sonnet 5 * fix(rewards-claim): make the clamped gate cadence unconstructible except by its clamp DIG-Network/dig_ecosystem#3336: `ClaimCadences { gate_clamped: u64, fee_window_raw: u64 }` closed the positional swap between the two cadence values but left the value swap open -- both fields were plain u64, so nothing stopped a caller writing the RAW configured cadence into `gate_clamped` and the CLAMPED one into `fee_window_raw`. `ClampedGateCadence`'s inner field is now private with `ClampedGateCadence::clamp(FeeWindowCadenceSeconds) -> Self` as its only constructor, so a raw value has no path into the gate slot without going through the clamp. `FeeWindowCadenceSeconds` wraps the raw side so the two are no longer interchangeable plain integers. * style(rewards-claim): cargo fmt Co-Authored-By: Claude Opus 5 (1M context) * refactor(rewards-claim): derive the gate cadence from one raw config value Closes the remaining half of the #3336 transposition hole structurally rather than by test. `ClaimCadences` now has private fields and a single constructor, `ClaimCadences::from_raw`, which derives `gate_clamped` from the same raw value it stores as `fee_window_raw`. The production call site passes ONE value, so there is no longer a pair to transpose or a `pub` field to write the wrong local into. Behaviour-identical: `CLAIM_CADENCE_FLOOR_SECONDS` is 60 and `config.rs` floors any smaller configured cadence at load, so `sanitized_schedule`'s zero branch is unreachable from this path and its output equals `min(cadence, MAX)` -- exactly what the clamp computes. Also renames `FeeWindowCadenceSeconds` to `RawConfiguredCadence` (it is the clamp's input type, not only the window's), and replaces the tick-1/tick-2 `assert_ne!(state, CadenceNotElapsed)` exclusions with exact-state assertions -- the exclusion was equally satisfied by PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above the window-roll block, so it would have gone vacuous the moment either fired. Refs DIG-Network/dig_ecosystem#3336 Co-Authored-By: Claude Opus 5 (1M context) * style(rewards-claim): restore string line continuations in claim-driver assertions Convert literal backslash-n escapes with trailing spaces back to proper Rust string line continuations (trailing backslash at end of line). The strings render as single sentences without artificial newlines or multi-space runs. Fixes continuation_guard. Co-Authored-By: Claude Haiku 4.5 * docs(rewards-claim): separate the type-enforced half from the test-guarded half The #3336 doc comments asserted a type-level guarantee the code does not deliver. `ClaimCadences::from_raw` does close the pairing mutation -- the gate and the fee window cannot disagree with each other, because `from_raw` derives `gate_clamped` from the same `RawConfiguredCadence` it stores as `fee_window_raw`, and it is the only constructor. It does NOT close the value mutation: `from_raw(RawConfiguredCadence(cadence_seconds))`, the already-clamped local instead of `cfg.cadence_seconds`, has the same type and compiles. Measured consequence: the fee window halves -- the tick-2 assertion fails with left Some(5356800), right Some(2678400). That mutation is caught by exactly one test, `the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`. The other money test stays green under it, because it hand-assembles `drive` and never traverses the production call site. The old docs told the next reader the value swap was type-closed -- the exact sentence that would be cited to delete the one guarding test. Also corrects the stale "transposing the two arguments" description: the call takes ONE argument, so that mutation cannot be written at all; and drops the claim that the surviving mutation also breaks the gate (clamping an already-clamped value is the identity, so it does not). Docs only -- no behaviour, no type, and no assertion changed. Refs DIG-Network/dig_ecosystem#3336 Co-Authored-By: Claude Opus 5 (1M context) * docs(rewards-claim): remove the two-argument F2 doc block and name the clamp precondition Doc comments only; no code, assertion, type or visibility changed. The test count is unchanged (92 passed, 0 failed). - engine.rs: delete the `with_persisted_fee_window` "F2 (money): takes TWO cadence values, deliberately not one" block. The method takes ONE `ClaimCadences`; those two names are private fields, not parameters, and "deliberately not one" instructed the next author to restore the two-`u64` signature this PR makes unwritable -- twenty lines above the #3336 section that contradicts it. - engine.rs: `ClampedGateCadence::clamp` no longer claims flat equivalence with `sanitized_schedule`. It reproduces only that function's clamp arm, not its zero-substitution arm; they agree because a zero cannot reach it, and that precondition is now named. - driver.rs: the surviving production-call-site comment describes the one-argument call it sits above, and names the single test that catches passing the clamped local instead of the raw config value. - driver.rs: the sibling money test now records that it stays GREEN under that mutation, so its doc cannot be cited to delete the test that does catch it. Refs DIG-Network/dig_ecosystem#3336 Co-Authored-By: Claude Opus 5 (1M context) * docs(rewards-claim): correct the clock-seam fn name and the load_from exit count Co-Authored-By: Claude Haiku 4.5 --------- Co-authored-by: Claude Sonnet 5 --- .../src/rewards_claim/driver.rs | 232 ++++++++++++++++-- .../src/rewards_claim/engine.rs | 211 +++++++++++++--- 2 files changed, 384 insertions(+), 59 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 4d4bdd52..6dbf737f 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -38,7 +38,7 @@ use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::ClaimEngine; +use super::engine::{ClaimCadences, ClaimEngine, RawConfiguredCadence}; use super::hints::{DistributorHintSource, NoHintSource}; use super::port::{ClaimChainPort, UnavailableClaimChainPort}; use super::types::{ClaimLoopState, ClaimStatus}; @@ -335,7 +335,7 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// this ticket: the claim loop never fires again, so no cycle, no `log_cycle` line, and the /// cycle counter reads a permanent, reassuring `0`. #594 shipped an engine that was inert and /// green; a config value must not be able to put this driver back in that state silently. -const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; +pub(crate) const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; /// [`sanitized_schedule`]'s call-scoped report of what it changed, threaded into [`drive`] and /// on into [`log_cycle`] -- NEVER stored on [`ClaimEngine`] as a field (see this module's SHAPE @@ -438,6 +438,42 @@ async fn run_claim_driver_in

( handle: ClaimLoopHandle, ) where P: ClaimChainPort, +{ + run_claim_driver_in_with_clock( + state_dir, + own_payout_puzzle_hash, + port, + handle, + unix_now_seconds, + ) + .await; +} + +/// The same production body as [`run_claim_driver_in`], with the clock [`drive`] ticks on taken +/// as a parameter instead of hardcoded to [`unix_now_seconds`] (real wall-clock). +/// +/// # DIG-Network/dig_ecosystem#3336: why this seam exists +/// `tokio::time::advance` (the mechanism every other test in this module uses to fast-forward +/// [`drive`]'s `sleep`, under `#[tokio::test(start_paused = true)]`) moves ONLY the tokio virtual +/// clock -- it cannot move [`SystemTime::now()`], which is what [`unix_now_seconds`] reads. Before +/// this seam, [`run_claim_driver_in`] was therefore untestable for anything that depends on the +/// VALUE `now()` returns each cycle (the restart-safety gate, the persisted fee-budget window's +/// roll condition) -- a test could advance the scheduler's ticks but every cycle would still see +/// the same real `now()`, so a defect in either cadence value threaded through +/// [`ClaimEngine::with_persisted_fee_window`] could not be observed through THIS function, only by +/// hand-assembling `drive` directly (see `the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`, +/// which had to do exactly that before this seam existed). +/// +/// [`run_claim_driver_in`] stays a thin wrapper that passes the real clock, so no caller of it -- +/// including `run_claim_driver`, the only production caller -- changes at all. +async fn run_claim_driver_in_with_clock

( + state_dir: &Path, + own_payout_puzzle_hash: Bytes32, + port: P, + handle: ClaimLoopHandle, + now: impl FnMut() -> u64, +) where + P: ClaimChainPort, { let cfg = RewardsClaimConfig::load_from(state_dir); // A4/F8: a corrupt config is not a reason to refuse to SPAWN -- `ClaimEngine::run_cycle` @@ -463,16 +499,19 @@ async fn run_claim_driver_in

( dig_mirror_coin::DIG_ASSET_ID, ) .with_rotation_cursor(cfg.rotation_cursor) - // F2: two DIFFERENT cadence values, deliberately -- `cadence_seconds` (CLAMPED, already - // bounded to `CLAIM_SCHEDULE_SECONDS_MAX`) gates WHEN a cycle may run, tracking the same - // schedule the driver below actually sleeps on. `cfg.cadence_seconds` (RAW, unclamped) sizes - // the persisted fee-budget window -- reusing the clamped value there would double the number - // of budget windows a long-cadence operator sized (a 60-day config would get ~12 windows/year - // instead of the ~6 its cadence implies -- 2x the fee ceiling they configured). Conflating the - // two into one value in either direction is wrong: clamped-for-both doubles the fee ceiling, - // raw-for-both can silently starve the gate (an unbounded-above raw cadence would stop cycles - // from ever running while the scheduler keeps ticking on the clamped interval). - .with_persisted_fee_window(state_dir, cadence_seconds, cfg.cadence_seconds); + // F2 (money): ONE argument, and it must be the RAW `cfg.cadence_seconds`. `ClaimCadences` + // derives both halves from it -- the CLAMPED gate (bounded to `CLAIM_SCHEDULE_SECONDS_MAX`, + // so WHEN a cycle may run tracks the same schedule the driver below actually sleeps on) and + // the RAW fee window (how long the persisted fee-budget window stays open). The one argument + // makes them impossible to transpose, but NOT impossible to get wrong: passing the clamped + // local `cadence_seconds` here instead of `cfg.cadence_seconds` has the same type, compiles, + // and halves the fee window -- ~12 budget windows a year for a 60-day operator instead of the + // ~6 their cadence implies, i.e. 2x the fee ceiling they configured. Exactly one test catches + // that: `tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`. + .with_persisted_fee_window( + state_dir, + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); drive( engine, @@ -480,7 +519,7 @@ async fn run_claim_driver_in

( jitter_seconds, adjustment, &OsJitter, - unix_now_seconds, + now, handle, ) .await; @@ -992,7 +1031,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 assert!(outcomes.is_empty()); @@ -1023,6 +1065,12 @@ mod tests { /// - the WINDOW must NOT roll at tick 2 (elapsed since it opened is one clamped interval, /// 2_678_400s, well under the raw 5_184_000s the operator configured) but MUST have rolled /// by tick 3 (elapsed is 2 clamped intervals, 5_356_800s, past the raw boundary). + /// + /// This test builds its `ClaimCadences` itself, so it stays GREEN if the PRODUCTION call site + /// in [`run_claim_driver_in_with_clock`] is mutated to pass the clamped local instead of the raw + /// `cfg.cadence_seconds`. It is therefore not a duplicate of + /// [`the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`], which is the only + /// test that catches that mutation -- do not delete that one as redundant with this one. #[tokio::test(start_paused = true)] async fn the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one() { let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. @@ -1039,7 +1087,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), effective_cadence, configured_cadence); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(configured_cadence)), + ); let handle = ClaimLoopHandle::default(); let h = handle.clone(); @@ -1070,10 +1121,11 @@ mod tests { tokio::time::advance(Duration::from_secs(effective_cadence)).await; settle().await; assert_eq!(handle.cycles_driven(), 1); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "the very first cycle has no prior completion to gate against" + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1093,11 +1145,14 @@ mod tests { "F2: the gate must track the CLAMPED cadence -- a cycle sized from the raw 5_184_000 \ cadence would still be refused here, reproducing the silent-non-claiming defect" ); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, + super::super::types::ClaimLoopState::Nominal, "F2: the gate opened one clamped interval after the last completion -- it must not \ - still be waiting on the raw 5_184_000s cadence" + still be waiting on the raw 5_184_000s cadence. Asserting the exact state, not \ + merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns sit above the \ + window-roll block too, so it would go vacuous the moment one of those fired instead" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1138,7 +1193,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); @@ -1166,7 +1224,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at assert!(outcomes.is_empty()); @@ -1198,7 +1259,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), 1_000, 1_000); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(1_000)), + ); let outcomes = engine.run_cycle(1).await; assert!(outcomes.is_empty()); @@ -1320,6 +1384,126 @@ mod tests { driver.abort(); } + /// F2/#3336, MONEY, THE JOINT VERSION: [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] + /// proves the gate/window split by hand-assembling `drive` directly. This test proves the SAME + /// property through [`run_claim_driver_in_with_clock`] -- the actual production body, the one + /// that threads `sanitized_schedule`'s CLAMPED cadence and the RAW `cfg.cadence_seconds` into + /// [`ClaimEngine::with_persisted_fee_window`] at driver.rs's one call site. + /// + /// THIS IS THE ONLY TEST THAT CATCHES THE ONE MUTATION STILL LEFT AT THAT CALL SITE. The call + /// takes a single argument now, so the historic two-argument transposition cannot be written + /// at all. What still compiles is passing the already-clamped local as the raw value -- + /// `ClaimCadences::from_raw(RawConfiguredCadence(cadence_seconds))`. Measured: the fee window + /// then rolls at tick 2 instead of tick 3, failing the tick-2 assertion below with + /// `left: Some(5356800)`, `right: Some(2678400)` -- half the window length, so 2x the + /// fee-budget windows the operator sized. The gate is NOT affected (clamping an + /// already-clamped value is the identity), and + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] stays GREEN + /// under that mutation, because it hand-assembles `drive` and never traverses the production + /// call site. + /// + /// Uses a written config with a 60-day RAW cadence (`5_184_000`s, clamped to the 31-day + /// `CLAIM_SCHEDULE_SECONDS_MAX`, `2_678_400`s) and a clock that advances one clamped interval + /// per invocation, matching the scheduler's own tick -- the same pattern + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] uses, but + /// driven through the production body instead of a hand-built engine. + #[tokio::test(start_paused = true)] + async fn the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window() { + let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. + let effective_cadence = 31 * 24 * 60 * 60u64; // 2_678_400, CLAMPED -- sizes the gate. + assert_eq!(configured_cadence, 5_184_000); + assert_eq!(effective_cadence, 2_678_400); + + let dir = tempfile::tempdir().unwrap(); + write_config(dir.path(), configured_cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in_with_clock(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h, { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }) + .await; + }); + + settle().await; + assert_eq!(handle.cycles_driven(), 0, "no interval has elapsed yet"); + + // Tick 1: the window opens for the first time. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + // `cycles_driven()` alone is NOT gate evidence: `drive` increments it unconditionally + // after every `run_cycle` call returns, whatever that cycle's outcome was -- a cycle the + // internal gate REFUSED (`ClaimLoopState::CadenceNotElapsed`) still increments it. Assert + // on the reported STATE, which the gate's early `return` in `run_cycle` actually controls. + assert_eq!(handle.cycles_driven(), 1); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" + ); + let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_1, + Some(effective_cadence), + "the window opens on tick 1" + ); + + // Tick 2: one clamped interval since tick 1 -- the GATE must open (it tracks the clamped + // schedule the loop actually ticks on) but the WINDOW must NOT roll yet (only one clamped + // interval, 2_678_400s, of its raw 5_184_000s length has elapsed). + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "the driver's loop iterated a second time" + ); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "#3336: the production body's gate must track the CLAMPED cadence -- a body that gated \ + on the raw 5_184_000s cadence would report CadenceNotElapsed here, restoring the \ + no-op gate #3306 fixed. Asserting the exact \ + state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \ + the window-roll block, so it would go vacuous the moment one of those fired instead. \ + `cycles_driven()` cannot see any of this: it counts every drive loop iteration, \ + including ones the internal gate refused" + ); + let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_2, after_tick_1, + "#3336: the fee window must NOT have rolled yet -- only one clamped interval has \ + elapsed against its raw 5_184_000s length. Sizing the window off the CLAMPED value \ + instead rolls it here (left Some(5356800), right Some(2678400)), halving the window \ + and doubling the operator's configured fee-window count. This assertion alone cannot \ + distinguish 'gate opened, window correctly held' from 'gate refused, window-roll \ + code never reached' (the gate's early return in `run_cycle` sits before the \ + window-roll block) -- it is only meaningful paired with the state assertion above, \ + which proves the gate did NOT refuse this cycle." + ); + + // Tick 3: two clamped intervals (5_356_800s) since the window opened -- past its raw + // 5_184_000s length. The window must finally roll. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 3); + let after_tick_3 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_ne!( + after_tick_3, after_tick_1, + "#3336: the window must have rolled once the RAW 5_184_000s cadence elapsed" + ); + + driver.abort(); + } + // ---- the cycle log: the only reader of the status surface in a shipped binary ---------- /// An in-memory sink a `tracing_subscriber::fmt` layer renders records into, so a test can diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index b570920f..df110ab5 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -11,6 +11,99 @@ use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +/// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the +/// single raw configured value they both come from. +/// +/// # DIG-Network/dig_ecosystem#3336 (money) -- what this shape closes, and what it does not +/// Earlier shapes handed the engine two numbers the CALLER had already chosen: first two bare +/// `u64` arguments, then two distinct newtypes, then this struct with two typed fields. +/// +/// TYPE-ENFORCED: the gate and the fee window cannot disagree with EACH OTHER. +/// [`Self::from_raw`] is the only constructor, both fields are private (so a struct literal is +/// not an alternative path from outside this module), and it derives `gate_clamped` by clamping +/// the very [`RawConfiguredCadence`] it stores as `fee_window_raw`. There is no pairing in which +/// the window sizes off one number and the gate off another. +/// +/// NOT type-enforced, and no type here can be: WHICH `u64` the call site labels raw. +/// `from_raw(RawConfiguredCadence(cadence_seconds))` -- the already-clamped local instead of +/// `cfg.cadence_seconds` -- has the same type and compiles. It halves the fee window: measured, +/// the window rolls one tick early, `Some(5356800)` becoming `Some(2678400)`, exactly 2x the +/// number of fee-budget windows the operator sized. Exactly ONE test catches that, and it is +/// `driver::tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`, which +/// drives the production call site. Do not delete it on the belief that a type stands behind it +/// -- nothing does. +/// +/// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s +/// restart-safety gate is measured against -- the same interval [`super::driver::drive`] +/// actually sleeps on. The RAW value here would restore the no-op gate +/// DIG-Network/dig_ecosystem#3306 fixed: an operator's 60-day config would gate on 60 days +/// again even though the loop keeps ticking every 31. +/// - `fee_window_raw`: the RAW configured cadence, in seconds, that sizes how long the persisted +/// aggregate fee-budget window stays open before rolling -- deliberately never the clamped +/// value. The CLAMPED value here doubles the number of fee-budget windows a long-cadence +/// operator sized (a 60-day config would get ~12 windows/year instead of the ~6 its cadence +/// implies), doubling the fee ceiling they configured. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ClaimCadences { + gate_clamped: ClampedGateCadence, + fee_window_raw: RawConfiguredCadence, +} + +impl ClaimCadences { + /// The ONLY constructor. The gate cadence is derived from the window's own raw source, so the + /// two can never be paired with each other's value -- that much the types enforce. What + /// nothing here enforces is that `raw` really is the raw configured value; see the + /// [`ClaimCadences`] #3336 section for the single test that does. + #[must_use] + pub(crate) fn from_raw(raw: RawConfiguredCadence) -> Self { + Self { + gate_clamped: ClampedGateCadence::clamp(raw), + fee_window_raw: raw, + } + } +} + +/// The RAW, operator-writable cadence in seconds (`RewardsClaimConfig::cadence_seconds` as +/// persisted) -- unbounded above. The single input [`ClaimCadences::from_raw`] takes: it sizes +/// the fee window directly and, through [`ClampedGateCadence::clamp`], the gate as well. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RawConfiguredCadence(pub(crate) u64); + +/// The schedule-CLAMPED cadence (bounded to [`super::driver::CLAIM_SCHEDULE_SECONDS_MAX`]) that +/// [`ClaimEngine::run_cycle`]'s restart-safety gate is measured against -- the same interval +/// [`super::driver::drive`] actually sleeps on. +/// +/// # DIG-Network/dig_ecosystem#3336 -- why the field is private +/// [`Self::clamp`] is the only way to produce this type, it is private to this module, it always +/// applies the bound, and the only caller of `clamp` is [`ClaimCadences::from_raw`]. So every +/// number that reaches the gate has been through the clamp -- which bounds its MAGNITUDE and +/// nothing else. It is not evidence about where the number came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ClampedGateCadence(u64); + +impl ClampedGateCadence { + /// Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a 60-day raw cadence in + /// yields the 31-day ceiling out, so the gate tracks the schedule the driver really sleeps + /// on. + /// + /// That is the same CEILING [`super::driver::sanitized_schedule`] applies at the config read, + /// but only its clamp arm: `sanitized_schedule` ALSO substitutes + /// `super::config::CLAIM_CADENCE_SECONDS_DEFAULT` for a zero cadence, and `clamp` has no such + /// arm. The two agree only under an unnamed-until-now precondition -- a zero never reaches + /// here, because [`super::config::RewardsClaimConfig::load_from`] floors `cadence_seconds` to + /// `super::config::CLAIM_CADENCE_FLOOR_SECONDS` (60) on its parse-success path and yields + /// `CLAIM_CADENCE_SECONDS_DEFAULT` (86_400) on its other four exits. A caller that builds a + /// cadence from anything but a loaded config breaks that precondition, and the equivalence + /// with it. + fn clamp(raw: RawConfiguredCadence) -> Self { + ClampedGateCadence(raw.0.min(super::driver::CLAIM_SCHEDULE_SECONDS_MAX)) + } + + fn seconds(self) -> u64 { + self.0 + } +} + /// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] /// and the anti-silence status surface across calls to [`Self::run_cycle`]. /// @@ -142,15 +235,6 @@ impl ClaimEngine { /// 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). /// - /// F2 (money): takes TWO cadence values, deliberately not one -- `gate_cadence_seconds` (the - /// CLAMPED value the driver's schedule actually runs on) gates WHEN a cycle is allowed to - /// start; `fee_window_seconds` (the RAW configured value) sizes how long the persisted - /// fee-budget window stays open. Conflating them into a single cadence (the pre-F2 shape) - /// either doubled the operator's fee ceiling (reusing the clamped value for the window) or - /// silently starved the gate to the raw value -- for an unbounded-above raw cadence, the gate - /// could stop opening at all while the scheduler kept ticking on the clamped interval. See - /// [`Self::gate_cadence_seconds`] and [`Self::fee_window_seconds`]'s field docs. - /// /// 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 @@ -174,18 +258,27 @@ impl ClaimEngine { /// 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. + /// + /// # DIG-Network/dig_ecosystem#3336 + /// Takes ONE [`ClaimCadences`], which the caller can only build with + /// [`ClaimCadences::from_raw`] -- so the two cadences are derived together, from one value, + /// and cannot contradict each other. WHICH value that is is still the call site's choice, and + /// is test-guarded only; see the [`ClaimCadences`] #3336 section. + /// + /// `pub(crate)`, not `pub`: [`ClaimCadences`] is crate-private (it is the argument type, so a + /// `pub` method taking it would be uncallable from outside anyway), and no out-of-crate + /// caller exists. #[must_use] - pub fn with_persisted_fee_window( - mut self, - dir: &Path, - gate_cadence_seconds: u64, - fee_window_seconds: u64, - ) -> Self { + pub(crate) fn with_persisted_fee_window(mut self, dir: &Path, cadences: ClaimCadences) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); - self.gate_cadence_seconds = - gate_cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); - self.fee_window_seconds = - fee_window_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.gate_cadence_seconds = cadences + .gate_clamped + .seconds() + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.fee_window_seconds = cadences + .fee_window_raw + .0 + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -1774,7 +1867,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1799,7 +1895,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_010).await; let second_submitted = second_outcomes @@ -1838,7 +1937,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes .iter() @@ -1878,7 +1980,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1901,7 +2006,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(later).await; assert_eq!( @@ -1934,7 +2042,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1951,7 +2062,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_050).await; assert_eq!( @@ -1987,7 +2101,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; let submitted: u64 = outcomes @@ -2036,7 +2153,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2094,7 +2214,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(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. @@ -2134,7 +2257,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -2156,7 +2282,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_010).await; assert_eq!( @@ -2208,7 +2337,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, // and must refuse. @@ -2281,7 +2413,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Cycle 1: the file is corrupt -- must refuse, submit nothing. let cycle1 = e.run_cycle(1_000).await; @@ -2353,7 +2488,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2401,7 +2539,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; From 44230a71c0cef01ddb6c58794cf2cd00bcf1d061 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:51:19 -0700 Subject: [PATCH 24/29] feat(rewards-claim): real ClaimChainPort over dig-rewards-coin 0.8.0 -- the claim loop pays a peer (#620) Refs DIG-Network/dig_ecosystem#3347. Refs DIG-Network/dig_ecosystem#3246. RealClaimChainPort installed from serve_with_shutdown via the corroborated chain source + a hint-index enumerator; own_entry and submit_initiate_payout are real over dig-rewards-coin 0.8.0 (ChainEntrySlotSource, finish_spend signature, Broadcaster); closure artifact drives run_claim_driver_in to a simulator-accepted payout coin. Triple gate PASS @ 96482362; five mutation proofs posted. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 4 +- crates/dig-node-service/Cargo.toml | 20 +- .../src/rewards_claim/chain_port.rs | 597 ++++++++++++++ .../src/rewards_claim/driver.rs | 345 +++++++- .../src/rewards_claim/engine.rs | 34 +- .../dig-node-service/src/rewards_claim/mod.rs | 42 +- .../src/rewards_claim/port.rs | 16 +- crates/dig-node-service/src/server.rs | 19 +- crates/dig-node-service/tests/common/mod.rs | 4 + .../tests/common/rewards_fixture.rs | 740 ++++++++++++++++++ .../tests/rewards_chain_port_a3.rs | 259 +----- .../tests/rewards_claim_chain_port_3347.rs | 515 ++++++++++++ 12 files changed, 2272 insertions(+), 323 deletions(-) create mode 100644 crates/dig-node-service/src/rewards_claim/chain_port.rs create mode 100644 crates/dig-node-service/tests/common/mod.rs create mode 100644 crates/dig-node-service/tests/common/rewards_fixture.rs create mode 100644 crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs diff --git a/Cargo.lock b/Cargo.lock index 6999284f..29a2dc3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3217,9 +3217,9 @@ dependencies = [ [[package]] name = "dig-rewards-coin" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bb94b1f02239b4ad8072c5b1d37a00cca366cfeca73b34fcecd94a2470fdd0" +checksum = "7afdbc8cf70e84ad13779824948d165577df91e0409cd65a40130f5421e99f5b" dependencies = [ "chia-bls 0.36.1", "chia-consensus 0.36.1", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 4cc7b516..5674b4ea 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -153,6 +153,12 @@ chia-sdk-types = { version = "=0.36.0", features = ["chip-0035", "action-layer"] # Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned # coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. chia-puzzle-types = "=0.36.1" +# `SINGLETON_LAUNCHER_HASH` -- `rewards_claim::chain_port::HintedLauncherIndex` filters a hinted +# coin candidate down to an actual singleton-launcher coin before ever handing its id to +# `dig_rewards_coin::discover_distributor` (dig_ecosystem#3347). Promoted from a dev-only edge to +# a normal one at the SAME `=0.20.3` pin the dev-dependency section below already resolves -- no +# second version enters the tree. +chia-puzzles = "=0.20.3" # `ToTreeHash`, to re-derive an owner puzzle hash from the key a mirror create is built for, so the # signer can refuse spends that are not its own wallet's rather than trusting the call site to have # passed the right key. Part of the same chia set above -- `chia-sdk-driver` already resolves it. @@ -202,11 +208,15 @@ dig-wallet = { path = "../dig-wallet" } # `ChainSource`. This is the ONE crate in this seam that depends on it — `dig-node-core` # deliberately does not (see `dig_node_core::rewards::port`'s module doc) because the # reader takes a `ChainSource` it has no seam to hold; this crate does (`dig-wallet`'s -# `CorroboratedChainSource`), so the adapter lives here. `0.5`, not the `0.4` the ticket -# names (stale): 0.5.0 is the release that already refuses `epoch_seconds == 0` inside -# `read_distributor` itself (see `rewards/chain_source.rs`'s module doc for the exact -# line, and why this crate ALSO refuses at its own edge as defence in depth). -dig-rewards-coin = "0.5" +# `CorroboratedChainSource`), so the adapter lives here. 0.5.0 was the release that first +# refused `epoch_seconds == 0` inside `read_distributor` itself (see `rewards/chain_source.rs`'s +# module doc for the exact line, and why this crate ALSO refuses at its own edge as defence in +# depth). Bumped to 0.8 for dig_ecosystem#3347: 0.8.0 adds `payout::accrued_base_units` (a public, +# pure accrual read) and `payout::ChainEntrySlotSource` (a chain-backed `EntrySlotSource`), which +# is what makes `RealClaimChainPort::own_entry` and `submit_initiate_payout` real instead of +# refusals. Still the same chia 0.36 line (chia-sdk-driver `=0.36.0`, chia-protocol 0.36.1) every +# other dependency in this crate is already pinned to. +dig-rewards-coin = "0.8" # HTTP stack: the same axum/tokio the node itself uses, so there is one async runtime # and one server framework across the node and the service shell. `ws` enables diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs new file mode 100644 index 00000000..e5bb7aff --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -0,0 +1,597 @@ +//! `RealClaimChainPort` -- the production [`super::port::ClaimChainPort`] adapter over +//! `dig-rewards-coin` 0.8.0 and this node's own [`dig_wallet::sage::corroborated_source::CorroboratedChainSource`] +//! (DIG-Network/dig_ecosystem#3347). Until this file existed, [`super::port::UnavailableClaimChainPort`] +//! was the ONLY adapter this trait had, so every real cycle reported `ChainSourceUnavailable` -- +//! see [`super`]'s module doc, "the chain seam", for the history. +//! +//! # Every method is a real chain read or a real broadcast +//! +//! Discovery, comment resolution, the reserve asset id and the chain-curried payout threshold are +//! all real reads. [`RealClaimChainPort::own_entry`] reads the real accrued amount via +//! `dig_rewards_coin::accrued_base_units` -- a public, pure function 0.8.0 added -- applied to a +//! freshly chain-read entry slot; it never fabricates `0` and never caches across calls. +//! [`RealClaimChainPort::submit_initiate_payout`] builds and broadcasts a real `InitiatePayout` +//! spend: the entry slot comes ONLY from `dig_rewards_coin::ChainEntrySlotSource` (a fresh, +//! authenticated chain walk, `SPEC.md` §12.5 clause 3a on `dig-rewards-coin`'s side) -- **never** +//! `RewardDistributor::created_slot_value_to_slot` on a chain-rebuilt distributor, which derives a +//! well-formed but PHANTOM `LineageProof` for a slot an earlier generation created +//! (DIG-Network/dig_ecosystem#3357). `initiate_payout`'s returned `conditions` are a CALLER-SIDE +//! assertion for a coin the caller would add to the same bundle; this adapter adds no coin of its +//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them -- +//! the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the proof the +//! resulting bundle is accepted without them. +//! +//! A silent no-op would be the exact defect this ticket exists to prevent -- a refused method +//! reports a NAMED [`ClaimPortError`], never a fabricated success. + +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::{Bytes32, SpendBundle}; +use chia_sdk_driver::{RewardDistributorConstants, RewardDistributorState, SpendContext}; +use chia_sdk_types::puzzles::RewardDistributorEntrySlotValue; +use dig_chainsource_interface::ChainSource; +use dig_rewards_coin::payout::{initiate_payout, PayoutOutcome}; +use dig_rewards_coin::ChainEntrySlotSource; +use dig_wallet::sage::spend::Broadcaster; + +use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError}; + +use super::port::{ClaimChainPort, ClaimPortError}; +use super::types::{DiscoveredDistributor, OwnEntry}; + +/// The longest a chain port's own error text is allowed to carry before it is truncated -- the +/// same 200-char discipline [`super::types::ClaimOutcome::Faulted`]'s `reason` field documents, +/// applied here at the source so every producer of a bounded string agrees on the bound. +const MAX_ERROR_CHARS: usize = 200; + +fn bounded(message: impl Into) -> String { + let message = message.into(); + if message.chars().count() <= MAX_ERROR_CHARS { + message + } else { + let truncated: String = message.chars().take(MAX_ERROR_CHARS).collect(); + format!("{truncated}... (truncated)") + } +} + +/// Proposes launcher ids for [`RealClaimChainPort::discover_distributors`] to try -- SPEC 13.1 +/// clause 2 needs a chain-wide enumerator and [`ChainSource`] has none of its own. An index only +/// PROPOSES: every id it returns is still re-verified through `dig_rewards_coin::discover_distributor` +/// inside `discover_distributors`, so an index that lies (or is merely stale) yields nothing, +/// never a forged discovery. +#[async_trait] +pub trait LauncherIndex: Send + Sync { + /// The launcher ids this index currently believes are worth trying. May include ids that turn + /// out not to be DIG rewards distributors at all -- that is `discover_distributors`'s filter to + /// apply, not this trait's. + async fn launcher_ids(&self) -> Result, ClaimPortError>; +} + +/// The real, chain-backed [`ClaimChainPort`] -- generic over the [`ChainSource`] (production: +/// [`dig_wallet::sage::corroborated_source::CorroboratedChainSource`], tests: +/// `dig_chainsource_interface::MockChainSource`) and the [`LauncherIndex`] (production: +/// [`HintedLauncherIndex`], tests: a small fixture in this crate's own test binaries). +pub struct RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + source: Arc, + index: I, + broadcaster: Arc, +} + +impl RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + /// Wraps an already-constructed chain source, launcher index and broadcaster. Takes the source + /// by `Arc` (mirroring `rewards::chain_port::RealRewardsChainPort::new`) since a blocking read + /// clones it into a `spawn_blocking` closure on every call. + #[must_use] + pub fn new(source: Arc, index: I, broadcaster: Arc) -> Self { + Self { + source, + index, + broadcaster, + } + } +} + +/// The pure decision [`RealClaimChainPort::own_entry`] delegates to once it has (or has not) +/// found a matching entry slot -- kept separate from the chain read itself so it is unit-testable +/// without a real launch: no entry means `Ok(None)`, honestly; a found entry's accrual is computed +/// via `dig_rewards_coin::accrued_base_units`, the puzzle's own arithmetic (never re-derived here, +/// per DIG-Network/dig_ecosystem#3286) -- `None` from THAT means the arithmetic overflowed or +/// underflowed, refused by name rather than reported as a fabricated `0`. +fn own_entry_from_slot( + payout_puzzle_hash: Bytes32, + constants: &RewardDistributorConstants, + state: &RewardDistributorState, + entry: Option<&RewardDistributorEntrySlotValue>, +) -> Result, ClaimPortError> { + let Some(entry) = entry else { + return Ok(None); + }; + + match dig_rewards_coin::accrued_base_units(constants, state, entry) { + Some(accrued_base_units) => Ok(Some(OwnEntry { + payout_puzzle_hash, + counter: entry.counter, + accrued_base_units, + })), + None => Err(ClaimPortError::Other(bounded( + "accrued amount overflowed the puzzle's arithmetic; refusing rather than reporting 0", + ))), + } +} + +/// Maps a [`GuardedReadError`] (this crate's own `epoch_seconds == 0` refusal, or +/// `dig_rewards_coin::state::read_distributor`'s own error) onto [`ClaimPortError`]. +fn guarded_read_error_to_claim_port_error(error: GuardedReadError) -> ClaimPortError { + match error { + GuardedReadError::NonTerminatingEpochSeconds => ClaimPortError::Other(bounded( + "refused: this distributor's epoch_seconds is 0, which would hang \ + commit_incentives's generation walk (see chain_source.rs's module doc)", + )), + GuardedReadError::Reader(dig_rewards_coin::RewardsError::ChainUnavailable(_)) => { + ClaimPortError::Unavailable + } + GuardedReadError::Reader(other) => ClaimPortError::Other(bounded(other.to_string())), + } +} + +/// Maps a `dig_rewards_coin::RewardsError` from `discover_distributor` onto [`ClaimPortError`] -- +/// the same `ChainUnavailable` split as [`guarded_read_error_to_claim_port_error`], applied to the +/// discovery module's own error type instead of the guarded-read one. +fn rewards_error_to_claim_port_error(error: dig_rewards_coin::RewardsError) -> ClaimPortError { + match error { + dig_rewards_coin::RewardsError::ChainUnavailable(_) => ClaimPortError::Unavailable, + other => ClaimPortError::Other(bounded(other.to_string())), + } +} + +/// Re-derives one launcher id's generation over `source`, verifying it through the real +/// parent-spend memo decode rather than trusting the id alone (SPEC 13.1 clause 6: a discovered +/// distributor's own fields are never caller input). `Ok(None)` covers BOTH "unknown to `source`" +/// and "not a DIG rewards distributor" -- neither is an error (SPEC §1.3). +fn resolve_via_chain( + source: &S, + launcher_id: Bytes32, +) -> Result, ClaimPortError> +where + S: ChainSource, +{ + let discovered = dig_rewards_coin::discover_distributor(source, launcher_id) + .map_err(rewards_error_to_claim_port_error)?; + + Ok(discovered.map(|d| { + let generation = d.generation(); + DiscoveredDistributor { + launcher_id: d.launcher_id(), + store_id: generation.store_id, + root: generation.root, + } + })) +} + +#[async_trait] +impl ClaimChainPort for RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + /// `&'static str` naming this adapter -- see the trait's own doc. Never a default impl, so a + /// new adapter must choose its own name rather than silently inheriting one that describes a + /// different adapter. + fn kind(&self) -> &'static str { + "real-corroborated" + } + + async fn discover_distributors(&self) -> Result, ClaimPortError> { + let candidate_ids = self.index.launcher_ids().await?; + let source = Arc::clone(&self.source); + + tokio::task::spawn_blocking(move || { + let mut discovered = Vec::new(); + for launcher_id in candidate_ids { + // SPEC 13.1 clause 6: the index only PROPOSES; every id is re-verified through the + // real memo decode. An id the decode rejects (unknown to `source`, or a spend that + // is not a DIG rewards launch) is DROPPED, never echoed back. + match resolve_via_chain(source.as_ref(), launcher_id) { + Ok(Some(distributor)) => discovered.push(distributor), + Ok(None) => {} + Err(ClaimPortError::Unavailable) => return Err(ClaimPortError::Unavailable), + Err(other) => return Err(other), + } + } + Ok(discovered) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "discover_distributors task panicked: {join_error}" + ))) + })? + } + + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || resolve_via_chain(source.as_ref(), launcher_id)) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "resolve_launch_comment task panicked: {join_error}" + ))) + })? + } + + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + Ok(snapshot.distributor().info.constants.reserve_asset_id) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "reserve_asset_id task panicked: {join_error}" + ))) + })? + } + + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + // Chain-curried, per SPEC §8.3 -- never `dig_rewards_coin::PAYOUT_THRESHOLD_BASE_UNITS` + // (that constant is this distributor's DEFAULT launch value, not what any given + // on-chain distributor was actually launched with; a distributor with a + // non-default threshold would silently mis-evaluate against the literal). + Ok(dig_rewards_coin::payout::payout_threshold_base_units( + snapshot.distributor(), + )) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "payout_threshold task panicked: {join_error}" + ))) + })? + } + + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + // SPEC §10.2/§12.5: fresh on EVERY call -- no cache field of any kind on this adapter. + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + + let entry = snapshot + .entry_slot(payout_puzzle_hash) + .map_err(rewards_error_to_claim_port_error)?; + own_entry_from_slot( + payout_puzzle_hash, + &snapshot.distributor().info.constants, + &snapshot.distributor().info.state, + entry.map(|slot| &slot.info.value), + ) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!("own_entry task panicked: {join_error}"))) + })? + } + + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + // This adapter attaches no fee coin and signs nothing: `InitiatePayout` is permissionless + // (SPEC §7.1, `require_payout_approval = false`) and the reserve pays out via a + // singleton-delegated announcement, not a fee this node fronts. Node policy, not a chain + // read -- so `0` here is not a fabricated chain answer, it is what this adapter charges. + Ok(0) + } + + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + // This adapter attaches no fee coin (see `required_fee_mojos`'s doc): a non-zero fee has + // nowhere to be paid from here, so refuse by name rather than silently dropping it. + if fee_mojos != 0 { + return Err(ClaimPortError::Other(bounded( + "this adapter attaches no fee coin; required_fee_mojos is 0 and a non-zero fee \ + cannot be paid here", + ))); + } + + let source = Arc::clone(&self.source); + let built = tokio::task::spawn_blocking(move || { + // SPEC §10.2/§12.5: fresh on EVERY call -- the same guarded, authenticated read every + // other method here uses. + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + + // NEVER `snapshot.distributor().created_slot_value_to_slot(..)` -- that derives a + // well-formed but PHANTOM `LineageProof` for a slot an earlier generation created + // (DIG-Network/dig_ecosystem#3357, this module's doc). The entry slot for THIS spend + // comes only from a fresh `ChainEntrySlotSource` walk. + let mut distributor = snapshot.distributor().clone(); + let mut ctx = SpendContext::new(); + let slots = ChainEntrySlotSource::new(source.as_ref(), launcher_id); + + let outcome = initiate_payout(&mut ctx, &mut distributor, &slots, payout_puzzle_hash) + .map_err(rewards_error_to_claim_port_error)?; + + let (_conditions, amount_base_units, counter) = + match outcome { + PayoutOutcome::Paid { + conditions, + amount_base_units, + counter, + } => (conditions, amount_base_units, counter), + PayoutOutcome::EntrySlotAbsent => return Err(ClaimPortError::Other(bounded( + "entry slot absent at submission; the entry set moved between own_entry \ + and submit", + ))), + }; + // `conditions` is a CALLER-SIDE assertion for a coin the caller would add to the same + // bundle (this module's doc) -- this adapter adds none, so it is dropped here rather + // than threaded into a bundle with nothing to satisfy it. + + let (_distributor, signature) = distributor + .finish_spend(&mut ctx, vec![]) + .map_err(|error| ClaimPortError::Other(bounded(error.to_string())))?; + + let bundle = SpendBundle::new(ctx.take(), signature); + Ok::<_, ClaimPortError>((bundle, amount_base_units, counter)) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "submit_initiate_payout task panicked: {join_error}" + ))) + })??; + + let (bundle, amount_base_units, counter) = built; + let coin_spends = bundle.coin_spends.len(); + + self.broadcaster.broadcast(&bundle).await.map_err(|error| { + ClaimPortError::Other(bounded(format!("broadcast refused: {error}"))) + })?; + + tracing::info!( + target: "rewards_claim", + %launcher_id, + amount_base_units, + counter, + coin_spends, + "InitiatePayout submitted" + ); + + Ok(()) + } +} + +/// The production [`LauncherIndex`]: proposes every launcher coin this node's own peers have seen +/// hinted with the DIG rewards distributor hint (SPEC 13.1 clause 5's literal, +/// `"Reward Distributor v1"`, tree-hashed here rather than written as a hash literal), filtered to +/// an actual singleton-launcher coin. +/// +/// Every id this proposes is still re-verified through `discover_distributor` by +/// [`RealClaimChainPort::discover_distributors`] -- a hinted coin that is not really a +/// DIG-rewards-launching singleton launcher yields nothing, it is never trusted directly. +pub struct HintedLauncherIndex { + wallet_chain: Arc, +} + +impl HintedLauncherIndex { + /// Wraps the node's own wallet chain transport -- the SAME `Arc` `server.rs` holds as + /// `state.wallet_chain`. + #[must_use] + pub fn new(wallet_chain: Arc) -> Self { + Self { wallet_chain } + } +} + +#[async_trait] +impl LauncherIndex for HintedLauncherIndex { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + use dig_wallet::sage::fallback::ChainFallback; + + // SPEC 13.1 clause 5: the hint is COMPUTED as the tree hash of the literal string, never a + // hash literal -- the identical discipline `dig_rewards_coin::discovery`'s own decode + // applies to the same constant. + let mut allocator = clvmr::Allocator::new(); + let hint_ptr = clvm_traits::ToClvm::to_clvm(&"Reward Distributor v1", &mut allocator) + .map_err(|error| { + ClaimPortError::Other(bounded(format!( + "could not allocate the launcher hint literal: {error}" + ))) + })?; + let hint: chia_protocol::Bytes32 = clvm_utils::tree_hash(&allocator, hint_ptr).into(); + let hint_hex = hex::encode(hint.to_bytes()); + + let coins = self + .wallet_chain + .coin_records_by_hints(&[hint_hex]) + .await + .map_err(|error| ClaimPortError::Other(bounded(error.to_string())))?; + + let launcher_hash_hex = hex::encode(chia_puzzles::SINGLETON_LAUNCHER_HASH); + + Ok(coins + .into_iter() + .filter(|coin| coin.puzzle_hash == launcher_hash_hex) + .filter_map(|coin| { + hex::decode(&coin.coin_id) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()) + .map(Bytes32::from) + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chia_sdk_driver::{RewardDistributorType, RoundRewardInfo, RoundTimeInfo}; + use chia_sdk_types::puzzles::RewardDistributorEntrySlotValue; + + fn some_constants(precision: u64) -> RewardDistributorConstants { + RewardDistributorConstants { + launcher_id: Bytes32::new([1; 32]), + reward_distributor_type: RewardDistributorType::Managed { + manager_singleton_launcher_id: Bytes32::new([7; 32]), + }, + fee_payout_puzzle_hash: Bytes32::new([2; 32]), + epoch_seconds: 1, + precision, + max_seconds_offset: 0, + payout_threshold: 0, + require_payout_approval: false, + fee_bps: 0, + withdrawal_share_bps: 0, + reserve_asset_id: Bytes32::new([3; 32]), + reserve_inner_puzzle_hash: Bytes32::new([4; 32]), + reserve_full_puzzle_hash: Bytes32::new([5; 32]), + } + } + + fn some_state(cumulative_payout: u128) -> RewardDistributorState { + RewardDistributorState { + total_reserves: 0, + active_shares: 0, + round_reward_info: RoundRewardInfo { + cumulative_payout, + remaining_rewards: 0, + }, + round_time_info: RoundTimeInfo { + last_update: 0, + epoch_end: 0, + }, + } + } + + /// No matching entry slot reads `Ok(None)` -- "no entry", never fabricated. + #[test] + fn no_entry_reads_ok_none() { + let constants = some_constants(100); + let state = some_state(1_000); + assert_eq!( + own_entry_from_slot(Bytes32::from([0x42; 32]), &constants, &state, None), + Ok(None) + ); + } + + /// SHAPE guard: a found entry's accrued amount comes from `dig_rewards_coin::accrued_base_units` + /// -- the puzzle's own arithmetic -- never a fabricated `0`. Mutation-proved: replacing this + /// function's `Some(accrued_base_units)` arm with `Some(0)` turns this test red. + #[test] + fn a_found_entry_reports_the_real_accrued_amount_never_zero() { + let constants = some_constants(100); + let state = some_state(1_000); + let payout_puzzle_hash = Bytes32::from([0x42; 32]); + let entry = RewardDistributorEntrySlotValue { + counter: 1, + payout_puzzle_hash, + initial_cumulative_payout: 200, + shares: 10, + }; + + let result = own_entry_from_slot(payout_puzzle_hash, &constants, &state, Some(&entry)); + + // (1_000 - 200) * 10 / 100 = 80 -- the puzzle's own figure, mirroring + // `dig_rewards_coin::payout`'s own equality-tested arithmetic. + assert_eq!( + result, + Ok(Some(OwnEntry { + payout_puzzle_hash, + counter: 1, + accrued_base_units: 80, + })) + ); + } + + /// A diverged read (`state`'s cumulative payout behind the entry's own) refuses rather than + /// reporting a wrapped or fabricated figure. + #[test] + fn a_diverged_read_refuses_rather_than_wraps() { + let constants = some_constants(100); + let state = some_state(50); + let payout_puzzle_hash = Bytes32::from([0x42; 32]); + let entry = RewardDistributorEntrySlotValue { + counter: 1, + payout_puzzle_hash, + initial_cumulative_payout: 200, + shares: 10, + }; + + let result = own_entry_from_slot(payout_puzzle_hash, &constants, &state, Some(&entry)); + assert!( + matches!(result, Err(ClaimPortError::Other(_))), + "an overflowed/underflowed accrual must refuse by name, never answer Ok at all: \ + got {result:?}" + ); + } + + /// #3357's phantom-slot trap: `RewardDistributor::created_slot_value_to_slot` on a + /// chain-rebuilt distributor derives a well-formed but PHANTOM `LineageProof` for a slot an + /// earlier generation created. This adapter must read every entry slot through + /// `ChainEntrySlotSource`/`snapshot.entry_slot(..)`, never that method. A literal-string check + /// rather than a compile-time one so it still catches the call even via a re-export or a fully + /// qualified path. + /// + /// Scoped to CODE lines only (comment lines, `//`/`///`/`//!`, are dropped first) -- the module + /// doc and this file's own inline warning both name the trap in prose, which must not trip the + /// guard meant to catch an actual call. Also scoped to the file's own non-test region: this + /// test's name/assertion text contains the literal string, so an unscoped scan over the whole + /// file would be self-defeating. + #[test] + fn adapter_source_never_calls_created_slot_value_to_slot() { + let production_src = production_region(include_str!("chain_port.rs")); + let code_only: String = production_src + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + assert!( + !code_only.contains("created_slot_value_to_slot"), + "chain_port.rs must never call created_slot_value_to_slot -- #3357 phantom-slot trap" + ); + } + + /// The slice of this source file before its own `#[cfg(test)]` module -- i.e. what actually + /// ships. Falls back to the whole file if there is no such marker. + fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } + } +} diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 6dbf737f..a62eb998 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -21,13 +21,15 @@ //! runs a cycle, then repeats — the counter is genuinely `0` until the first interval elapses. //! //! # No RPC surface here (SCOPE) -//! [`handle`] is an IN-PROCESS accessor only — a future RPC (blocked on DIG-Network/dig_ecosystem#3249 -//! re-deriving the `ClaimStatus` wire semantics) can read it; this module puts nothing on the wire -//! and adds no RPC method, dispatch-table row or handler. +//! [`handle`] is an IN-PROCESS accessor only — no RPC reads it yet; this module puts nothing on +//! the wire and adds no RPC method, dispatch-table row or handler. //! -//! # The only production adapter is [`super::UnavailableClaimChainPort`] -//! #3249 has not landed, so every real cycle this driver runs reports [`super::ClaimLoopState::ChainSourceUnavailable`] -//! and submits nothing — the honest state, not an invented adapter. +//! # The production adapter is [`super::RealClaimChainPort`] +//! Built from `wallet_chain.corroborated_chain_source(..)` -- the SAME call `server.rs`'s +//! funder-side install already makes -- paired with [`super::HintedLauncherIndex`]. If that source +//! cannot be built (offline, no peers), [`run_claim_driver`] records +//! [`ClaimDriverRefusal::ChainSourceUnbuildable`] and never builds an engine at all, rather than +//! falling back to [`super::UnavailableClaimChainPort`] silently. use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; @@ -37,10 +39,13 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; +use super::chain_port::{HintedLauncherIndex, RealClaimChainPort}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; use super::engine::{ClaimCadences, ClaimEngine, RawConfiguredCadence}; use super::hints::{DistributorHintSource, NoHintSource}; -use super::port::{ClaimChainPort, UnavailableClaimChainPort}; +use super::port::ClaimChainPort; +#[cfg(test)] +use super::port::UnavailableClaimChainPort; use super::types::{ClaimLoopState, ClaimStatus}; /// The in-process accessor onto the running claim loop (SCOPE: never exposed over the wire here). @@ -80,6 +85,15 @@ pub enum ClaimDriverRefusal { /// `enabled = true`, chain sync is on, but this node has no operator wallet to derive /// [`own_payout_puzzle_hash`] from -- there is no puzzle hash to build an engine with at all. NoOperatorWallet, + /// `enabled = true`, chain sync is on, this node HAS an operator wallet, but + /// `wallet_chain.corroborated_chain_source(..)` itself errored (offline, no peers) -- there is + /// no chain source to build [`super::RealClaimChainPort`] with. Never silently substitutes + /// [`super::UnavailableClaimChainPort`] instead (SHAPE: a named refusal, not a fallback). + ChainSourceUnbuildable, + /// `enabled = true`, chain sync is on, this node has a chain source, but + /// `wallet_chain.broadcaster(..)` itself errored -- there is no broadcaster to submit a real + /// `InitiatePayout` spend with. Never silently proceeds without one. + BroadcasterUnbuildable, } impl ClaimLoopHandle { @@ -139,7 +153,7 @@ impl ClaimLoopHandle { /// "not spawned yet" as a THIRD state distinct from `Idle` -- it is the same state, honestly. static HANDLE: OnceLock = OnceLock::new(); -/// The in-process accessor a future RPC (blocked on #3249) reads. Never wired onto the wire here. +/// The in-process accessor a future RPC could read. Never wired onto the wire here. #[must_use] pub fn handle() -> ClaimLoopHandle { HANDLE.get_or_init(ClaimLoopHandle::default).clone() @@ -162,6 +176,7 @@ async fn drive( P: ClaimChainPort, H: DistributorHintSource, { + let adapter = engine.port_kind(); loop { let interval = next_interval_seconds(cadence_seconds, jitter_seconds, jitter); tokio::time::sleep(Duration::from_secs(interval)).await; @@ -169,15 +184,15 @@ async fn drive( engine.run_cycle(t).await; let status = engine.status(); handle.record(status); - log_cycle(&status, handle.cycles_driven(), &adjustment); + log_cycle(&status, handle.cycles_driven(), &adjustment, adapter); } } /// Emit the ONE record that makes a driven cycle observable in a running node. /// /// Without this, the whole status surface has no reader in a shipped binary: [`handle`] is -/// in-process only and deliberately carries no RPC (deferred to DIG-Network/dig_ecosystem#3249), -/// so a node whose claim loop can never claim a single reward would produce output IDENTICAL to a +/// in-process only and deliberately carries no RPC yet, so a node whose claim loop can never +/// claim a single reward would produce output IDENTICAL to a /// healthy one -- silence. A status nobody can read is a doc claim, not a measurement. /// /// [`ClaimLoopState::Nominal`] is the routine case (`info`). Every other state means this peer is @@ -188,7 +203,12 @@ async fn drive( /// named on every single cycle line, not just in the once-per-spawn WARN `sanitized_schedule` /// itself emits. Without this, an operator reading any one cycle log line has no way to learn the /// schedule in force differs from the one they configured. -fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdjustment) { +fn log_cycle( + status: &ClaimStatus, + cycles_driven: u64, + adjustment: &ScheduleAdjustment, + adapter: &'static str, +) { let (configured_cadence_seconds, effective_cadence_seconds) = adjustment .cadence .map_or((None, None), |(c, e)| (Some(c), Some(e))); @@ -199,6 +219,7 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdju if status.state == ClaimLoopState::Nominal { tracing::info!( target: "rewards_claim", + adapter, state = ?status.state, cycles_driven, distributors_known = status.distributors_known, @@ -213,6 +234,7 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdju } else { tracing::warn!( target: "rewards_claim", + adapter, state = ?status.state, cycles_driven, distributors_known = status.distributors_known, @@ -281,15 +303,36 @@ pub fn own_payout_puzzle_hash(owner_inner_puzzle_hash: Bytes32) -> Bytes32 { /// The real, detached claim-loop task: derive this node's own payout puzzle hash from its operator /// wallet (the same public, no-unseal-required derivation [`crate::server::spawn_mirror_passes`] -/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the only -/// production adapter that exists ([`UnavailableClaimChainPort`] -- see this module's doc), and -/// drive it forever. +/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the production +/// [`super::RealClaimChainPort`] adapter (see this module's doc), and drive it forever. /// /// Never called directly by `server.rs` -- see [`spawn_claim_driver_if`], the tested gate that /// decides WHETHER to call this. `handle` is INJECTED (never the [`handle`] singleton read /// directly) so a test can drive this against a private, non-shared handle instead of the /// process-wide one. -async fn run_claim_driver(handle: ClaimLoopHandle) { +/// The production chain-port factory, split out of [`run_claim_driver`] so its return TYPE can be +/// pinned by `const _: fn(...) = production_claim_port;` in the test module -- a retyped factory +/// (e.g. one that starts returning [`super::UnavailableClaimChainPort`]) is then a COMPILE error, +/// not just a string-guard failure. See `run_claim_drivers_happy_path_actually_constructs_the_real_adapter` +/// for the companion source-reading guard, which catches a bypass of this factory entirely. +#[allow(clippy::let_and_return)] // the local `port` binding is the literal string the guard test + // in `production_region` searches this source for. +fn production_claim_port( + source: std::sync::Arc, + index: HintedLauncherIndex, + broadcaster: std::sync::Arc, +) -> RealClaimChainPort< + dig_wallet::sage::corroborated_source::CorroboratedChainSource, + HintedLauncherIndex, +> { + let port = RealClaimChainPort::new(source, index, broadcaster); + port +} + +async fn run_claim_driver( + handle: ClaimLoopHandle, + wallet_chain: std::sync::Arc, +) { let paths = dig_wallet::autoseed::default_paths(); let Some(owner_inner_puzzle_hash) = dig_wallet::operator_wallet::operator_puzzle_hash(&paths) else { @@ -304,10 +347,50 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { }; let own_payout_puzzle_hash = own_payout_puzzle_hash(owner_inner_puzzle_hash); + // The SAME call server.rs's funder-side install already makes -- see this module's doc. + // A named refusal, never a silent fallback to `UnavailableClaimChainPort`. + let source = match wallet_chain.corroborated_chain_source(tokio::runtime::Handle::current()) { + Ok(source) => source, + Err(error) => { + tracing::warn!( + target: "rewards_claim", + %error, + "could not build a corroborated chain source, so this node has no chain to claim \ + against; the claim loop is NOT started -- rewards_claim.enabled stays true but no \ + cycle will ever run until this node has peer reads to corroborate against" + ); + handle.set_refusal(ClaimDriverRefusal::ChainSourceUnbuildable); + return; + } + }; + + // A named refusal, never a silent proceed-without-broadcast -- see `ClaimDriverRefusal`'s doc. + let broadcaster = match wallet_chain.broadcaster().await { + Ok(broadcaster) => broadcaster, + Err(error) => { + tracing::warn!( + target: "rewards_claim", + %error, + "could not build a broadcaster, so this node has no way to submit a real \ + InitiatePayout spend; the claim loop is NOT started -- rewards_claim.enabled \ + stays true but no cycle will ever run until this node can broadcast" + ); + handle.set_refusal(ClaimDriverRefusal::BroadcasterUnbuildable); + return; + } + }; + + let port = production_claim_port( + std::sync::Arc::new(source), + HintedLauncherIndex::new(wallet_chain), + broadcaster, + ); + run_claim_driver_in( &crate::state::state_dir(), own_payout_puzzle_hash, - UnavailableClaimChainPort, + dig_mirror_coin::DIG_ASSET_ID, + port, handle, ) .await; @@ -320,9 +403,8 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// tested gate and the tested [`drive`] loop was previously the only UNTESTED link in the chain, /// and an untested joint is exactly how #594's claim engine shipped complete and inert. /// -/// Generic over `P` so a test can drive this real body against a fake port; production always -/// passes [`UnavailableClaimChainPort`] (see the module doc -- there is deliberately no second -/// production adapter until #3249 lands). +/// Generic over `P` so a test can drive this real body against a fake port; production passes +/// [`super::RealClaimChainPort`] (see the module doc). /// The largest schedule value this driver will honour, in seconds: 31 days. Chosen to sit /// comfortably above every documented default -- [`CLAIM_CADENCE_SECONDS_DEFAULT`] is 86,400s /// (1 day) and [`CLAIM_JITTER_SECONDS_DEFAULT`] is 3,600s (1 hour) -- and above any plausible @@ -431,9 +513,15 @@ fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64, S (cadence, jitter, adjustment) } -async fn run_claim_driver_in

( +/// Public ONLY for DIG-Network/dig_ecosystem#3347's acceptance integration test +/// (`tests/rewards_claim_chain_port_3347.rs`), which needs to drive the real production body end +/// to end against a real `RealClaimChainPort`. Not part of this crate's public API otherwise -- +/// every other caller reaches this exclusively through [`spawn_claim_driver_from_config`]. +#[doc(hidden)] +pub async fn run_claim_driver_in

( state_dir: &Path, own_payout_puzzle_hash: Bytes32, + reserve_asset_id: Bytes32, port: P, handle: ClaimLoopHandle, ) where @@ -442,6 +530,7 @@ async fn run_claim_driver_in

( run_claim_driver_in_with_clock( state_dir, own_payout_puzzle_hash, + reserve_asset_id, port, handle, unix_now_seconds, @@ -469,6 +558,7 @@ async fn run_claim_driver_in

( async fn run_claim_driver_in_with_clock

( state_dir: &Path, own_payout_puzzle_hash: Bytes32, + reserve_asset_id: Bytes32, port: P, handle: ClaimLoopHandle, now: impl FnMut() -> u64, @@ -496,7 +586,7 @@ async fn run_claim_driver_in_with_clock

( own_payout_puzzle_hash, cfg.max_fee_mojos, cfg.max_cycle_fee_budget_mojos, - dig_mirror_coin::DIG_ASSET_ID, + reserve_asset_id, ) .with_rotation_cursor(cfg.rotation_cursor) // F2 (money): ONE argument, and it must be the RAW `cfg.cadence_seconds`. `ClaimCadences` @@ -528,8 +618,11 @@ async fn run_claim_driver_in_with_clock

( /// Spawn the real claim-loop task, detached, against `handle` -- injected, never the [`handle`] /// singleton read from inside, so the only place the process-wide singleton is named is /// [`spawn_claim_driver_from_config`]. -fn spawn_claim_driver(handle: ClaimLoopHandle) { - tokio::spawn(run_claim_driver(handle)); +fn spawn_claim_driver( + handle: ClaimLoopHandle, + wallet_chain: std::sync::Arc, +) { + tokio::spawn(run_claim_driver(handle, wallet_chain)); } /// Why [`spawn_claim_driver_if`] declined to spawn -- named so the caller can log a reason instead @@ -596,14 +689,20 @@ fn spawn_claim_driver_if( /// Reads [`RewardsClaimConfig::load`] (the node's own state-dir config) and `enable_chain_sync`, /// and calls [`spawn_claim_driver_if`] -- the exact one call `serve_with_shutdown` makes. -pub fn spawn_claim_driver_from_config(enable_chain_sync: bool) { +/// `wallet_chain` is this node's own wallet chain transport, the SAME `Arc` `server.rs` holds as +/// `state.wallet_chain` -- threaded through to [`run_claim_driver`] to build the production +/// [`super::RealClaimChainPort`]. +pub fn spawn_claim_driver_from_config( + enable_chain_sync: bool, + wallet_chain: std::sync::Arc, +) { let cfg = RewardsClaimConfig::load(); // The ONE place the process-wide singleton is read: everything below it takes an injected // handle so it stays testable in-process. let process_handle = handle(); let driver_handle = process_handle.clone(); spawn_claim_driver_if(cfg.enabled, enable_chain_sync, &process_handle, move || { - spawn_claim_driver(driver_handle); + spawn_claim_driver(driver_handle, wallet_chain); }); } @@ -738,7 +837,11 @@ mod tests { return; // this machine HAS an operator wallet; the refusal branch is unreachable here } let handle = ClaimLoopHandle::default(); - run_claim_driver(handle.clone()).await; + run_claim_driver( + handle.clone(), + Arc::new(dig_wallet::sage::chain::ChainTransport::new()), + ) + .await; assert_eq!( handle.refusal(), Some(ClaimDriverRefusal::NoOperatorWallet), @@ -747,6 +850,135 @@ mod tests { assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); } + /// SHAPE guard: when this node HAS an operator wallet but its wallet chain transport cannot + /// build a corroborated source (offline, no peers -- `ChainTransport::new()`'s bare default), + /// `run_claim_driver` must record the named `ChainSourceUnbuildable` refusal, never fall back + /// to `UnavailableClaimChainPort` silently. Skipped on a machine with no operator wallet at + /// all -- that is the OTHER, earlier refusal, proven above. + #[tokio::test] + async fn an_unbuildable_chain_source_is_a_distinct_named_refusal() { + let paths = dig_wallet::autoseed::default_paths(); + if dig_wallet::operator_wallet::operator_puzzle_hash(&paths).is_none() { + return; // no operator wallet on this machine: the earlier refusal fires first + } + let handle = ClaimLoopHandle::default(); + run_claim_driver( + handle.clone(), + Arc::new(dig_wallet::sage::chain::ChainTransport::new()), + ) + .await; + assert_eq!( + handle.refusal(), + Some(ClaimDriverRefusal::ChainSourceUnbuildable), + "a chain transport with no peer reads must be a named refusal, not a silent \ + UnavailableClaimChainPort substitution" + ); + assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); + } + + /// SHAPE (6d, the #3310-class trap "compiles, nothing constructs it"): [`RealClaimChainPort`] + /// itself, built against a mock source with the fixture-provided [`LauncherIndex`], names + /// itself `"real-corroborated"`. This does NOT exercise `run_claim_driver`'s own construction + /// line -- see `run_claim_drivers_happy_path_actually_constructs_the_real_adapter` below for + /// the guard on THAT (this machine has no operator wallet, so `run_claim_driver`'s happy path + /// cannot be driven end to end here; that guard reads its own shipped source instead). + #[tokio::test] + async fn the_production_factory_builds_a_real_corroborated_port() { + struct NoLauncherIds; + #[async_trait] + impl super::super::chain_port::LauncherIndex for NoLauncherIds { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + } + + let source = dig_chainsource_interface::MockChainSource::new(); + let port = RealClaimChainPort::new( + Arc::new(source), + NoLauncherIds, + Arc::new(dig_wallet::sage::spend::MockBroadcaster::default()), + ); + assert_eq!( + ClaimChainPort::kind(&port), + "real-corroborated", + "the production adapter must name itself, not inherit UnavailableClaimChainPort's name" + ); + } + + /// SHAPE guard: `run_claim_driver`'s happy-path construction line must actually build + /// [`RealClaimChainPort`], never [`UnavailableClaimChainPort`] -- checked by reading this + /// module's own SHIPPED source (the production region, before this `#[cfg(test)]` module), + /// the same shape `rewards_chain_port_a3.rs`'s `install_reward_chain_port_refuses_a_second_install_with_a_warn` + /// and `chain_port.rs`'s `adapter_source_never_imports_withdraw_committed_incentives` already + /// use for a call site no test on this machine can drive behaviourally (this machine has no + /// operator wallet, so `run_claim_driver`'s happy path -- past both named refusals -- is + /// unreachable here). Mutation-proved: replacing the production `let port = + /// RealClaimChainPort::new(` line with `UnavailableClaimChainPort` turns this assertion red. + #[test] + fn run_claim_drivers_happy_path_actually_constructs_the_real_adapter() { + let source = production_region(include_str!("driver.rs")); + assert!( + source.contains("let port = RealClaimChainPort::new("), + "run_claim_driver's happy path must construct RealClaimChainPort, not silently fall \ + back to UnavailableClaimChainPort or anything else" + ); + } + + /// #3347/U3 SHAPE guard: `run_claim_driver`, the only production caller of + /// [`run_claim_driver_in`], must pass the REAL reserve asset, `dig_mirror_coin::DIG_ASSET_ID` + /// -- never a placeholder like `Bytes32::default()`, which would silently make the engine + /// treat every real distributor as `NotOurs`. Same shape as + /// `run_claim_drivers_happy_path_actually_constructs_the_real_adapter` above: a call site no + /// test on this machine can drive behaviourally (no operator wallet here), so read the + /// SHIPPED source instead. Mutation-proved: replacing the production + /// `dig_mirror_coin::DIG_ASSET_ID` argument with `Bytes32::default()` turns this assertion red. + #[test] + fn run_claim_driver_passes_the_real_reserve_asset_id() { + // CRLF-normalized: this file is checked out with `\r\n` line endings, which would break a + // literal `\n`-joined needle otherwise. + let source = production_region(include_str!("driver.rs")).replace("\r\n", "\n"); + assert!( + source.contains( + "run_claim_driver_in(\n &crate::state::state_dir(),\n \ + own_payout_puzzle_hash,\n dig_mirror_coin::DIG_ASSET_ID," + ), + "run_claim_driver must pass dig_mirror_coin::DIG_ASSET_ID as run_claim_driver_in's \ + reserve_asset_id argument, not a placeholder" + ); + } + + /// The slice of this file before its own `#[cfg(test)] mod tests` block -- i.e. what actually + /// ships. Searches for `"#[cfg(test)]\nmod tests"` specifically, never the bare + /// `"#[cfg(test)]"` marker: this file also has an EARLIER `#[cfg(test)] use` gating a single + /// test-only import, which the bare marker would match first and cut the slice off far too + /// early, before the very production code this helper exists to check. + fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]\nmod tests") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } + } + + /// Compile-time companion to `run_claim_drivers_happy_path_actually_constructs_the_real_adapter`: + /// pins [`production_claim_port`]'s TYPE, not just its source text. The string guard above + /// catches a bypass of the factory (some other construction spliced into `run_claim_driver`); + /// this catches the factory itself being RETYPED to return + /// [`super::UnavailableClaimChainPort`] (or anything else) -- a change the string guard cannot + /// see because `UnavailableClaimChainPort`'s own construction line would satisfy no textual + /// assertion this file makes, but a retyped factory would still compile and run. Mutation-proved: + /// changing `production_claim_port`'s return type is a compile error here. + type ProductionClaimPortFactory = fn( + std::sync::Arc, + HintedLauncherIndex, + std::sync::Arc, + ) -> RealClaimChainPort< + dig_wallet::sage::corroborated_source::CorroboratedChainSource, + HintedLauncherIndex, + >; + + #[allow(dead_code)] // referenced only for its type, never called + const _: ProductionClaimPortFactory = production_claim_port; + // ---- A1 + A2: the anti-silence cycle counter through the real drive() loop ------------- /// A fake port whose every call succeeds with an empty/zero answer -- enough to let @@ -791,6 +1023,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Ok(()) } + + fn kind(&self) -> &'static str { + "test-empty" + } } fn empty_engine() -> ClaimEngine { @@ -952,6 +1188,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Ok(()) } + + fn kind(&self) -> &'static str { + "test-one-distributor" + } } #[tokio::test] @@ -1307,7 +1547,14 @@ mod tests { let h = handle.clone(); let state_dir = dir.path().to_path_buf(); let driver = tokio::spawn(async move { - run_claim_driver_in(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h).await; + run_claim_driver_in( + &state_dir, + Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, + EmptyPort, + h, + ) + .await; }); settle().await; @@ -1339,12 +1586,11 @@ mod tests { driver.abort(); } - /// The same production body against the port production ACTUALLY passes it - /// ([`UnavailableClaimChainPort`], the only adapter until #3249) reports - /// [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven -- the - /// honest state of a real node today. Proves the real adapter path is reached, not only a fake - /// one: a counted cycle whose outcome names the missing chain source, never a reassuring - /// `Nominal` and never silence. + /// The same production body [`run_claim_driver_in`] runs, against [`UnavailableClaimChainPort`] + /// (now only the engine's test double -- production passes [`super::RealClaimChainPort`]), + /// reports [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven: a + /// counted cycle whose outcome names the missing chain source, never a reassuring `Nominal` + /// and never silence. #[tokio::test(start_paused = true)] async fn the_production_adapter_reports_chain_source_unavailable_by_name() { let cadence = 100u64; @@ -1358,6 +1604,7 @@ mod tests { run_claim_driver_in( &state_dir, Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, UnavailableClaimChainPort, h, ) @@ -1421,13 +1668,20 @@ mod tests { let h = handle.clone(); let state_dir = dir.path().to_path_buf(); let driver = tokio::spawn(async move { - run_claim_driver_in_with_clock(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h, { - let mut t = 0u64; - move || { - t += effective_cadence; - t - } - }) + run_claim_driver_in_with_clock( + &state_dir, + Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, + EmptyPort, + h, + { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }, + ) .await; }); @@ -1839,7 +2093,14 @@ mod tests { let h = handle.clone(); let state_dir = dir.path().to_path_buf(); let driver = tokio::spawn(async move { - run_claim_driver_in(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h).await; + run_claim_driver_in( + &state_dir, + Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, + EmptyPort, + h, + ) + .await; }); settle().await; diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index df110ab5..9aea1184 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -231,6 +231,13 @@ impl ClaimEngine { self.rotation_cursor } + /// Which [`ClaimChainPort`] adapter this engine is driving against -- surfaced so a running + /// node's own log line can name it (DIG-Network/dig_ecosystem#3347), never left implicit. + #[must_use] + pub fn port_kind(&self) -> &'static str { + self.port.kind() + } + /// 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). @@ -979,7 +986,8 @@ mod tests { } /// 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. + /// seam" section) — #3249 landed `RealClaimChainPort` as the production implementer of this + /// trait; this fake stays as the engine's own unit-test double. struct FakeChainPort { distributors: Mutex>, submitted: Mutex>, @@ -1140,6 +1148,10 @@ mod tests { .push((launcher_id, payout_puzzle_hash, fee_mojos)); Ok(()) } + + fn kind(&self) -> &'static str { + "test-fake" + } } fn one_distributor( @@ -1452,6 +1464,10 @@ mod tests { ) -> Result<(), ClaimPortError> { self.0.submit_initiate_payout(l, p, f).await } + + fn kind(&self) -> &'static str { + self.0.kind() + } } let port = HintOnlyPort(FakeChainPort::new(vec![d])); @@ -1537,6 +1553,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Err(ClaimPortError::Other("unreachable".into())) } + + fn kind(&self) -> &'static str { + "test-always-faulting-discovery" + } } /// Defect A1/A2 regression -- THE anti-green test for this defect: a port that errors on @@ -2850,6 +2870,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.inner.kind() + } } /// **F1 regression -- the anti-latch test.** `ChainSourceUnavailable` must be a PER-CYCLE @@ -2955,6 +2979,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.inner.kind() + } } /// **F3 regression -- staleness under a fresh timestamp.** Cycle 1 is healthy and submits a @@ -3074,6 +3102,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.0.kind() + } } /// **F4 (non-blocking, cheap) -- a duplicated launcher id must submit EXACTLY ONCE.** Without diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index 7804be4c..d4714c58 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -19,33 +19,35 @@ //! //! # 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. +//! `dig-rewards-coin` 0.8.0 ships a real driver (`discovery`, `payout`, `state`), landed by +//! DIG-Network/dig_ecosystem#3249 and extended for DIG-Network/dig_ecosystem#3347. The production +//! adapter is [`RealClaimChainPort`] (`chain_port.rs`), built over this node's own corroborated +//! chain source and a real [`dig_wallet::sage::spend::Broadcaster`]; [`UnavailableClaimChainPort`] +//! remains only as the engine's test double. `own_entry` reads the real accrued amount via +//! `dig_rewards_coin::accrued_base_units`, and `submit_initiate_payout` builds, signs and +//! broadcasts a real `InitiatePayout` spend via `dig_rewards_coin::payout::initiate_payout` and +//! `RewardDistributor::finish_spend` — see `chain_port.rs`'s own module doc for the #3357 +//! phantom-slot trap both must avoid. //! //! 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. +//! prevent (SPEC §2.4): a refused method reports a NAMED [`ClaimPortError`] or +//! [`super::types::ClaimOutcome::Faulted`], never a fabricated success. //! //! # Wired into node startup (DIG-Network/dig_ecosystem#3268) //! [`driver::spawn_claim_driver_from_config`] is the one call `dig-node-service::server`'s //! `serve_with_shutdown` makes: it is gated on `RewardsClaimConfig::enabled` AND //! `Config::enable_chain_sync` (the same flag `spawn_collateral_census` and //! `mirror::bond_verify::spawn_bond_verifier_install` already gate on), and when both are true it -//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever. -//! [`driver::handle`] is the IN-PROCESS accessor a future RPC can read once DIG-Network/dig_ecosystem#3249 -//! lands a real [`ClaimChainPort`] adapter and the `ClaimStatus` wire semantics are re-derived -//! against it — this module puts nothing on the wire itself (see `driver`'s own module doc for -//! why). Until #3249 lands, the only production adapter is still [`UnavailableClaimChainPort`], so -//! every real cycle reports [`ClaimLoopState::ChainSourceUnavailable`] and submits nothing — the -//! honest state, not a silent no-op. +//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever, +//! over a [`RealClaimChainPort`] built from `state.wallet_chain`'s corroborated source. If that +//! source cannot be built (offline, no peers), the loop reports the named refusal +//! `ClaimDriverRefusal::ChainSourceUnbuildable` and runs zero cycles rather than installing +//! [`UnavailableClaimChainPort`] silently. [`driver::handle`] is the IN-PROCESS accessor a future +//! RPC can read against the `ClaimStatus` wire semantics — this module puts nothing on the wire +//! itself (see `driver`'s own module doc for why). mod cadence; +mod chain_port; mod config; mod driver; mod engine; @@ -55,11 +57,15 @@ mod port; mod types; pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +pub use chain_port::{HintedLauncherIndex, LauncherIndex, RealClaimChainPort}; pub use config::{ RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, }; -pub use driver::{handle, spawn_claim_driver_from_config, ClaimDriverRefusal, ClaimLoopHandle}; +pub use driver::{ + handle, run_claim_driver_in, spawn_claim_driver_from_config, ClaimDriverRefusal, + ClaimLoopHandle, +}; pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; pub use parser::parse_launch_comment; diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index f5988f52..9a4c57a3 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -67,11 +67,17 @@ pub trait ClaimChainPort: Send + Sync { payout_puzzle_hash: Bytes32, fee_mojos: u64, ) -> Result<(), ClaimPortError>; + + /// Names which adapter is installed, so a running node's own log line can say which one -- + /// no default impl, so a new adapter must choose its own name rather than silently inheriting + /// one that describes a different adapter. + fn kind(&self) -> &'static str; } -/// 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. +/// The engine's test double: reports [`ClaimPortError::Unavailable`] on every call and runs zero +/// cycles — the named state `ChainSourceUnavailable` (see the module doc), never a silent no-op. +/// No production path constructs this any more; the real adapter is +/// [`super::chain_port::RealClaimChainPort`]. pub struct UnavailableClaimChainPort; #[async_trait] @@ -115,6 +121,10 @@ impl ClaimChainPort for UnavailableClaimChainPort { ) -> Result<(), ClaimPortError> { Err(ClaimPortError::Unavailable) } + + fn kind(&self) -> &'static str { + "unavailable" + } } #[cfg(test)] diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index ecb5b3bf..8a291270 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2231,16 +2231,25 @@ where // (a tested unit, #1864) so it cannot be silently flipped to always- or never-spawn. crate::self_heal::spawn_driver_if_service(); - // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251): drives + // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251, #3347): drives // `rewards_claim::ClaimEngine::run_cycle` on a jittered cadence so // `RewardsClaimConfig::enabled = true` stops being a false statement. Gated the same way the // census and bond-verifier spawns above are -- `enable_chain_sync` already means "this node // talks to the Chia network", and a harness sets it false precisely so nothing dials. The // service-gate lives inside the seam (a tested unit, mirroring `self_heal::spawn_driver_if`) - // so it cannot be silently flipped to always- or never-spawn. The only production chain - // adapter until DIG-Network/dig_ecosystem#3249 lands is `UnavailableClaimChainPort`, so every - // real cycle reports `ChainSourceUnavailable` and submits nothing -- the honest state. - crate::rewards_claim::spawn_claim_driver_from_config(config.enable_chain_sync); + // so it cannot be silently flipped to always- or never-spawn. The production chain adapter is + // `rewards_claim::RealClaimChainPort`, built from this node's own `wallet_chain`'s + // corroborated source (the same call the funder-side reward-chain-port install above makes) -- + // a source that fails to build is a named refusal (`ClaimDriverRefusal::ChainSourceUnbuildable`), + // paired with a real `Broadcaster` (`ClaimDriverRefusal::BroadcasterUnbuildable` if that fails + // to build), never a silent `UnavailableClaimChainPort` substitution. `own_entry`'s accrued + // amount and `submit_initiate_payout` are both real over `dig-rewards-coin` 0.8.0 + // (DIG-Network/dig_ecosystem#3347): a chain-backed spendable entry slot via + // `dig_rewards_coin::ChainEntrySlotSource` and a real `InitiatePayout` broadcast. + crate::rewards_claim::spawn_claim_driver_from_config( + config.enable_chain_sync, + state.wallet_chain.clone(), + ); // Best-effort wallet mTLS listener (#368, Sage byte-parity, node-class clients, §5.3). Binds // loopback only on [`DEFAULT_MTLS_PORT`], which is deliberately NOT Sage's own RPC port diff --git a/crates/dig-node-service/tests/common/mod.rs b/crates/dig-node-service/tests/common/mod.rs new file mode 100644 index 00000000..4f1d11ff --- /dev/null +++ b/crates/dig-node-service/tests/common/mod.rs @@ -0,0 +1,4 @@ +//! Shared integration-test fixtures — deliberately thin: each submodule owns one fixture, no +//! cross-fixture coupling. + +pub mod rewards_fixture; diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs new file mode 100644 index 00000000..964bbb66 --- /dev/null +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -0,0 +1,740 @@ +//! A real DIG rewards distributor, launched once against `chia-sdk-test`'s peer simulator, and a +//! `MockChainSource` loaded from that real state — shared between +//! `tests/rewards_chain_port_a3.rs` (DIG-Network/dig_ecosystem#3310) and +//! `tests/rewards_claim_chain_port_3347.rs` (DIG-Network/dig_ecosystem#3347), which both need the +//! SAME real, decodable launch rather than two independently hand-rolled ones. See +//! `rewards_chain_port_a3.rs`'s original module doc (still the fixture's own doc below) for why +//! this substitution (the network transport, nothing else) is sound. + +use chia_protocol::{Bytes32, Coin, CoinSpend, SpendBundle}; +use chia_puzzle_types::singleton::{SingletonArgs, SingletonSolution}; +use chia_puzzle_types::CoinProof; +use chia_puzzle_types::Memos; +use chia_puzzle_types::{EveProof, LineageProof, Proof}; +use chia_puzzles::{SETTLEMENT_PAYMENT_HASH, SINGLETON_LAUNCHER_HASH}; +use chia_sdk_driver::{ + sign_standard_transaction, Cat, CatSpend, Launcher, Offer, RewardDistributorConstants, + RewardDistributorType, SingleCatSpend, Slot, Spend, SpendContext, SpendWithConditions, + StandardLayer, +}; +use chia_sdk_test::Simulator; +use chia_sdk_types::puzzles::{RewardDistributorRewardSlotValue, RewardDistributorSlotNonce}; +use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; +use clvm_traits::{clvm_quote, ToClvm}; +use clvmr::NodePtr; +use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_rewards_coin::comment::LaunchComment; +use dig_rewards_coin::constants::{ + MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, +}; +use dig_rewards_coin::eligibility::{judge_candidate, EligibilityQuestion, MirrorCoinFacts}; +use dig_rewards_coin::entries::{add_entry, ManagerAuthority}; +use dig_rewards_coin::epoch::{start_next_distributor_epoch, sync_distributor}; +use dig_rewards_coin::fund::commit_incentives_for_distributor_epoch; +use dig_rewards_coin::launch::launch_dig_distributor; + +/// Small on purpose: the simulator's clock starts at zero. +pub const FIRST_EPOCH_START: u64 = 1_234; +/// A short epoch; these tests are about a report's/adapter's fields, not the epoch length. +pub const TEST_EPOCH_SECONDS: u64 = 1_000; +/// $DIG the funder mints for itself. +const MINTED_BASE_UNITS: u64 = 10_000_000_000; + +/// A fixed, never-launched manager singleton launcher id: curried into the constants table for +/// shape only, never read back off chain by `read_distributor`. +const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); + +/// The `store_id`/`root` this fixture's launch comment carries — asserted against by both +/// consumers of this fixture. +pub const LAUNCH_STORE_ID: Bytes32 = Bytes32::new([0xaa; 32]); +pub const LAUNCH_ROOT: Bytes32 = Bytes32::new([0xbb; 32]); + +/// Everything a real launch produced, named rather than positional. +pub struct LaunchedFixture { + pub sim: Simulator, + pub launcher_id: Bytes32, + pub security_coin_id: Bytes32, + pub distributor_coin_id: Bytes32, + pub reserve_coin_id: Bytes32, + pub reserve_launch_id: Bytes32, + pub reserve_parent_id: Bytes32, + pub launch_comment: LaunchComment, + pub constants: RewardDistributorConstants, +} + +/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via +/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from +/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. +pub fn launch_fixture() -> Result> { + let ctx = &mut SpendContext::new(); + let mut sim = Simulator::new(); + + let funder = sim.bls(MINTED_BASE_UNITS); + let funder_p2 = StandardLayer::new(funder.pk); + let (issue_cat, source_cats) = Cat::single_issuance( + ctx, + funder.coin.coin_id(), + None, + MINTED_BASE_UNITS, + Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), + )?; + funder_p2.spend(ctx, funder.coin, issue_cat)?; + let source_cat = source_cats[0]; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + + let offer_amount = 1; + let launcher_bls = sim.bls(offer_amount); + let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + + let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + SETTLEMENT_PAYMENT_HASH.into(), + source_cat.coin.amount, + Memos::None + )) + .to_clvm(ctx)?; + let cat_inner_spend = funder_p2.delegated_inner_spend( + ctx, + Spend { + puzzle: cat_inner_puzzle, + solution: NodePtr::NIL, + }, + )?; + source_cat.spend( + ctx, + SingleCatSpend { + prev_coin_id: source_cat.coin.coin_id(), + next_coin_proof: CoinProof { + parent_coin_info: source_cat.coin.parent_coin_info, + inner_puzzle_hash: funder.puzzle_hash, + amount: source_cat.coin.amount, + }, + prev_subtotal: 0, + extra_delta: 0, + p2_spend: cat_inner_spend, + revoke: false, + }, + )?; + + let spends = ctx.take(); + let cat_offer_spend = spends + .iter() + .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) + .expect("the CAT offer spend") + .clone(); + for spend in spends { + if spend.coin.coin_id() != source_cat.coin.coin_id() { + ctx.insert(spend); + } + } + + let signature = sign_standard_transaction( + ctx, + launcher_bls.coin, + offer_spend, + &launcher_bls.sk, + &TESTNET11_CONSTANTS, + )?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle { + coin_spends: vec![ + CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), + cat_offer_spend, + ], + aggregated_signature: signature, + }, + )?; + + let constants = RewardDistributorConstants::without_launcher_id( + RewardDistributorType::Managed { + manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, + }, + funder.puzzle_hash, + TEST_EPOCH_SECONDS, + u64::MAX, + MAX_SECONDS_OFFSET, + // Deliberately NOT `PAYOUT_THRESHOLD_BASE_UNITS` (the distributor's default launch + // value) -- #3347 mutation proof (iv) needs a fixture whose on-chain threshold DIFFERS + // from the default, or a port that ignores the chain and returns the default constant + // reads as correct by coincidence. See `reserve_asset_id_and_payout_threshold_are_read_from_chain`. + PAYOUT_THRESHOLD_BASE_UNITS.saturating_add(1_000_000), + false, + 0, + WITHDRAWAL_SHARE_BPS, + source_cat.info.asset_id, + ); + + let launch_comment = LaunchComment::new(LAUNCH_STORE_ID, LAUNCH_ROOT); + + let launched = launch_dig_distributor( + ctx, + &offer, + FIRST_EPOCH_START, + constants, + &TESTNET11_CONSTANTS, + launch_comment, + // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. + 0, + )?; + + sim.spend_coins( + ctx.take(), + &[ + launcher_bls.sk.clone(), + launched.security_coin_secret_key.clone(), + funder.sk.clone(), + ], + )?; + + let launcher_id = launched.distributor.info.constants.launcher_id; + let distributor_coin_id = launched.distributor.coin.coin_id(); + let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); + let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; + let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); + + // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. + // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record + // up after the fact, rather than tracking the security coin id through the launch machinery + // by hand. + let security_coin_id = sim + .coin_state(launcher_id) + .expect("the launcher coin was confirmed by the launch spend") + .coin + .parent_coin_info; + + Ok(LaunchedFixture { + sim, + launcher_id, + security_coin_id, + distributor_coin_id, + reserve_coin_id, + reserve_launch_id, + reserve_parent_id, + launch_comment, + constants: launched.distributor.info.constants, + }) +} + +/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what +/// `read_distributor_guarded`/`read_launch_comment` read — mirrors +/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. +pub fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { + let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; + + // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it + // named directly. + let eve_coin_id = fixture + .sim + .children(fixture.launcher_id) + .first() + .map(|state| state.coin.coin_id()); + + let extra_ids = [ + fixture.security_coin_id, + fixture.reserve_launch_id, + fixture.reserve_parent_id, + fixture.reserve_coin_id, + ]; + + let mut source = MockChainSource::new(); + for id in singleton_members + .iter() + .copied() + .chain(extra_ids.iter().copied()) + .chain(eve_coin_id) + { + if let Some(state) = fixture.sim.coin_state(id) { + source = source.with_coin(id, CoinRecord::from_coin_state(state)); + } + if let Some(spend) = fixture.sim.coin_spend(id) { + source = source.with_spend(id, spend); + } + } + + source = source.with_lineage( + fixture.launcher_id, + SingletonLineage::new( + fixture.distributor_coin_id, + singleton_members.iter().copied(), + ), + ); + + let peak = fixture.sim.height(); + for height in 0..=peak { + source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + } + source.with_peak(peak) +} + +// --------------------------------------------------------------------------------------------- +// A FUNDED, ADMITTED fixture -- DIG-Network/dig_ecosystem#3347's U2. `launch_fixture` above never +// spawns a real manager singleton (its `DUMMY_MANAGER_LAUNCHER_ID` is curried for shape only), so +// it cannot authorize an `AddEntry`. This second fixture launches a REAL manager singleton, commits +// incentives, admits one entry, rolls the epoch and syncs mid-epoch -- ported, line for line in +// spirit, from `dig-rewards-coin` 0.8.0's own +// `tests/simulator.rs::a_claim_built_entirely_from_a_chain_read_is_accepted` (the crate's own proof +// that a claim built entirely from a chain read is accepted by the simulator). +// --------------------------------------------------------------------------------------------- + +/// $DIG committed to the first epoch -- the SAME figure `dig-rewards-coin`'s own golden test uses. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +const COMMITTED_BASE_UNITS: u64 = 1_000_000; + +/// The mirror-collateral epoch [`verdict_for`] judges against. Any ordinal will do; what matters is +/// that the same one is asked and advertised. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +const TEST_MIRROR_COLLATERAL_EPOCH: u32 = 7; + +/// A mirror coin that passes every eligibility check and pays out to one hash -- mirrors +/// `dig-rewards-coin`'s own `EligibleMirrorCoin` test double. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +struct EligibleMirrorCoin { + payout_puzzle_hash: Bytes32, +} + +impl MirrorCoinFacts for EligibleMirrorCoin { + fn advertises(&self, _store: Bytes32, _root: Bytes32, mirror_collateral_epoch: u32) -> bool { + mirror_collateral_epoch == TEST_MIRROR_COLLATERAL_EPOCH + } + + fn declares_peer(&self, _peer_id: Bytes32) -> bool { + true + } + + fn owner_puzzle_hash(&self) -> Bytes32 { + self.payout_puzzle_hash + } +} + +/// Judge a candidate whose mirror coin pays out to `payout_puzzle_hash`, and take the verdict -- +/// the only way `add_entry` can be handed a payout hash at all. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn verdict_for(payout_puzzle_hash: Bytes32) -> dig_rewards_coin::eligibility::EligiblePayoutHash { + let question = EligibilityQuestion { + store_launcher_id: LAUNCH_STORE_ID, + root_hash: LAUNCH_ROOT, + mirror_collateral_epoch: TEST_MIRROR_COLLATERAL_EPOCH, + }; + let coin = EligibleMirrorCoin { payout_puzzle_hash }; + + judge_candidate(question, Bytes32::new([0xcc; 32]), Some(&coin)) + .expect("the epoch is established") + .expect("every eligibility check passes") +} + +/// A test manager singleton with an inner puzzle of `1` -- mirrors `dig-rewards-coin`'s own +/// `TestSingleton`. The cheapest singleton that can deliver conditions; nothing here depends on +/// which inner puzzle it is. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +struct TestSingleton { + launcher_id: Bytes32, + coin: Coin, + proof: Proof, + inner_puzzle_hash: Bytes32, + puzzle: NodePtr, +} + +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn launch_test_singleton( + ctx: &mut SpendContext, + sim: &mut Simulator, +) -> Result> { + let launcher_coin = sim.new_coin(SINGLETON_LAUNCHER_HASH.into(), 1); + let launcher = Launcher::new(launcher_coin.parent_coin_info, 1); + let launcher_id = launcher.coin().coin_id(); + + let inner_puzzle = ctx.alloc(&1)?; + let inner_puzzle_hash = ctx.tree_hash(inner_puzzle); + let (_, coin) = launcher.spend(ctx, inner_puzzle_hash.into(), ())?; + + let puzzle = ctx.curry(SingletonArgs::new(launcher_id, inner_puzzle))?; + let proof = Proof::Eve(EveProof { + parent_parent_coin_info: launcher_coin.parent_coin_info, + parent_amount: launcher_coin.amount, + }); + + Ok(TestSingleton { + launcher_id, + coin, + proof, + inner_puzzle_hash: inner_puzzle_hash.into(), + puzzle, + }) +} + +/// Deliver `output_conditions` from the manager singleton, recreating it for the next spend -- +/// mirrors `dig-rewards-coin`'s own `spend_manager_singleton`. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn spend_manager_singleton( + ctx: &mut SpendContext, + singleton: &TestSingleton, + output_conditions: Conditions, +) -> Result<(Coin, Proof), Box> { + let inner_puzzle = ctx.alloc(&1)?; + let inner_puzzle_hash: Bytes32 = ctx.tree_hash(inner_puzzle).into(); + + let inner_solution = output_conditions + .create_coin(inner_puzzle_hash, 1, Memos::None) + .to_clvm(ctx)?; + let solution = ctx.alloc(&SingletonSolution { + lineage_proof: singleton.proof, + amount: 1, + inner_solution, + })?; + + ctx.spend(singleton.coin, Spend::new(singleton.puzzle, solution))?; + + let next_proof = Proof::Lineage(LineageProof { + parent_parent_coin_info: singleton.coin.parent_coin_info, + parent_inner_puzzle_hash: inner_puzzle_hash, + parent_amount: singleton.coin.amount, + }); + let next_coin = Coin::new(singleton.coin.coin_id(), singleton.coin.puzzle_hash, 1); + + Ok((next_coin, next_proof)) +} + +/// Assert a permissionless action's conditions via a zero-value checker coin -- mirrors +/// `dig-rewards-coin`'s own `ensure_conditions_met`. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn ensure_conditions_met( + ctx: &mut SpendContext, + sim: &mut Simulator, + conditions: Conditions, +) -> Result<(), Box> { + let checker_puzzle = clvm_quote!(conditions).to_clvm(ctx)?; + let checker_coin = sim.new_coin(ctx.tree_hash(checker_puzzle).into(), 0); + ctx.spend(checker_coin, Spend::new(checker_puzzle, NodePtr::NIL))?; + Ok(()) +} + +/// As [`ensure_conditions_met`], but for the OPTIONAL `Sync` conditions an entry-set write may or +/// may not carry. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn ensure_optional_conditions_met( + ctx: &mut SpendContext, + sim: &mut Simulator, + conditions: Option>, +) -> Result<(), Box> { + match conditions { + Some(conditions) => ensure_conditions_met(ctx, sim, conditions), + None => Ok(()), + } +} + +/// A real launch, funded, with one admitted entry -- everything +/// `RealClaimChainPort::submit_initiate_payout` needs to build a claim the simulator will accept. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +pub struct FundedFixture { + pub sim: Simulator, + pub launcher_id: Bytes32, + /// The launcher's own parent (its "security coin") -- the spend that CREATES the launcher + /// coin, which `dig_rewards_coin::discover_distributor`'s `source.parent_spend(launcher_id)` + /// needs. Same derivation as `LaunchedFixture::security_coin_id`. + pub security_coin_id: Bytes32, + /// Every singleton generation's coin id, launcher first, tip last -- what `mock_chain_source` + /// needs to build a `SingletonLineage`. + pub singleton_members: Vec, + pub reserve_launch_id: Bytes32, + pub reserve_parent_id: Bytes32, + pub reserve_tip_id: Bytes32, + pub constants: RewardDistributorConstants, + /// The payout puzzle hash the one admitted entry was added with -- the same hash the caller + /// passed to [`launch_funded_admitted_fixture`]. + pub payout_puzzle_hash: Bytes32, +} + +/// Launches a real manager singleton and distributor, mints `COMMITTED_BASE_UNITS` into the first +/// epoch, admits ONE entry at `payout_puzzle_hash`, rolls to the next epoch, then syncs at the +/// epoch's midpoint -- so the entry has accrued something, comfortably above +/// `PAYOUT_THRESHOLD_BASE_UNITS`, entirely from real puzzle arithmetic. Mirrors +/// `dig-rewards-coin` 0.8.0's own `a_claim_built_entirely_from_a_chain_read_is_accepted` harness. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +pub fn launch_funded_admitted_fixture( + payout_puzzle_hash: Bytes32, +) -> Result> { + let ctx = &mut SpendContext::new(); + let mut sim = Simulator::new(); + + let funder = sim.bls(MINTED_BASE_UNITS); + let funder_p2 = StandardLayer::new(funder.pk); + let (issue_cat, source_cats) = Cat::single_issuance( + ctx, + funder.coin.coin_id(), + None, + MINTED_BASE_UNITS, + Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), + )?; + funder_p2.spend(ctx, funder.coin, issue_cat)?; + let mut source_cat = source_cats[0]; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + + let manager = launch_test_singleton(ctx, &mut sim)?; + + let offer_amount = 1; + let launcher_bls = sim.bls(offer_amount); + let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + + let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + SETTLEMENT_PAYMENT_HASH.into(), + source_cat.coin.amount, + Memos::None + )) + .to_clvm(ctx)?; + let cat_inner_spend = funder_p2.delegated_inner_spend( + ctx, + Spend { + puzzle: cat_inner_puzzle, + solution: NodePtr::NIL, + }, + )?; + source_cat.spend( + ctx, + SingleCatSpend { + prev_coin_id: source_cat.coin.coin_id(), + next_coin_proof: CoinProof { + parent_coin_info: source_cat.coin.parent_coin_info, + inner_puzzle_hash: funder.puzzle_hash, + amount: source_cat.coin.amount, + }, + prev_subtotal: 0, + extra_delta: 0, + p2_spend: cat_inner_spend, + revoke: false, + }, + )?; + + let spends = ctx.take(); + let cat_offer_spend = spends + .iter() + .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) + .expect("the CAT offer spend") + .clone(); + for spend in spends { + if spend.coin.coin_id() != source_cat.coin.coin_id() { + ctx.insert(spend); + } + } + + let signature = sign_standard_transaction( + ctx, + launcher_bls.coin, + offer_spend, + &launcher_bls.sk, + &TESTNET11_CONSTANTS, + )?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle { + coin_spends: vec![ + CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), + cat_offer_spend, + ], + aggregated_signature: signature, + }, + )?; + + let constants = RewardDistributorConstants::without_launcher_id( + RewardDistributorType::Managed { + manager_singleton_launcher_id: manager.launcher_id, + }, + funder.puzzle_hash, + TEST_EPOCH_SECONDS, + u64::MAX, + MAX_SECONDS_OFFSET, + PAYOUT_THRESHOLD_BASE_UNITS, + false, + 0, + WITHDRAWAL_SHARE_BPS, + source_cat.info.asset_id, + ); + + let launch_comment = LaunchComment::new(LAUNCH_STORE_ID, LAUNCH_ROOT); + + let launched = launch_dig_distributor( + ctx, + &offer, + FIRST_EPOCH_START, + constants, + &TESTNET11_CONSTANTS, + launch_comment, + 0, + )?; + + sim.spend_coins( + ctx.take(), + &[ + launcher_bls.sk.clone(), + launched.security_coin_secret_key.clone(), + funder.sk.clone(), + ], + )?; + + let launcher_id = launched.distributor.info.constants.launcher_id; + let mut distributor = launched.distributor; + let first_epoch_slot = launched.first_distributor_epoch_slot; + source_cat = launched.refund_cat; + + // Same derivation as `LaunchedFixture::security_coin_id` above: the launcher's own parent is + // the spend that CREATES the launcher coin, which `discover_distributor` reads. + let security_coin_id = sim + .coin_state(launcher_id) + .expect("the launcher coin was confirmed by the launch spend") + .coin + .parent_coin_info; + + let reserve_launch_id = distributor.reserve.coin.coin_id(); + let reserve_parent_id = distributor.reserve.coin.parent_coin_info; + + let mut singleton_members = vec![launcher_id, distributor.coin.coin_id()]; + + // Commit COMMITTED_BASE_UNITS to the first epoch. + let secure_conditions = commit_incentives_for_distributor_epoch( + ctx, + &mut distributor, + first_epoch_slot, + FIRST_EPOCH_START, + funder.puzzle_hash, + COMMITTED_BASE_UNITS, + )?; + + let hint = ctx.hint(funder.puzzle_hash)?; + let change = source_cat.coin.amount - COMMITTED_BASE_UNITS; + let source_cat_spend = CatSpend::new( + source_cat, + StandardLayer::new(funder.pk).spend_with_conditions( + ctx, + secure_conditions.create_coin(funder.puzzle_hash, change, hint), + )?, + ); + + let reward_slots: Vec<_> = distributor + .pending_spend + .created_reward_slots + .iter() + .map(|value| { + distributor.created_slot_value_to_slot(*value, RewardDistributorSlotNonce::REWARD) + }) + .collect(); + + distributor = distributor + .clone() + .finish_spend(ctx, vec![source_cat_spend])? + .0; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + singleton_members.push(distributor.coin.coin_id()); + + // Admit one entry at `payout_puzzle_hash`. + let authority = ManagerAuthority::new(manager.inner_puzzle_hash)?; + let write = add_entry( + ctx, + &mut distributor, + authority, + verdict_for(payout_puzzle_hash), + 0, + )?; + distributor = distributor.clone().finish_spend(ctx, vec![])?.0; + ensure_optional_conditions_met(ctx, &mut sim, write.sync_conditions)?; + // The manager singleton's post-AddEntry generation is never spent again by this fixture, so + // its returned coin/proof are discarded rather than threaded into an unused `mut` binding. + let (_next_manager_coin, _next_manager_proof) = + spend_manager_singleton(ctx, &manager, write.manager_conditions)?; + sim.spend_coins(ctx.take(), &[])?; + singleton_members.push(distributor.coin.coin_id()); + + // Two more generations past AddEntry: roll to the next epoch, then sync at its midpoint -- + // the entry slot the claim later spends was created several generations before the tip. + sim.set_next_timestamp(FIRST_EPOCH_START)?; + let first_reward_slot: Slot = reward_slots + .into_iter() + .find(|slot| slot.info.value.epoch_start == FIRST_EPOCH_START) + .expect("a reward slot for the first epoch"); + let roll = start_next_distributor_epoch(ctx, &mut distributor, first_reward_slot)?; + ensure_conditions_met(ctx, &mut sim, roll.conditions)?; + distributor = distributor.clone().finish_spend(ctx, vec![])?.0; + sim.spend_coins(ctx.take(), &[])?; + singleton_members.push(distributor.coin.coin_id()); + + let sync_time = FIRST_EPOCH_START + TEST_EPOCH_SECONDS / 2; + sim.set_next_timestamp(sync_time)?; + let sync_conditions = sync_distributor(ctx, &mut distributor, sync_time)?; + ensure_conditions_met(ctx, &mut sim, sync_conditions)?; + distributor = distributor.clone().finish_spend(ctx, vec![])?.0; + sim.spend_coins(ctx.take(), &[])?; + singleton_members.push(distributor.coin.coin_id()); + + let reserve_tip_id = distributor.reserve.coin.coin_id(); + + Ok(FundedFixture { + sim, + launcher_id, + security_coin_id, + singleton_members, + reserve_launch_id, + reserve_parent_id, + reserve_tip_id, + constants: distributor.info.constants, + payout_puzzle_hash, + }) +} + +/// Builds a `MockChainSource` over `fixture`'s real, multi-generation simulator state -- the +/// general form `mock_chain_source` above cannot serve, since a funded/admitted fixture has more +/// than one post-launch generation. Mirrors `dig-rewards-coin`'s own `mock_chain_source` (the +/// general `sim`/`singleton_members`/`extra_coin_ids` form, `tests/simulator.rs`). +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +pub fn mock_chain_source_for_funded_fixture(fixture: &FundedFixture) -> MockChainSource { + let eve_coin_id = fixture + .sim + .children(fixture.launcher_id) + .first() + .map(|state| state.coin.coin_id()); + + let extra_ids = [ + fixture.security_coin_id, + fixture.reserve_launch_id, + fixture.reserve_parent_id, + fixture.reserve_tip_id, + ]; + + let mut source = MockChainSource::new(); + for id in fixture + .singleton_members + .iter() + .copied() + .chain(extra_ids.iter().copied()) + .chain(eve_coin_id) + { + if let Some(state) = fixture.sim.coin_state(id) { + source = source.with_coin(id, CoinRecord::from_coin_state(state)); + } + if let Some(spend) = fixture.sim.coin_spend(id) { + source = source.with_spend(id, spend); + } + } + + let tip = *fixture + .singleton_members + .last() + .expect("a singleton chain always has at least the launcher"); + source = source.with_lineage( + fixture.launcher_id, + SingletonLineage::new(tip, fixture.singleton_members.iter().copied()), + ); + + let peak = fixture.sim.height(); + for height in 0..=peak { + source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + } + source.with_peak(peak) +} diff --git a/crates/dig-node-service/tests/rewards_chain_port_a3.rs b/crates/dig-node-service/tests/rewards_chain_port_a3.rs index 01ad05b7..d5ad5d32 100644 --- a/crates/dig-node-service/tests/rewards_chain_port_a3.rs +++ b/crates/dig-node-service/tests/rewards_chain_port_a3.rs @@ -35,258 +35,19 @@ //! itself. That is true of this test as written; it is not a structural guarantee, and would stop //! being true the moment a second source of those fields is added here. +mod common; + use std::sync::Arc; -use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; -use chia_puzzle_types::CoinProof; -use chia_puzzle_types::Memos; -use chia_puzzles::SETTLEMENT_PAYMENT_HASH; -use chia_sdk_driver::{ - sign_standard_transaction, Cat, Offer, RewardDistributorConstants, RewardDistributorType, - SingleCatSpend, Spend, SpendContext, SpendWithConditions, StandardLayer, -}; -use chia_sdk_test::Simulator; -use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; -use clvm_traits::{clvm_quote, ToClvm}; -use clvmr::NodePtr; -use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_chainsource_interface::MockChainSource; use dig_node_core::rewards::port::RewardsChainPort; use dig_node_core::Node; use dig_node_service::rewards::RealRewardsChainPort; -use dig_rewards_coin::comment::LaunchComment; -use dig_rewards_coin::constants::{ - MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, -}; -use dig_rewards_coin::launch::launch_dig_distributor; - -/// Small on purpose: the simulator's clock starts at zero. -const FIRST_EPOCH_START: u64 = 1_234; -/// A short epoch; this test is about the report's fields, not the epoch length. -const TEST_EPOCH_SECONDS: u64 = 1_000; -/// $DIG the funder mints for itself. -const MINTED_BASE_UNITS: u64 = 10_000_000_000; - -/// A fixed, never-launched manager singleton launcher id: curried into the constants table for -/// shape only, never read back off chain by `read_distributor`. -const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); - -/// Everything a real launch produced, named rather than positional. -struct LaunchedFixture { - sim: Simulator, - launcher_id: Bytes32, - security_coin_id: Bytes32, - distributor_coin_id: Bytes32, - reserve_coin_id: Bytes32, - reserve_launch_id: Bytes32, - reserve_parent_id: Bytes32, - launch_comment: LaunchComment, - constants: RewardDistributorConstants, -} - -/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via -/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from -/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. -fn launch_fixture() -> Result> { - let ctx = &mut SpendContext::new(); - let mut sim = Simulator::new(); - - let funder = sim.bls(MINTED_BASE_UNITS); - let funder_p2 = StandardLayer::new(funder.pk); - let (issue_cat, source_cats) = Cat::single_issuance( - ctx, - funder.coin.coin_id(), - None, - MINTED_BASE_UNITS, - Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), - )?; - funder_p2.spend(ctx, funder.coin, issue_cat)?; - let source_cat = source_cats[0]; - sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; - - let offer_amount = 1; - let launcher_bls = sim.bls(offer_amount); - let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( - ctx, - Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), - )?; - let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; - let solution = ctx.serialize(&offer_spend.solution)?; - - let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( - SETTLEMENT_PAYMENT_HASH.into(), - source_cat.coin.amount, - Memos::None - )) - .to_clvm(ctx)?; - let cat_inner_spend = funder_p2.delegated_inner_spend( - ctx, - Spend { - puzzle: cat_inner_puzzle, - solution: NodePtr::NIL, - }, - )?; - source_cat.spend( - ctx, - SingleCatSpend { - prev_coin_id: source_cat.coin.coin_id(), - next_coin_proof: CoinProof { - parent_coin_info: source_cat.coin.parent_coin_info, - inner_puzzle_hash: funder.puzzle_hash, - amount: source_cat.coin.amount, - }, - prev_subtotal: 0, - extra_delta: 0, - p2_spend: cat_inner_spend, - revoke: false, - }, - )?; - - let spends = ctx.take(); - let cat_offer_spend = spends - .iter() - .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) - .expect("the CAT offer spend") - .clone(); - for spend in spends { - if spend.coin.coin_id() != source_cat.coin.coin_id() { - ctx.insert(spend); - } - } - - let signature = sign_standard_transaction( - ctx, - launcher_bls.coin, - offer_spend, - &launcher_bls.sk, - &TESTNET11_CONSTANTS, - )?; - let offer = Offer::from_spend_bundle( - ctx, - &SpendBundle { - coin_spends: vec![ - CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), - cat_offer_spend, - ], - aggregated_signature: signature, - }, - )?; - - let constants = RewardDistributorConstants::without_launcher_id( - RewardDistributorType::Managed { - manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, - }, - funder.puzzle_hash, - TEST_EPOCH_SECONDS, - u64::MAX, - MAX_SECONDS_OFFSET, - PAYOUT_THRESHOLD_BASE_UNITS, - false, - 0, - WITHDRAWAL_SHARE_BPS, - source_cat.info.asset_id, - ); - - let launch_comment = LaunchComment::new(Bytes32::new([0xaa; 32]), Bytes32::new([0xbb; 32])); - - let launched = launch_dig_distributor( - ctx, - &offer, - FIRST_EPOCH_START, - constants, - &TESTNET11_CONSTANTS, - launch_comment, - // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. - 0, - )?; +use dig_rewards_coin::constants::WITHDRAWAL_SHARE_BPS; - sim.spend_coins( - ctx.take(), - &[ - launcher_bls.sk.clone(), - launched.security_coin_secret_key.clone(), - funder.sk.clone(), - ], - )?; - - let launcher_id = launched.distributor.info.constants.launcher_id; - let distributor_coin_id = launched.distributor.coin.coin_id(); - let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); - let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; - let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); - - // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. - // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record - // up after the fact, rather than tracking the security coin id through the launch machinery - // by hand. - let security_coin_id = sim - .coin_state(launcher_id) - .expect("the launcher coin was confirmed by the launch spend") - .coin - .parent_coin_info; - - Ok(LaunchedFixture { - sim, - launcher_id, - security_coin_id, - distributor_coin_id, - reserve_coin_id, - reserve_launch_id, - reserve_parent_id, - launch_comment, - constants: launched.distributor.info.constants, - }) -} - -/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what -/// `read_distributor_guarded`/`read_launch_comment` read — mirrors -/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. -fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { - let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; - - // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it - // named directly. - let eve_coin_id = fixture - .sim - .children(fixture.launcher_id) - .first() - .map(|state| state.coin.coin_id()); - - let extra_ids = [ - fixture.security_coin_id, - fixture.reserve_launch_id, - fixture.reserve_parent_id, - fixture.reserve_coin_id, - ]; - - let mut source = MockChainSource::new(); - for id in singleton_members - .iter() - .copied() - .chain(extra_ids.iter().copied()) - .chain(eve_coin_id) - { - if let Some(state) = fixture.sim.coin_state(id) { - source = source.with_coin(id, CoinRecord::from_coin_state(state)); - } - if let Some(spend) = fixture.sim.coin_spend(id) { - source = source.with_spend(id, spend); - } - } - - source = source.with_lineage( - fixture.launcher_id, - SingletonLineage::new( - fixture.distributor_coin_id, - singleton_members.iter().copied(), - ), - ); - - let peak = fixture.sim.height(); - for height in 0..=peak { - source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); - } - source.with_peak(peak) -} +use common::rewards_fixture::{ + launch_fixture, mock_chain_source, FIRST_EPOCH_START, TEST_EPOCH_SECONDS, +}; /// A3: `RealRewardsChainPort::distributor_report` — the real production adapter, driven by a /// `MockChainSource` loaded from a real simulator launch — reports the values launched with, @@ -317,7 +78,11 @@ async fn distributor_report_reflects_a_real_simulator_launch() { assert_eq!(report.epoch_seconds, TEST_EPOCH_SECONDS); assert_eq!(report.first_epoch_start, FIRST_EPOCH_START); - assert_eq!(report.payout_threshold, PAYOUT_THRESHOLD_BASE_UNITS); + assert_eq!( + report.payout_threshold, fixture.constants.payout_threshold, + "the report must echo the threshold the distributor was actually launched with, not a \ + constant -- the fixture launches with a non-default threshold (#3347 mutation proof iv)" + ); assert_eq!(report.fee_bps, 0); assert_eq!( report.withdrawal_share_bps, diff --git a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs new file mode 100644 index 00000000..89da668f --- /dev/null +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -0,0 +1,515 @@ +//! DIG-Network/dig_ecosystem#3347 acceptance: `RealClaimChainPort` — the real claim-side adapter, +//! over the SAME real, decodable simulator launch `rewards_chain_port_a3.rs` uses (shared via +//! `tests/common/rewards_fixture.rs`). +//! +//! # What this proves, and what it does not +//! +//! Discovery (through an untrusted [`LauncherIndex`]), `reserve_asset_id`, and `payout_threshold` +//! are all real reads, proven end to end against a real launch. `own_entry` for an unknown payout +//! puzzle hash correctly reads `Ok(None)` (there is no entry keyed to a hash this fixture never +//! launched with). The accrued-amount and submit paths are proven separately, against a funded, +//! admitted distributor -- see this file's own DIG-Network/dig_ecosystem#3347 tests below. + +mod common; + +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::Bytes32; +use chia_puzzle_types::cat::CatArgs; +use dig_chainsource_interface::MockChainSource; +use dig_node_service::rewards_claim::{ + run_claim_driver_in, ClaimChainPort, ClaimLoopHandle, ClaimLoopState, ClaimPortError, + LauncherIndex, RealClaimChainPort, RewardsClaimConfig, +}; +use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; +use dig_wallet::sage::spend::MockBroadcaster; + +use common::rewards_fixture::{ + launch_fixture, launch_funded_admitted_fixture, mock_chain_source, + mock_chain_source_for_funded_fixture, +}; + +/// An index that proposes exactly the ids it is built with -- no re-verification of its own; that +/// is `RealClaimChainPort::discover_distributors`'s job, which this file's tests exercise. +struct FixtureLauncherIndex(Vec); + +#[async_trait] +impl LauncherIndex for FixtureLauncherIndex { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + Ok(self.0.clone()) + } +} + +/// The real launcher id discovers with the fixture's own `store_id`/`root` -- proving the SAME +/// memo-decode path `rewards_chain_port_a3.rs` proves for the funder-side adapter, now for the +/// claim-side one. +#[tokio::test(flavor = "multi_thread")] +async fn discover_distributors_returns_exactly_the_real_launch() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let discovered = port + .discover_distributors() + .await + .expect("a real launched distributor must discover"); + + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].launcher_id, fixture.launcher_id); + assert_eq!(discovered[0].store_id, fixture.launch_comment.store_id); + assert_eq!(discovered[0].root, fixture.launch_comment.root); +} + +/// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be +/// dropped, silently, by discovery's own re-verification -- never echoed back and never an error +/// for the whole batch. +#[tokio::test(flavor = "multi_thread")] +async fn a_bogus_index_entry_is_dropped_not_echoed() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![bogus_id, fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let discovered = port + .discover_distributors() + .await + .expect("a bogus id must be dropped, not fail the whole discovery"); + + assert_eq!( + discovered.len(), + 1, + "an index lie must yield nothing for that id, and never overrule the real one" + ); + assert_eq!(discovered[0].launcher_id, fixture.launcher_id); +} + +/// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own +/// default-launch literal -- both read from the fixture's OWN launched constants. +#[tokio::test(flavor = "multi_thread")] +async fn reserve_asset_id_and_payout_threshold_are_read_from_chain() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let reserve_asset_id = port + .reserve_asset_id(fixture.launcher_id) + .await + .expect("a real launched distributor's reserve asset id must read"); + assert_eq!( + reserve_asset_id, fixture.constants.reserve_asset_id, + "must be the fixture's OWN minted CAT asset id, not any literal" + ); + + let payout_threshold = port + .payout_threshold(fixture.launcher_id) + .await + .expect("a real launched distributor's payout threshold must read"); + assert_eq!( + payout_threshold, fixture.constants.payout_threshold, + "must be the chain-curried value the fixture launched with (deliberately NOT \ + PAYOUT_THRESHOLD_BASE_UNITS, the default -- see rewards_fixture.rs), read via \ + dig_rewards_coin::payout::payout_threshold_base_units, never a literal" + ); + assert_ne!( + payout_threshold, PAYOUT_THRESHOLD_BASE_UNITS, + "the fixture must diverge from the default, or a port that ignored the chain and \ + returned the default constant would read as correct by coincidence" + ); +} + +/// `own_entry` for a payout puzzle hash this fixture never launched with reads `Ok(None)` -- +/// "no entry", never a fabricated one and never an error. +#[tokio::test(flavor = "multi_thread")] +async fn own_entry_reads_none_for_an_unknown_payout_puzzle_hash() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let entry = port + .own_entry(fixture.launcher_id, Bytes32::from([0x42; 32])) + .await + .expect("a bare launch with no matching entry must read Ok(None), not an error"); + assert_eq!(entry, None); +} + +/// The production adapter names itself -- never inherits `UnavailableClaimChainPort`'s name. +#[tokio::test(flavor = "multi_thread")] +async fn kind_names_the_real_adapter() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + assert_eq!(port.kind(), "real-corroborated"); +} + +/// A source that fails outright must surface `ClaimPortError::Unavailable` from every method, +/// never a silent empty answer that would misreport "the chain has nothing" instead of "the chain +/// could not be read". +#[tokio::test(flavor = "multi_thread")] +async fn a_failing_source_reports_unavailable_everywhere() { + let source = + MockChainSource::new().fail_with(dig_chainsource_interface::ChainSourceError::Transport( + "simulated transport failure".into(), + )); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![]), + Arc::new(MockBroadcaster::default()), + ); + + let launcher_id = Bytes32::from([1u8; 32]); + assert_eq!( + port.reserve_asset_id(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.payout_threshold(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.own_entry(launcher_id, Bytes32::from([2u8; 32])).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.resolve_launch_comment(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); +} + +/// The 3347 acceptance proof itself: driving the REAL production body +/// (`run_claim_driver_in`, the same function [`dig_node_service::rewards_claim::spawn_claim_driver_from_config`] +/// spawns in production) with a real `RealClaimChainPort` over the fixture's real launch reaches +/// an actual chain read -- not `ClaimLoopState::ChainSourceUnavailable`, and it actually discovers +/// the one real distributor and its one (entry-less) cycle outcome. This is the one test in this +/// file that proves the WIRING, not just the adapter in isolation. +#[tokio::test(start_paused = true)] +async fn a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let state_dir_guard = tempfile::tempdir().expect("a temp state dir"); + let state_dir = state_dir_guard.path().to_path_buf(); + let cfg = RewardsClaimConfig { + enabled: true, + cadence_seconds: 3_600, + jitter_seconds: 0, + ..RewardsClaimConfig::default() + }; + cfg.save_to(&state_dir) + .expect("the config must save before the driver reads it"); + + let handle = ClaimLoopHandle::default(); + let handle_for_task = handle.clone(); + let own_payout_puzzle_hash = Bytes32::from([0x42; 32]); + + tokio::spawn(async move { + run_claim_driver_in( + &state_dir, + own_payout_puzzle_hash, + dig_mirror_coin::DIG_ASSET_ID, + port, + handle_for_task, + ) + .await; + }); + + // Let the spawned task run far enough to register its first `sleep` BEFORE advancing the + // virtual clock -- `tokio::time::advance` only fires timers already registered. + for _ in 0..10 { + tokio::task::yield_now().await; + } + + // The driver's first pass sleeps `cadence_seconds + jitter` before running its first cycle + // (see `driver.rs`'s own `drive` doc) -- jitter is pinned to 0 above, so this is exact. + tokio::time::advance(std::time::Duration::from_secs(3_600)).await; + // Let the woken task actually run its cycle (real chain reads go through + // `tokio::task::spawn_blocking`, which runs on a real OS thread unaffected by the paused + // virtual clock) before reading the handle back. + for _ in 0..50 { + tokio::task::yield_now().await; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + + let status = handle.status(); + // Per `rewards_chain_port_a3.rs`'s own module doc: this fixture's reserve asset is the + // SIMULATOR's own freshly minted CAT, never the real (unmintable-in-a-simulator) + // `dig_mirror_coin::DIG_ASSET_ID` -- this call passes that real asset id (the same value + // `run_claim_driver` passes in production, since #3347's U3), so + // the engine correctly reads this real distributor, sees its asset does not match, and drops + // it as `NotOurs` (SPEC 9.3) BEFORE the entry-slot read -- it is never faulted, never + // read as chain-unavailable, and never fabricated as claimable. That is still real proof the + // production body reached a real chain read through `RealClaimChainPort`: a fabricated, + // no-adapter or wrongly-wired path could not produce "discovered exactly one, asset mismatch, + // cycle completed cleanly" -- it would read either zero known or chain-unavailable instead. + assert_ne!( + status.state, + ClaimLoopState::ChainSourceUnavailable, + "a real, launched fixture must not be read as chain-unavailable" + ); + assert_eq!( + status.distributors_known, 1, + "discovery must find the one real distributor this fixture launched" + ); + assert!( + !status.fault_reported, + "a real asset-id mismatch is a clean NotOurs drop, never a fault" + ); + assert!( + status.last_cycle_at.is_some(), + "a cycle must have actually completed, not merely been scheduled" + ); +} + +/// The 3347 closure proof at the adapter level: `submit_initiate_payout` over a real, funded, +/// admitted distributor builds a bundle the SIMULATOR actually accepts (never merely well-formed), +/// and the entry's own accrued figure -- read via `own_entry` -- is what the simulator pays out. +/// Ported from `dig-rewards-coin` 0.8.0's own +/// `tests/simulator.rs::a_claim_built_entirely_from_a_chain_read_is_accepted`, with the production +/// `RealClaimChainPort` (built with a `MockBroadcaster`) standing in for that test's hand-rolled +/// `initiate_payout` + `finish_spend` + `spend_coins` call sequence. +#[tokio::test(flavor = "multi_thread")] +async fn submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_the_entry() { + let payout_puzzle_hash = Bytes32::from([0x77; 32]); + let mut fixture = launch_funded_admitted_fixture(payout_puzzle_hash) + .expect("a funded, admitted distributor launches cleanly in the simulator"); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + assert_eq!( + fixture.payout_puzzle_hash, payout_puzzle_hash, + "the fixture must have admitted the same payout puzzle hash this test drives against" + ); + + let entry = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash"); + assert!( + entry.accrued_base_units > 0, + "half an epoch with one entry must have accrued something" + ); + assert!( + entry.accrued_base_units >= fixture.constants.payout_threshold, + "the fixture must fund enough that the entry clears its own launched threshold" + ); + + port.submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 0) + .await + .expect("a real accrued entry, submitted with zero fee, must build and broadcast"); + + // Fee refused -- `required_fee_mojos` is 0 for this adapter and it has nowhere to pay one from. + let fee_refusal = port + .submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 1) + .await; + assert!( + matches!(fee_refusal, Err(ClaimPortError::Other(_))), + "a non-zero fee must be refused by name, never silently dropped or paid" + ); + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 1, + "exactly one bundle must have been broadcast -- the fee-refused call must never reach \ + the broadcaster" + ); + let bundle = sent[0].clone(); + drop(sent); + + // The assertion the whole test exists for: a bundle built by the PRODUCTION adapter is + // actually ACCEPTED by the simulator, never merely well-formed. + fixture + .sim + .spend_coins(bundle.coin_spends.clone(), &[]) + .expect("the production adapter's bundle must be accepted by the simulator"); + + let reserve_asset_id = fixture.constants.reserve_asset_id; + let payee_puzzle_hash: Bytes32 = + CatArgs::curry_tree_hash(reserve_asset_id, payout_puzzle_hash.into()).into(); + let children = fixture.sim.children(fixture.reserve_tip_id); + let payee_coins: Vec<_> = children + .iter() + .filter(|state| state.coin.puzzle_hash == payee_puzzle_hash) + .collect(); + assert_eq!( + payee_coins.len(), + 1, + "exactly one payee CAT coin must exist on chain at the entry's payout puzzle hash" + ); + assert_eq!( + payee_coins[0].coin.amount, entry.accrued_base_units, + "the payee's on-chain CAT coin amount must equal the entry's own accrued figure" + ); +} + +/// DIG-Network/dig_ecosystem#3347's CLOSURE ARTIFACT: drives the whole PRODUCTION BODY +/// (`run_claim_driver_in`, the same function `run_claim_driver` calls in production, over a real +/// `RealClaimChainPort`) against a real, funded, admitted distributor -- and asserts the payout +/// coin the simulator actually accepted, not merely `cycles_driven()`. Where +/// `a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read` above stops at a clean +/// `NotOurs` (asset mismatch) and +/// `submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_the_entry` drives the +/// adapter directly, this test is the two combined: the production loop itself finds the entry, +/// builds and broadcasts the spend, and the simulator pays this peer. +#[tokio::test(start_paused = true)] +async fn a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer() { + let payout_puzzle_hash = Bytes32::from([0x99; 32]); + let mut fixture = launch_funded_admitted_fixture(payout_puzzle_hash) + .expect("a funded, admitted distributor launches cleanly in the simulator"); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + // Read the entry's own accrued figure directly through the adapter, BEFORE handing `port` to + // the driver -- this is the figure the driven cycle below must actually pay, independent of + // whatever the engine does with it. + let expected_accrued = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash") + .accrued_base_units; + assert!( + expected_accrued > 0, + "half an epoch with one entry must have accrued something" + ); + + let discovered = port + .discover_distributors() + .await + .expect("discovery must not error"); + assert_eq!( + discovered.len(), + 1, + "discovery must find the one real distributor this fixture launched" + ); + + let state_dir_guard = tempfile::tempdir().expect("a temp state dir"); + let state_dir = state_dir_guard.path().to_path_buf(); + let cfg = RewardsClaimConfig { + enabled: true, + cadence_seconds: 3_600, + jitter_seconds: 0, + ..RewardsClaimConfig::default() + }; + cfg.save_to(&state_dir) + .expect("the config must save before the driver reads it"); + + let handle = ClaimLoopHandle::default(); + let handle_for_task = handle.clone(); + let reserve_asset_id = fixture.constants.reserve_asset_id; + + tokio::spawn(async move { + run_claim_driver_in( + &state_dir, + payout_puzzle_hash, + reserve_asset_id, + port, + handle_for_task, + ) + .await; + }); + + // Same settle pattern as `a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read`: + // reach the driver's first `sleep` before advancing, then let the real chain read (a + // `spawn_blocking`, unaffected by the paused virtual clock) actually complete. + for _ in 0..10 { + tokio::task::yield_now().await; + } + tokio::time::advance(std::time::Duration::from_secs(3_600)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + + let status = handle.status(); + assert_ne!( + status.state, + ClaimLoopState::ChainSourceUnavailable, + "a real, funded, admitted distributor must not be read as chain-unavailable" + ); + assert_eq!( + status.claims_submitted, 1, + "one cadence over one admitted, thresholded entry must submit exactly one claim" + ); + assert_eq!( + status.distributors_claimable, 1, + "the one real distributor, with its entry cleared for payout, must count as claimable" + ); + assert!( + !status.fault_reported, + "a real, correctly-built submission must never be reported as a fault" + ); + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 1, + "the production driver must have broadcast exactly one bundle" + ); + let bundle = sent[0].clone(); + drop(sent); + + fixture + .sim + .spend_coins(bundle.coin_spends.clone(), &[]) + .expect("the bundle the production driver broadcast must be accepted by the simulator"); + + let payee_puzzle_hash: Bytes32 = + CatArgs::curry_tree_hash(reserve_asset_id, payout_puzzle_hash.into()).into(); + let children = fixture.sim.children(fixture.reserve_tip_id); + let payee_coins: Vec<_> = children + .iter() + .filter(|state| state.coin.puzzle_hash == payee_puzzle_hash) + .collect(); + assert_eq!( + payee_coins.len(), + 1, + "exactly one payee CAT coin must exist on chain at this peer's payout puzzle hash" + ); + assert_eq!( + payee_coins[0].coin.amount, expected_accrued, + "the payee's on-chain CAT coin amount must equal the entry's own accrued figure" + ); +} From 25a04f09ffc08be4f33e99c315f09b67eb16c662 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 23 Sep 2026 13:07:15 -0700 Subject: [PATCH 25/29] chore(release): forward-port the v0.260.0 workspace version to develop main is newer only in the two version lines (Cargo.toml [workspace.package].version and the dig-node-service entry in Cargo.lock, 0.258.0 -> 0.260.0). Adopting them here closes the develop-version inversion so the next cut's Check version increment compares against 0.260.0. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 29a2dc3c..788ff692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.258.0" +version = "0.260.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index d351d375..31e48aa5 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.258.0" +version = "0.260.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 2ca67dd801042cc4089d9f00c5ec027b39f44639 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:54:23 -0700 Subject: [PATCH 26/29] fix(rewards-claim): chain_port audit, approval refusal, discovery cap (#623) Refs DIG-Network/dig_ecosystem#3357 DIG-Network/dig_ecosystem#3362 DIG-Network/dig_ecosystem#3358 DIG-Network/dig_ecosystem#3363 Gates on 836a10d0: loop-reviewer PASS (review 5308787371), loop-security PASS (comment 5820041308), loop-decider adversarial code-PASS with M1 re-executed red (comment 5820055177). Five required contexts green by name. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 13 ++ clippy.toml | 18 ++ .../src/rewards_claim/chain_port.rs | 111 ++++++++++++- .../src/rewards_claim/driver.rs | 25 ++- .../src/rewards_claim/engine.rs | 87 +++++----- .../dig-node-service/src/rewards_claim/mod.rs | 4 +- .../src/rewards_claim/port.rs | 9 +- .../src/rewards_claim/types.rs | 26 +++ .../tests/common/rewards_fixture.rs | 32 +++- .../tests/rewards_claim_chain_port_3347.rs | 155 ++++++++++++++++-- 10 files changed, 401 insertions(+), 79 deletions(-) create mode 100644 clippy.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 46528fdc..feca10ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [Unreleased] + +### Reward claim port hardening +- Ban `RewardDistributor::created_slot_value_to_slot` from production code via a workspace + `disallowed-methods` clippy lint (phantom `LineageProof` on a chain-rebuilt distributor); allow + the one legitimate in-process test-fixture use (#3357) +- Refuse, by name, a distributor requiring payout approval in `submit_initiate_payout` before + building or broadcasting anything (#3362) +- Bound hinted launcher discovery candidates per cycle and report every drop via + `Discovery.candidates_dropped` / `ClaimStatus.discovery_candidates_dropped_this_cycle` (#3358) +- Fix a claim-port regression test to use a non-empty launcher index so it actually exercises the + failing chain source's discovery/submit paths (#3363) + ## [0.255.0] - 2026-09-07 ### Chores diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..696a4e1d --- /dev/null +++ b/clippy.toml @@ -0,0 +1,18 @@ +# DIG-Network/dig_ecosystem#3357: bans calling `RewardDistributor::created_slot_value_to_slot` +# from production code anywhere in this workspace. +# +# That method derives a `LineageProof` from the coin it is called ON. For a distributor rebuilt +# from chain (every production read path), that coin is the TIP -- so a `LineageProof` it derives +# for a slot an EARLIER generation created is a well-formed but PHANTOM proof. The entry slot for a +# real spend must come from a fresh, authenticated chain walk instead (see +# `dig_rewards_coin::ChainEntrySlotSource`, or `DistributorSnapshot::entry_slot` / +# `commitment_slots` / `reward_slots` for a read). +# +# This lint bans the CALL, not the receiver class: clippy cannot tell a chain-rebuilt receiver from +# an in-process one (a distributor built fresh in the same process this same generation, e.g. a +# test fixture reading back the reward slots ITS OWN spend just created -- that use is legitimate). +# Every `#[allow(clippy::disallowed_methods)]` against this entry must carry a comment stating WHY +# its receiver is in-process this generation, not chain-rebuilt. +disallowed-methods = [ + { path = "chia_sdk_driver::RewardDistributor::created_slot_value_to_slot", reason = "derives a LineageProof from the coin it is called on; on a distributor rebuilt from chain that coin is the TIP, so any earlier generation's slot is a PHANTOM (dig_ecosystem#3357). Use DistributorSnapshot::entry_slot / commitment_slots / reward_slots or ChainEntrySlotSource. This lint bans the CALL, not the receiver class: it cannot tell a chain-rebuilt receiver from an in-process one. Every #[allow] must state in a comment WHY its receiver is in-process." }, +] diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs index e5bb7aff..d267d53a 100644 --- a/crates/dig-node-service/src/rewards_claim/chain_port.rs +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -17,9 +17,13 @@ //! well-formed but PHANTOM `LineageProof` for a slot an earlier generation created //! (DIG-Network/dig_ecosystem#3357). `initiate_payout`'s returned `conditions` are a CALLER-SIDE //! assertion for a coin the caller would add to the same bundle; this adapter adds no coin of its -//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them -- -//! the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the proof the -//! resulting bundle is accepted without them. +//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them when +//! it proceeds -- the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the +//! proof the resulting bundle is accepted without them for `require_payout_approval = false`. When +//! the chain-curried `require_payout_approval` is `true` instead, dropping `conditions` would be +//! dropping the manager's approval assertion, not a no-op -- [`RealClaimChainPort::submit_initiate_payout`] +//! REFUSES by name in that case, before building anything, rather than broadcasting a bundle this +//! adapter cannot honestly satisfy (DIG-Network/dig_ecosystem#3362). //! //! A silent no-op would be the exact defect this ticket exists to prevent -- a refused method //! reports a NAMED [`ClaimPortError`], never a fabricated success. @@ -38,13 +42,48 @@ use dig_wallet::sage::spend::Broadcaster; use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError}; use super::port::{ClaimChainPort, ClaimPortError}; -use super::types::{DiscoveredDistributor, OwnEntry}; +use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// The longest a chain port's own error text is allowed to carry before it is truncated -- the /// same 200-char discipline [`super::types::ClaimOutcome::Faulted`]'s `reason` field documents, /// applied here at the source so every producer of a bounded string agrees on the bound. const MAX_ERROR_CHARS: usize = 200; +/// DIG-Network/dig_ecosystem#3358: the most hinted launcher candidates +/// [`RealClaimChainPort::discover_distributors`] will decode in one call. +/// +/// # Where the number comes from +/// `dig_rewards_coin`'s own `DECODE_MAX_SERIALIZED_BYTES` bounds ONE candidate's decode at 64 KiB +/// (65_536 bytes); `256 * 65_536 = 16_777_216` bytes -- a 16 MiB decode ceiling for one +/// `discover_distributors` call -- plus 256 parent-spend chain reads, one per candidate. +/// +/// # What this bound does NOT cover -- read this before assuming discovery is safe +/// 1. It does not bound gossip hints: `ClaimEngine::run_cycle`'s hint loop (`engine.rs`, +/// `self.hints.hints()`, feeding `resolve_launch_comment` one candidate at a time) is a +/// SEPARATE, unbounded path -- out of scope for this cap, named here so it is not mistaken for +/// covered. +/// 2. It does not choose WHICH candidates survive: this adapter decodes the index's first N in +/// WHATEVER ORDER the chain transport returned them, and that order is attacker-influenceable +/// (`HintedLauncherIndex` proposes every hinted coin its peers have seen) -- a flood of bogus +/// hinted coins ahead of a legitimate launcher in that order can push the legitimate one past +/// the cap and out of this cycle's candidate set. +/// 3. It does not persist "already decoded and rejected" across calls -- a dropped-for-real +/// candidate is re-attempted (and can be re-dropped) every cycle rather than being remembered +/// and skipped cheaply; left as a follow-up, not implemented here. +/// 4. It does not bound the COST of decoding one candidate -- that is +/// `DECODE_MAX_SERIALIZED_BYTES`'s job, not this cap's. +/// 5. It authenticates nothing -- every surviving candidate is still re-verified through the real +/// memo decode in [`resolve_via_chain`] exactly as before this cap existed; this cap only +/// decides how many candidates get that far. +/// +/// A drop is never silent: [`RealClaimChainPort::discover_distributors`] reports how many +/// candidates it declined via [`Discovery::candidates_dropped`], and +/// [`super::engine::ClaimEngine::run_cycle`] copies that count into +/// [`super::types::ClaimStatus::discovery_candidates_dropped_this_cycle`] and logs a `warn!` when +/// it is nonzero -- a silent cap on discovery is the exact censorship-primitive shape this ticket +/// exists to avoid. +pub const MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE: usize = 256; + fn bounded(message: impl Into) -> String { let message = message.into(); if message.chars().count() <= MAX_ERROR_CHARS { @@ -80,6 +119,11 @@ where source: Arc, index: I, broadcaster: Arc, + /// DIG-Network/dig_ecosystem#3358: how many hinted candidates one `discover_distributors` call + /// will decode -- [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] in production; + /// [`Self::with_candidate_cap`] overrides it for a test that needs a small cap to exercise + /// dropping without decoding hundreds of candidates. + candidate_cap: usize, } impl RealClaimChainPort @@ -89,13 +133,34 @@ where { /// Wraps an already-constructed chain source, launcher index and broadcaster. Takes the source /// by `Arc` (mirroring `rewards::chain_port::RealRewardsChainPort::new`) since a blocking read - /// clones it into a `spawn_blocking` closure on every call. + /// clones it into a `spawn_blocking` closure on every call. Uses the production + /// [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] cap -- see [`Self::with_candidate_cap`] to + /// override it. #[must_use] pub fn new(source: Arc, index: I, broadcaster: Arc) -> Self { Self { source, index, broadcaster, + candidate_cap: MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, + } + } + + /// Same as [`Self::new`] with an explicit candidate cap -- production never calls this; it + /// exists so a test can pin a small cap and prove the drop-and-report behaviour without + /// decoding hundreds of candidates. + #[must_use] + pub fn with_candidate_cap( + source: Arc, + index: I, + broadcaster: Arc, + candidate_cap: usize, + ) -> Self { + Self { + source, + index, + broadcaster, + candidate_cap, } } } @@ -190,13 +255,20 @@ where "real-corroborated" } - async fn discover_distributors(&self) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let candidate_ids = self.index.launcher_ids().await?; let source = Arc::clone(&self.source); + let candidate_cap = self.candidate_cap; tokio::task::spawn_blocking(move || { + // DIG-Network/dig_ecosystem#3358: bound how many candidates one call will decode -- + // see MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE's doc for what this does and does not + // protect. The drop count is REPORTED, never silently absorbed. + let total = candidate_ids.len(); + let candidates_dropped = total.saturating_sub(candidate_cap) as u32; + let mut discovered = Vec::new(); - for launcher_id in candidate_ids { + for launcher_id in candidate_ids.into_iter().take(candidate_cap) { // SPEC 13.1 clause 6: the index only PROPOSES; every id is re-verified through the // real memo decode. An id the decode rejects (unknown to `source`, or a spend that // is not a DIG rewards launch) is DROPPED, never echoed back. @@ -207,7 +279,10 @@ where Err(other) => return Err(other), } } - Ok(discovered) + Ok(Discovery { + distributors: discovered, + candidates_dropped, + }) }) .await .map_err(|join_error| { @@ -336,6 +411,26 @@ where ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) })?; + // DIG-Network/dig_ecosystem#3362: this distributor curries `require_payout_approval = + // true`, meaning `InitiatePayout` needs a manager-signed approval assertion in the same + // bundle. This adapter has no such assertion to attach and, per this module's doc, + // DROPS `initiate_payout`'s returned `conditions` unconditionally -- proceeding here + // would build a bundle the chain rejects, but only AFTER this adapter's caller had + // already reported `Paid` to whatever recorded the attempt. Refuse by name instead, + // before any spend is built. + if snapshot + .distributor() + .info + .constants + .require_payout_approval + { + return Err(ClaimPortError::Other(bounded( + "refused: distributor curries require_payout_approval = true; this adapter \ + carries no approval message (it drops initiate_payout's returned conditions), \ + so the bundle it would build is one the chain rejects after reporting Paid", + ))); + } + // NEVER `snapshot.distributor().created_slot_value_to_slot(..)` -- that derives a // well-formed but PHANTOM `LineageProof` for a slot an earlier generation created // (DIG-Network/dig_ecosystem#3357, this module's doc). The entry slot for THIS spend diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index a62eb998..70729f06 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -715,7 +715,7 @@ mod tests { use super::super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; use super::super::port::ClaimPortError; - use super::super::types::{DiscoveredDistributor, OwnEntry}; + use super::super::types::{DiscoveredDistributor, Discovery, OwnEntry}; // ---- decide_claim_driver / spawn_claim_driver_if (A3) ---------------------------------- @@ -988,10 +988,8 @@ mod tests { #[async_trait] impl ClaimChainPort for EmptyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(Vec::new()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery::default()) } async fn resolve_launch_comment( &self, @@ -1138,14 +1136,15 @@ mod tests { #[async_trait] impl ClaimChainPort for OneDistributorPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(vec![DiscoveredDistributor { - launcher_id: Bytes32::from([9u8; 32]), - store_id: Bytes32::from([0u8; 32]), - root: Bytes32::from([0u8; 32]), - }]) + async fn discover_distributors(&self) -> Result { + Ok(Discovery { + distributors: vec![DiscoveredDistributor { + launcher_id: Bytes32::from([9u8; 32]), + store_id: Bytes32::from([0u8; 32]), + root: Bytes32::from([0u8; 32]), + }], + candidates_dropped: 0, + }) } async fn resolve_launch_comment( &self, diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 9aea1184..0096666a 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -6,10 +6,11 @@ use std::path::{Path, PathBuf}; use chia_protocol::Bytes32; +use super::chain_port::MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE; use super::config::RewardsClaimConfig; use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; -use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus, Discovery}; /// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the /// single raw configured value they both come from. @@ -359,6 +360,7 @@ impl ClaimEngine { self.status.distributors_faulted = 0; self.status.claims_submitted_this_cycle = 0; self.status.no_entry_slot_this_cycle = 0; + self.status.discovery_candidates_dropped_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 @@ -481,14 +483,32 @@ impl ClaimEngine { // timestamp going stale to notice a wedged discovery path. self.status.fault_reported = true; discovery_failed = true; - Vec::new() + Discovery::default() } }; if !discovery_failed { self.status.last_discovery_at = Some(now); } - let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); + // DIG-Network/dig_ecosystem#3358: a per-cycle reading, never latched -- reset at the top + // of this function alongside every other per-cycle counter. Reported unconditionally, and + // logged when nonzero: a silently shrunk candidate set is exactly the failure this exists + // to prevent. + self.status.discovery_candidates_dropped_this_cycle = discovered.candidates_dropped; + if discovered.candidates_dropped > 0 { + tracing::warn!( + target: "rewards_claim", + dropped = discovered.candidates_dropped, + cap = MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, + "hinted launcher candidates over the per-cycle cap were NOT decoded" + ); + } + + let mut candidates: Vec = discovered + .distributors + .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 @@ -967,7 +987,7 @@ mod tests { use super::*; use crate::rewards_claim::hints::{DistributorHint, NoHintSource}; use crate::rewards_claim::parser::parse_launch_comment; - use crate::rewards_claim::types::DiscoveredDistributor; + use crate::rewards_claim::types::{DiscoveredDistributor, Discovery}; const DIG_ASSET_ID: Bytes32 = Bytes32::new([9u8; 32]); const OUR_PAYOUT_PUZZLE_HASH: Bytes32 = Bytes32::new([1u8; 32]); @@ -1046,20 +1066,21 @@ mod tests { #[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 discover_distributors(&self) -> Result { + Ok(Discovery { + distributors: self + .distributors + .lock() + .unwrap() + .values() + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + }) + .collect(), + candidates_dropped: 0, + }) } async fn resolve_launch_comment( @@ -1429,10 +1450,8 @@ mod tests { struct HintOnlyPort(FakeChainPort); #[async_trait] impl ClaimChainPort for HintOnlyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(Vec::new()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery::default()) } async fn resolve_launch_comment( &self, @@ -1518,9 +1537,7 @@ mod tests { struct AlwaysFaultingDiscoveryPort; #[async_trait] impl ClaimChainPort for AlwaysFaultingDiscoveryPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { Err(ClaimPortError::Other("simulated chain fault".into())) } async fn resolve_launch_comment( @@ -2829,9 +2846,7 @@ mod tests { #[async_trait] impl ClaimChainPort for FlakyThenHealthyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call_number == 1 { return Err(ClaimPortError::Unavailable); @@ -2938,9 +2953,7 @@ mod tests { #[async_trait] impl ClaimChainPort for HealthyThenUnavailablePort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call_number == 1 { return self.inner.discover_distributors().await; @@ -3062,13 +3075,11 @@ mod tests { #[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 discover_distributors(&self) -> Result { + let mut discovery = self.0.discover_distributors().await?; + let doubled = discovery.distributors.clone(); + discovery.distributors.extend(doubled); + Ok(discovery) } async fn resolve_launch_comment( &self, diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index d4714c58..6146f540 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -70,7 +70,9 @@ 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}; +pub use types::{ + ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, Discovery, OwnEntry, +}; #[cfg(test)] mod tests { diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index 9a4c57a3..1fe473d8 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use chia_protocol::Bytes32; -use super::types::{DiscoveredDistributor, OwnEntry}; +use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// Why a claim-chain call could not complete. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,7 +27,10 @@ pub enum ClaimPortError { 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>; + /// DIG-Network/dig_ecosystem#3358: the returned [`Discovery`] also carries + /// `candidates_dropped` -- a port that bounds how many candidates it will decode this call + /// MUST report how many it declined, never silently shrink the result. + async fn discover_distributors(&self) -> Result; /// 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). @@ -82,7 +85,7 @@ pub struct UnavailableClaimChainPort; #[async_trait] impl ClaimChainPort for UnavailableClaimChainPort { - async fn discover_distributors(&self) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { 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 index dfe22737..e7cd4e39 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -13,6 +13,23 @@ pub struct DiscoveredDistributor { pub root: Bytes32, } +/// One `discover_distributors` call's result: the distributors found AND how many candidates the +/// port declined to even decode -- DIG-Network/dig_ecosystem#3358. A bound on discovery that +/// silently swallowed the dropped count would be a censorship primitive (a cap that starves the +/// work it protects, never reported); this struct makes that count a first-class, always-present +/// field instead, so `RealClaimChainPort::discover_distributors` can never answer with fewer +/// distributors than it actually decoded without saying so. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Discovery { + /// Every distributor whose candidate id was actually decoded and verified this call. + pub distributors: Vec, + /// Candidates the port's own per-cycle cap declined to decode at all -- SPEC 13.1; + /// [`RealClaimChainPort`](super::chain_port::RealClaimChainPort)'s cap is + /// `MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`. `0` for every port that has no such cap (every + /// test double and [`super::port::UnavailableClaimChainPort`]). + pub candidates_dropped: u32, +} + /// 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)] @@ -267,6 +284,14 @@ pub struct ClaimStatus { /// 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, + /// DIG-Network/dig_ecosystem#3358: THIS CYCLE's count of hinted launcher candidates the port's + /// own per-cycle cap declined to decode at all -- distinct from [`Self::no_entry_slot_this_cycle`] + /// (those WERE decoded and simply had no entry). Reset at the top of every `run_cycle`, same + /// per-cycle discipline as every other counter on this struct: a stale nonzero count from a + /// PAST cycle must never sit under a fresh `last_attempt_at`. A nonzero reading here means a + /// legitimate distributor could have been silently excluded from this cycle's candidate set -- + /// see [`Discovery::candidates_dropped`], the field this is copied from. + pub discovery_candidates_dropped_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). @@ -296,6 +321,7 @@ impl Default for ClaimStatus { claims_refused_payout_mismatch: 0, payout_hash_mismatches_this_cycle: 0, no_entry_slot_this_cycle: 0, + discovery_candidates_dropped_this_cycle: 0, fault_reported: false, consecutive_faulted_cycles: 0, state: ClaimLoopState::Idle, diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs index 964bbb66..b8fcd0ff 100644 --- a/crates/dig-node-service/tests/common/rewards_fixture.rs +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -66,6 +66,16 @@ pub struct LaunchedFixture { /// `launch_dig_distributor` against a fresh `Simulator` — trimmed from /// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. pub fn launch_fixture() -> Result> { + launch_fixture_with_approval(false) +} + +/// Same as [`launch_fixture`], but with an explicit `require_payout_approval` -- DIG-Network/dig_ecosystem#3362 +/// needs a REAL simulator launch with the flag curried `true` (a fixture starting where production +/// cannot hides the bug -- a struct literal would never prove the chain-curried value is what the +/// adapter actually reads). +pub fn launch_fixture_with_approval( + require_payout_approval: bool, +) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -163,7 +173,7 @@ pub fn launch_fixture() -> Result> { // from the default, or a port that ignores the chain and returns the default constant // reads as correct by coincidence. See `reserve_asset_id_and_payout_threshold_are_read_from_chain`. PAYOUT_THRESHOLD_BASE_UNITS.saturating_add(1_000_000), - false, + require_payout_approval, 0, WITHDRAWAL_SHARE_BPS, source_cat.info.asset_id, @@ -457,6 +467,18 @@ pub struct FundedFixture { #[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs pub fn launch_funded_admitted_fixture( payout_puzzle_hash: Bytes32, +) -> Result> { + launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, false) +} + +/// Same as [`launch_funded_admitted_fixture`], but with an explicit `require_payout_approval` -- +/// DIG-Network/dig_ecosystem#3362 needs a REAL simulator launch (funded, admitted, above +/// threshold) with the flag curried `true`, not a struct literal a production read path could +/// never actually produce. +#[allow(dead_code)] +pub fn launch_funded_admitted_fixture_with_approval( + payout_puzzle_hash: Bytes32, + require_payout_approval: bool, ) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -553,7 +575,7 @@ pub fn launch_funded_admitted_fixture( u64::MAX, MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, - false, + require_payout_approval, 0, WITHDRAWAL_SHARE_BPS, source_cat.info.asset_id, @@ -618,6 +640,12 @@ pub fn launch_funded_admitted_fixture( )?, ); + // DIG-Network/dig_ecosystem#3357: safe here ONLY because `distributor` is this fixture's own + // freshly-built IN-PROCESS value -- these slots come from ITS OWN `pending_spend` this same + // generation, never from a distributor rebuilt from chain (where this call would derive a + // PHANTOM `LineageProof` for an earlier generation's slot). Production code must never call + // this; see `clippy.toml`'s `disallowed-methods` entry for the ban. + #[allow(clippy::disallowed_methods)] let reward_slots: Vec<_> = distributor .pending_spend .created_reward_slots diff --git a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs index 89da668f..ae3dfbd3 100644 --- a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -26,8 +26,8 @@ use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; use dig_wallet::sage::spend::MockBroadcaster; use common::rewards_fixture::{ - launch_fixture, launch_funded_admitted_fixture, mock_chain_source, - mock_chain_source_for_funded_fixture, + launch_fixture, launch_funded_admitted_fixture, launch_funded_admitted_fixture_with_approval, + mock_chain_source, mock_chain_source_for_funded_fixture, }; /// An index that proposes exactly the ids it is built with -- no re-verification of its own; that @@ -54,15 +54,19 @@ async fn discover_distributors_returns_exactly_the_real_launch() { Arc::new(MockBroadcaster::default()), ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("a real launched distributor must discover"); - assert_eq!(discovered.len(), 1); - assert_eq!(discovered[0].launcher_id, fixture.launcher_id); - assert_eq!(discovered[0].store_id, fixture.launch_comment.store_id); - assert_eq!(discovered[0].root, fixture.launch_comment.root); + assert_eq!(discovery.distributors.len(), 1); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); + assert_eq!( + discovery.distributors[0].store_id, + fixture.launch_comment.store_id + ); + assert_eq!(discovery.distributors[0].root, fixture.launch_comment.root); + assert_eq!(discovery.candidates_dropped, 0); } /// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be @@ -79,17 +83,74 @@ async fn a_bogus_index_entry_is_dropped_not_echoed() { Arc::new(MockBroadcaster::default()), ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("a bogus id must be dropped, not fail the whole discovery"); assert_eq!( - discovered.len(), + discovery.distributors.len(), 1, "an index lie must yield nothing for that id, and never overrule the real one" ); - assert_eq!(discovered[0].launcher_id, fixture.launcher_id); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); +} + +/// DIG-Network/dig_ecosystem#3358: a candidate cap that lands ON the real launcher id must drop it +/// and REPORT the drop -- never silently return fewer distributors than the caller can account for. +/// Uses [`RealClaimChainPort::with_candidate_cap`] pinned to 1 so this proves the drop without +/// decoding hundreds of candidates; the production constant stays +/// [`dig_node_service::rewards_claim::RealClaimChainPort`]'s own default (256). +#[tokio::test(flavor = "multi_thread")] +async fn a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::with_candidate_cap( + Arc::new(source), + FixtureLauncherIndex(vec![bogus_id, fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + 1, + ); + + let discovery = port + .discover_distributors() + .await + .expect("a capped cycle must still answer, never error, for the candidates it does try"); + + assert_eq!( + discovery.distributors.len(), + 0, + "the real launcher id sits past the cap of 1 and must be dropped, not decoded" + ); + assert_eq!( + discovery.candidates_dropped, 1, + "the one candidate past the cap must be reported, never silently absorbed" + ); +} + +/// The same cap, sized to admit every candidate -- proves the cap itself never drops anything when +/// there is nothing to drop (the companion proof to the capped case above). +#[tokio::test(flavor = "multi_thread")] +async fn a_cap_that_covers_every_candidate_drops_nothing() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::with_candidate_cap( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id, bogus_id]), + Arc::new(MockBroadcaster::default()), + 2, + ); + + let discovery = port + .discover_distributors() + .await + .expect("a real launched distributor must discover"); + + assert_eq!(discovery.distributors.len(), 1); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); + assert_eq!(discovery.candidates_dropped, 0); } /// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own @@ -171,13 +232,25 @@ async fn a_failing_source_reports_unavailable_everywhere() { MockChainSource::new().fail_with(dig_chainsource_interface::ChainSourceError::Transport( "simulated transport failure".into(), )); + let launcher_id = Bytes32::from([1u8; 32]); + // DIG-Network/dig_ecosystem#3363: a NON-empty index -- discovery must reach the failing + // source's own `Unavailable` answer, not stop short on an empty candidate list (which would + // pass this assertion for the wrong reason, without ever driving the source at all). let port = RealClaimChainPort::new( Arc::new(source), - FixtureLauncherIndex(vec![]), + FixtureLauncherIndex(vec![launcher_id]), Arc::new(MockBroadcaster::default()), ); - let launcher_id = Bytes32::from([1u8; 32]); + assert_eq!( + port.discover_distributors().await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.submit_initiate_payout(launcher_id, Bytes32::from([2u8; 32]), 0) + .await, + Err(ClaimPortError::Unavailable) + ); assert_eq!( port.reserve_asset_id(launcher_id).await, Err(ClaimPortError::Unavailable) @@ -376,6 +449,60 @@ async fn submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_t ); } +/// DIG-Network/dig_ecosystem#3362: a distributor that curries `require_payout_approval = true` +/// must be REFUSED, by name, before any bundle is built or broadcast -- this adapter drops +/// `initiate_payout`'s returned `conditions` unconditionally (see `chain_port.rs`'s module doc), so +/// proceeding here would build a bundle the chain would reject anyway, but only after this +/// adapter's caller believed the payout had been submitted. A REAL simulator launch with the flag +/// curried true (never a struct literal -- a fixture starting where production cannot reach hides +/// the bug), funded and admitted so the refusal is proven against an entry that would otherwise be +/// perfectly payable. +#[tokio::test(flavor = "multi_thread")] +async fn a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast() { + let payout_puzzle_hash = Bytes32::from([0x55; 32]); + let fixture = launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, true).expect( + "a funded, admitted distributor with require_payout_approval=true must launch cleanly", + ); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + let entry = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash"); + assert!( + entry.accrued_base_units >= fixture.constants.payout_threshold, + "the entry must clear its own threshold -- proving the refusal fires on a distributor that \ + would otherwise be perfectly payable, not merely an ineligible one" + ); + + let result = port + .submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 0) + .await; + match result { + Err(ClaimPortError::Other(msg)) => { + assert!( + msg.contains("require_payout_approval"), + "the refusal must name the reason: {msg}" + ); + } + other => panic!("expected a named refusal, got {other:?}"), + } + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 0, + "a require_payout_approval=true distributor must never reach the broadcaster" + ); +} + /// DIG-Network/dig_ecosystem#3347's CLOSURE ARTIFACT: drives the whole PRODUCTION BODY /// (`run_claim_driver_in`, the same function `run_claim_driver` calls in production, over a real /// `RealClaimChainPort`) against a real, funded, admitted distributor -- and asserts the payout @@ -412,12 +539,12 @@ async fn a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer() { "half an epoch with one entry must have accrued something" ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("discovery must not error"); assert_eq!( - discovered.len(), + discovery.distributors.len(), 1, "discovery must find the one real distributor this fixture launched" ); From af8b0107f077a0782cecf386ebd2f3e7ff51e6d1 Mon Sep 17 00:00:00 2001 From: mt-dev <5665004+MichaelTaylor3d@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:24:20 -0700 Subject: [PATCH 27/29] fix(rpc): gate node-local reward reads, rate-bound open chain reads (#621) Refs DIG-Network/dig_ecosystem#3352 DIG-Network/dig_ecosystem#3355 DIG-Network/dig_ecosystem#3351 Gates on cc87ac7a: loop-reviewer PASS, loop-security PASS (comment 5822471233), loop-decider adversarial PASS with M1 executed red (comment 5822423286). Five required contexts green by name. Co-Authored-By: Claude Fable 5.1 --- SPEC.md | 29 ++- .../src/seams/dig_rpc/dispatch.rs | 29 ++- crates/dig-node-service/src/meta.rs | 87 ++++++- crates/dig-node-service/src/server.rs | 233 +++++++++++++++++- .../tests/openrpc_drift_guard.rs | 13 +- crates/dig-node-service/tests/server.rs | 188 +++++++++++++- 6 files changed, 534 insertions(+), 45 deletions(-) diff --git a/SPEC.md b/SPEC.md index 96046691..6d7d6ace 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1044,7 +1044,7 @@ MUST NOT re-declare method names. Each entry carries a `served` class and `requi | `served` | Meaning | |---|---| -| `local` | Resolved by the node library (`handle_rpc`). | +| `local` | Resolved by the node library (`handle_rpc`). `requires_auth: false` except the HTTP-token-gated methods named below, which are `requires_auth: true`. | | `passthrough` | Read path returns `-32601`; relayed verbatim to the upstream WHEN one is configured (§5.4), else returned to the caller as `-32601`. | | `shell` | Answered by this service itself (`rpc.discover`). | | `control` | The gated control plane (§7); always `requires_auth: true`. | @@ -1056,8 +1056,11 @@ For the current node library (§2.2) the catalogue is: `dig.getCollection`, `dig.listCollectionItems`, the L7 peer surface (`dig.getNetworkInfo`, `dig.getPeers`, `dig.announce`, `dig.getAvailability`, `dig.listInventory`, `dig.fetchRange`), all `cache.*` (`cache.getConfig`, `cache.setCapBytes`, `cache.clear`, `cache.listCached`, - `cache.removeCached`, `cache.fetchAndCache`, `cache.pushCapsule` — §5.5.3), and the chat subsystem - `chat.send` / `chat.poll` (§5.5.2). + `cache.removeCached`, `cache.fetchAndCache`, `cache.pushCapsule` — §5.5.3), the chat subsystem + `chat.send` / `chat.poll` (§5.5.2), and the reward reads `dig.getRewardProverStatus`, + `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`, `dig.getRewardDistributor`, + `dig.listRewardDistributorCommitments` (dig_ecosystem#3352 / #3351 / #3355 — see the + `requires_auth` clause below). - **passthrough**: `dig.listCapsules` (needs a chain generation walk this node does not perform) and `dig.getProofStatus` (polls an execution-proof JOB this node does not run — inventing a status would be the fabrication the anti-fabrication rule forbids: an absent attestation is @@ -1077,8 +1080,18 @@ Param/result schemas for the `dig.*`/`cache.*` methods are owned by the digstore published on docs.dig.net (Protocol → the L7 read/RPC pages); this repo's OpenRPC document is a method + error **discovery** catalogue with intentionally permissive schemas. -Every non-`control.*` method MUST have `requires_auth: false`; every `control.*` method MUST have -`served: "control"` and `requires_auth: true`. +Every `control.*` method MUST have `served: "control"` and `requires_auth: true`. A non-`control.*` +method MUST have `requires_auth: false` UNLESS the HTTP surface token-gates it — today the +holder-/holdings-revealing `cache.fetchAndCache` / `cache.pushCapsule` / `cache.listCached` (§14.3, +#2108), the node-identity chat pair `chat.send` / `chat.poll` (#1946), and the NODE-LOCAL reward reads +`dig.getRewardProverStatus` / `dig.listRewardDistributors` / `dig.getPayeeRewardClaimStatus` +(dig_ecosystem#3352: each volunteers this node's own prover inventory, funded-distributor set or +payee claim state, failing §7.2's WHO-NAMES-THE-SUBJECT test). Those keep their `served` class and +carry `requires_auth: true`. `requires_auth` is the COMPILED statement of the HTTP token gate: the set +of catalogued methods with `requires_auth: true` MUST equal the set `server.rs` refuses `-32030 +UNAUTHORIZED` without a master or paired token (`requires_http_token`), and a test pins the equality. +The two chain-keyed reward reads `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` +are OPEN (dig_ecosystem#3351) and rate-bounded per source (§10, `-32034`). #### 5.5.0. `dig.getContent` — the window envelope (#2071) @@ -1469,8 +1482,9 @@ Two layers, both REQUIRED: mismatched credential is answered `UNAUTHORIZED` (`-32030`, §10). Token comparison MUST be constant-time (`ct_eq`) so verification cannot be probed via a timing oracle. -Exactly the `control.` method prefix is gated (`is_control_method`); unknown `control.*` methods -still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods +The `control.` method prefix is token-gated as a class (`is_control_method`); the HTTP surface +additionally token-gates the non-`control.*` methods §5.5 enumerates (`requires_http_token`); +unknown `control.*` methods still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods (`control.pairing.list`/`approve`/`revoke`, §7.11) require the MASTER token specifically — a paired token is NOT accepted for them. The exceptions are the wallet CHAIN READS — `control.wallet.balance`, `control.wallet.coins`, `control.wallet.coinById`, `control.wallet.coinSpend`, `control.wallet.coinsByParent`, @@ -3315,6 +3329,7 @@ method runs, and it MUST NOT be conflated with the wallet's own `-32043` egress | -32031 | `NOT_SUPPORTED` | shell | A control operation this build/pin cannot perform (e.g. §21 sync without an identity). | | -32032 | `CONTROL_ERROR` | shell | A control operation failed at runtime (distinct from bad input / absent capability). | | -32033 | `CONTROL_INGRESS_LIMITED` | shell | An OPEN, token-less `control.*` read was refused AT INGRESS, before the request reached the dispatcher and before any DB work was done for it: this SOURCE's request bound is exhausted. The open reads present no credential, so without this bound an unauthenticated caller can drive unbounded SQLite work (`.coinById`/`.coinSpend` each run up to two lookups plus an LRU `UPDATE`) simply by asking repeatedly. The bound is PER SOURCE — one flooding source MUST NOT refuse another — and the node's OWN loopback operator is EXEMPT, so this code is only ever seen by a non-loopback caller (i.e. under `DIG_NODE_ALLOW_REMOTE=1`). It MUST stay DISTINCT from `-32043 WALLET_RATE_LIMITED`: that bound is on chain EGRESS and protects the third-party oracle, this one is on REQUESTS and protects this process. They fire for different reasons and have different remedies, so collapsing them would leave a caller unable to tell which bound it hit. Back off and retry. | +| -32034 | `REWARD_INGRESS_LIMITED` | shell | An OPEN reward chain read (`dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`) was refused AT INGRESS: this SOURCE's request bound is exhausted. Each call is one upstream chain read for any caller-supplied launcher_id, so without this bound an anonymous caller drives unbounded upstream work. The bound is PER SOURCE (`RequestorId` — never the launcher id, which the caller controls); the loopback operator is EXEMPT, so only a `DIG_NODE_ALLOW_REMOTE=1` caller ever sees it. Distinct from -32033 (control-read ingress) and -32043 (wallet chain egress). Back off and retry. | | -32040 | `WALLET_NO_CHAIN_SOURCE` | node | a wallet chain read (`control.wallet.balance`/`.coins`/`.coinById`/`.coinSpend`/`.coinsByParent`/`.peak`) or `control.wallet.broadcast` had NO live chain source able to answer an arbitrary (non-wallet) address. Distinct from a truthful `0`. A read the node can answer WITHOUT a chain source MUST NOT be refused with this code: the replica fast path and the node own chain-read cache both answer from bytes already in hand, so on `.coinById`/`.coinSpend` liveness is consulted only on a cache MISS. Refusing a cached answer because a third party is momentarily unreachable gives availability away for nothing on exactly the rows a lineage walk re-reads (a spent coin record is immutable), and the refusal then cascades into the retries that exhaust the `-32043` bound. The refusal MUST stay for a miss, and `.coinSpend` MUST treat a PARTIAL cache hit (spend cached, coin record not) as a miss, because the heights come from the record. | | -32041 | `WALLET_NOT_SYNCED` | node | `control.wallet.balance` of the wallet's OWN address while the local DB is still syncing and no live fallback is attached (nothing can answer yet). | | -32042 | `WALLET_READ_FAILED` | node | `control.wallet.balance`/`.coins`/`.coinById`/`.coinSpend`/`.coinsByParent`/`.peak` failed at the underlying DB / chain-source layer. On `.coinById` this INCLUDES a chain source that answered with a record for a DIFFERENT coin than the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a substituted record is a failed READ -- never that coin's record, and never `coin: null`. On `.coinSpend` it likewise INCLUDES a source that answered with another coin's spend, a puzzle reveal that does not tree-hash to the spent coin's own `puzzle_hash` (or will not parse), and a spend the coin record contradicts (no record, or a record calling the coin unspent) -- each fails CLOSED rather than being served unverified. On `.coinsByParent` it INCLUDES a source that returned a child naming a different parent, which fails the WHOLE page rather than being silently filtered (a filtered page is a lineage with an invisible hole). Distinct from `WALLET_NO_CHAIN_SOURCE` and `WALLET_NOT_SYNCED`. | 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 6d6e1ded..256d08f2 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -907,10 +907,11 @@ impl RpcDispatch for Node { "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 (the token tier of - // this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's), 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 + // §2.3/§2.4) — `Tier::Control` = local dispatch only, never the mTLS peer surface + // (`reward_methods_tier_guard.rs`); token-GATED on `POST /` at + // `server.rs::is_node_local_reward_read` (master or paired token, `-32030`; + // dig_ecosystem#3352) because it volunteers node-local state; the in-process FFI path + // stays open like `cache.*`. 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":{"outcome":"consulted","observed_at":N,"items":[]}}` means "this node @@ -1013,7 +1014,11 @@ impl RpcDispatch for Node { // dig-node-service `tests/server.rs`. Chain-derived state ONLY — never the local prover // loop's self-reported state (see `GetRewardProverStatus` above for that). Goes entirely // through `rewards::port::RewardsChainPort`: this crate never calls `dig-rewards-coin` - // itself (dig_ecosystem#3269 unit 0). + // itself (dig_ecosystem#3269 unit 0). Token-LESS does not mean UNBOUNDED: staying open + // per dig_ecosystem#3351 above, `POST /` also rate-bounds this read PER SOURCE at + // ingress (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355, + // `server.rs::is_open_reward_chain_read`) — a caller-supplied `launcher_id` is never + // the limiter's key, only the source is. Some(Method::GetRewardDistributor) => { let params = req.get("params").cloned().unwrap_or(json!({})); let launcher_id = match parse_launcher_id_arg(¶ms) { @@ -1095,8 +1100,11 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } - // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — CONTROL plane, - // same guard shape as the other reward handlers above. Two independently-consulted + // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — `Tier::Control` + // = local dispatch only, never the mTLS peer surface (`reward_methods_tier_guard.rs`); + // token-GATED on `POST /` at `server.rs::is_node_local_reward_read` (master or paired + // token, `-32030`; dig_ecosystem#3352) because it volunteers node-local state; the + // in-process FFI path stays open like `cache.*`. Two independently-consulted // halves (`funded` / `claimable`), each a `Half` — SPEC §12.5 // clause 6's "reassuring zero" rule applies to EACH half separately. // @@ -1177,8 +1185,11 @@ impl RpcDispatch for Node { return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.getPayeeRewardClaimStatus (dig_ecosystem#3268/#3269 unit 3, SPEC §12.5) — - // CONTROL plane: loopback admin / in-process FFI ONLY, absent from - // `is_peer_reachable_method` (`reward_methods_tier_guard.rs` fails closed on that). + // `Tier::Control` = local dispatch only, never the mTLS peer surface + // (`reward_methods_tier_guard.rs`); token-GATED on `POST /` at + // `server.rs::is_node_local_reward_read` (master or paired token, `-32030`; + // dig_ecosystem#3352) because it volunteers node-local state; the in-process FFI path + // stays open like `cache.*`. // Dispatched through `Method::from_name(..)` like every other reward method — never // the string pre-match above the enum, which bypasses this tier guard entirely // (dig_ecosystem#3261: a reward RPC reachable by a peer is a money hole). diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 8e432c48..7dce128a 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -154,7 +154,7 @@ pub fn methods() -> &'static [MethodInfo] { name: "cache.listCached", served: "local", summary: "List cached capsules (storeId:rootHash).", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "cache.removeCached", @@ -166,7 +166,7 @@ pub fn methods() -> &'static [MethodInfo] { name: "cache.fetchAndCache", served: "local", summary: "Pre-fetch and cache a capsule.", - requires_auth: false, + requires_auth: true, }, MethodInfo { // #1476: the publish→seed push. Local-only by default; the HTTP surface adds a control-token @@ -181,7 +181,7 @@ pub fn methods() -> &'static [MethodInfo] { summary: "Push a freshly-committed capsule's bytes to seed this node as a holder \ (control-token gated over loopback; §21.9 authorized-writer signature when \ DIG_NODE_PUSH_OPEN=true).", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "cache.stats", @@ -601,7 +601,7 @@ pub fn methods() -> &'static [MethodInfo] { (base64 48-byte BLS G1 sealing key), peer_id (64-hex gossip target), \ envelope (base64 opaque DIGCHAT1) }; result { message_id (64-hex) }. \ recipient_pub + peer_id are app-supplied pending the key directory.", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "chat.poll", @@ -609,6 +609,53 @@ pub fn methods() -> &'static [MethodInfo] { summary: "Drain the node's inbound chat inbox. No params; result { messages: \ [{ sender_did (64-hex), message_id (64-hex), envelope (base64 opaque \ DIGCHAT1) }] } in arrival order.", + requires_auth: true, + }, + // -- reward reads (dig_ecosystem#3352 / #3355) — the three NODE-LOCAL reads volunteer this + // node's own prover inventory, funded-distributor set or payee claim state and are + // token-gated (`requires_http_token`); the two chain-keyed reads are OPEN (#3351) and + // rate-bounded per source at ingress instead (`-32034`). ------------------------------ + MethodInfo { + name: "dig.getRewardProverStatus", + served: "local", + summary: "This node's own reward-prover-loop status registry: { statuses }. \ + NODE-LOCAL — volunteers this node's own prover inventory — so it requires \ + the local control token or a paired token (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.listRewardDistributors", + served: "local", + summary: "This node's own funded/claimable reward-distributor identity sets: \ + { funded, claimable }. NODE-LOCAL — volunteers this node's own funded-\ + distributor set — so it requires the local control token or a paired token \ + (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.getPayeeRewardClaimStatus", + served: "local", + summary: "This node's own payee-side reward claim status. NODE-LOCAL — volunteers this \ + node's own claim state — so it requires the local control token or a paired \ + token (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.getRewardDistributor", + served: "local", + summary: "One reward distributor's chain-derived report for a caller-supplied \ + launcher_id. OPEN (dig_ecosystem#3351): the subject arrives in the request, \ + so no node-local association is disclosed. Rate-bounded per source at \ + ingress (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355).", + requires_auth: false, + }, + MethodInfo { + name: "dig.listRewardDistributorCommitments", + served: "local", + summary: "One reward distributor's clawback commitment slots for a caller-supplied \ + launcher_id. OPEN (dig_ecosystem#3351), same guard shape as \ + dig.getRewardDistributor: rate-bounded per source at ingress \ + (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355).", requires_auth: false, }, ] @@ -789,6 +836,16 @@ pub enum ErrorCode { /// leave the next person debugging a refusal unable to tell which bound they hit. Retriable: /// the caller should back off. Shell error (minted before dispatch). (Control range `-3203x`.) ControlIngressLimited, + /// `-32034` -- an OPEN reward chain read (`dig.getRewardDistributor` / + /// `dig.listRewardDistributorCommitments`) was refused AT INGRESS: this source's request + /// bound is exhausted (dig_ecosystem#3355). Each call is one upstream chain read for any + /// caller-supplied `launcher_id`, so without this bound an anonymous caller drives unbounded + /// upstream work. The bound is PER SOURCE (`RequestorId` -- never the launcher id, which the + /// caller controls); the loopback operator is EXEMPT, so only a `DIG_NODE_ALLOW_REMOTE=1` + /// caller ever sees it. Distinct from `-32033` (control-read ingress) and `-32043` + /// (wallet chain egress). Retriable: the caller should back off. Shell error (minted before + /// dispatch). (Control range `-3203x`.) + RewardIngressLimited, } /// The numeric code the shared wire contract assigns, widened to the `i64` the JSON-RPC @@ -838,6 +895,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused => -32060, ErrorCode::PushPendingLimited => -32016, ErrorCode::ControlIngressLimited => -32033, + ErrorCode::RewardIngressLimited => -32034, } } @@ -872,6 +930,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused => "PEER_PING_REFUSED", ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED", ErrorCode::ControlIngressLimited => "CONTROL_INGRESS_LIMITED", + ErrorCode::RewardIngressLimited => "REWARD_INGRESS_LIMITED", } } @@ -889,6 +948,7 @@ impl ErrorCode { | ErrorCode::ControlError // Minted by the control SERVER at ingress, before the request reaches the node. | ErrorCode::ControlIngressLimited + | ErrorCode::RewardIngressLimited // 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. @@ -991,6 +1051,11 @@ impl ErrorCode { "An open, token-less control read was refused at ingress: this source's request \ bound is exhausted. Distinct from WALLET_RATE_LIMITED, which bounds chain egress." } + ErrorCode::RewardIngressLimited => { + "An open reward chain read was refused at ingress: this source's request bound is \ + exhausted. Distinct from CONTROL_INGRESS_LIMITED (control reads) and \ + WALLET_RATE_LIMITED (chain egress)." + } } } @@ -1019,6 +1084,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused, ErrorCode::PushPendingLimited, ErrorCode::ControlIngressLimited, + ErrorCode::RewardIngressLimited, ] } } @@ -1552,9 +1618,16 @@ mod tests { ); assert_eq!(m.served, "control", "{} must be served=control", m.name); } else { - assert!( - !m.requires_auth, - "non-control method {} must NOT require auth", + // Not every non-`control.*` method is a public read: the cache trio, + // the chat pair and the three node-local reward reads are gated on + // `POST /` (dig_ecosystem#3352, SPEC §5.5) though they carry no + // `control.` prefix. `requires_http_token` is the compiled predicate + // that actually enforces the gate, so the catalogue must equal it + // exactly rather than assume every non-control method is open. + assert_eq!( + m.requires_auth, + crate::server::requires_http_token(m.name), + "{} catalogued requires_auth must equal requires_http_token", m.name ); } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 8a291270..e8543ba3 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -119,6 +119,13 @@ pub struct AppState { /// re-implemented — it is already a per-[`RequestorId`] token-bucket registry with the /// identity-cycling table bound this needs. control_ingress: Arc, + /// The per-source INGRESS bound on the two OPEN, chain-keyed reward reads + /// (dig_ecosystem#3355): `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`. + /// + /// A SEPARATE bucket from [`AppState::control_ingress`] (see `is_open_reward_chain_read`'s + /// call site): sharing one bucket would let one client's rewards-pane polling refuse its own + /// unrelated lineage-walk reads (or vice versa) under a code that names the wrong bound. + reward_ingress: Arc, /// §25.8's bond observation, as the last mirror pass published it (dig-node#412 step 7). /// /// Held on the shared state rather than rebuilt per request precisely so the control surface @@ -148,6 +155,18 @@ const CONTROL_INGRESS_REFILL_PER_SEC: f64 = 8.0; /// lowering the burst should fail the BUILD, not wait for someone to run the right test. const _: () = assert!(CONTROL_INGRESS_BURST >= 12.0); +/// Per-source burst for the two OPEN, chain-keyed reward reads (dig_ecosystem#3355): +/// `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`. Sized identically to +/// [`CONTROL_INGRESS_BURST`] for the same reason — "one rewards pane, a handful of reads" — +/// on its OWN bucket (`AppState::reward_ingress`) so it cannot refuse or be refused by the +/// unrelated control-read bound. +const REWARD_INGRESS_BURST: f64 = 32.0; + +/// Sustained per-source rate for OPEN reward chain reads once the burst is spent. Matches +/// [`CONTROL_INGRESS_REFILL_PER_SEC`]: comfortably above a human-driven refresh, far below what +/// makes the upstream chain work matter. +const REWARD_INGRESS_REFILL_PER_SEC: f64 = 8.0; + /// dig-node's "method not found" error code. `handle_rpc` resolves only /// `dig.getContent` / `dig.getAnchoredRoot` / `cache.*` and returns this for /// anything else; this service treats that as the cue to blind-passthrough the @@ -599,6 +618,10 @@ pub async fn build_state(config: &Config) -> AppState { CONTROL_INGRESS_BURST, CONTROL_INGRESS_REFILL_PER_SEC, )), + reward_ingress: Arc::new(dig_node_core::rate_limit::MissRateLimiter::new( + REWARD_INGRESS_BURST, + REWARD_INGRESS_REFILL_PER_SEC, + )), } } @@ -1259,9 +1282,20 @@ async fn rpc( // it never reaches this HTTP `rpc` handler. Anonymous public CONTENT reads remain ungated; only // these holder-/holdings-revealing methods are gated. (WS parity: `cache.*` is not routable over // `/ws` — the wallet-backend fall-through has no `cache.*` arm — asserted in the server tests.) + // + // FOLDED IN (dig_ecosystem#3352): the three NODE-LOCAL reward reads (`is_node_local_reward_read`) + // — `dig.getRewardProverStatus` (this node's own prover-inventory registry), `dig.listRewardDistributors` + // (this node's own funded-distributor identity set), and `dig.getPayeeRewardClaimStatus` (this + // node's own payee claim state) — join the SAME gate rather than duplicating the token-extraction + // block, because they fail the same WHO-NAMES-THE-SUBJECT test §7.2 already applies here: each + // volunteers a node-to-launcher-id or node-to-inventory ASSOCIATION nobody supplied, exactly the + // shape `cache.listCached` is gated for. `requires_http_token` is the single pure predicate this + // union compiles to; `openrpc_drift_guard`'s `served_classes_are_well_formed` pins the catalogue's + // `requires_auth` to it BY EQUALITY. if method == "cache.fetchAndCache" || method == "cache.pushCapsule" || method == "cache.listCached" + || is_node_local_reward_read(&method) { let header_tok = headers .get(control::CONTROL_TOKEN_HEADER) @@ -1281,17 +1315,46 @@ async fn rpc( Json(rpc_error( id, ErrorCode::Unauthorized, - "cache.fetchAndCache / cache.pushCapsule / cache.listCached require the local \ - control token (X-Dig-Control-Token header or params._control_token) or a paired \ - controller token (see `dig-node pair`): fetchAndCache/pushCapsule make this node \ - a durable DHT holder of the requested capsule, and listCached enumerates the \ - operator's cached-capsule inventory (deanonymizing consumed content) — none is a \ - public read", + "cache.fetchAndCache / cache.pushCapsule / cache.listCached / \ + dig.getRewardProverStatus / dig.listRewardDistributors / \ + dig.getPayeeRewardClaimStatus require the local control token \ + (X-Dig-Control-Token header or params._control_token) or a paired controller \ + token (see `dig-node pair`): fetchAndCache/pushCapsule make this node a durable \ + DHT holder of the requested capsule, listCached enumerates the operator's \ + cached-capsule inventory (deanonymizing consumed content), and the three reward \ + reads volunteer a node-to-launcher-id/inventory association nobody supplied — \ + none is a public read", )), ); } } + // OPEN reward-chain INGRESS bound (dig_ecosystem#3355): `dig.getRewardDistributor` and + // `dig.listRewardDistributorCommitments` stay OPEN (dig_ecosystem#3351) — each is one chain read + // for ANY caller-supplied `launcher_id` — so, exactly like the token-less `control.*` reads above, + // an anonymous caller could otherwise drive unbounded upstream chain work simply by asking, + // repeatedly, for free. Bounded PER SOURCE (`RequestorId`, never `launcher_id` — the caller + // supplies and controls that value, so a limiter keyed on it is a DoS primitive an attacker + // rotates around) on a SEPARATE bucket from `control_ingress`: sized for "one rewards pane, a + // handful of reads", not shared with the lineage-walk bound, so one client's rewards polling can + // never refuse its own unrelated wallet reads with a code that says the wrong bound fired. The + // loopback operator is exempt, identically to `control_ingress` (`control_ingress_admits` is + // already generic over the limiter). + if is_open_reward_chain_read(&method) + && !control_ingress_admits(&state.reward_ingress, &requestor) + { + return ( + StatusCode::OK, + Json(rpc_error( + id, + ErrorCode::RewardIngressLimited, + "open reward chain reads are rate-limited per source; back off and retry. This is \ + the INGRESS bound on requests to this node (per-source), distinct from \ + CONTROL_INGRESS_LIMITED (open control reads) and WALLET_RATE_LIMITED (chain-egress).", + )), + ); + } + // CHAT gate (F1, #1946): `chat.send` seals + BLS-signs a directed message as this node's OWN // 0x0010 identity, and `chat.poll` DRAINS the inbound inbox — both wield node-owned crypto/state, // so they require the control token exactly like `control.*` mutations. A loopback address alone @@ -1433,6 +1496,47 @@ fn is_gated_chat_method(method: &str) -> bool { matches!(method, "chat.send" | "chat.poll") } +/// The three NODE-LOCAL reward reads token-gated at HTTP ingress (dig_ecosystem#3352): each +/// volunteers a node-to-launcher-id or node-to-inventory ASSOCIATION nobody supplied — +/// `dig.getRewardProverStatus` (this node's own `reward_prover_statuses` registry), +/// `dig.listRewardDistributors` (this node's own `FundedDistributorRegistry`), and +/// `dig.getPayeeRewardClaimStatus` (this node's own payee-side claim state) — failing the same +/// WHO-NAMES-THE-SUBJECT test §7.2 already applies (SPEC §5.5). PURE. +fn is_node_local_reward_read(method: &str) -> bool { + matches!( + method, + "dig.getRewardProverStatus" + | "dig.listRewardDistributors" + | "dig.getPayeeRewardClaimStatus" + ) +} + +/// The two OPEN, chain-keyed reward reads rate-bounded per source at HTTP ingress +/// (dig_ecosystem#3355): `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments`, each +/// one `RewardsChainPort::distributor_report` call for any caller-supplied `launcher_id`. Does NOT +/// include `dig.listRewardDistributors` — that read does N chain reads too, but it is token-GATED +/// per dig_ecosystem#3352, so a presented credential is already accountable for it, same as every +/// `control.*` method. PURE. +fn is_open_reward_chain_read(method: &str) -> bool { + matches!( + method, + "dig.getRewardDistributor" | "dig.listRewardDistributorCommitments" + ) +} + +/// Whether `method` requires the HTTP token gate (master control token OR a valid paired token) at +/// this `POST /` ingress — the COMPILED statement the catalogue's `MethodInfo::requires_auth` must +/// equal (`openrpc_drift_guard::served_classes_are_well_formed` pins the equality; see SPEC §5.5). +/// The union: the holder-/holdings-revealing `cache.*` landing trio, the node-owned-identity chat +/// pair, and the three node-local reward reads (dig_ecosystem#3352). PURE. +pub fn requires_http_token(method: &str) -> bool { + method == "cache.fetchAndCache" + || method == "cache.pushCapsule" + || method == "cache.listCached" + || is_gated_chat_method(method) + || is_node_local_reward_read(method) +} + /// Whether `token` authorizes a gated chat call (F1, #1946): the master control token (constant-time) /// OR a valid paired controller token — the same master-or-paired policy that gates `control.*` and /// the wallet surface. Fails CLOSED on an empty master (the in-memory CSPRNG-failure sentinel) so a @@ -3379,8 +3483,9 @@ fn spawn_collateral_census(chain: Arc) mod tests { use super::{ chat_call_authorized, control_ingress_admits, is_app_origin, is_gated_chat_method, - is_local_origin, peer_tier_status, provenance_for, read_origin_for, reflects_origin, - requestor_for, served_response, ws_token, ServeProvenance, StorePath, APP_ORIGINS_ENV, + is_local_origin, is_node_local_reward_read, is_open_reward_chain_read, peer_tier_status, + provenance_for, read_origin_for, reflects_origin, requestor_for, requires_http_token, + served_response, ws_token, ServeProvenance, StorePath, APP_ORIGINS_ENV, EXPOSED_DIG_HEADERS, }; use axum::http::{HeaderMap, Method}; @@ -3520,6 +3625,118 @@ mod tests { ); } + /// **Proves (dig_ecosystem#3355):** the two OPEN chain-keyed reward reads + /// (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`) are rate-bounded PER + /// SOURCE, never per `launcher_id` — the caller supplies and controls `launcher_id`, so a + /// limiter keyed on it would be a DoS primitive an attacker rotates around for free. + /// + /// Drives the predicate/limiter pair directly, exactly as + /// `an_anonymous_flood_is_refused_at_ingress_once_its_burst_is_spent` does for the sibling + /// `control_ingress` bound: a real HTTP test can't drive a non-loopback `RequestorId` (the test + /// client IS the loopback operator), so the meaningful assertion lives here, at the pair the + /// HTTP gate calls. + /// + /// **Catches (M3):** a limiter keyed on `launcher_id` instead of `RequestorId` — the same + /// requestor rotating which `launcher_id` it names in `params` must stay refused once its own + /// budget is spent; a different SOURCE must never be touched by another source's burst. + #[test] + fn open_reward_chain_reads_are_rate_bounded_per_source() { + let limiter = MissRateLimiter::new(32.0, 8.0); + let source_a = anon("198.51.100.7"); + let source_b = anon("198.51.100.8"); + + for i in 0..32 { + assert!( + control_ingress_admits(&limiter, &source_a), + "call {i} is within the 32-burst and must be admitted" + ); + } + assert!( + !control_ingress_admits(&limiter, &source_a), + "the 33rd call from the same source must be refused: its burst is spent" + ); + assert!( + control_ingress_admits(&limiter, &source_b), + "a different source draws from its own bucket and is untouched by source_a's burst" + ); + // M3: the SAME requestor rotating which `launcher_id` it names must stay refused — the + // limiter is keyed on the connection's `RequestorId`, never on caller-supplied request + // content, so nothing about the (unmodelled here) launcher_id can revive its budget. + assert!( + !control_ingress_admits(&limiter, &source_a), + "the same source must stay refused regardless of what launcher_id it names in params" + ); + } + + /// Pins the two OPEN reward reads and no others into `is_open_reward_chain_read` — the + /// predicate the ingress bound gates on. `dig.listRewardDistributors` does N chain reads too, + /// but it is token-GATED per dig_ecosystem#3352 (a presented credential is accountable, same as + /// every `control.*` method), so it must NOT be in this OPEN, per-source-bounded set. + #[test] + fn is_open_reward_chain_read_is_exactly_the_two_open_reads() { + assert!(is_open_reward_chain_read("dig.getRewardDistributor")); + assert!(is_open_reward_chain_read( + "dig.listRewardDistributorCommitments" + )); + assert!( + !is_open_reward_chain_read("dig.listRewardDistributors"), + "listRewardDistributors is token-gated per #3352, not ingress-bounded" + ); + assert!(!is_open_reward_chain_read("dig.getRewardProverStatus")); + assert!(!is_open_reward_chain_read("dig.getPayeeRewardClaimStatus")); + assert!(!is_open_reward_chain_read("dig.getContent")); + } + + /// Pins the three NODE-LOCAL reward reads (and no others) into `is_node_local_reward_read` — + /// the predicate the HTTP token gate folds in beside the `cache.*` trio and the chat pair. + #[test] + fn is_node_local_reward_read_is_exactly_the_three_gated_reads() { + assert!(is_node_local_reward_read("dig.getRewardProverStatus")); + assert!(is_node_local_reward_read("dig.listRewardDistributors")); + assert!(is_node_local_reward_read("dig.getPayeeRewardClaimStatus")); + assert!(!is_node_local_reward_read("dig.getRewardDistributor")); + assert!(!is_node_local_reward_read( + "dig.listRewardDistributorCommitments" + )); + assert!(!is_node_local_reward_read("dig.getContent")); + } + + /// **Proves:** `requires_http_token` is exactly the union of the cache-trio landing gate, the + /// gated chat pair, and the three node-local reward reads — the single pure predicate the + /// `openrpc_drift_guard` equality test (`served_classes_are_well_formed`) pins the catalogue + /// against. + /// + /// **Catches (M1):** dropping `is_node_local_reward_read` from the union — the three reward + /// reads would stop requiring a token here while the catalogue still says they do, and the + /// drift-guard equality test (not this one) is what actually trips on that; this test pins the + /// union's OWN membership so a future edit to the fold does not silently narrow it. + #[test] + fn requires_http_token_is_the_gate_plus_reward_reads_union() { + for m in [ + "cache.fetchAndCache", + "cache.pushCapsule", + "cache.listCached", + "chat.send", + "chat.poll", + "dig.getRewardProverStatus", + "dig.listRewardDistributors", + "dig.getPayeeRewardClaimStatus", + ] { + assert!(requires_http_token(m), "{m} must require the HTTP token"); + } + for m in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + "dig.getContent", + "control.status", + ] { + assert!( + !requires_http_token(m), + "{m} must not require the HTTP token via this predicate" + ); + } + } + /// **Regression (#1763):** the `X-Dig-Peer-Tier` wire value for BOTH tiers, asserted on the real /// response builder rather than on the enum alone. /// diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index 5aa95e60..7412ddf4 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -272,9 +272,16 @@ fn control_peers_connect_disconnect_are_catalogued_and_not_peer_reachable() { fn served_classes_are_well_formed() { for m in meta::methods() { match m.served { - "local" | "passthrough" | "shell" => assert!( - !m.requires_auth, - "{} is a read/discovery method and must not require auth", + // `requires_auth` is the COMPILED statement of the HTTP token gate (SPEC §5.5): the + // set of catalogued methods with `requires_auth: true` MUST equal the set + // `server::requires_http_token` gates at `POST /` — not merely "false for every + // read", since dig_ecosystem#3352 token-gates a handful of non-`control.*` reads + // (the cache trio, the chat pair, the three node-local reward reads) that volunteer + // node-local state. + "local" | "passthrough" | "shell" => assert_eq!( + m.requires_auth, + dig_node_service::server::requires_http_token(m.name), + "{}: catalogued requires_auth must equal requires_http_token", m.name ), "control" => { diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 92b63f44..7846a755 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,17 +1535,18 @@ async fn cache_list_cached_is_not_routable_over_ws() { ); } -/// **Proves (dig_ecosystem#3351, WS parity):** `dig.getRewardDistributor` and -/// `dig.listRewardDistributorCommitments` are OPEN reads on the HTTP transport (no token required), -/// but that openness must not accidentally widen into a SECOND, WS-reachable path. The `ws_dispatch` -/// fall-through routes an unrecognized method to `WalletBackend::dispatch`, whose match has no -/// `dig.*` arm, so both methods come back as an unknown-method error over `/ws` -- never as -/// `UNAUTHORIZED` (that would mean WS gates them where HTTP does not, which is its own bug) and -/// never as a real result (that would mean the reward-chain answer leaked over an unaudited -/// transport). +/// **Proves (dig_ecosystem#3351/#3352, WS parity):** ALL FIVE reward reads -- the two OPEN, +/// chain-keyed reads (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`) and the +/// three HTTP-token-gated, node-local reads (`dig.getRewardProverStatus`, +/// `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`) -- have no WS-reachable path at +/// all, regardless of which HTTP tier each carries. The `ws_dispatch` fall-through routes an +/// unrecognized method to `WalletBackend::dispatch`, whose match has no `dig.*` arm, so every one +/// comes back as an unknown-method error over `/ws` -- never as `UNAUTHORIZED` (that would mean WS +/// gates a method where HTTP does not, or vice versa, either of which is its own bug) and never as +/// a real result (that would mean a reward answer leaked over an unaudited transport). /// -/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing `dig.*` reward reads over -/// `/ws` without the tier decision being revisited. +/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing any `dig.*` reward read +/// over `/ws` without the tier decision being revisited. #[tokio::test] async fn reward_distributor_reads_are_not_routable_over_ws() { use tokio_tungstenite::tungstenite::Message; @@ -1560,11 +1561,15 @@ async fn reward_distributor_reads_are_not_routable_over_ws() { for (idx, method) in [ "dig.getRewardDistributor", "dig.listRewardDistributorCommitments", + "dig.getRewardProverStatus", + "dig.listRewardDistributors", + "dig.getPayeeRewardClaimStatus", ] .into_iter() .enumerate() { - // No token: these reads are OPEN on HTTP, but that has no bearing on WS routability. + // No token: these reads (OPEN or HTTP-token-gated) have no bearing on WS routability -- + // the WS transport simply never dispatches ANY reward method (dig_ecosystem#3352/#3355). ws.send(Message::Text( json!({ "id": format!("rd{idx}"), "type": "request", "method": method }).to_string(), )) @@ -3962,3 +3967,164 @@ async fn reward_distributor_reads_answer_on_post_slash_without_a_token() { ); } } + +/// Proves (dig_ecosystem#3352): the three NODE-LOCAL reward reads (`dig.getRewardProverStatus`, +/// `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`) are token-gated at the HTTP +/// `POST /` ingress exactly like the `cache.*` landing trio: an untokened call is `-32030 +/// UNAUTHORIZED` and the response body carries NONE of the fields the handler would otherwise +/// return (`statuses`/`funded`/`subject`) -- a demoted gate that still leaked the payload alongside +/// the error would defeat the whole point. The master control token AND a genuine paired token each +/// clear the gate and reach the real handler (proven by the handler-specific shape each answers +/// with on this ephemeral, chain-portless node). +/// +/// Catches: any of the three reads left OPEN (or newly gated but leaking a body on refusal), and a +/// paired-token caller wrongly excluded from a gate that (per #3352, unlike wallet mutations) is +/// master-OR-paired, not master-only. +#[tokio::test] +async fn node_local_reward_reads_require_the_control_token() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, master, _hold) = start_node_full(&upstream).await; + + // A genuine paired token, obtained exactly as the extension does (see + // `a_paired_token_cannot_grant_itself_a_trusted_chia_peer`). + let req = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 1, "method": "pairing.request", + "params": { "client_name": "DIG Chrome Extension" } }), + None, + ) + .await; + let pairing_id = req["result"]["pairing_id"].as_str().unwrap().to_string(); + let approve = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 2, "method": "control.pairing.approve", + "params": { "pairing_id": pairing_id } }), + Some(&master), + ) + .await; + assert_eq!(approve["result"]["approved"], json!(true)); + let paired = poll_pairing(&addr, &pairing_id).await["result"]["token"] + .as_str() + .unwrap() + .to_string(); + + let cases: &[(&str, &str)] = &[ + ("dig.getRewardProverStatus", "statuses"), + ("dig.listRewardDistributors", "funded"), + ("dig.getPayeeRewardClaimStatus", "subject"), + ]; + + for (method, leaked_field) in cases { + let body = json!({ "jsonrpc": "2.0", "id": 9, "method": method }); + + // No token: UNAUTHORIZED, and the payload never leaks alongside the refusal. + let rejected = post_rpc(&addr, body.clone(), None).await; + assert_eq!( + rejected["error"]["code"], + json!(-32030), + "{method} without a token must be -32030, got {rejected:?}" + ); + assert_eq!( + rejected["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} without a token must be UNAUTHORIZED, got {rejected:?}" + ); + assert!( + rejected + .pointer(&format!("/result/{leaked_field}")) + .is_none(), + "{method} must never leak /result/{leaked_field} on a rejected call, got {rejected:?}" + ); + + // Master control token: clears the gate, reaches the real handler. + let via_master = post_rpc(&addr, body.clone(), Some(&master)).await; + assert_ne!( + via_master["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} with the master control token must clear the gate, got {via_master:?}" + ); + + // Paired token: clears the gate too (master-OR-paired, same as the cache trio). + let via_paired = post_rpc(&addr, body, Some(&paired)).await; + assert_ne!( + via_paired["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} with a paired token must clear the gate, got {via_paired:?}" + ); + } + + // Handler-specific shape, proving dispatch (not a stub) answered each authorized call. + let prover = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 10, "method": "dig.getRewardProverStatus" }), + Some(&master), + ) + .await; + assert_eq!( + prover["result"]["statuses"]["outcome"], + json!("consulted"), + "got {prover:?}" + ); + + let listed = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 11, "method": "dig.listRewardDistributors" }), + Some(&master), + ) + .await; + // This ephemeral node never writes a funded-distributor registry record, so the honest + // answer is "nothing looked" (`not_consulted`), NEVER a "consulted, found nothing" that + // would be SPEC §12.5 clause 6's forbidden reassuring zero (`FundedDistributorsRead:: + // NotConfigured`, dig_ecosystem#3269 unit 2). This still proves dispatch reached the real + // handler (a stub or a gate leak would answer neither `funded` nor `claimable` at all). + assert_eq!( + listed["result"]["funded"]["outcome"], + json!("not_consulted"), + "got {listed:?}" + ); + assert_eq!( + listed["result"]["claimable"]["outcome"], + json!("not_consulted"), + "got {listed:?}" + ); + + let payee = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 12, "method": "dig.getPayeeRewardClaimStatus" }), + Some(&master), + ) + .await; + assert_eq!(payee["result"]["subject"], json!("payee"), "got {payee:?}"); +} + +/// Proves (dig_ecosystem#3355): the loopback operator, driving the two OPEN chain-keyed reward +/// reads (`dig.getRewardDistributor`) well past the 32-call burst that bounds a per-source flood, is +/// NEVER refused with `REWARD_INGRESS_LIMITED` -- the ingress bound exempts `RequestorId::Local` +/// exactly as the existing `control_ingress` bound does (`control_ingress_admits`). The +/// non-exempt/anonymous side of this bound cannot be driven over a real loopback TCP connection (the +/// test client IS the operator), so it is pinned at the predicate/limiter level instead -- +/// `open_reward_chain_reads_are_rate_bounded_per_source` in `server.rs`'s own unit tests, beside the +/// sibling `control_ingress` tests it mirrors. +/// +/// Catches: an ingress bound wired onto the OPEN reward reads that forgets the loopback exemption, +/// reproducing the #3051 failure (a polling operator refused its own reads) one bound over. +#[tokio::test] +async fn open_reward_chain_reads_never_limit_the_loopback_operator() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _hold) = start_node(&upstream).await; + let launcher_id = "11".repeat(32); + for i in 0..40 { + let resp = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": i, "method": "dig.getRewardDistributor", + "params": { "launcher_id": launcher_id } }), + None, + ) + .await; + assert_ne!( + resp["error"]["data"]["code"], + json!("REWARD_INGRESS_LIMITED"), + "call {i} from the loopback operator must never be -32034, got {resp:?}" + ); + } +} From 6fe86472e1ecc9bb62bde180e543eabc4da2ae36 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 24 Sep 2026 19:23:07 -0700 Subject: [PATCH 28/29] chore(release): v0.261.0 -- reward RPC ingress hardening Bump the workspace version 0.260.0 -> 0.261.0 (Cargo.toml [workspace.package].version and the dig-node-service entry in Cargo.lock) and title the CHANGELOG's pending section 0.261.0. The changelog's #3352 entry is written from the enforcement, not the ticket title: the three node-local reads were ALREADY Tier::Control before this release, so what changed is the POST / TOKEN gate (server.rs::is_node_local_reward_read, -32030), not the tier. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 ++++++++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feca10ce..5b3416ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,20 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). -## [Unreleased] +## [0.261.0] - 2026-09-24 + +### Reward RPC ingress: token gate and rate bound +- Token-gate the three NODE-LOCAL reward reads on `POST /` (`dig.getRewardProverStatus`, + `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`): a master control token or a valid + paired token is now required, `-32030` otherwise. They were already `Tier::Control` (local + dispatch only, never the mTLS peer surface); what changed is that the anonymous `POST /` path no + longer volunteers node-local state, so the served catalogue's `requires_auth` and the enforced + predicate agree (#3352) +- Rate-bound the two OPEN chain-keyed reward reads per SOURCE at HTTP ingress + (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`), `-32034 + REWARD_INGRESS_LIMITED`, so an anonymous caller can no longer drive an unbounded number of + upstream chain reads. The limiter is keyed on the source, never on the caller-supplied + `launcher_id` (#3355) ### Reward claim port hardening - Ban `RewardDistributor::created_slot_value_to_slot` from production code via a workspace diff --git a/Cargo.lock b/Cargo.lock index 788ff692..7f57fc7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.260.0" +version = "0.261.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 31e48aa5..5d41b816 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.260.0" +version = "0.261.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 9d0f98ac9b7612e24b7ca6b69dac6b7813b601ce Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 24 Sep 2026 20:17:25 -0700 Subject: [PATCH 29/29] fix(rewards-claim): remove the #3358 discovery cap from the v0.261.0 cut The per-cycle hinted-launcher discovery cap is a censorship primitive on the money path, so it comes out of this release entirely rather than shipping disabled. Candidates reach `RealClaimChainPort::discover_distributors` from `coin_records_by_hints(tree_hash("Reward Distributor v1"))`. Anyone can mint a hinted 1-mojo launcher coin, and the adapter decoded only the first N of that index in whatever order the chain transport returned them -- an order the constant's own doc conceded is attacker-influenceable. Roughly 257 cheap coins were enough to push a legitimate distributor past the window and out of the cycle's candidate set. The node keeps reporting healthy and silently stops earning. There is no second route in production. `run_claim_driver_in_with_clock` wires `NoHintSource`, whose `hints()` returns an empty vector, so the gossip-hint loop that makes the generic `ClaimEngine` look multi-sourced is dead in the shipped binary. An earlier review called the cap non-blocking on the strength of that second source; that reasoning does not hold for what we ship. Removed outright rather than neutered: the constant and its doc block, the `candidate_cap` field and its `with_candidate_cap` constructor, the `.take(candidate_cap)` decode bound, `Discovery::candidates_dropped`, `ClaimStatus::discovery_candidates_dropped_this_cycle` and its per-cycle reset and `warn!`. A counter that can only ever read zero is a reassuring zero -- a reader cannot tell "nothing was dropped" from "nothing is counted", so the field must not exist. This restores the unbounded decode v0.260.0 already ships. That is a DoS surface, but a loud one, and it is a known, gated, shipped state rather than a new exposure. DIG-Network/dig_ecosystem#3358 stays OPEN. The bound has to land together with a persisted rejected-launcher cache and a wire projection of the drop count; any one of the three alone reproduces the shape removed here. CHANGELOG: drops the #3358 bullet, qualifies #3357 as the dig-node half only (dig-account and dig-app halves remain open), and records the served OpenRPC catalogue's `requires_auth` flip from false to true on five already-gated methods, which is visible to every `rpc.discover` consumer. Refs DIG-Network/dig_ecosystem#3358 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++- .../src/rewards_claim/chain_port.rs | 73 +------------------ .../src/rewards_claim/driver.rs | 1 - .../src/rewards_claim/engine.rs | 17 ----- .../src/rewards_claim/port.rs | 3 - .../src/rewards_claim/types.rs | 21 +----- .../tests/rewards_claim_chain_port_3347.rs | 46 ++---------- 7 files changed, 16 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3416ac..1a40ce87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,15 +18,19 @@ This project adheres to [Semantic Versioning](https://semver.org) and REWARD_INGRESS_LIMITED`, so an anonymous caller can no longer drive an unbounded number of upstream chain reads. The limiter is keyed on the source, never on the caller-supplied `launcher_id` (#3355) +- The served OpenRPC catalogue now reports `requires_auth: true` for five methods that previously + advertised `false` while already being enforced as authenticated: `cache.listCached`, + `cache.fetchAndCache`, `cache.pushCapsule`, `chat.send` and `chat.poll`. No enforcement changed + -- the catalogue was describing these methods wrongly. Every `rpc.discover` consumer that reads + `requires_auth` will see the flip (#3352) ### Reward claim port hardening - Ban `RewardDistributor::created_slot_value_to_slot` from production code via a workspace `disallowed-methods` clippy lint (phantom `LineageProof` on a chain-rebuilt distributor); allow - the one legitimate in-process test-fixture use (#3357) + the one legitimate in-process test-fixture use. This is the dig-node HALF of #3357 only -- + the ticket also has dig-account and dig-app halves and remains OPEN (#3357) - Refuse, by name, a distributor requiring payout approval in `submit_initiate_payout` before building or broadcasting anything (#3362) -- Bound hinted launcher discovery candidates per cycle and report every drop via - `Discovery.candidates_dropped` / `ClaimStatus.discovery_candidates_dropped_this_cycle` (#3358) - Fix a claim-port regression test to use a non-empty launcher index so it actually exercises the failing chain source's discovery/submit paths (#3363) diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs index d267d53a..c3f56266 100644 --- a/crates/dig-node-service/src/rewards_claim/chain_port.rs +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -49,41 +49,6 @@ use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// applied here at the source so every producer of a bounded string agrees on the bound. const MAX_ERROR_CHARS: usize = 200; -/// DIG-Network/dig_ecosystem#3358: the most hinted launcher candidates -/// [`RealClaimChainPort::discover_distributors`] will decode in one call. -/// -/// # Where the number comes from -/// `dig_rewards_coin`'s own `DECODE_MAX_SERIALIZED_BYTES` bounds ONE candidate's decode at 64 KiB -/// (65_536 bytes); `256 * 65_536 = 16_777_216` bytes -- a 16 MiB decode ceiling for one -/// `discover_distributors` call -- plus 256 parent-spend chain reads, one per candidate. -/// -/// # What this bound does NOT cover -- read this before assuming discovery is safe -/// 1. It does not bound gossip hints: `ClaimEngine::run_cycle`'s hint loop (`engine.rs`, -/// `self.hints.hints()`, feeding `resolve_launch_comment` one candidate at a time) is a -/// SEPARATE, unbounded path -- out of scope for this cap, named here so it is not mistaken for -/// covered. -/// 2. It does not choose WHICH candidates survive: this adapter decodes the index's first N in -/// WHATEVER ORDER the chain transport returned them, and that order is attacker-influenceable -/// (`HintedLauncherIndex` proposes every hinted coin its peers have seen) -- a flood of bogus -/// hinted coins ahead of a legitimate launcher in that order can push the legitimate one past -/// the cap and out of this cycle's candidate set. -/// 3. It does not persist "already decoded and rejected" across calls -- a dropped-for-real -/// candidate is re-attempted (and can be re-dropped) every cycle rather than being remembered -/// and skipped cheaply; left as a follow-up, not implemented here. -/// 4. It does not bound the COST of decoding one candidate -- that is -/// `DECODE_MAX_SERIALIZED_BYTES`'s job, not this cap's. -/// 5. It authenticates nothing -- every surviving candidate is still re-verified through the real -/// memo decode in [`resolve_via_chain`] exactly as before this cap existed; this cap only -/// decides how many candidates get that far. -/// -/// A drop is never silent: [`RealClaimChainPort::discover_distributors`] reports how many -/// candidates it declined via [`Discovery::candidates_dropped`], and -/// [`super::engine::ClaimEngine::run_cycle`] copies that count into -/// [`super::types::ClaimStatus::discovery_candidates_dropped_this_cycle`] and logs a `warn!` when -/// it is nonzero -- a silent cap on discovery is the exact censorship-primitive shape this ticket -/// exists to avoid. -pub const MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE: usize = 256; - fn bounded(message: impl Into) -> String { let message = message.into(); if message.chars().count() <= MAX_ERROR_CHARS { @@ -119,11 +84,6 @@ where source: Arc, index: I, broadcaster: Arc, - /// DIG-Network/dig_ecosystem#3358: how many hinted candidates one `discover_distributors` call - /// will decode -- [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] in production; - /// [`Self::with_candidate_cap`] overrides it for a test that needs a small cap to exercise - /// dropping without decoding hundreds of candidates. - candidate_cap: usize, } impl RealClaimChainPort @@ -133,34 +93,13 @@ where { /// Wraps an already-constructed chain source, launcher index and broadcaster. Takes the source /// by `Arc` (mirroring `rewards::chain_port::RealRewardsChainPort::new`) since a blocking read - /// clones it into a `spawn_blocking` closure on every call. Uses the production - /// [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] cap -- see [`Self::with_candidate_cap`] to - /// override it. + /// clones it into a `spawn_blocking` closure on every call. #[must_use] pub fn new(source: Arc, index: I, broadcaster: Arc) -> Self { Self { source, index, broadcaster, - candidate_cap: MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, - } - } - - /// Same as [`Self::new`] with an explicit candidate cap -- production never calls this; it - /// exists so a test can pin a small cap and prove the drop-and-report behaviour without - /// decoding hundreds of candidates. - #[must_use] - pub fn with_candidate_cap( - source: Arc, - index: I, - broadcaster: Arc, - candidate_cap: usize, - ) -> Self { - Self { - source, - index, - broadcaster, - candidate_cap, } } } @@ -258,17 +197,10 @@ where async fn discover_distributors(&self) -> Result { let candidate_ids = self.index.launcher_ids().await?; let source = Arc::clone(&self.source); - let candidate_cap = self.candidate_cap; tokio::task::spawn_blocking(move || { - // DIG-Network/dig_ecosystem#3358: bound how many candidates one call will decode -- - // see MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE's doc for what this does and does not - // protect. The drop count is REPORTED, never silently absorbed. - let total = candidate_ids.len(); - let candidates_dropped = total.saturating_sub(candidate_cap) as u32; - let mut discovered = Vec::new(); - for launcher_id in candidate_ids.into_iter().take(candidate_cap) { + for launcher_id in candidate_ids { // SPEC 13.1 clause 6: the index only PROPOSES; every id is re-verified through the // real memo decode. An id the decode rejects (unknown to `source`, or a spend that // is not a DIG rewards launch) is DROPPED, never echoed back. @@ -281,7 +213,6 @@ where } Ok(Discovery { distributors: discovered, - candidates_dropped, }) }) .await diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 70729f06..c23543bf 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1143,7 +1143,6 @@ mod tests { store_id: Bytes32::from([0u8; 32]), root: Bytes32::from([0u8; 32]), }], - candidates_dropped: 0, }) } async fn resolve_launch_comment( diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 0096666a..56cbc05c 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -6,7 +6,6 @@ use std::path::{Path, PathBuf}; use chia_protocol::Bytes32; -use super::chain_port::MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE; use super::config::RewardsClaimConfig; use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; @@ -360,7 +359,6 @@ impl ClaimEngine { self.status.distributors_faulted = 0; self.status.claims_submitted_this_cycle = 0; self.status.no_entry_slot_this_cycle = 0; - self.status.discovery_candidates_dropped_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 @@ -490,20 +488,6 @@ impl ClaimEngine { self.status.last_discovery_at = Some(now); } - // DIG-Network/dig_ecosystem#3358: a per-cycle reading, never latched -- reset at the top - // of this function alongside every other per-cycle counter. Reported unconditionally, and - // logged when nonzero: a silently shrunk candidate set is exactly the failure this exists - // to prevent. - self.status.discovery_candidates_dropped_this_cycle = discovered.candidates_dropped; - if discovered.candidates_dropped > 0 { - tracing::warn!( - target: "rewards_claim", - dropped = discovered.candidates_dropped, - cap = MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, - "hinted launcher candidates over the per-cycle cap were NOT decoded" - ); - } - let mut candidates: Vec = discovered .distributors .iter() @@ -1079,7 +1063,6 @@ mod tests { root: d.root, }) .collect(), - candidates_dropped: 0, }) } diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index 1fe473d8..36f02f16 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -27,9 +27,6 @@ pub enum ClaimPortError { 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`]. - /// DIG-Network/dig_ecosystem#3358: the returned [`Discovery`] also carries - /// `candidates_dropped` -- a port that bounds how many candidates it will decode this call - /// MUST report how many it declined, never silently shrink the result. async fn discover_distributors(&self) -> Result; /// Re-derive one launcher id's launch comment from chain (SPEC §13.2 clause 1: a gossip hint is diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index e7cd4e39..78b0487b 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -13,21 +13,11 @@ pub struct DiscoveredDistributor { pub root: Bytes32, } -/// One `discover_distributors` call's result: the distributors found AND how many candidates the -/// port declined to even decode -- DIG-Network/dig_ecosystem#3358. A bound on discovery that -/// silently swallowed the dropped count would be a censorship primitive (a cap that starves the -/// work it protects, never reported); this struct makes that count a first-class, always-present -/// field instead, so `RealClaimChainPort::discover_distributors` can never answer with fewer -/// distributors than it actually decoded without saying so. +/// One `discover_distributors` call's result: every distributor the port decoded and verified. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Discovery { /// Every distributor whose candidate id was actually decoded and verified this call. pub distributors: Vec, - /// Candidates the port's own per-cycle cap declined to decode at all -- SPEC 13.1; - /// [`RealClaimChainPort`](super::chain_port::RealClaimChainPort)'s cap is - /// `MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`. `0` for every port that has no such cap (every - /// test double and [`super::port::UnavailableClaimChainPort`]). - pub candidates_dropped: u32, } /// This node's own entry slot on one distributor (SPEC §10.2): keyed by a payout PUZZLE HASH, never @@ -284,14 +274,6 @@ pub struct ClaimStatus { /// 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, - /// DIG-Network/dig_ecosystem#3358: THIS CYCLE's count of hinted launcher candidates the port's - /// own per-cycle cap declined to decode at all -- distinct from [`Self::no_entry_slot_this_cycle`] - /// (those WERE decoded and simply had no entry). Reset at the top of every `run_cycle`, same - /// per-cycle discipline as every other counter on this struct: a stale nonzero count from a - /// PAST cycle must never sit under a fresh `last_attempt_at`. A nonzero reading here means a - /// legitimate distributor could have been silently excluded from this cycle's candidate set -- - /// see [`Discovery::candidates_dropped`], the field this is copied from. - pub discovery_candidates_dropped_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). @@ -321,7 +303,6 @@ impl Default for ClaimStatus { claims_refused_payout_mismatch: 0, payout_hash_mismatches_this_cycle: 0, no_entry_slot_this_cycle: 0, - discovery_candidates_dropped_this_cycle: 0, fault_reported: false, consecutive_faulted_cycles: 0, state: ClaimLoopState::Idle, diff --git a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs index ae3dfbd3..260df97f 100644 --- a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -66,7 +66,6 @@ async fn discover_distributors_returns_exactly_the_real_launch() { fixture.launch_comment.store_id ); assert_eq!(discovery.distributors[0].root, fixture.launch_comment.root); - assert_eq!(discovery.candidates_dropped, 0); } /// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be @@ -96,51 +95,19 @@ async fn a_bogus_index_entry_is_dropped_not_echoed() { assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); } -/// DIG-Network/dig_ecosystem#3358: a candidate cap that lands ON the real launcher id must drop it -/// and REPORT the drop -- never silently return fewer distributors than the caller can account for. -/// Uses [`RealClaimChainPort::with_candidate_cap`] pinned to 1 so this proves the drop without -/// decoding hundreds of candidates; the production constant stays -/// [`dig_node_service::rewards_claim::RealClaimChainPort`]'s own default (256). +/// Discovery decodes EVERY candidate the index proposes -- there is no per-cycle bound, so a real +/// launcher is never pushed out of a cycle by the number (or transport order) of the candidates +/// around it. The companion to `a_bogus_index_entry_is_dropped_not_echoed`, with the real id +/// FIRST, so neither order can be the only one that works. #[tokio::test(flavor = "multi_thread")] -async fn a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it() { +async fn every_candidate_the_index_proposes_is_decoded() { let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); let source = mock_chain_source(&fixture); let bogus_id = Bytes32::from([0xEE; 32]); - let port = RealClaimChainPort::with_candidate_cap( - Arc::new(source), - FixtureLauncherIndex(vec![bogus_id, fixture.launcher_id]), - Arc::new(MockBroadcaster::default()), - 1, - ); - - let discovery = port - .discover_distributors() - .await - .expect("a capped cycle must still answer, never error, for the candidates it does try"); - - assert_eq!( - discovery.distributors.len(), - 0, - "the real launcher id sits past the cap of 1 and must be dropped, not decoded" - ); - assert_eq!( - discovery.candidates_dropped, 1, - "the one candidate past the cap must be reported, never silently absorbed" - ); -} - -/// The same cap, sized to admit every candidate -- proves the cap itself never drops anything when -/// there is nothing to drop (the companion proof to the capped case above). -#[tokio::test(flavor = "multi_thread")] -async fn a_cap_that_covers_every_candidate_drops_nothing() { - let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); - let source = mock_chain_source(&fixture); - let bogus_id = Bytes32::from([0xEE; 32]); - let port = RealClaimChainPort::with_candidate_cap( + let port = RealClaimChainPort::new( Arc::new(source), FixtureLauncherIndex(vec![fixture.launcher_id, bogus_id]), Arc::new(MockBroadcaster::default()), - 2, ); let discovery = port @@ -150,7 +117,6 @@ async fn a_cap_that_covers_every_candidate_drops_nothing() { assert_eq!(discovery.distributors.len(), 1); assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); - assert_eq!(discovery.candidates_dropped, 0); } /// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own