Skip to content

fix(rewards-claim): chain_port audit, approval refusal, discovery cap - #623

Merged
MichaelTaylor3d merged 3 commits into
developfrom
harden/3246-remainder
Sep 24, 2026
Merged

MichaelTaylor3d merged 3 commits into
developfrom
harden/3246-remainder

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

DO NOT MERGE -- gate round in progress

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

Per-ticket status

#3357 -- audit created_slot_value_to_slot: LANDED

  • clippy.toml (repo root, new): disallowed-methods entry banning
    chia_sdk_driver::RewardDistributor::created_slot_value_to_slot workspace-wide, with the
    phantom-LineageProof rationale in the reason string.
  • The one legitimate in-process use, crates/dig-node-service/tests/common/rewards_fixture.rs:648,
    carries #[allow(clippy::disallowed_methods)] with a comment stating why its receiver is
    in-process this generation (its own pending_spend.created_reward_slots), not chain-rebuilt.
  • Lint-fires proof (M3, see below): removing the #[allow] makes
    cargo clippy -p dig-node-service --all-targets --locked -- -D warnings fail at
    rewards_fixture.rs:653 with the exact reason text; restored afterward.
  • The lint cannot express "receiver is in-process this generation" itself (a syntactic ban on the
    call, not a semantic check of the receiver's provenance) -- that is why every #[allow] site
    carries an explanatory comment instead of a narrower lint.

#3362 -- refuse require_payout_approval distributors by name: LANDED

  • crates/dig-node-service/src/rewards_claim/chain_port.rs, submit_initiate_payout: refuses with
    a named ClaimPortError::Other(...) before SpendContext::new() when
    snapshot.distributor().info.constants.require_payout_approval is true -- this adapter drops
    initiate_payout's returned conditions unconditionally, so proceeding would silently violate
    the approval requirement. Never Ok(()), never Ok(None) -- a named refusal.
  • Regression test: a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast
    (tests/rewards_claim_chain_port_3347.rs), against a REAL simulator launch with the flag curried
    true via the new launch_funded_admitted_fixture_with_approval (not a struct literal).
  • RED-first: on the untouched tree (guard absent) this test panics with
    "expected a named refusal, got Ok(())"; green with the guard in place (see M1 below).

#3358 -- bound hinted-launcher candidates, report every drop: LANDED

  • MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE = 256 (chain_port.rs), with a doc block stating the
    derivation and what it does NOT cover.
  • discover_distributors now caps candidates via .take(candidate_cap), computes
    candidates_dropped = total.saturating_sub(candidate_cap), and returns the new
    Discovery { distributors, candidates_dropped } (types.rs) instead of a bare Vec -- so a
    bounded port cannot silently shrink its answer.
  • engine.rs::run_cycle threads the drop count into
    ClaimStatus.discovery_candidates_dropped_this_cycle (reset every cycle, never latched) and logs
    tracing::warn! when nonzero.
  • RealClaimChainPort::with_candidate_cap(...) lets a test pin a small cap instead of decoding 256+
    real candidates.
  • Tests: a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it (cap=1, real launcher id
    past the cap -> dropped and reported) and a_cap_that_covers_every_candidate_drops_nothing
    (cap=2, nothing dropped) -- both in tests/rewards_claim_chain_port_3347.rs.
  • SPEC.md: no discover_distributors/hinted-launcher clause exists in this repo's SPEC.md (grepped
    for "discover_distributors", "HintedLauncherIndex", "hinted launcher" -- no matches), so nothing
    to amend there.

#3363 -- fix the empty-index shortcut: LANDED

  • a_failing_source_reports_unavailable_everywhere now builds its port over
    FixtureLauncherIndex(vec![launcher_id]) (was vec![]), so discover_distributors and
    submit_initiate_payout actually reach the failing MockChainSource's Unavailable answer
    instead of short-circuiting on an empty candidate list before ever driving the source.
  • M4 (below) proves the empty-index version passed for the wrong reason.

Mutation proofs

