From e66df3a4f8911637fb4929419d0e967b6b26871f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 14 Sep 2026 10:53:25 -0700 Subject: [PATCH 01/12] chore: open lane for #3336 Co-Authored-By: Claude Sonnet 5 --- .claim-driver-newtypes-stub.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .claim-driver-newtypes-stub.md diff --git a/.claim-driver-newtypes-stub.md b/.claim-driver-newtypes-stub.md new file mode 100644 index 00000000..cdd67366 --- /dev/null +++ b/.claim-driver-newtypes-stub.md @@ -0,0 +1 @@ +Lane open for #3336 — claim driver newtype guards + injectable clock. From 0f6b6e298924d2f8991bd81df4b8b0f9604642e7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 16 Sep 2026 13:25:19 -0700 Subject: [PATCH 02/12] fix(rewards-claim): newtype the fee-window cadence args + injectable-clock seam - GateCadenceSeconds / FeeWindowCadenceSeconds newtypes on ClaimEngine::with_persisted_fee_window so transposing the two cadence values at the driver.rs call site is a compile error, not a silent money defect (#3336). - run_claim_driver_in_with_clock: the production body with the clock parameterized instead of hardcoded to unix_now_seconds, so the timing behaviour is testable under tokio::time::advance. run_claim_driver_in stays a thin wrapper over it passing the real clock. Refs #3336 Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/driver.rs | 56 ++++++++++++--- .../src/rewards_claim/engine.rs | 69 +++++++++++++------ 2 files changed, 97 insertions(+), 28 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 4d4bdd52..8b616473 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -38,7 +38,7 @@ use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::ClaimEngine; +use super::engine::{ClaimEngine, FeeWindowCadenceSeconds, GateCadenceSeconds}; use super::hints::{DistributorHintSource, NoHintSource}; use super::port::{ClaimChainPort, UnavailableClaimChainPort}; use super::types::{ClaimLoopState, ClaimStatus}; @@ -438,6 +438,42 @@ async fn run_claim_driver_in

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

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

