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)#605MichaelTaylor3d wants to merge 4 commits into
Conversation
306f27c to
962bf60
Compare
Security audit — PASSHead SHA audited: Attack surfaces worked
Not a finding, noted for the record
Not covered
Verdict: PASS. |
…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>
4cc49f2 to
ac01370
Compare
ADVERSARIAL GATE (third leg) — CHANGES-REQUIRED @
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
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)
driver.rs,OsJitter::jitter_seconds(~line 180):bound + 1overflows ifjitter_secondsin the persisted config is everu64::MAX(no upper-bound clamp inconfig.rs). Debug build panics; release wraps to% 0which 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. Suggestchecked_add/saturating_addfallback to 0 as a follow-up, not blocking this PR.run_claim_driver: theNoOperatorWalletrefusal 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-testedengine.rs(CycleConditions.future_dated_clock) and re-exercised at the integration level byrestart_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_indeliberately does NOT gate spawn onRewardsClaimConfig::load_fromcorruption — confirmed correct becauseengine.rs::run_cyclere-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 unchangedengine.rsbehavior. - Status surface:
ClaimDriverRefusal::{Disabled, ChainSyncDisabled, NoOperatorWallet}are three distinct, non-latching truths (onlyspawn_claim_driver_if's pure decision sets them, and the driver's ownIdle/Nominal/etc. is a separate independent field) — they do not collapse into one reassuringIdle, confirmed by direct read ofClaimLoopHandle::{status, refusal}anddecide_claim_driver. - Joint tests:
the_production_body_drives_counted_cycles_from_a_written_config(driver.rs) andthe_production_adapter_reports_chain_source_unavailable_by_nameboth assert onhandle.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 viadevelop'sCargo.lock(pre-PR) thatring 0.17.14is already present transitively (via rustls) at the same version — no new dependency source introduced.EmptyPort→Nominal(independent read, not a change request —types.rsis out of scope for #605):ClaimLoopState::compute_state's priority ladder (unchanged, pre-existing #594 semantics) putsNominalas the bottom-of-ladder fallback, so "zero distributors discovered" and "distributors discovered but none currently due" are indistinguishable in the status surface — both readNominal. 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 revisitstypes.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.
| let interval = next_interval_seconds(cadence_seconds, jitter_seconds, jitter); | ||
| tokio::time::sleep(Duration::from_secs(interval)).await; | ||
| let t = now(); | ||
| engine.run_cycle(t).await; |
There was a problem hiding this comment.
BLOCKING: drive()'s loop body (sleep -> run_cycle -> handle.record) emits no tracing:: call on this, the success/running path. Every tracing:: call this PR adds fires only when the loop does NOT run (NoOperatorWallet/Disabled/ChainSyncDisabled). handle()/ClaimLoopHandle also have no caller outside this file's own tests (confirmed via git grep). Net: nothing in the shipped binary lets an operator distinguish 'never ran a cycle' from 'ran and is healthy' -- the exact inert-but-green failure this epic exists to close, moved one layer in. Suggest one tracing::debug!/info! here logging cycles_driven/resulting ClaimLoopState after run_cycle, without leaking payout puzzle hash/amount beyond what's already visible in chain state.
Why
dig-node#594shipped a complete, tested peer reward-claim engine that nothing constructs. Atdevelopthe module says so in its own words —rewards_claim/mod.rscarries 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.enableddefaults totrue— 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 = truecomes to mean "a background task exists, drives a cycle everycadence_seconds + jitter, and its outcome is readable in-process as a namedClaimLoopState."crates/dig-node-service/src/rewards_claim/driver.rs(new) — the cadence driver plus the tested spawn seam. Shape mirrorsself_heal's split rather than inventing one: a private injected-tickdrive(...)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 inserve_with_shutdown, in the existing spawn block besidespawn_collateral_census(...)andself_heal::spawn_driver_if_service(), gated onenable_chain_syncfor 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_hashis the CAT-wrapped hash (mirror::funding::dig_cat_puzzle_hash(operator_puzzle_hash)), derived the same public, no-unseal-required wayspawn_mirror_passesderives 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 renderingNominal.The only production chain adapter until DIG-Network/dig_ecosystem#3249 lands is
UnavailableClaimChainPort, so every real cycle reportsChainSourceUnavailableand 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:
Three gate passes over #594 found twelve defects, each pass finding new ones inside the previous pass's own remedies, so the
ClaimStatussemantics 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.rsand 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::Idlehonest; asserting thatspawnreturned 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