Skip to content

feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) - #605

Merged
MichaelTaylor3d merged 7 commits into
developfrom
loop/3268-claim-loop-startup
Sep 10, 2026
Merged

feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268)#605
MichaelTaylor3d merged 7 commits into
developfrom
loop/3268-claim-loop-startup

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Why

dig-node#594 shipped a complete, tested peer reward-claim engine that nothing constructs. At develop the module says so in its own words — rewards_claim/mod.rs carries a section headed "Not yet wired into node startup (Defect D — stated, not fixed here)" whose last line is "this module compiles, is fully tested against the fake chain port, and does nothing in a running node."

So no scheduler calls a cycle, the 86400s cadence never fires, and the anti-silence surface is unreadable. Meanwhile rewards_claim.enabled defaults to true — the node's own config asserts the subsystem is on while nothing can run. That is a surface stating something false about money, the same class as the three defects this epic already corrected.

This PR is the follow-through: the wiring half of DIG-Network/dig_ecosystem#3268.

What

A background cadence driver reachable from the node's real startup path, so rewards_claim.enabled = true comes to mean "a background task exists, drives a cycle every cadence_seconds + jitter, and its outcome is readable in-process as a named ClaimLoopState."

  • crates/dig-node-service/src/rewards_claim/driver.rs (new) — the cadence driver plus the tested spawn seam. Shape mirrors self_heal's split rather than inventing one: a private injected-tick drive(...) that is falsifiable under a paused clock, and a pure spawn gate that is itself a tested unit, so a refactor cannot silently flip it to always- or never-spawn.
  • crates/dig-node-service/src/server.rs — exactly ONE call in serve_with_shutdown, in the existing spawn block beside spawn_collateral_census(...) and self_heal::spawn_driver_if_service(), gated on enable_chain_sync for the reason those are: that flag already means "this node talks to the Chia network", and a harness sets it false precisely so nothing dials.
  • crates/dig-node-service/src/rewards_claim/mod.rs — the now-false "Defect D / not yet wired" module-doc section replaced by what the wiring actually does.

own_payout_puzzle_hash is the CAT-wrapped hash (mirror::funding::dig_cat_puzzle_hash(operator_puzzle_hash)), derived the same public, no-unseal-required way spawn_mirror_passes derives its wallet material, and proven against the engine's own comparison site. This is a money-correctness choice, not plumbing: pick the raw inner hash instead and every distributor is refused as a payout mismatch — a condition this epic has already measured as rendering Nominal.

The only production chain adapter until DIG-Network/dig_ecosystem#3249 lands is UnavailableClaimChainPort, so every real cycle reports ChainSourceUnavailable and submits nothing. That is the honest state and is why landing this now is worth it: it makes the gap loud instead of silent.

Scope — the RPC half is deliberately absent

DIG-Network/dig_ecosystem#3268 carries a non-optional acceptance condition:

#3268 MUST NOT expose ClaimStatus over RPC until the status surface has been re-derived against #3249's real chain adapter.

Three gate passes over #594 found twelve defects, each pass finding new ones inside the previous pass's own remedies, so the ClaimStatus semantics are a reviewed hypothesis rather than a verified surface — and a surface whose job is to report "the peer is earning nothing and here is why" cannot be validated against a port that can only ever answer one way. This PR therefore makes the status readable in-process only. No RPC method, no dispatch-table row, no handler: rpc.rs and every reward RPC handler belong to #3269's lane.

The bar

"It compiles and is not wired up yet" is not available here — that argument is literally the defect being fixed. The headline test is the inverse of the obvious one: "scheduler running, zero cycles ever fired, nothing reported wrong" must FAIL. The driver exposes an observed cycle count and keeps ClaimLoopState::Idle honest; asserting that spawn returned would be the defect, not the evidence. Test effort goes to restart, clock movement and corrupt persisted state, because across six gate passes on this engine not one of the 25 defects was at the chain seam.

Refs DIG-Network/dig_ecosystem#3268, DIG-Network/dig_ecosystem#3251, DIG-Network/dig_ecosystem#3246.

Draft: the head commit is a salvage checkpoint pushed after a session cap and its build is unverified. CI is the compiler; this is not review-ready until checks are green.

🤖 Generated with Claude Code

@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3268-claim-loop-startup branch from 306f27c to 962bf60 Compare September 10, 2026 12:21
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security audit — PASS

Head SHA audited: 4cc49f2560a90aa5918997170992a8a9c29e171a
Base: develop (merge-base e9f07c41fdcf295d525344722572de92a6d8efe2)
Scope: crates/dig-node-service/src/rewards_claim/driver.rs (new), rewards_claim/mod.rs, server.rs (+11), Cargo.toml, Cargo.lock. Fresh clone, read-only.

Attack surfaces worked

  1. Spend ceilings / persisted budgetrun_claim_driver_in builds the engine with .with_persisted_fee_window(state_dir, cfg.cadence_seconds) and loads RewardsClaimConfig::load_from(state_dir) fresh on every call into run_claim_driver_in. That call happens exactly once per process lifetime (spawned once from server.rs), so a crash-restart re-reads the SAME persisted window file from disk rather than fabricating a new one in memory — the budget/window logic itself (fail-closed on corrupt/future-dated state) lives unchanged in engine.rs, out of this PR's scope per the brief, and this wiring does not bypass or re-derive it. No live defect found in the wiring's interaction with the ceiling.

  2. own_payout_puzzle_hash / destinationdriver.rs:214-216 computes mirror::funding::dig_cat_puzzle_hash(owner_inner_puzzle_hash) where owner_inner_puzzle_hash comes from dig_wallet::operator_wallet::operator_puzzle_hash(&dig_wallet::autoseed::default_paths()) (driver.rs:229-241). This is the node's own local operator wallet path, not attacker-reachable, and the CAT-wrapping choice is proven both directions by test (a_distributor_paying_this_nodes_derivation_is_claimable, plus the unwrapped-hash refusal case). No user key enters the node here — only the node's own operator wallet is touched, consistent with the custody boundary. default_paths() is a local filesystem convention, not redirectable by an unprivileged remote principal from what this diff shows.

  3. Rate-limiter/budget keyed on attacker input — the driver introduces no new keying; the per-cycle budget shape is entirely engine.rs (unchanged, out of scope, already gated per the ticket's own history). Nothing in driver.rs lets an outside party influence which distributor is visited first or re-key the window.

  4. Refusal/status surface as an info channel — confirmed IN-PROCESS ONLY: handle()/ClaimLoopHandle/driver::handle are referenced nowhere outside rewards_claim/driver.rs and rewards_claim/mod.rs's re-export (git grep across the tree). No rpc.rs reference to ClaimStatus or rewards_claim exists in this diff or at head. Nothing leaks the payout puzzle hash or wallet paths over the wire.

  5. Startup order / privilege — the spawn call sits in the existing serve_with_shutdown spawn block beside spawn_collateral_census/self_heal, gated on config.enable_chain_sync, matching sibling calls. decide_claim_driver is a pure, fully-tested function (disabled_never_spawns, enabled_but_chain_sync_off_refuses_named, enabled_and_chain_sync_on_spawns) — no path lets it spawn before the gate evaluates. No new file writes/permissions introduced by this PR; persistence paths are inherited from the unchanged engine.rs/config.rs.

  6. ring = "0.17" — verified against both develop and PR lockfiles: ring 0.17.14 (same registry, same checksum) was ALREADY resolved transitively (via rustls) before this PR; the PR only promotes it to a direct dependency — no new supply-chain source. OsJitter (driver.rs:178-193) draws from ring::rand::SystemRandom (real OS CSPRNG), returns 0 jitter only on a CSPRNG error (degrades to no-spread, never panics, never a seeded/global RNG). Jitter bound (cadence.rs) is operator-configured (jitter_seconds), not attacker-influenced remotely; a fleet operator setting it to 0 is a self-inflicted, local config choice, not an externally exploitable primitive.

Not a finding, noted for the record

git diff develop pr605 (raw tip-to-tip) shows crates/dig-node-core/src/{lib.rs,rewards/{mod,port}.rs} reverting #606's funder-ownership registry, because PR #605's branch was cut from e9f07c41 (before #606 merged) and never rebased. Checked whether this is a live hazard: #605 makes NO changes to those files relative to their common ancestor, gh pr view --json mergeable,mergeStateStatus reports MERGEABLE/CLEAN, and GitHub's merge/squash computes a real three-way merge (equivalent to a rebase), not a literal reapplication of the raw two-tip diff — so merging will NOT revert #606's work. Confirmed this is a stale-branch artifact of the raw diff view, not a mergeable hazard. Recommend rebasing before merge anyway for hygiene, but it is not a gate blocker.

Not covered

engine.rs, types.rs, port.rs, cadence.rs's prior history, and config.rs were read only as needed to trace the wiring seam — unchanged in this diff, out of scope per the brief (belongs to #3249's re-derivation pass if a defect is later found inside them).

Verdict: PASS.