( // two into one value in either direction is wrong: clamped-for-both doubles the fee ceiling, // raw-for-both can silently starve the gate (an unbounded-above raw cadence would stop cycles // from ever running while the scheduler keeps ticking on the clamped interval). - .with_persisted_fee_window(state_dir, cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + state_dir, + GateCadenceSeconds(cadence_seconds), + FeeWindowCadenceSeconds(cfg.cadence_seconds), + ); drive( engine, @@ -480,7 +520,7 @@ async fn run_claim_driver_in

( jitter_seconds, adjustment, &OsJitter, - unix_now_seconds, + now, handle, ) .await; @@ -992,7 +1032,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(cfg.cadence_seconds), FeeWindowCadenceSeconds(cfg.cadence_seconds)); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 assert!(outcomes.is_empty()); @@ -1039,7 +1079,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), effective_cadence, configured_cadence); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(effective_cadence), FeeWindowCadenceSeconds(configured_cadence)); let handle = ClaimLoopHandle::default(); let h = handle.clone(); @@ -1138,7 +1178,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(cfg.cadence_seconds), FeeWindowCadenceSeconds(cfg.cadence_seconds)); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); @@ -1166,7 +1206,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(cfg.cadence_seconds), FeeWindowCadenceSeconds(cfg.cadence_seconds)); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at assert!(outcomes.is_empty()); @@ -1198,7 +1238,7 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), 1_000, 1_000); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(1_000), FeeWindowCadenceSeconds(1_000)); let outcomes = engine.run_cycle(1).await; assert!(outcomes.is_empty()); diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index b570920f..dd3a9b90 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -11,6 +11,30 @@ use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +/// The schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s restart-safety gate +/// is measured against -- the same interval [`super::driver::drive`] actually sleeps on. +/// +/// # DIG-Network/dig_ecosystem#3336 (money) +/// A bare `u64` here is interchangeable with [`FeeWindowCadenceSeconds`] at the call site +/// ([`ClaimEngine::with_persisted_fee_window`]) and nothing stops the two from being transposed -- +/// swapping them silently RESTORES the no-op gate DIG-Network/dig_ecosystem#3306 fixed: the gate +/// would be measured against the RAW, unclamped operator cadence instead of the clamped one the +/// scheduler actually runs on, so an operator's 60-day config would gate on 60 days again even +/// though the loop keeps ticking every 31. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GateCadenceSeconds(pub u64); + +/// The RAW configured cadence, in seconds, that sizes how long the persisted aggregate +/// fee-budget window stays open before rolling -- deliberately never the clamped value. +/// +/// # DIG-Network/dig_ecosystem#3336 (money) +/// Swapping this with [`GateCadenceSeconds`] at the [`ClaimEngine::with_persisted_fee_window`] +/// call site DOUBLES the number of fee-budget windows a long-cadence operator sized (a 60-day +/// config would get ~12 windows/year instead of the ~6 its cadence implies), doubling the fee +/// ceiling they configured. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FeeWindowCadenceSeconds(pub u64); + /// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] /// and the anti-silence status surface across calls to [`Self::run_cycle`]. /// @@ -174,18 +198,23 @@ impl ClaimEngine { /// of every cycle unconditionally (its `CycleConditions`), so a construction-time copy was /// pure overhead: it was never trusted past the first cycle anyway once F16 landed, and now it /// is never even taken. + /// + /// # DIG-Network/dig_ecosystem#3336 + /// `gate_cadence_seconds` and `fee_window_seconds` are distinct newtypes ([`GateCadenceSeconds`] + /// / [`FeeWindowCadenceSeconds`]), not two bare `u64`s, precisely so transposing them at a call + /// site is a compile error rather than a silent money defect -- see both types' docs. #[must_use] pub fn with_persisted_fee_window( mut self, dir: &Path, - gate_cadence_seconds: u64, - fee_window_seconds: u64, + gate_cadence_seconds: GateCadenceSeconds, + fee_window_seconds: FeeWindowCadenceSeconds, ) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); self.gate_cadence_seconds = - gate_cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + gate_cadence_seconds.0.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self.fee_window_seconds = - fee_window_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + fee_window_seconds.0.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -1774,7 +1803,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1799,7 +1828,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let second_outcomes = second.run_cycle(1_010).await; let second_submitted = second_outcomes @@ -1838,7 +1867,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes .iter() @@ -1878,7 +1907,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1901,7 +1930,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let second_outcomes = second.run_cycle(later).await; assert_eq!( @@ -1934,7 +1963,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1951,7 +1980,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let second_outcomes = second.run_cycle(1_050).await; assert_eq!( @@ -1987,7 +2016,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let outcomes = e.run_cycle(1_000).await; let submitted: u64 = outcomes @@ -2036,7 +2065,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2094,7 +2123,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the // gate cannot be what refuses this -- only the window accumulator can. @@ -2134,7 +2163,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -2156,7 +2185,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let second_outcomes = second.run_cycle(1_010).await; assert_eq!( @@ -2208,7 +2237,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, // and must refuse. @@ -2281,7 +2310,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); // Cycle 1: the file is corrupt -- must refuse, submit nothing. let cycle1 = e.run_cycle(1_000).await; @@ -2353,7 +2382,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2401,7 +2430,7 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); let outcomes = e.run_cycle(1_000).await; From d1fb724a08bdb0f56a8b76fbfea6391fe6550fc5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 16 Sep 2026 13:25:36 -0700 Subject: [PATCH 03/12] test(rewards-claim): drive gate/window split through run_claim_driver_in_with_clock Adds the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window, proving the F2 gate-vs-fee-window distinction through the actual production body (run_claim_driver_in_with_clock) rather than a hand-assembled drive() call, so a transposition of the two cadence arguments at with_persisted_fee_window's call site (#3336) is caught end-to-end. Refs #3336 Co-Authored-By: Claude Sonnet 5 --- .claim-driver-newtypes-stub.md | 1 - .../src/rewards_claim/driver.rs | 89 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) delete mode 100644 .claim-driver-newtypes-stub.md diff --git a/.claim-driver-newtypes-stub.md b/.claim-driver-newtypes-stub.md deleted file mode 100644 index cdd67366..00000000 --- a/.claim-driver-newtypes-stub.md +++ /dev/null @@ -1 +0,0 @@ -Lane open for #3336 — claim driver newtype guards + injectable clock. diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 8b616473..02781caf 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1360,6 +1360,95 @@ mod tests { driver.abort(); } + /// F2/#3336, MONEY, THE JOINT VERSION: [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] + /// proves the gate/window split by hand-assembling `drive` directly. This test proves the SAME + /// property through [`run_claim_driver_in_with_clock`] -- the actual production body, the one + /// that threads `sanitized_schedule`'s CLAMPED cadence and the RAW `cfg.cadence_seconds` into + /// [`ClaimEngine::with_persisted_fee_window`] at driver.rs's one call site. Transposing the two + /// arguments there (the #3336 defect) is invisible to a hand-assembled `drive` test but would + /// flip both counts this test asserts: the fee window would roll at tick 2 instead of tick 3 + /// (doubling the window count -- the "doubles the fee ceiling" half of the defect), and/or the + /// gate would stop opening every clamped tick (the "restores the no-op gate #3306 fixed" half). + /// + /// Uses a written config with a 60-day RAW cadence (`5_184_000`s, clamped to the 31-day + /// `CLAIM_SCHEDULE_SECONDS_MAX`, `2_678_400`s) and a clock that advances one clamped interval + /// per invocation, matching the scheduler's own tick -- the same pattern + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] uses, but + /// driven through the production body instead of a hand-built engine. + #[tokio::test(start_paused = true)] + async fn the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window() { + let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. + let effective_cadence = 31 * 24 * 60 * 60u64; // 2_678_400, CLAMPED -- sizes the gate. + assert_eq!(configured_cadence, 5_184_000); + assert_eq!(effective_cadence, 2_678_400); + + let dir = tempfile::tempdir().unwrap(); + write_config(dir.path(), configured_cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in_with_clock( + &state_dir, + Bytes32::from([1u8; 32]), + EmptyPort, + h, + { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }, + ) + .await; + }); + + settle().await; + assert_eq!(handle.cycles_driven(), 0, "no interval has elapsed yet"); + + // Tick 1: the window opens for the first time. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 1); + let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!(after_tick_1, Some(effective_cadence), "the window opens on tick 1"); + + // Tick 2: one clamped interval since tick 1 -- the GATE must open (it tracks the clamped + // schedule the loop actually ticks on) but the WINDOW must NOT roll yet (only one clamped + // interval, 2_678_400s, of its raw 5_184_000s length has elapsed). + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "#3336: the production body's gate must track the CLAMPED cadence -- a transposed \ + call to with_persisted_fee_window would gate on the raw 5_184_000s cadence instead \ + and this cycle would never run, restoring the no-op gate #3306 fixed" + ); + let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_2, after_tick_1, + "#3336: the fee window must NOT have rolled yet -- only one clamped interval has \ + elapsed against its raw 5_184_000s length. A transposed call would roll it here, \ + doubling the operator's configured fee-window count" + ); + + // Tick 3: two clamped intervals (5_356_800s) since the window opened -- past its raw + // 5_184_000s length. The window must finally roll. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 3); + let after_tick_3 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_ne!( + after_tick_3, after_tick_1, + "#3336: the window must have rolled once the RAW 5_184_000s cadence elapsed" + ); + + driver.abort(); + } + // ---- the cycle log: the only reader of the status surface in a shipped binary ---------- /// An in-memory sink a `tracing_subscriber::fmt` layer renders records into, so a test can From 46d803a3f081b652f569d383454380edef68ec64 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 16 Sep 2026 13:22:59 -0700 Subject: [PATCH 04/12] style: cargo fmt rewards-claim fee-window call sites Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/driver.rs | 56 +++++---- .../src/rewards_claim/engine.rs | 106 ++++++++++++++---- 2 files changed, 123 insertions(+), 39 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 02781caf..c654b35d 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1032,7 +1032,11 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(cfg.cadence_seconds), FeeWindowCadenceSeconds(cfg.cadence_seconds)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(cfg.cadence_seconds), + FeeWindowCadenceSeconds(cfg.cadence_seconds), + ); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 assert!(outcomes.is_empty()); @@ -1079,7 +1083,11 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(effective_cadence), FeeWindowCadenceSeconds(configured_cadence)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(effective_cadence), + FeeWindowCadenceSeconds(configured_cadence), + ); let handle = ClaimLoopHandle::default(); let h = handle.clone(); @@ -1178,7 +1186,11 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(cfg.cadence_seconds), FeeWindowCadenceSeconds(cfg.cadence_seconds)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(cfg.cadence_seconds), + FeeWindowCadenceSeconds(cfg.cadence_seconds), + ); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); @@ -1206,7 +1218,11 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(cfg.cadence_seconds), FeeWindowCadenceSeconds(cfg.cadence_seconds)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(cfg.cadence_seconds), + FeeWindowCadenceSeconds(cfg.cadence_seconds), + ); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at assert!(outcomes.is_empty()); @@ -1238,7 +1254,11 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(1_000), FeeWindowCadenceSeconds(1_000)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(1_000), + FeeWindowCadenceSeconds(1_000), + ); let outcomes = engine.run_cycle(1).await; assert!(outcomes.is_empty()); @@ -1389,19 +1409,13 @@ mod tests { let h = handle.clone(); let state_dir = dir.path().to_path_buf(); let driver = tokio::spawn(async move { - run_claim_driver_in_with_clock( - &state_dir, - Bytes32::from([1u8; 32]), - EmptyPort, - h, - { - let mut t = 0u64; - move || { - t += effective_cadence; - t - } - }, - ) + run_claim_driver_in_with_clock(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h, { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }) .await; }); @@ -1413,7 +1427,11 @@ mod tests { settle().await; assert_eq!(handle.cycles_driven(), 1); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; - assert_eq!(after_tick_1, Some(effective_cadence), "the window opens on tick 1"); + assert_eq!( + after_tick_1, + Some(effective_cadence), + "the window opens on tick 1" + ); // Tick 2: one clamped interval since tick 1 -- the GATE must open (it tracks the clamped // schedule the loop actually ticks on) but the WINDOW must NOT roll yet (only one clamped diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index dd3a9b90..1b3ba7d5 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -211,10 +211,12 @@ impl ClaimEngine { fee_window_seconds: FeeWindowCadenceSeconds, ) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); - self.gate_cadence_seconds = - gate_cadence_seconds.0.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); - self.fee_window_seconds = - fee_window_seconds.0.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.gate_cadence_seconds = gate_cadence_seconds + .0 + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.fee_window_seconds = fee_window_seconds + .0 + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -1803,7 +1805,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1828,7 +1834,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let second_outcomes = second.run_cycle(1_010).await; let second_submitted = second_outcomes @@ -1867,7 +1877,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes .iter() @@ -1907,7 +1921,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1930,7 +1948,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let second_outcomes = second.run_cycle(later).await; assert_eq!( @@ -1963,7 +1985,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1980,7 +2006,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let second_outcomes = second.run_cycle(1_050).await; assert_eq!( @@ -2016,7 +2046,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let outcomes = e.run_cycle(1_000).await; let submitted: u64 = outcomes @@ -2065,7 +2099,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2123,7 +2161,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(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. @@ -2163,7 +2205,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -2185,7 +2231,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let second_outcomes = second.run_cycle(1_010).await; assert_eq!( @@ -2237,7 +2287,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, // and must refuse. @@ -2310,7 +2364,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); // Cycle 1: the file is corrupt -- must refuse, submit nothing. let cycle1 = e.run_cycle(1_000).await; @@ -2382,7 +2440,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2430,7 +2492,11 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), GateCadenceSeconds(CADENCE_SECONDS), FeeWindowCadenceSeconds(CADENCE_SECONDS)); + .with_persisted_fee_window( + dir.path(), + GateCadenceSeconds(CADENCE_SECONDS), + FeeWindowCadenceSeconds(CADENCE_SECONDS), + ); let outcomes = e.run_cycle(1_000).await; From 5f94183b444c0eb9ec44d3c49189acbde507e2b2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 16 Sep 2026 19:52:00 -0700 Subject: [PATCH 05/12] refactor(rewards-claim): collapse two cadence newtypes into one ClaimCadences value The positional-newtype shape (GateCadenceSeconds/FeeWindowCadenceSeconds as two adjacent with_persisted_fee_window arguments) closed the positional swap but left the value swap open -- GateCadenceSeconds(raw)/FeeWindowCadenceSeconds(clamped) still compiled and still passed the existing test (dig_ecosystem#3336 rework). Bundle both cadences into one ClaimCadences { gate_clamped, fee_window_raw } value instead: there is no longer a pair of adjacent arguments for a swap to target at the call site. Every production and test call site updated. Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/driver.rs | 65 +++++-- .../src/rewards_claim/engine.rs | 163 +++++++++++------- 2 files changed, 147 insertions(+), 81 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index c654b35d..b21335c8 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -38,7 +38,7 @@ use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::{ClaimEngine, FeeWindowCadenceSeconds, GateCadenceSeconds}; +use super::engine::{ClaimCadences, ClaimEngine}; use super::hints::{DistributorHintSource, NoHintSource}; use super::port::{ClaimChainPort, UnavailableClaimChainPort}; use super::types::{ClaimLoopState, ClaimStatus}; @@ -510,8 +510,10 @@ async fn run_claim_driver_in_with_clock

