diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 4d4bdd52..6dbf737f 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -38,7 +38,7 @@ use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::ClaimEngine; +use super::engine::{ClaimCadences, ClaimEngine, RawConfiguredCadence}; use super::hints::{DistributorHintSource, NoHintSource}; use super::port::{ClaimChainPort, UnavailableClaimChainPort}; use super::types::{ClaimLoopState, ClaimStatus}; @@ -335,7 +335,7 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// this ticket: the claim loop never fires again, so no cycle, no `log_cycle` line, and the /// cycle counter reads a permanent, reassuring `0`. #594 shipped an engine that was inert and /// green; a config value must not be able to put this driver back in that state silently. -const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; +pub(crate) const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; /// [`sanitized_schedule`]'s call-scoped report of what it changed, threaded into [`drive`] and /// on into [`log_cycle`] -- NEVER stored on [`ClaimEngine`] as a field (see this module's SHAPE @@ -438,6 +438,42 @@ async fn run_claim_driver_in

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

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

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

( jitter_seconds, adjustment, &OsJitter, - unix_now_seconds, + now, handle, ) .await; @@ -992,7 +1031,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 assert!(outcomes.is_empty()); @@ -1023,6 +1065,12 @@ mod tests { /// - the WINDOW must NOT roll at tick 2 (elapsed since it opened is one clamped interval, /// 2_678_400s, well under the raw 5_184_000s the operator configured) but MUST have rolled /// by tick 3 (elapsed is 2 clamped intervals, 5_356_800s, past the raw boundary). + /// + /// This test builds its `ClaimCadences` itself, so it stays GREEN if the PRODUCTION call site + /// in [`run_claim_driver_in_with_clock`] is mutated to pass the clamped local instead of the raw + /// `cfg.cadence_seconds`. It is therefore not a duplicate of + /// [`the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`], which is the only + /// test that catches that mutation -- do not delete that one as redundant with this one. #[tokio::test(start_paused = true)] async fn the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one() { let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. @@ -1039,7 +1087,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), effective_cadence, configured_cadence); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(configured_cadence)), + ); let handle = ClaimLoopHandle::default(); let h = handle.clone(); @@ -1070,10 +1121,11 @@ mod tests { tokio::time::advance(Duration::from_secs(effective_cadence)).await; settle().await; assert_eq!(handle.cycles_driven(), 1); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "the very first cycle has no prior completion to gate against" + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1093,11 +1145,14 @@ mod tests { "F2: the gate must track the CLAMPED cadence -- a cycle sized from the raw 5_184_000 \ cadence would still be refused here, reproducing the silent-non-claiming defect" ); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, + super::super::types::ClaimLoopState::Nominal, "F2: the gate opened one clamped interval after the last completion -- it must not \ - still be waiting on the raw 5_184_000s cadence" + still be waiting on the raw 5_184_000s cadence. Asserting the exact state, not \ + merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns sit above the \ + window-roll block too, so it would go vacuous the moment one of those fired instead" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1138,7 +1193,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); @@ -1166,7 +1224,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at assert!(outcomes.is_empty()); @@ -1198,7 +1259,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), 1_000, 1_000); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(1_000)), + ); let outcomes = engine.run_cycle(1).await; assert!(outcomes.is_empty()); @@ -1320,6 +1384,126 @@ mod tests { driver.abort(); } + /// F2/#3336, MONEY, THE JOINT VERSION: [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] + /// proves the gate/window split by hand-assembling `drive` directly. This test proves the SAME + /// property through [`run_claim_driver_in_with_clock`] -- the actual production body, the one + /// that threads `sanitized_schedule`'s CLAMPED cadence and the RAW `cfg.cadence_seconds` into + /// [`ClaimEngine::with_persisted_fee_window`] at driver.rs's one call site. + /// + /// THIS IS THE ONLY TEST THAT CATCHES THE ONE MUTATION STILL LEFT AT THAT CALL SITE. The call + /// takes a single argument now, so the historic two-argument transposition cannot be written + /// at all. What still compiles is passing the already-clamped local as the raw value -- + /// `ClaimCadences::from_raw(RawConfiguredCadence(cadence_seconds))`. Measured: the fee window + /// then rolls at tick 2 instead of tick 3, failing the tick-2 assertion below with + /// `left: Some(5356800)`, `right: Some(2678400)` -- half the window length, so 2x the + /// fee-budget windows the operator sized. The gate is NOT affected (clamping an + /// already-clamped value is the identity), and + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] stays GREEN + /// under that mutation, because it hand-assembles `drive` and never traverses the production + /// call site. + /// + /// Uses a written config with a 60-day RAW cadence (`5_184_000`s, clamped to the 31-day + /// `CLAIM_SCHEDULE_SECONDS_MAX`, `2_678_400`s) and a clock that advances one clamped interval + /// per invocation, matching the scheduler's own tick -- the same pattern + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] uses, but + /// driven through the production body instead of a hand-built engine. + #[tokio::test(start_paused = true)] + async fn the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window() { + let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. + let effective_cadence = 31 * 24 * 60 * 60u64; // 2_678_400, CLAMPED -- sizes the gate. + assert_eq!(configured_cadence, 5_184_000); + assert_eq!(effective_cadence, 2_678_400); + + let dir = tempfile::tempdir().unwrap(); + write_config(dir.path(), configured_cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in_with_clock(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h, { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }) + .await; + }); + + settle().await; + assert_eq!(handle.cycles_driven(), 0, "no interval has elapsed yet"); + + // Tick 1: the window opens for the first time. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + // `cycles_driven()` alone is NOT gate evidence: `drive` increments it unconditionally + // after every `run_cycle` call returns, whatever that cycle's outcome was -- a cycle the + // internal gate REFUSED (`ClaimLoopState::CadenceNotElapsed`) still increments it. Assert + // on the reported STATE, which the gate's early `return` in `run_cycle` actually controls. + assert_eq!(handle.cycles_driven(), 1); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" + ); + let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_1, + Some(effective_cadence), + "the window opens on tick 1" + ); + + // Tick 2: one clamped interval since tick 1 -- the GATE must open (it tracks the clamped + // schedule the loop actually ticks on) but the WINDOW must NOT roll yet (only one clamped + // interval, 2_678_400s, of its raw 5_184_000s length has elapsed). + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "the driver's loop iterated a second time" + ); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "#3336: the production body's gate must track the CLAMPED cadence -- a body that gated \ + on the raw 5_184_000s cadence would report CadenceNotElapsed here, restoring the \ + no-op gate #3306 fixed. Asserting the exact \ + state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \ + the window-roll block, so it would go vacuous the moment one of those fired instead. \ + `cycles_driven()` cannot see any of this: it counts every drive loop iteration, \ + including ones the internal gate refused" + ); + let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_2, after_tick_1, + "#3336: the fee window must NOT have rolled yet -- only one clamped interval has \ + elapsed against its raw 5_184_000s length. Sizing the window off the CLAMPED value \ + instead rolls it here (left Some(5356800), right Some(2678400)), halving the window \ + and doubling the operator's configured fee-window count. This assertion alone cannot \ + distinguish 'gate opened, window correctly held' from 'gate refused, window-roll \ + code never reached' (the gate's early return in `run_cycle` sits before the \ + window-roll block) -- it is only meaningful paired with the state assertion above, \ + which proves the gate did NOT refuse this cycle." + ); + + // Tick 3: two clamped intervals (5_356_800s) since the window opened -- past its raw + // 5_184_000s length. The window must finally roll. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 3); + let after_tick_3 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_ne!( + after_tick_3, after_tick_1, + "#3336: the window must have rolled once the RAW 5_184_000s cadence elapsed" + ); + + driver.abort(); + } + // ---- the cycle log: the only reader of the status surface in a shipped binary ---------- /// An in-memory sink a `tracing_subscriber::fmt` layer renders records into, so a test can diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index b570920f..df110ab5 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -11,6 +11,99 @@ use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +/// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the +/// single raw configured value they both come from. +/// +/// # DIG-Network/dig_ecosystem#3336 (money) -- what this shape closes, and what it does not +/// Earlier shapes handed the engine two numbers the CALLER had already chosen: first two bare +/// `u64` arguments, then two distinct newtypes, then this struct with two typed fields. +/// +/// TYPE-ENFORCED: the gate and the fee window cannot disagree with EACH OTHER. +/// [`Self::from_raw`] is the only constructor, both fields are private (so a struct literal is +/// not an alternative path from outside this module), and it derives `gate_clamped` by clamping +/// the very [`RawConfiguredCadence`] it stores as `fee_window_raw`. There is no pairing in which +/// the window sizes off one number and the gate off another. +/// +/// NOT type-enforced, and no type here can be: WHICH `u64` the call site labels raw. +/// `from_raw(RawConfiguredCadence(cadence_seconds))` -- the already-clamped local instead of +/// `cfg.cadence_seconds` -- has the same type and compiles. It halves the fee window: measured, +/// the window rolls one tick early, `Some(5356800)` becoming `Some(2678400)`, exactly 2x the +/// number of fee-budget windows the operator sized. Exactly ONE test catches that, and it is +/// `driver::tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`, which +/// drives the production call site. Do not delete it on the belief that a type stands behind it +/// -- nothing does. +/// +/// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s +/// restart-safety gate is measured against -- the same interval [`super::driver::drive`] +/// actually sleeps on. The RAW value here would restore the no-op gate +/// DIG-Network/dig_ecosystem#3306 fixed: an operator's 60-day config would gate on 60 days +/// again even though the loop keeps ticking every 31. +/// - `fee_window_raw`: the RAW configured cadence, in seconds, that sizes how long the persisted +/// aggregate fee-budget window stays open before rolling -- deliberately never the clamped +/// value. The CLAMPED value here doubles the number of fee-budget windows a long-cadence +/// operator sized (a 60-day config would get ~12 windows/year instead of the ~6 its cadence +/// implies), doubling the fee ceiling they configured. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ClaimCadences { + gate_clamped: ClampedGateCadence, + fee_window_raw: RawConfiguredCadence, +} + +impl ClaimCadences { + /// The ONLY constructor. The gate cadence is derived from the window's own raw source, so the + /// two can never be paired with each other's value -- that much the types enforce. What + /// nothing here enforces is that `raw` really is the raw configured value; see the + /// [`ClaimCadences`] #3336 section for the single test that does. + #[must_use] + pub(crate) fn from_raw(raw: RawConfiguredCadence) -> Self { + Self { + gate_clamped: ClampedGateCadence::clamp(raw), + fee_window_raw: raw, + } + } +} + +/// The RAW, operator-writable cadence in seconds (`RewardsClaimConfig::cadence_seconds` as +/// persisted) -- unbounded above. The single input [`ClaimCadences::from_raw`] takes: it sizes +/// the fee window directly and, through [`ClampedGateCadence::clamp`], the gate as well. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RawConfiguredCadence(pub(crate) u64); + +/// The schedule-CLAMPED cadence (bounded to [`super::driver::CLAIM_SCHEDULE_SECONDS_MAX`]) that +/// [`ClaimEngine::run_cycle`]'s restart-safety gate is measured against -- the same interval +/// [`super::driver::drive`] actually sleeps on. +/// +/// # DIG-Network/dig_ecosystem#3336 -- why the field is private +/// [`Self::clamp`] is the only way to produce this type, it is private to this module, it always +/// applies the bound, and the only caller of `clamp` is [`ClaimCadences::from_raw`]. So every +/// number that reaches the gate has been through the clamp -- which bounds its MAGNITUDE and +/// nothing else. It is not evidence about where the number came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ClampedGateCadence(u64); + +impl ClampedGateCadence { + /// Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a 60-day raw cadence in + /// yields the 31-day ceiling out, so the gate tracks the schedule the driver really sleeps + /// on. + /// + /// That is the same CEILING [`super::driver::sanitized_schedule`] applies at the config read, + /// but only its clamp arm: `sanitized_schedule` ALSO substitutes + /// `super::config::CLAIM_CADENCE_SECONDS_DEFAULT` for a zero cadence, and `clamp` has no such + /// arm. The two agree only under an unnamed-until-now precondition -- a zero never reaches + /// here, because [`super::config::RewardsClaimConfig::load_from`] floors `cadence_seconds` to + /// `super::config::CLAIM_CADENCE_FLOOR_SECONDS` (60) on its parse-success path and yields + /// `CLAIM_CADENCE_SECONDS_DEFAULT` (86_400) on its other four exits. A caller that builds a + /// cadence from anything but a loaded config breaks that precondition, and the equivalence + /// with it. + fn clamp(raw: RawConfiguredCadence) -> Self { + ClampedGateCadence(raw.0.min(super::driver::CLAIM_SCHEDULE_SECONDS_MAX)) + } + + fn seconds(self) -> u64 { + self.0 + } +} + /// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] /// and the anti-silence status surface across calls to [`Self::run_cycle`]. /// @@ -142,15 +235,6 @@ impl ClaimEngine { /// arms this engine to keep persisting them there after every submission and every completed /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). /// - /// F2 (money): takes TWO cadence values, deliberately not one -- `gate_cadence_seconds` (the - /// CLAMPED value the driver's schedule actually runs on) gates WHEN a cycle is allowed to - /// start; `fee_window_seconds` (the RAW configured value) sizes how long the persisted - /// fee-budget window stays open. Conflating them into a single cadence (the pre-F2 shape) - /// either doubled the operator's fee ceiling (reusing the clamped value for the window) or - /// silently starved the gate to the raw value -- for an unbounded-above raw cadence, the gate - /// could stop opening at all while the scheduler kept ticking on the clamped interval. See - /// [`Self::gate_cadence_seconds`] and [`Self::fee_window_seconds`]'s field docs. - /// /// Without this call, the engine is exactly as it was before F7: a fresh /// [`Self::cycle_fee_budget_mojos`] and no cadence gate on every construction. That is /// deliberately still true for a caller that has not opted in (every pre-F7 test), but it is @@ -174,18 +258,27 @@ impl ClaimEngine { /// of every cycle unconditionally (its `CycleConditions`), so a construction-time copy was /// pure overhead: it was never trusted past the first cycle anyway once F16 landed, and now it /// is never even taken. + /// + /// # DIG-Network/dig_ecosystem#3336 + /// Takes ONE [`ClaimCadences`], which the caller can only build with + /// [`ClaimCadences::from_raw`] -- so the two cadences are derived together, from one value, + /// and cannot contradict each other. WHICH value that is is still the call site's choice, and + /// is test-guarded only; see the [`ClaimCadences`] #3336 section. + /// + /// `pub(crate)`, not `pub`: [`ClaimCadences`] is crate-private (it is the argument type, so a + /// `pub` method taking it would be uncallable from outside anyway), and no out-of-crate + /// caller exists. #[must_use] - pub fn with_persisted_fee_window( - mut self, - dir: &Path, - gate_cadence_seconds: u64, - fee_window_seconds: u64, - ) -> Self { + pub(crate) fn with_persisted_fee_window(mut self, dir: &Path, cadences: ClaimCadences) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); - self.gate_cadence_seconds = - gate_cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); - self.fee_window_seconds = - fee_window_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.gate_cadence_seconds = cadences + .gate_clamped + .seconds() + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.fee_window_seconds = cadences + .fee_window_raw + .0 + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -1774,7 +1867,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1799,7 +1895,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_010).await; let second_submitted = second_outcomes @@ -1838,7 +1937,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes .iter() @@ -1878,7 +1980,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1901,7 +2006,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(later).await; assert_eq!( @@ -1934,7 +2042,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1951,7 +2062,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_050).await; assert_eq!( @@ -1987,7 +2101,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; let submitted: u64 = outcomes @@ -2036,7 +2153,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2094,7 +2214,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the // gate cannot be what refuses this -- only the window accumulator can. @@ -2134,7 +2257,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -2156,7 +2282,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_010).await; assert_eq!( @@ -2208,7 +2337,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, // and must refuse. @@ -2281,7 +2413,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Cycle 1: the file is corrupt -- must refuse, submit nothing. let cycle1 = e.run_cycle(1_000).await; @@ -2353,7 +2488,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2401,7 +2539,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await;