MichaelTaylor3d and others added 4 commits September 10, 2026 10:21
…al startup (#3268)

The peer reward-claim engine shipped complete and tested in #594 but INERT:
nothing constructed it, so the 86400s cadence never fired while
`rewards_claim.enabled` defaulted to `true` -- a config asserting a subsystem
is on while nothing runs.

`rewards_claim/driver.rs` is a SCHEDULER, not a chain adapter: it derives this
node's own payout puzzle hash, loads `RewardsClaimConfig`, builds a
`ClaimEngine` against the only production port that exists
(`UnavailableClaimChainPort`, until #3249 lands a real one) and drives
`run_cycle` every `cadence_seconds + jitter`, jitter drawn from the OS CSPRNG.
`server.rs`'s `serve_with_shutdown` makes exactly one call into it, beside
`self_heal::spawn_driver_if_service()`.

`enabled = true` now means: a background task exists, drives a counted cycle
per interval, and its outcome is readable in-process as a NAMED state. With
`UnavailableClaimChainPort` every cycle honestly reports
`ChainSourceUnavailable` -- the gap is loud instead of silent.

Anti-silence: `ClaimLoopHandle` carries a monotonic `cycles_driven` counter
alongside the status, because `Idle` before the first cycle is correct and
honest, so status alone cannot tell "scheduler never fired" from "nothing was
claimable". The gate takes an INJECTED handle rather than reading the
process-wide singleton, so `ClaimDriverRefusal::{Disabled, ChainSyncDisabled,
NoOperatorWallet}` and "spawned but never ticked" are four pairwise-distinct
readings a test asserts in-process.

Nothing goes on the wire: no RPC method, dispatch row, handler or OpenRPC
entry. `ClaimStatus` stays off the wire until #3249's real adapter lets the
status surface be re-derived against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…268)

`OneDistributorPort::own_entry` ignores the puzzle hash the engine passes in
on purpose -- the fake always returns the entry keyed to `entry_keyed_to` so
the ENGINE's own comparison is what decides claimable vs. refused. Named it
`_payout_puzzle_hash` (clippy `-D unused-variables`) and moved the rationale
onto the parameter, where the next reader meets it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e drive loop (#3268)

`decide_claim_driver` was tested and `drive` was tested, but the production
body joining them -- load the config from the state dir, derive the engine,
reach `drive` -- was exercised by nothing. That is the exact shape of #594,
which shipped a complete, fully-tested and entirely inert claim engine: had
this body returned early, built the engine wrong, or never reached `drive`,
every test on this change would still have passed and a real node would still
never claim.

Split `run_claim_driver` on the same `load` / `load_from` pattern the config
itself uses: `run_claim_driver_in(state_dir, own_payout_puzzle_hash, port,
handle)` holds the whole body and is generic over the port, and
`run_claim_driver` is reduced to the wallet-derivation adapter that cannot be
reached from a test. Adds two tests through the real body: counted cycles from
a written config (zero before the interval, exactly one per interval after),
and `UnavailableClaimChainPort` reporting `ChainSourceUnavailable` by name on a
driven cycle -- proving the production adapter path is reached, not only a fake.

No behaviour change: same config, same engine construction, same port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… out of rustfmt's reach (#3268)

Two repairs to the new composition tests:

- The `ChainSourceUnavailable` test advanced the paused clock before the
  spawned body had reached its first `sleep`, so the timer was not yet
  registered and the advance bought no cycle at all -- it read zero cycles, not
  a driven one. A `settle()` first, mirroring the counted-cycles test.
- rustfmt rejoined a `\`-continued assertion message into one line, leaving 14
  literal spaces mid-sentence and tripping the repo's own
  `continuation_guard`. `concat!` states the wrap explicitly, so no formatter
  pass can reintroduce the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3268-claim-loop-startup branch from 4cc49f2 to ac01370 Compare September 10, 2026 17:22
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

ADVERSARIAL GATE (third leg) — CHANGES-REQUIRED @ 4cc49f25

Read at head 4cc49f2560a90aa5918997170992a8a9c29e171a (driver.rs 1013 lines, mod.rs, server.rs:2216, with types.rs/engine.rs read-only for context). This leg attacks the SHAPE and the CLAIM, not style; the correctness and security gates run in parallel and I did not duplicate them.

BLOCKING (one finding, driver.rs only, no design latitude)

The honesty claim rests on a reader that does not exist in the binary. The module doc and the PR body both state that after this change enabled = true means "a task exists, drives a cycle every interval, and its outcome is readable as a named ClaimLoopState". In the shipped process, it is readable by nothing:

  • handle() has zero callers outside driver.rs's own tests — no rewards_claim::handle in server.rs or anywhere else in the crate, and no RPC row (correctly forbidden until #3249).
  • drive() — the loop around engine.run_cycle(t) / handle.record(...) — emits no tracing event at all.
  • engine.rs logs only two fee-window persistence warnings (engine.rs:187, :198) — never the cycle's ClaimLoopState.

So all three tracing:: calls this PR adds (the NoOperatorWallet warn, the Disabled debug, the ChainSyncDisabled warn) fire only on paths where the loop does not run. On the path where it does run — the default path on every node, enabled = true with chain sync on — the subsystem produces exactly the observable output it produced before this PR: silence. Today that silence covers a permanent ChainSourceUnavailable; after #3249 it will equally cover Faulted, PersistedStateCorrupt and ClaimableButNotClaiming.

That is the anti-silence property this epic exists to enforce, missing at the outermost layer — the layer the previous six passes never reached, because until this PR there was no outermost layer to inspect.

Remedy (blocking, in this PR): in drive(), after handle.record(engine.status()), emit one tracing event per cycle naming the state and the cycle count — info! when the state is Nominal, warn! otherwise — with target: "rewards_claim", plus distributors_known and claims_submitted_this_cycle. Then the acceptance sentence is true of a running node rather than only of the test suite. No new state, no new type, one added statement; the existing start_paused tests are unaffected.

The five questions

  1. Honesty: a real improvement, but the claim as written is overstated — and the fix is the blocking item above, not the default flip. "enabled = true and nothing is constructed" is the worse lie, because it is unfalsifiable, whereas "a loop runs and can only say ChainSourceUnavailable" becomes true the day #3249 lands. I reject the alternative of defaulting enabled = false: it makes the config truthful only by making the subsystem absent, so #3249 would have to ship a silent behavioural flip that every existing operator's on-disk false then overrides forever — the failure direction there is a network of nodes that never claim, each with a config file saying so in a place nobody re-reads. Land the wiring. But with no log and no RPC, a running-and-impotent loop today does create the new false impression: "claiming is handled" is asserted in the module doc and observable nowhere. The one-line remedy converts the claim from a doc statement into a measured one.
  2. EmptyPortNominal is a real instance of the pattern, but it belongs to #3249, not here. A peer that discovered zero distributors will never be paid, and Nominal (types.rs:196, "a cycle completed, nothing above is true") is a reassuring reading of that; distributors_known = 0 rides on the same ClaimStatus, so the reading is underdetermined rather than laundered — a reader holding the struct can tell, a reader holding the state name cannot. Two reasons it does not block: types.rs/engine.rs are out of scope and held by a user condition, and with UnavailableClaimChainPort as the only production port a real node cannot reach this reading at all today. One hazard to record, though — the vault page's item 5: zero_cycles_before_the_interval_elapses_then_a_counted_number_after now asserts Nominal as the expected outcome of a zero-distributor cycle, pinning semantics a future pass must change. Follow-on with #3249: a NoDistributorsDiscovered variant, or an explicit comment on that assertion recording the reading as provisional.
  3. The four zero-cycle truths are genuinely distinguishable and ClaimDriverRefusal does not move the conflation — but NoOperatorWallet is a process-lifetime latch with no retry. refusal() is None/Disabled/ChainSyncDisabled/NoOperatorWallet and cycles_driven() is monotonic from zero, so the four readings are pairwise distinct (the PR's own test asserts exactly that pairwise-distinctness); no path records nothing, and no latch pins a cycle state — record overwrites status wholesale every cycle, and set_refusal is only ever called on paths that immediately return. The residue: run_claim_driver reads the operator wallet once, and on absence sets NoOperatorWallet and returns forever. A node started before its operator wallet exists — a fresh install, or a key imported later — then never claims for the lifetime of a process that runs for weeks, and the single startup warn! saying so has long scrolled past. That is the prior ChainSourceUnavailable process-lifetime latch, relocated to the driver. Follow-on ticket, not blocking: re-check the operator wallet on the cadence rather than once, or clear the refusal and continue the loop.
  4. The chain from server.rs:2216 down to run_cycle is now genuinely covered; the residue is small, and one part of it is money-shaped. the_production_body_drives_counted_cycles_from_a_written_config (driver.rs:928) and the_production_adapter_reports_chain_source_unavailable_by_name (:976) drive the real run_claim_driver_in body — config load, engine construction, drive, counted cycles, and the production port's named state — so the joint is no longer assumed. What remains untested is the five-line run_claim_driver wrapper: default_paths(), operator_puzzle_hash, state_dir(), UnavailableClaimChainPort. Four of those five are inconsequential; the derivation is not, and it diverges from its own cited precedent. spawn_mirror_passes (server.rs:2742-2745) deliberately prefers signer.owner_puzzle_hash() and uses operator_wallet::operator_puzzle_hash(&paths) only as a fallback, with a comment giving the reason: "the key a spend is built for and the address its bonds are observed under cannot be two different values". The driver takes that fallback unconditionally, while its doc describes it as "the same derivation spawn_mirror_passes falls back to" — accurate about the fallback, silent about the preference it skips. Consequence today is bounded (nothing submits, and a mismatch yields PayoutPuzzleHashMismatch, i.e. refusal, not loss), so follow-on, not blocking: either mirror the signer-first preference, or record in own_payout_puzzle_hash's doc why the fallback alone is the correct source for a claim.
  5. The shape does not reintroduce the FeeWindowState hazard, but it introduces a milder cousin. The OnceLock holds no transient decision state — a Mutex<ClaimStatus> overwritten wholesale each cycle, a monotonic counter, and a set-once refusal — so nothing here can carry a stale fee window or budget across cycles, and the E0609 structural closure is untouched. The cousin: if the detached task ever panics inside run_cycle, the process-wide handle keeps its last recorded status and a frozen counter forever with refusal() == None, indistinguishable at a single read from "spawned, interval not yet elapsed". It is recoverable across two reads via cycles_driven() and ClaimStatus::last_attempt_at, so it is not a defect today, and the blocking log remedy also makes a dead task visible as an absence of periodic events. Worth one sentence in ClaimLoopHandle's doc once #3249 gives the handle a real reader.

Blocks vs follows on

  • Blocks 4cc49f25: the per-cycle tracing event in drive() (the Q1/Q4 finding above). One statement, driver.rs only.
  • Follows on, to file against #3249's re-derivation: zero-distributors-as-Nominal and the assertion pinning it (Q2); the NoOperatorWallet no-retry latch (Q3); the signer-vs-operator payout derivation divergence (Q4); the frozen-handle-after-panic note (Q5).

If the correctness gate reaches PASS I do not withdraw: my objection is not to the diff's logic but to its acceptance claim, which is unmeasurable in a running node as written. The remedy is additive and cannot fail the existing suite.

🤖 Generated with Claude Code

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loop-reviewer independent correctness gate — CHANGES-REQUIRED

Head reviewed: ac01370edc7e13310d89457a60342261c75d3e49 (rebased onto current develop by the orchestrator; confirmed content-identical to the originally-briefed 4cc49f2560a90aa5918997170992a8a9c29e171a via git diff 4cc49f25 ac01370e -- <the 4 changed files> = empty).

Method

Fresh clone (develop base + fetched PR head by SHA), read driver.rs in full plus the unchanged engine.rs/types.rs/config.rs/self_heal.rs/Cargo.lock it depends on or mirrors. No worktree reused. No code edited.

Verdict: CHANGES-REQUIRED — one blocking finding

Blocking: the wiring this PR claims ("reachable from node startup") has no observable effect on the shipped binary's output.

drive() (crates/dig-node-service/src/rewards_claim/driver.rs, the loop { sleep; run_cycle; handle.record } body) emits zero tracing:: calls on its running path. I grepped every tracing:: call site in the file at the current head: they are at the NoOperatorWallet refusal branch (run_claim_driver, ~line 232) and the Disabled/ChainSyncDisabled decision branches (spawn_claim_driver_if, ~lines 350/357) — i.e. every log line this PR adds fires only when the loop does NOT run. On the default/success path (operator wallet present, enabled=true, chain-sync enabled), the loop runs forever and produces no distinguishable trace output versus pre-PR #594 (where it also compiled but was never spawned). handle()/ClaimLoopHandle are exported (mod.rs:62) but I confirmed via git grep there is no caller of handle() and no reference to ClaimLoopHandle anywhere outside driver.rs itself (tests included) — so nothing in the shipped binary reads the in-process status this PR builds either.

Net effect: this PR is unfalsifiable from the outside. A future regression that silently stops cycles from firing (e.g. a panic in OsJitter, see low finding below) would look identical in production logs to a healthy node — the exact "inert but green" failure mode this epic (#3246) exists to close, just moved one layer in. I independently confirm this is real and blocking, not a style nit: the ticket's own acceptance bar is "a cycle observably fires from a real startup path," and today that's only true inside the unit-test harness, not the deployed binary.

Suggested fix (does not require touching engine.rs/types.rs, stays inside driver.rs): add one tracing::debug!/info! call inside drive()'s loop body after engine.run_cycle — e.g. logging cycles_driven and the resulting ClaimLoopState — so an operator (or an on-call human grepping logs) can distinguish "never ran" from "ran and is Nominal" from "ran and is refused" without needing the not-yet-built RPC surface. Do not log anything that could leak a payout puzzle hash or amount beyond what's already visible in existing chain state.

Non-blocking findings (recorded, not gating)

  1. driver.rs, OsJitter::jitter_seconds (~line 180): bound + 1 overflows if jitter_seconds in the persisted config is ever u64::MAX (no upper-bound clamp in config.rs). Debug build panics; release wraps to % 0 which also panics — either way the detached claim-loop task dies permanently with no restart and no distinguishing log line (compounding the blocking finding above). Low severity: requires local state-dir file write to reach. Suggest checked_add/saturating_add fallback to 0 as a follow-up, not blocking this PR.
  2. run_claim_driver: the NoOperatorWallet refusal is set once at startup and never re-evaluated; if an operator wallet is added post-startup without a node restart, the refusal stays permanently stale. Likely acceptable (matches the rest of this function's one-shot-at-startup shape) but worth a one-line doc comment noting the restart requirement.

Confirmed correct (per brief's specific scrutiny areas)

  • Clock: next_interval_seconds / restart-skip / future-dated-completion handling is delegated to unchanged, already-tested engine.rs (CycleConditions.future_dated_clock) and re-exercised at the integration level by restart_with_a_recent_completion_skips_via_cadence_not_elapsed, restart_with_an_elapsed_completion_runs_a_cycle, a_future_dated_completion_fails_closed_not_underflowed. No underflow path found.
  • Persisted file: run_claim_driver_in deliberately does NOT gate spawn on RewardsClaimConfig::load_from corruption — confirmed correct because engine.rs::run_cycle re-reads the config fresh every single cycle and fails closed (PersistedStateCorrupt) on every path, including post-restart. Comment in the diff explaining this matches the actual unchanged engine.rs behavior.
  • Status surface: ClaimDriverRefusal::{Disabled, ChainSyncDisabled, NoOperatorWallet} are three distinct, non-latching truths (only spawn_claim_driver_if's pure decision sets them, and the driver's own Idle/Nominal/etc. is a separate independent field) — they do not collapse into one reassuring Idle, confirmed by direct read of ClaimLoopHandle::{status, refusal} and decide_claim_driver.
  • Joint tests: the_production_body_drives_counted_cycles_from_a_written_config (driver.rs) and the_production_adapter_reports_chain_source_unavailable_by_name both assert on handle.cycles_driven() transitioning 0→1(→2), not merely "spawn returned" — confirmed by direct read, satisfies the bar set after the earlier sent-back revision.
  • ring = "0.17": confirmed via develop's Cargo.lock (pre-PR) that ring 0.17.14 is already present transitively (via rustls) at the same version — no new dependency source introduced.
  • EmptyPortNominal (independent read, not a change request — types.rs is out of scope for #605): ClaimLoopState::compute_state's priority ladder (unchanged, pre-existing #594 semantics) puts Nominal as the bottom-of-ladder fallback, so "zero distributors discovered" and "distributors discovered but none currently due" are indistinguishable in the status surface — both read Nominal. I don't think this is fully honest in the everyday-English sense ("nothing to claim right now" vs "this node has never once found a single distributor" are different facts an operator would want told apart), but it predates this PR and is explicitly out of this PR's blast radius. Recording as an observation for whoever eventually revisits types.rs, not a defect in #605.

Not run

Did not wait for Test + coverage/Analyze (rust) (still pending at time of this review, per parent orchestrator's instruction that CI is not this gate's blocking signal — this review is a static/independent correctness read of the diff, not a CI-completion gate).


Concur with the security leg (PASS) and independently reproduce the adversarial leg's blocking finding above via direct tracing:: call-site and handle()-caller greps at this head — not just taking it on report.

Comment thread crates/dig-node-service/src/rewards_claim/driver.rs
Comment thread crates/dig-node-service/src/rewards_claim/driver.rs Outdated
MichaelTaylor3d and others added 3 commits September 10, 2026 14:02
…(#3268)

The adversarial gate blocked #605 on this: the PR justified itself by making
an inert subsystem loud, but nothing in the shipped binary could hear it.
ClaimLoopHandle had no caller outside driver.rs tests, drive() emitted no
event, and all three tracing calls fired only on paths where the loop does
NOT run -- so on the default path (enabled=true, chain sync on) the
observable output was identical to before the PR: silence. Today that
silence covers a permanent ChainSourceUnavailable; after #3249 it would also
cover Faulted, PersistedStateCorrupt and ClaimableButNotClaiming.

log_cycle() now names the state and the cycle count after every cycle --
info for Nominal, warn for everything else, because "this peer is earning
nothing and here is why" is a warning, not routine chatter. Tested by
capturing the subscriber output rather than asserting the call site exists,
since this ticket exists because a guarantee that cannot be observed in a
running node is not a guarantee.

Refs DIG-Network/dig_ecosystem#3268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`OsJitter::jitter_seconds` computed `bound + 1` for its modulus. `jitter_seconds`
comes from the node's persisted `rewards_claim` config and is not clamped, so a
config carrying `u64::MAX` overflow-panicked inside the detached claim-driver
task -- which has no restart and emits no further log output, so the claim loop
would die silently for the rest of the process lifetime.

`saturating_add(1)` keeps the draw within `0..=bound` for every input; the
composed `next_interval_seconds` range is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… disables the loop

`next_interval_seconds` saturates instead of panicking, so a persisted
`jitter_seconds = u64::MAX` no longer crashes the driver -- it schedules the
next cycle ~585 billion years out. The claim loop then never fires again: no
cycle, no `log_cycle` line, and a permanent, reassuring `0` cycle count. That is
#594's inert-but-green shape reopened one level up, in the config file.

`run_claim_driver_in` now sanitizes both schedule fields where it reads them,
before either reaches the engine's fee window or `drive`:

- `CLAIM_SCHEDULE_SECONDS_MAX = 31 * 24 * 60 * 60` (31 days) -- above every
  documented default (86,400s cadence, 3,600s jitter) and above "claim
  monthly", while excluding everything that means never.
- out of range (or a zero cadence, which would busy-loop) substitutes the
  published default and emits `tracing::warn!` naming the field, the rejected
  value and the substituted one. Nothing is accepted silently.

`config.rs` is untouched: it keeps reporting what is on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3268-claim-loop-startup branch from 97ba316 to 5fce847 Compare September 10, 2026 21:27
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial re-gate: PASS — head 5fce8477 (5fce847770455862f39d022f0a9a9285fb8e1375)

My earlier CHANGES-REQUIRED on this PR was: the honesty claim had no reader in the shipped binary
handle() had no non-test caller, drive() emitted nothing, and all three tracing:: calls fired only on
paths where the loop does not run. That finding is remedied. Read from a fresh fetch of the blob at this
head (tree 5300289552…, byte-identical to 97ba3160; the amend was message-only, confirmed).

1. Is the reader real, or does it just move the silence?

Real. driver.rs:170 calls log_cycle(&status, handle.cycles_driven()) unconditionally inside drive's
loop body
, after handle.record(status) — not inside a branch, not after a ?, and not dependent on
anything run_cycle did internally. The ChainUnavailable early returns inside run_cycle cannot skip it:
drive reads engine.status() after the call returns, so an early-returning cycle is logged exactly like
any other. Level choice is right: Nominalinfo!, every other state → warn! (:184-208), which on
a money surface is correct — a peer that is earning nothing is not routine chatter.

The one thing that could still have moved the silence, and the reason I did not take the commit message on
trust: this module uses a custom tracing target "rewards_claim", and there is no other custom
target: anywhere in dig-node — so if the shipped subscriber's filter were crate-scoped, every one of these
records would be dropped and my original finding would simply have relocated into the filter. It is not:
logging.rs::init installs dig_logging 0.2, whose DEFAULT_DIRECTIVE is
"info,hyper=warn,rustls=warn,h2=warn,tower=warn" — a bare global info, which matches an arbitrary
target. So on today's default production path (enabled = true, chain sync on, UnavailableClaimChainPort)
an operator gets a recurring WARN naming ChainSourceUnavailable and the cycle count, once per
cadence + jitter. The tests prove the emission by capturing rendered subscriber output
(a_cycle_that_cannot_claim_warns_and_names_why asserts both WARN and ChainSourceUnavailable on the real
production adapter), not by asserting a call site exists.

2. Does any silent-inertness path remain?

No new one, and no reachable panic left in the changed code. With sanitized_schedule applied at
:381-384 before the engine is built, cadence ≤ 31d and jitter ≤ 31d, so next_interval_seconds cannot
saturate, Duration::from_secs cannot overflow, and OsJitter's bound.saturating_add(1) (:243) cannot
panic for any input the scheduler can now see. record and every handle accessor use
PoisonError::into_inner, so a poisoned mutex is not a second panic path.

Two residual paths, both out of scope here:

  • A panic anywhere inside ClaimEngine::run_cycle (read-only, unchanged) still kills the detached task for
    the process lifetime — spawn_claim_driver drops the JoinHandle, there is no supervision, and
    cycles_driven() then freezes with no further warning. Pre-existing, not introduced by this PR.
  • NoOperatorWallet warns once at startup and is then permanently inert — that is the
    operator-wallet-read-once latch already filed on DIG-Network/dig_ecosystem#3296. It has not become
    blocking: it emits a named warning naming the condition, which is what my block required.

config.rs reporting unclamped values does not matter for this PR: cadence_seconds / jitter_seconds
have no consumer outside this module, and the driver is the only scheduler that reads them. Sanitizing at the
read is the right seam.

3. Did three rounds of remedy introduce anything new?

One design consequence, not a defect, and it is visible. sanitized_schedule substitutes the documented
default
rather than clamping to CLAIM_SCHEDULE_SECONDS_MAX, so an operator who writes
cadence_seconds = 60 days meaning "claim rarely" gets a 1-day cadence — over 31× more often than asked,
in the more-active direction. It is warned at WARN naming field, rejected value, substituted value and max
(:326-341), and at this head nothing is ever submitted (UnavailableClaimChainPort), so there is no spend
consequence today. Two notes for the follow-on, neither blocking:

  • prefer clamping to CLAIM_SCHEDULE_SECONDS_MAX over substituting the default — clamping is the
    substitution closest to the operator's stated intent, and matters once #3249's real adapter can spend fees;
  • the substitution warns once per process, while the per-cycle line carries the state but not the fact
    that the configured cadence was ignored — so an operator reading a mid-life log sees a cadence they did not
    configure with no nearby explanation.

I also checked the sanitized cadence is the one threaded into with_persisted_fee_window (:400), not the
raw config value — it is, so the fee window and the actual schedule agree. That was the easy way to get this
round wrong.

4. Is the merge argument sound?

One sentence: at this head, rewards_claim.enabled = true means that whenever chain sync is on and an
operator wallet exists, a detached task drives a claim cycle every cadence + jitter — with any
loop-disabling cadence or jitter rejected and warned — and every cycle writes a named, filterable record
to the node's log, today always a WARN naming ChainSourceUnavailable because no chain adapter exists
yet.
That sentence is TRUE.

Follow-on only (no blockers): the clamp-vs-substitute choice and the once-per-process substitution warning →
add to DIG-Network/dig_ecosystem#3296; detached-task panic supervision is a separate, pre-existing item.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RE-GATE (correctness leg) — PASS

Head reviewed: 5fce8477 (5fce847770455862f39d022f0a9a9285fb8e1375), base develop.
Re-pointed from 97ba3160 after a message-only force-push; I verified that independently rather than on report — commit.tree.sha is 530028955201a30aa90d9447a6bebd560f8f5934 on both commits, so the tree, the diff and every line cited below are byte-identical across the two SHAs.

The blocking thread is CLOSED

PRRT_kwDOTHG0ds6hLkkX ("the honesty claim has no reader in the shipped binary") — resolved, with the verification written into the thread. In short:

  • driver.rs:171log_cycle(&status, handle.cycles_driven()) is the last statement of drive()'s loop body, unconditional, not inside any if/match/?. No path a cycle takes can skip it.
  • The startup chain is unbroken and ungated by cfg: server.rs:2216spawn_claim_driver_ifspawn_claim_driverrun_claim_driverrun_claim_driver_indrivelog_cycle.
  • The historical hazard this thread named — run_cycle's ChainUnavailable early returns skipping the end-of-function assignment block — cannot make the new line lie: engine.rs:215 resets every per-cycle field at the top and assigns self.status.state immediately before every early return (:292, :315, :344, :410, :470). engine.status() is therefore always this cycle's state.
  • The proof is a capture, not a call-site assertion: capture_logs() (:1162) installs a real tracing_subscriber::fmt over an in-memory writer; a_driven_cycle_emits_an_event_naming_its_state (:1177) asserts the buffer is EMPTY before the interval elapses, advances the paused clock, then asserts the rendered bytes contain Nominal, cycles_driven=1 and the rewards_claim target. Revert log_cycle's call and the first contains fails against an empty string — non-vacuous. a_cycle_that_cannot_claim_warns_and_names_why (:1231) repeats it through the real UnavailableClaimChainPort, which is today's production path, and asserts WARN.

The acceptance bar is met on its own terms: a cycle fires from the real startup path, and it is now paired with an operator-visible signal — warn! for every non-Nominal state, which with UnavailableClaimChainPort is every real cycle today.

The three new commits, reviewed as a fresh diff against ac01370e

e27f6d2d log_cycle (:161-208) — no finding. Reads &ClaimStatus and a u64; no arithmetic, no unwrap, no allocation on the hot path; info! only on Nominal, warn! otherwise. handle.record() precedes it so the logged cycles_driven always includes the cycle being reported. There is exactly one driver task per process (spawn_claim_driver_from_config is called once, server.rs:2216), so the record-then-load pair has no interleaving writer.

d1f9cac9 bound.saturating_add(1) (:230) — no finding. The draw stays in 0..=bound for every u64, including u64::MAX, and the bound == 0 guard above still short-circuits, so % 1 is unreachable rather than merely harmless.

97ba3160/5fce8477 sanitized_schedule (:305-345), applied at :381-384 — no blocking finding. I weighted the substitution semantics specifically:

  • Can a substitution change behaviour the operator did not ask for and cannot see? It changes it, and it is visible: each branch emits warn! naming field, rejected, substituted and max before returning the default. Nothing is accepted silently, and the default is not accepted silently either.
  • The sanitized cadence is what reaches with_persisted_fee_window(state_dir, cadence_seconds) at :400 as well as drive at :404-405. That is the correct pairing and worth stating, because the alternative is a live defect: had the engine kept the raw value while drive used the substituted one, the engine's cadence gate would have returned CadenceNotElapsed on every cycle forever — a loop that fires, logs, and never does any work. The two agree.
  • A zero cadence is rejected (it would busy-loop the engine) and a zero jitter is deliberately preserved as legitimate — asserted by an_out_of_range_cadence_is_replaced_by_the_default_and_warned, which checks jitter == 0 survives untouched.
  • No new overflow: with both fields bounded by 31 days, next_interval_seconds' sum cannot approach u64::MAX, so its saturation is now unreachable rather than load-bearing. No new panic path in non-test code; every unwrap/expect added is inside #[cfg(test)].
  • an_out_of_range_jitter_is_replaced_and_the_loop_still_drives_cycles (:1342) is the strongest of the new tests: it persists jitter_seconds = u64::MAX to a real config file and drives the real production body (run_claim_driver_in, real OsJitter), then asserts cycles_driven() >= 1 within cadence + CLAIM_JITTER_SECONDS_DEFAULT. Without the sanitizer the interval saturates and the count stays 0 — the test fails for the right reason.
  • ring = "0.17" enters as a real dependency (not dev), which is correct for OsJitter; tracing-subscriber = "0.3" and tempfile = "3" were already [dev-dependencies] (Cargo.toml:373, :393), so the log-capture harness adds nothing to the shipped binary.
  • mod.rs's module doc no longer claims "does nothing in a running node" — doc and code now agree, and the "no wire surface" clause is still true (nothing added to any dispatch table).

Findings

Sev Location Finding
Low (non-blocking, resolved by me) driver.rs:304 / :367 The new const's /// block was inserted directly beneath run_claim_driver_in's doc comment with no blank line, so Rust joins them into one doc comment: the const's rustdoc opens with prose about a function and its generic P, and run_claim_driver_in — the function that reads the operator's config and applies the sanitizer — is left with no doc comment at all. Doc-only, no observable effect, invisible to clippy. Fix is a blank line plus moving the block back above :367. The inline thread carries the full instruction.

Nothing blocking. an_unclamped_max_jitter_bound_does_not_panic_the_driver (:1281) asserts offset <= bound where bound == u64::MAX, which is tautological as a range check — it is still a valid panic test (the pre-fix bound + 1 panics before the assert is reached), so I am not filing it; noted only so nobody later mistakes it for a range proof.

Criteria checked, one by one

  1. Reader on the default production path — yes, driver.rs:171 via server.rs:2216, proven by captured subscriber output.
  2. log_cycle unskippable on every path a cycle takes, including run_cycle's early returns — yes, verified in engine.rs (out-of-scope file, read-only, read for this purpose).
  3. Test vacuity, all four new tests — each fails with only its fix reverted; named above per test.
  4. New panic / overflow paths — none in non-test code.
  5. Substitution observability on a money path — warned, naming field, rejected value, substituted value and max.
  6. Scope — the diff touches only driver.rs, mod.rs (doc + one pub use), server.rs (+11, one call and its comment), Cargo.toml (+6), Cargo.lock (+1). engine.rs / types.rs / port.rs / cadence.rs / config.rs are unchanged, as declared.
  7. Readable-code bar — grep -cE '[^ ] {10,}[^ ]' driver.rs = 0; the 7 lines over 100 columns are all //////! prose, which rustfmt does not wrap; the file is valid UTF-8 (57,736 bytes, no cp1252 stragglers).
  8. Not re-reported, per brief: the EmptyPortNominal fall-through, the operator-wallet read-once latch, and server.rs:2742-2745's payout-destination inconsistency — all on dig_ecosystem#3296. Also not treated as gaps: no ClaimStatus RPC (a user-written condition until #3249), no real chain adapter.

What I did NOT run

cargo test -p dig-node-service, cargo clippy --all-targets -- -D warnings and cargo build. No warm target/ for this crate exists outside another lane's worktree, and using one would mutate a shared checkout; a cold build of this dependency tree does not fit this gate's budget. The reported 21-driver-test / clean-clippy result is therefore unverified locally by me and rests on CI, which the dispatching orchestrator owns. Every claim above comes from reading the blobs at 5fce8477, not from an execution. If CI is red on this SHA, this PASS does not survive it.

Verdict: PASS at 5fce8477. Zero open review threads.

Same-identity review, so this is posted as a COMMENT, not an approval — GitHub returns 422 for --approve from the author identity.

Comment thread crates/dig-node-service/src/rewards_claim/driver.rs
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 10, 2026 21:48
@MichaelTaylor3d
MichaelTaylor3d merged commit 49ae2c6 into develop Sep 10, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/3268-claim-loop-startup branch September 10, 2026 21:48
MichaelTaylor3d added a commit that referenced this pull request Sep 10, 2026
…unning (#607)

* feat(mirror): persist mirror-bond coin ids (#575)

* chore: open lane for #574

* feat(mirror): persist mirror-bond coin ids so a restart cannot double-create

Bond identity was reconstructed from a live chain scan on every read
(`mirror/observe.rs`), with no persistence of its own. A restart, a cold
replica, or a lagging/flaky chain source all rendered a real, unspent,
confirmed bond as "no bonds" -- and because the in-flight suppression is
keyed on pending/submitted audit entries, a bond whose create had already
CONFIRMED was not suppressed either, so the same short scan that emptied
the read surface also cleared the one thing that would have stopped a
second coin being paid for collateral that already exists (dig-node#574).

Persist the (store, root, epoch) -> coin_id mapping in the EXISTING spend
audit record (spend-audit.jsonl) rather than a new store: a mirror-coin
create already writes store_id + AuditedBond{root, epoch} + amount there,
and the coin id itself becomes durable the moment resolve_landed_spends
confirms it. This adds the one missing piece -- the advertised URL a
create carries -- and a read-side query, confirmed_mirror_bond, that
returns the newest CONFIRMED record naming a triple.

Chain stays authoritative. mirror::local_bond::recheck_missing_bonds
never trusts the record: for a held bond the live scan did not cover, it
asks the record for a candidate coin id, then re-verifies that SPECIFIC
coin against chain via the same independent check (chain_bond_verdict)
that verifies an untrusted peer's claimed bond. Only a fresh `Bonded`
verdict is folded back in, as covered; `Unbonded`/`Unverified` fall
through to an ordinary create, exactly as if no record existed.

Version: 0.254.86 (patch -- per #522 the MSI ProductVersion minor field
is exhausted and the counter lives in patch).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mirror): prove the recovery wiring end to end through PassRunner::run

Adds two integration-level tests over the REAL pass pipeline, not just the
isolated recheck_missing_bonds unit tests: a bond missing from the live
scan with a chain-reverified durable record is recovered (no double
create, correct Bonded state reported), and the control -- the same
record but chain disproves it -- correctly falls through to an ordinary
create. Together these are the concrete regression test for the
cold-start/lagging-chain-source double-create scenario the ticket asked
to have measured.

Also refactors in_flight_creates to take the already-folded SpendLedger
instead of re-reading the log itself, so PassRunner::run reads the audit
file once per pass and shares it with the new recovery step, and fixes a
doc comment on in_flight_creates that the recovery step would otherwise
have made stale on landing ("a Confirmed create has a coin the chain
observation already sees" is no longer unconditionally true).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(fmt): wrap long test signatures to satisfy rustfmt

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(clippy): use slice::from_ref instead of cloning for a single-element slice

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump to v0.254.89

Base branch moved to develop after PR #576 merged there at v0.254.88
(main and develop are currently identical), leaving this branch's
carried-forward .88 as a zero-increment against the new base. Bumped
to the next free integer after fetching and verifying both origin/main
and origin/develop tip at .88.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(peer): count accepted relayed circuits in the connected pool (#579)

serve_accepted_relay_conn served every accepted relayed circuit (full mTLS
auth, full L7 peer RPC) while registering it nowhere, so connected_peers
under-reported every relayed inbound peer -- the relay-leg twin of the
direct-inbound defect #402/#523 already fixed.

adopt_inbound_peer_in_pool now dispatches by TraversalKind: Relayed routes to
dig-gossip's already-published adopt_relayed_inbound_handle (v0.32.0, the rev
this repo already pins), every other tier keeps the unchanged
adopt_direct_inbound_handle path. serve_accepted_relay_conn adopts before
serving and releases after, mirroring the direct listener exactly.

Refs: https://github.com/DIG-Network/dig_ecosystem/issues/3124

* fix(cli): guard the exit-code namespace shared with diga against collisions (#582)

* chore: open lane for #3189

* fix(cli): guard the exit-code namespace shared with diga against collisions

dign and diga deliberately share one process exit-code numbering (dig-app's
outcome.rs says so in its own doc comment), so a number is free only if it
is unoccupied ecosystem-wide. dig-node#407 assigned exit 7 to
NODE_UNREACHABLE by checking only this repo's own table, where 7 genuinely
was free -- and collided with diga's NOT_CONNECTED. A reviewer caught it by
hand; nothing failed automatically.

Adds scripts/check-exit-code-collisions.sh: parses both enums' code()/name()
match arms straight from their own source -- this repo's ExitCode, and a
live fetch of dig-app's outcome.rs at its default branch -- and fails if a
number carries two different names, or if either side draws a number from
the reserved shell signal range (126, 127, 128+N). Ships with an 18-case
hermetic test harness (scripts/tests/check-exit-code-collisions.test.sh)
covering the actual #407 collision shape, arm-order independence, arm-count
mismatch, the reserved-range boundary from both sides, the live-fetch path
itself, and fail-closed behaviour on an empty/missing/unreachable table.

Wires a real (unstubbed) invocation into ci.yml's existing "Release-script
tests" job so a collision introduced by a future PR, on either side, is a
red required check on that PR -- not a note a reviewer has to catch. The
fetch retries twice (2s backoff) since this becomes a required, network-
dependent check; a fetch failure still fails closed after retrying, never
silently passing as "diga has no codes".

Updates SPEC.md 8.4 to point at the mechanical guard instead of leaving
"re-check both tables" as unenforced prose, and records that the
extension's WALLET_WS_ERR.NOT_CONNECTED = -33001 is a separate JSON-RPC
error-code space, not a rival of this one. Adds a doc-comment to the
existing transcribed collision test pointing future readers at the live
script as the authoritative check; the transcription remains as a narrower,
hermetic regression pin for the #407 shape specifically.

No renumbering: every currently-assigned code is unchanged.

Refs #3189

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings (#3190) (#583)

* chore: open lane for #3190

* fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 corrupted strings

Replicates dig-node-service::continuation_guard (dig-node#526/#501) into dig-node-core,
dig-wallet, dig-runtime and dig-chat-protocol, line-for-line apart from crate-specific
constants -- ported rather than reinvented, per dig_ecosystem#3190.

Wiring the guard in surfaced 48 pre-existing lost-continuation defects the ticket's own
"no measured corruption in these four crates" note did not anticipate: 36 in dig-node-core,
12 in dig-wallet, mostly test-assertion prose where a multi-line message lost its `\`
continuation and shipped the source's own indentation as a mid-sentence space run (one
as the worse `\n`-plus-indentation variant). All 48 are collapsed to the single space the
sentence always meant, with surrounding indentation and wording otherwise untouched.

Two lines are real column-alignment, not defects, and get a targeted EXCLUDED_LINE_RANGES
entry on dig-node-core instead of a rewrite: download.rs's `claimed(...)` fixture-table
trailing comments, and net.rs's `label : value` debug-print alignment.

Refs https://github.com/DIG-Network/dig_ecosystem/issues/3190
Refs https://github.com/DIG-Network/dig_ecosystem/issues/3130

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL

Automatic half (D1-D4) of the daily mirror-URL reconcile: derived personal-day offset, two-observation hysteresis, nine ordered gates with K sized as a self-funding prefix before any reclaim, `submitted` never `completed`, audit lines gain `reclaim_reason` + `trigger`.

Gates at b4c09866: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e1 (tree byte-identical, `git diff 175304e1 b4c09866` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately).

Refs DIG-Network/dig-node#570
Refs DIG-Network/dig_ecosystem#3203

* feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosystem#3212)

Nine commits from the #3212 serve-path lane, gated at 899cc68f (reviewer review 5130425808,
security comment 5568662836), plus the single semver bump to 0.255.0 for the develop -> main batch.

- store_id/root case normalised at the CapsuleKey boundary; cache delete targets the matched entry
- tier-0 occupancy reads the eviction-aware ledger
- profile-sync outbound budget in bytes; announcer asked first
- melt confirmation depth on the terminal spend, fail-closed
- EngineWarming (-32002) while the peer tier attaches, never -32004
- window completeness derived from the bytes read
- deps: dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2

Refs DIG-Network/dig_ecosystem#3212

* chore: untrack gitnexus-generated agent files (#590)

* chore: untrack gitnexus-generated agent files

These files were generated by `gitnexus analyze` as a side effect of
indexing this repository. They are development-loop private tooling
output, not product code, and carry no secrets. They are removed from
tracking going forward via .gitignore; history is deliberately NOT
rewritten.

Refs #3177

* chore: drop private-repo reference from gitignore comment

The ignore comment named a private repository and an internal issue
number in a public file, which is the same disclosure class this
change set exists to remove; the reference is dropped and the
guidance kept.

* feat(rewards): always-on prover loop engine -- honest liveness, type-enforced self-exclusion, bounded spend (#593)

The always-on reward-prover engine: ~2,000 lines under
`crates/dig-node-core/src/rewards/`, built against the merged `dig-rewards-coin`
SPEC. Library only -- nothing spawns it, and the sole production
`RewardsChainPort` refuses every call, so it cannot spend. Wiring the composed
system is #3265, which carries its own gate.

The epic's premise -- "anytime the process isn't running, rewards are not being
distributed" -- is half wrong, and the false half is the dangerous one. `Sync`,
`NewEpoch` and `InitiatePayout` need no manager authority, so funder downtime does
not stop rewards: it FREEZES THE ENTRY SET while accrual and payouts continue.
Peers that stopped mirroring keep earning; peers that started cannot begin. That
shaped the whole design.

Liveness honesty (SPEC 2.4). The status record carries no `healthy`/`ok`/`up`
boolean and no precomputed staleness, because a wedged loop cannot report its own
wedging -- whatever it last wrote stays there, so a writer-set flag reads true
forever after the failure it exists to reveal. The reader derives staleness from
`last_cycle_completed_at` against `observed_at` and its own clock. A recursive
JSON-key test enforces the absence at every nesting depth; asserting on keys and
never substrings, since `ProverState::Running` legitimately serializes the VALUE
"running". The one legal staleness signal is chain-derived (SPEC 12.4: 48 hours
AND a non-zero reserve, from the singleton's own spend history) and lives on the
distributor read, where a wedged prover cannot fake it.

Self-exclusion is a compile error, not a habit. dig-node#261's lesson is that an
invariant enforced on some paths is not an invariant. `admit` is the single
admission point, checks both SPEC 5.2 coordinates (own peer_id OR a payout puzzle
hash this wallet controls), and mints an `AdmittedPeer` with private fields and no
public constructor -- so `EntryAction::Add` cannot be built by a path that skipped
admission.

A prover's own fault can never strike a peer. `GateError` is a distinct type from
`GateIneligibleReason` and `record_prover_fault` takes `&self`, so SPEC 3.6 clause
4 is enforced by the borrow checker rather than by comment. Without that, a
misconfigured operator -- one missing mirror-collateral epoch ordinal -- would
strike every peer at once and evict its entire 250-entry set in three hours, each
eviction a fee it pays plus a settlement out of its own reserve.

The money bounds are stated where a human reads them (`rewards/mod.rs`): 24
bundles/day, 192 entry actions/day, a fee ceiling of 24x the configured standard
fee, 192 removals/day worst case with 96/day sustained churn. Recorded honestly:
SPEC 6.3's rate bound and fee ceiling are ONE control, not two.

Three gate rounds, every leg fresh-context. Round 3 at this head: reviewer PASS,
adversarial decider RATIFY (leg closed), security CHANGES-REQUIRED on a finding
the decider ratified deliberately -- adjudicated in
https://github.com/DIG-Network/dig-node/pull/593#issuecomment-5601784815 and
carried to #3265 with the remedy corrected, because the proposed fix would have
persisted a poison flag to the very store whose writes were failing.

Found and fixed under gate: a census ordinal off by one in both directions (SPEC
4.6 requires n-1 exactly); an unreachable grace window leaving a named constant
with no reader; a missing `NewEpoch` spend; an absent-ordinal path attributing a
prover fault to peers; and a daily fee ceiling 24x too high because a per-bundle
fee was consumed as a daily ceiling.

Refs DIG-Network/dig_ecosystem#3250

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: serve dig.getRewardProverStatus at Tier::Control (#595)

* chore: open lane for #3269

Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and
the other reward RPC methods to the wire).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(rewards): fail-closed Reward-tier guard + dig-rpc-protocol 0.11 line assertion

- dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10);
  documents the known-red two-version state pending the dig-peer 0.14.0 /
  dig-download 0.23.0 cascade (#3269).
- reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL
  catalogue -- every Reward-named method must be Tier::Control and not
  peer-reachable, so a fifth reward method added later is caught at the wrong
  tier automatically rather than inheriting a wrong default (binds #3261's rule
  node-side).
- peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method,
  since an external integration test cannot see it -- same guard, executed against
  this node's own allowlist rather than only the shared crate's.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* style: remove trailing blank line in reward_methods_tier_guard.rs

* feat(rpc): serve dig.getRewardProverStatus at Tier::Control

Adds the missing handler for PR#595: a new reward_prover_statuses
registry + accessors on Node (empty until #3265 spawns a prover loop,
so the registry read is real, not a stub), a dispatch.rs arm inside
the Method enum match (never the string pre-match), and a
field-for-field mapping from dig-node-core's internal
rewards::state::RewardProverStatus (camelCase-tagged) onto
dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged
struct, camelCase-tagged ProverState value), widening entry_count
u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity
fields.

An all-zero launcher_id (what an uninitialised registry slot
hex-encodes to) is omitted at this boundary rather than rendered as
a real distributor with a plausible-looking id -- the money-hole
class the dig-rewards-coin driver's adversarial gates found three
times.

Tests (in dig-node-core::lib.rs's existing test module, where the
pub(crate) registry accessors are visible) drive the real dispatch
entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm)
and assert field-for-field on the serialized JSON body: populated
registry, empty registry (-> {"statuses": []}), zero-id omission,
tier/peer-reachability, enum-match-not-string-prematch, and
launcher_id filtering. The no-health-boolean / no-staleness
assertion is by key set, not substring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* style: rustfmt the reward-prover-status registry + tests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(deps): bump dig-download 0.23, dig-peer 0.14, dig-rpc-protocol 0.11

Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0
and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching
dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's
two 0.10 lines (main dep + dev-dependency restatement for
openrpc_drift_guard.rs) move to 0.11 to match.

Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth
capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires
dig-peer = "^0.13" in every published version through 0.11.1, so the tree
cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14.
CI will stay red on this commit for that reason, which is expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rpc): close dig-rpc-protocol 0.11 cascade + attribute payout figures

Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer
^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol
split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0,
alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0.

Add a subject-attribution test and doc comments to
reward_prover_status_to_wire: total_paid_out_base_units and
reserve_base_units are per-distributor totals (this distributor's payout to
ALL its mirrors, and this distributor's own reserve), never the querying
node's own earnings and never summed/cross-attributed across distributors.
This is the defect class a sibling adversarial gate found in dig-app#403's
rewards pane, which rendered a distributor total as one mirror operator's
personal earnings and overstated by up to 250x.

Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no
Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a
persisted EligiblePayoutHash verdict.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rpc): silence dead_code on register_reward_prover_status pending #3265

Clippy's non-test lib target has no production caller for
register_reward_prover_status yet, because #3265 (the always-on prover loop
that would call it from bring-up) has not landed -- only tests call it today.
cfg_attr(not(test), allow(dead_code)) stands in for that missing caller
until #3265 wires a real one.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rpc): make the all-zero identity guard non-silent and cover all three fields

Security (blocking) and the adversarial leg both found the same defect in the
zero-launcher_id filter: it checked only launcher_id, so a registration bug
that zeroed store_id or root beside a valid launcher_id would pass through as
a plausible record, and dropping the bad record silently destroyed the
evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact
prohibition.

zeroed_identity_fields() now checks launcher_id, store_id AND root. The
dispatch filter still excludes a record with any zeroed field (never renders
an uninitialised slot as a real distributor), but first fires a
tracing::warn! naming which field(s) were zero, so a bad registration is
observable rather than swallowed. Kept isolated in dispatch.rs rather than
woven into the wire mapping, since this belongs at #3265's writer once that
lands.

Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which
proved the omission but not the observability, and never exercised a zeroed
store_id/root beside a valid launcher_id) with
get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering
both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id,
and asserting the tracing::warn! output via the crate's existing
capture_sync_logs test utility.

Fixed a now-false "Known-red" doc comment on
tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate:
the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade
already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green,
not red. Assertion itself untouched -- still exact-version.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rpc): correct a born-false "shipped dig-app 15.5.0" doc claim

dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and
dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN
and unmerged. Point the doc comment at the real, unmerged consumer instead
so a future reader doesn't take this as evidence a shipped consumer depends
on the guard, which would wrongly discourage relocating it to #3265's writer.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rpc): correct doc placement, assert the zeroed field by name, treat root as an observation

Three findings from the correctness gate on PR#595 at 134864a9.

1. The zeroed-identity helper's doc block was spliced onto the end of
   reward_prover_status_to_wire's block with no separator, so the wire-mapping
   rationale documented a boolean predicate and the mapping function was left
   with no doc at all. Each doc block now sits above the item it describes.

2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn
   emits launcher_id as a structured field on every fire, so the property the
   guard exists to add -- naming which field was zeroed -- was unasserted.
   Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green.
   The test now asserts the zeroed_fields value itself, which the fixture makes
   exact and disjoint across cases.

3. `root` is an observation, not an identity. A registered prover that has not
   completed its first cycle plausibly has no root, and a writer that zero-inits
   it would have made a healthy prover invisible. A zeroed launcher_id or
   store_id still excludes the record; a zeroed root alone warns and returns.

Refs #3269

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rpc): restore zeroed_fields structured field dropped from the pushed warn

The previous commit (080be3df) landed with `zeroed_fields = ?zeroed` missing
from the tracing::warn! call in the GetRewardProverStatus filter -- a
one-line regression introduced while proving the new log assertion goes red
without it, never restored before the commit was made. Without this field
the log line never names WHICH field was zero, so an operator sees only
that something was excluded, and the test asserting `zeroed_fields=[...]`
per case would fail. Restored; all 7 reward-prover-status tests green.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rpc): split zeroed-field logging by level -- WARN for a missing
identity, DEBUG for a zeroed root

A zeroed launcher_id or store_id is a real registration bug: the record is
excluded and now logs at WARN, naming the exact field(s) via
`zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle
state, not a fault: the record is still returned, and now logs at DEBUG
instead of WARN, so an operator polling this endpoint sees warn-level
volume proportional to real registration bugs, not to every
not-yet-cycled prover on every poll.

Updated the doc comments on `zeroed_fields`, the dispatch filter and the
test to describe the level split, and extended the regression test to
assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value.
Proved both directions: flipping the DEBUG branch back to WARN turns the
test red on the level assertion; flipping the field-name assertion back to
a bare `contains("launcher_id")` would have passed unconditionally (the
prior tautology) and is no longer possible since the assertions now pin
`zeroed_fields=[...]` plus the level string.

Refs #3269

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards): peer-side claim loop -- watch distributors, claim on cadence (#3251) (#594)

* feat(rewards): peer claim loop skeleton -- discovery, cadence, config, claim port

* test(rewards): write all twelve acceptance tests for the peer claim loop

* feat(rewards): wire the seven rewards_claim submodules into the crate

mod.rs declared no submodules, so types.rs/port.rs/config.rs/cadence.rs/
parser.rs/engine.rs/hints.rs (1,435 lines, 25 tests) were never part of the
crate and never compiled. Declare them and re-export the public surface.

* style(rewards): cargo fmt the rewards_claim submodules

* chore(deps): bump dig-node-control-interface 0.33->0.35, dig-rpc-protocol 0.10->0.11.0

dig-rpc-protocol 0.11.0 is merged and tagged upstream; the other dig-*/chia-*
deps of dig-node-service were already at the latest permitted-by-caret version
in Cargo.lock. crates/dig-node-core/Cargo.toml is untouched (#3250's file set).

* chore(deps): revert dig-node-control-interface and dig-rpc-protocol bumps

Both create a duplicate-version split in this PR's scope and neither can be
closed without editing a sibling crate's manifest this lane does not own:

- dig-rpc-protocol 0.11.0 duplicates against dig-node-core/Cargo.toml:194
  ("0.10.2"), which is #3250's live file set (dig-node#593).
- dig-node-control-interface 0.35.0 duplicates against
  dig-wallet/Cargo.toml:81 ("0.33"), a sibling crate this lane does not own;
  the observed Clippy break (BalanceAsset/Asset type-identity mismatch,
  missing url_reconcile/url_current/urls fields) came from THIS duplicate,
  not from dig-rpc-protocol.

Both belong to their own sequenced dep-bump unit of work, not this ticket.

* fix(rewards-claim): fault laundering, permanent no-entry blacklist, fee ceiling magnitude

Three independent gates on dig-node#594 (51516e62) found four logic defects; this
addresses A, B and C per the corrected fix brief (D is documented only, not fixed
here per the brief's own instruction).

Defect A -- the anti-silence surface laundered every real fault into `Nominal`:
- A1: `fault_reported` had no fault-bearing ClaimLoopState to fall through to, so a
  chain adapter erroring every cycle read `Nominal` forever. Added
  `ClaimLoopState::Faulted { cycles }`, outranking Nominal/ClaimableButNotClaiming,
  under ChainSourceUnavailable.
- A2: inverted the test that asserted A1's bug as correct behaviour.
- A3: `ClaimableButNotClaiming` compared a per-cycle snapshot
  (`distributors_claimable`) against a lifetime-cumulative counter
  (`claims_submitted`), so it latched healthy forever after one lifetime success.
  Added `claims_submitted_this_cycle` (per-cycle) as the correct comparand; kept
  `claims_submitted` as a cumulative counter.
- A4: `last_discovery_at`/`last_cycle_at` were stamped even on a failed discovery
  or an all-faulted cycle, destroying the staleness signal a reader depends on.
  Now only stamped on success; added `last_attempt_at` to prove liveness
  separately. `fault_reported` and `distributors_faulted` now reset per cycle
  instead of latching for the process's lifetime.

Defect B -- "terminal, stop retrying" was implemented as a process-lifetime
blacklist (`terminal_no_entry: HashSet<Bytes32>`, never cleared). That blocked
SPEC 12.5 clause 2's re-entry path (evicted, re-challenged, re-admitted never
claims again) and permanently punished a peer that discovered a distributor
before the funder's AddEntry landed. Removed the blacklist entirely -- `own_entry`
is a cheap chain read, re-issued every cycle for every candidate, matching clause
3's "never cache across cycles". `NoEntrySlot` is now a per-cycle observation, not
a lifetime sentence.

Defect C -- the fee ceiling didn't bind anything and there was no aggregate cap:
- C1: default `CLAIM_FEE_CEILING_MOJOS_DEFAULT` lowered from 1_000_000_000
  (transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`, sized for a mirror-coin
  spend) to 200_000 -- 2x the observed routine Chia fee range (5,000-100,000
  mojos), so it actually binds instead of leaving 4-5 orders of magnitude of
  slack.
- C2: added a per-cycle aggregate fee budget
  (`max_cycle_fee_budget_mojos`, default 10x the per-claim ceiling) checked
  across all claims in a cycle, closing the attacker-cost gap where funding K
  distributors could force a victim to spend K x the per-claim ceiling per cycle.
  New `ClaimOutcome::SkippedCycleBudgetExhausted`.

Tests: rewards_claim test count 26 -> 37 (11 new: repeated_discovery_faults_never_
read_as_nominal, failed_discovery_leaves_last_discovery_at_unchanged, a_reported_
fault_surfaces_as_faulted_not_nominal, a_lifetime_submission_does_not_mask_a_
later_cycle_that_submits_nothing, no_entry_slot_then_re_admitted_produces_a_claim_
on_the_later_cycle, distributors_each_under_ceiling_do_not_collectively_exceed_
the_cycle_budget, the_default_per_claim_ceiling_actually_binds_a_routine_fee, plus
renamed/rewritten no_entry_slot_is_non_terminal_and_re_checked_every_cycle).

Refs #3251

* fix(rewards-claim): refuse a claim entry for the wrong payout puzzle hash

CI fix: cadence.rs's RewardsClaimConfig literal was missing the
max_cycle_fee_budget_mojos field added in the previous commit (E0063,
caught by CI's Clippy/Test jobs -- the local cargo check for this
workspace is too slow to use as the compiler here).

Defect E (security-gate finding, folded in before this pass closes):
submit_initiate_payout was called with entry.payout_puzzle_hash -- whatever
the chain port handed back -- with no check against this node's own
own_payout_puzzle_hash. UnavailableClaimChainPort is the only production
adapter today so nothing can exploit this yet, but the whole point of the
ClaimChainPort seam is that #3249 swaps in a real adapter with nothing
above it changing, so deferring this would ship the landmine live with no
review pass watching for it. Added an equality guard before the spend:
a mismatch refuses to submit, counts
(ClaimStatus::claims_refused_payout_mismatch), surfaces its own named
outcome (ClaimOutcome::PayoutPuzzleHashMismatch), and is reported as a
fault (a divergent entry means the port is confused or hostile, not that
there is nothing to claim) -- never corrected by substituting our own
hash and proceeding.

Defect D: documented, not wired, per instruction -- added the "not yet
wired into node startup" paragraph to mod.rs's module doc (the PR body
carries the same paragraph) so the next reader arrives at the caveat in
the code, not only in a merged PR description.

Refs #3251

* fix(rewards-claim): B1 -- ClaimableButNotClaiming is a magnitude comparison, not a zero-test

submitted_this_cycle < claimable_this_cycle now fires the anti-silence state, carrying
the shortfall as ClaimableButNotClaiming { claimable, submitted }. The previous
submitted_this_cycle == 0 zero-test let one submission mask any number of same-cycle
skips (claimable=10, submitted=1 read Nominal).

Also folds in B3's precedence fix (ChainSourceUnavailable > Faulted >
ClaimableButNotClaiming > Idle > Nominal) and the per-distributor
payout_hash_mismatches_this_cycle counter so a per-distributor fault can no longer
pin the cycle-wide Faulted state, plus R2's rename of terminal_no_entry_slot to
no_entry_slot_this_cycle now that it is no longer terminal.

* fix(rewards-claim): B2/B3 -- value-ordered budget with rotation, per-distributor fault isolation

B2: run_cycle now splits into a pre-budget phase (asset/entry/hash/threshold checks,
producing the claimable set) and a budget phase, ordering the claimable set by accrued
value descending before applying the fee ceiling and cycle budget. Dust distributors
(low accrued value regardless of attacker-controlled fee) now sort last and are the
ones the budget drops, closing the claim-suppression attack where ten high-fee dust
distributors could consume the whole cycle budget ahead of a victim's real earnings.
A rotation_cursor tie-breaks only WITHIN equal-accrued-value tiers so a genuinely
tied honest tail that exceeds one cycle's budget every cycle still rotates through
and is eventually served, rather than dropping the same tail forever.

B3: the payout-hash mismatch check in evaluate_pre_budget now increments the
per-distributor payout_hash_mismatches_this_cycle counter instead of setting
fault_reported, so one hostile or buggy entry can no longer pin the cycle-wide
Faulted state and bury ClaimableButNotClaiming for every other healthy distributor.

R2: terminal_no_entry_slot -> no_entry_slot_this_cycle throughout.

* fix(rewards-claim): R5 -- document not-yet-wired loop; persist B2 rotation cursor

An operator reading their own rewards-claim.json and seeing enabled: true has no way
to know from that file alone that no startup path constructs a ClaimEngine yet
(#3268) -- mod.rs said so, but a config file reader does not arrive at a module doc.

Also gives RewardsClaimConfig a rotation_cursor: Option<Bytes32> field so B2's
tie-break cursor survives a save/load round-trip -- an in-memory-only cursor resets
on every restart, which would starve a legitimately tied honest tail forever on any
node that restarts daily.

* fix(rewards-claim): clippy collapsible-match + SPEC v0.1.3 wording refresh

Collapses the nested if into the outer match arm in run_cycle (clippy::collapsible_match).
Also refreshes NoEntrySlot / no_entry_slot_this_cycle doc comments now that
dig-rewards-coin v0.1.3's SPEC §12.5 amendment is merged and tagged: absence is
terminal for one claim attempt only, never for the distributor, must not be cached,
and must not accumulate into a permanent exclusion set -- confirming rather than
diverging from the re-read-every-cycle behaviour already implemented.

* fix(rewards-claim): add missing rotation_cursor field in cadence.rs test literal

Struct literal in the cadence test module was not updated when RewardsClaimConfig
gained rotation_cursor (R5 commit) -- CI's Clippy/Test jobs caught the missing field
(E0063) that a local cargo check could not (killed by memory pressure before this
workspace-wide build completed).

* fix(rewards-claim): F1 -- ChainSourceUnavailable is per-cycle, never a latch

compute_state() compared against self.state -- last cycle's OWN computed
output -- so once any cycle took an Unavailable port path, every later
cycle re-asserted ChainSourceUnavailable forever, even after the chain
came back and real claims were submitting. A node still syncing, or one
dropped connection, was enough to trip this permanently.

Add ClaimStatus::chain_unavailable_this_cycle, reset to false at the top
of every run_cycle and set true only on a cycle that actually took the
Unavailable path; compute_state now reads that flag instead of
self.state, so the reading is live again.

Test: a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process
(engine.rs) drives cycle 1 unavailable, cycle 2 healthy with a
submission, and asserts cycle 2 reads Nominal. Plus a compute_state-level
regression in types.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): F3/F4/F5 -- fix stale counters, dedup candidates, correct version doc

F3: reset EVERY per-cycle counter (distributors_known/with_own_entry/
claimable/faulted, claims_submitted_this_cycle, no_entry_slot_this_cycle)
at the TOP of run_cycle, before any early return. The three
ChainUnavailable early-return paths skip the end-of-function assignment
block entirely, so a cycle that hit one used to leave the PRIOR cycle's
counts sitting on self.status while last_attempt_at stamped fresh for
THIS cycle -- a stale count under a fresh timestamp, exactly what SPEC
§2.4's staleness reasoning forbids. types.rs's doc sentence for
no_entry_slot_this_cycle now correctly says it is dated by
last_attempt_at (the field stamped unconditionally every cycle), not
last_cycle_at.

F4: dedup `candidates` by launcher id before phase 2. A real adapter
scanning §1.3 launch comments across every (store_id, root) this node
mirrors can plausibly return the same launcher id twice; without dedup
phase 2 would evaluate it twice and submit InitiatePayout twice against
one entry slot in one cycle -- the second spend is invalid but the fee
is paid anyway.

F5: dig-rewards-coin is v0.1.3, published on crates.io -- correct the
stale "v0.1.1" module-doc claim.

Tests: a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale
(F3), a_duplicated_launcher_id_submits_exactly_once (F4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards_claim): fold payout-hash mismatches into the shortfall predicate (F2)

A payout-hash mismatch never enters the eligible set, so it was counted in
NEITHER distributors_claimable NOR claims_submitted_this_cycle -- the
shortfall lived in neither term of compute_state's magnitude comparison.
All-K-distributors mismatching therefore read Nominal (falsely healthy).

Fold payout_hash_mismatches_this_cycle into the comparison's denominator:
submitted < claimable + mismatches. The result is ClaimableButNotClaiming
(a shortfall), never the cycle-wide Faulted -- Defect B3 stays fixed.

Inverts the assertion at what was engine.rs:1305
(a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors):
it previously asserted ClaimLoopState::Nominal across three cycles of an
ongoing mismatch, which pinned the defect as intended behaviour (an
A2-class test). It now asserts ClaimableButNotClaiming { claimable: 1,
submitted: 1 }.

Adds all_distributors_mismatching_is_a_shortfall_not_nominal, covering the
brief's exact "what if every distributor refuses for the same reason" case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): F1/F3 -- AtomicU32 in fake ports, not Mutex, keeps discovery Send

CI's Clippy job (the compiler for this crate, per brief) caught it: holding
a std::sync::MutexGuard across the .await in FlakyThenHealthyPort and
HealthyThenUnavailablePort's discover_distributors made the returned future
not Send, which #[async_trait]'s generated trait signature requires.

Neither fake needs a lock -- each holds one call counter, incremented once
per call, never read-modify-written across an await point. AtomicU32's
fetch_add removes the guard (and the Send bound violation) entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): F7 -- persist the fee window and cadence gate across restart

The per-cycle aggregate fee budget and the 24h cadence clock both lived only
in memory: `spent_this_cycle_mojos` was a `run_cycle` local and nothing on
disk recorded a completed cycle. Every fresh process got a full
`max_cycle_fee_budget_mojos` and an empty cadence clock, so a node stuck in
a crash-restart loop could spend unbounded XCH on fees, one full budget per
restart.

Adds three `#[serde(default)]` fields to `RewardsClaimConfig`
(`fee_window_start_unix`, `fee_spent_in_window_mojos`,
`last_cycle_completed_at`) and a new opt-in `ClaimEngine::with_persisted_fee_
window(dir, cadence_seconds)` that:
- restores the window/cadence state from `dir` at construction,
- refuses to start a cycle until the cadence has elapsed since the last
  completed one,
- rolls a fresh budget window only once the cadence has elapsed since it
  opened, otherwise keeps enforcing the budget against the persisted spend,
- persists the spend BEFORE every chain submission (write-then-spend), never
  batched to cycle end, and persists the completed-cycle timestamp when a
  cycle finishes.

Engines that never call `with_persisted_fee_window` (every pre-F7 test) are
unaffected -- this is additive, opt-in state beside the existing rotation
cursor, not a change to B2's value-ordering or rotation mechanism.

`ClaimStatus`'s own counters stay in-memory on purpose (observability, meant
to reset on restart); only the spend bound and the cadence gate persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): F7 -- update cadence.rs test literal for new persisted fields

The three new persisted RewardsClaimConfig fields (fee_window_start_unix,
fee_spent_in_window_mojos, last_cycle_completed_at) broke this crate's only
remaining full struct literal outside config.rs/engine.rs's own test
modules -- E0063 missing fields, caught by CI's Clippy job. Switched to
..RewardsClaimConfig::default() so the next added field cannot break this
literal again, the same fix already applied once before for rotation_cursor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): F8/F14 -- atomic state write, fail closed on a corrupt window

Salvaged from a lane killed by a weekly cap before it could commit. Uncompiled at
commit time; CI is the compile signal.

Covers the fourth gate pass findings on the F7 persisted spend bound:

- F8: RewardsClaimConfig::save_to now writes atomically (temp file + rename in the
  same directory), reusing the pattern already used by mirror/reconcile_state.rs
  for the same class of state. load_from distinguishes an ABSENT file (clean first
  run, defaults are correct) from a PRESENT but unparsable one, which fails CLOSED:
  the window is treated as fully spent and nothing is submitted. Never Default, and
  never a silent clamp downward, which would hand back the budget the corruption
  was hiding.
- F14: the budget comparison uses saturating arithmetic so a corrupt disk-seeded
  fee_spent_in_window_mojos cannot panic under the release profile's
  overflow-checks.
- F9/F10/F12/F13 in progress in the same files.

Refs #3251

* fix(rewards-claim): negate with ! rather than the unimported Not trait

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(rewards-claim): F13 -- correct stale test literals to the folded shortfall

compute_state (types.rs) already reported the folded shortfall
denominator (distributors_claimable + payout_hash_mismatches_this_cycle)
as `claimable` -- that part of F13 landed in f478516a. The two engine.rs
tests asserting this state were written against the pre-fold, un-folded
numbers and never updated, so CI showed the implementation producing the
correct folded value (`claimable: 2`, `claimable: 1`) while the test
literals still expected the stale un-folded one (`claimable: 1`,
`claimable: 0`).

Update both literals -- and the comments describing them -- to the
folded values the F13 fix actually produces. No production code change;
compute_state's predicate and payload were already correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards-claim): add ClaimOutcome::Faulted variant

Add the seventh ClaimOutcome variant: the type could only say a peer was
legitimately not paid, never that a chain call failed. Carries the launcher
id, a bounded (200 char) copy of the chain port's error text, and whether a
pre-committed fee was reversed, so a reader can tell no money moved.

Engine wiring at the two fault arms (engine.rs:332, :377) follows in the
next commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): both fault arms now push ClaimOutcome::Faulted