M1 (#3362 guard removed):

thread 'a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast' panicked:
expected a named refusal, got Ok(())

Restored; test green again (1 passed; 0 failed).

M2 (#3358: candidates_dropped forced to 0 unconditionally):

thread 'a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it' panicked at
crates\dig-node-service\tests\rewards_claim_chain_port_3347.rs:126:5:
assertion `left == right` failed: the one candidate past the cap must be reported, never silently absorbed
  left: 0
 right: 1

Restored; both cap tests green again.

M3 (#3357: #[allow(clippy::disallowed_methods)] removed at the fixture call site):

error: use of a disallowed method `chia_sdk_driver::RewardDistributor::created_slot_value_to_slot`
   --> crates\dig-node-service\tests\common\rewards_fixture.rs:653:25
    = note: derives a LineageProof from the coin it is called on; on a distributor rebuilt from
      chain that coin is the TIP, so any earlier generation's slot is a PHANTOM (dig_ecosystem#3357)...
error: could not compile `dig-node-service` (test "rewards_chain_port_a3") due to 1 previous error
error: could not compile `dig-node-service` (test "rewards_claim_chain_port_3347") due to 1 previous error

Confirms --all-targets catches the fixture-crate call too. Restored; clippy clean again.

M4 (#3363: index emptied back to vec![]):

thread 'a_failing_source_reports_unavailable_everywhere' panicked at
crates\dig-node-service\tests\rewards_claim_chain_port_3347.rs:245:5:
assertion `left == right` failed
  left: Ok(Discovery { distributors: [], candidates_dropped: 0 })
 right: Err(Unavailable)

With vec![], discovery short-circuits to an empty Ok WITHOUT ever calling the failing source --
proving the pre-#3363 empty-index version passed for the wrong reason. Restored; test green again.

M5 (#3357: production-call probe): a temporary call to created_slot_value_to_slot added inside
chain_port.rs::reserve_asset_id (production code) made
cargo clippy -p dig-node-service --all-targets --locked -- -D warnings fail with the same
disallowed-method diagnostic at that production call site; reverted immediately after capture, no
trace left in the diff.

Tests

  • cargo test -p dig-node-service --test rewards_claim_chain_port_3347 (whole file, --test-threads=1):
    12 passed, 0 failed (was 10 before this PR; 2 new #3358 cap tests added).
  • cargo test -p dig-node-service --lib rewards_claim: 100 passed, 0 failed (unchanged count --
    confirms the Discovery refactor across engine.rs's 6 test fakes and driver.rs's 2 test fakes
    introduced no regression).
  • Named acceptance tests confirmed green individually within the above runs:
    a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer,
    a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read,
    discover_distributors_returns_exactly_the_real_launch,
    adapter_source_never_calls_created_slot_value_to_slot (chain_port.rs:666),
    the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window (driver.rs:1657).

Local verification

  • cargo clippy -p dig-node-service --all-targets --locked -- -D warnings: clean.
  • cargo fmt --check -p dig-node-service: clean (after one cargo fmt pass, committed).
  • Did NOT run cargo clippy --workspace or cargo test --workspace (brief: never --workspace;
    scoped to -p dig-node-service throughout). Did not run the full CI matrix (.deb/.msi/.pkg builds,
    CodeQL) locally -- those are running on the pushed commit per gh pr checks.

Blast radius

Touched only crates/dig-node-service/src/rewards_claim/{chain_port,port,mod,types,engine,driver}.rs,
crates/dig-node-service/tests/{rewards_claim_chain_port_3347.rs,common/rewards_fixture.rs},
root clippy.toml, and CHANGELOG.md. No changes to server.rs, meta.rs, dispatch.rs,
tests/server.rs, or modules/apps/dig-node (all read-only per brief; sibling lane PR #621).
Discovery's replacement of Vec<DiscoveredDistributor> as ClaimChainPort::discover_distributors's
return type is a breaking signature change on a crate-internal trait with exactly the implementors
listed above (grepped for every impl ClaimChainPort/discover_distributors in
crates/dig-node-service); none live outside this crate.

Not done / needs the parent

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

MichaelTaylor3d and others added 3 commits September 24, 2026 10:39
- #3357: ban RealClaimChainPort's/adapters' calls to
  RewardDistributor::created_slot_value_to_slot from production code via a
  workspace clippy.toml disallowed-methods lint (phantom LineageProof risk on
  a chain-rebuilt distributor); allow the one legitimate in-process
  test-fixture use in rewards_fixture.rs with a justifying comment.
- #3362: submit_initiate_payout refuses, by name, a distributor whose
  require_payout_approval is true, before building or broadcasting anything
  -- this adapter drops initiate_payout's returned conditions unconditionally,
  so silently proceeding would violate the approval requirement. Regression
  test launches a real simulator fixture with the flag curried true.
- #3358: discover_distributors now bounds hinted launcher candidates to
  MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE (256) and reports every drop via
  the new Discovery{distributors, candidates_dropped} return type, threaded
  through ClaimStatus.discovery_candidates_dropped_this_cycle and a warn! log
  when nonzero -- a silent cap on discovery is a censorship primitive.
- #3363: a_failing_source_reports_unavailable_everywhere now uses a
  non-empty launcher index so the assertion exercises the failing source's
  discover/submit paths instead of an already-empty-list shortcut.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it and
  a_cap_that_covers_every_candidate_drops_nothing exercise
  RealClaimChainPort::with_candidate_cap's drop-and-report behaviour without
  decoding hundreds of candidates.
- cargo fmt across the touched rewards_claim files.
- CHANGELOG.md: Unreleased / "Reward claim port hardening" entry for #3357,
  #3362, #3358, #3363.

Refs DIG-Network/dig_ecosystem#3358

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d MichaelTaylor3d changed the title harden(rewards-claim): chain_port audit, approval refusal, discovery cap fix(rewards-claim): chain_port audit, approval refusal, discovery cap Sep 24, 2026
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security verdict: PASS

Head audited: 836a10d0beea30c4f5c8907285a997961e5590f1

S1 - Censorship/starvation via the discovery cap (#3358)

  • The node decodes the chain transport's own returned order, .take(candidate_cap) - attacker-influenceable per the doc block at chain_port.rs (MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE doc, point 2), and the PR names this explicitly in the "does NOT cover" list rather than claiming coverage. That is an honest disclosure, not a live defect this PR introduces.
  • Permanent starvation is theoretically possible (a flood of bogus hinted coins ahead of a legitimate one every cycle) but is pre-existing to the unbounded hint path (explicitly out of scope, item 1) - the 256 cap on discover_distributors only bounds decode cost on the index path; it does not create a new starvation vector beyond what an unbounded index already had.
  • candidates_dropped is computed unconditionally (total.saturating_sub(candidate_cap)) before the loop runs, not after .take()+dedup, so there is no path where the cap fires and the count stays 0 for a real drop. It is reset every cycle in run_cycle (engine.rs - self.status.discovery_candidates_dropped_this_cycle = 0 at top, then overwritten unconditionally with discovered.candidates_dropped, logged via warn! when nonzero) - never latched, always reported.
  • with_candidate_cap(0) is NOT reachable from production wiring: driver.rs:328 and driver.rs:896 (the only two RealClaimChainPort::new( construction sites, one of which is asserted-on by a string-literal regression test at driver.rs:921) call ::new, which always sets candidate_cap = MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE (256). with_candidate_cap is called only from the two new tests in rewards_claim_chain_port_3347.rs. Verified via grep across driver.rs and mod.rs - no production call site.

S2 - Refuse-vs-skip on require_payout_approval (#3362)

  • chain_port.rs submit_initiate_payout: the guard reads snapshot.distributor().info.constants.require_payout_approval off the freshly-read snapshot (the same guarded chain read every other field in this function uses, not a stale cache) and returns Err(ClaimPortError::Other(bounded(...))) immediately - before SpendContext::new(), before any initiate_payout/spend-building/broadcast call. Confirmed no SpendContext/broadcast call exists between the flag check and the return.
  • Downstream: engine.rs maps every Err(ClaimPortError::Other(_)) from this call to self.status.fault_reported = true and a ClaimOutcome::Faulted{..} (confirmed at each of the four submit-adjacent match sites, e.g. engine.rs:699-706, 822-829) - never Paid/Nominal. The new regression test additionally asserts the broadcaster's sent log stays empty.

S3 - Phantom slot lint (#3357)

  • clippy.toml bans chia_sdk_driver::RewardDistributor::created_slot_value_to_slot workspace-wide via disallowed-methods.
  • The single #[allow(clippy::disallowed_methods)] is at tests/common/rewards_fixture.rs (in launch_funded_admitted_fixture_with_approval), immediately preceded by a comment naming the receiver as the fixture's own freshly-built in-process distributor.pending_spend this same generation - matches the "in-process this generation" carve-out the lint's own reason text requires.
  • The lint's reason string and the clippy.toml header comment both explicitly say the lint bans the CALL, not the receiver class, and cannot distinguish a chain-rebuilt receiver from an in-process one - this is stated, not implied, so it does not over-claim coverage.
  • Mutation proof M5 in the PR body (temporary production-call probe inside chain_port.rs::reserve_asset_id, reverted) is consistent with the lint firing workspace-wide including production code, not just the test crate.

S4 - Error taxonomy

  • The diff's only change to the Err(...) match in engine.rs's discovery call is Vec::new() -> Discovery::default() inside the existing Err(other) => arm (the Other branch) - the Err(ClaimPortError::Unavailable) arm is untouched by this diff (confirmed via full-file grep: chain_unavailable_this_cycle is still set at engine.rs:477 and the early-return PreBudgetResult::ChainUnavailable/BudgetPhaseResult::ChainUnavailable paths at lines 698, 721, 750, 822 are unchanged). No absence<->outage remapping is introduced.
  • discovery_candidates_dropped_this_cycle is a distinct, new per-cycle field (an absence-of-decode count), never conflated with no_entry_slot_this_cycle (an absence-of-entry count on candidates that WERE decoded) - the field's own doc comment states this distinction.

S5 - Bounded strings/logs

  • The new refusal message is wrapped in bounded(...) (the existing MAX_ERROR_CHARS = 200 helper), same discipline as every other ClaimPortError::Other producer in this file.
  • The new tracing::warn! in engine.rs carries only dropped (u32) and cap (a compile-time constant) - no attacker-controlled unbounded field.

S6 - Fee/spend regressions

  • No changes to required_fee_mojos, fee-window accounting, or the early return Vec::new() ordering in engine.rs outside the one Discovery-typed replacement already covered under S4. Confirmed by diff scope: chain_port.rs, driver.rs (test fakes only), engine.rs (type threading + new counter + warn), mod.rs/port.rs/types.rs (type plumbing), test files, clippy.toml, CHANGELOG.md.

Mutation proofs (M1-M5) vs. diff

All five pasted red lines name file:line locations and test/lint names that are present in this diff (a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast, a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it, the rewards_fixture.rs #[allow] site, a_failing_source_reports_unavailable_everywhere, and the chain_port.rs::reserve_asset_id production-call probe described as reverted with no trace in the diff - confirmed absent from the diff). No mutation proof cites a file or test outside this PR's scope.

CI (required contexts, by name)

  • Rustfmt: SUCCESS
  • Clippy: SUCCESS
  • Release-script tests: SUCCESS
  • Test + coverage: IN_PROGRESS at time of audit - not yet terminal
  • Lint commit messages: FAILURE - commitlint rejects the PR title's type token harden (not in [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert]). This is a title-format failure, not a code defect; mechanical fix (retitle to a conventional type, e.g. fix(rewards-claim): ...) needed before merge but is not a security finding.

Findings

None LIVE. No exploit path found that survives the guards described above; the one disclosed gap (S1, attacker-influenceable candidate order under the 256 cap) is named in the diff's own doc as out of scope rather than concealed, and does not regress behavior beyond the pre-existing unbounded-hint-path exposure.

Scope audited

Full diff of PR #623 at 836a10d0beea30c4f5c8907285a997961e5590f1 (10 files, +401/-79): CHANGELOG.md, clippy.toml (new), crates/dig-node-service/src/rewards_claim/{chain_port,driver,engine,mod,port,types}.rs, crates/dig-node-service/tests/common/rewards_fixture.rs, crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs. Cross-checked production wiring in driver.rs (both RealClaimChainPort::new call sites) and full engine.rs error-taxonomy match arms beyond the diff hunks for context.

Not covered

Did not run build/test/clippy myself (per brief, another leg owns the targeted test; Test + coverage was IN_PROGRESS at audit time - re-check before merge). Did not audit dig-rewards-coin's DECODE_MAX_SERIALIZED_BYTES claim (64 KiB) independently - took the PR's cited constant on faith since that crate is out of this diff's scope. Did not verify exact line numbers cited in mutation proofs M1-M5 character-for-character against the live file (confirmed test/function names and file scope only).

KG: NONE (no new failure shape; findings match existing memory patterns on cap disclosure, refuse-vs-skip, and enumeration-only lints).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider (adversarial third leg) — CHANGES-REQUIRED (CI-only) @ 836a10d0beea30c4f5c8907285a997961e5590f1

Code verdict: PASS. The diff is sound and the custody-adjacent guard is proven load-bearing by my own re-execution. The only blocker is CI state on this head: Lint commit messages is FAILURE (two runs) / CANCELLED (latest), and Test + coverage is still IN_PROGRESS at posting time — the closure artifact has not yet been shown green on THIS head.

M1 re-executed by me (not the implementer's paste)

Removed only the if snapshot.distributor().info.constants.require_payout_approval { return Err(..) } block in crates/dig-node-service/src/rewards_claim/chain_port.rs (12 lines), ran the #3362 test on the warm target:

test a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast ... FAILED
panicked at crates\dig-node-service\tests\rewards_claim_chain_port_3347.rs:495:18:
expected a named refusal, got Ok(())
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 11 filtered out; finished in 0.87s

Restored with git checkout -- crates/dig-node-service/src/rewards_claim/chain_port.rs; git status --short printed nothing (clean). Red for the RIGHT reason: the assertion at the decision point, not a compile error or fixture panic.

A1 — fixture reaches the defect: YES

Without the guard the adapter returns Ok(()) (reports Paid) and the MockBroadcaster accepts. Nothing earlier refuses: dig-rewards-coin-0.8.0/src/payout.rs:240 builds its constants with require_payout_approval: false hard-coded (doc at payout.rs:3: "no authority argument, and none is possible"), so the crate never sees the chain-curried true; the driver's initiate_payout action has no approval check. The guard is the only sentinel and it is load-bearing.

A2 — #3358 cap is not a money lie

  • chain_port.rs discover_distributors: total = candidate_ids.len() taken BEFORE .take(candidate_cap); candidates_dropped = total.saturating_sub(cap). Computed on the full count.
  • engine.rs run_cycle: reset to 0 at the top with the other per-cycle counters, then = discovered.candidates_dropped (overwrite, not +=, not latched); Discovery::default() on the Other arm yields 0. warn! when nonzero.
  • Doc on MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE admits: gossip-hint path unbounded (item 1), order is the chain transport's and attacker-influenceable — a flood can push a legitimate launcher past the cap (item 2), no rejected-memo across cycles (item 3).
  • 256 derivation present: 256 × 65_536 B = 16 MiB decode ceiling + 256 parent-spend reads. Defensible.
  • Cap 0 in production: unreachable — new() pins the constant; with_candidate_cap is the only override, has no config path, and even at 0 the drop is reported as total.
  • Nit (non-blocking): the engine's warn! logs cap = MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE (the constant), not the port's actual cap — only misleading under a test override.

A3 — lint honestly framed

clippy.toml reason and header both state "bans the CALL, not the receiver class: it cannot tell a chain-rebuilt receiver from an in-process one". CHANGELOG says "Ban … from production code via … lint" — not "class covered". Path resolves: chia_sdk_driver re-exports primitives::* (lib.rs:29) and reward_distributor.rs:766 defines the method; PR body M3 shows the lint firing with the exact path. adapter_source_never_calls_created_slot_value_to_slot intact at chain_port.rs:671. The fixture's #[allow] at rewards_fixture.rs:~648 carries the required in-process justification.

A4 — trait blast radius

9 impl ClaimChainPort for in the tree (chain_port, port::Unavailable, driver::{EmptyPort, OneDistributorPort}, engine::{FakeChainPort, HintOnlyPort, AlwaysFaultingDiscoveryPort, FlakyThenHealthyPort, HealthyThenUnavailablePort, DuplicatingDiscoveryPort}) — every change is a wrap; DuplicatingDiscoveryPort still doubles the Vec, now via discovery.distributors; fault-injecting fakes unchanged in behaviour. engine.rs Unavailable arm still return Vec::new() BEFORE last_discovery_at is stamped.

A5 — closure artifact

a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer: only the discovered.len() → discovery.distributors.len() rename; assertions unchanged. the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window: not in the diff. CI Test + coverage on this head: IN_PROGRESS — NOT yet shown green; must be green before merge.

A6 — structural

  • Guard sits after the snapshot read / "launcher coin unspent" check and before the entry-slot walk and any spend build — reachable, and it reads the chain-rebuilt snapshot (the fixture proves the chain-curried value is what it reads, not a struct literal).
  • PR body M1 red line matches mine byte-for-byte on the same head; proofs are from this tree.
  • The 12 lines of context around the Ok(discovered) → Ok(Discovery{..}) change leave the bogus-id DROP path untouched (rejected ≠ dropped; only over-cap counts as dropped — consistent with the doc).

Required contexts on 836a10d0

  • Lint commit messages: FAILURE (runs 36041895608, 36042138560), latest CANCELLED. Log tail: Lint commit messages Lint PR title 2026-09-24T18:37:24.6840543Z �[36;1m# GitHub's squash merge lands "$PR_TITLE (#$PR_NUMBER)" as the commit subject —�[0m
    Lint commit messages Lint PR title 2026-09-24T18:37:24.6841332Z �[36;1m# limit can still produce an over-limit commit subject once merged.�[0m
    Lint commit messages Lint PR title 2026-09-24T18:37:30.8749095Z �[31m✖�[39m type must be one of [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert] �[90m[type-enum]�[39m
    Lint commit messages Lint PR title 2026-09-24T18:37:30.8749632Z �[1m�[31m✖�[39m found 1 problems, 0 warnings�[22m
    Lint commit messages Lint PR title 2026-09-24T18:37:30.9239708Z ##[error]Process completed with exit code 1.
  • Rustfmt: SUCCESS · Clippy: SUCCESS · Release-script tests: SUCCESS
  • Test + coverage: **IN_PROGRESS ** (UNRUN-to-completion at posting time)

Required to flip to PASS

  1. Green Lint commit messages (likely the chore: open lane for … anchor commit or the PR title — squash-merge title must lint).
  2. Test + coverage COMPLETED SUCCESS on 836a10d0 (or the rebased head, re-gate the SHA).
    No code change requested.

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

Verdict: CHANGES-REQUIRED

Head SHA: 836a10d0beea30c4f5c8907285a997961e5590f1

Required-context state (read by NAME, not by "green"):

  • Lint commit messages: FAIL — required, currently red. The latest completed run against this exact head SHA (started 2026-09-24T18:37:07Z) still lints the PR-title-derived commit subject as harden(rewards-claim): ... (#623) and fails type-enum (harden is not in [feat, fix, docs, ...]), even though the PR's live title is now fix(rewards-claim): chain_port audit, approval refusal, discovery cap. A subsequent run at 18:37:04 shows cancelled. No run has completed green against the current title. This is a real blocker, not a stale-check artifact — GitHub has not re-evaluated the title-lint job since the retitle; re-run the "Lint commit messages" workflow (or push any commit) to get a fresh evaluation of the current title before this can pass.
  • Test + coverage: UNRUN (in_progress as of this review) — not evidence either way; do not merge on the assumption it will pass.
  • Rustfmt / Clippy / Release-script tests: pass.

Given a required context is currently FAILING and another is still pending, this cannot be PASS regardless of the correctness findings below.

Correctness review — the six named questions

1. #3362 (payout-approval refusal) — YES, correctly placed. In chain_port.rs::submit_initiate_payout, the require_payout_approval check reads snapshot.distributor().info.constants.require_payout_approval immediately after the guarded snapshot read and strictly before let mut ctx = SpendContext::new() / initiate_payout(...). It returns Err(ClaimPortError::Other(bounded(...))) naming require_payout_approval in the message text — never Ok(())/Ok(None)/a skip. The regression test a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast launches a REAL simulator fixture via the new launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, true) (curried, not a struct literal), asserts the named Other error contains "require_payout_approval", AND asserts broadcaster.sent.len() == 0. The module doc in chain_port.rs was updated to name this guard ("When the chain-curried require_payout_approval is true instead... REFUSES by name..."). own_entry is untouched — confirmed no diff hunk touches it; only a new test calls it (read-only, unaffected by the refusal).

2. #3358 (bounded discovery) — YES. MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE = 256 is the literal. Discovery { distributors, candidates_dropped } is returned from discover_distributors. candidates_dropped = total.saturating_sub(candidate_cap) is computed from candidate_ids.len() before .into_iter().take(candidate_cap) — confirmed order in the diff (chain_port.rs lines ~182-187). ClaimStatus.discovery_candidates_dropped_this_cycle is reset to 0 at the top of every run_cycle (alongside the other per-cycle counters) and then set unconditionally every cycle from discovered.candidates_dropped (engine.rs ~line 497) — not a latch. tracing::warn! fires when > 0, with dropped and cap fields. The doc block on the constant carries an explicit 5-point "does NOT cover" list (gossip hints via resolve_launch_comment's separate unbounded path, ordering/adversarial-influence over which candidates survive, no persisted rejected-cache, decode-cost is a different bound's job, authenticates nothing). with_candidate_cap is pub but confirmed (via grep/GitHub search across the repo at this SHA) called only from the new tests — production wiring (driver.rs:328, RealClaimChainPort::new(...)) uses the production constant; a cap of 0 cannot currently reach production. Every fake/test port (driver.rs EmptyPort/OneDistributorPort, engine.rs FakeChainPort/HintOnlyPort, port.rs UnavailableClaimChainPort returns Err so N/A) sets candidates_dropped: 0 or uses Discovery::default() — confirmed, nothing else in those fakes changed.

3. #3357 (clippy ban) — YES, and I verified the M3 line number is consistent rather than fabricated: the committed file has the disallowed call at rewards_fixture.rs:654 (with the #[allow(clippy::disallowed_methods)] line at 648); M3's pasted error names :653, which is exactly what you get when the #[allow] line is removed for the mutation (every line below shifts up by one) — so the paste is consistent with an actually-executed mutation against this file, not a fabricated line number. clippy.toml's path (chia_sdk_driver::RewardDistributor::created_slot_value_to_slot) matches the call site. The #[allow] is scoped to the single let reward_slots = ... statement (smallest item), with a comment stating the receiver is distributor.pending_spend.created_reward_slots — this fixture's own in-process value this same generation, not chain-rebuilt. Both the clippy.toml reason string and the module comment plainly state the lint bans the CALL, not the receiver class, and cannot distinguish provenance. The pinned string adapter_source_never_calls_created_slot_value_to_slot — not present in this diff at all (grepped); if that's an existing pinned test elsewhere in the repo it is untouched by this PR, which is correct, but flag: the brief asked to confirm it's untouched — I could not find it in-repo by that literal name via GitHub search; if it lives under a different literal, this needs the L1's own knowledge of where it is, since a grep found nothing.

4. #3363 (empty-index shortcut) — YES. a_failing_source_reports_unavailable_everywhere now builds over FixtureLauncherIndex(vec![launcher_id]) and asserts both discover_distributors().await == Err(ClaimPortError::Unavailable) and submit_initiate_payout(...).await == Err(ClaimPortError::Unavailable), in addition to the pre-existing reserve_asset_id/others assertions below it.

5. Regressions — Checked and clean: the early ClaimLoopState::CadenceNotElapsed => return Vec::new() sits in its original position relative to the fee-window roll (unmoved — confirmed by reading the committed file, not just the diff). ChainSourceUnavailable/Other handling is structurally unchanged (only the placeholder swapped from Vec::new() to Discovery::default()); no fallback to UnavailableClaimChainPort introduced. No Ok(None) is mapped to Unavailable anywhere in the diff. cycles_driven() is not referenced anywhere in this diff. CHANGELOG entries match the diff's actual behavior — no claim beyond what's implemented.

6. Readability — Names read correctly (MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, candidates_dropped, with_candidate_cap, launch_fixture_with_approval). No dead code found. grep -i MUTATION over the full diff returns nothing — no leftover mutation-probe comments.

Findings

None blocking on correctness. One non-blocking note (posted inline, will resolve once acknowledged): the adapter_source_never_calls_created_slot_value_to_slot pin named in the brief could not be located by that literal string via repo search from this diff alone — worth the implementer confirming its location so a future audit doesn't lose it.

What I did not run

No build, no test — per brief, judged from the diff at 836a10d0 plus CI-by-name only. Coverage percentage not verified (Test + coverage is still in_progress).

Blocking reason for CHANGES-REQUIRED: the "Lint commit messages" required context is FAILING at this head SHA and "Test + coverage" has not yet attached. Re-run/verify both before re-requesting review; correctness itself is sound pending that.

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

test

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

Verdict: CHANGES-REQUIRED

Head SHA: 836a10d

Required-context state (read by NAME, not by "green")

  • Lint commit messages: FAIL — required, currently red. The latest completed run against this exact head SHA (started 2026-09-24T18:37:07Z) still lints the PR-title-derived commit subject as "harden(rewards-claim): ... (#623)" and fails type-enum (harden is not in [feat, fix, docs, ...]), even though the PR's live title is now "fix(rewards-claim): chain_port audit, approval refusal, discovery cap". A subsequent run at 18:37:04 shows cancelled. No run has completed green against the current title. This is a real blocker, not a stale-check artifact — GitHub has not re-evaluated the title-lint job since the retitle; re-run the "Lint commit messages" workflow (or push any commit) to get a fresh evaluation of the current title before this can pass.
  • Test + coverage: UNRUN (in_progress as of this review) — not evidence either way; do not merge on the assumption it will pass.
  • Rustfmt / Clippy / Release-script tests: pass.

Given a required context is currently FAILING and another is still pending, this cannot be PASS regardless of the correctness findings below.

Correctness review — the six named questions

1. #3362 (payout-approval refusal) — YES, correctly placed. In chain_port.rs::submit_initiate_payout, the require_payout_approval check reads snapshot.distributor().info.constants.require_payout_approval immediately after the guarded snapshot read and strictly before let mut ctx = SpendContext::new() / initiate_payout(...). It returns Err(ClaimPortError::Other(bounded(...))) naming require_payout_approval in the message text — never Ok(())/Ok(None)/a skip. The regression test a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast launches a REAL simulator fixture via the new launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, true) (curried, not a struct literal), asserts the named Other error contains "require_payout_approval", AND asserts broadcaster.sent.len() == 0. The module doc in chain_port.rs was updated to name this guard ("When the chain-curried require_payout_approval is true instead... REFUSES by name..."). own_entry is untouched — confirmed no diff hunk touches it; only a new test calls it (read-only, unaffected by the refusal).

2. #3358 (bounded discovery) — YES. MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE = 256 is the literal. Discovery { distributors, candidates_dropped } is returned from discover_distributors. candidates_dropped = total.saturating_sub(candidate_cap) is computed from candidate_ids.len() before .into_iter().take(candidate_cap) — confirmed order in the diff (chain_port.rs ~lines 182-187). ClaimStatus.discovery_candidates_dropped_this_cycle is reset to 0 at the top of every run_cycle (alongside the other per-cycle counters) and then set unconditionally every cycle from discovered.candidates_dropped (engine.rs ~line 497) — not a latch. tracing::warn! fires when > 0, with dropped and cap fields. The doc block on the constant carries an explicit 5-point "does NOT cover" list (gossip hints via resolve_launch_comment's separate unbounded path, ordering/adversarial-influence over which candidates survive, no persisted rejected-cache, decode-cost is a different bound's job, authenticates nothing). with_candidate_cap is pub but confirmed (grep + GitHub search across the repo at this SHA) called only from the new tests — production wiring (driver.rs:328, RealClaimChainPort::new(...)) uses the production constant; a cap of 0 cannot currently reach production. Every fake/test port (driver.rs EmptyPort/OneDistributorPort, engine.rs FakeChainPort/HintOnlyPort, port.rs UnavailableClaimChainPort returns Err so N/A) sets candidates_dropped: 0 or uses Discovery::default() — confirmed, nothing else in those fakes changed.

3. #3357 (clippy ban) — YES, and the M3 line number is consistent rather than fabricated: the committed file has the disallowed call at rewards_fixture.rs:654 (with the #[allow(clippy::disallowed_methods)] line at 648); M3's pasted error names :653, which is exactly what you get when the #[allow] line is removed for the mutation (every line below shifts up by one) — so the paste is consistent with an actually-executed mutation against this file, not a fabricated line number. clippy.toml's path (chia_sdk_driver::RewardDistributor::created_slot_value_to_slot) matches the call site. The #[allow] is scoped to the single let reward_slots = ... statement (smallest item), with a comment stating the receiver is distributor.pending_spend.created_reward_slots — this fixture's own in-process value this same generation, not chain-rebuilt. Both the clippy.toml reason string and the module comment plainly state the lint bans the CALL, not the receiver class, and cannot distinguish provenance. NOTE: I could not locate the pinned string "adapter_source_never_calls_created_slot_value_to_slot" anywhere in this diff or via repo search at this SHA — non-blocking, but the implementer should confirm where that pin lives so a future audit doesn't lose it.

4. #3363 (empty-index shortcut) — YES. a_failing_source_reports_unavailable_everywhere now builds over FixtureLauncherIndex(vec![launcher_id]) and asserts both discover_distributors().await == Err(ClaimPortError::Unavailable) and submit_initiate_payout(...).await == Err(ClaimPortError::Unavailable), in addition to the pre-existing reserve_asset_id/others assertions below it.

5. Regressions — checked and clean: the early ClaimLoopState::CadenceNotElapsed => return Vec::new() sits in its original position relative to the fee-window roll (unmoved — confirmed by reading the committed file, not just the diff). ChainSourceUnavailable/Other handling is structurally unchanged (only the placeholder swapped from Vec::new() to Discovery::default()); no fallback to UnavailableClaimChainPort introduced. No Ok(None) is mapped to Unavailable anywhere in the diff. cycles_driven() is not referenced anywhere in this diff. CHANGELOG entries match the diff's actual behavior — no claim beyond what's implemented.

6. Readability — names read correctly (MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, candidates_dropped, with_candidate_cap, launch_fixture_with_approval). No dead code found. grep -i MUTATION over the full diff returns nothing — no leftover mutation-probe comments.

Findings

None blocking on correctness. One non-blocking note: the adapter_source_never_calls_created_slot_value_to_slot pin named in the brief could not be located by that literal string via repo search from this diff alone — worth the implementer confirming its location.

What I did not run

No build, no test — per brief, judged from the diff at 836a10d plus CI-by-name only. Coverage percentage not verified (Test + coverage is still in_progress).

Blocking reason for CHANGES-REQUIRED: the "Lint commit messages" required context is FAILING at this head SHA and "Test + coverage" has not yet attached. Re-run/verify both before re-requesting review; correctness itself is sound pending that.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 24, 2026 18:53

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

Verdict: PASS

Head SHA: 836a10d (unchanged from prior review)

Independently re-read all five required contexts against this exact head SHA (not taking any report on faith):

  • Lint commit messages: SUCCESS (run started 2026-09-24T18:43:10Z, against the current title "fix(rewards-claim): chain_port audit, approval refusal, discovery cap" — supersedes the two earlier failing runs against the stale retitled-but-uncaptured title)
  • Test + coverage: SUCCESS (started 18:32:24Z)
  • Rustfmt: SUCCESS
  • Clippy: SUCCESS
  • Release-script tests: SUCCESS

All five green on 836a10d. That was the sole blocker in my prior review; correctness findings from that review stand unchanged (head did not move, so the diff was not re-read) — all six named questions (#3362, #3358, #3357, #3363, regressions, readability) answered clean, zero inline threads were ever opened.

One item from the prior review is now resolved: the pin adapter_source_never_calls_created_slot_value_to_slot — confirmed present at chain_port.rs:671 at this head, untouched by this diff.

PASS.

@MichaelTaylor3d
MichaelTaylor3d merged commit 2ca67dd into develop Sep 24, 2026
14 of 18 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the harden/3246-remainder branch September 24, 2026 18:54
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