( // from ever running while the scheduler keeps ticking on the clamped interval). .with_persisted_fee_window( state_dir, - GateCadenceSeconds(cadence_seconds), - FeeWindowCadenceSeconds(cfg.cadence_seconds), + ClaimCadences { + gate_clamped: cadence_seconds, + fee_window_raw: cfg.cadence_seconds, + }, ); drive( @@ -1034,8 +1036,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(cfg.cadence_seconds), - FeeWindowCadenceSeconds(cfg.cadence_seconds), + ClaimCadences { + gate_clamped: cfg.cadence_seconds, + fee_window_raw: cfg.cadence_seconds, + }, ); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 @@ -1085,8 +1089,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(effective_cadence), - FeeWindowCadenceSeconds(configured_cadence), + ClaimCadences { + gate_clamped: effective_cadence, + fee_window_raw: configured_cadence, + }, ); let handle = ClaimLoopHandle::default(); @@ -1188,8 +1194,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(cfg.cadence_seconds), - FeeWindowCadenceSeconds(cfg.cadence_seconds), + ClaimCadences { + gate_clamped: cfg.cadence_seconds, + fee_window_raw: cfg.cadence_seconds, + }, ); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 @@ -1220,8 +1228,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(cfg.cadence_seconds), - FeeWindowCadenceSeconds(cfg.cadence_seconds), + ClaimCadences { + gate_clamped: cfg.cadence_seconds, + fee_window_raw: cfg.cadence_seconds, + }, ); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at @@ -1256,8 +1266,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(1_000), - FeeWindowCadenceSeconds(1_000), + ClaimCadences { + gate_clamped: 1_000, + fee_window_raw: 1_000, + }, ); let outcomes = engine.run_cycle(1).await; @@ -1425,7 +1437,16 @@ mod tests { // Tick 1: the window opens for the first time. tokio::time::advance(Duration::from_secs(effective_cadence)).await; settle().await; + // `cycles_driven()` alone is NOT gate evidence: `drive` increments it unconditionally + // after every `run_cycle` call returns, whatever that cycle's outcome was -- a cycle the + // internal gate REFUSED (`ClaimLoopState::CadenceNotElapsed`) still increments it. Assert + // on the reported STATE, which the gate's early `return` in `run_cycle` actually controls. assert_eq!(handle.cycles_driven(), 1); + assert_ne!( + handle.status().state, + super::super::types::ClaimLoopState::CadenceNotElapsed, + "the very first cycle has no prior completion to gate against" + ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( after_tick_1, @@ -1441,16 +1462,28 @@ mod tests { assert_eq!( handle.cycles_driven(), 2, + "the driver's loop iterated a second time" + ); + assert_ne!( + handle.status().state, + super::super::types::ClaimLoopState::CadenceNotElapsed, "#3336: the production body's gate must track the CLAMPED cadence -- a transposed \ - call to with_persisted_fee_window would gate on the raw 5_184_000s cadence instead \ - and this cycle would never run, restoring the no-op gate #3306 fixed" + call to with_persisted_fee_window would gate on the raw 5_184_000s cadence instead, \ + and `run_cycle` would report CadenceNotElapsed here, restoring the no-op gate #3306 \ + fixed. `cycles_driven()` cannot see this: it counts every `drive` loop iteration \ + (sleep-wake-record), including ones the internal gate refused -- the STATE is the \ + only signal a cycle actually ran past the gate." ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( after_tick_2, after_tick_1, "#3336: the fee window must NOT have rolled yet -- only one clamped interval has \ elapsed against its raw 5_184_000s length. A transposed call would roll it here, \ - doubling the operator's configured fee-window count" + doubling the operator's configured fee-window count. This assertion alone cannot \ + distinguish 'gate opened, window correctly held' from 'gate refused, window-roll \ + code never reached' (the gate's early return in `run_cycle` sits before the \ + window-roll block) -- it is only meaningful paired with the state assertion above,\ + which proves the gate did NOT refuse this cycle." ); // Tick 3: two clamped intervals (5_356_800s) since the window opened -- past its raw diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 1b3ba7d5..221acf43 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -11,29 +11,36 @@ use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; -/// The schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s restart-safety gate -/// is measured against -- the same interval [`super::driver::drive`] actually sleeps on. +/// The two cadence values [`ClaimEngine::with_persisted_fee_window`] needs, bundled into ONE +/// value rather than passed as a pair of positional arguments. /// -/// # DIG-Network/dig_ecosystem#3336 (money) -/// A bare `u64` here is interchangeable with [`FeeWindowCadenceSeconds`] at the call site -/// ([`ClaimEngine::with_persisted_fee_window`]) and nothing stops the two from being transposed -- -/// swapping them silently RESTORES the no-op gate DIG-Network/dig_ecosystem#3306 fixed: the gate -/// would be measured against the RAW, unclamped operator cadence instead of the clamped one the -/// scheduler actually runs on, so an operator's 60-day config would gate on 60 days again even -/// though the loop keeps ticking every 31. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct GateCadenceSeconds(pub u64); - -/// The RAW configured cadence, in seconds, that sizes how long the persisted aggregate -/// fee-budget window stays open before rolling -- deliberately never the clamped value. +/// # DIG-Network/dig_ecosystem#3336 (money) -- why one struct, not two newtypes +/// An earlier shape used two distinct single-field newtypes (`GateCadenceSeconds` / +/// `FeeWindowCadenceSeconds`) at two adjacent call-site arguments -- that closed the *positional* +/// swap (transposing them was a compile error) but left the *value* swap open: nothing stopped a +/// caller from writing `GateCadenceSeconds(raw)` / `FeeWindowCadenceSeconds(clamped)` -- still +/// two well-typed newtypes in the right argument slots, just constructed from the wrong sources. +/// That value swap still compiled AND passed every test written against the newtype shape, because +/// nothing about the call site's SHAPE forced the right value into the right name. Bundling the +/// two into one struct with named fields removes the pair of adjacent arguments a swap could +/// target at all -- there is one call-site argument, and each field is written once, next to its +/// own name, not matched positionally against a sibling argument. /// -/// # DIG-Network/dig_ecosystem#3336 (money) -/// Swapping this with [`GateCadenceSeconds`] at the [`ClaimEngine::with_persisted_fee_window`] -/// call site DOUBLES the number of fee-budget windows a long-cadence operator sized (a 60-day -/// config would get ~12 windows/year instead of the ~6 its cadence implies), doubling the fee -/// ceiling they configured. +/// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s +/// restart-safety gate is measured against -- the same interval [`super::driver::drive`] +/// actually sleeps on. Using the RAW value here would restore the no-op gate +/// DIG-Network/dig_ecosystem#3306 fixed: an operator's 60-day config would gate on 60 days +/// again even though the loop keeps ticking every 31. +/// - `fee_window_raw`: the RAW configured cadence, in seconds, that sizes how long the persisted +/// aggregate fee-budget window stays open before rolling -- deliberately never the clamped +/// value. Using the CLAMPED value here doubles the number of fee-budget windows a long-cadence +/// operator sized (a 60-day config would get ~12 windows/year instead of the ~6 its cadence +/// implies), doubling the fee ceiling they configured. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FeeWindowCadenceSeconds(pub u64); +pub struct ClaimCadences { + pub gate_clamped: u64, + pub fee_window_raw: u64, +} /// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] /// and the anti-silence status surface across calls to [`Self::run_cycle`]. @@ -200,22 +207,16 @@ impl ClaimEngine { /// is never even taken. /// /// # DIG-Network/dig_ecosystem#3336 - /// `gate_cadence_seconds` and `fee_window_seconds` are distinct newtypes ([`GateCadenceSeconds`] - /// / [`FeeWindowCadenceSeconds`]), not two bare `u64`s, precisely so transposing them at a call - /// site is a compile error rather than a silent money defect -- see both types' docs. + /// Takes ONE [`ClaimCadences`] value, not two positional arguments -- see that type's doc for + /// why this is stronger than two distinct newtypes at two adjacent call-site slots. #[must_use] - pub fn with_persisted_fee_window( - mut self, - dir: &Path, - gate_cadence_seconds: GateCadenceSeconds, - fee_window_seconds: FeeWindowCadenceSeconds, - ) -> Self { + pub fn with_persisted_fee_window(mut self, dir: &Path, cadences: ClaimCadences) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); - self.gate_cadence_seconds = gate_cadence_seconds - .0 + self.gate_cadence_seconds = cadences + .gate_clamped .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); - self.fee_window_seconds = fee_window_seconds - .0 + self.fee_window_seconds = cadences + .fee_window_raw .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -1807,8 +1808,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -1836,8 +1839,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let second_outcomes = second.run_cycle(1_010).await; @@ -1879,8 +1884,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes @@ -1923,8 +1930,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -1950,8 +1959,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let second_outcomes = second.run_cycle(later).await; @@ -1987,8 +1998,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -2008,8 +2021,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let second_outcomes = second.run_cycle(1_050).await; @@ -2048,8 +2063,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let outcomes = e.run_cycle(1_000).await; @@ -2101,8 +2118,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let outcomes = e.run_cycle(1_000).await; @@ -2163,8 +2182,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the @@ -2207,8 +2228,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -2233,8 +2256,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let second_outcomes = second.run_cycle(1_010).await; @@ -2289,8 +2314,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, @@ -2366,8 +2393,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); // Cycle 1: the file is corrupt -- must refuse, submit nothing. @@ -2442,8 +2471,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let outcomes = e.run_cycle(1_000).await; @@ -2494,8 +2525,10 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - GateCadenceSeconds(CADENCE_SECONDS), - FeeWindowCadenceSeconds(CADENCE_SECONDS), + ClaimCadences { + gate_clamped: CADENCE_SECONDS, + fee_window_raw: CADENCE_SECONDS, + }, ); let outcomes = e.run_cycle(1_000).await; From 4d4cf986bf35493273018b278fd514f29c11d2a0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 16 Sep 2026 21:19:59 -0700 Subject: [PATCH 06/12] fix(rewards-claim): make the clamped gate cadence unconstructible except by its clamp DIG-Network/dig_ecosystem#3336: `ClaimCadences { gate_clamped: u64, fee_window_raw: u64 }` closed the positional swap between the two cadence values but left the value swap open -- both fields were plain u64, so nothing stopped a caller writing the RAW configured cadence into `gate_clamped` and the CLAMPED one into `fee_window_raw`. `ClampedGateCadence`'s inner field is now private with `ClampedGateCadence::clamp(FeeWindowCadenceSeconds) -> Self` as its only constructor, so a raw value has no path into the gate slot without going through the clamp. `FeeWindowCadenceSeconds` wraps the raw side so the two are no longer interchangeable plain integers. --- .../src/rewards_claim/driver.rs | 28 ++-- .../src/rewards_claim/engine.rs | 127 +++++++++++------- 2 files changed, 96 insertions(+), 59 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index b21335c8..ffd18602 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -38,7 +38,7 @@ use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::{ClaimCadences, ClaimEngine}; +use super::engine::{ClaimCadences, ClaimEngine, ClampedGateCadence, FeeWindowCadenceSeconds}; use super::hints::{DistributorHintSource, NoHintSource}; use super::port::{ClaimChainPort, UnavailableClaimChainPort}; use super::types::{ClaimLoopState, ClaimStatus}; @@ -335,7 +335,7 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// this ticket: the claim loop never fires again, so no cycle, no `log_cycle` line, and the /// cycle counter reads a permanent, reassuring `0`. #594 shipped an engine that was inert and /// green; a config value must not be able to put this driver back in that state silently. -const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; +pub(crate) const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; /// [`sanitized_schedule`]'s call-scoped report of what it changed, threaded into [`drive`] and /// on into [`log_cycle`] -- NEVER stored on [`ClaimEngine`] as a field (see this module's SHAPE @@ -511,8 +511,8 @@ async fn run_claim_driver_in_with_clock

( .with_persisted_fee_window( state_dir, ClaimCadences { - gate_clamped: cadence_seconds, - fee_window_raw: cfg.cadence_seconds, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cadence_seconds)), + fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); @@ -1037,8 +1037,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: cfg.cadence_seconds, - fee_window_raw: cfg.cadence_seconds, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cfg.cadence_seconds)), + fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); @@ -1090,8 +1090,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: effective_cadence, - fee_window_raw: configured_cadence, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(effective_cadence)), + fee_window_raw: FeeWindowCadenceSeconds(configured_cadence), }, ); @@ -1195,8 +1195,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: cfg.cadence_seconds, - fee_window_raw: cfg.cadence_seconds, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cfg.cadence_seconds)), + fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); @@ -1229,8 +1229,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: cfg.cadence_seconds, - fee_window_raw: cfg.cadence_seconds, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cfg.cadence_seconds)), + fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); @@ -1267,8 +1267,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: 1_000, - fee_window_raw: 1_000, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(1_000)), + fee_window_raw: FeeWindowCadenceSeconds(1_000), }, ); diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 221acf43..85353359 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -14,17 +14,16 @@ use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; /// The two cadence values [`ClaimEngine::with_persisted_fee_window`] needs, bundled into ONE /// value rather than passed as a pair of positional arguments. /// -/// # DIG-Network/dig_ecosystem#3336 (money) -- why one struct, not two newtypes -/// An earlier shape used two distinct single-field newtypes (`GateCadenceSeconds` / -/// `FeeWindowCadenceSeconds`) at two adjacent call-site arguments -- that closed the *positional* -/// swap (transposing them was a compile error) but left the *value* swap open: nothing stopped a -/// caller from writing `GateCadenceSeconds(raw)` / `FeeWindowCadenceSeconds(clamped)` -- still -/// two well-typed newtypes in the right argument slots, just constructed from the wrong sources. -/// That value swap still compiled AND passed every test written against the newtype shape, because -/// nothing about the call site's SHAPE forced the right value into the right name. Bundling the -/// two into one struct with named fields removes the pair of adjacent arguments a swap could -/// target at all -- there is one call-site argument, and each field is written once, next to its -/// own name, not matched positionally against a sibling argument. +/// # DIG-Network/dig_ecosystem#3336 (money) -- why one struct, AND why its fields are typed +/// An earlier shape used two distinct single-field newtypes at two adjacent call-site arguments +/// -- that closed the *positional* swap (transposing them was a compile error) but left the +/// *value* swap open: both newtypes wrapped a plain `u64`, so nothing stopped a caller +/// constructing the gate's newtype from the raw source and the window's newtype from the clamped +/// one. Bundling the two into one struct with named fields (this pass) closed the positional +/// hazard again, but plain `u64` fields still let the wrong VALUE reach either name. Typing the +/// fields as [`ClampedGateCadence`] / [`FeeWindowCadenceSeconds`] closes that too: +/// `ClampedGateCadence`'s field is private and [`ClampedGateCadence::clamp`] is its only +/// constructor, so a raw value has no path into `gate_clamped` at all -- see that type's doc. /// /// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s /// restart-safety gate is measured against -- the same interval [`super::driver::drive`] @@ -38,8 +37,44 @@ use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; /// implies), doubling the fee ceiling they configured. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ClaimCadences { - pub gate_clamped: u64, - pub fee_window_raw: u64, + pub gate_clamped: ClampedGateCadence, + pub fee_window_raw: FeeWindowCadenceSeconds, +} + +/// The RAW, operator-writable cadence in seconds (`RewardsClaimConfig::cadence_seconds` as +/// persisted) -- unbounded above. This is the source [`ClampedGateCadence::clamp`] clamps FROM; +/// it is never itself a value the restart-safety gate should be measured against. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FeeWindowCadenceSeconds(pub u64); + +/// The schedule-CLAMPED cadence (bounded to [`super::driver::CLAIM_SCHEDULE_SECONDS_MAX`]) that +/// [`ClaimEngine::run_cycle`]'s restart-safety gate is measured against -- the same interval +/// [`super::driver::drive`] actually sleeps on. +/// +/// # DIG-Network/dig_ecosystem#3336 -- why the field is private +/// The earlier `ClaimCadences { gate_clamped: u64, fee_window_raw: u64 }` shape closed the +/// *positional* swap but left the *value* swap open: nothing stopped a caller writing the RAW +/// cadence into `gate_clamped` and the CLAMPED one into `fee_window_raw` -- both fields were the +/// same type, so the wrong assignment still compiled and passed every test written against that +/// shape. Making the field here private removes that path entirely: [`Self::clamp`] is the ONLY +/// way to produce a `ClampedGateCadence`, and it always applies the clamp, so there is no value a +/// caller can hand this slot that skips it. A raw value literally has no constructor to reach +/// this type through. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClampedGateCadence(u64); + +impl ClampedGateCadence { + /// The ONLY constructor. Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a + /// 60-day raw cadence in yields the 31-day ceiling out, the same bound + /// [`super::driver::sanitized_schedule`] applies at the config read. + #[must_use] + pub fn clamp(raw: FeeWindowCadenceSeconds) -> Self { + ClampedGateCadence(raw.0.min(super::driver::CLAIM_SCHEDULE_SECONDS_MAX)) + } + + fn seconds(self) -> u64 { + self.0 + } } /// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] @@ -214,9 +249,11 @@ impl ClaimEngine { self.fee_window_state_dir = Some(dir.to_path_buf()); self.gate_cadence_seconds = cadences .gate_clamped + .seconds() .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self.fee_window_seconds = cadences .fee_window_raw + .0 .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -1809,8 +1846,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let first_outcomes = first.run_cycle(1_000).await; @@ -1840,8 +1877,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let second_outcomes = second.run_cycle(1_010).await; @@ -1885,8 +1922,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; @@ -1931,8 +1968,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let first_outcomes = first.run_cycle(1_000).await; @@ -1960,8 +1997,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let second_outcomes = second.run_cycle(later).await; @@ -1999,8 +2036,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let first_outcomes = first.run_cycle(1_000).await; @@ -2022,8 +2059,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let second_outcomes = second.run_cycle(1_050).await; @@ -2064,8 +2101,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); @@ -2119,8 +2156,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); @@ -2183,8 +2220,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); @@ -2229,8 +2266,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let first_outcomes = first.run_cycle(1_000).await; @@ -2257,8 +2294,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); let second_outcomes = second.run_cycle(1_010).await; @@ -2315,8 +2352,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); @@ -2394,8 +2431,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); @@ -2472,8 +2509,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); @@ -2526,8 +2563,8 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: CADENCE_SECONDS, - fee_window_raw: CADENCE_SECONDS, + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); From 68f4101d5e9a1d8f5ae4cef94450acea96360adb Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 06:05:52 -0700 Subject: [PATCH 07/12] style(rewards-claim): cargo fmt Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-service/src/rewards_claim/driver.rs | 12 +++++++++--- crates/dig-node-service/src/rewards_claim/engine.rs | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index ffd18602..9716f65e 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1037,7 +1037,9 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cfg.cadence_seconds)), + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( + cfg.cadence_seconds, + )), fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); @@ -1195,7 +1197,9 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cfg.cadence_seconds)), + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( + cfg.cadence_seconds, + )), fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); @@ -1229,7 +1233,9 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cfg.cadence_seconds)), + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( + cfg.cadence_seconds, + )), fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), }, ); diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 85353359..429333da 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -1922,7 +1922,9 @@ mod tests { .with_persisted_fee_window( dir.path(), ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), + gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( + CADENCE_SECONDS, + )), fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), }, ); From 2bb0a5d9e3b0c2f2d3ce66eb1fa22f0358a186f4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 09:03:15 -0700 Subject: [PATCH 08/12] refactor(rewards-claim): derive the gate cadence from one raw config value Closes the remaining half of the #3336 transposition hole structurally rather than by test. `ClaimCadences` now has private fields and a single constructor, `ClaimCadences::from_raw`, which derives `gate_clamped` from the same raw value it stores as `fee_window_raw`. The production call site passes ONE value, so there is no longer a pair to transpose or a `pub` field to write the wrong local into. Behaviour-identical: `CLAIM_CADENCE_FLOOR_SECONDS` is 60 and `config.rs` floors any smaller configured cadence at load, so `sanitized_schedule`'s zero branch is unreachable from this path and its output equals `min(cadence, MAX)` -- exactly what the clamp computes. Also renames `FeeWindowCadenceSeconds` to `RawConfiguredCadence` (it is the clamp's input type, not only the window's), and replaces the tick-1/tick-2 `assert_ne!(state, CadenceNotElapsed)` exclusions with exact-state assertions -- the exclusion was equally satisfied by PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above the window-roll block, so it would have gone vacuous the moment either fired. Refs DIG-Network/dig_ecosystem#3336 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rewards_claim/driver.rs | 68 ++----- .../src/rewards_claim/engine.rs | 166 +++++++----------- 2 files changed, 82 insertions(+), 152 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 9716f65e..34aa7430 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -38,7 +38,7 @@ use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::{ClaimCadences, ClaimEngine, ClampedGateCadence, FeeWindowCadenceSeconds}; +use super::engine::{ClaimCadences, ClaimEngine, RawConfiguredCadence}; use super::hints::{DistributorHintSource, NoHintSource}; use super::port::{ClaimChainPort, UnavailableClaimChainPort}; use super::types::{ClaimLoopState, ClaimStatus}; @@ -510,10 +510,7 @@ async fn run_claim_driver_in_with_clock