engine.rs:332 and :377 used to increment `faulted` and discard the
outcome, leaving a definitively-failed claim absent from the outcome
stream -- indistinguishable from a cycle that never touched that
distributor. Both PreBudgetResult::Fault and BudgetPhaseResult::Fault
now carry the chain port's (bounded) error text, and the
submit_initiate_payout failure path also carries the fee it reversed,
so a reader can tell no money moved. The counter stays; it is not a
substitute for the outcome.

7 call sites needed updating: 3 PreBudgetResult::Fault constructions
(reserve_asset_id, own_entry, payout_threshold), 2 BudgetPhaseResult::Fault
constructions (required_fee_mojos, submit_initiate_payout), and the 2
consuming match arms -- exactly the set that was silently discarding a
failure before this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(rewards-claim): a failed submission produces a Faulted outcome

Regression for the rework: reuses F12's fixture (a submission that
definitely never broadcast) to prove both facts from one cycle -- the
outcome exists and carries the reversed fee, and the persisted window
still reflects zero net spend. Also fixes a rustfmt diff on the
PreBudgetResult::Fault variant Clippy's Rustfmt job flagged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): delete fee_window_poisoned, stop latching self-healing state

Finding 1 (dig-node#594 pass 6): a future-dated clock is self-healing by
construction (`t > now` goes false the moment real time passes it), but the
engine ORed it into `self.fee_window_poisoned` and set that field `true`
permanently -- an RTC glitch or VM resume froze the claim loop forever instead
of until the skew passed. This is the third instance of one mechanism (pass 3
latched ChainSourceUnavailable, pass 4 left a stale cadence-gate `state`), so
the fix removes the FIELD, not just the bug: with no `fee_window_poisoned` on
`ClaimEngine`, `self.fee_window_poisoned = true` is a compile error, not a
convention to remember.

Per-cycle conditions (corrupt + future-dated-clock) now live in a
`CycleConditions` value built fresh at the top of every `run_cycle` from `now`
plus a freshly reloaded `RewardsClaimConfig`, used, and dropped -- never
stored on the engine. `corrupt` is now re-read from disk every cycle too (it
previously latched at construction only), matching what
`ClaimLoopState::PersistedStateCorrupt`'s doc already claimed but the code
never did.

Rewrites the single-cycle f10 regression into a two-cycle test: cycle 1 with a
future-dated clock refuses; cycle 2, after the clock catches up and the
cadence elapses, MUST claim. The old one-cycle version was green whether the
latch bug was present or not.

Refs #594

* fix(rewards-claim): satisfy clippy doc-list indent and rustfmt

Clippy failed with 3x doc_lazy_continuation on the PersistedStateCorrupt
doc comment (types.rs:165-167): continuation lines of a `-` bullet must
be indented under the marker, not left flush. Indent them.

Rustfmt failed on the new fail_reserve_asset_for early-return in
FakeChainPort::reserve_asset_id (engine.rs:888): the Err(...) call
exceeded the line-length limit unwrapped. Let rustfmt wrap it.

Refs #594

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(rewards-claim): red proof for corrupt-then-repaired stale read

Cycle 1 refuses a corrupt fee-window file; the file is then repaired to
valid values with a fully-spent window and a recent completed-cycle
time. Cycle 2 must neither grant a fresh budget nor skip the cadence
gate. Fails against current `with_persisted_fee_window`, which loads
the three fee-window fields once at construction and never refreshes
them from the per-cycle `cfg` -- see engine.rs:149-157, #594.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(rewards-claim): resync fee-window fields from disk every cycle

`with_persisted_fee_window` only loaded fee_window_start_unix,
fee_spent_in_window_mojos and last_cycle_completed_at once, at
construction. Once the now-deleted fee_window_poisoned latch stopped
masking it, a file corrupt at construction and repaired later left
those three fields stuck on poisoned()'s None/0/None placeholders --
a fresh budget and a skipped cadence gate, and persist_fee_window then
overwrote the repaired disk values with them.

CycleConditions now carries the three fields from the SAME freshly
reloaded cfg it already used for the corrupt/future-dated check, and
run_cycle copies them onto self before the cadence gate or window-roll
logic runs, but only on a read that is neither corrupt nor future-
dated. This also fixes Finding 2b: future_dated_clock now reads cfg's
own clocks instead of self's stale ones. Corrects the doc claim at the
old lines 236-238 to describe what the code now does for both halves.

Closes #594.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(rewards-claim): make disk the sole store for the fee window

Delete `fee_window_start_unix`, `fee_spent_in_window_mojos` and
`last_cycle_completed_at` from `ClaimEngine`. `run_cycle` already re-reads
`RewardsClaimConfig` fresh every cycle for the corrupt/future-dated check,
so caching a copy on the engine bought nothing and cost exactly the
stale-read defect class F16 just fixed. A local `FeeWindowState`, scoped to
one `run_cycle` call, now threads the in-flight values through
`evaluate_budget_phase`/`uncommit_fee`/`persist_fee_window` instead. With
no field left to cache into, a future `self.fee_window_start_unix = ...`
outside this file is an E0609 compile error, the same enforcement
`fee_window_poisoned`'s removal already has.

No behaviour change: every early return, the corrupt/future-dated fail-
closed path, the cadence gate, the window roll, write-then-spend
pre-commit/uncommit and the per-claim ceiling are unchanged -- only where
the three values live changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat(rewards): chain port + listRewardDistributors (unit 2) (#604)

* chore: open lane for #3269 (unit 2 -- rewards chain port + listRewardDistributors)

* chore(rewards): add dig-rewards-coin 0.2 dep; record blocked-reader finding

dig-rewards-coin 0.2.0 is published but ships no chain reader (its own
state.rs module doc: SPEC 12.1's read_distributor is withheld pending
DIG-Network/dig_ecosystem#3267). Separately, no registry in this codebase
records which distributors this node funds. A "real" RewardsChainPort
adapter over 0.2.0 therefore has no honest way to answer any of the four
trait methods with live data yet -- reimplementing read_distributor or
inventing a funded-distributor registry would be exactly the unreviewed
money-shape guess kernel invariant 6 says to escalate instead of build.
UnavailableChainPort remains the only production adapter; port.rs records
the finding for the next unit.

Refs #3269

* docs(rewards): revert dep add, name both blockers with evidence in port.rs

Per L1 direction: an unused dig-rewards-coin dep with no consumer is inert
weight and would want whichever version ships the reader (0.3.0+, PR#6 open
against DIG-Network/dig-rewards-coin), not 0.2 -- so it's reverted here and
belongs in the unit that actually consumes it.

Expanded the port.rs module doc to name both blockers explicitly with what
was read (state.rs:1-31, #3267, the open reader PR) and the negative grep
that found no funder-ownership registry anywhere in the tree, plus why
serving dig.listRewardDistributors through UnavailableChainPort was
considered and rejected (false capability signal; the exact "dispatch
surface with no function behind it" pattern dig-node#593 was the last PR
allowed to land on).

No RewardsChainPort adapter, no Node wiring, no dispatch arm -- all three
reward methods stay -32601 pending #3267 and a funder-ownership registry
(parallel tickets, both required).

Refs #3269

* feat(rewards): durable funder-ownership registry (identity only) (#606)

* feat(rewards): durable funder-ownership registry (identity only)

Records WHICH reward distributors this node funds -- launcher id plus the
store id when the funding act knew it -- and nothing else. No amount can
be recorded: every money figure here is chain-derived and goes stale, and
dig_ecosystem#3286's wrapping u64 share multiply means a figure crossing
this boundary can already be wrong. Durable storage would make it
permanent.

Persistence mirrors rewards_claim::engine::ClaimEngine: an optional state
directory (absent = inert, so tests and default builds need no disk),
atomic write, and a corrupt record is never overwritten. The set is never
cached on the registry -- every read re-reads the file -- so no transient
state lives on the struct across calls (the engine's F16/F18 discipline).

The read outcome is closed and distinguishes funds-nothing from every
unknown: NotConfigured (no state dir / dir missing / nothing written yet),
PersistedStateCorrupt and IoFailed. A corrupt record is quarantined by
COPY and left in place, so the next read is corrupt too rather than
decaying into an empty list -- SPEC 2.4 clause 1 in the place it costs
most, since an empty dig.listRewardDistributors tells an operator it funds
no distributors.

Node carries it in a OnceLock slot with pub(crate) accessors, mirroring
mirror_pointers and reward_prover_statuses. Nothing installs it in
production yet: no dig-node code funds a distributor, and the startup
wiring belongs to dig_ecosystem#3268, so the slot is marked the same way
register_reward_prover_status is.

Refs #3285

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rewards): drop a duplicated funded_distributors initializer

Two test-only `Node` literals got the slot twice (E0062), because the
inserted line's own indentation made the wider-indented site match twice.

Refs #3285

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268) (#605)

* feat(rewards): wire the peer claim loop onto a cadence driver from real startup (#3268)

The peer reward-claim engine shipped complete and tested in #594 but INERT:
nothing constructed it, so the 86400s cadence never fired while
`rewards_claim.enabled` defaulted to `true` -- a config asserting a subsystem
is on while nothing runs.

`rewards_claim/driver.rs` is a SCHEDULER, not a chain adapter: it derives this
node's own payout puzzle hash, loads `RewardsClaimConfig`, builds a
`ClaimEngine` against the only production port that exists
(`UnavailableClaimChainPort`, until #3249 lands a real one) and drives
`run_cycle` every `cadence_seconds + jitter`, jitter drawn from the OS CSPRNG.
`server.rs`'s `serve_with_shutdown` makes exactly one call into it, beside
`self_heal::spawn_driver_if_service()`.

`enabled = true` now means: a background task exists, drives a counted cycle
per interval, and its outcome is readable in-process as a NAMED state. With
`UnavailableClaimChainPort` every cycle honestly reports
`ChainSourceUnavailable` -- the gap is loud instead of silent.

Anti-silence: `ClaimLoopHandle` carries a monotonic `cycles_driven` counter
alongside the status, because `Idle` before the first cycle is correct and
honest, so status alone cannot tell "scheduler never fired" from "nothing was
claimable". The gate takes an INJECTED handle rather than reading the
process-wide singleton, so `ClaimDriverRefusal::{Disabled, ChainSyncDisabled,
NoOperatorWallet}` and "spawned but never ticked" are four pairwise-distinct
readings a test asserts in-process.

Nothing goes on the wire: no RPC method, dispatch row, handler or OpenRPC
entry. `ClaimStatus` stays off the wire until #3249's real adapter lets the
status surface be re-derived against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rewards): silence the deliberately-ignored fake-port argument (#3268)

`OneDistributorPort::own_entry` ignores the puzzle hash the engine passes in
on purpose -- the fake always returns the entry keyed to `entry_keyed_to` so
the ENGINE's own comparison is what decides claimable vs. refused. Named it
`_payout_puzzle_hash` (clippy `-D unused-variables`) and moved the rationale
onto the parameter, where the next reader meets it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(rewards): close the untested joint between the claim gate and the drive loop (#3268)

`decide_claim_driver` was tested and `drive` was tested, but the production
body joining them -- load the config from the state dir, derive the engine,
reach `drive` -- was exercised by nothing. That is the exact shape of #594,
which shipped a complete, fully-tested and entirely inert claim engine: had
this body returned early, built the engine wrong, or never reached `drive`,
every test on this change would still have passed and a real node would still
never claim.

Split `run_claim_driver` on the same `load` / `load_from` pattern the config
itself uses: `run_claim_driver_in(state_dir, own_payout_puzzle_hash, port,
handle)` holds the whole body and is generic over the port, and
`run_claim_driver` is reduced to the wallet-derivation adapter that cannot be
reached from a test. Adds two tests through the real body: counted cycles from
a written config (zero before the interval, exactly one per interval after),
and `UnavailableClaimChainPort` reporting `ChainSourceUnavailable` by name on a
driven cycle -- proving the production adapter path is reached, not only a fake.

No behaviour change: same config, same engine construction, same port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rewards): settle before advancing, and keep the wrapped assertion out of rustfmt's reach (#3268)

Two repairs to the new composition tests:

- The `ChainSourceUnavailable` test advanced the paused clock before the
  spawned body had reached its first `sleep`, so the timer was not yet
  registered and the advance bought no cycle at all -- it read zero cycles, not
  a driven one. A `settle()` first, mirroring the counted-cycles test.
- rustfmt rejoined a `\`-continued assertion message into one line, leaving 14
  literal spaces mid-sentence and tripping the repo's own
  `continuation_guard`. `concat!` states the wrap explicitly, so no formatter
  pass can reintroduce the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(rewards): emit a per-cycle event so the claim loop has a reader (#3268)

The adversarial gate blocked #605 on this: the PR justified itself by making
an inert subsystem loud, but nothing in the shipped binary could hear it.
ClaimLoopHandle had no caller outside driver.rs tests, drive() emitted no
event, and all three tracing calls fired only on paths where the loop does
NOT run -- so on the default path (enabled=true, chain sync on) the
observable output was identical to before the PR: silence. Today that
silence covers a permanent ChainSourceUnavailable; after #3249 it would also
cover Faulted, PersistedStateCorrupt and ClaimableButNotClaiming.

log_cycle() now names the state and the cycle count after every cycle --
info for Nominal, warn for everything else, because "this peer is earning
nothing and here is why" is a warning, not routine chatter. Tested by
capturing the subscriber output rather than asserting the call site exists,
since this ticket exists because a guarantee that cannot be observed in a
running node is not a guarantee.

Refs DIG-Network/dig_ecosystem#3268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rewards): stop a u64::MAX jitter bound panicking the claim driver

`OsJitter::jitter_seconds` computed `bound + 1` for its modulus. `jitter_seconds`
comes from the node's persisted `rewards_claim` config and is not clamped, so a
config carrying `u64::MAX` overflow-panicked inside the detached claim-driver
task -- which has no restart and emits no further log output, so the claim loop
would die silently for the rest of the process lifetime.

`saturating_add(1)` keeps the draw within `0..=bound` for every input; the
composed `next_interval_seconds` range is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rewards): sanitize the claim schedule so no config value silently disables the loop

`next_interval_seconds` saturates instead of panicking, so a persisted
`jitter_seconds = u64::MAX` no longer crashes the driver -- it schedules the
next cycle ~585 billion years out. The claim loop then never fires again: no
cycle, no `log_cycle` line, and a permanent, reassuring `0` cycle count. That is
#594's inert-but-green shape reopened one level up, in the config file.

`run_claim_driver_in` now sanitizes both schedule fields where it reads them,
before either reaches the engine's fee window or `drive`:

- `CLAIM_SCHEDULE_SECONDS_MAX = 31 * 24 * 60 * 60` (31 days) -- above every
  documented default (86,400s cadence, 3,600s jitter) and above "claim
  monthly", while excluding everything that means never.
- out of range (or a zero cadence, which would busy-loop) substitutes the
  published default and emits `tracing::warn!` naming the field, the rejected
  value and the substituted one. Nothing is accepted silently.

`config.rs` is untouched: it keeps reporting what is on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(release): v0.257.0 -- the reward distributor lifecycle starts running

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant