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 {
/// 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 {
/// 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;