From 695443f4d6005d3cdb941c141291dbabdc25fdc0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:16:41 -0700 Subject: [PATCH 01/31] feat(rewards): peer claim loop skeleton -- discovery, cadence, config, claim port --- crates/dig-node-service/src/lib.rs | 6 + .../src/rewards_claim/cadence.rs | 61 ++ .../src/rewards_claim/config.rs | 185 +++++ .../src/rewards_claim/engine.rs | 689 ++++++++++++++++++ .../src/rewards_claim/hints.rs | 43 ++ .../dig-node-service/src/rewards_claim/mod.rs | 41 ++ .../src/rewards_claim/parser.rs | 98 +++ .../src/rewards_claim/port.rs | 137 ++++ .../src/rewards_claim/types.rs | 183 +++++ 9 files changed, 1443 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..8bd2d5dd 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -80,6 +80,12 @@ pub mod meta; /// `(store, root, epoch)`, and its disappearance drives reclaim of the collateral. The node signs /// those spends itself with its own operator wallet, scoped by construction. See [`mirror`]. pub mod mirror; +/// 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; /// `dign network-info` (#303): this node's OWN network posture -- peer id, network + genesis, /// advertised addresses (IPv6-first, §5.2), reachability and relay reservation. Reads the node's /// OPEN `dig.getNetworkInfo` surface, so it needs no control token. See [`network_info`]. 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..4f518877 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -0,0 +1,61 @@ +//! 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); + } +} 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..4d132f30 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -0,0 +1,185 @@ +//! 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 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), reusing +/// `crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS` as the source for the same class of +/// peer-side spend. 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. See the module doc's "fee ceiling" reasoning for the full argument. +pub const CLAIM_FEE_CEILING_MOJOS_DEFAULT: u64 = + crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS; + +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. + #[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 fee ceiling (requirement 2) — see [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s doc for why this + /// is a ceiling, not a floor. + #[serde(default = "default_max_fee_mojos")] + pub max_fee_mojos: u64, +} + +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 +} + +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(), + } + } +} + +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()) + } + + /// Load from an explicit directory. A missing or unparsable file yields the default — the same + /// survivable-degradation posture as `CollateralConfig::load_from` — visibly logged, never + /// silent, and never fatal to node start over one preferences file. + 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::warn!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be read; using defaults" + ); + return Self::default(); + } + }; + match serde_json::from_str(&text) { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be parsed; using defaults" + ); + Self::default() + } + } + } + + /// Persist to `dir`, creating the state directory with restricted permissions if needed. + 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 body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(&path, body)?; + 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, 1_000_000_000); + } + + #[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: 500_000_000, + }; + 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() + ); + } +} 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..373fd4ee --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -0,0 +1,689 @@ +//! 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::collections::HashSet; + +use chia_protocol::Bytes32; + +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`], +/// holding the terminal "no entry slot" set (SPEC §12.5 clause 1) and the anti-silence status +/// surface across calls to [`Self::run_cycle`]. +pub struct ClaimEngine { + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + dig_asset_id: Bytes32, + terminal_no_entry: HashSet, + status: ClaimStatus, +} + +impl ClaimEngine { + pub fn new( + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + dig_asset_id: Bytes32, + ) -> Self { + ClaimEngine { + port, + hints, + own_payout_puzzle_hash, + max_fee_mojos, + dig_asset_id, + terminal_no_entry: HashSet::new(), + status: ClaimStatus::default(), + } + } + + #[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, and submit a claim for every one that clears both the threshold and the fee + /// ceiling. Returns every outcome, one per evaluated distributor. + pub async fn run_cycle(&mut self, now: u64) -> Vec { + let discovered = match self.port.discover_distributors().await { + Ok(v) => v, + Err(ClaimPortError::Unavailable) => { + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return Vec::new(); + } + Err(ClaimPortError::Other(_)) => { + self.status.fault_reported = true; + Vec::new() + } + }; + self.status.last_discovery_at = Some(now); + + let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); + + // 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 mut outcomes = Vec::new(); + let mut with_entry = 0u32; + let mut claimable = 0u32; + + for launcher_id in candidates { + if self.terminal_no_entry.contains(&launcher_id) { + continue; + } + + match self.evaluate_one(launcher_id).await { + EvalResult::Fault => continue, + EvalResult::Outcome(outcome, entry_seen, was_claimable) => { + if entry_seen { + with_entry += 1; + } + if was_claimable { + claimable += 1; + } + if matches!(outcome, ClaimOutcome::NoEntrySlot { .. }) { + self.terminal_no_entry.insert(launcher_id); + self.status.terminal_no_entry_slot += 1; + } + outcomes.push(outcome); + } + EvalResult::ChainUnavailable => { + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + } + } + + self.status.distributors_with_own_entry = with_entry; + self.status.distributors_claimable = claimable; + self.status.last_cycle_at = Some(now); + if self.status.state != ClaimLoopState::ChainSourceUnavailable { + self.status.state = self.status.compute_state(); + } + outcomes + } + + async fn evaluate_one(&mut self, launcher_id: Bytes32) -> EvalResult { + let asset = match self.port.reserve_asset_id(launcher_id).await { + Ok(a) => a, + Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, + Err(ClaimPortError::Other(_)) => { + self.status.fault_reported = true; + return EvalResult::Fault; + } + }; + if asset != self.dig_asset_id { + // SPEC §9.3: not ours, dropped — not counted as known/claimable. + return EvalResult::Outcome(ClaimOutcome::NotOurs { launcher_id }, false, 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 EvalResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false, false); + } + Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, + Err(ClaimPortError::Other(_)) => { + self.status.fault_reported = true; + return EvalResult::Fault; + } + }; + + let threshold = match self.port.payout_threshold(launcher_id).await { + Ok(t) => t, + Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, + Err(ClaimPortError::Other(_)) => { + self.status.fault_reported = true; + return EvalResult::Fault; + } + }; + + if entry.accrued_base_units < threshold { + self.status.claims_skipped_below_threshold += 1; + return EvalResult::Outcome( + ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: entry.accrued_base_units, + threshold, + }, + true, + false, + ); + } + + let fee = match self.port.required_fee_mojos(launcher_id).await { + Ok(f) => f, + Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, + Err(ClaimPortError::Other(_)) => { + self.status.fault_reported = true; + return EvalResult::Fault; + } + }; + + if fee > self.max_fee_mojos { + self.status.claims_skipped_fee_ceiling += 1; + return EvalResult::Outcome( + ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: fee, + ceiling_mojos: self.max_fee_mojos, + }, + true, + true, + ); + } + + match self + .port + .submit_initiate_payout(launcher_id, entry.payout_puzzle_hash, fee) + .await + { + Ok(()) => { + self.status.claims_submitted += 1; + EvalResult::Outcome(ClaimOutcome::Submitted { launcher_id }, true, true) + } + Err(ClaimPortError::Unavailable) => EvalResult::ChainUnavailable, + Err(ClaimPortError::Other(_)) => { + self.status.fault_reported = true; + EvalResult::Fault + } + } + } +} + +enum EvalResult { + /// `(outcome, entry_slot_was_present, was_claimable)`. + Outcome(ClaimOutcome, bool, bool), + Fault, + ChainUnavailable, +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + 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; + + #[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, + } + + 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), + } + } + } + + #[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 { + 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> { + 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, + 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()); + } + + /// ACCEPTANCE 6 — no entry slot is terminal, non-error, and reports neither a chain fault nor + /// a lost payment; the SECOND tick performs zero retries against that distributor. + #[tokio::test] + async fn no_entry_slot_is_terminal_and_not_retried() { + 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().terminal_no_entry_slot, 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!(second.is_empty(), "terminal distributor is skipped, not retried"); + assert_eq!( + *e.port.own_entry_reads.lock().unwrap(), + reads_after_first, + "zero retries on the next tick" + ); + } + + /// 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, + 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, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty()); + assert_eq!(e.status().distributors_known, 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])); + } +} 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..fe9305ea --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -0,0 +1,41 @@ +//! 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 SPEC-only at v0.1.1 (`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. + +#[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..62996d5b --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/parser.rs @@ -0,0 +1,98 @@ +//! 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..6aa80b59 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -0,0 +1,137 @@ +//! 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 — the engine MUST + /// NOT cache the result across cycles. `Ok(None)` is SPEC §12.5's terminal "no slot" outcome. + 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..1ec4abaf --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -0,0 +1,183 @@ +//! 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. +#[derive(Debug, Clone, Copy, 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 §12.5 clause 1: no entry slot for our puzzle hash — terminal, non-error. Eviction + /// already settled everything owed (SPEC §6.4). + NoEntrySlot { launcher_id: Bytes32 }, + /// SPEC §9.3: the distributor's reserve asset is not `DIG_ASSET_ID` — not ours, dropped. + NotOurs { launcher_id: Bytes32 }, +} + +/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClaimLoopState { + /// No cycle has completed 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, + /// The silent-failure case this ticket exists to prevent: at least one distributor was + /// claimable, nothing was submitted, and no fault was reported. Computed, never asserted by a + /// writer about itself — see [`ClaimStatus::compute_state`]. + ClaimableButNotClaiming, + /// 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 { + 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. Comparing + /// this against `claims_submitted` is what makes [`ClaimLoopState::ClaimableButNotClaiming`] + /// catch a broken submit path even when discovery and evaluation both still look correct. + pub distributors_claimable: u32, + pub last_discovery_at: Option, + pub last_cycle_at: Option, + pub claims_submitted: u64, + pub claims_skipped_below_threshold: u64, + pub claims_skipped_fee_ceiling: u64, + pub terminal_no_entry_slot: u32, + /// Set when a chain call this cycle returned `ClaimPortError::Other(_)` — a real fault, distinct + /// from `ChainSourceUnavailable` (no chain at all) and from ordinary skip outcomes. + pub fault_reported: bool, + pub state: ClaimLoopState, +} + +impl Default for ClaimStatus { + fn default() -> Self { + ClaimStatus { + distributors_known: 0, + distributors_with_own_entry: 0, + distributors_claimable: 0, + last_discovery_at: None, + last_cycle_at: None, + claims_submitted: 0, + claims_skipped_below_threshold: 0, + claims_skipped_fee_ceiling: 0, + terminal_no_entry_slot: 0, + fault_reported: false, + state: ClaimLoopState::Idle, + } + } +} + +impl ClaimStatus { + /// Derives [`ClaimLoopState`] from the status fields alone — a pure computation, so a test can + /// assert `ClaimableButNotClaiming` 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). + #[must_use] + pub fn compute_state(&self) -> ClaimLoopState { + if self.state == ClaimLoopState::ChainSourceUnavailable { + return ClaimLoopState::ChainSourceUnavailable; + } + if self.last_cycle_at.is_none() { + return ClaimLoopState::Idle; + } + if !self.fault_reported && self.distributors_claimable > 0 && self.claims_submitted == 0 { + return ClaimLoopState::ClaimableButNotClaiming; + } + 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: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::ClaimableButNotClaiming); + } + + #[test] + fn a_reported_fault_does_not_masquerade_as_claimable_but_not_claiming() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted: 0, + fault_reported: true, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + #[test] + fn nothing_claimable_and_nothing_submitted_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted: 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), + state: ClaimLoopState::ChainSourceUnavailable, + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::ChainSourceUnavailable); + } +} From c6654d2d6ed1457e4a2df74d57e85e9fe5216aec Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:16:41 -0700 Subject: [PATCH 02/31] test(rewards): write all twelve acceptance tests for the peer claim loop --- crates/dig-node-service/src/lib.rs | 12 +++++----- .../src/rewards_claim/cadence.rs | 17 ++++++++++++++ .../src/rewards_claim/engine.rs | 22 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 8bd2d5dd..7d240feb 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -80,12 +80,6 @@ pub mod meta; /// `(store, root, epoch)`, and its disappearance drives reclaim of the collateral. The node signs /// those spends itself with its own operator wallet, scoped by construction. See [`mirror`]. pub mod mirror; -/// 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; /// `dign network-info` (#303): this node's OWN network posture -- peer id, network + genesis, /// advertised addresses (IPv6-first, §5.2), reachability and relay reservation. Reads the node's /// OPEN `dig.getNetworkInfo` surface, so it needs no control token. See [`network_info`]. @@ -109,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 index 4f518877..1cad37b5 100644 --- a/crates/dig-node-service/src/rewards_claim/cadence.rs +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -58,4 +58,21 @@ mod tests { 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, + }; + 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/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 373fd4ee..7d21161e 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -675,6 +675,28 @@ mod tests { assert_eq!(e.status().distributors_known, 0); } + /// 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, + 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"); + } + /// 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). From 96d19a8542e222cf46104f99f508ca1bb7a2fd43 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:15:52 -0700 Subject: [PATCH 03/31] 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. --- .../dig-node-service/src/rewards_claim/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index fe9305ea..e0f10791 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -31,6 +31,24 @@ //! 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. +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_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] From 35577f4b4676b4591939e66b80f37e53ce720353 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:16:07 -0700 Subject: [PATCH 04/31] style(rewards): cargo fmt the rewards_claim submodules --- .../src/rewards_claim/cadence.rs | 8 ++++++-- .../src/rewards_claim/engine.rs | 20 +++++++++++++------ .../src/rewards_claim/parser.rs | 6 +++++- .../src/rewards_claim/types.rs | 10 ++++++++-- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs index 1cad37b5..f03b1dea 100644 --- a/crates/dig-node-service/src/rewards_claim/cadence.rs +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -69,8 +69,12 @@ mod tests { jitter_seconds: 500, max_fee_mojos: 1, }; - 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 = + 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/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 7d21161e..2383b8ff 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -144,7 +144,11 @@ impl ClaimEngine { { Ok(Some(e)) => e, Ok(None) => { - return EvalResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false, false); + return EvalResult::Outcome( + ClaimOutcome::NoEntrySlot { launcher_id }, + false, + false, + ); } Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, Err(ClaimPortError::Other(_)) => { @@ -261,7 +265,10 @@ mod tests { fn new(distributors: Vec) -> Self { FakeChainPort { distributors: Mutex::new( - distributors.into_iter().map(|d| (d.launcher_id, d)).collect(), + distributors + .into_iter() + .map(|d| (d.launcher_id, d)) + .collect(), ), submitted: Mutex::new(Vec::new()), own_entry_reads: Mutex::new(0), @@ -374,9 +381,7 @@ mod tests { } } - fn engine( - port: FakeChainPort, - ) -> ClaimEngine { + fn engine(port: FakeChainPort) -> ClaimEngine { ClaimEngine::new( port, NoHintSource, @@ -515,7 +520,10 @@ mod tests { let reads_after_first = *e.port.own_entry_reads.lock().unwrap(); let second = e.run_cycle(2_000).await; - assert!(second.is_empty(), "terminal distributor is skipped, not retried"); + assert!( + second.is_empty(), + "terminal distributor is skipped, not retried" + ); assert_eq!( *e.port.own_entry_reads.lock().unwrap(), reads_after_first, diff --git a/crates/dig-node-service/src/rewards_claim/parser.rs b/crates/dig-node-service/src/rewards_claim/parser.rs index 62996d5b..aebfaade 100644 --- a/crates/dig-node-service/src/rewards_claim/parser.rs +++ b/crates/dig-node-service/src/rewards_claim/parser.rs @@ -89,7 +89,11 @@ mod tests { 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()), + &format!( + "dig-rewards:v1:{}:{}", + store.to_uppercase(), + root.to_uppercase() + ), ) .unwrap(); assert_eq!(lower.store_id, upper.store_id); diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 1ec4abaf..269909a1 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -141,7 +141,10 @@ mod tests { last_cycle_at: Some(1), ..ClaimStatus::default() }; - assert_eq!(status.compute_state(), ClaimLoopState::ClaimableButNotClaiming); + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming + ); } #[test] @@ -178,6 +181,9 @@ mod tests { state: ClaimLoopState::ChainSourceUnavailable, ..ClaimStatus::default() }; - assert_eq!(status.compute_state(), ClaimLoopState::ChainSourceUnavailable); + assert_eq!( + status.compute_state(), + ClaimLoopState::ChainSourceUnavailable + ); } } From 7d5194cbb9a7b2aea00168ef813e57dc559e7168 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:38:32 -0700 Subject: [PATCH 05/31] 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). --- Cargo.lock | 66 ++++++++++++++++++++---------- crates/dig-node-service/Cargo.toml | 8 ++-- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a7478c6..7d4264ee 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]] @@ -2698,7 +2698,7 @@ dependencies = [ "dig-dht", "dig-nat", "dig-peer", - "dig-rpc-protocol", + "dig-rpc-protocol 0.10.3", "futures", "serde", "serde_json", @@ -2976,6 +2976,19 @@ dependencies = [ "url", ] +[[package]] +name = "dig-node-control-interface" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae55b76cb5b6a621b8ebc7d4ca8cb6f8a22c2b8b735aeb85e5b923d211d5ea45" +dependencies = [ + "async-trait", + "semver", + "serde", + "serde_json", + "url", +] + [[package]] name = "dig-node-core" version = "0.68.1" @@ -3002,7 +3015,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rpc-protocol", + "dig-rpc-protocol 0.10.3", "dig-sex", "dig-social-profile", "dig-store-cache", @@ -3061,10 +3074,10 @@ dependencies = [ "dig-logging", "dig-mirror-coin", "dig-mirror-collateral", - "dig-node-control-interface", + "dig-node-control-interface 0.35.0", "dig-node-core", "dig-node-service", - "dig-rpc-protocol", + "dig-rpc-protocol 0.11.0", "dig-stun", "dig-urn-resolver", "dig-wallet", @@ -3152,7 +3165,7 @@ dependencies = [ "chia-traits 0.36.1", "dig-message", "dig-nat", - "dig-rpc-protocol", + "dig-rpc-protocol 0.10.3", "dig-tls", "serde", "serde_json", @@ -3219,6 +3232,17 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "dig-rpc-protocol" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "dig-runtime" version = "0.4.0" @@ -3392,7 +3416,7 @@ dependencies = [ "clvmr 0.16.4", "dig-clvm", "dig-keystore", - "dig-node-control-interface", + "dig-node-control-interface 0.33.0", "dig-node-core", "dig-offers", "dig-options 0.5.0", @@ -3752,7 +3776,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3924,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 +4560,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4787,7 +4811,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5169,7 +5193,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 +5804,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 +5842,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 +6594,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7020,7 +7044,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 +7306,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 +8440,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-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 3666c163..f6424fcb 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -92,7 +92,7 @@ async-trait = "0.1" # "0.6.0" the suite went green over a `control.wallet.coinById` the contract had never heard of -- # it checked nothing about the very method the change added. A caret range keeps the pin moving # with the published catalog instead of silently narrowing what CI can see. -dig-node-control-interface = "0.33" +dig-node-control-interface = "0.35" # The deterministic mirror-coin collateral model: the per-epoch requirement, the controller # multiplier, the small-network handicap, the floor clamp, and the safety-margin arithmetic. @@ -172,7 +172,7 @@ 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" +dig-rpc-protocol = "0.11.0" # 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 +314,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.0" 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.0" # 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 51516e626913ce67651833ab0b5594692b67f0f1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 17:42:38 -0700 Subject: [PATCH 06/31] 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. --- Cargo.lock | 36 +++++------------------------- crates/dig-node-service/Cargo.toml | 8 +++---- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d4264ee..25b0e0ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2698,7 +2698,7 @@ dependencies = [ "dig-dht", "dig-nat", "dig-peer", - "dig-rpc-protocol 0.10.3", + "dig-rpc-protocol", "futures", "serde", "serde_json", @@ -2976,19 +2976,6 @@ dependencies = [ "url", ] -[[package]] -name = "dig-node-control-interface" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae55b76cb5b6a621b8ebc7d4ca8cb6f8a22c2b8b735aeb85e5b923d211d5ea45" -dependencies = [ - "async-trait", - "semver", - "serde", - "serde_json", - "url", -] - [[package]] name = "dig-node-core" version = "0.68.1" @@ -3015,7 +3002,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rpc-protocol 0.10.3", + "dig-rpc-protocol", "dig-sex", "dig-social-profile", "dig-store-cache", @@ -3074,10 +3061,10 @@ dependencies = [ "dig-logging", "dig-mirror-coin", "dig-mirror-collateral", - "dig-node-control-interface 0.35.0", + "dig-node-control-interface", "dig-node-core", "dig-node-service", - "dig-rpc-protocol 0.11.0", + "dig-rpc-protocol", "dig-stun", "dig-urn-resolver", "dig-wallet", @@ -3165,7 +3152,7 @@ dependencies = [ "chia-traits 0.36.1", "dig-message", "dig-nat", - "dig-rpc-protocol 0.10.3", + "dig-rpc-protocol", "dig-tls", "serde", "serde_json", @@ -3232,17 +3219,6 @@ dependencies = [ "serde_repr", ] -[[package]] -name = "dig-rpc-protocol" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "dig-runtime" version = "0.4.0" @@ -3416,7 +3392,7 @@ dependencies = [ "clvmr 0.16.4", "dig-clvm", "dig-keystore", - "dig-node-control-interface 0.33.0", + "dig-node-control-interface", "dig-node-core", "dig-offers", "dig-options 0.5.0", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index f6424fcb..3666c163 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -92,7 +92,7 @@ async-trait = "0.1" # "0.6.0" the suite went green over a `control.wallet.coinById` the contract had never heard of -- # it checked nothing about the very method the change added. A caret range keeps the pin moving # with the published catalog instead of silently narrowing what CI can see. -dig-node-control-interface = "0.35" +dig-node-control-interface = "0.33" # The deterministic mirror-coin collateral model: the per-epoch requirement, the controller # multiplier, the small-network handicap, the floor clamp, and the safety-margin arithmetic. @@ -172,7 +172,7 @@ 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.11.0" +dig-rpc-protocol = "0.10" # 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 +314,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.0" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.10" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.11.0" +dig-rpc-protocol = "0.10" # 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 1701759cf1c4dac037059818d8034af1f61e28eb Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 18:24:31 -0700 Subject: [PATCH 07/31] 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 --- .../src/rewards_claim/config.rs | 74 +++- .../src/rewards_claim/engine.rs | 326 ++++++++++++++++-- .../dig-node-service/src/rewards_claim/mod.rs | 3 +- .../src/rewards_claim/port.rs | 9 +- .../src/rewards_claim/types.rs | 131 ++++++- 5 files changed, 479 insertions(+), 64 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs index 4d132f30..f8b1159f 100644 --- a/crates/dig-node-service/src/rewards_claim/config.rs +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -12,15 +12,35 @@ 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), reusing -/// `crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS` as the source for the same class of -/// peer-side spend. 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. See the module doc's "fee ceiling" reasoning for the full argument. -pub const CLAIM_FEE_CEILING_MOJOS_DEFAULT: u64 = - crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS; +/// 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; const REWARDS_CLAIM_CONFIG_FILE: &str = "rewards-claim.json"; @@ -42,10 +62,15 @@ pub struct RewardsClaimConfig { #[serde(default = "default_jitter_seconds")] pub jitter_seconds: u64, - /// The fee ceiling (requirement 2) — see [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s doc for why this - /// is a ceiling, not a floor. + /// 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, } fn default_enabled() -> bool { @@ -64,6 +89,10 @@ 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 { @@ -71,6 +100,7 @@ impl Default for RewardsClaimConfig { 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(), } } } @@ -137,7 +167,24 @@ mod tests { assert!(cfg.enabled); assert_eq!(cfg.cadence_seconds, 86_400); assert_eq!(cfg.jitter_seconds, 3_600); - assert_eq!(cfg.max_fee_mojos, 1_000_000_000); + 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] @@ -151,7 +198,8 @@ mod tests { enabled: false, cadence_seconds: 43_200, jitter_seconds: 1_800, - max_fee_mojos: 500_000_000, + max_fee_mojos: 150_000, + max_cycle_fee_budget_mojos: 900_000, }; cfg.save_to(dir.path()).expect("save"); diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 2383b8ff..6d141849 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -2,24 +2,36 @@ //! [`DistributorHintSource`], never against a concrete chain client (see the module doc's "chain //! seam" section). -use std::collections::HashSet; - use chia_protocol::Bytes32; 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`], -/// holding the terminal "no entry slot" set (SPEC §12.5 clause 1) and the anti-silence status -/// surface across calls to [`Self::run_cycle`]. +/// 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, - terminal_no_entry: HashSet, status: ClaimStatus, } @@ -29,6 +41,7 @@ impl ClaimEngine { hints: H, own_payout_puzzle_hash: Bytes32, max_fee_mojos: u64, + cycle_fee_budget_mojos: u64, dig_asset_id: Bytes32, ) -> Self { ClaimEngine { @@ -36,8 +49,8 @@ impl ClaimEngine { hints, own_payout_puzzle_hash, max_fee_mojos, + cycle_fee_budget_mojos, dig_asset_id, - terminal_no_entry: HashSet::new(), status: ClaimStatus::default(), } } @@ -48,9 +61,18 @@ impl ClaimEngine { } /// Run one cycle: discover candidates (chain + re-derived hints), evaluate each against SPEC - /// §9.3/§8.3/§12.5, and submit a claim for every one that clears both the threshold and the fee - /// ceiling. Returns every outcome, one per evaluated distributor. + /// §9.3/§8.3/§12.5, and submit a claim for every one that clears the per-claim threshold, the + /// per-claim fee 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: per-cycle fields are reset here, not carried over — a fault or a claim count + // from a PAST cycle must never leak into this cycle's reading of `compute_state()`. + self.status.fault_reported = false; + self.status.last_attempt_at = Some(now); + let mut spent_this_cycle_mojos = 0u64; + let mut budget_exhausted = false; + + let mut discovery_failed = false; let discovered = match self.port.discover_distributors().await { Ok(v) => v, Err(ClaimPortError::Unavailable) => { @@ -58,11 +80,16 @@ impl ClaimEngine { 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() } }; - self.status.last_discovery_at = Some(now); + if !discovery_failed { + self.status.last_discovery_at = Some(now); + } let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); @@ -81,18 +108,29 @@ impl ClaimEngine { } 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 claimable = 0u32; + let mut faulted = 0u32; + let mut submitted_this_cycle = 0u64; + let mut no_entry_this_cycle = 0u32; for launcher_id in candidates { - if self.terminal_no_entry.contains(&launcher_id) { - continue; - } - - match self.evaluate_one(launcher_id).await { - EvalResult::Fault => continue, + // 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_one( + launcher_id, + &mut spent_this_cycle_mojos, + &mut budget_exhausted, + ) + .await + { + EvalResult::Fault => { + faulted += 1; + } EvalResult::Outcome(outcome, entry_seen, was_claimable) => { if entry_seen { with_entry += 1; @@ -100,9 +138,10 @@ impl ClaimEngine { if was_claimable { claimable += 1; } - if matches!(outcome, ClaimOutcome::NoEntrySlot { .. }) { - self.terminal_no_entry.insert(launcher_id); - self.status.terminal_no_entry_slot += 1; + match &outcome { + ClaimOutcome::NoEntrySlot { .. } => no_entry_this_cycle += 1, + ClaimOutcome::Submitted { .. } => submitted_this_cycle += 1, + _ => {} } outcomes.push(outcome); } @@ -113,16 +152,36 @@ impl ClaimEngine { } } + // 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. + let all_faulted_cycle = any_candidates && outcomes.is_empty() && self.status.fault_reported; + self.status.distributors_with_own_entry = with_entry; self.status.distributors_claimable = claimable; - self.status.last_cycle_at = Some(now); + self.status.distributors_faulted = faulted; + self.status.claims_submitted += submitted_this_cycle; + self.status.claims_submitted_this_cycle = submitted_this_cycle; + self.status.terminal_no_entry_slot = 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); + } if self.status.state != ClaimLoopState::ChainSourceUnavailable { self.status.state = self.status.compute_state(); } outcomes } - async fn evaluate_one(&mut self, launcher_id: Bytes32) -> EvalResult { + async fn evaluate_one( + &mut self, + launcher_id: Bytes32, + spent_this_cycle_mojos: &mut u64, + budget_exhausted: &mut bool, + ) -> EvalResult { let asset = match self.port.reserve_asset_id(launcher_id).await { Ok(a) => a, Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, @@ -201,13 +260,30 @@ impl ClaimEngine { ); } + // 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. + if *budget_exhausted || *spent_this_cycle_mojos + fee > self.cycle_fee_budget_mojos { + *budget_exhausted = true; + self.status.claims_skipped_cycle_budget += 1; + return EvalResult::Outcome( + ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: fee, + budget_mojos: self.cycle_fee_budget_mojos, + }, + true, + true, + ); + } + match self .port .submit_initiate_payout(launcher_id, entry.payout_puzzle_hash, fee) .await { Ok(()) => { - self.status.claims_submitted += 1; + *spent_this_cycle_mojos += fee; EvalResult::Outcome(ClaimOutcome::Submitted { launcher_id }, true, true) } Err(ClaimPortError::Unavailable) => EvalResult::ChainUnavailable, @@ -241,6 +317,7 @@ mod tests { 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 { @@ -387,6 +464,7 @@ mod tests { NoHintSource, OUR_PAYOUT_PUZZLE_HASH, FEE_CEILING, + CYCLE_BUDGET, DIG_ASSET_ID, ) } @@ -504,10 +582,11 @@ mod tests { assert!(e.port.submitted.lock().unwrap().is_empty()); } - /// ACCEPTANCE 6 — no entry slot is terminal, non-error, and reports neither a chain fault nor - /// a lost payment; the SECOND tick performs zero retries against that distributor. + /// 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_terminal_and_not_retried() { + 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])); @@ -520,17 +599,50 @@ mod tests { let reads_after_first = *e.port.own_entry_reads.lock().unwrap(); let second = e.run_cycle(2_000).await; - assert!( - second.is_empty(), - "terminal distributor is skipped, not retried" + 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, - "zero retries on the next tick" + 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() { @@ -648,6 +760,7 @@ mod tests { OneHint(launcher_id), OUR_PAYOUT_PUZZLE_HASH, FEE_CEILING, + CYCLE_BUDGET, DIG_ASSET_ID, ); @@ -675,6 +788,7 @@ mod tests { BogusHint, OUR_PAYOUT_PUZZLE_HASH, FEE_CEILING, + CYCLE_BUDGET, DIG_ASSET_ID, ); @@ -683,6 +797,157 @@ mod tests { 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" + ); + } + } + + /// 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); + } + /// 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). @@ -693,6 +958,7 @@ mod tests { NoHintSource, OUR_PAYOUT_PUZZLE_HASH, FEE_CEILING, + CYCLE_BUDGET, DIG_ASSET_ID, ); diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index e0f10791..ce71a34b 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -41,7 +41,8 @@ mod types; pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; pub use config::{ - RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, + 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}; diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index 6aa80b59..f5988f52 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -44,8 +44,13 @@ pub trait ClaimChainPort: Send + Sync { /// 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 — the engine MUST - /// NOT cache the result across cycles. `Ok(None)` is SPEC §12.5's terminal "no slot" outcome. + /// 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, diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 269909a1..0759a5f3 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -47,21 +47,43 @@ pub enum ClaimOutcome { 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, + }, } /// 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 (Defect A1 fix) +/// `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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ClaimLoopState { - /// No cycle has completed yet. + /// 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, + /// 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: at least one distributor was - /// claimable, nothing was submitted, and no fault was reported. Computed, never asserted by a - /// writer about itself — see [`ClaimStatus::compute_state`]. + /// claimable THIS CYCLE, nothing was submitted THIS CYCLE, and no fault is live. Computed, never + /// asserted by a writer about itself — see [`ClaimStatus::compute_state`]. ClaimableButNotClaiming, /// A cycle completed, nothing above is true. Nominal, @@ -74,19 +96,50 @@ pub struct ClaimStatus { 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. Comparing - /// this against `claims_submitted` is what makes [`ClaimLoopState::ClaimableButNotClaiming`] - /// catch a broken submit path even when discovery and evaluation both still look correct. + /// 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, + /// 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. pub terminal_no_entry_slot: u32, - /// Set when a chain call this cycle returned `ClaimPortError::Other(_)` — a real fault, distinct - /// from `ChainSourceUnavailable` (no chain at all) and from ordinary skip outcomes. + /// 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, } @@ -96,13 +149,18 @@ impl Default for ClaimStatus { 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, terminal_no_entry_slot: 0, fault_reported: false, + consecutive_faulted_cycles: 0, state: ClaimLoopState::Idle, } } @@ -110,18 +168,27 @@ impl Default for ClaimStatus { impl ClaimStatus { /// Derives [`ClaimLoopState`] from the status fields alone — a pure computation, so a test can - /// assert `ClaimableButNotClaiming` 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). + /// 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` > `Nominal`. See [`ClaimLoopState`]'s doc for why a fault must + /// never be absorbed into `Nominal` (Defect A1). #[must_use] pub fn compute_state(&self) -> ClaimLoopState { if self.state == ClaimLoopState::ChainSourceUnavailable { return ClaimLoopState::ChainSourceUnavailable; } - if self.last_cycle_at.is_none() { + if self.last_attempt_at.is_none() && self.last_cycle_at.is_none() { return ClaimLoopState::Idle; } - if !self.fault_reported && self.distributors_claimable > 0 && self.claims_submitted == 0 { + if self.fault_reported { + return ClaimLoopState::Faulted { + cycles: self.consecutive_faulted_cycles.max(1), + }; + } + if self.distributors_claimable > 0 && self.claims_submitted_this_cycle == 0 { return ClaimLoopState::ClaimableButNotClaiming; } ClaimLoopState::Nominal @@ -136,7 +203,7 @@ mod tests { fn claimable_but_not_claiming_is_computed_from_fields_alone() { let status = ClaimStatus { distributors_claimable: 3, - claims_submitted: 0, + claims_submitted_this_cycle: 0, fault_reported: false, last_cycle_at: Some(1), ..ClaimStatus::default() @@ -147,23 +214,51 @@ mod tests { ); } + /// 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_does_not_masquerade_as_claimable_but_not_claiming() { + fn a_reported_fault_surfaces_as_faulted_not_nominal() { let status = ClaimStatus { distributors_claimable: 3, - claims_submitted: 0, + 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::Nominal); + 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 + ); } #[test] fn nothing_claimable_and_nothing_submitted_is_nominal() { let status = ClaimStatus { distributors_claimable: 0, - claims_submitted: 0, + claims_submitted_this_cycle: 0, fault_reported: false, last_cycle_at: Some(1), ..ClaimStatus::default() From e9553f1cd5e60583f80e6d74e8049e98a125b544 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 18:31:30 -0700 Subject: [PATCH 08/31] 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 --- .../src/rewards_claim/cadence.rs | 1 + .../src/rewards_claim/engine.rs | 48 +++++++++++++++++++ .../dig-node-service/src/rewards_claim/mod.rs | 11 +++++ .../src/rewards_claim/types.rs | 10 ++++ 4 files changed, 70 insertions(+) diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs index f03b1dea..65e0bf64 100644 --- a/crates/dig-node-service/src/rewards_claim/cadence.rs +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -68,6 +68,7 @@ mod tests { cadence_seconds: 12_000, jitter_seconds: 500, max_fee_mojos: 1, + max_cycle_fee_budget_mojos: 10, }; let interval = next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(0)); diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 6d141849..3ce5b55b 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -216,6 +216,20 @@ impl ClaimEngine { } }; + 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 -- and surface it as a fault, since a divergent entry means the port is + // confused or hostile, not that there is nothing to claim. + self.status.fault_reported = true; + self.status.claims_refused_payout_mismatch += 1; + return EvalResult::Outcome( + ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }, + true, + false, + ); + } + let threshold = match self.port.payout_threshold(launcher_id).await { Ok(t) => t, Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, @@ -948,6 +962,40 @@ mod tests { assert_eq!(e.status().claims_skipped_cycle_budget, 2); } + /// Defect E regression: a port returning an entry whose `payout_puzzle_hash` diverges from this + /// node's own must produce ZERO submissions and a reported fault -- never pay whoever the port + /// named instead. + #[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, + "a divergent entry is a fault, not a routine skip" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 1); + } + /// 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). diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index ce71a34b..5aee9c35 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -30,6 +30,17 @@ //! 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; diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 0759a5f3..93f7248d 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -56,6 +56,12 @@ pub enum ClaimOutcome { 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 closed set of states this loop can be in. Never a health boolean (SPEC §2.4) — each name @@ -129,6 +135,9 @@ pub struct ClaimStatus { /// 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`]. + pub claims_refused_payout_mismatch: u64, /// 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. @@ -158,6 +167,7 @@ impl Default for ClaimStatus { claims_skipped_below_threshold: 0, claims_skipped_fee_ceiling: 0, claims_skipped_cycle_budget: 0, + claims_refused_payout_mismatch: 0, terminal_no_entry_slot: 0, fault_reported: false, consecutive_faulted_cycles: 0, From e80e9a0a5089a0602c804339fc6455236b266595 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:46:32 -0700 Subject: [PATCH 09/31] 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. --- .../src/rewards_claim/types.rs | 133 ++++++++++++++++-- 1 file changed, 119 insertions(+), 14 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 93f7248d..369dbc74 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -67,12 +67,23 @@ pub enum ClaimOutcome { /// 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 (Defect A1 fix) +/// # 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. +/// +/// # 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(_)`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ClaimLoopState { /// No cycle has ever been attempted yet. @@ -87,10 +98,20 @@ pub enum ClaimLoopState { /// 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: at least one distributor was - /// claimable THIS CYCLE, nothing was submitted THIS CYCLE, and no fault is live. Computed, never - /// asserted by a writer about itself — see [`ClaimStatus::compute_state`]. - ClaimableButNotClaiming, + /// 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, } @@ -136,12 +157,23 @@ pub struct ClaimStatus { /// 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`]. + /// 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. - pub terminal_no_entry_slot: u32, + /// + /// # 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; see R1 in the fix + /// brief for why clause 1's literal wording is itself the thing under amendment, not this field. + 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). @@ -168,7 +200,8 @@ impl Default for ClaimStatus { claims_skipped_fee_ceiling: 0, claims_skipped_cycle_budget: 0, claims_refused_payout_mismatch: 0, - terminal_no_entry_slot: 0, + payout_hash_mismatches_this_cycle: 0, + no_entry_slot_this_cycle: 0, fault_reported: false, consecutive_faulted_cycles: 0, state: ClaimLoopState::Idle, @@ -183,8 +216,9 @@ impl ClaimStatus { /// than its own numbers support (SPEC §2.4's reasoning, applied to this loop's own surface). /// /// Precedence, most urgent first: `ChainSourceUnavailable` > `Faulted` > - /// `ClaimableButNotClaiming` > `Nominal`. See [`ClaimLoopState`]'s doc for why a fault must - /// never be absorbed into `Nominal` (Defect A1). + /// `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. #[must_use] pub fn compute_state(&self) -> ClaimLoopState { if self.state == ClaimLoopState::ChainSourceUnavailable { @@ -198,8 +232,14 @@ impl ClaimStatus { cycles: self.consecutive_faulted_cycles.max(1), }; } - if self.distributors_claimable > 0 && self.claims_submitted_this_cycle == 0 { - return ClaimLoopState::ClaimableButNotClaiming; + // 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. + if self.claims_submitted_this_cycle < u64::from(self.distributors_claimable) { + return ClaimLoopState::ClaimableButNotClaiming { + claimable: self.distributors_claimable, + submitted: u32::try_from(self.claims_submitted_this_cycle).unwrap_or(u32::MAX), + }; } ClaimLoopState::Nominal } @@ -220,10 +260,49 @@ mod tests { }; assert_eq!( status.compute_state(), - ClaimLoopState::ClaimableButNotClaiming + 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 @@ -260,7 +339,10 @@ mod tests { }; assert_eq!( status.compute_state(), - ClaimLoopState::ClaimableButNotClaiming + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + } ); } @@ -291,4 +373,27 @@ mod tests { ClaimLoopState::ChainSourceUnavailable ); } + + /// 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(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 2, + submitted: 1 + } + ); + } } From c44827239f085b13491eada579534facb9a21cdd Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:46:43 -0700 Subject: [PATCH 10/31] 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. --- .../src/rewards_claim/engine.rs | 467 +++++++++++++++--- 1 file changed, 388 insertions(+), 79 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 3ce5b55b..7fb485c9 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -33,6 +33,15 @@ pub struct ClaimEngine { 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, } impl ClaimEngine { @@ -52,22 +61,40 @@ impl ClaimEngine { cycle_fee_budget_mojos, dig_asset_id, status: ClaimStatus::default(), + rotation_cursor: None, } } + /// 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 + } + #[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, and submit a claim for every one that clears the per-claim threshold, the - /// per-claim fee ceiling AND the per-cycle aggregate fee budget. Returns every outcome, one per - /// evaluated distributor. + /// §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: per-cycle fields are reset here, not carried over — a fault or a claim count // from a PAST cycle must never leak into this cycle's reading of `compute_state()`. self.status.fault_reported = false; + self.status.payout_hash_mismatches_this_cycle = 0; self.status.last_attempt_at = Some(now); let mut spent_this_cycle_mojos = 0u64; let mut budget_exhausted = false; @@ -112,45 +139,86 @@ impl ClaimEngine { let mut outcomes = Vec::new(); let mut with_entry = 0u32; - let mut claimable = 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_one( + match self.evaluate_pre_budget(launcher_id).await { + PreBudgetResult::Fault => faulted += 1, + PreBudgetResult::ChainUnavailable => { + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + PreBudgetResult::Eligible { launcher_id, - &mut spent_this_cycle_mojos, - &mut budget_exhausted, - ) - .await - { - EvalResult::Fault => { - faulted += 1; + accrued_base_units, + } => { + with_entry += 1; + eligible.push(EligibleClaim { + launcher_id, + accrued_base_units, + }); } - EvalResult::Outcome(outcome, entry_seen, was_claimable) => { + PreBudgetResult::Outcome(outcome, entry_seen) => { if entry_seen { with_entry += 1; } - if was_claimable { - claimable += 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) + .await + { + BudgetPhaseResult::Fault => faulted += 1, + BudgetPhaseResult::ChainUnavailable => { + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + BudgetPhaseResult::Outcome(outcome) => { match &outcome { - ClaimOutcome::NoEntrySlot { .. } => no_entry_this_cycle += 1, 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); } - EvalResult::ChainUnavailable => { - self.status.state = ClaimLoopState::ChainSourceUnavailable; - return outcomes; - } } } + // 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. @@ -161,7 +229,7 @@ impl ClaimEngine { self.status.distributors_faulted = faulted; self.status.claims_submitted += submitted_this_cycle; self.status.claims_submitted_this_cycle = submitted_this_cycle; - self.status.terminal_no_entry_slot = no_entry_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 { @@ -176,23 +244,21 @@ impl ClaimEngine { outcomes } - async fn evaluate_one( - &mut self, - launcher_id: Bytes32, - spent_this_cycle_mojos: &mut u64, - budget_exhausted: &mut bool, - ) -> EvalResult { + /// 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 EvalResult::ChainUnavailable, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, Err(ClaimPortError::Other(_)) => { self.status.fault_reported = true; - return EvalResult::Fault; + return PreBudgetResult::Fault; } }; if asset != self.dig_asset_id { // SPEC §9.3: not ours, dropped — not counted as known/claimable. - return EvalResult::Outcome(ClaimOutcome::NotOurs { launcher_id }, false, false); + return PreBudgetResult::Outcome(ClaimOutcome::NotOurs { launcher_id }, false); } // SPEC §12.5 clause 3: re-read the entry slot fresh on EVERY call — never cached. @@ -203,75 +269,118 @@ impl ClaimEngine { { Ok(Some(e)) => e, Ok(None) => { - return EvalResult::Outcome( - ClaimOutcome::NoEntrySlot { launcher_id }, - false, - false, - ); + return PreBudgetResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false); } - Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, Err(ClaimPortError::Other(_)) => { self.status.fault_reported = true; - return EvalResult::Fault; + return PreBudgetResult::Fault; } }; 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 -- and surface it as a fault, since a divergent entry means the port is - // confused or hostile, not that there is nothing to claim. - self.status.fault_reported = true; + // 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; - return EvalResult::Outcome( + self.status.payout_hash_mismatches_this_cycle += 1; + return PreBudgetResult::Outcome( ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }, true, - false, ); } let threshold = match self.port.payout_threshold(launcher_id).await { Ok(t) => t, - Err(ClaimPortError::Unavailable) => return EvalResult::ChainUnavailable, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, Err(ClaimPortError::Other(_)) => { self.status.fault_reported = true; - return EvalResult::Fault; + return PreBudgetResult::Fault; } }; if entry.accrued_base_units < threshold { self.status.claims_skipped_below_threshold += 1; - return EvalResult::Outcome( + return PreBudgetResult::Outcome( ClaimOutcome::SkippedBelowThreshold { launcher_id, accrued: entry.accrued_base_units, threshold, }, true, - false, ); } + 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, + ) -> 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 EvalResult::ChainUnavailable, + Err(ClaimPortError::Unavailable) => return BudgetPhaseResult::ChainUnavailable, Err(ClaimPortError::Other(_)) => { self.status.fault_reported = true; - return EvalResult::Fault; + return BudgetPhaseResult::Fault; } }; if fee > self.max_fee_mojos { self.status.claims_skipped_fee_ceiling += 1; - return EvalResult::Outcome( - ClaimOutcome::SkippedFeeAboveCeiling { - launcher_id, - fee_mojos: fee, - ceiling_mojos: self.max_fee_mojos, - }, - true, - true, - ); + 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 @@ -280,38 +389,53 @@ impl ClaimEngine { if *budget_exhausted || *spent_this_cycle_mojos + fee > self.cycle_fee_budget_mojos { *budget_exhausted = true; self.status.claims_skipped_cycle_budget += 1; - return EvalResult::Outcome( - ClaimOutcome::SkippedCycleBudgetExhausted { - launcher_id, - fee_mojos: fee, - budget_mojos: self.cycle_fee_budget_mojos, - }, - true, - true, - ); + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: fee, + budget_mojos: self.cycle_fee_budget_mojos, + }); } match self .port - .submit_initiate_payout(launcher_id, entry.payout_puzzle_hash, fee) + .submit_initiate_payout(launcher_id, self.own_payout_puzzle_hash, fee) .await { Ok(()) => { *spent_this_cycle_mojos += fee; - EvalResult::Outcome(ClaimOutcome::Submitted { launcher_id }, true, true) + BudgetPhaseResult::Outcome(ClaimOutcome::Submitted { launcher_id }) } - Err(ClaimPortError::Unavailable) => EvalResult::ChainUnavailable, + Err(ClaimPortError::Unavailable) => BudgetPhaseResult::ChainUnavailable, Err(ClaimPortError::Other(_)) => { self.status.fault_reported = true; - EvalResult::Fault + BudgetPhaseResult::Fault } } } } -enum EvalResult { - /// `(outcome, entry_slot_was_present, was_claimable)`. - Outcome(ClaimOutcome, bool, bool), +/// An above-threshold, hash-matching candidate waiting for the budget pass (Defect B2). +struct EligibleClaim { + launcher_id: Bytes32, + accrued_base_units: u64, +} + +/// 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, + }, + Fault, + ChainUnavailable, +} + +/// The outcome of [`ClaimEngine::evaluate_budget_phase`]. +enum BudgetPhaseResult { + Outcome(ClaimOutcome), Fault, ChainUnavailable, } @@ -607,7 +731,7 @@ mod tests { let first = e.run_cycle(1_000).await; assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); - assert_eq!(e.status().terminal_no_entry_slot, 1); + 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"); @@ -962,9 +1086,143 @@ mod tests { 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)); + } + /// Defect E regression: a port returning an entry whose `payout_puzzle_hash` diverges from this - /// node's own must produce ZERO submissions and a reported fault -- never pay whoever the port - /// named instead. + /// 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]); @@ -990,10 +1248,61 @@ mod tests { 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, - "a divergent entry is a fault, not a routine skip" + !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" + ); + } + // The healthy distributor claims every cycle, so the surface reads Nominal, not buried. + assert_eq!(e.status().state, ClaimLoopState::Nominal); + assert_eq!(e.status().claims_submitted, 3, "the healthy one claimed all 3 cycles"); + assert_eq!(e.status().claims_refused_payout_mismatch, 3); } /// ACCEPTANCE 12 — with `UnavailableClaimChainPort` wired, the engine reports the named state From 3ef601f74ad91bf2ab4a29dbab54032e1bcb5c73 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:52:30 -0700 Subject: [PATCH 11/31] 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. --- .../src/rewards_claim/config.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs index f8b1159f..14ca131a 100644 --- a/crates/dig-node-service/src/rewards_claim/config.rs +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -5,6 +5,7 @@ use std::path::Path; +use chia_protocol::Bytes32; use serde::{Deserialize, Serialize}; use super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; @@ -50,6 +51,13 @@ 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, @@ -71,6 +79,16 @@ pub struct RewardsClaimConfig { /// [`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, } fn default_enabled() -> bool { @@ -101,6 +119,7 @@ impl Default for RewardsClaimConfig { 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, } } } @@ -200,10 +219,33 @@ mod tests { jitter_seconds: 1_800, max_fee_mojos: 150_000, max_cycle_fee_budget_mojos: 900_000, + rotation_cursor: None, + }; + 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); } From 749c7a7032ca2d74c3493a08f4bb505167649f1d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:52:10 -0700 Subject: [PATCH 12/31] fix(rewards-claim): clippy collapsible-match + SPEC v0.1.3 wording refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/rewards_claim/engine.rs | 20 +++++++++++++------ .../src/rewards_claim/types.rs | 18 +++++++++++++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 7fb485c9..b692020f 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -203,10 +203,10 @@ impl ClaimEngine { 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); - } + ClaimOutcome::SkippedCycleBudgetExhausted { .. } + if first_deferred_this_cycle.is_none() => + { + first_deferred_this_cycle = Some(claim.launcher_id); } _ => {} } @@ -1146,7 +1146,11 @@ mod tests { 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_submitted, + 1, + "the budget fits exactly one claim" + ); assert_eq!( e.status().claims_skipped_cycle_budget, 10, @@ -1301,7 +1305,11 @@ mod tests { } // The healthy distributor claims every cycle, so the surface reads Nominal, not buried. assert_eq!(e.status().state, ClaimLoopState::Nominal); - assert_eq!(e.status().claims_submitted, 3, "the healthy one claimed all 3 cycles"); + assert_eq!( + e.status().claims_submitted, + 3, + "the healthy one claimed all 3 cycles" + ); assert_eq!(e.status().claims_refused_payout_mismatch, 3); } diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 369dbc74..b80196d0 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -42,8 +42,11 @@ pub enum ClaimOutcome { fee_mojos: u64, ceiling_mojos: u64, }, - /// SPEC §12.5 clause 1: no entry slot for our puzzle hash — terminal, non-error. Eviction - /// already settled everything owed (SPEC §6.4). + /// 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 }, @@ -171,8 +174,15 @@ pub struct ClaimStatus { /// # 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; see R1 in the fix - /// brief for why clause 1's literal wording is itself the thing under amendment, not this field. + /// 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_cycle_at`], reset at the start of every `run_cycle` alongside the other + /// per-cycle counters (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, From a64d1480aa3223ff027518c829c949c14d42e5b6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 8 Sep 2026 22:57:08 -0700 Subject: [PATCH 13/31] 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). --- crates/dig-node-service/src/rewards_claim/cadence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs index 65e0bf64..20a244a2 100644 --- a/crates/dig-node-service/src/rewards_claim/cadence.rs +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -69,6 +69,7 @@ mod tests { jitter_seconds: 500, max_fee_mojos: 1, max_cycle_fee_budget_mojos: 10, + rotation_cursor: None, }; let interval = next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(0)); From 5cdf83232602f624bd840209820e4527b1e08683 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 05:33:37 -0700 Subject: [PATCH 14/31] 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 --- .../src/rewards_claim/engine.rs | 124 +++++++++++++++++- .../src/rewards_claim/types.rs | 44 ++++++- 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index b692020f..348ede77 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -91,9 +91,12 @@ impl ClaimEngine { /// (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: per-cycle fields are reset here, not carried over — a fault or a claim count - // from a PAST cycle must never leak into this cycle's reading of `compute_state()`. + // Defect A1/A4/F1: per-cycle fields are reset here, not carried over — a fault or a claim + // count from a PAST cycle must never leak into this cycle's reading of `compute_state()`. + // F1: `chain_unavailable_this_cycle` joins this reset -- it must never persist past the + // cycle it was observed in (see `ClaimStatus::chain_unavailable_this_cycle`'s doc). self.status.fault_reported = false; + self.status.chain_unavailable_this_cycle = false; self.status.payout_hash_mismatches_this_cycle = 0; self.status.last_attempt_at = Some(now); let mut spent_this_cycle_mojos = 0u64; @@ -103,6 +106,8 @@ impl ClaimEngine { 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(); } @@ -153,6 +158,7 @@ impl ClaimEngine { match self.evaluate_pre_budget(launcher_id).await { PreBudgetResult::Fault => faulted += 1, PreBudgetResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; self.status.state = ClaimLoopState::ChainSourceUnavailable; return outcomes; } @@ -197,6 +203,7 @@ impl ClaimEngine { { BudgetPhaseResult::Fault => faulted += 1, BudgetPhaseResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; self.status.state = ClaimLoopState::ChainSourceUnavailable; return outcomes; } @@ -238,9 +245,10 @@ impl ClaimEngine { if !discovery_failed && !all_faulted_cycle { self.status.last_cycle_at = Some(now); } - if self.status.state != ClaimLoopState::ChainSourceUnavailable { - self.status.state = self.status.compute_state(); - } + // 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 } @@ -1336,6 +1344,112 @@ mod tests { 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 { + calls: Mutex, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for FlakyThenHealthyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let mut calls = self.calls.lock().unwrap(); + *calls += 1; + if *calls == 1 { + return Err(ClaimPortError::Unavailable); + } + drop(calls); + 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: Mutex::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" + ); + } + /// 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). diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index b80196d0..53383a2c 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -78,6 +78,14 @@ pub enum ClaimOutcome { /// 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 @@ -123,6 +131,9 @@ pub enum ClaimLoopState { /// 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 @@ -197,6 +208,7 @@ pub struct ClaimStatus { impl Default for ClaimStatus { fn default() -> Self { ClaimStatus { + chain_unavailable_this_cycle: false, distributors_known: 0, distributors_with_own_entry: 0, distributors_claimable: 0, @@ -229,9 +241,14 @@ impl ClaimStatus { /// `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.state == ClaimLoopState::ChainSourceUnavailable { + if self.chain_unavailable_this_cycle { return ClaimLoopState::ChainSourceUnavailable; } if self.last_attempt_at.is_none() && self.last_cycle_at.is_none() { @@ -375,7 +392,7 @@ mod tests { claims_submitted: 0, fault_reported: false, last_cycle_at: Some(1), - state: ClaimLoopState::ChainSourceUnavailable, + chain_unavailable_this_cycle: true, ..ClaimStatus::default() }; assert_eq!( @@ -384,6 +401,29 @@ mod tests { ); } + /// 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 From 28c1b681d5629ad8d3a94f293c3f1c8483f268c8 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 05:36:55 -0700 Subject: [PATCH 15/31] fix(rewards-claim): F3/F4/F5 -- fix stale counters, dedup candidates, correct version doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/rewards_claim/engine.rs | 227 +++++++++++++++++- .../dig-node-service/src/rewards_claim/mod.rs | 3 +- .../src/rewards_claim/types.rs | 6 +- 3 files changed, 229 insertions(+), 7 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 348ede77..f83d2d09 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -91,13 +91,22 @@ impl ClaimEngine { /// (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: per-cycle fields are reset here, not carried over — a fault or a claim - // count from a PAST cycle must never leak into this cycle's reading of `compute_state()`. - // F1: `chain_unavailable_this_cycle` joins this reset -- it must never persist past the - // cycle it was observed in (see `ClaimStatus::chain_unavailable_this_cycle`'s doc). + // 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); let mut spent_this_cycle_mojos = 0u64; let mut budget_exhausted = false; @@ -124,6 +133,14 @@ impl ClaimEngine { } 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. @@ -1450,6 +1467,118 @@ mod tests { ); } + /// 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 { + calls: Mutex, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for HealthyThenUnavailablePort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let mut calls = self.calls.lock().unwrap(); + *calls += 1; + if *calls == 1 { + drop(calls); + 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: Mutex::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). @@ -1461,4 +1590,94 @@ mod tests { 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/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index 5aee9c35..5d5d4a57 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -19,7 +19,8 @@ //! //! # The chain seam //! -//! `dig-rewards-coin` is SPEC-only at v0.1.1 (`src/` is `error.rs` + `lib.rs`); its driver is +//! `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 diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 53383a2c..a915a400 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -192,8 +192,10 @@ pub struct ClaimStatus { /// 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_cycle_at`], reset at the start of every `run_cycle` alongside the other - /// per-cycle counters (never a lifetime latch). + /// [`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, From 5aaeae3c357bf57992ca13a5ecc544400a9b9ca7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 05:42:51 -0700 Subject: [PATCH 16/31] 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 --- .../src/rewards_claim/engine.rs | 65 ++++++++++++++++++- .../src/rewards_claim/types.rs | 12 +++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index f83d2d09..df086739 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -1328,8 +1328,22 @@ mod tests { "cycle {cycle}: a per-distributor mismatch must never read as the cycle-wide Faulted" ); } - // The healthy distributor claims every cycle, so the surface reads Nominal, not buried. - assert_eq!(e.status().state, ClaimLoopState::Nominal); + // 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)`. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + 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, @@ -1338,6 +1352,53 @@ mod tests { 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)" + ); + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 0, + 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). diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index a915a400..db50d935 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -264,7 +264,17 @@ impl ClaimStatus { // 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. - if self.claims_submitted_this_cycle < u64::from(self.distributors_claimable) { + // + // 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 { return ClaimLoopState::ClaimableButNotClaiming { claimable: self.distributors_claimable, submitted: u32::try_from(self.claims_submitted_this_cycle).unwrap_or(u32::MAX), From a09205e53e46cebf7b63cf11a3d2adda5e5d512d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 05:50:23 -0700 Subject: [PATCH 17/31] 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 --- .../src/rewards_claim/engine.rs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index df086739..8d6cf4d4 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -468,6 +468,7 @@ enum BudgetPhaseResult { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Mutex; use async_trait::async_trait; @@ -1426,7 +1427,11 @@ mod tests { /// 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 { - calls: Mutex, + // 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, } @@ -1435,12 +1440,10 @@ mod tests { async fn discover_distributors( &self, ) -> Result, ClaimPortError> { - let mut calls = self.calls.lock().unwrap(); - *calls += 1; - if *calls == 1 { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { return Err(ClaimPortError::Unavailable); } - drop(calls); self.inner.discover_distributors().await } async fn resolve_launch_comment( @@ -1494,7 +1497,7 @@ mod tests { ); let launcher_id = distributor.launcher_id; let port = FlakyThenHealthyPort { - calls: Mutex::new(0), + calls: AtomicU32::new(0), inner: FakeChainPort::new(vec![distributor]), }; let mut e = ClaimEngine::new( @@ -1531,7 +1534,9 @@ mod tests { /// 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 { - calls: Mutex, + // 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, } @@ -1540,10 +1545,8 @@ mod tests { async fn discover_distributors( &self, ) -> Result, ClaimPortError> { - let mut calls = self.calls.lock().unwrap(); - *calls += 1; - if *calls == 1 { - drop(calls); + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { return self.inner.discover_distributors().await; } Err(ClaimPortError::Unavailable) @@ -1600,7 +1603,7 @@ mod tests { 10, ); let port = HealthyThenUnavailablePort { - calls: Mutex::new(0), + calls: AtomicU32::new(0), inner: FakeChainPort::new(vec![distributor]), }; let mut e = ClaimEngine::new( From a2aa956641ee47456c3e5779eacba2d6d2936f48 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 06:29:52 -0700 Subject: [PATCH 18/31] 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 --- .../src/rewards_claim/config.rs | 54 +++ .../src/rewards_claim/engine.rs | 403 +++++++++++++++++- 2 files changed, 456 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs index 14ca131a..64ed5e07 100644 --- a/crates/dig-node-service/src/rewards_claim/config.rs +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -89,6 +89,33 @@ pub struct RewardsClaimConfig { /// 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, } fn default_enabled() -> bool { @@ -120,6 +147,9 @@ impl Default for RewardsClaimConfig { 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, } } } @@ -220,6 +250,9 @@ mod tests { 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, }; cfg.save_to(dir.path()).expect("save"); @@ -249,6 +282,27 @@ mod tests { 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() diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 8d6cf4d4..1c2182d6 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -2,8 +2,11 @@ //! [`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}; @@ -42,6 +45,27 @@ pub struct ClaimEngine { /// [`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 [`Self::fee_window_start_unix`], + /// [`Self::fee_spent_in_window_mojos`] and [`Self::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. + 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, + /// F7: the start (unix seconds) of the current aggregate-fee-budget window -- see + /// [`super::config::RewardsClaimConfig::fee_window_start_unix`]. + fee_window_start_unix: Option, + /// F7: fee mojos already spent inside the current window -- the field that actually bounds a + /// crash-restart loop. See [`super::config::RewardsClaimConfig::fee_spent_in_window_mojos`]. + fee_spent_in_window_mojos: u64, + /// F7: when the last cycle that ran to completion finished -- the cadence gate's clock. See + /// [`super::config::RewardsClaimConfig::last_cycle_completed_at`]. + last_cycle_completed_at: Option, } impl ClaimEngine { @@ -62,6 +86,11 @@ impl ClaimEngine { dig_asset_id, status: ClaimStatus::default(), rotation_cursor: None, + fee_window_state_dir: None, + cadence_seconds: 0, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, } } @@ -81,6 +110,53 @@ impl ClaimEngine { 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. + #[must_use] + pub fn with_persisted_fee_window(mut self, dir: &Path, cadence_seconds: u64) -> Self { + let cfg = RewardsClaimConfig::load_from(dir); + self.fee_window_state_dir = Some(dir.to_path_buf()); + self.cadence_seconds = cadence_seconds; + self.fee_window_start_unix = cfg.fee_window_start_unix; + self.fee_spent_in_window_mojos = cfg.fee_spent_in_window_mojos; + self.last_cycle_completed_at = cfg.last_cycle_completed_at; + 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. + fn persist_fee_window(&self) { + let Some(dir) = &self.fee_window_state_dir else { + return; + }; + let mut cfg = RewardsClaimConfig::load_from(dir); + cfg.fee_window_start_unix = self.fee_window_start_unix; + cfg.fee_spent_in_window_mojos = self.fee_spent_in_window_mojos; + cfg.last_cycle_completed_at = self.last_cycle_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 @@ -108,7 +184,36 @@ impl ClaimEngine { self.status.claims_submitted_this_cycle = 0; self.status.no_entry_slot_this_cycle = 0; self.status.last_attempt_at = Some(now); - let mut spent_this_cycle_mojos = 0u64; + + // 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 self.fee_window_state_dir.is_some() { + // 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. + if let Some(last_completed) = self.last_cycle_completed_at { + if now.saturating_sub(last_completed) < self.cadence_seconds { + 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 = self + .fee_window_start_unix + .is_some_and(|start| now.saturating_sub(start) < self.cadence_seconds); + if !window_still_open { + self.fee_window_start_unix = Some(now); + self.fee_spent_in_window_mojos = 0; + self.persist_fee_window(); + } + } + let mut spent_this_cycle_mojos = if self.fee_window_state_dir.is_some() { + self.fee_spent_in_window_mojos + } else { + 0 + }; let mut budget_exhausted = false; let mut discovery_failed = false; @@ -262,6 +367,15 @@ impl ClaimEngine { 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"). 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() { + self.last_cycle_completed_at = Some(now); + self.persist_fee_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. @@ -421,6 +535,18 @@ impl ClaimEngine { }); } + // 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. Conservative in the failure direction + // only: a submission that ultimately errors still counts against the persisted window, + // even though `spent_this_cycle_mojos` below (the in-cycle running total the NEXT + // candidate's budget check reads) only advances on a confirmed `Ok`, exactly as before F7. + if self.fee_window_state_dir.is_some() { + self.fee_spent_in_window_mojos += fee; + self.persist_fee_window(); + } + match self .port .submit_initiate_payout(launcher_id, self.own_payout_puzzle_hash, fee) @@ -1246,6 +1372,281 @@ mod tests { 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" + ); + } + /// 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. From 24b7b63e904272063fafcacc7f48e1d63108e451 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 06:54:24 -0700 Subject: [PATCH 19/31] 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 --- crates/dig-node-service/src/rewards_claim/cadence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs index 20a244a2..ec9cc540 100644 --- a/crates/dig-node-service/src/rewards_claim/cadence.rs +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -70,6 +70,7 @@ mod tests { 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)); From f478516a58771560e971d475c1f24f33ab7344fa Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 13:04:53 -0700 Subject: [PATCH 20/31] 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 --- .../src/rewards_claim/config.rs | 187 +++++++- .../src/rewards_claim/engine.rs | 427 +++++++++++++++++- .../src/rewards_claim/types.rs | 64 ++- 3 files changed, 652 insertions(+), 26 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs index 64ed5e07..8e0a77bd 100644 --- a/crates/dig-node-service/src/rewards_claim/config.rs +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -43,6 +43,14 @@ pub const CLAIM_FEE_CEILING_MOJOS_DEFAULT: u64 = 200_000; /// 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. @@ -116,6 +124,17 @@ pub struct RewardsClaimConfig { /// 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 { @@ -150,6 +169,7 @@ impl Default for RewardsClaimConfig { fee_window_start_unix: None, fee_spent_in_window_mojos: 0, last_cycle_completed_at: None, + corrupt: false, } } } @@ -165,42 +185,102 @@ impl RewardsClaimConfig { self.save_to(&crate::state::state_dir()) } - /// Load from an explicit directory. A missing or unparsable file yields the default — the same - /// survivable-degradation posture as `CollateralConfig::load_from` — visibly logged, never - /// silent, and never fatal to node start over one preferences file. + /// 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::warn!( + tracing::error!( path = %path.display(), error = %e, - "the rewards-claim preference file could not be read; using defaults" + "the rewards-claim preference file could not be read; failing closed, not \ + using defaults" ); - return Self::default(); + return Self::poisoned(); } }; - match serde_json::from_str(&text) { - Ok(cfg) => cfg, + 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::warn!( + tracing::error!( path = %path.display(), error = %e, - "the rewards-claim preference file could not be parsed; using defaults" + "the rewards-claim preference file could not be parsed; failing closed, not \ + using defaults" ); - Self::default() + Self::poisoned() } } } - /// Persist to `dir`, creating the state directory with restricted permissions if needed. + /// 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(&path, body)?; + std::fs::write(&temp, &body)?; + crate::control::restrict_permissions(&temp); + std::fs::rename(&temp, &path)?; crate::control::restrict_permissions(&path); Ok(()) } @@ -253,6 +333,7 @@ mod tests { 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"); @@ -326,4 +407,84 @@ mod tests { 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 index 1c2182d6..351a2e08 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -66,6 +66,15 @@ pub struct ClaimEngine { /// F7: when the last cycle that ran to completion finished -- the cadence gate's clock. See /// [`super::config::RewardsClaimConfig::last_cycle_completed_at`]. last_cycle_completed_at: Option, + /// F8/F10: set by [`Self::with_persisted_fee_window`] when the loaded + /// [`RewardsClaimConfig`] was [`RewardsClaimConfig::corrupt`] (unreadable, unparsable, or a + /// spend exceeding its own budget), OR discovered at the top of [`Self::run_cycle`] when + /// either persisted clock reads AFTER `now` (a future-dated clock is corrupt state exactly + /// the same way, F10). Either way this fails CLOSED: the window reads as fully spent and no + /// candidate is evaluated, rather than silently loading [`RewardsClaimConfig::default`] and + /// re-granting a budget (F8) or silently freezing forever under a healthy-looking state (the + /// pre-F9 reading of F10). + fee_window_poisoned: bool, } impl ClaimEngine { @@ -91,6 +100,7 @@ impl ClaimEngine { fee_window_start_unix: None, fee_spent_in_window_mojos: 0, last_cycle_completed_at: None, + fee_window_poisoned: false, } } @@ -124,11 +134,15 @@ impl ClaimEngine { /// 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. #[must_use] pub fn with_persisted_fee_window(mut self, dir: &Path, cadence_seconds: u64) -> Self { let cfg = RewardsClaimConfig::load_from(dir); self.fee_window_state_dir = Some(dir.to_path_buf()); - self.cadence_seconds = cadence_seconds; + self.cadence_seconds = cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.fee_window_poisoned = cfg.corrupt; self.fee_window_start_unix = cfg.fee_window_start_unix; self.fee_spent_in_window_mojos = cfg.fee_spent_in_window_mojos; self.last_cycle_completed_at = cfg.last_cycle_completed_at; @@ -140,11 +154,28 @@ impl ClaimEngine { /// [`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`]. fn persist_fee_window(&self) { 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 = self.fee_window_start_unix; cfg.fee_spent_in_window_mojos = self.fee_spent_in_window_mojos; cfg.last_cycle_completed_at = self.last_cycle_completed_at; @@ -189,11 +220,31 @@ impl ClaimEngine { // `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 self.fee_window_state_dir.is_some() { + // 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. + let future_dated_clock = self.last_cycle_completed_at.is_some_and(|t| t > now) + || self.fee_window_start_unix.is_some_and(|t| t > now); + if self.fee_window_poisoned || future_dated_clock { + self.fee_window_poisoned = true; + self.status.state = ClaimLoopState::PersistedStateCorrupt; + return Vec::new(); + } // 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) = self.last_cycle_completed_at { if now.saturating_sub(last_completed) < self.cadence_seconds { + self.status.state = ClaimLoopState::CadenceNotElapsed; return Vec::new(); } } @@ -369,9 +420,12 @@ impl ClaimEngine { } // 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"). 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. + // "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() { self.last_cycle_completed_at = Some(now); self.persist_fee_window(); @@ -525,7 +579,17 @@ impl ClaimEngine { // 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. - if *budget_exhausted || *spent_this_cycle_mojos + fee > self.cycle_fee_budget_mojos { + // + // 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 { @@ -538,12 +602,12 @@ impl ClaimEngine { // 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. Conservative in the failure direction - // only: a submission that ultimately errors still counts against the persisted window, - // even though `spent_this_cycle_mojos` below (the in-cycle running total the NEXT - // candidate's budget check reads) only advances on a confirmed `Ok`, exactly as before F7. + // 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() { - self.fee_spent_in_window_mojos += fee; + self.fee_spent_in_window_mojos = self.fee_spent_in_window_mojos.saturating_add(fee); self.persist_fee_window(); } @@ -556,13 +620,35 @@ impl ClaimEngine { *spent_this_cycle_mojos += fee; BudgetPhaseResult::Outcome(ClaimOutcome::Submitted { launcher_id }) } - Err(ClaimPortError::Unavailable) => BudgetPhaseResult::ChainUnavailable, + // 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); + BudgetPhaseResult::ChainUnavailable + } Err(ClaimPortError::Other(_)) => { + self.uncommit_fee(fee); self.status.fault_reported = true; BudgetPhaseResult::Fault } } } + + /// 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) { + if self.fee_window_state_dir.is_some() { + self.fee_spent_in_window_mojos = self.fee_spent_in_window_mojos.saturating_sub(fee); + self.persist_fee_window(); + } + } } /// An above-threshold, hash-matching candidate waiting for the budget pass (Defect B2). @@ -626,6 +712,15 @@ mod tests { 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>, + /// 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 { @@ -639,8 +734,21 @@ mod tests { ), submitted: Mutex::new(Vec::new()), own_entry_reads: Mutex::new(0), + fail_submit_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); + } + + /// 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] @@ -724,6 +832,13 @@ mod tests { 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() @@ -1647,6 +1762,296 @@ mod tests { ); } + /// 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 regression: a future-dated `last_cycle_completed_at` (an NTP step, a clock glitch, or + /// corrupt state) must not silently freeze the loop forever while reading healthy -- it must + /// be a REPORTED condition. Must go red with only the `future_dated_clock` check removed (the + /// old behaviour: `saturating_sub` yields 0, the cadence gate blocks the cycle, and — with F9 + /// fixed — that reads as `CadenceNotElapsed`, never `PersistedStateCorrupt` as asserted here). + #[tokio::test] + async fn f10_a_future_dated_clock_is_reported_not_silent() { + 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); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + 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)" + ); + } + + /// 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 { .. }).not(),); + + 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" + ); + } + + /// 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 result = e + .evaluate_budget_phase(&claim, &mut spent_this_cycle_mojos, &mut budget_exhausted) + .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. diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index db50d935..d946e60a 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -95,6 +95,17 @@ pub enum ClaimOutcome { /// [`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. @@ -103,6 +114,20 @@ pub enum ClaimLoopState { /// 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, 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 until an operator fixes or removes the file; 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). + 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. @@ -275,8 +300,15 @@ impl ClaimStatus { 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: self.distributors_claimable, + claimable: u32::try_from(shortfall_denominator).unwrap_or(u32::MAX), submitted: u32::try_from(self.claims_submitted_this_cycle).unwrap_or(u32::MAX), }; } @@ -452,10 +484,38 @@ mod tests { }; 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: 2, + 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 165c8f454e3f2a80b93f2d6ed05a99f500b19d8b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 13:07:20 -0700 Subject: [PATCH 21/31] fix(rewards-claim): negate with ! rather than the unimported Not trait Co-Authored-By: Claude Haiku 4.5 --- crates/dig-node-service/src/rewards_claim/engine.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 351a2e08..1afbe4d0 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -2011,7 +2011,10 @@ mod tests { 1, "the one candidate must have been evaluated, or this proves nothing about its fee" ); - assert!(matches!(outcomes[0], ClaimOutcome::PayoutPuzzleHashMismatch { .. }).not(),); + assert!(!matches!( + outcomes[0], + ClaimOutcome::PayoutPuzzleHashMismatch { .. } + )); let persisted = RewardsClaimConfig::load_from(dir.path()); assert_eq!( From 1b104f9881a8ad6155ae243354d6771411176880 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 13:34:43 -0700 Subject: [PATCH 22/31] 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 --- crates/dig-node-service/src/rewards_claim/engine.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 1afbe4d0..c3dc19b9 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -2145,10 +2145,14 @@ mod tests { // 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: 1, + claimable: 2, submitted: 1 }, "an ongoing payout-hash mismatch is a real, per-cycle shortfall -- it must never read \ @@ -2198,10 +2202,13 @@ mod tests { !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: 0, + claimable: 1, submitted: 0 }, "all-K-distributors mismatching is a real, systemic shortfall -- it must never read \ From 730aa0bcb68500366426b6532d1dc9e0d3265d22 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 14:08:04 -0700 Subject: [PATCH 23/31] 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 --- .../src/rewards_claim/types.rs | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index d946e60a..4ebf26a7 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -25,7 +25,9 @@ pub struct OwnEntry { } /// What one distributor's evaluation this cycle produced — never silently nothing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// 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 }, @@ -65,6 +67,42 @@ pub enum ClaimOutcome { /// 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 From e3bd3dd3a33bddf5ba86bd78c755a04545e7030f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 14:10:10 -0700 Subject: [PATCH 24/31] 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 --- .../src/rewards_claim/engine.rs | 73 +++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index c3dc19b9..f37d64bc 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -329,7 +329,14 @@ impl ClaimEngine { // 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 => faulted += 1, + 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; @@ -374,7 +381,17 @@ impl ClaimEngine { .evaluate_budget_phase(claim, &mut spent_this_cycle_mojos, &mut budget_exhausted) .await { - BudgetPhaseResult::Fault => faulted += 1, + 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; @@ -444,9 +461,11 @@ impl ClaimEngine { let asset = match self.port.reserve_asset_id(launcher_id).await { Ok(a) => a, Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, - Err(ClaimPortError::Other(_)) => { + Err(ClaimPortError::Other(message)) => { self.status.fault_reported = true; - return PreBudgetResult::Fault; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; } }; if asset != self.dig_asset_id { @@ -465,9 +484,11 @@ impl ClaimEngine { return PreBudgetResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false); } Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, - Err(ClaimPortError::Other(_)) => { + Err(ClaimPortError::Other(message)) => { self.status.fault_reported = true; - return PreBudgetResult::Fault; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; } }; @@ -492,9 +513,11 @@ impl ClaimEngine { let threshold = match self.port.payout_threshold(launcher_id).await { Ok(t) => t, Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, - Err(ClaimPortError::Other(_)) => { + Err(ClaimPortError::Other(message)) => { self.status.fault_reported = true; - return PreBudgetResult::Fault; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; } }; @@ -561,9 +584,12 @@ impl ClaimEngine { let fee = match self.port.required_fee_mojos(launcher_id).await { Ok(f) => f, Err(ClaimPortError::Unavailable) => return BudgetPhaseResult::ChainUnavailable, - Err(ClaimPortError::Other(_)) => { + Err(ClaimPortError::Other(message)) => { self.status.fault_reported = true; - return BudgetPhaseResult::Fault; + return BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: None, + }; } }; @@ -631,10 +657,13 @@ impl ClaimEngine { self.uncommit_fee(fee); BudgetPhaseResult::ChainUnavailable } - Err(ClaimPortError::Other(_)) => { + Err(ClaimPortError::Other(message)) => { self.uncommit_fee(fee); self.status.fault_reported = true; - BudgetPhaseResult::Fault + BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: Some(fee), + } } } } @@ -666,17 +695,33 @@ enum PreBudgetResult { launcher_id: Bytes32, accrued_base_units: u64, }, - Fault, + /// 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), - Fault, + /// 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; From 5f729d1b9c68bbd36e4d224e351d03ed056cc5f1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 14:11:35 -0700 Subject: [PATCH 25/31] 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 --- .../src/rewards_claim/engine.rs | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index f37d64bc..1fdf0a4c 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -698,7 +698,9 @@ enum PreBudgetResult { /// 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 }, + Fault { + reason: String, + }, ChainUnavailable, } @@ -2069,6 +2071,61 @@ mod tests { ); } + /// #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 From b93b1db5b2103a06a89f77a0b0f2df755171912e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 18:27:13 -0700 Subject: [PATCH 26/31] 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 --- .../src/rewards_claim/engine.rs | 174 +++++++++++++++--- .../src/rewards_claim/types.rs | 17 +- 2 files changed, 160 insertions(+), 31 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 1fdf0a4c..e96dd7a9 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -66,15 +66,19 @@ pub struct ClaimEngine { /// F7: when the last cycle that ran to completion finished -- the cadence gate's clock. See /// [`super::config::RewardsClaimConfig::last_cycle_completed_at`]. last_cycle_completed_at: Option, - /// F8/F10: set by [`Self::with_persisted_fee_window`] when the loaded - /// [`RewardsClaimConfig`] was [`RewardsClaimConfig::corrupt`] (unreadable, unparsable, or a - /// spend exceeding its own budget), OR discovered at the top of [`Self::run_cycle`] when - /// either persisted clock reads AFTER `now` (a future-dated clock is corrupt state exactly - /// the same way, F10). Either way this fails CLOSED: the window reads as fully spent and no - /// candidate is evaluated, rather than silently loading [`RewardsClaimConfig::default`] and - /// re-granting a budget (F8) or silently freezing forever under a healthy-looking state (the - /// pre-F9 reading of F10). - fee_window_poisoned: bool, + // 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 { @@ -100,7 +104,6 @@ impl ClaimEngine { fee_window_start_unix: None, fee_spent_in_window_mojos: 0, last_cycle_completed_at: None, - fee_window_poisoned: false, } } @@ -137,12 +140,16 @@ impl ClaimEngine { /// 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`. #[must_use] pub fn with_persisted_fee_window(mut self, dir: &Path, cadence_seconds: u64) -> Self { let cfg = RewardsClaimConfig::load_from(dir); self.fee_window_state_dir = Some(dir.to_path_buf()); self.cadence_seconds = cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); - self.fee_window_poisoned = cfg.corrupt; self.fee_window_start_unix = cfg.fee_window_start_unix; self.fee_spent_in_window_mojos = cfg.fee_spent_in_window_mojos; self.last_cycle_completed_at = cfg.last_cycle_completed_at; @@ -219,7 +226,29 @@ impl ClaimEngine { // 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 self.fee_window_state_dir.is_some() { + 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). + struct CycleConditions { + corrupt: bool, + future_dated_clock: bool, + } + let conditions = { + let cfg = RewardsClaimConfig::load_from(&dir); + CycleConditions { + corrupt: cfg.corrupt, + future_dated_clock: self.last_cycle_completed_at.is_some_and(|t| t > now) + || self.fee_window_start_unix.is_some_and(|t| t > now), + } + }; // 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), @@ -227,10 +256,7 @@ impl ClaimEngine { // 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. - let future_dated_clock = self.last_cycle_completed_at.is_some_and(|t| t > now) - || self.fee_window_start_unix.is_some_and(|t| t > now); - if self.fee_window_poisoned || future_dated_clock { - self.fee_window_poisoned = true; + if conditions.corrupt || conditions.future_dated_clock { self.status.state = ClaimLoopState::PersistedStateCorrupt; return Vec::new(); } @@ -419,7 +445,17 @@ impl ClaimEngine { // 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. - let all_faulted_cycle = any_candidates && outcomes.is_empty() && self.status.fault_reported; + // + // 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; @@ -763,6 +799,11 @@ mod tests { /// `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. @@ -782,6 +823,7 @@ mod tests { 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()), } @@ -792,6 +834,13 @@ mod tests { 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); @@ -833,6 +882,14 @@ mod tests { } 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() @@ -1320,6 +1377,48 @@ mod tests { } } + /// 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] @@ -1975,13 +2074,17 @@ mod tests { ); } - /// F10 regression: a future-dated `last_cycle_completed_at` (an NTP step, a clock glitch, or - /// corrupt state) must not silently freeze the loop forever while reading healthy -- it must - /// be a REPORTED condition. Must go red with only the `future_dated_clock` check removed (the - /// old behaviour: `saturating_sub` yields 0, the cadence gate blocks the cycle, and — with F9 - /// fixed — that reads as `CadenceNotElapsed`, never `PersistedStateCorrupt` as asserted here). + /// 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_is_reported_not_silent() { + 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() @@ -2009,10 +2112,11 @@ mod tests { ) .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); - let outcomes = e.run_cycle(1_000).await; - + // 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!( - outcomes, + cycle1, Vec::new(), "a future-dated clock must submit nothing this cycle" ); @@ -2023,6 +2127,24 @@ mod tests { 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" + ); } /// F12 regression: a submission that DEFINITELY failed (the call returned `Err`, so it never diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 4ebf26a7..5c5614f0 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -152,12 +152,19 @@ pub enum ClaimLoopState { /// 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, fund-safety: the persisted rewards-claim state (`RewardsClaimConfig`) was + /// 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 until an operator fixes or removes the file; 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). + /// 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 From 231341178266d6946fd8bd8f01173f0f965297bf Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 18:46:18 -0700 Subject: [PATCH 27/31] 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 --- crates/dig-node-service/src/rewards_claim/engine.rs | 4 +++- crates/dig-node-service/src/rewards_claim/types.rs | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index e96dd7a9..8e6aee60 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -888,7 +888,9 @@ mod tests { .unwrap() .contains(&launcher_id) { - return Err(ClaimPortError::Other("simulated reserve_asset_id fault".into())); + return Err(ClaimPortError::Other( + "simulated reserve_asset_id fault".into(), + )); } self.distributors .lock() diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index 5c5614f0..dfe22737 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -162,9 +162,9 @@ pub enum ClaimLoopState { /// - 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. + /// 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 From 99c278eb7a1ddc265c52bf1b244278be460179f6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 18:47:25 -0700 Subject: [PATCH 28/31] test: THROWAWAY revert-check -- remove 4 config.rs corrupt-load enforcements (not for merge) Rebased onto the clippy/rustfmt-fixed tip so Test + coverage failures here are attributable only to the removed enforcement, not to the unrelated doc/fmt issue that was present at the prior base. Refs #594 Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/config.rs | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs index 8e0a77bd..5f88b754 100644 --- a/crates/dig-node-service/src/rewards_claim/config.rs +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -213,7 +213,7 @@ impl RewardsClaimConfig { 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) if e.kind() == std::io::ErrorKind::NotFound => return Self::poisoned(), Err(e) => { tracing::error!( path = %path.display(), @@ -229,28 +229,9 @@ impl RewardsClaimConfig { // 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(); - } + // THROWAWAY REVERT-CHECK (Finding 3): both enforcement blocks removed to prove + // `a_cadence_below_the_floor_is_clamped_up_on_load` and + // `a_spend_exceeding_its_own_budget_fails_closed` are not vacuous. Never merged. cfg } Err(e) => { @@ -260,7 +241,9 @@ impl RewardsClaimConfig { "the rewards-claim preference file could not be parsed; failing closed, not \ using defaults" ); - Self::poisoned() + // THROWAWAY REVERT-CHECK (Finding 3): fail-open instead of `Self::poisoned()`, to + // prove `a_corrupt_file_fails_closed_not_default` is not vacuous. Never merged. + Self::default() } } } From 28dbdd3554c0b3d93f85a5b0ff0117a4852740f2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 19:09:38 -0700 Subject: [PATCH 29/31] fix: THROWAWAY -- drop now-unused mut after removing the clamp mutation Refs #594 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-service/src/rewards_claim/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs index 5f88b754..9d7f0f4d 100644 --- a/crates/dig-node-service/src/rewards_claim/config.rs +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -225,7 +225,7 @@ impl RewardsClaimConfig { } }; match serde_json::from_str::(&text) { - Ok(mut cfg) => { + Ok(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. From 77d06f185b1cb32ccd039d3f999ee3a05f86bd75 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 19:29:51 -0700 Subject: [PATCH 30/31] test: THROWAWAY -- disable nextest fail-fast so all 5 revert-check tests run Refs #594 Co-Authored-By: Claude Sonnet 5 --- .config/nextest.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 1e2fbefa..538c9ae5 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -14,3 +14,7 @@ slow-timeout = { period = "60s", terminate-after = 3 } [profile.ci] slow-timeout = { period = "60s", terminate-after = 3 } retries = 2 +# THROWAWAY (Finding 3 revert-check diagnostic, never merged): disable fail-fast so a deliberately +# reverted enforcement's failure doesn't cancel the rest of the suite before the other four +# under-test config.rs tests get a chance to run. +fail-fast = false From 055a0456758f2b6adfb0e771f97946f60107c541 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 19:49:53 -0700 Subject: [PATCH 31/31] fix: THROWAWAY -- move fail-fast=false to [profile.default], CI passes no --profile The prior commit put fail-fast under [profile.ci], but the Test + coverage job invokes `cargo nextest run` without --profile, so [profile.default] governs and the setting never applied (run still cut off at 1913/3347 after the first failure). Refs #594 Co-Authored-By: Claude Sonnet 5 --- .config/nextest.toml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 538c9ae5..6f1a1034 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -8,13 +8,14 @@ # per-test override here rather than removing this backstop. [profile.default] slow-timeout = { period = "60s", terminate-after = 3 } +# THROWAWAY (Finding 3 revert-check diagnostic, never merged): CI invokes cargo nextest +# run WITHOUT --profile, so [profile.ci] settings never applied here -- fail-fast must +# live on [profile.default] to disable it, so a deliberately reverted enforcement does +# not cancel the rest of the suite before the other under-test config.rs tests run. +fail-fast = false # CI runs `cargo llvm-cov nextest` with `--retries 2`; the same backstop must apply there so a hang # on CI is terminated identically to a local run. [profile.ci] slow-timeout = { period = "60s", terminate-after = 3 } retries = 2 -# THROWAWAY (Finding 3 revert-check diagnostic, never merged): disable fail-fast so a deliberately -# reverted enforcement's failure doesn't cancel the rest of the suite before the other four -# under-test config.rs tests get a chance to run. -fail-fast = false