( // from ever running while the scheduler keeps ticking on the clamped interval). .with_persisted_fee_window( state_dir, - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(cadence_seconds)), - fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), - }, + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), ); drive( @@ -1036,12 +1033,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( - cfg.cadence_seconds, - )), - fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), - }, + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), ); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 @@ -1091,10 +1083,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(effective_cadence)), - fee_window_raw: FeeWindowCadenceSeconds(configured_cadence), - }, + ClaimCadences::from_raw(RawConfiguredCadence(configured_cadence)), ); let handle = ClaimLoopHandle::default(); @@ -1126,10 +1115,10 @@ mod tests { tokio::time::advance(Duration::from_secs(effective_cadence)).await; settle().await; assert_eq!(handle.cycles_driven(), 1); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "the very first cycle has no prior completion to gate against" + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to completion \n and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1149,11 +1138,10 @@ mod tests { "F2: the gate must track the CLAMPED cadence -- a cycle sized from the raw 5_184_000 \ cadence would still be refused here, reproducing the silent-non-claiming defect" ); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "F2: the gate opened one clamped interval after the last completion -- it must not \ - still be waiting on the raw 5_184_000s cadence" + super::super::types::ClaimLoopState::Nominal, + "F2: the gate opened one clamped interval after the last completion -- it must not still be \n waiting on the raw 5_184_000s cadence. Asserting the exact state, not merely \n `!= CadenceNotElapsed`: that exclusion is equally satisfied by PersistedStateCorrupt \n and ChainSourceUnavailable, whose early returns sit above the window-roll block too, \n so it would go vacuous the moment one of those fired instead" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1196,12 +1184,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( - cfg.cadence_seconds, - )), - fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), - }, + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), ); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 @@ -1232,12 +1215,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( - cfg.cadence_seconds, - )), - fee_window_raw: FeeWindowCadenceSeconds(cfg.cadence_seconds), - }, + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), ); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at @@ -1272,10 +1250,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(1_000)), - fee_window_raw: FeeWindowCadenceSeconds(1_000), - }, + ClaimCadences::from_raw(RawConfiguredCadence(1_000)), ); let outcomes = engine.run_cycle(1).await; @@ -1448,10 +1423,10 @@ mod tests { // internal gate REFUSED (`ClaimLoopState::CadenceNotElapsed`) still increments it. Assert // on the reported STATE, which the gate's early `return` in `run_cycle` actually controls. assert_eq!(handle.cycles_driven(), 1); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "the very first cycle has no prior completion to gate against" + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to completion \n and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1470,15 +1445,10 @@ mod tests { 2, "the driver's loop iterated a second time" ); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "#3336: the production body's gate must track the CLAMPED cadence -- a transposed \ - call to with_persisted_fee_window would gate on the raw 5_184_000s cadence instead, \ - and `run_cycle` would report CadenceNotElapsed here, restoring the no-op gate #3306 \ - fixed. `cycles_driven()` cannot see this: it counts every `drive` loop iteration \ - (sleep-wake-record), including ones the internal gate refused -- the STATE is the \ - only signal a cycle actually ran past the gate." + super::super::types::ClaimLoopState::Nominal, + "#3336: the production body's gate must track the CLAMPED cadence -- a transposed call to \n with_persisted_fee_window would gate on the raw 5_184_000s cadence and report \n CadenceNotElapsed here, restoring the no-op gate #3306 fixed. Asserting the exact \n state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \n PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \n the window-roll block, so it would go vacuous the moment one of those fired instead. \n `cycles_driven()` cannot see any of this: it counts every drive loop iteration, \n including ones the internal gate refused" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 429333da..19aede7a 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -11,64 +11,69 @@ use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; -/// The two cadence values [`ClaimEngine::with_persisted_fee_window`] needs, bundled into ONE -/// value rather than passed as a pair of positional arguments. +/// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the +/// single raw configured value they both come from. /// -/// # DIG-Network/dig_ecosystem#3336 (money) -- why one struct, AND why its fields are typed -/// An earlier shape used two distinct single-field newtypes at two adjacent call-site arguments -/// -- that closed the *positional* swap (transposing them was a compile error) but left the -/// *value* swap open: both newtypes wrapped a plain `u64`, so nothing stopped a caller -/// constructing the gate's newtype from the raw source and the window's newtype from the clamped -/// one. Bundling the two into one struct with named fields (this pass) closed the positional -/// hazard again, but plain `u64` fields still let the wrong VALUE reach either name. Typing the -/// fields as [`ClampedGateCadence`] / [`FeeWindowCadenceSeconds`] closes that too: -/// `ClampedGateCadence`'s field is private and [`ClampedGateCadence::clamp`] is its only -/// constructor, so a raw value has no path into `gate_clamped` at all -- see that type's doc. +/// # DIG-Network/dig_ecosystem#3336 (money) -- why one value, constructed one way +/// Earlier shapes handed the engine two numbers the CALLER had already chosen: first two bare +/// `u64` arguments, then two distinct newtypes, then this struct with two typed fields. Each +/// closed the *positional* swap and left the *value* swap open -- the caller still decided which +/// number went into the fee-window slot, and writing the CLAMPED value there compiled and halved +/// the fee window an operator configured. [`Self::from_raw`] removes the choice: the caller hands +/// over the raw configured cadence once, and the clamp is applied here, on this side of the +/// boundary. Both fields are private, so a struct literal is not an alternative path to them +/// from outside this module. /// /// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s /// restart-safety gate is measured against -- the same interval [`super::driver::drive`] -/// actually sleeps on. Using the RAW value here would restore the no-op gate +/// actually sleeps on. The RAW value here would restore the no-op gate /// DIG-Network/dig_ecosystem#3306 fixed: an operator's 60-day config would gate on 60 days /// again even though the loop keeps ticking every 31. /// - `fee_window_raw`: the RAW configured cadence, in seconds, that sizes how long the persisted /// aggregate fee-budget window stays open before rolling -- deliberately never the clamped -/// value. Using the CLAMPED value here doubles the number of fee-budget windows a long-cadence +/// value. The CLAMPED value here doubles the number of fee-budget windows a long-cadence /// operator sized (a 60-day config would get ~12 windows/year instead of the ~6 its cadence /// implies), doubling the fee ceiling they configured. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClaimCadences { - pub gate_clamped: ClampedGateCadence, - pub fee_window_raw: FeeWindowCadenceSeconds, +pub(crate) struct ClaimCadences { + gate_clamped: ClampedGateCadence, + fee_window_raw: RawConfiguredCadence, +} + +impl ClaimCadences { + /// The ONLY constructor: the gate cadence is derived from the window's own raw source, so the + /// two can never be paired with each other's value. + #[must_use] + pub(crate) fn from_raw(raw: RawConfiguredCadence) -> Self { + Self { + gate_clamped: ClampedGateCadence::clamp(raw), + fee_window_raw: raw, + } + } } /// The RAW, operator-writable cadence in seconds (`RewardsClaimConfig::cadence_seconds` as -/// persisted) -- unbounded above. This is the source [`ClampedGateCadence::clamp`] clamps FROM; -/// it is never itself a value the restart-safety gate should be measured against. +/// persisted) -- unbounded above. The single input [`ClaimCadences::from_raw`] takes: it sizes +/// the fee window directly and, through [`ClampedGateCadence::clamp`], the gate as well. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FeeWindowCadenceSeconds(pub u64); +pub(crate) struct RawConfiguredCadence(pub(crate) u64); /// The schedule-CLAMPED cadence (bounded to [`super::driver::CLAIM_SCHEDULE_SECONDS_MAX`]) that /// [`ClaimEngine::run_cycle`]'s restart-safety gate is measured against -- the same interval /// [`super::driver::drive`] actually sleeps on. /// /// # DIG-Network/dig_ecosystem#3336 -- why the field is private -/// The earlier `ClaimCadences { gate_clamped: u64, fee_window_raw: u64 }` shape closed the -/// *positional* swap but left the *value* swap open: nothing stopped a caller writing the RAW -/// cadence into `gate_clamped` and the CLAMPED one into `fee_window_raw` -- both fields were the -/// same type, so the wrong assignment still compiled and passed every test written against that -/// shape. Making the field here private removes that path entirely: [`Self::clamp`] is the ONLY -/// way to produce a `ClampedGateCadence`, and it always applies the clamp, so there is no value a -/// caller can hand this slot that skips it. A raw value literally has no constructor to reach -/// this type through. +/// [`Self::clamp`] is the only way to produce this type, it is private to this module, and it +/// always applies the bound. So the only cadence that can reach the gate is one this module +/// clamped, and the only caller of `clamp` is [`ClaimCadences::from_raw`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClampedGateCadence(u64); +pub(crate) struct ClampedGateCadence(u64); impl ClampedGateCadence { - /// The ONLY constructor. Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a - /// 60-day raw cadence in yields the 31-day ceiling out, the same bound - /// [`super::driver::sanitized_schedule`] applies at the config read. - #[must_use] - pub fn clamp(raw: FeeWindowCadenceSeconds) -> Self { + /// Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a 60-day raw cadence in + /// yields the 31-day ceiling out, the same bound [`super::driver::sanitized_schedule`] + /// applies at the config read, so the gate tracks the schedule the driver really sleeps on. + fn clamp(raw: RawConfiguredCadence) -> Self { ClampedGateCadence(raw.0.min(super::driver::CLAIM_SCHEDULE_SECONDS_MAX)) } @@ -242,10 +247,15 @@ impl ClaimEngine { /// is never even taken. /// /// # DIG-Network/dig_ecosystem#3336 - /// Takes ONE [`ClaimCadences`] value, not two positional arguments -- see that type's doc for - /// why this is stronger than two distinct newtypes at two adjacent call-site slots. + /// Takes ONE [`ClaimCadences`], which the caller can only build with + /// [`ClaimCadences::from_raw`] -- so the gate cadence and the fee-window cadence are derived + /// here, together, from the one raw config value, rather than chosen at the call site. + /// + /// `pub(crate)`, not `pub`: [`ClaimCadences`] is crate-private (it is the argument type, so a + /// `pub` method taking it would be uncallable from outside anyway), and no out-of-crate + /// caller exists. #[must_use] - pub fn with_persisted_fee_window(mut self, dir: &Path, cadences: ClaimCadences) -> Self { + pub(crate) fn with_persisted_fee_window(mut self, dir: &Path, cadences: ClaimCadences) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); self.gate_cadence_seconds = cadences .gate_clamped @@ -1845,10 +1855,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -1876,10 +1883,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let second_outcomes = second.run_cycle(1_010).await; @@ -1921,12 +1925,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds( - CADENCE_SECONDS, - )), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes @@ -1969,10 +1968,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -1998,10 +1994,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let second_outcomes = second.run_cycle(later).await; @@ -2037,10 +2030,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -2060,10 +2050,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let second_outcomes = second.run_cycle(1_050).await; @@ -2102,10 +2089,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let outcomes = e.run_cycle(1_000).await; @@ -2157,10 +2141,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let outcomes = e.run_cycle(1_000).await; @@ -2221,10 +2202,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the @@ -2267,10 +2245,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( @@ -2295,10 +2270,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let second_outcomes = second.run_cycle(1_010).await; @@ -2353,10 +2325,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, @@ -2432,10 +2401,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); // Cycle 1: the file is corrupt -- must refuse, submit nothing. @@ -2510,10 +2476,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let outcomes = e.run_cycle(1_000).await; @@ -2564,10 +2527,7 @@ mod tests { ) .with_persisted_fee_window( dir.path(), - ClaimCadences { - gate_clamped: ClampedGateCadence::clamp(FeeWindowCadenceSeconds(CADENCE_SECONDS)), - fee_window_raw: FeeWindowCadenceSeconds(CADENCE_SECONDS), - }, + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), ); let outcomes = e.run_cycle(1_000).await; From 80a263260fc1822a03fbaad384c9d3d0e964a197 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 14:24:09 -0700 Subject: [PATCH 09/12] style(rewards-claim): restore string line continuations in claim-driver assertions Convert literal backslash-n escapes with trailing spaces back to proper Rust string line continuations (trailing backslash at end of line). The strings render as single sentences without artificial newlines or multi-space runs. Fixes continuation_guard. Co-Authored-By: Claude Haiku 4.5 --- .../src/rewards_claim/driver.rs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 34aa7430..b1a8b18a 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1118,7 +1118,8 @@ mod tests { assert_eq!( handle.status().state, super::super::types::ClaimLoopState::Nominal, - "the very first cycle has no prior completion to gate against, so it must run to completion \n and report Nominal" + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1141,7 +1142,11 @@ mod tests { assert_eq!( handle.status().state, super::super::types::ClaimLoopState::Nominal, - "F2: the gate opened one clamped interval after the last completion -- it must not still be \n waiting on the raw 5_184_000s cadence. Asserting the exact state, not merely \n `!= CadenceNotElapsed`: that exclusion is equally satisfied by PersistedStateCorrupt \n and ChainSourceUnavailable, whose early returns sit above the window-roll block too, \n so it would go vacuous the moment one of those fired instead" + "F2: the gate opened one clamped interval after the last completion -- it must not \ + still be waiting on the raw 5_184_000s cadence. Asserting the exact state, not \ + merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns sit above the \ + window-roll block too, so it would go vacuous the moment one of those fired instead" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1426,7 +1431,8 @@ mod tests { assert_eq!( handle.status().state, super::super::types::ClaimLoopState::Nominal, - "the very first cycle has no prior completion to gate against, so it must run to completion \n and report Nominal" + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1448,7 +1454,14 @@ mod tests { assert_eq!( handle.status().state, super::super::types::ClaimLoopState::Nominal, - "#3336: the production body's gate must track the CLAMPED cadence -- a transposed call to \n with_persisted_fee_window would gate on the raw 5_184_000s cadence and report \n CadenceNotElapsed here, restoring the no-op gate #3306 fixed. Asserting the exact \n state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \n PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \n the window-roll block, so it would go vacuous the moment one of those fired instead. \n `cycles_driven()` cannot see any of this: it counts every drive loop iteration, \n including ones the internal gate refused" + "#3336: the production body's gate must track the CLAMPED cadence -- a transposed call to \ + with_persisted_fee_window would gate on the raw 5_184_000s cadence and report \ + CadenceNotElapsed here, restoring the no-op gate #3306 fixed. Asserting the exact \ + state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \ + the window-roll block, so it would go vacuous the moment one of those fired instead. \ + `cycles_driven()` cannot see any of this: it counts every drive loop iteration, \ + including ones the internal gate refused" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( From 97450a0b52b10020cb3ce3a0194a8ed31492652c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 19:19:31 -0700 Subject: [PATCH 10/12] docs(rewards-claim): separate the type-enforced half from the test-guarded half The #3336 doc comments asserted a type-level guarantee the code does not deliver. `ClaimCadences::from_raw` does close the pairing mutation -- the gate and the fee window cannot disagree with each other, because `from_raw` derives `gate_clamped` from the same `RawConfiguredCadence` it stores as `fee_window_raw`, and it is the only constructor. It does NOT close the value mutation: `from_raw(RawConfiguredCadence(cadence_seconds))`, the already-clamped local instead of `cfg.cadence_seconds`, has the same type and compiles. Measured consequence: the fee window halves -- the tick-2 assertion fails with left Some(5356800), right Some(2678400). That mutation is caught by exactly one test, `the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`. The other money test stays green under it, because it hand-assembles `drive` and never traverses the production call site. The old docs told the next reader the value swap was type-closed -- the exact sentence that would be cited to delete the one guarding test. Also corrects the stale "transposing the two arguments" description: the call takes ONE argument, so that mutation cannot be written at all; and drops the claim that the surviving mutation also breaks the gate (clamping an already-clamped value is the identity, so it does not). Docs only -- no behaviour, no type, and no assertion changed. Refs DIG-Network/dig_ecosystem#3336 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rewards_claim/driver.rs | 31 ++++++++----- .../src/rewards_claim/engine.rs | 43 ++++++++++++------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index b1a8b18a..b9a0b428 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1382,11 +1382,19 @@ mod tests { /// proves the gate/window split by hand-assembling `drive` directly. This test proves the SAME /// property through [`run_claim_driver_in_with_clock`] -- the actual production body, the one /// that threads `sanitized_schedule`'s CLAMPED cadence and the RAW `cfg.cadence_seconds` into - /// [`ClaimEngine::with_persisted_fee_window`] at driver.rs's one call site. Transposing the two - /// arguments there (the #3336 defect) is invisible to a hand-assembled `drive` test but would - /// flip both counts this test asserts: the fee window would roll at tick 2 instead of tick 3 - /// (doubling the window count -- the "doubles the fee ceiling" half of the defect), and/or the - /// gate would stop opening every clamped tick (the "restores the no-op gate #3306 fixed" half). + /// [`ClaimEngine::with_persisted_fee_window`] at driver.rs's one call site. + /// + /// THIS IS THE ONLY TEST THAT CATCHES THE ONE MUTATION STILL LEFT AT THAT CALL SITE. The call + /// takes a single argument now, so the historic two-argument transposition cannot be written + /// at all. What still compiles is passing the already-clamped local as the raw value -- + /// `ClaimCadences::from_raw(RawConfiguredCadence(cadence_seconds))`. Measured: the fee window + /// then rolls at tick 2 instead of tick 3, failing the tick-2 assertion below with + /// `left: Some(5356800)`, `right: Some(2678400)` -- half the window length, so 2x the + /// fee-budget windows the operator sized. The gate is NOT affected (clamping an + /// already-clamped value is the identity), and + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] stays GREEN + /// under that mutation, because it hand-assembles `drive` and never traverses the production + /// call site. /// /// Uses a written config with a 60-day RAW cadence (`5_184_000`s, clamped to the 31-day /// `CLAIM_SCHEDULE_SECONDS_MAX`, `2_678_400`s) and a clock that advances one clamped interval @@ -1454,9 +1462,9 @@ mod tests { assert_eq!( handle.status().state, super::super::types::ClaimLoopState::Nominal, - "#3336: the production body's gate must track the CLAMPED cadence -- a transposed call to \ - with_persisted_fee_window would gate on the raw 5_184_000s cadence and report \ - CadenceNotElapsed here, restoring the no-op gate #3306 fixed. Asserting the exact \ + "#3336: the production body's gate must track the CLAMPED cadence -- a body that gated \ + on the raw 5_184_000s cadence would report CadenceNotElapsed here, restoring the \ + no-op gate #3306 fixed. Asserting the exact \ state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \ the window-roll block, so it would go vacuous the moment one of those fired instead. \ @@ -1467,11 +1475,12 @@ mod tests { assert_eq!( after_tick_2, after_tick_1, "#3336: the fee window must NOT have rolled yet -- only one clamped interval has \ - elapsed against its raw 5_184_000s length. A transposed call would roll it here, \ - doubling the operator's configured fee-window count. This assertion alone cannot \ + elapsed against its raw 5_184_000s length. Sizing the window off the CLAMPED value \ + instead rolls it here (left Some(5356800), right Some(2678400)), halving the window \ + and doubling the operator's configured fee-window count. This assertion alone cannot \ distinguish 'gate opened, window correctly held' from 'gate refused, window-roll \ code never reached' (the gate's early return in `run_cycle` sits before the \ - window-roll block) -- it is only meaningful paired with the state assertion above,\ + window-roll block) -- it is only meaningful paired with the state assertion above, \ which proves the gate did NOT refuse this cycle." ); diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 19aede7a..a638df80 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -14,15 +14,24 @@ use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; /// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the /// single raw configured value they both come from. /// -/// # DIG-Network/dig_ecosystem#3336 (money) -- why one value, constructed one way +/// # DIG-Network/dig_ecosystem#3336 (money) -- what this shape closes, and what it does not /// Earlier shapes handed the engine two numbers the CALLER had already chosen: first two bare -/// `u64` arguments, then two distinct newtypes, then this struct with two typed fields. Each -/// closed the *positional* swap and left the *value* swap open -- the caller still decided which -/// number went into the fee-window slot, and writing the CLAMPED value there compiled and halved -/// the fee window an operator configured. [`Self::from_raw`] removes the choice: the caller hands -/// over the raw configured cadence once, and the clamp is applied here, on this side of the -/// boundary. Both fields are private, so a struct literal is not an alternative path to them -/// from outside this module. +/// `u64` arguments, then two distinct newtypes, then this struct with two typed fields. +/// +/// TYPE-ENFORCED: the gate and the fee window cannot disagree with EACH OTHER. +/// [`Self::from_raw`] is the only constructor, both fields are private (so a struct literal is +/// not an alternative path from outside this module), and it derives `gate_clamped` by clamping +/// the very [`RawConfiguredCadence`] it stores as `fee_window_raw`. There is no pairing in which +/// the window sizes off one number and the gate off another. +/// +/// NOT type-enforced, and no type here can be: WHICH `u64` the call site labels raw. +/// `from_raw(RawConfiguredCadence(cadence_seconds))` -- the already-clamped local instead of +/// `cfg.cadence_seconds` -- has the same type and compiles. It halves the fee window: measured, +/// the window rolls one tick early, `Some(5356800)` becoming `Some(2678400)`, exactly 2x the +/// number of fee-budget windows the operator sized. Exactly ONE test catches that, and it is +/// `driver::tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`, which +/// drives the production call site. Do not delete it on the belief that a type stands behind it +/// -- nothing does. /// /// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s /// restart-safety gate is measured against -- the same interval [`super::driver::drive`] @@ -41,8 +50,10 @@ pub(crate) struct ClaimCadences { } impl ClaimCadences { - /// The ONLY constructor: the gate cadence is derived from the window's own raw source, so the - /// two can never be paired with each other's value. + /// The ONLY constructor. The gate cadence is derived from the window's own raw source, so the + /// two can never be paired with each other's value -- that much the types enforce. What + /// nothing here enforces is that `raw` really is the raw configured value; see the + /// [`ClaimCadences`] #3336 section for the single test that does. #[must_use] pub(crate) fn from_raw(raw: RawConfiguredCadence) -> Self { Self { @@ -63,9 +74,10 @@ pub(crate) struct RawConfiguredCadence(pub(crate) u64); /// [`super::driver::drive`] actually sleeps on. /// /// # DIG-Network/dig_ecosystem#3336 -- why the field is private -/// [`Self::clamp`] is the only way to produce this type, it is private to this module, and it -/// always applies the bound. So the only cadence that can reach the gate is one this module -/// clamped, and the only caller of `clamp` is [`ClaimCadences::from_raw`]. +/// [`Self::clamp`] is the only way to produce this type, it is private to this module, it always +/// applies the bound, and the only caller of `clamp` is [`ClaimCadences::from_raw`]. So every +/// number that reaches the gate has been through the clamp -- which bounds its MAGNITUDE and +/// nothing else. It is not evidence about where the number came from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ClampedGateCadence(u64); @@ -248,8 +260,9 @@ impl ClaimEngine { /// /// # DIG-Network/dig_ecosystem#3336 /// Takes ONE [`ClaimCadences`], which the caller can only build with - /// [`ClaimCadences::from_raw`] -- so the gate cadence and the fee-window cadence are derived - /// here, together, from the one raw config value, rather than chosen at the call site. + /// [`ClaimCadences::from_raw`] -- so the two cadences are derived together, from one value, + /// and cannot contradict each other. WHICH value that is is still the call site's choice, and + /// is test-guarded only; see the [`ClaimCadences`] #3336 section. /// /// `pub(crate)`, not `pub`: [`ClaimCadences`] is crate-private (it is the argument type, so a /// `pub` method taking it would be uncallable from outside anyway), and no out-of-crate From 6c1a01d592726d0883b881bc9e7d0cbfbab0e3b6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 19:53:52 -0700 Subject: [PATCH 11/12] docs(rewards-claim): remove the two-argument F2 doc block and name the clamp precondition Doc comments only; no code, assertion, type or visibility changed. The test count is unchanged (92 passed, 0 failed). - engine.rs: delete the `with_persisted_fee_window` "F2 (money): takes TWO cadence values, deliberately not one" block. The method takes ONE `ClaimCadences`; those two names are private fields, not parameters, and "deliberately not one" instructed the next author to restore the two-`u64` signature this PR makes unwritable -- twenty lines above the #3336 section that contradicts it. - engine.rs: `ClampedGateCadence::clamp` no longer claims flat equivalence with `sanitized_schedule`. It reproduces only that function's clamp arm, not its zero-substitution arm; they agree because a zero cannot reach it, and that precondition is now named. - driver.rs: the surviving production-call-site comment describes the one-argument call it sits above, and names the single test that catches passing the clamped local instead of the raw config value. - driver.rs: the sibling money test now records that it stays GREEN under that mutation, so its doc cannot be cited to delete the test that does catch it. Refs DIG-Network/dig_ecosystem#3336 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rewards_claim/driver.rs | 24 ++++++++++++------- .../src/rewards_claim/engine.rs | 23 +++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index b9a0b428..d7019c72 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -499,15 +499,15 @@ async fn run_claim_driver_in_with_clock

( dig_mirror_coin::DIG_ASSET_ID, ) .with_rotation_cursor(cfg.rotation_cursor) - // F2: two DIFFERENT cadence values, deliberately -- `cadence_seconds` (CLAMPED, already - // bounded to `CLAIM_SCHEDULE_SECONDS_MAX`) gates WHEN a cycle may run, tracking the same - // schedule the driver below actually sleeps on. `cfg.cadence_seconds` (RAW, unclamped) sizes - // the persisted fee-budget window -- reusing the clamped value there would double the number - // of budget windows a long-cadence operator sized (a 60-day config would get ~12 windows/year - // instead of the ~6 its cadence implies -- 2x the fee ceiling they configured). Conflating the - // two into one value in either direction is wrong: clamped-for-both doubles the fee ceiling, - // raw-for-both can silently starve the gate (an unbounded-above raw cadence would stop cycles - // from ever running while the scheduler keeps ticking on the clamped interval). + // F2 (money): ONE argument, and it must be the RAW `cfg.cadence_seconds`. `ClaimCadences` + // derives both halves from it -- the CLAMPED gate (bounded to `CLAIM_SCHEDULE_SECONDS_MAX`, + // so WHEN a cycle may run tracks the same schedule the driver below actually sleeps on) and + // the RAW fee window (how long the persisted fee-budget window stays open). The one argument + // makes them impossible to transpose, but NOT impossible to get wrong: passing the clamped + // local `cadence_seconds` here instead of `cfg.cadence_seconds` has the same type, compiles, + // and halves the fee window -- ~12 budget windows a year for a 60-day operator instead of the + // ~6 their cadence implies, i.e. 2x the fee ceiling they configured. Exactly one test catches + // that: `tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`. .with_persisted_fee_window( state_dir, ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), @@ -1065,6 +1065,12 @@ mod tests { /// - the WINDOW must NOT roll at tick 2 (elapsed since it opened is one clamped interval, /// 2_678_400s, well under the raw 5_184_000s the operator configured) but MUST have rolled /// by tick 3 (elapsed is 2 clamped intervals, 5_356_800s, past the raw boundary). + /// + /// This test builds its `ClaimCadences` itself, so it stays GREEN if the PRODUCTION call site + /// in [`run_claim_driver_in`] is mutated to pass the clamped local instead of the raw + /// `cfg.cadence_seconds`. It is therefore not a duplicate of + /// [`the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`], which is the only + /// test that catches that mutation -- do not delete that one as redundant with this one. #[tokio::test(start_paused = true)] async fn the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one() { let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index a638df80..d1af807e 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -83,8 +83,18 @@ pub(crate) struct ClampedGateCadence(u64); impl ClampedGateCadence { /// Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a 60-day raw cadence in - /// yields the 31-day ceiling out, the same bound [`super::driver::sanitized_schedule`] - /// applies at the config read, so the gate tracks the schedule the driver really sleeps on. + /// yields the 31-day ceiling out, so the gate tracks the schedule the driver really sleeps + /// on. + /// + /// That is the same CEILING [`super::driver::sanitized_schedule`] applies at the config read, + /// but only its clamp arm: `sanitized_schedule` ALSO substitutes + /// `super::config::CLAIM_CADENCE_SECONDS_DEFAULT` for a zero cadence, and `clamp` has no such + /// arm. The two agree only under an unnamed-until-now precondition -- a zero never reaches + /// here, because [`super::config::RewardsClaimConfig::load_from`] floors `cadence_seconds` to + /// `super::config::CLAIM_CADENCE_FLOOR_SECONDS` (60) on its parse-success path and yields + /// `CLAIM_CADENCE_SECONDS_DEFAULT` (86_400) on its other two exits. A caller that builds a + /// cadence from anything but a loaded config breaks that precondition, and the equivalence + /// with it. fn clamp(raw: RawConfiguredCadence) -> Self { ClampedGateCadence(raw.0.min(super::driver::CLAIM_SCHEDULE_SECONDS_MAX)) } @@ -225,15 +235,6 @@ impl ClaimEngine { /// arms this engine to keep persisting them there after every submission and every completed /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). /// - /// F2 (money): takes TWO cadence values, deliberately not one -- `gate_cadence_seconds` (the - /// CLAMPED value the driver's schedule actually runs on) gates WHEN a cycle is allowed to - /// start; `fee_window_seconds` (the RAW configured value) sizes how long the persisted - /// fee-budget window stays open. Conflating them into a single cadence (the pre-F2 shape) - /// either doubled the operator's fee ceiling (reusing the clamped value for the window) or - /// silently starved the gate to the raw value -- for an unbounded-above raw cadence, the gate - /// could stop opening at all while the scheduler kept ticking on the clamped interval. See - /// [`Self::gate_cadence_seconds`] and [`Self::fee_window_seconds`]'s field docs. - /// /// Without this call, the engine is exactly as it was before F7: a fresh /// [`Self::cycle_fee_budget_mojos`] and no cadence gate on every construction. That is /// deliberately still true for a caller that has not opted in (every pre-F7 test), but it is From efdbea9505d940c75f2f16367dc7b203bd96e8c2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 20:07:25 -0700 Subject: [PATCH 12/12] docs(rewards-claim): correct the clock-seam fn name and the load_from exit count Co-Authored-By: Claude Haiku 4.5 --- crates/dig-node-service/src/rewards_claim/driver.rs | 2 +- crates/dig-node-service/src/rewards_claim/engine.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index d7019c72..6dbf737f 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -1067,7 +1067,7 @@ mod tests { /// by tick 3 (elapsed is 2 clamped intervals, 5_356_800s, past the raw boundary). /// /// This test builds its `ClaimCadences` itself, so it stays GREEN if the PRODUCTION call site - /// in [`run_claim_driver_in`] is mutated to pass the clamped local instead of the raw + /// in [`run_claim_driver_in_with_clock`] is mutated to pass the clamped local instead of the raw /// `cfg.cadence_seconds`. It is therefore not a duplicate of /// [`the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`], which is the only /// test that catches that mutation -- do not delete that one as redundant with this one. diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index d1af807e..df110ab5 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -92,7 +92,7 @@ impl ClampedGateCadence { /// arm. The two agree only under an unnamed-until-now precondition -- a zero never reaches /// here, because [`super::config::RewardsClaimConfig::load_from`] floors `cadence_seconds` to /// `super::config::CLAIM_CADENCE_FLOOR_SECONDS` (60) on its parse-success path and yields - /// `CLAIM_CADENCE_SECONDS_DEFAULT` (86_400) on its other two exits. A caller that builds a + /// `CLAIM_CADENCE_SECONDS_DEFAULT` (86_400) on its other four exits. A caller that builds a /// cadence from anything but a loaded config breaks that precondition, and the equivalence /// with it. fn clamp(raw: RawConfiguredCadence) -> Self {