Skip to content

chore(release): v0.261.0 -- reward RPC ingress hardening - #624

Merged
MichaelTaylor3d merged 33 commits into
mainfrom
release/v0.261.0
Sep 25, 2026
Merged

MichaelTaylor3d merged 33 commits into
mainfrom
release/v0.261.0

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Cuts v0.261.0 from develop (af8b0107) to main, plus the workspace version bump and the
CHANGELOG section title.

What is in this cut

Two merges of content beyond v0.260.0, and nothing else — origin/main is a strict ancestor of
origin/develop (git merge-base origin/main origin/develop == origin/main == 27cf18da), so
this cut sweeps no unfinished work. The two open PRs against develop (#592, #589) are drafts whose
commits are not on develop.

Merge Content
2ca67dd8 (#623) claim chain_port audit, approval refusal, discovery cap
af8b0107 (#621) node-local reward read token gate, open chain read rate bound
6fe86472 this branch: version bump + CHANGELOG title

Content delta main...develop: 16 files, +935 / -124.

Tickets

Closes DIG-Network/dig_ecosystem#3352
Closes DIG-Network/dig_ecosystem#3355
Closes DIG-Network/dig_ecosystem#3362
Closes DIG-Network/dig_ecosystem#3363

Refs DIG-Network/dig_ecosystem#3358 — PARTIAL, deliberately not closed. This release lands the
per-cycle bound (MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE = 256) and reports
Discovery.candidates_dropped / ClaimStatus.discovery_candidates_dropped_this_cycle every cycle.
Still open on that ticket: the rejected-launcher cache and the wire projection of the drop
counter.

Refs DIG-Network/dig_ecosystem#3357 — dig-node half evidenced here, deliberately not closed.
The dig-node half is the workspace disallowed-methods clippy lint banning
RewardDistributor::created_slot_value_to_slot from production code (the existing pin held). #3357
spans three repos; dig-account and dig-app have evidenced their halves separately, and closure is a
cross-repo call held above this lane. A Closes here would close a ticket two other repos are still
answering.

Changelog accuracy

The #3352 entry is written from the enforcement, not from the ticket title. The three node-local
reads were already Tier::Control (local dispatch only, never the mTLS peer surface,
reward_methods_tier_guard.rs) before this release. What #3352 changed is the POST / token
gate
— server.rs::is_node_local_reward_read, master control token or valid paired token,
-32030 otherwise. The changelog says that rather than repeating the ticket's framing.

For #3355 the limiter is keyed on the source, never on the caller-supplied launcher_id
(server.rs::is_open_reward_chain_read, -32034 REWARD_INGRESS_LIMITED) — a limiter keyed on
attacker-supplied input would itself be a DoS primitive.

Release mechanics

Merging this PR does not cut the tag. release.yml is tag-only; the tag comes from the
Nightly + stable release orchestrator dispatched manually with channel=stable, and the
Stable — changelog + tag job must be confirmed to have run, not skipped.

Gates

Money-adjacent — this ships a real spend path. Full triple gate (reviewer, security, adversarial
decider) against the head SHA below before this leaves draft.

🤖 Generated with Claude Code

MichaelTaylor3d and others added 30 commits September 5, 2026 20:33
* 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>
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: DIG-Network/dig_ecosystem#3124
…isions (#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>
…rrupted 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 DIG-Network/dig_ecosystem#3190
Refs DIG-Network/dig_ecosystem#3130

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
… 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 b4c0986: loop-reviewer PASS (review 5129272285), loop-security PASS @ 175304e (tree byte-identical, `git diff 175304e b4c0986` empty), adversarial loop-decider PASS (comment 5567083644; SHOULD-FIX findings ticketed separately).

Refs #570
Refs DIG-Network/dig_ecosystem#3203
…tem#3212)

Nine commits from the #3212 serve-path lane, gated at 899cc68 (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
… squash

Content-neutral. main@5eac531a is the squash of develop@194a0163 (tree-identical, verified
with `git diff --quiet 194a016 origin/main`), so develop already carries every byte main has;
this merge only records the ancestry so PR #588 (develop -> main) stops reading as CONFLICTING
and GitHub can build its merge ref again (the Commitlint and Check Version Increment
pull_request-event checks never attached to e11434a for that reason).

Refs DIG-Network/dig_ecosystem#3212
…ash + changelog)

Content-neutral: develop already carried every byte of the #588 squash; this brings in the
chore(release): v0.255.0 changelog commit so develop == main in content for the next batch.
develop is branch-protected against force-push and deletion, so it is synced by merge, not
recreated from main.

Refs DIG-Network/dig_ecosystem#3212
* 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.
…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
#593 (comment) 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>
* 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 134864a.

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 (080be3d) 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>
…adence (#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 (51516e6) 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 f478516. 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>
* 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)

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>
…al 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.256.0 -- reward distributor prover, peer claim loop, prover-status RPC (#602)

* 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>

* chore(release): v0.256.0

Bump dig-node-service to v0.256.0 for release.

This release includes:
- Reward distributor prover loop (#593)
- Peer reward claim loop (#594)
- Reward prover status RPC (#595)

* ci: scope commitlint to PR-introduced commits, fix title suffix check

A develop -> main release-cut PR was linting main..develop, the full
inherited commit range, instead of just the commits it introduces.
Every commit in that range was already linted at its own PR while it
was still mutable; re-linting it at cut time adds no information and
cannot be satisfied once merged (gitlinks and rev-pinned deps make
history immutable). Use commitDepth: 1 on a main-base PR; keep the
full-range lint unchanged for develop-base PRs, where authors can
still fix the commits.

Also fix the PR-title lint's blind spot: GitHub's squash merge lands
"$PR_TITLE (#$PR_NUMBER)" as the commit subject, about eight
characters longer than the title alone, so a title that passes
header-max-length can still produce an over-limit commit subject that
nothing checks. Lint the exact string that will land.

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

---------

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

* chore(release): v0.257.0 -- the reward distributor lifecycle starts running (#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 (#5…
PR #608 was squash-merged, which discarded the second parent and left main
still not an ancestor of develop -- the -s ours remedy is itself defeated by
squash-only merging. This commit carries both parents so the link actually
exists.

Content is byte-identical to develop: verified git diff 49ae2c6..develop is
empty before and after. The only thing this adds is the ancestry edge that
stops the next cut PR being DIRTY.

See DIG-Network/dig_ecosystem#3298 -- the durable fix is allow_merge_commit
on this repo; this is the only remedy available without it.

Refs DIG-Network/dig_ecosystem#3268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Serves all five reward-distributor RPC methods, all Tier::Control and none
peer-reachable: dig.listRewardDistributors, dig.getRewardDistributor,
dig.listRewardDistributorCommitments, dig.getRewardProverStatus and
dig.getPayeeRewardClaimStatus. Migrates the partial-knowledge results to
Half<T>/ClaimLogObservation so an unread half is NotConsulted rather than a
reassuring zero, and refuses getRewardProverStatus on a zeroed identity instead
of silently dropping the record.

The chain ADAPTER remains dig_ecosystem#3310's: install_reward_chain_port has no
non-test caller, so port-backed methods answer REWARD_CHAIN_UNAVAILABLE in
production and the claimable half is always NotConsulted.

Bumps dig-rpc-protocol 0.11 -> 0.12 across dig-node-core and dig-node-service.

Gates at head 95ca5f9: 5/5 required checks SUCCESS by name, 3414 tests passed,
coverage 90.17%, security PASS, 0 unresolved review threads.
Follow-ups: #3327 (mutation probe), #3328 (range-check comment).

Refs #3268
Refs #3269
Refs #3246

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Develop sat at 0.255.0 while main was at 0.257.0, so the develop -> main release PR could not satisfy the required Check Version Increment gate. Cargo.lock synced so --locked is satisfied.

Refs #3269

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On pull_request GitHub runs the workflow from the PR's HEAD branch, so a develop -> main release PR runs develop's copy of commitlint.yml. main's commitDepth: 1 release-cut branch was therefore never consulted at a cut. The file never reached develop because the -s ours reconciliation at cd8ce7b records ancestry without bringing content across.

Refs #3298
Refs #3269

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(rewards): clamp an out-of-range claim schedule to the max instead of substituting the default

sanitized_schedule() previously replaced any above-maximum cadence/jitter with the
documented 1-day/1-hour default, so a 60-day configured cadence ran every 1 day --
31x more often than the operator asked. Above-max values now clamp to
CLAIM_SCHEDULE_SECONDS_MAX (31 days) instead. A zero cadence still substitutes the
default (it has no clamp direction and would busy-loop the engine) -- that branch
is unchanged and its WARN reads "substituted", distinct from the clamp branches'
"clamped".

The adjustment (configured vs effective) is now threaded call-scoped (ScheduleAdjustment,
never an engine field) from sanitized_schedule() into drive() and onto the per-cycle
log line, so an operator reading any single cycle can see the schedule actually in
force -- previously only a once-per-process WARN said so.

Refs #3306

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

* fix(test): assert for CLAMPED warning in uppercase, not lowercase

Production tracing::warn! at sanitized_schedule emits "CLAMPED" in uppercase
in both cadence and jitter clamping cases; test assertions must match.

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

* fix(clippy): remove unused import CLAIM_JITTER_SECONDS_DEFAULT

The jitter branch no longer substitutes the default, so the constant is
not used at module level. Test module accesses it through use super::*.

Fixes clippy error at crates/dig-node-service/src/rewards_claim/driver.rs:39
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* style(fmt): rustfmt formatting of driver.rs

Applied cargo fmt to maintain code style consistency across new and
modified test code.

Fixes rustfmt check on PR #611
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(test): add CLAIM_JITTER_SECONDS_DEFAULT import to test module

The constant is used by the test at line 1446 but was removed from
module-level imports. Add it to the test module's own use statements
to fix the compilation error.

Fixes clippy and test compilation errors on PR #611
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(import): correct CLAIM_JITTER_SECONDS_DEFAULT import path in tests

Changed from super::cadence to super::super::cadence to match the module
nesting level. mod tests is inside driver, so super references driver.
Requires super::super to reach cadence. Also applied rustfmt formatting.

Verified with: cargo check -p dig-node-service --lib --tests
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(rewards): assert per-cycle field names, size fee window from raw cadence

Gate F1: the per-cycle log test asserted bare numbers against the whole
captured log buffer, which the once-per-spawn clamp WARN already contains --
deleting every per-cycle field from log_cycle would still pass it. Filter to
"claim cycle complete" lines, require at least two, and assert the actual
field names (configured_cadence_seconds / effective_cadence_seconds).

Gate F2: with_persisted_fee_window was sized from the CLAMPED cadence,
doubling the fee-budget windows a long-cadence operator sized (60-day
config -> ~12 windows/year instead of ~6). Pass the raw cfg.cadence_seconds
instead; the scheduler still uses the clamped value. Added a regression
test showing the two configurations roll the persisted fee window at
different ticks.

Gate F3/F4: corrected the "unclamped at rest" and "would busy-loop" doc
claims -- config.rs already floors cadence_seconds to
CLAIM_CADENCE_FLOOR_SECONDS before the driver ever sees it, so the
zero-cadence branch is defence in depth, not the primary guard. Removed
the vacuous `!rendered.contains("clamped")` assertion (every clamp string
is uppercase CLAMPED) and replaced it with a real one.

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

* style(fmt): rustfmt driver.rs

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

* fix(rewards): split the overloaded cadence field so the clamp governs the gate

`ClaimEngine::cadence_seconds` was a single field feeding two policies: the
restart-safety cadence gate (`run_cycle`'s `CadenceNotElapsed` check) and the
persisted aggregate fee-budget window length. Passing the raw, unclamped
configured cadence into `with_persisted_fee_window` set the GATE to the raw
60-day value while the driver's scheduler kept ticking on the clamped 31-day
interval -- the loop woke every 31 days and was refused every time, turning
the previous clamp fix into a complete no-op with every test green.

Split the field into `gate_cadence_seconds` (CLAMPED -- the schedule the
driver's background loop actually sleeps on; used by the `CadenceNotElapsed`
gate at `run_cycle`) and `fee_window_seconds` (RAW -- the operator's
configured cadence; sizes how long the persisted fee-budget window stays
open before rolling). Reusing the clamped value for the window would double
the number of fee-budget windows a long-cadence operator sized; reusing the
raw value for the gate reproduces the exact silent-non-claiming defect this
split exists to close.

Updated `with_persisted_fee_window`'s and both fields' doc comments to
describe the two-value split (the prior doc described the single field as a
deliberate overload, which is now false); added an explicit mutation-tested
proof (`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`)
that drives the real `drive()` loop with an injected clock and asserts the
gate opens on the clamped interval while the window rolls only on the raw
one; and documented, on the existing `run_claim_driver_in` composition test,
why that test cannot make the same state assertion (that body hardcodes
`unix_now_seconds`, real wall-clock, which `tokio::time::advance` cannot
drive).

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…-rewards-coin (#614)

* chore(deps): pin chia-sdk-driver/chia-sdk-types/chia-puzzle-types, add dig-rewards-coin

Money-bug containment for dig_ecosystem#3303/#3286: exact `=` pins on
dig-node-service's direct edge stop the resolved chia-sdk-driver/chia-sdk-types/
chia-puzzle-types versions moving under us. This constrains OUR compile target
only -- Cargo.lock still carries a second, transitive chia-sdk-driver 0.30.0 and
chia-puzzle-types 0.26.0 line via other crates, and this pin does not evict
those. Containment, not correction.

chia-protocol/chia-bls/chia-sha2 stay caret: an `=` on a wire type poisons every
crate that depends on chia-protocol directly.

dig-rewards-coin = "0.5" (not the ticket's stale "0.4"): 0.5.0 is the release
that already refuses `epoch_seconds == 0` inside `read_distributor` itself.

Refs #3310

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

* feat(rewards): construct and install a real RewardsChainPort over dig-rewards-coin

Adds RealRewardsChainPort (crates/dig-node-service/src/rewards/): distributor_report
served for real over dig-wallet's CorroboratedChainSource and
dig_rewards_coin::state::read_distributor, via a guarded read
(read_distributor_guarded) that refuses launch constants carrying
epoch_seconds == 0 BEFORE calling into dig-rewards-coin at all -- defense in
depth over that crate's own identical refusal (state.rs:1015), since
chia-sdk-driver-0.36.0's commit_incentives backfill loop never terminates on
that value and has no await point a timeout could interrupt.

store_id/root are recovered from the launcher's creating spend's CREATE_COIN
memo (chain_source.rs), following dig-mirror-coin's read_parent_outputs
pattern: authenticate the puzzle reveal against the coin's puzzle hash before
running it, never trust an unauthenticated memo alone.

The other four RewardsChainPort methods (funded_distributors,
distributor_state, submit_entry_writes, spend_new_epoch) answer Unavailable --
out of this ticket's scope (funder-registry and prover-cycle work tracked
separately).

Installed once from server.rs's enable_chain_sync-gated block, logging a
false (already-installed) return at WARN.

Refs #3310

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

* fix(rewards): satisfy rustfmt and regenerate the workspace lockfile

CI's `cargo fmt --all -- --check` and `--locked` builds both failed on
the prior commit: rustfmt wanted several closures/return-types
reformatted, and Cargo.lock was missing the `chia-sdk-test`,
`clvm-traits` and `clvmr` entries the new adapter's Cargo.toml lines
require, which `--locked` refuses to backfill.

Refs #3310

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

* fix(rewards): read LaunchCommentError payloads, scope containment test, fix constants fixture

- LaunchCommentError::ChainSource/Malformed tuple payloads were flagged
  dead_code by clippy: a derived Debug impl does not count as reading a
  private field. Add a manual Display impl that formats each variant's
  payload, and switch chain_port.rs's build_report call site from {error:?}
  to {error} so the reason reaches a log reader.

- adapter_source_never_imports_withdraw_committed_incentives was
  self-defeating: it include_str!s its own file and the test's own name
  and assertion messages contain the literal string it searches for, so
  it could never pass. Scope the scan to production_region(), everything
  before the file's own #[cfg(test)] marker.

- launcher_spend_with's RewardDistributorConstants fixture set
  reserve_inner_puzzle_hash/reserve_full_puzzle_hash to Bytes32::default()
  without calling .with_launcher_id(launcher_id), which recomputes both
  fields from curried tree hashes. chia-sdk-driver's
  RewardDistributor::from_launcher_solution requires constants ==
  constants.with_launcher_id(launcher_id), so this fixture deterministically
  failed to decode on every invocation (not flaky/timing-dependent).

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

* test(rewards): finish A3 -- real-simulator distributor_report + install-path evidence

Drives RealRewardsChainPort::distributor_report end to end against a
distributor launched by dig_rewards_coin::launch_dig_distributor in
chia-sdk-test's peer simulator, over a MockChainSource loaded from the
simulator's real coin records/spends. store_id/root are asserted
against the values launched with -- recoverable only by actually
running the launcher's parent spend and decoding its CLVM memo, so no
fixture shortcut can pass this test.

Also covers install_reward_chain_port's single-install refusal (true
then false, with the WARN server.rs's own call site logs).

Node exposes no lighter test constructor to an external integration
test crate, so the install-path test uses Node::from_env(), the same
constructor openrpc_drift_guard.rs's own test uses. launch_fixture
returns Box<dyn std::error::Error> rather than pulling in anyhow for
one test file.

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

* fix(rewards): stop reporting a missing parent spend as a distributor-identity verdict

ChainSource::parent_spend returning Ok(None) is a chain-source gap (the
source does not yet hold the launcher's creating spend), not a genuine
"this is not a DIG distributor" classification. Give it its own
LaunchCommentError::ParentSpendUnavailable variant so chain_port.rs can
map it to ChainPortError::Unavailable, agreeing with the other absence
path (read_distributor_guarded's own Ok(None)), instead of rendering a
transport lag as a definitive negative identity claim.

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

* feat(rewards): observable degradation and a testable epoch computation

- Add one bounded tracing::warn! on distributor_report's error path,
  firing once per failure->success transition (not per call, via an
  AtomicBool), so an installed-but-degraded chain source is no longer
  indistinguishable from "no port installed."
- Extract current_distributor_epoch's arithmetic into a pure
  epoch_ordinal fn and unit-test it directly (non-zero multi-epoch case,
  the saturating_sub clock-skew branch, and the bare-launch zero case) --
  the only non-trivial computed field in the report mapping, previously
  unexercised by anything but a zero-valued default.
- Add launch_comment_error_to_port_error, wiring chain_source.rs's new
  ParentSpendUnavailable variant to ChainPortError::Unavailable, with a
  regression test, and a companion test confirming
  GuardedReadError::NonTerminatingEpochSeconds still maps to the named
  refusal (Other), never to Unavailable.
- Add a unit test proving a failing chain source surfaces as a named
  ChainPortError::Unavailable, never Ok(_) with a default-valued report.

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

* test(rewards): de-circularize the install-refusal warn evidence

install_reward_chain_port_refuses_a_second_install_with_a_warn
previously captured and asserted against a warn it emitted itself
inside the test's own closure -- deleting server.rs's real warn line
would have left it green. Replace the self-emission with a source-text
check against server.rs's own production region (the same shape
adapter_source_never_imports_withdraw_committed_incentives already uses),
so the assertion can only be satisfied by what server.rs actually ships.

Also softens the A3 module doc's overstated "no fixture value can
produce the right store_id/root" claim: true of this file today, not a
structural guarantee.

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

* style(rewards): rustfmt, and note the install-warn test asserts source text not runtime behaviour

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Characterization test locking the #870 refusal rule: a peer identity holding both a dialled (outbound) pool slot and an accepted (inbound) mTLS connection is counted exactly once, the dialled slot survives, and the refused inbound peer is still served.

Refs DIG-Network/dig_ecosystem#3124

Gates at 2d46d9a: loop-reviewer verdict APPROVED (review 5228183672), loop-security PASS (issuecomment-5704107401), 14/14 checks green, 0 unresolved threads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…BLE (#618)

* chore: open lane for #3342

Refs #3342

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

* test(rewards): red test -- an answering chain with no distributor is an absence

`build_report` maps `read_distributor_guarded`'s `Ok(None)` -- the chain
answering "no distributor at this launcher" -- onto the same
`ChainPortError::Unavailable` an unreachable chain produces. A funder
deciding whether to claw back cannot tell "you have nothing there" from
"we cannot see the chain", and one wire shape for both makes that call a
guess on a money surface.

This test is RED at this commit, deliberately: it is the acceptance
evidence for the split that follows, written before the fix so the fix
has something to turn green.

Refs #3342

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

* fix(rewards): split absence from outage behind REWARD_CHAIN_UNAVAILABLE

`ChainPortError::Unavailable` stood for three conditions a caller must
tell apart:

1. no chain-read adapter installed on this Node at all;
2. an installed adapter could not reach the chain -- a real outage;
3. the chain ANSWERED and holds no distributor at that launcher id
   (`read_distributor_guarded` -> `Ok(None)`).

Case 3 is the money-surface defect: an absence rendered as an outage. A
funder deciding whether to claw back cannot distinguish "you have
nothing there" from "we cannot see the chain", and one wire shape for
both makes that call a guess.

`Ok(None)` now gets its own `ChainPortError::NotADistributor` and its
own machine code, so the two can never share a shape again.

The message "no chain-read adapter is wired yet" was also false at the
site that emitted it -- on a default install an adapter IS wired and IS
answering. It is not reworded; it is confined to
`reward_chain_port_absent_response`, the one case where it is true, and
the `Unavailable` arm now names its real cause. A diagnostic that
misnames its own cause sends the next reader to the wrong subsystem.

Turns green the red test added in the previous commit.

Refs #3342
Refs #3246

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

* fix(rewards): pin the wire split and keep an absence off the degraded latch

Two pre-merge gate findings, both proven by mutation.

H1 -- the wire-level distinction was unpinned. No test asserted any
REWARD_* string on a JSON-RPC response body, so pointing the
`NotADistributor` arm's `data.code` at `REWARD_CHAIN_UNAVAILABLE_MACHINE`
re-collapsed the split, compiled clean (the match stays exhaustive) and
left all 3431 tests green. The port-level test was also too loose:
`assert_ne!(.., Unavailable)` passes if the variant is later re-mapped to
`Other`. Now a dispatch test installs a port answering
`Err(NotADistributor)` and asserts `data.code` by value for both methods,
and the port test asserts the variant by name.

H2 -- an absence armed the degradation latch. Every `Err`, including
`NotADistributor`, set the adapter-wide `report_degraded` latch and
warned that reads "stay refused until the chain source recovers". The
warn fires only on a false->true transition, so one probe for a launcher
id that simply is not a distributor silenced the warning for the next
GENUINE outage. That is worse than the defect this branch set out to fix:
it trades a misleading error for missing telemetry on a money surface.
`NotADistributor` is now excluded from both the latch and the warn; `Ok`
still clears it, matching a real recovery.

Refs #3342
Refs #3246

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

* style(rewards): rustfmt the H1 dispatch test

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

---------

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

dig_ecosystem#3351: dig.getRewardDistributor / dig.listRewardDistributorCommitments were documented
"CONTROL plane: loopback admin / in-process FFI ONLY" and answer on anonymous POST /. Decision (option 2,
posted on the ticket): Tier::Control is a transport-path class (local dispatch, never the mTLS peer
surface), not a token gate; both reads disclose only public on-chain state keyed by the caller's
launcher_id; zero live RPC callers exist; SPEC §5.5 L1080 requires every non-control.* method to be
requires_auth: false. The doc now says what the code does, and two tests pin it: POST / with no token
reaches the reward handler (not -32030); /ws does not route the reads (ok:false, not Unauthorized in
either numeric or string form). Mutation proofs M1/M2 run and RED (PR comment 5724073551). Node-local
reward reads -> #3352; rewards-coin SPEC §2.6 tier sentence -> #3354; rate bound on the open chain
reads -> #3355.

Gates on head 262551c: reviewer PASS (5243744057), security PASS (5243754264), adversarial PASS
(5724353432). Zero production code-path change.

Refs DIG-Network/dig_ecosystem#3351

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…d inject the clock (#617)

* chore: open lane for #3336

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

* fix(rewards-claim): newtype the fee-window cadence args + injectable-clock seam

- GateCadenceSeconds / FeeWindowCadenceSeconds newtypes on
  ClaimEngine::with_persisted_fee_window so transposing the two cadence
  values at the driver.rs call site is a compile error, not a silent
  money defect (#3336).
- run_claim_driver_in_with_clock: the production body with the clock
  parameterized instead of hardcoded to unix_now_seconds, so the timing
  behaviour is testable under tokio::time::advance. run_claim_driver_in
  stays a thin wrapper over it passing the real clock.

Refs #3336

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

* test(rewards-claim): drive gate/window split through run_claim_driver_in_with_clock

Adds the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window,
proving the F2 gate-vs-fee-window distinction through the actual
production body (run_claim_driver_in_with_clock) rather than a
hand-assembled drive() call, so a transposition of the two cadence
arguments at with_persisted_fee_window's call site (#3336) is caught
end-to-end.

Refs #3336

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

* style: cargo fmt rewards-claim fee-window call sites

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

* refactor(rewards-claim): collapse two cadence newtypes into one ClaimCadences value

The positional-newtype shape (GateCadenceSeconds/FeeWindowCadenceSeconds as two
adjacent with_persisted_fee_window arguments) closed the positional swap but left
the value swap open -- GateCadenceSeconds(raw)/FeeWindowCadenceSeconds(clamped)
still compiled and still passed the existing test (dig_ecosystem#3336 rework).

Bundle both cadences into one ClaimCadences { gate_clamped, fee_window_raw }
value instead: there is no longer a pair of adjacent arguments for a swap to
target at the call site. Every production and test call site updated.

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

* fix(rewards-claim): make the clamped gate cadence unconstructible except by its clamp

DIG-Network/dig_ecosystem#3336: `ClaimCadences { gate_clamped: u64, fee_window_raw: u64 }`
closed the positional swap between the two cadence values but left the value swap open --
both fields were plain u64, so nothing stopped a caller writing the RAW configured cadence
into `gate_clamped` and the CLAMPED one into `fee_window_raw`. `ClampedGateCadence`'s inner
field is now private with `ClampedGateCadence::clamp(FeeWindowCadenceSeconds) -> Self` as its
only constructor, so a raw value has no path into the gate slot without going through the
clamp. `FeeWindowCadenceSeconds` wraps the raw side so the two are no longer interchangeable
plain integers.

* style(rewards-claim): cargo fmt

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

* refactor(rewards-claim): derive the gate cadence from one raw config value

Closes the remaining half of the #3336 transposition hole structurally rather
than by test. `ClaimCadences` now has private fields and a single constructor,
`ClaimCadences::from_raw`, which derives `gate_clamped` from the same raw value
it stores as `fee_window_raw`. The production call site passes ONE value, so
there is no longer a pair to transpose or a `pub` field to write the wrong
local into.

Behaviour-identical: `CLAIM_CADENCE_FLOOR_SECONDS` is 60 and `config.rs` floors
any smaller configured cadence at load, so `sanitized_schedule`'s zero branch is
unreachable from this path and its output equals `min(cadence, MAX)` -- exactly
what the clamp computes.

Also renames `FeeWindowCadenceSeconds` to `RawConfiguredCadence` (it is the
clamp's input type, not only the window's), and replaces the tick-1/tick-2
`assert_ne!(state, CadenceNotElapsed)` exclusions with exact-state assertions --
the exclusion was equally satisfied by PersistedStateCorrupt and
ChainSourceUnavailable, whose early returns also sit above the window-roll
block, so it would have gone vacuous the moment either fired.

Refs DIG-Network/dig_ecosystem#3336

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

* style(rewards-claim): restore string line continuations in claim-driver assertions

Convert literal backslash-n escapes with trailing spaces back to proper Rust string
line continuations (trailing backslash at end of line). The strings render as single
sentences without artificial newlines or multi-space runs. Fixes continuation_guard.

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

* docs(rewards-claim): separate the type-enforced half from the test-guarded half

The #3336 doc comments asserted a type-level guarantee the code does not
deliver. `ClaimCadences::from_raw` does close the pairing mutation -- the gate
and the fee window cannot disagree with each other, because `from_raw` derives
`gate_clamped` from the same `RawConfiguredCadence` it stores as
`fee_window_raw`, and it is the only constructor. It does NOT close the value
mutation: `from_raw(RawConfiguredCadence(cadence_seconds))`, the already-clamped
local instead of `cfg.cadence_seconds`, has the same type and compiles. Measured
consequence: the fee window halves -- the tick-2 assertion fails with
left Some(5356800), right Some(2678400).

That mutation is caught by exactly one test,
`the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`. The other
money test stays green under it, because it hand-assembles `drive` and never
traverses the production call site. The old docs told the next reader the value
swap was type-closed -- the exact sentence that would be cited to delete the one
guarding test.

Also corrects the stale "transposing the two arguments" description: the call
takes ONE argument, so that mutation cannot be written at all; and drops the
claim that the surviving mutation also breaks the gate (clamping an
already-clamped value is the identity, so it does not).

Docs only -- no behaviour, no type, and no assertion changed.

Refs DIG-Network/dig_ecosystem#3336

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

* docs(rewards-claim): remove the two-argument F2 doc block and name the clamp precondition

Doc comments only; no code, assertion, type or visibility changed. The test
count is unchanged (92 passed, 0 failed).

- engine.rs: delete the `with_persisted_fee_window` "F2 (money): takes TWO
  cadence values, deliberately not one" block. The method takes ONE
  `ClaimCadences`; those two names are private fields, not parameters, and
  "deliberately not one" instructed the next author to restore the two-`u64`
  signature this PR makes unwritable -- twenty lines above the #3336 section
  that contradicts it.
- engine.rs: `ClampedGateCadence::clamp` no longer claims flat equivalence with
  `sanitized_schedule`. It reproduces only that function's clamp arm, not its
  zero-substitution arm; they agree because a zero cannot reach it, and that
  precondition is now named.
- driver.rs: the surviving production-call-site comment describes the
  one-argument call it sits above, and names the single test that catches
  passing the clamped local instead of the raw config value.
- driver.rs: the sibling money test now records that it stays GREEN under that
  mutation, so its doc cannot be cited to delete the test that does catch it.

Refs DIG-Network/dig_ecosystem#3336

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

* docs(rewards-claim): correct the clock-seam fn name and the load_from exit count

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…-- the claim loop pays a peer (#620)

Refs DIG-Network/dig_ecosystem#3347. Refs DIG-Network/dig_ecosystem#3246.

RealClaimChainPort installed from serve_with_shutdown via the corroborated chain source + a hint-index enumerator; own_entry and submit_initiate_payout are real over dig-rewards-coin 0.8.0 (ChainEntrySlotSource, finish_spend signature, Broadcaster); closure artifact drives run_claim_driver_in to a simulator-accepted payout coin. Triple gate PASS @ 9648236; five mutation proofs posted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ink)

-s ours records main's history so the next develop -> main cut opens MERGEABLE instead of
CONFLICTING (GitHub fires no pull_request workflows on a conflicting PR). It brings NO content;
the forward-port of main's newer files follows in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main is newer only in the two version lines (Cargo.toml [workspace.package].version and the
dig-node-service entry in Cargo.lock, 0.258.0 -> 0.260.0). Adopting them here closes the
develop-version inversion so the next cut's Check version increment compares against 0.260.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#623)

Refs DIG-Network/dig_ecosystem#3357 DIG-Network/dig_ecosystem#3362 DIG-Network/dig_ecosystem#3358 DIG-Network/dig_ecosystem#3363

Gates on 836a10d: loop-reviewer PASS (review 5308787371), loop-security PASS (comment 5820041308), loop-decider adversarial code-PASS with M1 re-executed red (comment 5820055177). Five required contexts green by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MichaelTaylor3d and others added 2 commits September 24, 2026 14:24
…621)

Refs DIG-Network/dig_ecosystem#3352 DIG-Network/dig_ecosystem#3355 DIG-Network/dig_ecosystem#3351

Gates on cc87ac7: loop-reviewer PASS, loop-security PASS (comment 5822471233), loop-decider adversarial PASS with M1 executed red (comment 5822423286). Five required contexts green by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Bump the workspace version 0.260.0 -> 0.261.0 (Cargo.toml [workspace.package].version and the
dig-node-service entry in Cargo.lock) and title the CHANGELOG's pending section 0.261.0.

The changelog's #3352 entry is written from the enforcement, not the ticket title: the three
node-local reads were ALREADY Tier::Control before this release, so what changed is the POST /
TOKEN gate (server.rs::is_node_local_reward_read, -32030), not the tier.

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

Copy link
Copy Markdown
Contributor Author

Adversarial third leg — verdict on head SHA 6fe86472

Read-only pass over main...6fe86472 (16 files, +935/-124) plus the six tickets. I am the leg that asks what the correctness and security legs do not. Verdict at the bottom.

VERDICT: HOLD — pull the #3358 per-cycle discovery cap out of this cut; ship the rest

Not the whole cut. Three of the four closures are clean and the release's headline (reward RPC ingress hardening) is net-positive and non-breaking for every shipped consumer. One commit inside it converts a LOUD, EXPENSIVE failure into a SILENT, UNPAID one on the surface v0.260.0 just shipped to pay peers.


Q3 (the blocker): #3358 shipped PARTIAL leaves a state worse than neither half

crates/dig-node-service/src/rewards_claim/chain_port.rs:267-271:

let total = candidate_ids.len();
let candidates_dropped = total.saturating_sub(candidate_cap) as u32;
for launcher_id in candidate_ids.into_iter().take(candidate_cap) {

MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE = 256 (chain_port.rs:67). The attack:

  1. Candidates come from coin_records_by_hints(tree_hash("Reward Distributor v1")) filtered to SINGLETON_LAUNCHER_HASH. Anyone can mint a hinted 1-mojo launcher coin. The ticket says so itself.
  2. The cap takes the index's first N in index order — the code's own doc, chain_port.rs:47-51, concedes it "does not choose WHICH candidates survive". Ordering is upstream-determined and attacker-influenceable by volume.
  3. There is no rejected-launcher cache (chain_port.rs:52-54, explicitly). So the same flood re-fills the window every cadence, forever.
  4. The other route to a candidate — ClaimEngine::run_cycle's hint loop — is wired to NoHintSource in the production driver (crates/dig-node-service/src/rewards_claim/driver.rs:583-585). It returns an empty vec (hints.rs:30). So on a shipped node, capped discovery is the only path to a distributor.

Net: ~257 cheap coins evict the real DIG distributor from every node's discovery window on every cycle. The claim loop finds nothing. Nobody gets paid.

And the drop is invisible where it matters. discovery_candidates_dropped_this_cycle exists only on the internal rewards_claim::types::ClaimStatus (types.rs:294) and a tracing::warn! (engine.rs:499-505). dig.getPayeeRewardClaimStatus answers with dig_rpc_protocol::types::PayeeClaimStatus (dispatch.rs:1215) — unchanged in this cut, no such field. A repo-wide grep for discovery_candidates_dropped_this_cycle returns five hits, all internal; meta.rs has zero. #3358's own words were "never silently truncated". At the operator's surface it is silently truncated; only a log line says otherwise, and a log line is not a wire projection.

This is a regression against v0.260.0, not merely an incomplete feature. Pre-cap, the same flood cost the node unbounded decode work but discovery still found the distributor. Post-cap it does not. A bound that starves the work it protects is a money lie.

Remedy for this cut: revert the take(candidate_cap) enforcement (keep the Discovery::candidates_dropped plumbing and the tests, which are good), returning discovery to v0.260.0 behaviour — known, loud, correct. Re-land #3358 whole: rejected-launcher cache and the wire projection through dig-rpc-protocol (release-first), so the cap can never censor the pay path unobserved. I considered shipping it anyway on the grounds that the cap also bounds a DoS that v0.260.0 already has open — rejected, because the pre-existing DoS is loud (a visibly busy node) and the new failure is silent (a node that looks healthy and stops earning), and this cut fixes neither the DoS's root nor makes it worse by reverting.

Q1: is this a BREAKING wire change shipped as a minor bump? — YES on the wire, NO for any shipped consumer

server.rs:1282-1330 folds is_node_local_reward_read into the cache.* token gate: dig.getRewardProverStatus, dig.listRewardDistributors, dig.getPayeeRewardClaimStatus now answer -32030 without a master or paired token. Any anonymous caller breaks.

Searched for callers, and found none that is live:

  • dig-app — the obvious suspect, and it is clean. modules/apps/dig-app/crates/dig-app-core/src/rewards/chain_read.rs:40-60: list_distributors and prover_status both return hard Errs whose text is "dig.listRewardDistributors is not called from anywhere in this build" and "dig-app does not call dig.getRewardProverStatus anywhere in this build". store_rewards.rs:28-29 says the same. Every hit in dig-app is a doc comment, a test constant or a gallery example — zero call sites. A grep for the quoted method-name string literals across crates/ returned nothing.
  • dig-chrome-extension, dig-relay — org-wide gh api search/code for all three method names across org:DIG-Network returned no hit in either repo (hits only in dig-node, dig-rpc-protocol, dig-app, dig-rewards-coin SPEC, docs.dig.net, contextOS wiki).

So the capability gap the app itself causes — the recurring class in this epic — is not triggered here: dig-app cannot break on a method it never calls. Third-party anonymous callers of a documented-as-CONTROL method do break, which is the intended correction.

One drift to file, non-blocking: docs.dig.net/docs/audiences/reward-funders.md documents getRewardProverStatus / listRewardDistributors and does not know they now need a token.

Q2: does 0.260.0 → 0.261.0 describe it honestly? — mostly, with one real hole

The bump is a minor on a 0.x line, which is the repo's convention for a behaviour change, and the CHANGELOG's first two bullets name the gate and the new -32034 by number and by ticket. That is where an operator would look, and it is honest.

The hole is elsewhere: CHANGELOG.md jumps from ## [0.261.0] straight to ## [0.255.0]. git show origin/main:CHANGELOG.md confirms main has the same gap — v0.256.0 through v0.260.0 have no entries at all, including the release whose entire point was that the claim loop pays a peer. An operator reading the changelog to decide whether to upgrade cannot see five releases. Worth a ticket.

Q4: are the four closures earned, and is #3357 rightly left open? — three verified, one partially; #3357 correctly open

  • #3352 — earned. server.rs:1496-1523: is_node_local_reward_read + requires_http_token as one pure predicate, openrpc_drift_guard::served_classes_are_well_formed pins the catalogue's requires_auth to it by equality, and SPEC.md §5.5 / §7 are rewritten so doc and enforcement agree — which was the ticket's actual ask ("decide the tier, then make doc and enforcement agree"). Both halves of the removal/replacement present.
  • #3355 — earned, partially verified by me. server.rs:1334-1357 bounds is_open_reward_chain_read on a separate reward_ingress bucket keyed on RequestorId, never on the caller-supplied launcher_id — the DoS-primitive trap the ticket named, avoided, and the separate bucket is the right call (a shared one would refuse a client's unrelated wallet reads under a code naming the wrong bound). I did not read the flood test the ticket's acceptance requires; tests/server.rs grew +188 lines and I defer to the reviewer leg on it. Flagging rather than ratifying.
  • #3362 — earned. chain_port.rs:14-17: submit_initiate_payout refuses by name when the chain-curried require_payout_approval is true, before building anything, with a test.
  • #3363 — earned. tests/rewards_claim_chain_port_3347.rs:101-153 uses non-empty launcher indexes via with_candidate_cap and asserts the drop counts.
  • #3357 — correctly NOT closed. The clippy disallowed-methods entry landed in dig-node's clippy.toml only; the ticket's acceptance spans dig-app and dig-account. But the CHANGELOG bullet for #3357 reads as finished work with no "dig-node only" qualifier — a reader sweeping the ticket later will think it is done. Add the qualifier.

Q5: does the cut sweep unfinished work? — NO. You are right; I checked rather than agreed

PR 592 head a4664285 -> NOT on origin/develop
PR 589 head 6c1a3287 -> NOT on origin/develop

git merge-base --is-ancestor <head> origin/develop fails for both. Neither draft's commits are reachable from develop, so main...6fe86472 cannot contain them. Your measurement stands.

Q6: failure direction — SILENT, and that is why this holds

  • #3352 / #3355 fail loudly: a caller that needed anonymity gets -32030 or -32034 with text naming the credential and the bound. Loud, diagnosable, and no shipped consumer is on that path (Q1).
  • #3358's cap fails silently: the node starts, serves, reports healthy, logs one warn! nobody tails, and quietly stops discovering the distributor that pays it. No RPC surface carries the drop count. A peer stops earning and nobody sees it.

Silent is the one that must block, so it does.

Also not terminal

Test + coverage was pending when I read the check set — every other required context is green. Zero failing checks is not the same as terminal; re-read before any merge.

DID NOT RUN

No test or build executed (a cold cargo build in this repo outlives the lane). No cargo process started, so none to reap. gitnexus not indexed, socraticode down, claude-mem down for this pass.

Tickets I want filed

  1. fix(dig-node): the #3358 discovery cap can censor the pay path — land the rejected-launcher cache and project the drop count onto the wire before the cap ships — with NoHintSource in the production driver, capped discovery is the only route to a distributor, and the drop count reaches no RPC surface.
  2. docs(docs.dig.net): reward-funders.md still describes the three node-local reward reads as anonymous — they require a token as of v0.261.0
  3. docs(dig-node): CHANGELOG.md has no entries for v0.256.0–v0.260.0, including the release that first paid a peer
  4. chore(dig-node): the v0.261.0 CHANGELOG bullet for #3357 does not say dig-node only — dig-app/dig-account remain unswept

@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.

Release gate: CHANGES-REQUIRED (head 6fe86472)

Independent re-gate of the whole origin/main..6fe86472 delta (16 files, +935/-124), not just the release commit. One blocking finding; both findings are in CHANGELOG.md — the code itself passes.

Verdict

CHANGES-REQUIRED on 6fe86472 — one overclaiming CHANGELOG bullet (#3358) and one unrecorded user-visible catalogue change. Both are fixable in a single amend to the release commit; no code change is needed.

1. CHANGELOG [0.261.0] bullets, verified one by one against code

bullet verdict evidence
#3352 token-gate the three node-local reads, -32030 TRUE crates/dig-node-service/src/server.rs:1295-1330 (gate + master-or-paired compare); is_node_local_reward_read at server.rs:1500-1510; catalogue meta.rs:620-646; equality pinned by tests/openrpc_drift_guard.rs:275-285 and meta.rs:1621-1632. Sub-claim "they were already Tier::Control" is TRUE: dispatch.rs is comment-only in this diff and crates/dig-node-core/tests/reward_methods_tier_guard.rs is untouched.
#3355 rate-bound the two open chain reads per source, -32034 TRUE server.rs:1332-1360; own bucket AppState::reward_ingress (server.rs:122, built at server.rs:621); keyed on requestor, never launcher_id; ErrorCode::RewardIngressLimited => -32034 at meta.rs:898.
#3357 clippy disallowed-methods ban + one allowed fixture use TRUE clippy.toml:16-18; enforced by .github/workflows/ci.yml:102 (-D warnings); exactly one #[allow(clippy::disallowed_methods)] workspace-wide, at crates/dig-node-service/tests/common/rewards_fixture.rs:648, carrying the required why-comment.
#3362 refuse an approval-requiring distributor by name before building/broadcasting TRUE crates/dig-node-service/src/rewards_claim/chain_port.rs:414-430 — the refusal sits after the guarded read and before SpendContext::new() / initiate_payout / finish_spend, so nothing is built and nothing reaches the broadcaster (asserted at tests/rewards_claim_chain_port_3347.rs:497-503).
#3358 bound hinted launcher discovery candidates per cycle + report drops OVERCLAIMS see the inline thread on CHANGELOG.md:28.
#3363 non-empty launcher index in the claim-port regression test TRUE tests/rewards_claim_chain_port_3347.rs:235-252 — the index now carries a real id, and the test additionally drives discover_distributors and submit_initiate_payout to Unavailable.

2. Version bump

Consistent. Cargo.toml [workspace.package].version = "0.261.0" (line 36), Cargo.lock dig-node-service = 0.261.0 (line 3044), strictly greater than the released v0.260.0. Check version increment green.

3. The five must-not-regress guards — each body read, not just its name

  • a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer — present, tests/rewards_claim_chain_port_3347.rs:505+; still drives run_claim_driver_in and still asserts status.claims_submitted == 1 against the simulator-paid coin, not cycles_driven(). Only the discovery-shape lines changed.
  • Claim driver's NAMED refusals — intact: driver.rs:341 ClaimDriverRefusal::NoOperatorWallet, driver.rs:360 ClaimDriverRefusal::ChainSourceUnbuildable. No production construction of UnavailableClaimChainPort anywhere; production_claim_port (driver.rs:320-330) returns RealClaimChainPort by concrete type, so a substitution is a compile error.
  • submit_initiate_payout entry slot via ChainEntrySlotSource — chain_port.rs:440, with the source-string guard adapter_source_never_calls_created_slot_value_to_slot (chain_port.rs:671-681) still asserting chain_port.rs contains no created_slot_value_to_slot call.
  • cycles_driven() counts loop iterations behind the sleep — driver.rs:376-383: sleep → run_cycle → record (driver.rs:146). The Site B guard even states in-body that the counter is not gate evidence and asserts on status().state instead.
  • the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window — present at driver.rs:1657, untouched by this diff, still driving run_claim_driver_in_with_clock with the 60-day raw / 31-day clamped pair and asserting both the gate and fee_window_start_unix.

4. Required contexts on main, read BY NAME at 6fe86472

Lint commit messages pass · Check version increment pass · Rustfmt pass · Clippy pass (4m00s) · Test + coverage pass (19m49s) · Release-script tests pass. All six green.

What I did not run

No cargo test and no cargo clippy locally (a cold build in this repo outlives the lane); the suite evidence is CI's Test + coverage job read by name. I therefore did not empirically prove that clippy resolves the chia_sdk_driver::RewardDistributor::created_slot_value_to_slot def-path — a green clippy run is equally consistent with a lint that never fires. Non-gating because chain_port.rs:671 guards the one file that matters independently; see the inline note on clippy.toml.

Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md
Comment thread clippy.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security gate — PASS

Head audited: 6fe86472e1ecc9bb62bde180e543eabc4da2ae36 (chore(release): v0.261.0), base origin/main @ 27cf18da.
Scope: the full 16-file / +935/-124 release delta (2ca67dd8 #623 + af8b0107 #621 + the version/CHANGELOG bump). Read-only from a private worktree; no build started, no process left running.

No LIVE vulnerability found. Four defence-in-depth items below, each recommended as a ticket, none gating.


Attack 1 — is the #3355 limiter keyed on caller-supplied input? No finding.

The key is the TCP peer, never launcher_id:

  • crates/dig-node-service/src/server.rs:1343-1344 — is_open_reward_chain_read(&method) && !control_ingress_admits(&state.reward_ingress, &requestor).
  • requestor is bound once at server.rs:1034 from requestor_for(&peer_addr), and peer_addr is axum's ConnectInfo<SocketAddr> — the real accepted socket, not a header. No X-Forwarded-For / X-Real-IP read anywhere on this path.
  • requestor_for (server.rs:957-963) maps loopback to RequestorId::Local, else Anonymous(peer_addr.ip()).
  • control_ingress_admits (server.rs:993-999) is requestor.is_local() || limiter.check(requestor); MissRateLimiter::check (crates/dig-node-core/src/rate_limit.rs:289-290) keys on requestor.key() only.

params.launcher_id is parsed far downstream in dispatch.rs (parse_launcher_id_arg) and never reaches the limiter. The comment's claim is true of the code. The separate bucket from control_ingress is real (server.rs:128, constructed at server.rs:621-624), so the two bounds cannot refuse each other.

Attack 2 — is the #3358 discovery cap a censorship primitive? Reported: yes. Selection: attacker-steerable — DEFENCE-IN-DEPTH, not a gate.

Reporting is sound and unconditional:

  • crates/dig-node-service/src/rewards_claim/chain_port.rs:262-263 — candidates_dropped = total.saturating_sub(candidate_cap), computed from the pre-truncation length, then take(candidate_cap) at :265.
  • Discovery::candidates_dropped is a non-optional field (rewards_claim/types.rs:16-30), so no port implementation can omit it.
  • rewards_claim/engine.rs:494-503 copies it into ClaimStatus::discovery_candidates_dropped_this_cycle unconditionally and warn!s when nonzero; reset per cycle at engine.rs:363 alongside every other counter, so it can never latch or go stale under a fresh last_attempt_at.

Selection is steerable, and the module doc says so at chain_port.rs:64-70 (clause 2). The candidate set is HintedLauncherIndex::launcher_ids (chain_port.rs:517-535): every on-chain coin carrying the tree-hash of "Reward Distributor v1". That hint is permissionless — anyone can mint hinted coins for dust. Truncation is take(256) in whatever order the chain transport returns, so:

Exploit: attacker mints >256 coins hinted "Reward Distributor v1" that return ahead of a legitimate distributor's launcher, that distributor falls past the cap, it is excluded from this cycle's candidate set, and the payee earns nothing that cycle — repeatable every cycle for the cost of dust.

Why this is not a gate:

  1. The gossip-hint path is independent of the cap — engine.rs:522-533 iterates self.hints.hints() and pushes any launcher that passes resolve_launch_comment re-derivation, continueing only on ids already present. An evicted-but-legitimate distributor is still claimable via a hint.
  2. The drop is loud (counter + warn! + status field), which is exactly what the "silent cap" failure mode forbids.
  3. The pre-cap state was strictly worse: unbounded decode plus one parent-spend chain read per hinted coin, i.e. the same dust flood wedged the whole claim loop rather than trimming its tail.

Ticket recommended: priority ordering for the cap (persisted known / previously-claimed launchers first, flood-minted newcomers last). Note that sorting candidate_ids is not a fix — launcher ids are grindable.

Attack 3 — does the gate predicate equal enforcement, and is there a second path? No bypass found. One drift-surface item.

Second paths, all closed:

  • mTLS peer surface — all five reward methods are Tier::Control and non-peer-reachable, enforced by a rule test over the live catalogue rather than an enumeration (crates/dig-node-core/tests/reward_methods_tier_guard.rs:22-80): it filters Method::ALL on name().contains("Reward"), asserts the scan is non-empty and pins the count at 5, then asserts Tier::Control and !is_peer_reachable(). A sixth reward method is caught automatically. Fails closed.
  • String pre-match above the enum — every reward-method name in crates/dig-node-core/src/seams/dig_rpc/dispatch.rs occurs only in a comment or in a Some(Method::…) arm (:909, :1002, :1059, :1103, :1187). None is in the pre-match.
  • /ws — ws_dispatch (server.rs:1653-1716) handles control.*, pairing.*, chat, wallet-authz, then falls through to state.wallet.dispatch. WalletBackend::dispatch_inner (crates/dig-wallet/src/sage/rpc.rs:4688+) has no dig.* arm at all (grep for "dig. in that file: zero hits) — the reward methods are unroutable over /ws, the same reason cache.* is.
  • POST /{method} wallet mirror — wallet_rpc (server.rs:1571-1600) dispatches to the same WalletBackend; same absence, same conclusion.
  • In-process FFI — stays open by design (SYSTEM.md), never reaches the HTTP handler. Unchanged by this diff.
  • Upstream passthrough — the token gate returns at server.rs:1310-1327 before any relay, so a refused method is never answered by the upstream gateway.

Catalogue equality holds: crates/dig-node-service/tests/openrpc_drift_guard.rs:275-284 now asserts m.requires_auth == server::requires_http_token(m.name) by equality for every local / passthrough / shell method, and crates/dig-node-service/src/meta.rs:612-694 sets requires_auth: true on the three node-local reads and false on the two open ones. Clippy and Test + coverage are green on this head.

DEFENCE-IN-DEPTH: requires_http_token (server.rs:1532-1538) is a re-statement of the enforcement expression, not the expression itself. Enforcement is server.rs:1295-1298 (three cache.* string literals OR-ed with is_node_local_reward_read) plus a separate chat gate at server.rs:1364. The reward and chat sets are structurally safe — both sites call the same shared predicate. The cache trio is duplicated literally in two places: deleting || method == "cache.listCached" from :1297 leaves both requires_http_token and the catalogue still asserting a token is required, and the drift guard still passes. Recommend the gate call requires_http_token(&method) directly, so the predicate is the enforcement.

Attack 4 — can the #3362 payout-approval refusal be skipped or fooled? No finding.

chain_port.rs:420-432. The refusal sits between the guarded read and any construction:

  • The snapshot is a fresh authenticated chain read every call — read_distributor_guarded(source.as_ref(), launcher_id) at :409, with not a distributor: launcher coin unspent on absence.
  • The flag read is snapshot.distributor().info.constants.require_payout_approval — a curried puzzle constant, so a fabricated value changes the distributor's puzzle hash and fails the guarded read. It is not caller-supplied and not derived from any request field.
  • The refusal is at :425-432, before SpendContext::new() (:437), before ChainEntrySlotSource::new (:440), before initiate_payout (:442), before finish_spend (:459) and before the only broadcast call in the file (:478). Nothing is built, signed or broadcast.
  • submit_initiate_payout is the sole broadcast path in this adapter (:478 is the only broadcast call site).
  • Errors are a named ClaimPortError::Other, never a fabricated success — no Paid is reported on the refusal branch.

The fee_mojos != 0 refusal at :397-403 sits above it with the same discipline.

Attack 5 — is the #3357 lint bypassable, and is there a production allow? No finding.

  • clippy.toml:17-19 bans chia_sdk_driver::RewardDistributor::created_slot_value_to_slot by resolved def path (an alias or re-export still resolves to it).
  • Exactly one #[allow(clippy::disallowed_methods)] exists in the whole repo: crates/dig-node-service/tests/common/rewards_fixture.rs:648 — the sanctioned in-process test fixture. Zero in production code.
  • The lint is enforced, not advisory: .github/workflows/ci.yml:102 runs cargo clippy --workspace --all-targets --locked -- -D warnings, and Clippy is green on 6fe86472.
  • A lint alone would still be weak, but it is belt-and-braces here: chain_port.rs:671-681 is a source-text test asserting the string created_slot_value_to_slot appears nowhere in this module's non-comment code.
  • submit_initiate_payout does go through the authenticated walk: ChainEntrySlotSource::new(source.as_ref(), launcher_id) at chain_port.rs:440, passed into initiate_payout at :442. own_entry likewise uses snapshot.entry_slot(payout_puzzle_hash) at :366.

Attack 6 — custody/consent: what changed for an anonymous caller? Withholds three reads; exposes nothing new.

Strictly a withdrawal. dig.getRewardProverStatus, dig.listRewardDistributors and dig.getPayeeRewardClaimStatus previously answered any POST / caller (under DIG_NODE_ALLOW_REMOTE=1); they now require the master control token or a paired token (server.rs:1298, :1505-1512). Each volunteered a node-to-launcher-id or node-to-inventory association nobody supplied — the operator's holdings and earnings posture. No method moves in the open direction.

No new oracle: the -32030 refusal is minted at server.rs:1310-1327 before dispatch, so the response is identical whether or not this node has any distributors, prover entries or claim state. The two methods that stay open (dig.getRewardDistributor, dig.listRewardDistributorCommitments) take a caller-supplied launcher_id and read only public chain state — they disclose nothing node-local, so the -32034 bound carries no existence signal either. The enlarged error string at :1318-1326 names the three method names, which are already in the public OpenRPC catalogue.


Defence-in-depth — tickets recommended, none gating this merge

  1. chain_port.rs:265 — discovery cap survivor selection is attacker-steerable (Attack 2). Priority ordering by persisted known launchers.
  2. server.rs:1295-1298 vs :1532-1538 — requires_http_token restates rather than is the gate; the cache.* trio is the duplicated part (Attack 3). Have the gate call the predicate.
  3. server.rs:957-963 — requestor_for keys on the accepted socket IP, so behind a reverse proxy or CDN every caller collapses into one Anonymous(proxy_ip) bucket sharing a single 32-burst / 8-rps budget. Pre-existing shape inherited from control_ingress; this PR gives it a second instance. Not exploitable in the default loopback posture, but a remote-exposed node behind a proxy is self-DoS-able by one client.
  4. crates/dig-node-core/src/rate_limit.rs:296-303 — on table saturation (MAX_TRACKED_REQUESTORS = 4096) the limiter reclaims only full (idle) buckets and otherwise refuses the newcomer. An attacker holding ~4096 partially-drained buckets from rotated source IPs can make a legitimate new source be refused outright. Pre-existing and deliberately fail-closed; this PR adds a second table with the same property.

Not covered

Behaviour of dig_rewards_coin internals (initiate_payout, ChainEntrySlotSource, DECODE_MAX_SERIALIZED_BYTES) beyond their call sites — out of this diff. CLVM / puzzle-level correctness of the distributor puzzle itself. No simulator or cargo run performed; relied on the green Clippy and Test + coverage jobs on this head.

gate: loop-security · head 6fe8647 · read-only, shared checkout unmodified

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Orchestrator gate summary @ 6fe86472 — and an overrule of the security PASS

Three gates ran, all fresh-context, all on this head.

Leg Verdict
loop-reviewer CHANGES-REQUIRED — the #3358 CHANGELOG bullet overclaims
loop-security PASS — attacks 1-6 answered; #3358 cap flagged non-gating
loop-decider (adversarial) HOLD — pull the #3358 cap, ship the rest

I am taking the HOLD and overruling the security PASS on that one point. Recording why, because
overruling a PASS needs to be on the record.

Security judged the discovery cap non-blocking on the grounds that "engine.rs:522-533's gossip-hint
path adds candidates independently of the cap"
. That is true of the generic ClaimEngine<P, H> and
false of the shipped binary. Production instantiates it with NoHintSource
(rewards_claim/driver.rs:585, inside run_claim_driver_in_with_clock, reached from the pub async fn run_claim_driver_in — not test-gated), and NoHintSource::hints() returns Vec::new()
(rewards_claim/hints.rs:30-32). In the artifact this release would publish, the "independent path"
yields nothing. The launcher index the cap truncates is the sole production route to a
distributor — which NoHintSource's own doc states: "a peer that never hears a hint MUST still find
and claim via 13.1."

The reviewer read the same generic loop and reached the same place. Two of three legs reasoned about
the type parameter rather than the production instantiation. That is the failure mode where a gate is
wrong the same way the code is.

So the composed defect stands: ~257 permissionlessly-minted 1-mojo hinted launcher coins evict a
legitimate distributor from every node's discovery window, every cycle, with no cache to remember the
rejects and no wire projection to show an operator it is happening. Against v0.260.0 that is a
regression on the pay path: the unbounded decode the cap replaces is a DoS that fails loudly,
while the cap fails silently — a healthy-looking node that stops earning.

Action: the #3358 cap and its drop-reporting machinery are being removed from this cut in full —
not disabled, not left as a permanently-zero counter, which would be the reassuring-zero shape this
epic keeps reproducing. dig_ecosystem#3358 stays OPEN and must land the bound, the persisted
rejected-launcher cache and the wire projection together, with a regression test in which a flood
ordered ahead of a legitimate launcher still gets that launcher claimed. Rationale recorded at
https://github.com/DIG-Network/dig_ecosystem/issues/3358#issuecomment-5825869006

The reviewer's blocking finding is resolved subtractively by the same change: the overclaiming bullet
is deleted rather than reworded, because the feature it describes is leaving the release.

Also landing in the same pass: the #3357 bullet gains a "dig-node half only" qualifier (#3357 spans
three repos and is not closed here), and the catalogue requires_auth flip false -> true for five
already-gated methods (meta.rs:157,169,184,604,612) gets a CHANGELOG line — it is visible to every
rpc.discover consumer and was unrecorded.

Everything else both legs verified stands and is not being re-opened: the limiter keys on the
accepted socket and never on the caller-supplied launcher_id; submit_initiate_payout refuses by
name before any build or broadcast and goes through ChainEntrySlotSource; the disallowed-methods
lint has exactly one sanctioned #[allow], in a test fixture; predicate/catalogue equality holds; and
all five must-not-regress guards were read body-by-body, not by name.

Re-gate will run on the new head.

The per-cycle hinted-launcher discovery cap is a censorship primitive on the
money path, so it comes out of this release entirely rather than shipping
disabled.

Candidates reach `RealClaimChainPort::discover_distributors` from
`coin_records_by_hints(tree_hash("Reward Distributor v1"))`. Anyone can mint a
hinted 1-mojo launcher coin, and the adapter decoded only the first N of that
index in whatever order the chain transport returned them -- an order the
constant's own doc conceded is attacker-influenceable. Roughly 257 cheap coins
were enough to push a legitimate distributor past the window and out of the
cycle's candidate set. The node keeps reporting healthy and silently stops
earning.

There is no second route in production. `run_claim_driver_in_with_clock` wires
`NoHintSource`, whose `hints()` returns an empty vector, so the gossip-hint
loop that makes the generic `ClaimEngine<P, H>` look multi-sourced is dead in
the shipped binary. An earlier review called the cap non-blocking on the
strength of that second source; that reasoning does not hold for what we ship.

Removed outright rather than neutered: the constant and its doc block, the
`candidate_cap` field and its `with_candidate_cap` constructor, the
`.take(candidate_cap)` decode bound, `Discovery::candidates_dropped`,
`ClaimStatus::discovery_candidates_dropped_this_cycle` and its per-cycle reset
and `warn!`. A counter that can only ever read zero is a reassuring zero -- a
reader cannot tell "nothing was dropped" from "nothing is counted", so the
field must not exist.

This restores the unbounded decode v0.260.0 already ships. That is a DoS
surface, but a loud one, and it is a known, gated, shipped state rather than a
new exposure.

DIG-Network/dig_ecosystem#3358 stays OPEN. The bound has to land together with
a persisted rejected-launcher cache and a wire projection of the drop count;
any one of the three alone reproduces the shape removed here.

CHANGELOG: drops the #3358 bullet, qualifies #3357 as the dig-node half only
(dig-account and dig-app halves remain open), and records the served OpenRPC
catalogue's `requires_auth` flip from false to true on five already-gated
methods, which is visible to every `rpc.discover` consumer.

Refs DIG-Network/dig_ecosystem#3358

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

Copy link
Copy Markdown
Contributor Author

loop-security re-audit @ 9d0f98ac — IN PROGRESS, not the verdict

Re-auditing the new head (prior PASS was at 6fe86472; an unrun leg is not a PASS).

Correction carried in: my earlier reasoning that engine.rs's gossip-hint path adds candidates
independently of the #3358 cap was true of the generic ClaimEngine<P, H> and false of the shipped
binary
. Verified at this head: production instantiates ClaimEngine::new(port, NoHintSource, ..) at
crates/dig-node-service/src/rewards_claim/driver.rs:584-585, and
crates/dig-node-service/src/rewards_claim/hints.rs:29-32 returns Vec::new(). There is no independent
route to a distributor in this artifact, so the truncated launcher index would have been the sole route.
Removing the cap is the correct call; I do not re-litigate it.

Q1 — did the subtraction open anything? No. discover_distributors at 9d0f98ac
(chain_port.rs:197-224) is line-for-line v0.260.0 (27cf18da) apart from the Discovery { distributors }
wrapper. Diffed the two bodies directly; the only deltas are the return type and the Ok(...) wrapper.
Preserved intact:

  • the SPEC 13.1 clause 6 re-verification — every id still goes through resolve_via_chain (:207), nothing
    is echoed from the index;
  • Err(ClaimPortError::Unavailable) => return Err(ClaimPortError::Unavailable) short-circuits (:210),
    and Err(other) propagates (:211);
  • the spawn_blocking boundary (:201) with the Arc::cloned source and the JoinError mapped to a
    bounded Other (:219-223).
    No re-verification step, error arm or async boundary was dropped.

More to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Re-gate verdict at head 9d0f98ac — SHIP

Prior verdict: HOLD at 6fe86472 (comment) — pull the #3358 per-cycle discovery cap, ship the rest. That HOLD is now CLEARED. The removal is complete and introduced nothing new.

I re-read the tree at 9d0f98ac independently; I did not rely on the implementer's reported greens.


Q1 — Is the censorship primitive gone? YES — actually gone, not disabled.

git grep -il "candidates_dropped\|candidate_cap\|MAX_HINTED_LAUNCHER" 9d0f98ac over the whole tree (not just crates/) returns zero files. Not a constant set to usize::MAX, not a feature flag, not a dead field — the symbols do not exist. The .take(candidate_cap) is gone from discover_distributors, which iterates the full index again.

Q2 — Did the removal leave a reassuring zero? NO.

There is no surviving count field anywhere. Discovery at crates/dig-node-service/src/rewards_claim/types.rs:18-21 is now exactly one field:

pub struct Discovery {
    pub distributors: Vec<DiscoveredDistributor>,
}

No candidates_dropped, no ClaimStatus::discovery_candidates_dropped_this_cycle, no warn! line, no RPC projection. Nothing can report a structurally-always-zero drop count, because nothing reports a drop count at all. This is the full deletion that was required, not a pinned-to-zero counter.

Q3 — Did subtraction break something? NO. The rewritten test is not vacuous.

Claim A (the removal): Test + coverage is green at 9d0f98ac — the full workspace sweep the implementer did not run locally. I checked that job by name, not by count.

Claim B (the replacement test): every_candidate_the_index_proposes_is_decoded keeps real assertions against the real simulator fixture — it asserts discovery.distributors.len() == 1 and distributors[0].launcher_id == fixture.launcher_id, decoding a bogus id alongside the real one. It cannot pass vacuously: a discovery that returned nothing, or that echoed the bogus id, fails both assertions.

Coverage lost by deleting the two cap tests is cap-only. The ordering coverage they incidentally carried is preserved and is in fact now symmetric: a_bogus_index_entry_is_dropped_not_echoed runs the index as [bogus, real], and the rewritten test runs it as [real, bogus]. Neither transport order is the only one exercised.

Q4 — Is this v0.260.0's behaviour? Yes, behaviourally identical; one cosmetic type difference.

Diffing discover_distributors code lines (comments stripped) between v0.260.0 and 9d0f98ac: 14 of 14 body lines match. The only delta is the return type — Result<Vec<DiscoveredDistributor>, ClaimPortError> at v0.260.0 vs Result<Discovery, ClaimPortError> now, wrapping the same discovered vec in the one-field struct. The iteration, decode and verify path is byte-for-byte v0.260.0's. The Discovery wrapper is residue of the pulled #3358 work on a crate-internal trait; it carries no data v0.260.0 did not carry and no field that can lie. Not worth churning a release cut to unwrap.

Q5 — CHANGELOG version gap. Your disposition is right. Does not block.

Confirmed still present at head: ## [0.261.0] at line 7 jumps straight to ## [0.255.0] at line 37; v0.256.0–v0.260.0 unrecorded, and all five tags exist. dig_ecosystem#3370 is OPEN and names exactly this. Back-filling five versions of history inside a release cut would widen this diff with unreviewed prose on a money-path release for zero ship-blocking benefit — a documentation gap is not a correctness or custody defect. Filed-and-ship is the correct call.

Q6 — Do the new CHANGELOG bullets over-claim? NO. All three verified.

  • requires_auth flip — all five method names verified individually in crates/dig-node-service/src/meta.rs, each false at v0.260.0 and true at 9d0f98ac: cache.listCached, cache.fetchAndCache, cache.pushCapsule, chat.send (meta.rs:597→604), chat.poll. No sixth method flipped, none of the five was already true.
  • "No enforcement changed" — verified true. At v0.260.0, crates/dig-node-service/src/server.rs:1262-1264 already token-gated all three cache.* methods (constant-time compare against master or paired token, deliberately not control::is_authorized, which fails open for non-control.*), and is_gated_chat_method already matched "chat.send" | "chat.poll". The catalogue was describing enforced methods as open; the flip corrects the description only. The bullet's warning that every rpc.discover consumer reading requires_auth will see the flip is the right disclosure.
  • #3357 qualification is honest — the bullet now says dig-node half only, ticket remains OPEN, which is what a reader needs to not mistake this for closure.

Six required contexts, read BY NAME at 9d0f98ac

Context Result
Lint commit messages success
Check version increment success
Rustfmt success
Clippy success
Test + coverage success
Release-script tests success

(Analyze (rust) also completed success; CodeQL neutral as usual for this repo.)

Bottom line

SHIP. No follow-up needs filing beyond dig_ecosystem#3370, which is already open. The cap is gone rather than neutered, the money path returned to proven v0.260.0 discovery behaviour, the replacement test carries real assertions, and the full workspace test sweep is green at this head. Mark the PR ready and merge.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-audit — PASS

Head audited: 9d0f98ac9b7612e24b7ca6b69dac6b7813b601ce, merge-base origin/main @ 27cf18da (= v0.260.0).
Delta re-audited vs the previously-audited 6fe86472: 7 files, +16 / -155, purely subtractive. Read-only; no build started, no process left running, shared checkout unmodified (git fetch only).

No LIVE vulnerability. Nothing in this cut is gating.


Q1 — did the subtraction open anything? No.

discover_distributors at crates/dig-node-service/src/rewards_claim/chain_port.rs:197-224 is line-for-line v0.260.0 (27cf18da) apart from the Discovery { distributors } return wrapper. I diffed the two bodies directly; the only deltas are the return type and the Ok(..) wrapper. Preserved intact:

  • Re-verification kept. SPEC 13.1 clause 6 — every index-proposed id still goes through resolve_via_chain at chain_port.rs:207; Ok(None) is dropped at :209, nothing is echoed back from the index. The index still only PROPOSES.
  • Error propagation kept. Err(ClaimPortError::Unavailable) => return Err(ClaimPortError::Unavailable) still short-circuits at chain_port.rs:210, and Err(other) still propagates at :211. Neither arm was collapsed into a continue, which is the shape that would have turned a chain fault into a silently short candidate set.
  • spawn_blocking boundary unchanged. chain_port.rs:201, same Arc::cloned source (:199), same JoinError mapped to a bounded Other (:219-223).

Engine side: the removed reset/copy/warn! block left run_cycle's other per-cycle counters untouched (engine.rs:356-359), and the discovery-failure arm now yields Discovery::default() — an empty distributors vec, identical in effect to v0.260.0's Vec::new(). No re-verification step, error arm or async boundary was dropped.

Correction carried in and confirmed at this head. My prior Attack 2 reasoning that the gossip-hint path adds candidates independently of the cap was true of the generic ClaimEngine<P, H> and false of the shipped binary: production wires ClaimEngine::new(port, NoHintSource, ..) at rewards_claim/driver.rs:584-585 inside run_claim_driver_in_with_clock, and NoHintSource::hints() returns Vec::new() (rewards_claim/hints.rs:29-32). In this artifact discover_distributors is the SOLE route to a distributor. Removing the cap is the correct call; I do not re-litigate it.

Q2 — attacks 1, 3, 4, 5, 6 at the new head. All undisturbed.

  • Attacks 1, 3, 6 (limiter keying, gate-predicate-equals-enforcement + second paths, custody/consent withdrawal) rest entirely on files this commit does not touch. git diff 6fe86472..9d0f98ac is byte-empty for crates/dig-node-service/src/server.rs, crates/dig-node-service/src/meta.rs, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, crates/dig-node-core/src/rate_limit.rs, crates/dig-node-service/tests/server.rs and crates/dig-node-service/tests/openrpc_drift_guard.rs (0 bytes each). Conclusions carry unchanged.
  • Attacks 4 and 5 touch chain_port.rs, which DID change — re-verified positionally rather than assumed:
    • A4 (#3362 payout-approval refusal): the refusal still reads the chain-curried snapshot.distributor().info.constants.require_payout_approval at chain_port.rs:350-356 and returns a named ClaimPortError::Other at :358-364 — still before SpendContext::new() (:370), ChainEntrySlotSource::new (:371), initiate_payout (:373) and the file's only broadcast call (:409). The fee_mojos != 0 refusal still sits above it at :328. Nothing built, signed or broadcast on the refusal branch.
    • A5 (#3357 lint): clippy.toml diff is byte-empty; the source-text backstop test survives at chain_port.rs:602-611 (!code_only.contains("created_slot_value_to_slot")); git grep over 9d0f98ac finds exactly one #[allow(clippy::disallowed_methods)] in the tree, still the sanctioned fixture at tests/common/rewards_fixture.rs:648, zero in production. Clippy is green on this head.

I also checked the new CHANGELOG requires_auth bullet rather than taking it on trust, since it is a claim about an auth flag. All five named methods do flip false to true in meta.rs between 27cf18da and 9d0f98ac (cache.listCached, cache.fetchAndCache, cache.pushCapsule, chat.poll, and chat.send at meta.rs:597/604). meta.rs is unchanged by this commit, so the bullet documents the already-audited 6fe86472 state — catalogue honesty, no enforcement change, and the equality assertion in openrpc_drift_guard.rs keeps catalogue and gate in lockstep.

Q3 — what exposure v0.261.0 therefore ships. Strictly the v0.260.0 status quo; not worse in any way.

Plainly: hinted launcher discovery is unbounded again. Per claim cycle, HintedLauncherIndex::launcher_ids (chain_port.rs:448-482) issues one coin_records_by_hints query for the tree-hash of the literal "Reward Distributor v1", filters to coins whose puzzle hash is SINGLETON_LAUNCHER_HASH (:470-473), and hands every survivor to discover_distributors, which performs one parent-spend chain read plus one decode per candidate with no ceiling on the count. The hint is permissionless — anyone can mint singleton launcher coins carrying it for dust — so an attacker sets the size of that set.

Per-candidate cost is bounded, confirmed. dig_rewards_coin 0.8.0 (locked: Cargo.lock, dig-rewards-coin 0.8.0) refuses an oversized candidate before any allocation: discovery.rs:165 and :172 check puzzle_reveal.len() and solution.len() against DECODE_MAX_SERIALIZED_BYTES = 65_536 (discovery.rs:117) ahead of ctx.alloc. So the exposure is N * (1 chain read + at most 2 * 64 KiB bounded decode), unbounded only in N.

Two mitigating facts, stated for accuracy rather than as a defence:

  1. This is a node-local, cadence-driven loop, not an RPC ingress path — it is not reachable per-request. CLAIM_CADENCE_FLOOR_SECONDS = 60 (rewards_claim/config.rs:52) floors it at one discovery cycle per 60s, so the flood is amortised, not amplified per attacker request.
  2. The #3355 ingress limiter this release adds is on a different surface entirely and neither bounds nor is bounded by this path.

One consequence worth naming, also unchanged from v0.260.0: because discover_distributors aborts the whole call on the first Err (chain_port.rs:210-211), a large attacker-set N raises the odds that some candidate's chain read errors and takes the entire cycle's candidate set with it (engine.rs sets fault_reported, zero distributors that cycle). That is a cheaper claim-loop stall than exhausting the decode budget. It is pre-existing, identical in v0.260.0, and belongs on dig_ecosystem#3358 with the rest — not a regression introduced here.

Verdict on Q3: accepted, LOUD, tracked on dig_ecosystem#3358. Byte-identical to the v0.260.0 behaviour this release replaces. Nothing in this cut makes it worse.

Q4 — the two defence-in-depth items. Neither made worse.

Both live in files with a byte-empty diff from 6fe86472 (server.rs 0 bytes, crates/dig-node-core/src/rate_limit.rs 0 bytes), and this cut touches no ingress, limiter or requestor-keying path at all — it is confined to rewards_claim/ plus the CHANGELOG. requestor_for is still server.rs:957-963 and the saturation branch still rate_limit.rs:296-303, unchanged text at unchanged lines.

Ticket titles to file:

  1. rate-limiter keyed on the accepted socket IP collapses every caller behind a reverse proxy into one Anonymous(proxy_ip) bucket
  2. MissRateLimiter refuses newcomers outright once MAX_TRACKED_REQUESTORS is saturated by partially-drained buckets

CI at 9d0f98ac — the six required contexts, by name

Lint commit messages pass · Check version increment pass · Rustfmt pass · Clippy pass · Test + coverage pass · Release-script tests pass.

(Test + coverage was pending at the start of this audit; I waited for it to reach terminal rather than infer from the others. It is the evidence for the workspace sweep the implementer did not run locally. I ran no cargo build myself and left no process running.)

Scope audited / not covered

Audited: the 7-file 6fe86472..9d0f98ac delta in full, plus positional re-verification of every chain_port.rs anchor my prior verdict cited, plus the 27cf18da..9d0f98ac diff of rewards_claim/ as a whole. Not covered: dig_rewards_coin internals beyond the DECODE_MAX_SERIALIZED_BYTES gate I read directly; CLVM/puzzle-level correctness of the distributor puzzle; no simulator run.

gate: loop-security · head 9d0f98ac · read-only, shared checkout fetched but unmodified

@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.

PASS — head 9d0f98ac9b7612e24b7ca6b69dac6b7813b601ce

Re-review of the CHANGES-REQUIRED verdict I left at 6fe86472
(#624 (review)). One commit on top,
7 files, +16/-155. All three threads I opened are now resolved; zero open threads.

I do not approve or request changes through the review API: same identity as the lane that drove
this PR, so GitHub returns 422. This comment is the verdict.

1. The blocking finding — RESOLVED (subtractively)

The #3358 bullet overclaimed coverage. It is gone, because the cap it described is gone. Repo-wide
at this head, grep -rn "candidates_dropped\|MAX_HINTED_LAUNCHER\|with_candidate_cap\|candidate_cap"
over *.rs / *.md / *.toml returns zero hits. Detail in the thread.

I checked the subtraction left no half-updated shape:

  • Discovery is a one-field struct (crates/dig-node-service/src/rewards_claim/types.rs:18); all
    three construction sites updated (chain_port.rs:214, driver.rs:1140, engine.rs:1054).
  • ClaimStatus::discovery_candidates_dropped_this_cycle removed from the struct and its
    hand-written Default impl — no declared-but-undefaulted or defaulted-but-undeclared field.
  • engine.rs no longer imports the constant; the per-cycle reset, the copy and the warn! went
    with it. port.rs's trait doc no longer imposes a reporting duty no implementor carries.
  • Clippy and Test + coverage are green at this head, which is the compile-level proof that no
    dangling field or import survives.

2. The new requires_auth bullet — TRUE, checked mechanically

CHANGELOG.md:20-24 names five methods. I enumerated every requires_auth line REMOVED in
crates/dig-node-service/src/meta.rs between the base (27cf18da) and this head, resolving each
back to its owning name:. The flip set is exactly:

method meta.rs line at head base → head
cache.listCached meta.rs:157 false → true
cache.fetchAndCache meta.rs:169 false → true
cache.pushCapsule meta.rs:184 false → true
chat.send meta.rs:604 false → true
chat.poll meta.rs:612 false → true

(chat.poll's flip renders oddly in the unified diff — the old false line is re-anchored as
context for the new trailing entry — so I read it with git show <rev>:meta.rs at both revisions
rather than off the diff. Both confirm false at 27cf18da, true at 9d0f98ac.)

Nothing else flipped. The three dig.* node-local reward reads are new catalogue entries, not
flips, and the first bullet already covers them. The full set of non-control catalogue entries
carrying requires_auth: true at this head is those five plus those three — exactly the eight
server::requires_http_token returns true for.

"No enforcement changed" holds for all five. The POST / cache gate
(crates/dig-node-service/src/server.rs:1292-1296) still reads
method == "cache.fetchAndCache" || method == "cache.pushCapsule" || method == "cache.listCached"
— unchanged lines in the diff; the commit only adds || is_node_local_reward_read(&method) and
rewrites the refusal message. is_gated_chat_method (server.rs:1496) is untouched context:
matches!(method, "chat.send" | "chat.poll"). Both gates predate this PR, so the five were already
enforced as authenticated while advertising false — precisely what the bullet says.

3. The #3357 bullet now scopes itself — TRUE

CHANGELOG.md:28-30 says "the dig-node HALF of #3357 only … remains OPEN". clippy.toml:17-19
carries the workspace disallowed-methods entry; exactly one #[allow(clippy::disallowed_methods)]
exists in the tree (crates/dig-node-service/tests/common/rewards_fixture.rs:648) and it carries
the WHY comment clippy.toml demands (in-process, same-generation pending_spend slots). No
production caller of created_slot_value_to_slot remains; chain_port.rs:602 additionally holds a
source-text guard against reintroducing one.

4. Every remaining [0.261.0] bullet still TRUE at this head

  • Token-gate the three node-local reward reads (#3352) — server.rs:1296 +
    is_node_local_reward_read (server.rs:1502), -32030 on refusal.
  • Rate-bound the two OPEN chain-keyed reads per SOURCE (#3355) — server.rs:1339-1352, keyed on
    RequestorId via state.reward_ingress, a bucket separate from control_ingress; never on the
    caller-supplied launcher_id. -32034 REWARD_INGRESS_LIMITED minted at meta.rs ErrorCode.
  • Refuse require_payout_approval by name (#3362) — chain_port.rs:345-362, inside the
    spawn_blocking closure, before initiate_payout, finish_spend or any broadcast.
  • Non-empty launcher index in the claim-port regression test (#3363) — intact.
  • Version 0.261.0 in Cargo.toml:36 matches the section heading; Check version increment green.

5. Test vacuity of the rewritten test — acceptable, with one honest limit

every_candidate_the_index_proposes_is_decoded
(crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs:105) is not vacuous: it drives
a real simulator fixture, passes [real_id, bogus_id], and asserts distributors.len() == 1 and
distributors[0].launcher_id == fixture.launcher_id — the bogus id is dropped, the real one is
decoded. Paired with a_bogus_index_entry_is_dropped_not_echoed (bogus FIRST) it does establish
order-independence, which is what its doc claims.

Non-gating limit, stated so nobody over-reads it later: this test would also pass under a
re-introduced .take(1), because the real id is first. It proves decoding and order-independence,
not the absence of a bound. I do not treat that as blocking — the removal is the subtraction of
an unreleased feature and the CHANGELOG now makes no claim about discovery bounds, so there is no
shipped claim left needing a proof test.

6. The five must-not-regress guards — all intact, read by BODY

  • a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer
    (tests/rewards_claim_chain_port_3347.rs:482) still drives run_claim_driver_in over a real
    RealClaimChainPort and asserts the accrued figure the simulator actually paid, read through
    own_entry before the driver ever sees the port.
  • The claim driver's NAMED refusals with no silent fallback — driver.rs:351 ("A named refusal,
    never a silent fallback to UnavailableClaimChainPort"), plus the string guard (driver.rs:916)
    and the type-level guard (driver.rs:966) that the string guard alone cannot cover.
  • submit_initiate_payout goes through ChainEntrySlotSource — chain_port.rs:368, with the
    explicit "NEVER created_slot_value_to_slot" comment above it.
  • cycles_driven() counts INVOCATIONS, not gate passes — driver.rs:114, and
    the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window (driver.rs:1656) still
    carries the in-body warning that cycles_driven() alone is not gate evidence and asserts on
    status().state instead.
  • the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window itself: raw 5_184_000s vs
    clamped 2_678_400s still asserted as two distinct numbers.

7. All six REQUIRED contexts green BY NAME at 9d0f98ac

Lint commit messages pass · Check version increment pass · Rustfmt pass · Clippy pass ·
Test + coverage pass (19m58s, run 36089761161) · Release-script tests pass. I waited for
Test + coverage rather than assuming it; it was in_progress when I started.

Threads

3 opened by me across both rounds · 3 resolved · 0 open.

What I did NOT run

No local cargo test / cargo build of any kind — a cold build here outlives the lane, and CI's
Test + coverage at this exact head is the stronger evidence. No runtime exercise of the HTTP
ingress limiter; I read its predicate and its bucket wiring, and relied on the PR's own
open_reward_chain_reads_never_limit_the_loopback_operator / per-source tests, which are inside the
green Test + coverage. I did not re-audit files whose diff is empty against 6fe86472
(clippy.toml, meta.rs, server.rs, Cargo.toml, Cargo.lock, tests/common/rewards_fixture.rs)
beyond the claims above that depend on them.

Residual, filed not blocking

server::requires_http_token (server.rs:1533) restates the gate the POST / handler expresses
inline at server.rs:1292; openrpc_drift_guard::served_classes_are_well_formed pins the
catalogue to requires_http_token by equality, but nothing pins requires_http_token to the
inline gate. They agree today. This is follow-up material, not a bar to this release, and it is
unchanged from 6fe86472 — I am not opening a round-3 thread for it.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 25, 2026 03:41
@MichaelTaylor3d
MichaelTaylor3d merged commit cf022fa into main Sep 25, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the release/v0.261.0 branch September 25, 2026 03:41
MichaelTaylor3d added a commit that referenced this pull request Sep 25, 2026
…ink)

dig-node is squash-only by settled decision, so the develop -> main cut cannot preserve ancestry and
main's squash commit is unreachable from develop. This -s ours merge records that ancestry so the
NEXT cut's compare is a content diff rather than a replay of every develop commit.

-s ours records ancestry and brings NO content. The content follows in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <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