diff --git a/.config/nextest.toml b/.config/nextest.toml index 1e2fbefa..6f1a1034 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -8,6 +8,11 @@ # 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. diff --git a/Cargo.lock b/Cargo.lock index 0a7478c6..25b0e0ed 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]] @@ -3752,7 +3752,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4536,7 +4536,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4787,7 +4787,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5169,7 +5169,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5780,7 +5780,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -5818,9 +5818,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6570,7 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7020,7 +7020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7282,7 +7282,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8416,7 +8416,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 906ff93d..7d240feb 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -103,6 +103,12 @@ pub mod peers; /// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an /// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. pub mod relay; +/// The node's PEER-SIDE reward claim loop (DIG-Network/dig_ecosystem#3251): discovers the +/// reward distributors covering the `(store_id, root)`s this node mirrors and submits +/// `InitiatePayout` on a jittered cadence, default 24h. The other half of the reward-distributor +/// lifecycle from `dig_node_core::rewards` (#3250, the funder-side prover, a sibling lane). See +/// [`rewards_claim`]. +pub mod rewards_claim; pub mod rpc; /// The offline `wallet export-seed` rescue command: a local read of this node's /// encrypted seed file. Adds no network surface, and is removed with node-side custody. diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs new file mode 100644 index 00000000..ec9cc540 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -0,0 +1,85 @@ +//! Claim cadence + jitter (SPEC §8.6): the peer's own setting, never curried on the distributor, +//! and jittered so a network of peers on the default does not converge on one minute of the day. +//! +//! The jitter SOURCE is injected — never a global RNG or clock read directly — so the schedule is +//! deterministic under test. + +/// SPEC §8.6: the minimum jitter spread every peer MUST apply. +pub const CLAIM_JITTER_SECONDS_DEFAULT: u64 = 3_600; + +/// Supplies the jitter offset for one scheduling decision. A production implementation draws from +/// the OS CSPRNG; tests inject a fixed or sequenced value. +pub trait JitterSource: Send + Sync { + /// An offset in `0..=bound` seconds. + fn jitter_seconds(&self, bound: u64) -> u64; +} + +/// A jitter source that always returns the same value — for deterministic tests. +pub struct FixedJitter(pub u64); + +impl JitterSource for FixedJitter { + fn jitter_seconds(&self, bound: u64) -> u64 { + self.0.min(bound) + } +} + +/// The next cadence interval, in seconds: `cadence_seconds + jitter`, where `jitter` is drawn from +/// `[0, jitter_seconds]` via the injected source (SPEC §8.6). `jitter_seconds` is a lower bound on +/// the SPREAD available to the source, not a fixed addition — a source that always returns `0` +/// still produces a schedule within the required bound, just at its floor. +#[must_use] +pub fn next_interval_seconds( + cadence_seconds: u64, + jitter_seconds: u64, + source: &dyn JitterSource, +) -> u64 { + let offset = source.jitter_seconds(jitter_seconds); + cadence_seconds.saturating_add(offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interval_is_cadence_plus_a_bounded_jitter() { + let cadence = 86_400; + let jitter_bound = 3_600; + + let at_floor = next_interval_seconds(cadence, jitter_bound, &FixedJitter(0)); + assert_eq!(at_floor, cadence); + + let at_ceiling = next_interval_seconds(cadence, jitter_bound, &FixedJitter(jitter_bound)); + assert_eq!(at_ceiling, cadence + jitter_bound); + + // A source that tries to exceed the bound is clamped by the source contract itself + // (FixedJitter here), and the composed interval never exceeds cadence + jitter_seconds. + let over = next_interval_seconds(cadence, jitter_bound, &FixedJitter(jitter_bound * 10)); + assert!(over <= cadence + jitter_bound); + assert!(over >= cadence); + } + + /// ACCEPTANCE 8 (part) — a config setting a cadence OTHER than the default is honoured by the + /// scheduling function, not silently overridden back to `CLAIM_CADENCE_SECONDS_DEFAULT`. + #[test] + fn a_non_default_configured_cadence_is_honoured() { + let cfg = super::super::config::RewardsClaimConfig { + enabled: true, + cadence_seconds: 12_000, + jitter_seconds: 500, + max_fee_mojos: 1, + max_cycle_fee_budget_mojos: 10, + rotation_cursor: None, + ..super::super::config::RewardsClaimConfig::default() + }; + let interval = + next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(0)); + assert_eq!( + interval, 12_000, + "configured cadence, not the 86_400 default" + ); + let interval_at_ceiling = + next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(500)); + assert_eq!(interval_at_ceiling, 12_500); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs new file mode 100644 index 00000000..9d7f0f4d --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -0,0 +1,473 @@ +//! This node's peer-side claim-loop preferences (requirement 5) — persisted the same way +//! `crate::collateral::CollateralConfig` is: a dedicated JSON file in the node's state dir, every +//! field `#[serde(default = "...")]` so a config written before a field existed loads that field's +//! DEFAULT, never a fabricated deliberate choice. + +use std::path::Path; + +use chia_protocol::Bytes32; +use serde::{Deserialize, Serialize}; + +use super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; + +/// SPEC §8.6: the peer-side claim cadence default. +pub const CLAIM_CADENCE_SECONDS_DEFAULT: u64 = 86_400; + +/// The max fee ceiling this node will spend on ONE claim (requirement 2). Not a floor: a true +/// "net > 0" floor is not computable here — the fee is XCH mojos, the reward is $DIG base units, +/// and the node holds no exchange rate between them. SPEC §8.3 clause 2 already asserts +/// `payout_threshold` (1 $DIG) is "above any plausible fee", so the threshold IS the economic floor +/// by construction; this constant only caps what the node will pay to collect it. +/// +/// # Defect C1: the magnitude, not the reasoning, was wrong +/// This constant originally reused `crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS` +/// (1_000_000_000 mojos = 0.001 XCH) — a number sized for a mirror-coin spend, not a per-distributor +/// claim repeated daily. Against a routine Chia transaction fee of 5,000-100,000 mojos, that ceiling +/// was four to five orders of magnitude too loose to ever bind a real fee: a peer could still lose +/// money inside it whenever 1 $DIG is worth less than 0.001 XCH, and the ceiling would never notice. +/// 200,000 mojos is 2x the top of the observed routine-fee range — enough headroom to survive a +/// congested mempool without giving up the one computable control this loop has. +pub const CLAIM_FEE_CEILING_MOJOS_DEFAULT: u64 = 200_000; + +/// The per-cycle AGGREGATE fee budget (Defect C2): a cap on what this node will spend across ALL +/// claims in one cycle, independent of [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s per-claim cap. +/// +/// `required_fee_mojos(launcher_id)` is per-distributor state that anyone may create: a DIG-asset +/// distributor may be launched over any widely mirrored store. Without an aggregate cap, an +/// attacker funding K such distributors and getting a victim peer's payout puzzle hash admitted to +/// each could force that peer to spend up to `K * CLAIM_FEE_CEILING_MOJOS_DEFAULT` of its own XCH +/// per cycle, at a cost to the attacker of only K $DIG. Defaulting this to 10x the per-claim ceiling +/// bounds a single cycle to roughly 10 distributors' worth of fees before the loop stops claiming +/// for the rest of that cycle and reports it by name +/// (`ClaimOutcome::SkippedCycleBudgetExhausted`) — configurable for an operator who mirrors more +/// than that many distributors' worth of stores. +pub const CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT: u64 = CLAIM_FEE_CEILING_MOJOS_DEFAULT * 10; + +/// F10 (§8.6 floor): the lowest `cadence_seconds` this config will honour. SPEC §8.6 sets the +/// default at `86_400` but never floors an operator-supplied override, so an unvalidated `0` (or a +/// handful of seconds) would hot-loop `ClaimEngine::run_cycle` — a chain read on every tick with no +/// cadence protection at all, the same class of unbounded-work defect F7 closed for spend. One +/// minute is short enough to never bind a legitimate operator (SPEC's own default is a full day) +/// and long enough that a degenerate value cannot turn this loop into a busy-poll. +pub const CLAIM_CADENCE_FLOOR_SECONDS: u64 = 60; + +const REWARDS_CLAIM_CONFIG_FILE: &str = "rewards-claim.json"; + +/// This node's peer-side claim-loop preferences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RewardsClaimConfig { + /// Whether the claim loop runs at all. Default-on: a peer earning rewards and never claiming + /// them is the silent-failure case this ticket exists to prevent, so opting IN by default is + /// the honest posture — see [`crate::rewards_claim`]'s module doc. + /// + /// # R5: `true` here does not mean the loop is running yet + /// Nothing in this codebase constructs a [`super::ClaimEngine`] outside this module's own tests + /// (DIG-Network/dig_ecosystem#3268, not yet landed) — see [`crate::rewards_claim`]'s module doc, + /// "Not yet wired into node startup". An operator who reads their own `rewards-claim.json` and + /// sees `enabled: true` is exactly the person who needs to know that; the module doc alone does + /// not reach them. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// SPEC §8.6: base cadence between claim cycles, before jitter. + #[serde(default = "default_cadence_seconds")] + pub cadence_seconds: u64, + + /// SPEC §8.6: the jitter spread applied on top of `cadence_seconds` (see + /// [`super::cadence::next_interval_seconds`]). + #[serde(default = "default_jitter_seconds")] + pub jitter_seconds: u64, + + /// The PER-CLAIM fee ceiling (requirement 2) — see [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s doc for + /// why this is a ceiling, not a floor, and for the magnitude reasoning (Defect C1). + #[serde(default = "default_max_fee_mojos")] + pub max_fee_mojos: u64, + + /// The PER-CYCLE aggregate fee budget (Defect C2) — see + /// [`CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT`]'s doc for the attacker-cost reasoning. + #[serde(default = "default_max_cycle_fee_budget_mojos")] + pub max_cycle_fee_budget_mojos: u64, + + /// Defect B2: the tie-break cursor [`super::ClaimEngine::order_for_budget`] uses to rotate a + /// legitimately starved tail (a set of equal-accrual honest distributors whose combined fee + /// exceeds one cycle's budget every cycle) so the SAME distributors are not dropped every + /// cycle forever. Persisted here — not just held in the in-memory [`super::ClaimEngine`] — so + /// a node that restarts daily does not reset the rotation and starve the tail permanently. + /// `None` until the first cycle defers something; absent from a config written before this + /// field existed, which is the same as `None` (no rotation history yet). + #[serde(default)] + pub rotation_cursor: Option, + + /// F7: the start (unix seconds) of the CURRENT aggregate-fee-budget window. Read alongside + /// [`Self::fee_spent_in_window_mojos`] to decide, on each cycle, whether the window has rolled + /// over (`now - fee_window_start_unix >= cadence_seconds`) or whether spend must keep + /// accumulating into it. `None` until the first cycle ever runs; absent from a config written + /// before this field existed, which is the same as `None` (no window has started yet, so the + /// next cycle starts one fresh rather than reading a fabricated "already spent" history). + #[serde(default)] + pub fee_window_start_unix: Option, + + /// F7: fee mojos already spent inside [`Self::fee_window_start_unix`]'s window. This is the + /// field that actually bounds a crash-restart loop: without it, every fresh process starts + /// this at zero and re-grants a full [`Self::max_cycle_fee_budget_mojos`] on every restart, no + /// matter how many restarts happen inside one cadence period. Defaults to `0` — a config + /// written before this field existed had spent nothing in a window that did not exist either. + #[serde(default)] + pub fee_spent_in_window_mojos: u64, + + /// F7: the unix-second timestamp of the last cycle that ran to completion. The cadence gate + /// (`now - last_cycle_completed_at < cadence_seconds`) refuses to START a new cycle at all + /// until the cadence has genuinely elapsed since this time, so a crash-restart loop cannot + /// immediately re-run a cycle that already ran, independent of the fee-window check above. + /// `None` until the first cycle ever completes; absent from a config written before this field + /// existed is the same as `None` (no completed cycle on record, so the next cycle is allowed to + /// run immediately -- the honest reading for a node that has never run this loop before). + #[serde(default)] + pub last_cycle_completed_at: Option, + + /// F8: set by [`Self::load_from`] (never persisted, never read from the file itself) when the + /// file was present but unparsable, unreadable, or carried a `fee_spent_in_window_mojos` + /// exceeding its own `max_cycle_fee_budget_mojos` (F14) — corrupt state, not a fresh peer. + /// `ClaimEngine` reads this to fail CLOSED (treat the window as fully spent, submit nothing) + /// rather than the old behaviour of falling back to [`Self::default`], which re-granted a full + /// budget through the exact crash-restart loop F7 exists to bound. `#[serde(skip)]` because a + /// value read off disk can never itself declare "I am corrupt" — that fact lives only in + /// *how* the read failed, decided once, here, at load time. + #[serde(skip)] + pub corrupt: bool, +} + +fn default_enabled() -> bool { + true +} + +fn default_cadence_seconds() -> u64 { + CLAIM_CADENCE_SECONDS_DEFAULT +} + +fn default_jitter_seconds() -> u64 { + CLAIM_JITTER_SECONDS_DEFAULT +} + +fn default_max_fee_mojos() -> u64 { + CLAIM_FEE_CEILING_MOJOS_DEFAULT +} + +fn default_max_cycle_fee_budget_mojos() -> u64 { + CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT +} + +impl Default for RewardsClaimConfig { + fn default() -> Self { + RewardsClaimConfig { + enabled: default_enabled(), + cadence_seconds: default_cadence_seconds(), + jitter_seconds: default_jitter_seconds(), + max_fee_mojos: default_max_fee_mojos(), + max_cycle_fee_budget_mojos: default_max_cycle_fee_budget_mojos(), + rotation_cursor: None, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, + corrupt: false, + } + } +} + +impl RewardsClaimConfig { + /// Load from the node's own machine-wide state directory (production entry point). + pub fn load() -> Self { + RewardsClaimConfig::load_from(&crate::state::state_dir()) + } + + /// Persist to the node's own machine-wide state directory. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&crate::state::state_dir()) + } + + /// F8: the fail-CLOSED reading for a file this process could not trust — present but + /// unparsable, unreadable, or carrying a spend that exceeds its own budget (F14). Deliberately + /// NOT [`Self::default`]: a missing file is a clean first run and defaults are the honest + /// reading for it, but a corrupt one must never be treated the same way, because `default()` + /// re-grants a full spend budget into exactly the crash-restart loop F7 exists to bound. + /// `corrupt: true` is the only signal a caller needs — every other field here is a placeholder + /// `ClaimEngine` must not act on, and [`Self::save_to`] must never be called with this value + /// (see [`super::engine::ClaimEngine::persist_fee_window`]'s corrupt-file guard). + fn poisoned() -> Self { + RewardsClaimConfig { + corrupt: true, + ..Self::default() + } + } + + /// Load from an explicit directory. + /// + /// A MISSING file is a clean first run: [`Self::default`] is the honest reading, because + /// nothing has ever been decided or spent yet. + /// + /// A file this process cannot trust — unreadable, unparsable, or (F14) carrying a persisted + /// spend larger than its own budget — is a DIFFERENT fact and must never share `default()`'s + /// code path (F8): it becomes [`Self::poisoned`], visibly logged, and never fatal to node + /// start over one preferences file, but never silently re-granting a budget either. + pub fn load_from(dir: &Path) -> Self { + let path = dir.join(REWARDS_CLAIM_CONFIG_FILE); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::poisoned(), + Err(e) => { + tracing::error!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be read; failing closed, not \ + using defaults" + ); + return Self::poisoned(); + } + }; + match serde_json::from_str::(&text) { + Ok(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. + // 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) => { + tracing::error!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be parsed; failing closed, not \ + using defaults" + ); + // 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() + } + } + } + + /// Persist to `dir`, ATOMICALLY: written to a temp file beside the real path, then renamed + /// over it — the same pattern `crate::mirror::reconcile_state::ReconcileState::save_to` uses + /// for the same class of state in this crate (F8). Without this, a crash mid-`write` can leave + /// a torn file that [`Self::load_from`] would previously have read as [`Self::default`] and + /// re-granted a full budget into — the exact restart-loop F7 was written to close, reopened + /// through F7's own persist path. A rename is atomic on the same filesystem, so the file this + /// process's crash leaves behind is always either the old complete contents or the new + /// complete contents, never a half-write. + pub fn save_to(&self, dir: &Path) -> std::io::Result<()> { + crate::state::ensure_dir_restricted(dir)?; + let path = dir.join(REWARDS_CLAIM_CONFIG_FILE); + let temp = path.with_extension("json.tmp"); + let body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(&temp, &body)?; + crate::control::restrict_permissions(&temp); + std::fs::rename(&temp, &path)?; + crate::control::restrict_permissions(&path); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec_8_6_and_the_fee_ceiling() { + let cfg = RewardsClaimConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.cadence_seconds, 86_400); + assert_eq!(cfg.jitter_seconds, 3_600); + assert_eq!(cfg.max_fee_mojos, 200_000); + assert_eq!(cfg.max_cycle_fee_budget_mojos, 2_000_000); + } + + /// Defect C1 regression: the ceiling must actually bind a routine Chia fee — the old default + /// (1_000_000_000, transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`) was 4-5 orders of + /// magnitude looser than the observed 5,000-100,000 mojo range and never rejected a real fee. + #[test] + fn the_default_per_claim_ceiling_actually_binds_a_routine_fee() { + let cfg = RewardsClaimConfig::default(); + assert!( + cfg.max_fee_mojos < 1_000_000, + "the default ceiling must be within striking distance of a routine fee, not 1e9" + ); + assert!( + cfg.max_fee_mojos >= 100_000, + "the default ceiling must not reject the top of the routine fee range outright" + ); + } + + #[test] + fn save_then_load_round_trips_and_survives_restart() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-test-") + .tempdir() + .expect("a scratch dir"); + + let cfg = RewardsClaimConfig { + enabled: false, + cadence_seconds: 43_200, + jitter_seconds: 1_800, + max_fee_mojos: 150_000, + max_cycle_fee_budget_mojos: 900_000, + rotation_cursor: None, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, + corrupt: false, + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, cfg); + } + + /// Defect B2: a rotation cursor left in memory only resets on every restart, which starves a + /// legitimately-tied honest tail forever on any node that restarts daily. It must round-trip + /// through save/load exactly like every other field. + #[test] + fn the_rotation_cursor_survives_a_save_load_round_trip() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-cursor-test-") + .tempdir() + .expect("a scratch dir"); + + let cursor = Bytes32::from([7u8; 32]); + let cfg = RewardsClaimConfig { + rotation_cursor: Some(cursor), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded.rotation_cursor, Some(cursor)); + assert_eq!(loaded, cfg); + } + + /// F7: the persisted fee-window fields must round-trip through save/load exactly like every + /// other field -- this is the state a restart reads back to avoid re-granting a fresh budget. + #[test] + fn the_fee_window_fields_survive_a_save_load_round_trip() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-fee-window-test-") + .tempdir() + .expect("a scratch dir"); + + let cfg = RewardsClaimConfig { + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: 1_500_000, + last_cycle_completed_at: Some(1_000), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, cfg); + } + + #[test] + fn a_config_written_before_a_field_existed_loads_that_fields_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-legacy-test-") + .tempdir() + .expect("a scratch dir"); + std::fs::write(dir.path().join(REWARDS_CLAIM_CONFIG_FILE), b"{}").expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, RewardsClaimConfig::default()); + } + + #[test] + fn a_missing_file_yields_the_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-missing-test-") + .tempdir() + .expect("a scratch dir"); + assert_eq!( + RewardsClaimConfig::load_from(dir.path()), + RewardsClaimConfig::default() + ); + } + + /// F8 regression: a present-but-unparsable file must NOT load as [`RewardsClaimConfig::default`] + /// — that is exactly the fail-OPEN bug (a torn write reads as a clean first run and re-grants a + /// full spend budget). Must go red with only the `Err(e) => ... Self::poisoned()` branch of + /// [`RewardsClaimConfig::load_from`]'s parse-failure arm reverted to `Self::default()`. + #[test] + fn a_corrupt_file_fails_closed_not_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-corrupt-test-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + b"{ this is not json, or a torn write mid-object", + ) + .expect("write garbage"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert!( + loaded.corrupt, + "a present-but-unparsable file must be reported as corrupt, never silently defaulted" + ); + assert_ne!( + loaded, + RewardsClaimConfig::default(), + "corrupt state must be distinguishable from a clean first run" + ); + } + + /// F8: a MISSING file is the opposite fact from a corrupt one -- still a clean first run. + #[test] + fn a_missing_file_is_not_corrupt() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-missing-not-corrupt-") + .tempdir() + .expect("a scratch dir"); + assert!(!RewardsClaimConfig::load_from(dir.path()).corrupt); + } + + /// F10 (§8.6 floor) regression: an operator (or corrupt/hostile) config with `cadence_seconds: + /// 0` must not be honoured verbatim -- it would hot-loop `run_cycle` with no cadence + /// protection at all. Must go red with only the floor-clamp removed from `load_from`. + #[test] + fn a_cadence_below_the_floor_is_clamped_up_on_load() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-cadence-floor-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + br#"{"cadence_seconds": 0}"#, + ) + .expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded.cadence_seconds, CLAIM_CADENCE_FLOOR_SECONDS); + assert!(!loaded.corrupt, "a low cadence is clamped, not corrupt"); + } + + /// F14 regression: a persisted spend larger than its own budget is corrupt state, not a large + /// number to clamp down -- clamping down would hand back exactly the budget the corruption was + /// hiding. Must go red with only that branch removed (i.e. the field loaded verbatim). + #[test] + fn a_spend_exceeding_its_own_budget_fails_closed() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-spend-overflow-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + br#"{"fee_spent_in_window_mojos": 999999999999, "max_cycle_fee_budget_mojos": 2000000}"#, + ) + .expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert!( + loaded.corrupt, + "a spend exceeding its own budget must fail closed, never be clamped down" + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs new file mode 100644 index 00000000..8e6aee60 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -0,0 +1,2789 @@ +//! The claim loop's one tick: discover, evaluate, claim — driven against [`ClaimChainPort`] and +//! [`DistributorHintSource`], never against a concrete chain client (see the module doc's "chain +//! seam" section). + +use std::path::{Path, PathBuf}; + +use chia_protocol::Bytes32; + +use super::config::RewardsClaimConfig; +use super::hints::DistributorHintSource; +use super::port::{ClaimChainPort, ClaimPortError}; +use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; + +/// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] +/// and the anti-silence status surface across calls to [`Self::run_cycle`]. +/// +/// # No permanent "no entry slot" blacklist (Defect B) +/// An earlier version of this engine cached a launcher id in a process-lifetime `terminal_no_entry` +/// set the first time `own_entry` returned `None`, and never re-checked it. That is wrong in two +/// reachable cases: SPEC §12.5 clause 2's re-entry path (a peer evicted, re-challenged and +/// legitimately re-admitted would never claim again until the process restarted), and a peer that +/// discovers a distributor before the funder's `AddEntry` lands (blacklisted on its very first +/// cycle, never paid at all). SPEC §12.5 clause 3 — re-read the entry slot before every claim, +/// never cache one across cycles — argues directly against caching an absence forever too. The fix: +/// no blacklist at all. `own_entry` is a cheap chain READ, so it is re-issued every cycle for every +/// candidate; `ClaimOutcome::NoEntrySlot` stays the reported outcome (still non-error, still no +/// spend, still no chain fault), but it is now a per-cycle observation, not a lifetime sentence. +pub struct ClaimEngine { + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + /// Defect C2: the per-cycle aggregate fee budget — bounds what this node will spend across ALL + /// claims in one cycle, independent of the per-claim ceiling. See [`super::config`]'s module doc + /// for the attacker-cost reasoning that makes this necessary in addition to `max_fee_mojos`. + cycle_fee_budget_mojos: u64, + dig_asset_id: Bytes32, + status: ClaimStatus, + /// Defect B2: which launcher id the per-cycle budget cut off LAST, so the next cycle gives that + /// one first crack instead of it being permanently outranked. This only breaks TIES among + /// candidates with equal accrued value (see [`Self::order_for_budget`]) — it can never let a + /// lower-accrued distributor (an attacker's dust) jump ahead of a genuinely higher-earning one, + /// because accrued value is always the primary sort key. `None` until a cycle first defers + /// someone for budget. Persisted alongside [`super::config::RewardsClaimConfig`] (via + /// [`Self::with_rotation_cursor`] / [`Self::rotation_cursor`]) so a restart does not re-arm a + /// fresh queue and starve the tail forever. + rotation_cursor: Option, + + /// F7: when `Some`, this engine persists [`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, + // F16: there used to be a `fee_window_poisoned: bool` field here, set `true` by a corrupt + // load or a future-dated clock and never cleared. That is the THIRD instance of one + // mechanism -- a per-cycle condition stored as process-lifetime state (pass 3: + // `ChainSourceUnavailable` latched forever; pass 4: a cadence gate's early return left a + // stale `state` standing) -- and it is the one place where latching was actively wrong: a + // future-dated clock is SELF-HEALING (`t > now` goes false the moment real time passes it), + // so ORing it into a field that is then set permanently `true` turned a transient RTC glitch + // into a permanent refusal to claim. The fix removes the field rather than the bug: with no + // `fee_window_poisoned` field on this struct, `self.fee_window_poisoned = true` is a COMPILE + // ERROR (E0609, no such field), not a convention a future pass has to remember. See + // [`Self::run_cycle`]'s `CycleConditions` -- built fresh at the top of every cycle from `now` + // plus a freshly reloaded [`RewardsClaimConfig`], used, and dropped before the function + // returns; there is nowhere on `Self` to write it back into. +} + +impl ClaimEngine { + pub fn new( + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + cycle_fee_budget_mojos: u64, + dig_asset_id: Bytes32, + ) -> Self { + ClaimEngine { + port, + hints, + own_payout_puzzle_hash, + max_fee_mojos, + cycle_fee_budget_mojos, + dig_asset_id, + status: ClaimStatus::default(), + rotation_cursor: None, + fee_window_state_dir: None, + cadence_seconds: 0, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: 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 + } + + /// F7: restores the persisted aggregate-fee-budget window and cadence clock from `dir` and + /// arms this engine to keep persisting them there after every submission and every completed + /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). + /// + /// `cadence_seconds` is both the window length and the cadence gate's threshold: the same + /// number [`super::config::RewardsClaimConfig::cadence_seconds`] carries, passed in explicitly + /// because this engine has no other dependency on the rest of that config. + /// + /// Without this call, the engine is exactly as it was before F7: a fresh + /// [`Self::cycle_fee_budget_mojos`] and no cadence gate on every construction. That is + /// deliberately still true for a caller that has not opted in (every pre-F7 test), but it is + /// also the defect this method exists to close for production use: nothing here is wired into + /// node startup yet (`crate::rewards_claim`'s module doc, "Not yet wired into node startup"), + /// so the production wiring (#3268) is the one place expected to call this. + /// F10 (§8.6 floor): also applied here, not just in [`RewardsClaimConfig::load_from`] -- + /// this is a constructor argument, independent of whatever the config file says, and the same + /// hot-loop hazard applies to whatever caller passes it a degenerate value directly. + /// + /// F16: this no longer latches `cfg.corrupt` into a field. [`Self::run_cycle`] re-reads + /// [`RewardsClaimConfig::load_from`] fresh at the top of every cycle instead, so a file an + /// operator fixes or removes between cycles is observed on the VERY NEXT cycle, not only on + /// the next process restart -- see that method's `CycleConditions`. + #[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_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. + /// + /// # 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; + if let Err(e) = cfg.save_to(dir) { + tracing::warn!( + path = %dir.display(), + error = %e, + "the rewards-claim fee-budget window could not be persisted" + ); + } + } + + #[must_use] + pub fn status(&self) -> ClaimStatus { + self.status + } + + /// Run one cycle: discover candidates (chain + re-derived hints), evaluate each against SPEC + /// §9.3/§8.3/§12.5, then claim from the above-threshold set in DESCENDING ACCRUED-VALUE ORDER + /// (Defect B2) within the per-claim ceiling AND the per-cycle aggregate fee budget. Returns + /// every outcome, one per evaluated distributor. + pub async fn run_cycle(&mut self, now: u64) -> Vec { + // Defect A1/A4/F1/F3: EVERY per-cycle field is reset here, at the TOP, before any early + // return — a fault, a claim count or a stale distributor tally from a PAST cycle must never + // leak into this cycle's reading, including on the `ChainUnavailable` early-return paths + // below that skip the end-of-function assignment block entirely (F3: those paths used to + // leave last cycle's `distributors_claimable` / `claims_submitted_this_cycle` / + // `distributors_faulted` / `no_entry_slot_this_cycle` sitting stale under this cycle's + // freshly-stamped `last_attempt_at`). + self.status.fault_reported = false; + self.status.chain_unavailable_this_cycle = false; + self.status.payout_hash_mismatches_this_cycle = 0; + self.status.distributors_known = 0; + self.status.distributors_with_own_entry = 0; + self.status.distributors_claimable = 0; + self.status.distributors_faulted = 0; + self.status.claims_submitted_this_cycle = 0; + self.status.no_entry_slot_this_cycle = 0; + self.status.last_attempt_at = Some(now); + + // F7: the cadence gate and the persisted budget window -- both keyed off + // `self.fee_window_state_dir`, so a caller that never opted in via + // `with_persisted_fee_window` sees no change at all (every pre-F7 test). + if let Some(dir) = self.fee_window_state_dir.clone() { + // F16: `CycleConditions` is built HERE, at the top of this cycle, from `now` plus a + // freshly reloaded `RewardsClaimConfig` -- and dropped at the end of this `if let` + // block. It is never a field on `Self`, so there is nowhere to latch it: a + // future-dated clock is self-healing by construction (`t > now` goes false the + // moment real time passes it) and is now recomputed, never remembered, every cycle. + // An actually-corrupt file (unreadable, unparsable, or F14's spend-exceeds-budget) is + // re-read from disk on every cycle too, so a file an operator fixes or removes is + // observed on the VERY NEXT cycle rather than only after a process restart -- see + // `RewardsClaimConfig::load_from`'s doc for why re-reading here is cheap and safe + // (`persist_fee_window` below already re-reads the same file for the same reason). + 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), + // 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. + if conditions.corrupt || conditions.future_dated_clock { + 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(); + } + } + // 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; + let discovered = match self.port.discover_distributors().await { + Ok(v) => v, + Err(ClaimPortError::Unavailable) => { + // F1: per-cycle only — never a latch. See `ClaimStatus::chain_unavailable_this_cycle`. + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return Vec::new(); + } + Err(ClaimPortError::Other(_)) => { + // Defect A4: do NOT stamp `last_discovery_at` here — a reader relies on this + // timestamp going stale to notice a wedged discovery path. + self.status.fault_reported = true; + discovery_failed = true; + Vec::new() + } + }; + if !discovery_failed { + self.status.last_discovery_at = Some(now); + } + + let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); + // F4: a real adapter can plausibly return the same launcher id twice (one distributor + // reachable via two of the §1.3 launch comments this node scans, across the + // `(store_id, root)` pairs it mirrors). Without this, phase 2 would evaluate it twice and + // submit `InitiatePayout` twice against one entry slot in one cycle -- the second spend is + // invalid (counter already incremented) but the fee is paid anyway, double-charging the + // cycle budget for a single distributor. + candidates.sort_unstable(); + candidates.dedup(); + + // SPEC §13.2: a hint only ADDS a candidate; every property is re-derived from chain before + // it counts, and a hint that fails re-derivation is dropped, never trusted. + for hint in self.hints.hints().await { + if candidates.contains(&hint.launcher_id) { + continue; + } + match self.port.resolve_launch_comment(hint.launcher_id).await { + Ok(Some(_)) => candidates.push(hint.launcher_id), + Ok(None) => {} + Err(ClaimPortError::Unavailable) => {} + Err(ClaimPortError::Other(_)) => self.status.fault_reported = true, + } + } + + self.status.distributors_known = candidates.len() as u32; + let any_candidates = !candidates.is_empty(); + + let mut outcomes = Vec::new(); + let mut with_entry = 0u32; + let mut faulted = 0u32; + let mut submitted_this_cycle = 0u64; + let mut no_entry_this_cycle = 0u32; + let mut eligible: Vec = Vec::new(); + + // Phase 1: everything up to (and including) the payout-threshold check, for every + // candidate — none of this touches the per-cycle budget. Above-threshold candidates become + // `Eligible` and move to phase 2 instead of being decided here. + for launcher_id in candidates { + // Defect B: no permanent blacklist skip here — every candidate is re-evaluated every + // cycle, including one that reported `NoEntrySlot` on a prior cycle. + match self.evaluate_pre_budget(launcher_id).await { + PreBudgetResult::Fault { reason } => { + faulted += 1; + outcomes.push(ClaimOutcome::Faulted { + launcher_id, + reversed_fee_mojos: None, + reason, + }); + } + PreBudgetResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + PreBudgetResult::Eligible { + launcher_id, + accrued_base_units, + } => { + with_entry += 1; + eligible.push(EligibleClaim { + launcher_id, + accrued_base_units, + }); + } + PreBudgetResult::Outcome(outcome, entry_seen) => { + if entry_seen { + with_entry += 1; + } + if let ClaimOutcome::NoEntrySlot { .. } = &outcome { + no_entry_this_cycle += 1; + } + outcomes.push(outcome); + } + } + } + + // Defect B1/E: every `Eligible` candidate is claimable regardless of what phase 2 later + // decides for it (submitted, ceiling-skipped or budget-skipped all count) — matching what + // `distributors_claimable` always meant here. + let claimable = u32::try_from(eligible.len()).unwrap_or(u32::MAX); + + // Phase 2: order by accrued value DESCENDING (Defect B2) — an attacker's dust distributors + // (our own entry there accrues little to nothing) always sort behind a victim's genuine + // earnings, regardless of the fee the attacker sets. The persisted rotation cursor only + // breaks TIES within an accrued-value tier, so it can never let a lower-value distributor + // displace a higher-value one; see `Self::order_for_budget`. + let ordered = self.order_for_budget(eligible); + let mut first_deferred_this_cycle: Option = None; + for claim in &ordered { + match self + .evaluate_budget_phase(claim, &mut spent_this_cycle_mojos, &mut budget_exhausted) + .await + { + BudgetPhaseResult::Fault { + reason, + reversed_fee_mojos, + } => { + faulted += 1; + outcomes.push(ClaimOutcome::Faulted { + launcher_id: claim.launcher_id, + reversed_fee_mojos, + reason, + }); + } + BudgetPhaseResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + BudgetPhaseResult::Outcome(outcome) => { + match &outcome { + ClaimOutcome::Submitted { .. } => submitted_this_cycle += 1, + ClaimOutcome::SkippedCycleBudgetExhausted { .. } + if first_deferred_this_cycle.is_none() => + { + first_deferred_this_cycle = Some(claim.launcher_id); + } + _ => {} + } + outcomes.push(outcome); + } + } + } + // Defect B2: advance the rotation cursor to whoever the budget cut off FIRST this cycle, so + // that one gets first crack next cycle instead of the same tail being dropped every time. + if let Some(deferred) = first_deferred_this_cycle { + self.rotation_cursor = Some(deferred); + } + + // Defect A4: an all-faulted cycle (candidates existed, discovery succeeded, but every one of + // them faulted) must not stamp `last_cycle_at` either — same staleness reasoning as above. + // + // F17: this used to be `outcomes.is_empty() && self.status.fault_reported` — a predicate + // over the OUTCOME STREAM's emptiness. The authorized `ClaimOutcome::Faulted` rework then + // started pushing an outcome at every one of the five per-candidate fault sites, so + // `outcomes` is never empty when a per-candidate fault occurs and this predicate silently + // went permanently false, letting `last_cycle_at` get stamped on a cycle where every + // candidate faulted and nothing was submitted. Never test a stream for emptiness to infer + // a property of its contents — ask what actually happened instead: no submissions this + // cycle, and at least one fault reported. + let all_faulted_cycle = + any_candidates && submitted_this_cycle == 0 && self.status.fault_reported; + + self.status.distributors_with_own_entry = with_entry; + self.status.distributors_claimable = claimable; + self.status.distributors_faulted = faulted; + self.status.claims_submitted += submitted_this_cycle; + self.status.claims_submitted_this_cycle = submitted_this_cycle; + self.status.no_entry_slot_this_cycle = no_entry_this_cycle; + self.status.consecutive_faulted_cycles = if self.status.fault_reported { + self.status.consecutive_faulted_cycles + 1 + } else { + 0 + }; + if !discovery_failed && !all_faulted_cycle { + self.status.last_cycle_at = Some(now); + } + // F7: this cycle ran to completion (every early return above -- ChainUnavailable -- skips + // this line, which is exactly right: those never reached the cadence gate's definition of + // "ran" -- F9: neither does the `CadenceNotElapsed` / `PersistedStateCorrupt` early + // returns above, for the same reason: none of these ever reached the point where a cycle + // is considered to have run). Stamp and persist unconditionally, including a fault-only or + // all-faulted cycle -- an operator restarting to work around a wedged cycle must still get + // the cadence gate's protection, not a loophole that lets a fault re-arm an immediate + // retry. + if self.fee_window_state_dir.is_some() { + 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. + self.status.state = self.status.compute_state(); + outcomes + } + + /// Everything up to and including the payout-threshold check (SPEC §9.3, §12.5, §8.6) — none of + /// it depends on, or affects, the per-cycle budget. An above-threshold, hash-matching entry + /// becomes `Eligible` and is decided in [`Self::evaluate_budget_phase`] instead. + async fn evaluate_pre_budget(&mut self, launcher_id: Bytes32) -> PreBudgetResult { + let asset = match self.port.reserve_asset_id(launcher_id).await { + Ok(a) => a, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + if asset != self.dig_asset_id { + // SPEC §9.3: not ours, dropped — not counted as known/claimable. + return PreBudgetResult::Outcome(ClaimOutcome::NotOurs { launcher_id }, false); + } + + // SPEC §12.5 clause 3: re-read the entry slot fresh on EVERY call — never cached. + let entry = match self + .port + .own_entry(launcher_id, self.own_payout_puzzle_hash) + .await + { + Ok(Some(e)) => e, + Ok(None) => { + return PreBudgetResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false); + } + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + + if entry.payout_puzzle_hash != self.own_payout_puzzle_hash { + // Defect E: the port handed back an entry for a puzzle hash that is not this node's own. + // Submitting against it would pay someone else. Refuse -- never substitute our own hash + // and proceed. + // + // Defect B3: this is a PER-DISTRIBUTOR problem, not a cycle-wide one -- it must never + // set `fault_reported` (that pins the whole surface at `Faulted`, permanently, since the + // refusal is deliberately non-terminal and recurs every cycle). Count it instead, both + // lifetime and per-cycle, and let `ClaimableButNotClaiming` (or `Nominal`, if everything + // else claimed) surface it. + self.status.claims_refused_payout_mismatch += 1; + self.status.payout_hash_mismatches_this_cycle += 1; + return PreBudgetResult::Outcome( + ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }, + true, + ); + } + + let threshold = match self.port.payout_threshold(launcher_id).await { + Ok(t) => t, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + + if entry.accrued_base_units < threshold { + self.status.claims_skipped_below_threshold += 1; + return PreBudgetResult::Outcome( + ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: entry.accrued_base_units, + threshold, + }, + true, + ); + } + + PreBudgetResult::Eligible { + launcher_id, + accrued_base_units: entry.accrued_base_units, + } + } + + /// Orders the above-threshold candidates for the budget pass (Defect B2): primarily by accrued + /// value DESCENDING, so an attacker's dust distributors — where this node's own entry accrues + /// little to nothing — always sort behind a victim's genuine earnings no matter what fee the + /// attacker sets. The persisted [`Self::rotation_cursor`] only breaks ties WITHIN an equal-value + /// tier: it rebuilds a byte-order canonical ranking of the candidates present this cycle, then + /// rotates that ranking so the cursor's own launcher id sorts first — guaranteeing a genuinely + /// tied, budget-exceeding honest tail eventually reaches the front, without ever letting a + /// lower-value candidate outrank a higher-value one. + fn order_for_budget(&self, mut eligible: Vec) -> Vec { + let mut canonical: Vec = eligible.iter().map(|c| c.launcher_id).collect(); + canonical.sort(); + let cursor_index = self + .rotation_cursor + .and_then(|cursor| canonical.iter().position(|id| *id == cursor)) + .unwrap_or(0); + let len = canonical.len(); + let rotation_key = |id: &Bytes32| -> usize { + let pos = canonical.iter().position(|x| x == id).unwrap_or(0); + if len == 0 { + 0 + } else { + (pos + len - cursor_index) % len + } + }; + eligible.sort_by(|a, b| { + b.accrued_base_units + .cmp(&a.accrued_base_units) + .then_with(|| rotation_key(&a.launcher_id).cmp(&rotation_key(&b.launcher_id))) + }); + eligible + } + + /// The fee ceiling, per-cycle budget and submission for one already-`Eligible` candidate (SPEC + /// §8.3, Defect C1/C2). The payout puzzle hash is `self.own_payout_puzzle_hash` unconditionally + /// — [`Self::evaluate_pre_budget`] already refused any entry that diverged from it. + async fn evaluate_budget_phase( + &mut self, + claim: &EligibleClaim, + spent_this_cycle_mojos: &mut u64, + budget_exhausted: &mut bool, + ) -> BudgetPhaseResult { + let launcher_id = claim.launcher_id; + let fee = match self.port.required_fee_mojos(launcher_id).await { + Ok(f) => f, + Err(ClaimPortError::Unavailable) => return BudgetPhaseResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: None, + }; + } + }; + + if fee > self.max_fee_mojos { + self.status.claims_skipped_fee_ceiling += 1; + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: fee, + ceiling_mojos: self.max_fee_mojos, + }); + } + + // Defect C2: the per-claim ceiling alone does not bound what K distributors can collectively + // force this node to spend in one cycle. Once the cycle budget is gone, every remaining + // candidate is skipped the same way, not spent past it. + // + // F14: `saturating_add`, never a bare `+` -- `spent_this_cycle_mojos` is seeded from a + // persisted value (`RewardsClaimConfig::fee_spent_in_window_mojos`) on the very first + // candidate of a cycle. `config::RewardsClaimConfig::load_from` now rejects a spend + // exceeding its own budget at load time (fails closed, see F8), but this comparison must + // not ALSO be able to panic on a `u64` overflow if that guard is ever bypassed -- the + // workspace enables `overflow-checks` in release, so an unchecked add here is a live + // panic-on-corrupt-input path, not just a debug-build lint. + if *budget_exhausted + || spent_this_cycle_mojos.saturating_add(fee) > self.cycle_fee_budget_mojos + { + *budget_exhausted = true; + self.status.claims_skipped_cycle_budget += 1; + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: fee, + budget_mojos: self.cycle_fee_budget_mojos, + }); + } + + // F7: write-then-spend, never spend-then-write. If persistence is armed, the fee this + // submission is about to cost is committed to disk BEFORE the chain call, not after -- + // so a crash between "we decided to spend" and the chain call returning can never leave + // an unpersisted spend that a restart would repeat. This pre-commit is deliberately + // conservative: a genuine crash mid-`await` never returns to the `match` below at all, so + // the only way to protect against THAT case is to have already written the spend before + // making the call. + if self.fee_window_state_dir.is_some() { + self.fee_spent_in_window_mojos = self.fee_spent_in_window_mojos.saturating_add(fee); + self.persist_fee_window(); + } + + match self + .port + .submit_initiate_payout(launcher_id, self.own_payout_puzzle_hash, fee) + .await + { + Ok(()) => { + *spent_this_cycle_mojos += fee; + BudgetPhaseResult::Outcome(ClaimOutcome::Submitted { launcher_id }) + } + // F12: the call HAS resolved here, with a definite answer -- unlike the crash case + // above, "no" means the fee was never broadcast (`ClaimPortError::Unavailable`: never + // even reached the network; `Other(_)`: the network is reachable but the submission + // was rejected). Charging the persisted window for a fee that never left would let an + // attacker exhaust this node's per-cycle budget for free with K always-failing + // submissions, suppressing a victim's real claims for the rest of the window at zero + // cost -- reverse the pre-commit now that we know it did not consume a fee. + Err(ClaimPortError::Unavailable) => { + self.uncommit_fee(fee); + BudgetPhaseResult::ChainUnavailable + } + Err(ClaimPortError::Other(message)) => { + self.uncommit_fee(fee); + self.status.fault_reported = true; + BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: Some(fee), + } + } + } + } + + /// F12: reverses a pre-committed persisted spend once [`Self::evaluate_budget_phase`]'s + /// submission call has DEFINITELY returned without broadcasting -- see that method's "F12" + /// doc comment for why the pre-commit itself must stay conservative for a genuine crash + /// mid-call, which never reaches this method at all. + fn uncommit_fee(&mut self, fee: u64) { + 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). +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, + }, + /// A chain read (`reserve_asset_id`, `own_entry` or `payout_threshold`) returned + /// `ClaimPortError::Other`. None of these ever reads a fee, so [`ClaimOutcome::Faulted`] built + /// from this is always `reversed_fee_mojos: None`. + Fault { + reason: String, + }, + ChainUnavailable, +} + +/// The outcome of [`ClaimEngine::evaluate_budget_phase`]. +enum BudgetPhaseResult { + Outcome(ClaimOutcome), + /// A chain call (`required_fee_mojos` or `submit_initiate_payout` itself) returned + /// `ClaimPortError::Other`. `reversed_fee_mojos` is `Some` only for the latter, where a fee was + /// already pre-committed and [`ClaimEngine::uncommit_fee`] has already reversed it. + Fault { + reason: String, + reversed_fee_mojos: Option, + }, + ChainUnavailable, +} + +/// Bounds a chain port's error text before it is carried into [`ClaimOutcome::Faulted`] or logged — +/// it originates from a chain port and is therefore attacker-adjacent, the same 200-char discipline +/// `service::summarize_stderr` applies to a spawned tool's own stderr. +fn bound_port_error_text(message: &str) -> String { + message.chars().take(200).collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Mutex; + + use async_trait::async_trait; + + use super::*; + use crate::rewards_claim::hints::{DistributorHint, NoHintSource}; + use crate::rewards_claim::parser::parse_launch_comment; + use crate::rewards_claim::types::DiscoveredDistributor; + + const DIG_ASSET_ID: Bytes32 = Bytes32::new([9u8; 32]); + const OUR_PAYOUT_PUZZLE_HASH: Bytes32 = Bytes32::new([1u8; 32]); + const FEE_CEILING: u64 = 1_000_000_000; + const CYCLE_BUDGET: u64 = 1_000_000_000; + + #[derive(Clone)] + struct FakeDistributor { + launcher_id: Bytes32, + store_id: Bytes32, + root: Bytes32, + reserve_asset_id: Bytes32, + payout_threshold: u64, + entry: Option, + fee_mojos: u64, + } + + /// A full in-memory fake standing in for the real chain adapter (see the module doc's "chain + /// seam" section) — the ONLY thing #3249 landing changes is which struct implements this trait. + struct FakeChainPort { + distributors: Mutex>, + submitted: Mutex>, + own_entry_reads: Mutex, + /// F12: launcher ids whose `submit_initiate_payout` must return + /// `Err(ClaimPortError::Other(_))` -- simulates a submission that definitely never + /// broadcast. + fail_submit_for: Mutex>, + /// F17: launcher ids whose `reserve_asset_id` must return `Err(ClaimPortError::Other(_))` + /// -- simulates a per-candidate chain-read fault reached during discovery success, the + /// scenario `repeated_discovery_faults_never_read_as_nominal` does NOT cover (that test + /// fails discovery itself, a different and already-correct path). + fail_reserve_asset_for: Mutex>, + /// F15: when set, `submit_initiate_payout` snapshots the persisted spend at this + /// directory into `submit_snapshots` BEFORE returning -- proving the write already + /// landed on disk before the chain call resolves, not just before `run_cycle` returns. + submit_snapshot_dir: Mutex>, + submit_snapshots: Mutex>, + } + + impl FakeChainPort { + fn new(distributors: Vec) -> Self { + FakeChainPort { + distributors: Mutex::new( + distributors + .into_iter() + .map(|d| (d.launcher_id, d)) + .collect(), + ), + submitted: Mutex::new(Vec::new()), + own_entry_reads: Mutex::new(0), + fail_submit_for: Mutex::new(std::collections::HashSet::new()), + fail_reserve_asset_for: Mutex::new(std::collections::HashSet::new()), + submit_snapshot_dir: Mutex::new(None), + submit_snapshots: Mutex::new(Vec::new()), + } + } + + /// F12: makes `submit_initiate_payout` for `id` return `Err(Other(_))` instead of `Ok`. + fn fail_submit_for(&self, id: Bytes32) { + self.fail_submit_for.lock().unwrap().insert(id); + } + + /// F17: makes `reserve_asset_id` for `id` return `Err(Other(_))` instead of `Ok` -- `id` + /// still appears in `discover_distributors`' output (discovery itself succeeds), so this + /// simulates a per-candidate fault reached AFTER discovery, not a discovery failure. + fn fail_reserve_asset_for(&self, id: Bytes32) { + self.fail_reserve_asset_for.lock().unwrap().insert(id); + } + + /// F15: arms the pre-submit snapshot hook against `dir`. + fn arm_submit_snapshot(&self, dir: std::path::PathBuf) { + *self.submit_snapshot_dir.lock().unwrap() = Some(dir); + } + } + + #[async_trait] + impl ClaimChainPort for FakeChainPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(self + .distributors + .lock() + .unwrap() + .values() + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + }) + .collect()) + } + + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(self + .distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + })) + } + + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + if self + .fail_reserve_asset_for + .lock() + .unwrap() + .contains(&launcher_id) + { + return Err(ClaimPortError::Other( + "simulated reserve_asset_id fault".into(), + )); + } + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.reserve_asset_id) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.payout_threshold) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn own_entry( + &self, + launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + *self.own_entry_reads.lock().unwrap() += 1; + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.entry) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.fee_mojos) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + if let Some(dir) = self.submit_snapshot_dir.lock().unwrap().clone() { + let snapshot = RewardsClaimConfig::load_from(&dir).fee_spent_in_window_mojos; + self.submit_snapshots.lock().unwrap().push(snapshot); + } + if self.fail_submit_for.lock().unwrap().contains(&launcher_id) { + return Err(ClaimPortError::Other("simulated submission failure".into())); + } + self.submitted + .lock() + .unwrap() + .push((launcher_id, payout_puzzle_hash, fee_mojos)); + Ok(()) + } + } + + fn one_distributor( + entry: Option, + payout_threshold: u64, + fee_mojos: u64, + ) -> FakeDistributor { + FakeDistributor { + launcher_id: Bytes32::new([2u8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold, + entry, + fee_mojos, + } + } + + fn engine(port: FakeChainPort) -> ClaimEngine { + ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + } + + /// ACCEPTANCE 1 — the anti-green test. A loop that runs and claims nothing MUST fail this. + #[tokio::test] + async fn one_tick_submits_exactly_one_claim_for_an_above_threshold_entry() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!(outcomes, vec![ClaimOutcome::Submitted { launcher_id }]); + assert_eq!(e.status().claims_submitted, 1); + assert_eq!(e.port.submitted.lock().unwrap().len(), 1); + let (submitted_launcher, submitted_ppz, _fee) = e.port.submitted.lock().unwrap()[0]; + assert_eq!(submitted_launcher, launcher_id); + assert_eq!(submitted_ppz, OUR_PAYOUT_PUZZLE_HASH); + } + + /// ACCEPTANCE 3 — below threshold is skipped, never an error, never a spend. + #[tokio::test] + async fn below_threshold_is_skipped_not_failed_and_spends_nothing() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 500, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: 500, + threshold: 1_000, + }] + ); + assert_eq!(e.status().claims_submitted, 0); + assert_eq!(e.status().claims_skipped_below_threshold, 1); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// ACCEPTANCE 4 — the threshold is read from chain, not hardcoded. + #[tokio::test] + async fn threshold_other_than_1000_is_honoured() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 2_500, + }), + 5_000, + 10, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: 2_500, + threshold: 5_000, + }] + ); + } + + /// ACCEPTANCE 5 — a required fee above the ceiling is skipped, zero submissions. + #[tokio::test] + async fn fee_above_ceiling_is_skipped() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + FEE_CEILING + 1, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: FEE_CEILING + 1, + ceiling_mojos: FEE_CEILING, + }] + ); + assert_eq!(e.status().claims_submitted, 0); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// Defect B regression: `NoEntrySlot` is non-error and reports neither a chain fault nor a lost + /// payment, but it is NO LONGER a permanent blacklist — the second tick re-checks the same + /// distributor (SPEC §12.5 clause 3: re-read fresh before every claim, never cache). + #[tokio::test] + async fn no_entry_slot_is_non_terminal_and_re_checked_every_cycle() { + let d = one_distributor(None, 1_000, 10); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let first = e.run_cycle(1_000).await; + assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); + assert_eq!(e.status().no_entry_slot_this_cycle, 1); + assert!(!e.status().fault_reported, "no chain fault reported"); + assert_eq!(e.status().claims_submitted, 0, "no lost payment claimed"); + + let reads_after_first = *e.port.own_entry_reads.lock().unwrap(); + let second = e.run_cycle(2_000).await; + assert_eq!( + second, + vec![ClaimOutcome::NoEntrySlot { launcher_id }], + "still no entry, so still reported -- but re-evaluated, not silently skipped" + ); + assert_eq!( + *e.port.own_entry_reads.lock().unwrap(), + reads_after_first + 1, + "the second tick re-reads the entry slot rather than trusting a cached absence" + ); + } + + /// Defect B — the fix's whole point: SPEC §12.5 clause 2's re-entry path. A distributor with no + /// entry slot on cycle 1 (never admitted yet, or evicted) that gains one before cycle 2 (legit + /// re-admission, or a discovery-vs-`AddEntry` race resolving) must produce a claim on cycle 2 — + /// the old process-lifetime blacklist made this permanently unreachable. + #[tokio::test] + async fn no_entry_slot_then_re_admitted_produces_a_claim_on_the_later_cycle() { + let d = one_distributor(None, 1_000, 10); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let first = e.run_cycle(1_000).await; + assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); + + // The distributor admits our entry between cycle 1 and cycle 2. + e.port + .distributors + .lock() + .unwrap() + .get_mut(&launcher_id) + .unwrap() + .entry = Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }); + + let second = e.run_cycle(2_000).await; + assert_eq!(second, vec![ClaimOutcome::Submitted { launcher_id }]); + assert_eq!(e.status().claims_submitted, 1); + } + + /// ACCEPTANCE 7 — two consecutive ticks perform two fresh entry-slot reads; no cached slot. + #[tokio::test] + async fn consecutive_ticks_re_read_the_entry_slot_fresh() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 500, + }), + 1_000, + 10, + ); + let mut e = engine(FakeChainPort::new(vec![d])); + + e.run_cycle(1_000).await; + e.run_cycle(2_000).await; + + assert_eq!(*e.port.own_entry_reads.lock().unwrap(), 2); + } + + /// ACCEPTANCE 10 — SPEC §9.3: a distributor whose reserve asset is not DIG_ASSET_ID is dropped. + #[tokio::test] + async fn non_dig_reserve_asset_distributor_is_dropped() { + let mut d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + d.reserve_asset_id = Bytes32::new([0xFFu8; 32]); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!(outcomes, vec![ClaimOutcome::NotOurs { launcher_id }]); + assert_eq!(e.status().claims_submitted, 0); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// ACCEPTANCE 11a/11c — a hint ADDS a candidate the chain sweep did not already return, and + /// `NoHintSource` changes no outcome versus the chain-only path (every other test here uses + /// `NoHintSource` already; this test is the direct A/B). + #[tokio::test] + async fn a_hint_adds_a_candidate_the_chain_sweep_alone_would_miss() { + struct OneHint(Bytes32); + #[async_trait] + impl DistributorHintSource for OneHint { + async fn hints(&self) -> Vec { + vec![DistributorHint { + launcher_id: self.0, + }] + } + } + + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + + // Chain-only sweep never returns this distributor -- only resolve_launch_comment does, + // simulating "known to exist on chain but not enumerated by the discovery sweep yet". + struct HintOnlyPort(FakeChainPort); + #[async_trait] + impl ClaimChainPort for HintOnlyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.0.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, l: Bytes32) -> Result { + self.0.reserve_asset_id(l).await + } + async fn payout_threshold(&self, l: Bytes32) -> Result { + self.0.payout_threshold(l).await + } + async fn own_entry( + &self, + l: Bytes32, + p: Bytes32, + ) -> Result, ClaimPortError> { + self.0.own_entry(l, p).await + } + async fn required_fee_mojos(&self, l: Bytes32) -> Result { + self.0.required_fee_mojos(l).await + } + async fn submit_initiate_payout( + &self, + l: Bytes32, + p: Bytes32, + f: u64, + ) -> Result<(), ClaimPortError> { + self.0.submit_initiate_payout(l, p, f).await + } + } + + let port = HintOnlyPort(FakeChainPort::new(vec![d])); + let mut e = ClaimEngine::new( + port, + OneHint(launcher_id), + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!(outcomes, vec![ClaimOutcome::Submitted { launcher_id }]); + } + + /// ACCEPTANCE 11b — a hint whose chain re-derivation fails (resolve_launch_comment -> None) is + /// dropped, never becomes a candidate, never a claim's authority. + #[tokio::test] + async fn a_hint_that_fails_chain_rederivation_is_dropped() { + struct BogusHint; + #[async_trait] + impl DistributorHintSource for BogusHint { + async fn hints(&self) -> Vec { + vec![DistributorHint { + launcher_id: Bytes32::new([0xEEu8; 32]), + }] + } + } + + let port = FakeChainPort::new(Vec::new()); + let mut e = ClaimEngine::new( + port, + BogusHint, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty()); + assert_eq!(e.status().distributors_known, 0); + } + + /// A port whose discovery call always returns a real (non-`Unavailable`) chain fault, every + /// cycle -- the failure Defect A1 describes. + struct AlwaysFaultingDiscoveryPort; + #[async_trait] + impl ClaimChainPort for AlwaysFaultingDiscoveryPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Other("simulated chain fault".into())) + } + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(None) + } + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Err(ClaimPortError::Other("unreachable".into())) + } + } + + /// Defect A1/A2 regression -- THE anti-green test for this defect: a port that errors on + /// discovery every cycle must NEVER read `Nominal`. Before the fix, `fault_reported` had no + /// fault-bearing state to fall through to and this laundered into `Nominal` forever. + #[tokio::test] + async fn repeated_discovery_faults_never_read_as_nominal() { + let mut e = ClaimEngine::new( + AlwaysFaultingDiscoveryPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + for cycle in 1..=3u32 { + e.run_cycle(u64::from(cycle) * 1_000).await; + assert_ne!( + e.status().state, + ClaimLoopState::Nominal, + "cycle {cycle}: a reported fault must never read as Nominal" + ); + assert_eq!( + e.status().state, + ClaimLoopState::Faulted { cycles: cycle }, + "cycle {cycle}: consecutive fault count must track the streak" + ); + } + } + + /// Finding 2 regression: discovery SUCCEEDS (unlike + /// `repeated_discovery_faults_never_read_as_nominal`, which fails discovery itself -- a + /// different and already-correct path), every candidate faults on a per-candidate chain read, + /// and `last_cycle_at` must NOT be stamped. Must go red with `all_faulted_cycle` restored to + /// its old `outcomes.is_empty() && self.status.fault_reported` proxy -- a `ClaimOutcome::Faulted` + /// IS an outcome, so `outcomes` is never empty here and the old proxy silently stamped + /// `last_cycle_at` on a cycle where nothing was actually claimed. + #[tokio::test] + async fn all_candidates_faulted_does_not_stamp_last_cycle_at() { + let launcher_id = Bytes32::new([2u8; 32]); + let port = FakeChainPort::new(vec![one_distributor(None, 1_000, 10)]); + port.fail_reserve_asset_for(launcher_id); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Faulted { + launcher_id, + reversed_fee_mojos: None, + reason: "simulated reserve_asset_id fault".to_string(), + }], + "discovery succeeded (the candidate was found) but its only chain read faulted" + ); + assert_eq!(e.status().claims_submitted_this_cycle, 0); + assert_eq!( + e.status().last_cycle_at, + None, + "an all-faulted cycle (candidates existed, discovery succeeded, nothing submitted) \ + must not stamp last_cycle_at -- never infer 'nothing happened' from outcomes being \ + empty, because a Faulted outcome is still an outcome" + ); + } + + /// Defect A4 regression: a failed discovery must leave `last_discovery_at` unchanged (a reader + /// depends on that timestamp going stale to notice a wedged discovery path). + #[tokio::test] + async fn failed_discovery_leaves_last_discovery_at_unchanged() { + let mut e = ClaimEngine::new( + AlwaysFaultingDiscoveryPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + e.run_cycle(1_000).await; + assert_eq!(e.status().last_discovery_at, None); + e.run_cycle(2_000).await; + assert_eq!( + e.status().last_discovery_at, + None, + "still unchanged after a second failed discovery" + ); + assert_eq!( + e.status().last_attempt_at, + Some(2_000), + "last_attempt_at still proves the loop is alive" + ); + } + + /// Defect C2 regression: K distributors each individually under the per-claim ceiling must NOT + /// collectively spend past the per-cycle aggregate budget. + #[tokio::test] + async fn distributors_each_under_ceiling_do_not_collectively_exceed_the_cycle_budget() { + const PER_CLAIM_FEE: u64 = 10; + const BUDGET: u64 = 25; // only 2 of 4 distributors can be paid out of this budget + let distributors: Vec = (0..4u8) + .map(|i| FakeDistributor { + launcher_id: Bytes32::new([i + 10; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: PER_CLAIM_FEE, + }) + .collect(); + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, // each individual fee (10) is far under the per-claim ceiling + BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + let submitted = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(); + let budget_skipped = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::SkippedCycleBudgetExhausted { .. })) + .count(); + assert_eq!( + submitted, 2, + "only 2 claims fit inside the 25-mojo budget at 10 each" + ); + assert_eq!( + budget_skipped, 2, + "the remaining 2 are skipped, not spent past the budget" + ); + assert_eq!(e.status().claims_submitted, 2); + assert_eq!(e.status().claims_skipped_cycle_budget, 2); + } + + /// **Defect B2 (blocking) -- the anti-suppression test.** Ten attacker-funded dust distributors + /// (our own entry there accrues almost nothing, but each demands a fee big enough that ONE of + /// them alone exhausts the cycle budget) must NOT prevent a genuinely high-accrual distributor + /// from being claimed in the same cycle, no matter what order the chain sweep happens to return + /// them in (`FakeChainPort` stores candidates in a `HashMap`, so discovery order here is exactly + /// as arbitrary as a real chain sweep's). + #[tokio::test] + async fn dust_distributors_do_not_suppress_a_high_accrual_claim_in_the_same_cycle() { + const DUST_FEE: u64 = 100; + let victim = FakeDistributor { + launcher_id: Bytes32::new([0xFFu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 100_000, // genuinely high accrual + }), + fee_mojos: DUST_FEE, + }; + let victim_id = victim.launcher_id; + let mut distributors = vec![victim]; + for i in 0..10u8 { + distributors.push(FakeDistributor { + launcher_id: Bytes32::new([i; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 1_100, // just above threshold -- dust, not zero + }), + fee_mojos: DUST_FEE, // funder-controlled: attacker sets this at will + }); + } + // The budget fits exactly ONE distributor's fee -- first-come order would let any dust + // distributor that sorts ahead of the victim consume it entirely. + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + DUST_FEE, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { launcher_id } if *launcher_id == victim_id)) + .count(), + 1, + "the high-accrual victim must be the one claimed, regardless of discovery order" + ); + assert_eq!( + e.status().claims_submitted, + 1, + "the budget fits exactly one claim" + ); + assert_eq!( + e.status().claims_skipped_cycle_budget, + 10, + "every dust distributor is deferred, never the victim" + ); + } + + /// **Defect B2 (blocking) -- the fairness half.** A persisted rotation cursor must advance + /// across cycles so a genuinely tied, budget-exceeding honest tail is not the same distributor + /// dropped every cycle forever. + #[tokio::test] + async fn the_rotation_cursor_advances_so_a_tied_starved_tail_is_eventually_served() { + const FEE: u64 = 10; + const BUDGET: u64 = 20; // only 2 of 3 equal-value distributors fit per cycle + let distributors: Vec = (0..3u8) + .map(|i| FakeDistributor { + launcher_id: Bytes32::new([i + 1; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, // EQUAL for all three -- a genuine tie + }), + fee_mojos: FEE, + }) + .collect(); + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + BUDGET, + DIG_ASSET_ID, + ); + + let mut deferred_across_cycles: std::collections::HashSet = + std::collections::HashSet::new(); + for cycle in 1..=3u32 { + let outcomes = e.run_cycle(u64::from(cycle) * 1_000).await; + for outcome in &outcomes { + if let ClaimOutcome::SkippedCycleBudgetExhausted { launcher_id, .. } = outcome { + deferred_across_cycles.insert(*launcher_id); + } + } + } + + assert!( + deferred_across_cycles.len() > 1, + "the same distributor must not be the only one ever deferred across cycles -- got {deferred_across_cycles:?}" + ); + assert!( + e.rotation_cursor().is_some(), + "the cursor must have advanced at least once" + ); + } + + /// Defect B2: `with_rotation_cursor` / `rotation_cursor` are the seam a persisted config uses + /// to survive a restart -- proves the getter reflects what the setter installed before any + /// cycle has run. + #[test] + fn rotation_cursor_round_trips_through_the_engine_accessors() { + let cursor = Bytes32::new([0x42u8; 32]); + let e = engine(FakeChainPort::new(Vec::new())).with_rotation_cursor(Some(cursor)); + assert_eq!(e.rotation_cursor(), Some(cursor)); + } + + /// F7: a distributor whose required fee alone equals the whole cycle budget, so ONE submitted + /// claim exhausts it completely -- makes every F7 test below unambiguous about whether a + /// SECOND full budget was granted. + fn budget_consuming_distributor(launcher_id: Bytes32, fee_mojos: u64) -> FakeDistributor { + FakeDistributor { + launcher_id, + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos, + } + } + + /// **F7 (blocking) — the restart reproducer.** Before the fix, a fresh [`ClaimEngine`] has an + /// empty in-memory budget and cadence clock no matter what a PRIOR process already spent, so + /// this must FAIL before the fix: the second engine submits its claim too, spending a second + /// full [`CYCLE_BUDGET`] inside the same window a prior process already exhausted. + #[tokio::test] + async fn f7_restart_reproducer_a_second_engine_from_the_same_directory_refuses_to_overspend() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-reproducer-") + .tempdir() + .expect("a scratch dir"); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x10u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x10u8; 32]) + }], + "the first cycle must actually spend the whole budget, or this reproduces nothing" + ); + drop(first); + + // A NEW process, seconds later — nowhere near CADENCE_SECONDS away — reconstructs the + // engine from the SAME directory and faces a DIFFERENT distributor that also costs the + // whole budget. + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x20u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_010).await; + + let second_submitted = second_outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(); + assert_eq!( + second_submitted, 0, + "a restart inside the same budget window must not be able to spend a second full \ + cycle budget -- a process restart is not a fresh peer" + ); + } + + /// F7: ten simulated restarts inside ONE window must not collectively exceed the aggregate + /// budget, however many of those restarts each try to spend a full budget's worth. + #[tokio::test] + async fn f7_ten_restarts_inside_one_window_never_collectively_exceed_the_budget() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-ten-restarts-") + .tempdir() + .expect("a scratch dir"); + + let mut total_submitted_mojos = 0u64; + for i in 0..10u8 { + let launcher_id = Bytes32::new([0x30 + i; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + launcher_id, + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let outcomes = e.run_cycle(1_000 + u64::from(i)).await; + if outcomes + .iter() + .any(|o| matches!(o, ClaimOutcome::Submitted { .. })) + { + total_submitted_mojos += CYCLE_BUDGET; + } + } + + assert!( + total_submitted_mojos <= CYCLE_BUDGET, + "ten restarts inside one window spent {total_submitted_mojos} mojos, over the \ + {CYCLE_BUDGET}-mojo budget" + ); + } + + /// F7: once the window has genuinely elapsed, a restart MUST be allowed a fresh budget — the + /// fix bounds a crash-restart loop, it does not starve a node that legitimately restarts + /// between cadence periods. + #[tokio::test] + async fn f7_a_restart_after_the_window_elapsed_gets_a_fresh_budget() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-window-elapsed-") + .tempdir() + .expect("a scratch dir"); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x40u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x40u8; 32]) + }] + ); + drop(first); + + // Well past both the window AND the cadence gate. + let later = 1_000 + CADENCE_SECONDS + 1; + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x50u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(later).await; + + assert_eq!( + second_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x50u8; 32]) + }], + "a restart after the window elapsed must be granted a fresh budget" + ); + } + + /// F7: a restart immediately after a completed cycle must not even START another cycle before + /// the cadence elapses — independent of the fee-window check, this stops a fast restart loop + /// from re-running full cycles (with their own chain reads) back to back. + #[tokio::test] + async fn f7_a_restart_immediately_after_a_completed_cycle_does_not_run_another() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-cadence-gate-") + .tempdir() + .expect("a scratch dir"); + let launcher_id = Bytes32::new([0x60u8; 32]); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "the first cycle must complete normally, or this proves nothing about a restart" + ); + drop(first); + + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_050).await; + + assert_eq!( + second_outcomes, + Vec::new(), + "a restart 50 seconds after a completed cycle must not run another before the \ + 86,400-second cadence elapses" + ); + } + + /// F7: a crash after a submission but before the cycle finishes must still leave that spend + /// recorded on disk — proves the write happens PER SUBMISSION, never batched to cycle end. + /// Simulated by reading the persisted config directly after a cycle that submits more than one + /// claim, rather than waiting for `run_cycle` to return. + #[tokio::test] + async fn f7_a_spend_is_persisted_per_submission_not_batched_to_cycle_end() { + const CYCLE_BUDGET: u64 = 30; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-per-submission-") + .tempdir() + .expect("a scratch dir"); + + let distributors = vec![ + budget_consuming_distributor(Bytes32::new([0x70u8; 32]), 10), + budget_consuming_distributor(Bytes32::new([0x71u8; 32]), 10), + ]; + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + let submitted: u64 = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count() as u64 + * 10; + assert_eq!(submitted, 20, "both distributors must have been submitted"); + + // Read the file directly rather than through `e` -- proves the write already landed on + // disk, not just in the engine's own in-memory mirror. + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 20, + "each submission must persist its own spend immediately, not wait for cycle end" + ); + } + + /// F15: `f7_a_spend_is_persisted_per_submission_not_batched_to_cycle_end` above only reads the + /// file AFTER `run_cycle` returns, which a cycle-end-batched persist would also satisfy -- + /// exactly the vacuous-test class F11 named. This test snapshots the file DURING each + /// submission's own chain call, before that call (or `run_cycle`) has returned: the second + /// distributor's snapshot can only show the first distributor's 10-mojo spend already on disk + /// if persistence genuinely happens per submission. Must go red with the pre-commit in + /// `evaluate_budget_phase` moved to after the `.await` (or to cycle end). + #[tokio::test] + async fn f15_a_spend_is_visible_on_disk_before_the_submission_call_resolves() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f15-") + .tempdir() + .expect("a scratch dir"); + + let distributors = vec![ + budget_consuming_distributor(Bytes32::new([0x72u8; 32]), 10), + budget_consuming_distributor(Bytes32::new([0x73u8; 32]), 10), + ]; + let port = FakeChainPort::new(distributors); + port.arm_submit_snapshot(dir.path().to_path_buf()); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!( + outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(), + 2, + "both distributors must have been submitted, or this proves nothing" + ); + + let snapshots = e.port.submit_snapshots.lock().unwrap().clone(); + assert_eq!( + snapshots, + vec![10, 20], + "the first submission's own snapshot must already see ITS OWN pre-committed 10-mojo \ + spend (write-then-spend), and the second must see BOTH -- a cycle-end batch would \ + show 0 for both, since neither had landed on disk yet when these calls ran" + ); + } + + /// F11: the two restart tests above (`f7_restart_reproducer_...` and `f7_ten_restarts_...`) + /// advance the clock by ≤10s, so the CADENCE GATE alone makes them pass -- delete the window + /// enforcement entirely and they still go green. This test satisfies the gate (no prior + /// completed cycle at all, so it never even runs) and instead binds the window accumulator + /// directly: a cycle the gate permits, entering a window that already carries a full persisted + /// spend, must still be refused by the budget. Must go red with only the window-seeding line + /// in `with_persisted_fee_window` (`self.fee_spent_in_window_mojos = cfg.fee_spent_in_window_ + /// mojos`) reverted to always start at `0`. + #[tokio::test] + async fn f11_a_gate_permitted_cycle_is_still_refused_by_an_already_full_persisted_window() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f11-window-binds-") + .tempdir() + .expect("a scratch dir"); + + // Simulate a crash mid-window: a prior process opened this window and spent it in full, + // but never recorded a completed cycle (a real crash never gets that far either). + let seeded = RewardsClaimConfig { + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: CYCLE_BUDGET, + last_cycle_completed_at: None, // no completed cycle on record -- the gate is satisfied + ..RewardsClaimConfig::default() + }; + seeded.save_to(dir.path()).expect("seed the window"); + + let launcher_id = Bytes32::new([0x74u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the + // gate cannot be what refuses this -- only the window accumulator can. + let outcomes = e.run_cycle(1_005).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: 10, + budget_mojos: CYCLE_BUDGET, + }], + "a window seeded as already fully spent must refuse every claim, even though the \ + cadence gate itself was satisfied" + ); + } + + /// F9 regression: a deliberately-skipped cycle must report its OWN named condition, never a + /// stale reading left over from the last cycle that actually ran. Must go red with only the + /// `self.status.state = ClaimLoopState::CadenceNotElapsed;` assignment on the cadence-gate + /// early return removed. + #[tokio::test] + async fn f9_a_cadence_skipped_cycle_reports_its_own_state_not_a_stale_one() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f9-cadence-state-") + .tempdir() + .expect("a scratch dir"); + let launcher_id = Bytes32::new([0x75u8; 32]); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "the first cycle must actually run and claim, or its state proves nothing to skip past" + ); + assert_eq!( + first.status().state, + ClaimLoopState::Nominal, + "sanity: the first cycle's OWN state must be something other than CadenceNotElapsed" + ); + drop(first); + + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_010).await; + + assert_eq!( + second_outcomes, + Vec::new(), + "the cadence gate must still refuse to run" + ); + assert_eq!( + second.status().state, + ClaimLoopState::CadenceNotElapsed, + "a deliberately-skipped cycle must name itself, never read as the previous cycle's \ + Nominal (or any other stale) state" + ); + } + + /// F10/F16 regression -- THE anti-latch test for Finding 1. A future-dated + /// `last_cycle_completed_at` (an NTP step, a clock glitch) must (a) refuse cycle 1, reported as + /// `PersistedStateCorrupt`, never silent, and (b) — this is the part the ONE-cycle version of + /// this test could never prove — self-heal the moment real time catches up: cycle 2, run after + /// the clock has caught up AND the cadence has elapsed, MUST claim. A single-cycle version of + /// this test is green whether the latch bug is present or not, because it never gives the + /// latch a second cycle to prove it never clears. Must go red against the pre-F16 engine (the + /// `fee_window_poisoned` field latching `future_dated_clock` permanently `true`), and green + /// once that field is gone and `future_dated_clock` is recomputed fresh every cycle. + #[tokio::test] + async fn f10_a_future_dated_clock_refuses_then_self_heals_next_cycle() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f10-future-clock-") + .tempdir() + .expect("a scratch dir"); + + let far_future = 9_999_999_999u64; + let seeded = RewardsClaimConfig { + last_cycle_completed_at: Some(far_future), + ..RewardsClaimConfig::default() + }; + seeded + .save_to(dir.path()) + .expect("seed a future-dated clock"); + + let launcher_id = Bytes32::new([0x76u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, + // and must refuse. + let cycle1 = e.run_cycle(1_000).await; + assert_eq!( + cycle1, + Vec::new(), + "a future-dated clock must submit nothing this cycle" + ); + assert_eq!( + e.status().state, + ClaimLoopState::PersistedStateCorrupt, + "a future-dated clock must be its own reported condition, never silent, and never \ + read as CadenceNotElapsed (which is what the old unvalidated saturating_sub bug \ + would produce once F9 is fixed)" + ); + + // Cycle 2: real time has now passed `far_future` (self-healing the clock condition) AND + // the cadence has elapsed since `far_future` (satisfying the cadence gate too) -- a + // genuinely healthy cycle that a permanent latch would still refuse forever. + let caught_up = far_future + CADENCE_SECONDS + 1; + let cycle2 = e.run_cycle(caught_up).await; + assert_eq!( + cycle2, + vec![ClaimOutcome::Submitted { launcher_id }], + "once the clock has genuinely caught up, the next cycle MUST claim -- a latched \ + `fee_window_poisoned` would refuse this cycle forever, long after the glitch that \ + caused it stopped being true" + ); + assert_ne!( + e.status().state, + ClaimLoopState::PersistedStateCorrupt, + "a self-healed clock must not still read as corrupt" + ); + } + + /// F12 regression: a submission that DEFINITELY failed (the call returned `Err`, so it never + /// broadcast) must not permanently inflate the persisted window -- that is free denial-of- + /// service for an attacker running K always-failing submissions. Must go red with the + /// `uncommit_fee` calls on the `Err` branches of `evaluate_budget_phase`'s `match` removed. + #[tokio::test] + async fn f12_a_failed_submission_does_not_inflate_the_persisted_window() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f12-failed-submit-") + .tempdir() + .expect("a scratch dir"); + + let failing = Bytes32::new([0x78u8; 32]); + let d = budget_consuming_distributor(failing, 10); + let port = FakeChainPort::new(vec![d]); + port.fail_submit_for(failing); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!( + outcomes.len(), + 1, + "the one candidate must have been evaluated, or this proves nothing about its fee" + ); + assert!(!matches!( + outcomes[0], + ClaimOutcome::PayoutPuzzleHashMismatch { .. } + )); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 0, + "a submission that definitely never broadcast must leave the persisted window \ + exactly as it was, not charged for a fee that was never spent" + ); + } + + /// #3251 rework: a failed submission must produce `ClaimOutcome::Faulted`, not just increment + /// `distributors_faulted` and vanish from the outcome stream -- the exact silence this ticket + /// exists to close. Reuses F12's own fixture (a submission that DEFINITELY failed) so both + /// facts are proven from the SAME cycle: the outcome exists AND the fee it reversed is not + /// left charged against the persisted window. + #[tokio::test] + async fn a_failed_submission_produces_a_faulted_outcome_with_the_fee_it_reversed() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + const FEE_MOJOS: u64 = 10; + let dir = tempfile::Builder::new() + .prefix("dig-node-faulted-outcome-") + .tempdir() + .expect("a scratch dir"); + + let failing = Bytes32::new([0x79u8; 32]); + let d = budget_consuming_distributor(failing, FEE_MOJOS); + let port = FakeChainPort::new(vec![d]); + port.fail_submit_for(failing); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Faulted { + launcher_id: failing, + reversed_fee_mojos: Some(FEE_MOJOS), + reason: "simulated submission failure".to_string(), + }], + "a definitely-failed submission must be reported, not silently absorbed into the \ + `faulted` counter alone" + ); + assert_eq!( + e.status().distributors_faulted, + 1, + "the counter stays; it is not a substitute for the outcome" + ); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 0, + "the fee `Faulted` reports as reversed must actually be reversed in the persisted \ + window, not merely claimed reversed in the outcome" + ); + } + + /// F14 regression: the per-cycle budget comparison must never panic on a corrupted or + /// otherwise near-`u64::MAX` in-cycle spend total -- the workspace enables `overflow-checks` + /// in release, so a bare `+` here is a live panic-on-corrupt-input path, not just a debug + /// lint. Must go red (panic) with `saturating_add` reverted to a bare `+` in + /// `evaluate_budget_phase`'s budget comparison. + #[tokio::test] + async fn f14_a_near_max_spent_value_does_not_panic_the_budget_comparison() { + let d = budget_consuming_distributor(Bytes32::new([0x80u8; 32]), 10); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + let mut spent_this_cycle_mojos = u64::MAX - 5; + let mut budget_exhausted = false; + let claim = EligibleClaim { + launcher_id, + accrued_base_units: 5_000, + }; + + let 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. + /// + /// Defect B3 regression: this used to also assert `fault_reported`, which set the CYCLE-WIDE + /// `Faulted` state for a PER-DISTRIBUTOR problem -- see `a_payout_mismatch_never_sets_the_cycle_ + /// wide_fault_or_masks_other_distributors` below for the exploit this enabled. + #[tokio::test] + async fn entry_for_a_different_payout_puzzle_hash_is_refused_not_paid() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + assert_ne!(wrong_hash, OUR_PAYOUT_PUZZLE_HASH); + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }] + ); + assert_eq!(e.status().claims_submitted, 0, "never paid the wrong hash"); + assert!(e.port.submitted.lock().unwrap().is_empty()); + assert!( + !e.status().fault_reported, + "Defect B3: a per-distributor mismatch must never set the cycle-wide fault" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 1); + assert_eq!(e.status().payout_hash_mismatches_this_cycle, 1); + } + + /// **Defect B3 (blocking) -- the exploit the review found.** A single hostile/buggy entry row + /// (a payout-hash mismatch on one launcher) must NOT pin the whole surface at `Faulted` and + /// must NOT bury the `ClaimableButNotClaiming` signal for every OTHER, healthy distributor. + #[tokio::test] + async fn a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + let mismatched = FakeDistributor { + launcher_id: Bytes32::new([0xAAu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + // A second, healthy distributor whose claim would exceed the budget alongside the + // mismatched one's fee, so a fault-flag leak would be free to hide behind + // `ClaimableButNotClaiming` too -- proving the precedence fix, not just the flag. + let healthy = FakeDistributor { + launcher_id: Bytes32::new([0xBBu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + let mut e = engine(FakeChainPort::new(vec![mismatched, healthy])); + + for cycle in 1..=3u32 { + e.run_cycle(u64::from(cycle) * 1_000).await; + assert!( + !matches!(e.status().state, ClaimLoopState::Faulted { .. }), + "cycle {cycle}: a per-distributor mismatch must never read as the cycle-wide Faulted" + ); + } + // F2 inversion: this assertion used to read `ClaimLoopState::Nominal` (an A2-class test + // pinning the defect as intended behaviour). A live payout-hash mismatch is a real, + // per-cycle shortfall exactly like an unmet `claimable` -- the healthy distributor + // claiming does NOT make the surface healthy while the mismatched one is still refused + // every cycle. `distributors_claimable` counts only the healthy one (1); the mismatch + // never enters `eligible` so it is not in `claimable` either, but it IS folded into the + // shortfall predicate's denominator, so `submitted (1) < claimable (1) + mismatches (1)`. + // + // F13: the payload reports that same folded denominator (2), not the un-folded + // `distributors_claimable` (1) alone -- a state named `ClaimableButNotClaiming` whose + // numbers said "0 short" would contradict its own name. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 2, + submitted: 1 + }, + "an ongoing payout-hash mismatch is a real, per-cycle shortfall -- it must never read \ + as Nominal just because the OTHER distributor claimed" + ); + assert_eq!( + e.status().claims_submitted, + 3, + "the healthy one claimed all 3 cycles" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 3); + } + + /// **F2 -- all-K-distributors mismatching must read as a shortfall, never `Nominal`.** Before + /// the fix, a mismatch never entered `eligible`, so `claims_submitted_this_cycle` (0) and + /// `distributors_claimable` (0) were BOTH zero and the magnitude comparison read healthy -- + /// the exact case the F2 brief calls out: "what if every distributor refuses for the same + /// reason." This must be a shortfall (`ClaimableButNotClaiming`), and it must NOT reintroduce + /// Defect B3 by setting the cycle-wide `Faulted`. + #[tokio::test] + async fn all_distributors_mismatching_is_a_shortfall_not_nominal() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + let mismatched = FakeDistributor { + launcher_id: Bytes32::new([0xAAu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + let mut e = engine(FakeChainPort::new(vec![mismatched])); + + e.run_cycle(1_000).await; + + assert_eq!( + e.status().distributors_claimable, + 0, + "the mismatched distributor never enters eligible" + ); + assert_eq!(e.status().claims_submitted_this_cycle, 0); + assert!( + !matches!(e.status().state, ClaimLoopState::Faulted { .. }), + "a per-distributor mismatch must never set the cycle-wide Faulted (Defect B3)" + ); + // F13: `distributors_claimable` (the un-folded term) is 0, but the payload reports the + // folded shortfall denominator -- `distributors_claimable (0) + mismatches (1)` -- so an + // all-mismatching cycle carries a nonzero `claimable` instead of a reassuring zero. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + }, + "all-K-distributors mismatching is a real, systemic shortfall -- it must never read \ + as Nominal just because nothing entered `eligible`" + ); + } + + /// ACCEPTANCE 12 — with `UnavailableClaimChainPort` wired, the engine reports the named state + /// `ChainSourceUnavailable` and runs zero cycles: no discovery outcome, no fault flag, no + /// claim, never a silent no-op (see the module doc's "chain seam" + "HONESTY" sections). + #[tokio::test] + async fn unavailable_port_reports_chain_source_unavailable_and_runs_zero_cycles() { + let mut e = ClaimEngine::new( + crate::rewards_claim::port::UnavailableClaimChainPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert!(outcomes.is_empty(), "zero cycles ran"); + assert_eq!(e.status().state, ClaimLoopState::ChainSourceUnavailable); + assert_eq!(e.status().claims_submitted, 0); + assert_eq!(e.status().distributors_known, 0); + assert!(e.status().last_cycle_at.is_none(), "no cycle completed"); + } + + /// A discovery port that answers `Unavailable` on its FIRST call only, then delegates every + /// call (including later `discover_distributors` calls) to a healthy inner `FakeChainPort` -- + /// modelling a node still syncing, or one dropped connection, exactly as F1 describes. + struct FlakyThenHealthyPort { + // An atomic counter, not a `Mutex` -- a guard held across the `.await` below would + // make this port's future not `Send`, which `#[async_trait]`'s generated signature + // requires. Nothing here needs a lock: it is a single counter, never held past its own + // increment. + calls: AtomicU32, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for FlakyThenHealthyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { + return Err(ClaimPortError::Unavailable); + } + self.inner.discover_distributors().await + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.inner.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.inner.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.inner.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.inner + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F1 regression -- the anti-latch test.** `ChainSourceUnavailable` must be a PER-CYCLE + /// reading, never a process-lifetime latch. Cycle 1 hits the transient `Unavailable` port path + /// and must report it honestly; cycle 2, once the chain answers again, MUST read `Nominal` -- + /// not the stale `ChainSourceUnavailable` from cycle 1 -- because a real claim submits. + #[tokio::test] + async fn a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = distributor.launcher_id; + let port = FlakyThenHealthyPort { + calls: AtomicU32::new(0), + inner: FakeChainPort::new(vec![distributor]), + }; + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty(), "cycle 1: no chain, no outcomes"); + assert_eq!( + e.status().state, + ClaimLoopState::ChainSourceUnavailable, + "cycle 1: the transient unavailability must be reported honestly" + ); + + let outcomes = e.run_cycle(2_000).await; + assert_eq!( + outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "cycle 2: the chain is healthy and a real claim is submitted" + ); + assert_eq!( + e.status().state, + ClaimLoopState::Nominal, + "cycle 2 MUST NOT still read ChainSourceUnavailable -- that is a process-lifetime \ + latch on the very state whose whole point is to be a live reading" + ); + } + + /// A discovery port that answers healthily on its FIRST call, then `Unavailable` on every call + /// after that -- the inverse of `FlakyThenHealthyPort`, for F3's staleness scenario. + struct HealthyThenUnavailablePort { + // Atomic, not `Mutex` -- see `FlakyThenHealthyPort`'s comment: a guard held across + // the `.await` below would make this port's future not `Send`. + calls: AtomicU32, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for HealthyThenUnavailablePort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { + return self.inner.discover_distributors().await; + } + Err(ClaimPortError::Unavailable) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.inner.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.inner.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.inner.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.inner + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F3 regression -- staleness under a fresh timestamp.** Cycle 1 is healthy and submits a + /// real claim (`distributors_claimable == 1`, `claims_submitted_this_cycle == 1`). Cycle 2 hits + /// the `ChainUnavailable` early-return path, which skips the end-of-function assignment block + /// entirely. Before the fix, cycle 1's counts stayed on `self.status` while `last_attempt_at` + /// was stamped fresh for cycle 2 -- exactly the stale-count-under-a-fresh-timestamp §2.4 + /// forbids. Every per-cycle counter must read as this cycle's true zero. + #[tokio::test] + async fn a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let port = HealthyThenUnavailablePort { + calls: AtomicU32::new(0), + inner: FakeChainPort::new(vec![distributor]), + }; + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + e.run_cycle(1_000).await; + assert_eq!( + e.status().distributors_claimable, + 1, + "cycle 1: healthy and claimable" + ); + assert_eq!( + e.status().claims_submitted_this_cycle, + 1, + "cycle 1: submitted" + ); + + e.run_cycle(2_000).await; + assert_eq!(e.status().state, ClaimLoopState::ChainSourceUnavailable); + assert_eq!( + e.status().distributors_claimable, + 0, + "F3: cycle 1's claimable count must not survive under cycle 2's fresh last_attempt_at" + ); + assert_eq!( + e.status().claims_submitted_this_cycle, + 0, + "F3: cycle 1's submission count must not survive into cycle 2" + ); + assert_eq!(e.status().distributors_faulted, 0); + assert_eq!(e.status().no_entry_slot_this_cycle, 0); + } + + /// The launch-comment parser wired end-to-end: what `resolve_launch_comment` would produce for + /// a real chain reply, confirming the two modules compose (not a duplicate of parser.rs's own + /// table-driven unit tests). + #[test] + fn parser_output_feeds_discovered_distributor_shape() { + let store = "a".repeat(64); + let root = "b".repeat(64); + let comment = format!("dig-rewards:v1:{store}:{root}"); + let d = parse_launch_comment(Bytes32::new([5u8; 32]), &comment).expect("parses"); + assert_eq!(d.launcher_id, Bytes32::new([5u8; 32])); + } + /// A discovery port that returns the SAME launcher id twice from one `discover_distributors` + /// call -- plausible for a real adapter scanning §1.3 launch comments across every + /// `(store_id, root)` pair this node mirrors, when one distributor is reachable via two of + /// them. + struct DuplicatingDiscoveryPort(FakeChainPort); + + #[async_trait] + impl ClaimChainPort for DuplicatingDiscoveryPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let mut v = self.0.discover_distributors().await?; + let doubled = v.clone(); + v.extend(doubled); + Ok(v) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.0.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.0.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.0.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.0.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.0.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.0 + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F4 (non-blocking, cheap) -- a duplicated launcher id must submit EXACTLY ONCE.** Without + /// dedup, phase 2 evaluates the same candidate twice and pays the fee twice against one entry + /// slot in one cycle; the second spend is invalid (`counter` already incremented) but the fee + /// is spent anyway. + #[tokio::test] + async fn a_duplicated_launcher_id_submits_exactly_once() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = distributor.launcher_id; + let port = DuplicatingDiscoveryPort(FakeChainPort::new(vec![distributor])); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "exactly one submission for one distributor, even though discovery reported it twice" + ); + assert_eq!(e.status().claims_submitted, 1); + assert_eq!( + e.status().distributors_known, + 1, + "dedup collapses the duplicate" + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/hints.rs b/crates/dig-node-service/src/rewards_claim/hints.rs new file mode 100644 index 00000000..6a2a6a53 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/hints.rs @@ -0,0 +1,43 @@ +//! The DIG-Network/dig_ecosystem#3252 seam — defined here, wired to nothing (SPEC §13.2). + +use async_trait::async_trait; +use chia_protocol::Bytes32; + +/// An UNTRUSTED pointer to a distributor (SPEC §13.2 clause 1) — exactly like +/// `unverified_mirror_coin_id`. It MUST NOT admit an entry, MUST NOT rank a candidate and MUST NOT +/// be a claim's authority. Every property is re-derived from the chain via +/// [`super::port::ClaimChainPort::resolve_launch_comment`] before this hint's launcher id becomes a +/// candidate. DIG-Network/dig_ecosystem#3252 supplies the dig-gossip implementation by extending the +/// holdings-announce wire (opcode 222). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DistributorHint { + pub launcher_id: Bytes32, +} + +/// A source of untrusted distributor pointers (SPEC §13.2). +#[async_trait] +pub trait DistributorHintSource: Send + Sync { + async fn hints(&self) -> Vec; +} + +/// The MVP wiring: no hints. SPEC §13.2 clause 2 — a peer that never hears a hint MUST still find +/// and claim via §13.1, so this changes no outcome; it only removes a latency shortcut this lane +/// does not build. +pub struct NoHintSource; + +#[async_trait] +impl DistributorHintSource for NoHintSource { + async fn hints(&self) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn no_hint_source_yields_nothing() { + assert!(NoHintSource.hints().await.is_empty()); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs new file mode 100644 index 00000000..5d5d4a57 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -0,0 +1,72 @@ +//! The node's PEER-SIDE reward claim loop (DIG-Network/dig_ecosystem#3251). +//! +//! This is the other half of the reward-distributor lifecycle from +//! `dig_node_core::rewards` (DIG-Network/dig_ecosystem#3250, a sibling lane): that crate proves a +//! FUNDER's distributors are honest and writes entries; this module discovers the distributors that +//! cover the `(store_id, root)`s THIS node mirrors, watches its own entry slot, and submits +//! `InitiatePayout` on a jittered cadence. It lives in `dig-node-service`, not `dig-node-core`, +//! because `dig-mirror-coin` (the on-chain peer<->payout binding this loop reuses, SPEC §10.1) is a +//! dependency of this crate and not of `dig-node-core`. +//! +//! # No rival copy of a shared type +//! +//! `chia_protocol::Bytes32` is the one canonical 32-byte type — never a locally declared +//! `type Bytes32 = [u8; 32]`. This module's own types (`DiscoveredDistributor`, `OwnEntry`, the +//! [`ClaimChainPort`] trait) are named differently from #3250's `port.rs` (`DistributorRef`, +//! `EntrySlot`, `RewardsChainPort`) because they carry different behaviour: #3250 reads the FUNDER's +//! whole entry set and writes entries; this module reads only THIS node's own entry slot and submits +//! payout claims. Same protocol, other side, not a duplicate. +//! +//! # The chain seam +//! +//! `dig-rewards-coin` is v0.1.3, published on crates.io, and still SPEC-only (`src/` is +//! `error.rs` + `lib.rs`); its driver is +//! DIG-Network/dig_ecosystem#3249, still open. So the whole engine here is built against the narrow +//! [`ClaimChainPort`] trait derived from the SPEC's described surface, tested with a full in-memory +//! fake, and the production adapter — until #3249 ships — is [`UnavailableClaimChainPort`], which +//! reports the named state `ChainSourceUnavailable` and runs zero cycles. This mirrors #3250's own +//! `UnavailableChainPort` exactly. When #3249 lands, one adapter is written against +//! `ClaimChainPort` and nothing above this seam changes. +//! +//! A silent no-op that reported progress instead would be the exact defect this ticket exists to +//! prevent (SPEC §2.4): with the unavailable adapter wired, zero claims IS the true state, so the +//! status surface must say so by name, not by omission. +//! +//! # Not yet wired into node startup (Defect D — stated, not fixed here) +//! Nothing in this codebase constructs a [`ClaimEngine`] outside this module's own tests: there is +//! no scheduler that drives [`ClaimEngine::run_cycle`] on a cadence, and no RPC method exposes +//! [`ClaimStatus`] to an operator, even though [`RewardsClaimConfig::enabled`] defaults to `true`. +//! Wiring this into node startup — picking a concrete [`ClaimChainPort`] adapter, starting the +//! cadence loop, and exposing `ClaimStatus` over RPC — is a separate unit of work with its own +//! review surface, deferred out of this PR on purpose: the only production adapter available today +//! is [`UnavailableClaimChainPort`], and the real one arrives with +//! DIG-Network/dig_ecosystem#3249. Until that wiring lands, this module compiles, is fully tested +//! against the fake chain port, and does nothing in a running node. + +mod cadence; +mod config; +mod engine; +mod hints; +mod parser; +mod port; +mod types; + +pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +pub use config::{ + RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, + CLAIM_FEE_CEILING_MOJOS_DEFAULT, +}; +pub use engine::ClaimEngine; +pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; +pub use parser::parse_launch_comment; +pub use port::{ClaimChainPort, ClaimPortError, UnavailableClaimChainPort}; +pub use types::{ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, OwnEntry}; + +#[cfg(test)] +mod tests { + #[test] + fn module_compiles_and_loads() { + // Skeleton checkpoint (kernel invariant 3): a compiling module with one passing test, + // pushed before any design work. Superseded by the real engine tests as they land. + } +} diff --git a/crates/dig-node-service/src/rewards_claim/parser.rs b/crates/dig-node-service/src/rewards_claim/parser.rs new file mode 100644 index 00000000..aebfaade --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/parser.rs @@ -0,0 +1,102 @@ +//! The launch-comment parser (SPEC §1.3) — the only place a distributor's launch spend is tied to +//! content, so a wrong parse here is a wrong claim everywhere downstream. +//! +//! `dig-rewards:v1::`, each half exactly 64 lowercase hex characters. A +//! writer MUST emit lowercase; a reader MUST accept either case and compare the 32 BYTES, never the +//! text (SPEC §1.3). A comment that does not parse is "not a DIG rewards distributor" — not an +//! error (SPEC §1.3 clause 3). + +use chia_protocol::Bytes32; + +use super::types::DiscoveredDistributor; + +const PREFIX: &str = "dig-rewards:v1:"; + +/// Parse a launch comment into the `(store_id, root)` it names, or `None` if it is not a DIG +/// rewards distributor's comment. `launcher_id` is threaded through unchanged — this function only +/// interprets the comment string. +#[must_use] +pub fn parse_launch_comment(launcher_id: Bytes32, comment: &str) -> Option { + let rest = comment.strip_prefix(PREFIX)?; + let (store_hex, root_hex) = rest.split_once(':')?; + let store_id = parse_hex32(store_hex)?; + let root = parse_hex32(root_hex)?; + Some(DiscoveredDistributor { + launcher_id, + store_id, + root, + }) +} + +/// Exactly 64 hex characters (either case), compared as the 32 bytes they denote — never as text. +fn parse_hex32(hex: &str) -> Option { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut bytes = [0u8; 32]; + hex::decode_to_slice(hex, &mut bytes).ok()?; + Some(Bytes32::from(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lid() -> Bytes32 { + Bytes32::from([7u8; 32]) + } + + #[test] + fn table_driven_launch_comment_parsing() { + let store = "a".repeat(64); + let root = "b".repeat(64); + let store_upper = "A".repeat(64); + + let cases: &[(&str, bool)] = &[ + ("valid lowercase", true), + ("valid uppercase halves", true), + ("wrong prefix", false), + ("wrong version", false), + ("short store half", false), + ("long store half", false), + ("non-hex store half", false), + ("empty comment", false), + ("empty halves", false), + ]; + + let comments: &[String] = &[ + format!("dig-rewards:v1:{store}:{root}"), + format!("dig-rewards:v1:{store_upper}:{root}"), + format!("dig-mirror:v1:{store}:{root}"), + format!("dig-rewards:v2:{store}:{root}"), + format!("dig-rewards:v1:{}:{root}", &store[..63]), + format!("dig-rewards:v1:{store}a:{root}"), + format!("dig-rewards:v1:{}:{root}", "z".repeat(64)), + String::new(), + "dig-rewards:v1::".to_string(), + ]; + + for ((name, expect_some), comment) in cases.iter().zip(comments.iter()) { + let got = parse_launch_comment(lid(), comment); + assert_eq!(got.is_some(), *expect_some, "case: {name} ({comment:?})"); + } + } + + #[test] + fn parse_compares_bytes_not_text_case() { + let store = "ab".repeat(32); + let root = "cd".repeat(32); + let lower = parse_launch_comment(lid(), &format!("dig-rewards:v1:{store}:{root}")).unwrap(); + let upper = parse_launch_comment( + lid(), + &format!( + "dig-rewards:v1:{}:{}", + store.to_uppercase(), + root.to_uppercase() + ), + ) + .unwrap(); + assert_eq!(lower.store_id, upper.store_id); + assert_eq!(lower.root, upper.root); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs new file mode 100644 index 00000000..f5988f52 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -0,0 +1,142 @@ +//! The claim-side chain port — the seam this engine is built against instead of +//! `dig-rewards-coin` (see the module doc's "chain seam" section for why). +//! +//! Deliberately a DIFFERENT trait from #3250's `RewardsChainPort`: that one reads a funder's whole +//! entry set and writes entries; this one reads only THIS node's own entry slot and submits its own +//! payout. + +use async_trait::async_trait; +use chia_protocol::Bytes32; + +use super::types::{DiscoveredDistributor, OwnEntry}; + +/// Why a claim-chain call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimPortError { + /// No chain source is wired yet — [`UnavailableClaimChainPort`]'s only answer, and what any + /// real adapter should answer for an unreachable chain too. + Unavailable, + /// A chain answered but the call failed for a reason worth a message (bounded before logging). + Other(String), +} + +/// The narrow surface the claim engine needs from the reward-distributor chain state, derived from +/// SPEC's described surface (§1.3 discovery, §8.3/§9.3 evaluation, §10.2/§12.5 the peer's own entry, +/// §10.2 clause 3 the claim write) — not from `dig-rewards-coin`'s internals. +#[async_trait] +pub trait ClaimChainPort: Send + Sync { + /// SPEC §13.1: every CHIP-0051 distributor on chain whose launch comment parses per §1.3 — + /// before the §9.3 reserve-asset filter, which the engine applies via [`Self::reserve_asset_id`]. + async fn discover_distributors(&self) -> Result, ClaimPortError>; + + /// Re-derive one launcher id's launch comment from chain (SPEC §13.2 clause 1: a gossip hint is + /// untrusted, so it is verified through this same on-chain path, never trusted directly). + /// `Ok(None)` means the comment does not parse — "not a DIG rewards distributor", not an error + /// (SPEC §1.3). + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError>; + + /// SPEC §9.1/§9.3: the distributor's on-chain `reserve_asset_id`. + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §8.3: the distributor's own chain-curried `payout_threshold` — never hardcoded here. + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §10.2/§12.5: this node's own entry slot, re-read fresh on EVERY call, EVERY cycle — the + /// engine MUST NOT cache the result across cycles and MUST NOT treat one `Ok(None)` as + /// permanent (Defect B): SPEC §12.5 clause 2 describes a legitimate re-entry path (evicted, + /// re-challenged, re-admitted), and this call cannot tell "never admitted yet" apart from + /// "evicted" from the absence alone — nor does it need to, since SPEC §6.4 clause 1 means + /// nothing is owed either way. `Ok(None)` means only "no claim this cycle", never "no claim + /// ever again". + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError>; + + /// The network fee, in mojos, an `InitiatePayout` for this launcher id would need. + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §10.2: submit ONE `InitiatePayout` for `payout_puzzle_hash` at `fee_mojos`. + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError>; +} + +/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// [`ClaimPortError::Unavailable`] on every call and runs zero cycles — the named state +/// `ChainSourceUnavailable` (see the module doc), never a silent no-op. +pub struct UnavailableClaimChainPort; + +#[async_trait] +impl ClaimChainPort for UnavailableClaimChainPort { + async fn discover_distributors(&self) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Err(ClaimPortError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unavailable_adapter_never_reports_a_cycle_ran() { + let port = UnavailableClaimChainPort; + assert_eq!( + port.discover_distributors().await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.own_entry(Bytes32::from([0u8; 32]), Bytes32::from([0u8; 32])) + .await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.submit_initiate_payout(Bytes32::from([0u8; 32]), Bytes32::from([0u8; 32]), 0) + .await, + Err(ClaimPortError::Unavailable) + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs new file mode 100644 index 00000000..dfe22737 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -0,0 +1,566 @@ +//! The data shapes the claim loop moves — deliberately named apart from #3250's `port.rs` +//! (`DistributorRef` / `EntrySlot`) because this side carries discovery provenance the funder side +//! has no concept of. + +use chia_protocol::Bytes32; + +/// A distributor this node has located on-chain and confirmed is ours (SPEC §1.3, §9.3): its launch +/// comment parsed and its reserve asset is `dig_constants::DIG_ASSET_ID`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiscoveredDistributor { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, +} + +/// This node's own entry slot on one distributor (SPEC §10.2): keyed by a payout PUZZLE HASH, never +/// a pubkey, re-read fresh before every claim (SPEC §12.5 clause 3) and never cached across cycles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnEntry { + pub payout_puzzle_hash: Bytes32, + /// The slot's replay guard; `InitiatePayout` writes `counter + 1` (SPEC §10.2 clause 3). + pub counter: u64, + /// What this entry has accrued and not yet claimed, in $DIG base units. + pub accrued_base_units: u64, +} + +/// What one distributor's evaluation this cycle produced — never silently nothing. +/// +/// Not `Copy` since [`Self::Faulted`] carries a `String` (the chain port's own bounded error text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimOutcome { + /// `InitiatePayout` was submitted for this launcher id. + Submitted { launcher_id: Bytes32 }, + /// SPEC §8.6 final sentence: skipped, not failed — no spend, no fee. + SkippedBelowThreshold { + launcher_id: Bytes32, + accrued: u64, + threshold: u64, + }, + /// The fee ceiling (`crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS`-derived, see + /// [`super::config`]) would be exceeded — skipped, not failed. + SkippedFeeAboveCeiling { + launcher_id: Bytes32, + fee_mojos: u64, + ceiling_mojos: u64, + }, + /// SPEC v0.1.3 §12.5: no entry slot for our puzzle hash this cycle — terminal for THIS claim + /// attempt only, never for the distributor. Eviction already settled everything owed (SPEC + /// §6.4), but §12.5 forbids caching an absence any more than a value and forbids a permanent + /// per-distributor exclusion set: the loop keeps observing this distributor on §8.6's cadence, + /// because a peer can re-enter after eviction (§12.5 clause 2's re-entry path). + NoEntrySlot { launcher_id: Bytes32 }, + /// SPEC §9.3: the distributor's reserve asset is not `DIG_ASSET_ID` — not ours, dropped. + NotOurs { launcher_id: Bytes32 }, + /// Defect C2: the per-cycle aggregate fee budget (`RewardsClaimConfig::max_cycle_fee_budget_mojos`) + /// is exhausted — skipped, not failed, and every later candidate this cycle is skipped the same + /// way rather than spent past the budget. Bounds what an attacker funding many distributors over + /// a widely mirrored store can force this node to spend in one cycle. + SkippedCycleBudgetExhausted { + launcher_id: Bytes32, + fee_mojos: u64, + budget_mojos: u64, + }, + /// Defect E: the port's `own_entry` returned an entry whose `payout_puzzle_hash` does not equal + /// THIS node's own (`ClaimEngine::own_payout_puzzle_hash`). Paying it would send funds to + /// somewhere that is not this node, so the claim is REFUSED — not corrected by substituting our + /// own hash and proceeding. A mismatch means the port is confused or hostile, so it counts as a + /// fault, never a routine skip. + PayoutPuzzleHashMismatch { launcher_id: Bytes32 }, + /// The seventh case, added because the other six could only say a peer was legitimately not + /// paid, never that something went wrong: a chain call for this launcher id returned + /// `ClaimPortError::Other(_)` this cycle -- the chain answered but the call itself failed. + /// Distinct from `ClaimPortError::Unavailable` (no chain reached at all -- a cycle-wide + /// condition, surfaced as [`ClaimLoopState::ChainSourceUnavailable`], never per-launcher). Every + /// one of `evaluate_pre_budget`'s three chain reads and `evaluate_budget_phase`'s two can + /// produce this outcome; only the last of those five (`submit_initiate_payout` itself) is a + /// genuine "we tried to pay you and the chain said no" -- the earlier four never got far enough + /// to read a fee or attempt a spend. For a peer's money this is still the one fact worth + /// reporting either way: nothing legitimate happened to this distributor this cycle, and unlike + /// every variant above, it is not a deliberate, correct non-payment. + /// + /// The current [`super::port::ClaimPortError`] shape cannot distinguish "definitely never + /// landed" from "landed, fate unknown" any further than this: `Other(_)` IS the chain giving a + /// resolved answer (see `evaluate_budget_phase`'s "F12" doc comment), so every site that + /// produces this outcome already knows the call did not succeed and, by construction, that no + /// fee is left committed for it (either none was ever read, or it was read, pre-committed to + /// the persisted window, and reversed by `ClaimEngine::uncommit_fee` before this outcome was + /// built). There is no "fate unknown" case reachable today; if one is ever added (e.g. a + /// request that times out with no chain answer at all), it needs its own variant rather than + /// being folded in here, because it could not carry the same "no money moved" guarantee. + Faulted { + launcher_id: Bytes32, + /// `Some(fee)` only when a fee was pre-committed to the persisted fee window and then + /// reversed before this outcome was produced (the `submit_initiate_payout` failure path) -- + /// proof the fee did not stay spent despite the pre-commit. `None` means no fee was ever + /// read for this attempt, so there was nothing to commit or reverse. Either way the + /// persisted window reflects zero net spend for this launcher id this cycle (see + /// `f12_a_failed_submission_does_not_inflate_the_persisted_window`). + reversed_fee_mojos: Option, + /// The chain port's own words for why (`ClaimPortError::Other`'s payload), bounded to 200 + /// chars before it is stored or logged -- it originates from a chain port and so is + /// attacker-adjacent, the same discipline `service::summarize_stderr` applies to a tool's + /// own stderr. + reason: String, + }, +} + +/// The closed set of states this loop can be in. Never a health boolean (SPEC §2.4) — each name +/// maps to a different fact an operator can act on. +/// +/// # Precedence: `ChainSourceUnavailable` > `Faulted` > `ClaimableButNotClaiming` > `Idle` > +/// `Nominal` (Defect A1, refined by Defect B3) +/// `ChainSourceUnavailable` outranks everything (no chain at all). Next, `Faulted` outranks +/// `Nominal` and `ClaimableButNotClaiming`: a cycle where a chain call returned +/// `ClaimPortError::Other(_)` is never allowed to read as healthy just because nothing else in +/// the cycle happened to be claimable. Only once no fault is live can `ClaimableButNotClaiming` +/// or `Nominal` apply. +/// +/// # F1: `ChainSourceUnavailable` is a per-cycle reading, never a latch +/// This used to be decided by comparing against `self.state` -- LAST cycle's computed reading -- +/// so once any cycle took an `Unavailable` port path, every later cycle's `compute_state` saw its +/// own prior verdict and re-asserted it forever, even after the chain came back and real claims +/// were submitting. [`ClaimStatus::chain_unavailable_this_cycle`] fixes this: reset to `false` at +/// the top of every `run_cycle`, set `true` only on a cycle that actually took the `Unavailable` +/// path this cycle. `compute_state` reads that flag, never `self.state`. +/// +/// # Defect B3: a per-distributor problem must never set the cycle-wide fault +/// `Faulted` used to also fire on [`ClaimOutcome::PayoutPuzzleHashMismatch`] — a single hostile or +/// buggy ENTRY ROW pinned the whole surface at `Faulted` indefinitely (non-terminal, so it recurred +/// every cycle) and buried the `ClaimableButNotClaiming` signal this ticket exists to produce. A +/// payout-hash mismatch is now a per-distributor COUNTED refusal (see +/// [`ClaimStatus::payout_hash_mismatches_this_cycle`] and +/// [`Self::claims_refused_payout_mismatch`]), never [`Self::fault_reported`]. `Faulted` is reserved +/// for a genuinely cycle-wide failure: discovery itself failing, or a chain-port call returning +/// `ClaimPortError::Other(_)`. +/// +/// # F8/F9/F10: `PersistedStateCorrupt` and `CadenceNotElapsed` are assigned DIRECTLY, never via +/// [`ClaimStatus::compute_state`] +/// Both are written by [`super::engine::ClaimEngine::run_cycle`] on an early return that happens +/// BEFORE any of this cycle's own numbers exist to compute a reading from — there is no +/// "claimable" or "faulted" count to rank against `compute_state`'s ladder, because no candidate +/// was ever evaluated. F9's finding was exactly this gap: an early return that assigned NEITHER a +/// direct state NOR fell through to `compute_state` left whatever `self.state` a PAST cycle +/// computed sitting there, stamped with a fresh `last_attempt_at` that made a deliberate skip read +/// as "healthy and idle". Every exit out of `run_cycle` now sets `state` one of these two ways — +/// directly here, or through `compute_state` at the bottom — never neither. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClaimLoopState { + /// No cycle has ever been attempted yet. + #[default] + Idle, + /// The chain seam reported [`super::port::ClaimPortError::Unavailable`] — see the module doc's + /// "chain seam" section. Zero cycles ran; this is the true state, not a silent no-op. + ChainSourceUnavailable, + /// F8/F10/F16, fund-safety: the persisted rewards-claim state (`RewardsClaimConfig`) was + /// unreadable, unparsable, carried a spend exceeding its own budget (F14), or carried a + /// future-dated clock (F10) — corrupt state, not a fresh peer. The engine treats the window as + /// fully spent and submits nothing THIS CYCLE. What happens next differs by cause, and both are + /// re-checked fresh on every cycle (F16), never latched: + /// - an unreadable/unparsable file or an over-budget spend needs an operator to fix or remove + /// it, and stays `PersistedStateCorrupt` until they do; + /// - a future-dated clock is SELF-HEALING — `t > now` goes false the moment real time passes + /// the stored timestamp, so the very next cycle after catch-up reads as whatever + /// `compute_state` decides (typically `Nominal`), never stuck here. + /// This state exists so that refusal is visible rather than a silent, permanent freeze that + /// reads as `Nominal` (the pre-F9 shape of the F10 defect) — or, before F16, a permanent freeze + /// of its OWN under a different name once the clock had already caught up. + PersistedStateCorrupt, + /// F9: the cadence has not yet elapsed since the last cycle that ran to completion — a + /// DELIBERATE skip, its own named condition rather than the absence of one. Without this, the + /// gate's early return left a stale `self.state` from whatever a PAST cycle computed standing + /// under this cycle's freshly-stamped `last_attempt_at`, indistinguishable from a healthy idle + /// loop (the fourth relocation of this error class — see [`super::engine::ClaimEngine`]'s + /// module doc for the first three). + CadenceNotElapsed, + /// A chain call this cycle returned `ClaimPortError::Other(_)` — a real fault, distinct from + /// `ChainSourceUnavailable` (no chain at all). `cycles` is the number of CONSECUTIVE cycles a + /// fault has now been observed on, so an operator can tell a one-off blip from a wedged loop. + /// Defect A1: this state exists precisely so a reported fault can never be laundered into + /// `Nominal` for lack of anywhere else to fall through to. + Faulted { cycles: u32 }, + /// The silent-failure case this ticket exists to prevent: fewer distributors were claimed THIS + /// CYCLE than were claimable, and no fault is live. Carries both numbers so a reader sees the + /// SIZE of the gap, not just its existence. Computed, never asserted by a writer about itself — + /// see [`ClaimStatus::compute_state`]. + /// + /// # Defect B1: a zero-test masked a partial shortfall + /// This used to fire only when `claims_submitted_this_cycle == 0` — a magnitude comparison + /// disguised as an existence check. `claimable = 10, submitted_this_cycle = 1` read `Nominal`: + /// one submission (e.g. a distributor whose fee happened to sort first) masked nine same-cycle + /// skips. Reachable precisely because [`super::engine::ClaimEngine`]'s per-cycle budget + /// (Defect C2) is the first thing that can skip a claimable distributor while another one + /// submits in the same cycle. Fixed to a true magnitude comparison: fires whenever + /// `submitted < claimable`, whatever the non-zero submitted count is. + ClaimableButNotClaiming { claimable: u32, submitted: u32 }, + /// A cycle completed, nothing above is true. + Nominal, +} + +/// The anti-silence status surface (requirement 4): what an operator or a monitor reads to know +/// whether this loop is actually doing anything, never a boolean. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimStatus { + /// F1: set when THIS cycle actually took an `Unavailable` port path -- reset to `false` at the + /// top of every `run_cycle`, never latched. See [`ClaimLoopState`]'s "F1" doc section. + pub chain_unavailable_this_cycle: bool, + pub distributors_known: u32, + pub distributors_with_own_entry: u32, + /// Computed independently of whether a submission actually happened this cycle — an + /// entry that accrued at least `payout_threshold` with a fee at or under the ceiling. THIS + /// CYCLE's snapshot, overwritten every `run_cycle`, and compared against + /// [`Self::claims_submitted_this_cycle`] (also per-cycle) — never against the cumulative + /// [`Self::claims_submitted`], which only ever grows and would let one success in the process's + /// life mask every later broken cycle (Defect A3). + pub distributors_claimable: u32, + /// Defect C3/A3: distributors whose evaluation THIS cycle returned `ClaimPortError::Other(_)`. + /// A faulted distributor is not counted in [`Self::distributors_claimable`] — a fault must never + /// silently shrink that denominator into looking healthier than it is. + pub distributors_faulted: u32, + /// Only stamped on a discovery call that actually SUCCEEDED (Defect A4) — a reader uses this as + /// an independent staleness signal, so refreshing it on a failed discovery would destroy the one + /// reading that would have exposed the fault. See [`Self::last_attempt_at`] for "the loop is + /// still alive" instead. + pub last_discovery_at: Option, + /// Only stamped on a cycle that was not a failed discovery and not all-faulted (Defect A4) — same + /// reasoning as [`Self::last_discovery_at`]. + pub last_cycle_at: Option, + /// Stamped every time `run_cycle` is invoked, success or failure — proves the loop is still + /// running even across a run of all-faulted cycles, without polluting the staleness signal the + /// other two timestamps carry (Defect A4). + pub last_attempt_at: Option, + /// Lifetime total — a useful counter, kept cumulative on purpose. NOT the predicate for + /// [`ClaimLoopState::ClaimableButNotClaiming`]; see [`Self::claims_submitted_this_cycle`]. + pub claims_submitted: u64, + /// THIS CYCLE's submission count, overwritten every `run_cycle` (Defect A3) — the correct half + /// of the `ClaimableButNotClaiming` predicate. + pub claims_submitted_this_cycle: u64, + pub claims_skipped_below_threshold: u64, + pub claims_skipped_fee_ceiling: u64, + /// Defect C2: lifetime count of claims skipped because the per-cycle aggregate fee budget was + /// already exhausted this cycle. + pub claims_skipped_cycle_budget: u64, + /// Defect E: lifetime count of claims REFUSED because the port returned an entry for a puzzle + /// hash other than this node's own — see [`ClaimOutcome::PayoutPuzzleHashMismatch`]. A + /// per-distributor counted fault (Defect B3), never [`Self::fault_reported`]. + pub claims_refused_payout_mismatch: u64, + /// Defect B3: THIS CYCLE's twin of [`Self::claims_refused_payout_mismatch`] — without it a + /// reader could not tell an ONGOING misdirection from an old, no-longer-recurring one, the same + /// per-cycle-vs-lifetime gap Defect A3 named for the other counters. + pub payout_hash_mismatches_this_cycle: u32, + /// THIS CYCLE's count of distributors observed with no entry slot (Defect B) — no longer a + /// lifetime blacklist size, because the engine no longer blacklists a launcher id permanently; + /// see [`super::engine::ClaimEngine`]'s module doc. + /// + /// # Defect R2: renamed from `terminal_no_entry_slot` + /// That name quoted SPEC §12.5 clause 1's "terminal, non-error" language to justify behaviour + /// that is deliberately non-terminal since the Defect B fix — a doc claim born false in the + /// commit that fixed the code. Renamed before #3268 publishes it over RPC. + /// + /// SPEC v0.1.3 §12.5 (the amendment R1 flagged as pending is now merged and tagged) confirms + /// this reading directly: an absent entry slot is terminal for ONE claim attempt, never for the + /// distributor, MUST NOT be cached, and MUST NOT accumulate into a permanent exclusion set — + /// this field satisfies v0.1.3 clause 6's "surfaced, not silently absorbed" requirement without + /// a tenth named [`ClaimLoopState`] variant: it is a per-cycle count, dated by + /// [`Self::last_attempt_at`] -- the field stamped unconditionally every cycle, the true + /// analogue of §2.3's `observed_at` -- and reset at the TOP of every `run_cycle` alongside the + /// other per-cycle counters, before any early return, so a stalled writer can never leave a + /// stale count sitting under a fresh timestamp (never a lifetime latch). + pub no_entry_slot_this_cycle: u32, + /// Set when a chain call THIS CYCLE returned `ClaimPortError::Other(_)` — reset at the start of + /// every `run_cycle` (Defect A1: this used to latch true for the rest of the process's life, + /// which would have permanently suppressed every other state once tripped once). + pub fault_reported: bool, + /// Consecutive cycles (including this one, if `fault_reported`) that have reported a fault — + /// resets to 0 the moment a cycle reports no fault. Surfaced via [`ClaimLoopState::Faulted`]. + pub consecutive_faulted_cycles: u32, + pub state: ClaimLoopState, +} + +impl Default for ClaimStatus { + fn default() -> Self { + ClaimStatus { + chain_unavailable_this_cycle: false, + distributors_known: 0, + distributors_with_own_entry: 0, + distributors_claimable: 0, + distributors_faulted: 0, + last_discovery_at: None, + last_cycle_at: None, + last_attempt_at: None, + claims_submitted: 0, + claims_submitted_this_cycle: 0, + claims_skipped_below_threshold: 0, + claims_skipped_fee_ceiling: 0, + claims_skipped_cycle_budget: 0, + claims_refused_payout_mismatch: 0, + payout_hash_mismatches_this_cycle: 0, + no_entry_slot_this_cycle: 0, + fault_reported: false, + consecutive_faulted_cycles: 0, + state: ClaimLoopState::Idle, + } + } +} + +impl ClaimStatus { + /// Derives [`ClaimLoopState`] from the status fields alone — a pure computation, so a test can + /// assert `ClaimableButNotClaiming` (or `Faulted`) directly against hand-built fields without + /// driving a whole engine cycle, and so a stalled writer can never manufacture a healthier state + /// than its own numbers support (SPEC §2.4's reasoning, applied to this loop's own surface). + /// + /// Precedence, most urgent first: `ChainSourceUnavailable` > `Faulted` > + /// `ClaimableButNotClaiming` > `Idle` > `Nominal`. See [`ClaimLoopState`]'s doc for why a fault + /// must never be absorbed into `Nominal` (Defect A1) and why a per-distributor fault (Defect B3) + /// must never set it. + /// + /// # F1: reads `chain_unavailable_this_cycle`, never `self.state` + /// The old guard compared against `self.state` -- last cycle's OWN computed output -- which + /// made `ChainSourceUnavailable` a process-lifetime latch (see [`ClaimLoopState`]'s "F1" doc + /// section). `chain_unavailable_this_cycle` is reset every cycle, so this reading is live. + #[must_use] + pub fn compute_state(&self) -> ClaimLoopState { + if self.chain_unavailable_this_cycle { + return ClaimLoopState::ChainSourceUnavailable; + } + if self.last_attempt_at.is_none() && self.last_cycle_at.is_none() { + return ClaimLoopState::Idle; + } + if self.fault_reported { + return ClaimLoopState::Faulted { + cycles: self.consecutive_faulted_cycles.max(1), + }; + } + // Defect B1: a magnitude comparison, not a zero-test -- `claims_submitted_this_cycle < 10` + // fires just as much when 1 of 10 claimable was submitted as when 0 were; a partial + // shortfall must never be masked by whichever claims did go through. + // + // F2: `payout_hash_mismatches_this_cycle` folds into the RIGHT side of the comparison. A + // mismatching distributor never enters `eligible`, so it is counted in NEITHER + // `claims_submitted_this_cycle` NOR `distributors_claimable` -- the shortfall was in + // neither term of this comparison. All-K-mismatching used to read `submitted = 0, + // claimable = 0` -> healthy. An ongoing mismatch is a real per-cycle shortfall exactly like + // an unmet `claimable`, so it belongs in the same predicate, never a separate signal + // nothing reads. + let shortfall_denominator = u64::from(self.distributors_claimable) + + u64::from(self.payout_hash_mismatches_this_cycle); + if self.claims_submitted_this_cycle < shortfall_denominator { + // F13: report the SAME quantity the predicate above just used, not the un-folded + // `distributors_claimable` alone. Before this fix, all-K-mismatching produced + // `ClaimableButNotClaiming { claimable: 0, submitted: 0 }` -- the name was right (F2 + // already folded mismatches into firing the state at all) but the payload said + // nothing was wrong, because it reported the term the mismatches were never counted + // in. The payload must carry the full shortfall the name is claiming, or it is a + // state whose numbers contradict its own name. + return ClaimLoopState::ClaimableButNotClaiming { + claimable: u32::try_from(shortfall_denominator).unwrap_or(u32::MAX), + submitted: u32::try_from(self.claims_submitted_this_cycle).unwrap_or(u32::MAX), + }; + } + ClaimLoopState::Nominal + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claimable_but_not_claiming_is_computed_from_fields_alone() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted_this_cycle: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 3, + submitted: 0 + } + ); + } + + /// Defect B1 regression: a magnitude comparison, not a zero-test. `claimable = 10, + /// submitted_this_cycle = 1` used to read `Nominal` because the old predicate only checked + /// `submitted_this_cycle == 0` -- one submission masked nine same-cycle skips. + #[test] + fn a_partial_shortfall_is_claimable_but_not_claiming_not_nominal() { + let status = ClaimStatus { + distributors_claimable: 10, + claims_submitted_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 10, + submitted: 1 + }, + "1 of 10 claimable submitted must still read the shortfall, never Nominal" + ); + } + + /// Defect B1 regression: the other half of the fix -- every claimable distributor submitted + /// must read `Nominal`, not a false-positive shortfall. + #[test] + fn claiming_every_claimable_distributor_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 10, + claims_submitted_this_cycle: 10, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + /// Defect A1/A2: this test used to assert `Nominal` here, encoding the bug (a reported fault + /// was silently absorbed into the healthy state) as intended behaviour. Inverted per the fix + /// brief: a fault must surface its own named state, never masquerade as either + /// `ClaimableButNotClaiming` or `Nominal`. + #[test] + fn a_reported_fault_surfaces_as_faulted_not_nominal() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted_this_cycle: 0, + fault_reported: true, + consecutive_faulted_cycles: 1, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::Faulted { cycles: 1 } + ); + } + + /// Defect A3 regression: `distributors_claimable` is a per-cycle snapshot and + /// `claims_submitted` (cumulative) only ever grows, so comparing the two lets one success in + /// the process's lifetime mask every later cycle where the submit path has since broken. The + /// fix compares against `claims_submitted_this_cycle` instead. + #[test] + fn a_lifetime_submission_does_not_mask_a_later_cycle_that_submits_nothing() { + let status = ClaimStatus { + distributors_claimable: 1, + claims_submitted: 7, // non-zero lifetime total from an earlier successful cycle + claims_submitted_this_cycle: 0, // but THIS cycle submitted nothing + fault_reported: false, + last_cycle_at: Some(2), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + } + ); + } + + #[test] + fn nothing_claimable_and_nothing_submitted_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted_this_cycle: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + #[test] + fn chain_source_unavailable_wins_over_every_other_reading() { + let status = ClaimStatus { + distributors_claimable: 5, + claims_submitted: 0, + fault_reported: false, + last_cycle_at: Some(1), + chain_unavailable_this_cycle: true, + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ChainSourceUnavailable + ); + } + + /// F1 regression at the `compute_state` level: a PAST cycle's `ChainSourceUnavailable` must + /// never leak into THIS cycle's reading once `chain_unavailable_this_cycle` is false again -- + /// proving the fix reads the per-cycle flag, never `self.state` (which this struct literal + /// deliberately still carries as `ChainSourceUnavailable`, simulating what a stale `self.state` + /// would look like if the old guard were still in place). + #[test] + fn a_past_cycles_chain_unavailable_state_does_not_latch_the_next_computation() { + let status = ClaimStatus { + distributors_claimable: 1, + claims_submitted_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(2), + chain_unavailable_this_cycle: false, + state: ClaimLoopState::ChainSourceUnavailable, + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::Nominal, + "chain_unavailable_this_cycle is false this cycle -- a stale self.state must not win" + ); + } + + /// Defect B3 regression: a per-distributor payout-hash mismatch count, with no cycle-wide + /// `fault_reported`, must read the `ClaimableButNotClaiming` shortfall it actually represents, + /// never `Faulted` -- `engine.rs` is the one that decides `fault_reported`, but this proves the + /// state computation itself no longer has any path from "a mismatch happened" to `Faulted`. + #[test] + fn a_payout_mismatch_count_alone_does_not_force_faulted() { + let status = ClaimStatus { + distributors_claimable: 2, + claims_submitted_this_cycle: 1, + payout_hash_mismatches_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + // F13: `claimable` is now the FOLDED shortfall (2 claimable + 1 mismatch = 3), not + // the un-folded `distributors_claimable` alone -- see the F13 regression below for + // the case (all-K-mismatching) that made the un-folded reading actively misleading. + ClaimLoopState::ClaimableButNotClaiming { + claimable: 3, + submitted: 1 + } + ); + } + + /// F13 regression: all-K-mismatching must report the shortfall it actually represents, not a + /// payload that contradicts its own state name. Before the fix, this read `claimable: 0, + /// submitted: 0` -- a name saying something is wrong next to numbers saying nothing is. Must + /// go red with only the `claimable: shortfall_denominator` fix reverted to + /// `claimable: self.distributors_claimable`. + #[test] + fn all_k_mismatching_reports_the_folded_shortfall_not_zero() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted_this_cycle: 0, + payout_hash_mismatches_this_cycle: 4, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 4, + submitted: 0 + }, + "the payload must carry the same shortfall the predicate fired on, never 0" + ); + } +}