Skip to content

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

Merged
MichaelTaylor3d merged 9 commits into
developfrom
feat/3310-rewards-chain-adapter
Sep 14, 2026
Merged

feat(rewards): construct and install a real RewardsChainPort over dig-rewards-coin#614
MichaelTaylor3d merged 9 commits into
developfrom
feat/3310-rewards-chain-adapter

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements DIG-Network/dig_ecosystem#3310: a production RewardsChainPort (RealRewardsChainPort) over dig-wallet's CorroboratedChainSource + dig_rewards_coin::state::read_distributor, installed at startup via Node::install_reward_chain_port.

  • crates/dig-node-service/src/rewards/chain_source.rs: the guarded read (read_distributor_guarded) that refuses epoch_seconds == 0 before calling read_distributor at all (defense in depth over dig-rewards-coin's own refusal at state.rs:1015), and launch-comment (store_id/root) recovery from the launcher's creating spend's memo. A missing parent spend (ChainSource::parent_spend -> Ok(None)) is now a distinct LaunchCommentError::ParentSpendUnavailable, mapped to ChainPortError::Unavailable -- a chain-source gap is no longer reported as the definitive "not a DIG distributor" identity verdict.
  • crates/dig-node-service/src/rewards/chain_port.rs: RealRewardsChainPort, serving distributor_report for real; the other four RewardsChainPort methods answer Unavailable (out of this ticket's scope). Extracted the report's one non-trivial computed field (current_distributor_epoch) into a pure, directly-unit-tested epoch_ordinal function. Added one bounded tracing::warn! (once per failure->success transition, not per call) on distributor_report's error path, so a degraded chain source is observable rather than silently indistinguishable from "no port installed."
  • server.rs: installs the port once, in the enable_chain_sync gated block.
  • Cargo.toml: pins chia-sdk-driver/chia-sdk-types/chia-puzzle-types to exact versions (containment for #3303/#3286), adds dig-rewards-coin, clvm-traits, clvmr, and dev-dependencies (dig-chainsource-interface with the testing feature, chia-sdk-test) for the simulator-backed acceptance test.

dig-rewards-coin stays on 0.5, not 0.6

The read-only surface this adapter uses is state.rs (read_distributor, DistributorSnapshot), comment.rs (LaunchComment), and clawback.rs (recoverable_base_units) -- those three files are byte-identical between dig-rewards-coin 0.5.0 and 0.6.0 (verified against the published .crate tarballs). The only real 0.6.0 change is a fail-closed fix in entries.rs's add_entry/remove_entry (a permanently-open entry-set write window, now closed) -- that lives on the entry write path, which this adapter never calls (submit_entry_writes returns Unavailable). Staying on 0.5 is therefore safe for what this PR ships, and only for that: 0.6.0 becomes mandatory, not optional, the moment submit_entry_writes is implemented (#3249/#3250), since it carries a fail-closed fix for a write-path hazard this PR's own read-only adapter does not exercise.

Test plan

  • Guard unit tests (chain_source.rs): refuses epoch_seconds == 0 before reading; passes through a legitimate value.
  • withdraw_committed_incentives string-absence test (chain_port.rs).
  • Full simulator-backed distributor_report integration test (A3, tests/rewards_chain_port_a3.rs): drives the production RealRewardsChainPort<MockChainSource> end to end against a real chia-sdk-test simulator launch -- only the network transport is a double; every coin record/spend comes from a genuine Simulator::spend_coins call.
  • A failing chain source reports a named ChainPortError::Unavailable, never Ok with a default-valued report (chain_port.rs unit test), and GuardedReadError::NonTerminatingEpochSeconds is confirmed never mapped to Unavailable.
  • current_distributor_epoch's arithmetic (epoch_ordinal) is unit-tested directly, including the non-zero multi-epoch case and the clock-skewed saturating_sub branch -- a bare-launch-only assertion could not distinguish the real computation from a 0-returning gut.
  • The install-path warn test (install_reward_chain_port_refuses_a_second_install_with_a_warn) now asserts against server.rs's actual production source region, not a warn the test emits itself; mutation-proved (deleting server.rs's real warn line turns the assertion red, restoring it turns it green again).

Known out of scope for this PR, filed as follow-up (dig_ecosystem#3310 epic, not blocking this merge): the commitments/recoverable_base_units loop and the puzzle-reveal substitution guard in parse_launch_comment are unexercised by any test here (both need a commit_incentives spend in the A3 fixture -- real simulator work); the ZeroIdentity/InvalidWithdrawalShare refusal cases in report_from_snapshot are untested; and the error-mapping table's "never Unavailable" safety claim (guarded_read_error_to_port_error) has no test holding it end to end.

Refs #3310

🤖 Generated with Claude Code

@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/3310-rewards-chain-adapter branch from acc8e6b to 6ff2fd9 Compare September 14, 2026 10:20
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate leg 3 (loop-decider) — CHANGES-REQUIRED

Head SHA read: 6ff2fd98a4cbcc22082e55bca9e1f5b694005838.

Required contexts on develop, confirmed by name (not by count — there are five, not four):
Lint commit messages, Rustfmt, Clippy, Test + coverage, Release-script tests — all SUCCESS.
(Check version increment is required on main only; its workflow triggers on pull_request: branches: [main], so its absence here is correct, not a missing context.)

I am not re-reviewing. Six attacks, six answers.


1. Is A3 evidence, or a fixture in a simulator's clothes? — It is real evidence.

tests/rewards_chain_port_a3.rs constructs RealRewardsChainPort::<MockChainSource> — the production type — and calls the production distributor_report, which goes through build_report → the real read_distributor_guarded → the real dig_rewards_coin::state::read_distributor, and the real read_launch_commentparse_launch_comment, which deserializes, tree-hashes, run_puzzles and from_clvms for real. Every CoinSpend/CoinRecord in the MockChainSource came out of sim.coin_spend(id) / sim.coin_state(id) after a genuine Simulator::spend_coins. The socket is the only thing replaced. No hand-built record is inserted anywhere.

The author's central claim holds as the test is written: the only path from the source to report.store_id is parse_launch_comment, which fails closed on every decode error, so a green assertion on 0xaa…/0xbb… cannot be reached without the CLVM memo parse having run over the real security-coin spend.

One correction to the prose, though: the module doc's "No fixture value can produce the right store_id/root without that parse actually happening" is stronger than what is true. The expected values are test-chosen low-entropy constants ([0xaa; 32], [0xbb; 32]); they are unguessable-by-accident only because no rival code path exists to invent them, not because of any entropy in the fixture. The claim is true of this file today; it is not a structural property. Soften it, or it becomes a false doc the first time someone adds a second source of those fields.

2. The mutation standard applied to A3 itself — a full gut is caught; three partial guts are not.

Gutting build_report/report_from_snapshot to return a default-valued report fails A3: launcher_id, store_id, root, epoch_seconds (1000), first_epoch_start (1234), payout_threshold, withdrawal_share_bps all go red. Good.

But the author mutation-proved the guard, not the report, and the report has unasserted computed fields. A3 asserts 10 of 15. Note also that three of the ten asserted values are zero (fee_bps, reserve_base_units, entry_count) — satisfied by a default report, carrying no mutation weight.

Unasserted: current_distributor_epoch, last_entry_write_at, entry_set_stale, commitments, observed_at.

The one that matters: current_distributor_epoch (chain_port.rs, epoch_end.saturating_sub(first_epoch_start) / epoch_seconds) is the only non-trivial arithmetic in the whole mapping and is a live wire field (dig-node-core/src/rewards/port.rs:238). Replace that expression with 0 and every test in this PR stays green. Worse, an assertion added naively to A3 would also be worthless: at a bare launch epoch_end == first_epoch_start == 1234, so the correct answer is 0 — indistinguishable from the gut. This needs a pure unit, not an A3 line.

3. The two-site rule — sites whose revert leaves the suite green

Covered (a revert goes red): the epoch_seconds == 0 guard; read_launch_constants; read_launch_comment/parse_launch_comment happy path; memo_comment's [hint, comment] layout; the first_epoch_start derivation; the withdraw_committed_incentives containment check.

Not covered — revert leaves the whole suite green:

  1. chain_source.rs, parse_launch_comment's puzzle-reveal check (revealed != creating_spend.coin.puzzle_hashMalformed). This is the diff's only anti-substitution guard and nothing exercises it. Delete those four lines: green.
  2. chain_port.rs, the current_distributor_epoch computation (§2).
  3. chain_port.rs, the whole commitments loop and its recoverable_base_units call. A bare launch has zero commitment slots, so the closure never executes once in any test. Replace the loop body with an empty vec: green. This is the money-bearing field of dig.listRewardDistributorCommitments.
  4. chain_port.rs, report_from_snapshot's ZeroIdentity check and the withdrawal_share_bps > 10_000 / try_into refusal — both untested.
  5. chain_port.rs, guarded_read_error_to_port_error / reader_error_to_port_error. The module doc makes a safety claim about this table — "never Unavailable, so a caller cannot mistake a refused read for a merely offline chain" — and no test holds it. Map every arm to Unavailable: green.
  6. server.rs's install block, both the Ok and Err arms and the warn! string. See below — this one is worse than untested.

And the circular one. install_reward_chain_port_refuses_a_second_install_with_a_warn is presented as evidence for server.rs's call site. It is not. The test calls install_reward_chain_port, and then the test itself emits tracing::warn!("… declined a second install …") inside its own closure, then asserts the captured log contains that string. It asserts a string it just wrote. Delete the entire warn! from server.rs and this test stays green. Only the assert!(!second_install) half is real. This is exactly the two-site shape flagged on the sibling lane, and it is sitting inside the named evidence.

4. The dropped anti-silence requirement — right about the test, wrong about the silence. There is one, and it is in this diff.

The scheduler anti-silence test genuinely does not apply; a test asserting "at least one cycle fired" over a reactive port could not fail. Agreed, and not what I am arguing.

The silence this port can have is a different shape: installed, every call failing, operator sees nothing. distributor_report emits no log, no counter, no event on any error path — not for Unavailable, not for Other, not for a refused epoch_seconds == 0, not for an unreadable launch comment. And server.rs's Err arm logs exactly one WARN at startup and then never speaks again. So a node whose CorroboratedChainSource is degraded, or whose chain source lags behind the launcher's parent spend, serves ChainPortError::Unavailable to every dig.getRewardDistributor caller forever, and at the RPC surface that is byte-identical to "no port was ever installed" — which is the exact UnavailableChainPort condition #3310 exists to end. The PR would then have shipped its own defect back, wearing a RealRewardsChainPort type name.

Surface: the error return of RealRewardsChainPort::distributor_report / build_report in chain_port.rs. In this PR, not a follow-up — it is one bounded tracing::warn! on the error path of a function this diff creates, additive, reroutes nothing.

5. The dependency decision — 0.5 is the right call; the stated reasoning does not establish it.

Verified independently against the published .crate tarballs for 0.5.0 and 0.6.0.

launch.rs and constants.rs being byte-identical proves nothing about the read path, because the read path does not go through either of them. It goes through state.rs (read_distributor, DistributorSnapshot::{reserve_base_units, entry_count, entry_set_stale, last_entry_write_unix}), comment.rs (LaunchComment) and clawback.rs (recoverable_base_units). The correct evidence is that those three files are byte-identical 0.5.0 → 0.6.0, which they are — diff -rq src/ reports only: entries.rs modified, lib.rs modified, discovery.rs and manager.rs added.

Both deltas are off our path. lib.rs only adds pub mod discovery / pub mod manager and their re-exports. entries.rs's change is not the orthogonal manager-singleton feature at all — it is a fail-closed security fix (#3321): last_update.saturating_add(max_seconds_offset) read as a permanently-open entry-set write window at a saturating max_seconds_offset, now checked_add with a closed-window refusal. That lives in add_entry/remove_entry, i.e. the entry write path, which this adapter never calls (submit_entry_writes returns Unavailable). state.rs contains no entries:: reference, so the report's entry_count/entry_set_stale do not touch it.

Conclusion: staying on 0.5 is safe for the read-only surface this PR ships, and only for that. The moment submit_entry_writes is implemented (#3249/#3250), 0.6.0 becomes mandatory, not optional — it carries a fail-closed fix for a permanently-open write window. Record that in the Cargo.toml comment so the next bump is not argued from launch.rs again.

6. Claims true at merge?

  • The Cargo.toml dig-rewards-coin = "0.5" comment is accurate. state.rs:1015 really is if constants.epoch_seconds == 0 { return Err(RewardsError::UnreadableEpochSeconds) }, and MAX_COMMIT_INCENTIVES_BACKFILL_SLOTS really is at state.rs:73. Both verified against the published crate.
  • The PR body is false at merge. It still reads - [ ] Full simulator-backed distributor_report integration test (A3) -- not completed this unit; see PR comment while this very diff ships tests/rewards_chain_port_a3.rs, 398 lines, as the PR's headline evidence. It also still carries DO NOT MERGE -- gate round in progress and CI is the compiler for this PR. The squash body is the durable record; merging this ships a commit whose own description denies the test it lands.
  • chain_source.rs's LaunchCommentError::NotADigDistributor doc contradicts the code one file over. The doc says it "is a genuine classification, not a failure to read." read_launch_comment returns it when source.parent_spend(launcher_id) yields Ok(None) — the chain source simply does not hold the parent spend yet — and build_report then renders it to the caller as Other("launch comment unreadable: not a DIG rewards distributor (no matching launch comment)"). A transport gap is being reported as a definitive negative claim about the distributor's identity. Every launcher coin created by a security coin has a parent spend on a complete chain; Ok(None) here means the source is lagging or pruned, never that the distributor is not DIG's.
  • The A3 module doc's "no fixture value can produce the right store_id/root" — see §1; overstated, not false.

Remedy (explicit, checked against every reader of what it reroutes)

R1 — PR body. Rewrite the Test plan to describe what actually ships (A3 present and passing) and drop DO NOT MERGE at merge time. Doc only; readers are the squash commit message and this ticket. No code.

R2 — de-circularize the install evidence. In install_reward_chain_port_refuses_a_second_install_with_a_warn: keep assert!(first_install) / assert!(!second_install), delete the self-emitted tracing::warn! and its log capture entirely, and replace it with the source-literal check this PR already uses successfully in adapter_source_never_imports_withdraw_committed_incentives — assert that server.rs's production region contains "declined a second install". Reroutes nothing at runtime; the only reader of the deleted machinery is that test.

R3 — make the one computed field testable. Extract the epoch ordinal from report_from_snapshot into a small pure fn (epoch_end, first_epoch_start, epoch_secondsu64) and unit-test it, including the saturating_sub branch (epoch_end < first_epoch_start, which a clock-skewed read can produce). The only reader of the rerouted expression is report_from_snapshot itself; no wire, no doc, no sibling crate sees the move.

R4 — one log line, this PR. A bounded tracing::warn! on distributor_report's error return (§4). Additive; reroutes nothing.

R5 — stop calling a transport gap an identity verdict. Give read_launch_comment a distinct outcome for parent_spendOk(None) and map it to ChainPortError::Unavailable. Readers checked: port.rs:148-149 explicitly sanctions Unavailable as "what any real adapter should answer for an unreachable chain," and build_report already returns Unavailable for read_distributor_guarded's own Ok(None) — so this makes the two absence paths agree rather than inventing a new convention. It does not violate chain_port.rs's "never Unavailable" rule, which governs refusals (NonTerminatingEpochSeconds, malformed data), not gaps; those stay Other. Downstream readers of Unavailable are the three RPC handlers named at port.rs:165-167, all of which already refuse the whole call on it. Reword the NotADigDistributor doc to match what remains.

Follow-up ticket (not blocking this PR): the untested sites in §3 items 1, 3, 4 and 5 — the puzzle-reveal substitution guard, the commitments/recoverable_base_units loop (needs a commit_incentives in the A3 fixture, which is real simulator work and is the money-bearing path), the ZeroIdentity/InvalidWithdrawalShare refusals, and the error-mapping table's "never Unavailable" safety claim. File it against the #3310 epic and hold the epic's close on it, not this PR's merge.

Failure direction if this merges as-is: the wrong call here is merging on "five greens and a simulator-backed test." It ships a port that is genuinely real on its one happy path, with its money field never executed once, its anti-substitution guard unexercised, its install-site warning proved by a test that wrote the string itself, a PR body that denies its own test, and no way for an operator ever to learn that every reward read is failing. That is not an inert-code defect — it is the #3310 defect with a production type name on it, which is strictly harder to find later than the UnavailableChainPort it replaces.

I split from the other two legs on this. R1–R5 are all small and in-file; none require redesigning the adapter.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Security gate verdict -- PASS

Head audited: 6ff2fd98a4cbcc22082e55bca9e1f5b694005838

Required checks (confirmed by name): Rustfmt SUCCESS, Clippy SUCCESS, Test + coverage SUCCESS, Lint commit messages SUCCESS.

S1 -- Reachability of the hang

crates/dig-node-service/src/rewards/chain_source.rs:89 is the ONLY call site of dig_rewards_coin::state::read_distributor anywhere in this repo (confirmed by grep across the whole tree, no other match besides doc comments/tests). It is reached exclusively through read_distributor_guarded (chain_source.rs:82-90), which calls refuse_non_terminating_epoch_seconds first. read_distributor_guarded and refuse_non_terminating_epoch_seconds are both pub(crate) (not pub), so no external crate, re-export, or future caller outside dig-node-service can reach the unguarded read_distributor through this module even though pub mod chain_source;/pub mod rewards; make the module visible -- the hazardous function itself is not exported. The only production caller of the guarded path is chain_port.rs's build_report. No second door found.

S2 -- Guard completeness beyond epoch_seconds == 0

Confirmed against chia-sdk-driver-0.36.0's actual source (commit_incentives.rs:100-111): the loop is literally non-terminating only at epoch_seconds == 0. For a small-but-nonzero epoch_seconds against a huge epoch_start gap, dig-rewards-coin-0.5.0's state.rs (refuse_unrepresentable_action_arithmetic, ~:620-680) computes the iteration count via div_ceil arithmetic BEFORE ever invoking the hazardous replay, and refuses above the fixed MAX_COMMIT_INCENTIVES_BACKFILL_SLOTS = 1_000_000 (state.rs:73) -- a separate, correctly-described bound (this PR's own doc comment is accurate: "a DIFFERENT bound... that one refuses a backfill that is large but FINITE"). This PR does not claim that bound as its own containment, which is the honest framing the brief demanded. The two bounds compose correctly: literal non-termination is caught by #614's edge guard, large-but-finite is caught by dig-rewards-coin's pre-existing arithmetic cap.

S3 -- Who picks the launcher id / cost

dig-rpc-protocol-0.12.0's Method::GetRewardDistributor and Method::ListRewardDistributorCommitments are both Tier::Control (method.rs, verified in the registry cache) and are absent from is_peer_reachable -- so only a caller holding a valid control token can invoke them, never an anonymous RPC caller or a P2P peer. A control-token holder fully controls launcher_id, including one pointing at an attacker-launched distributor with epoch_seconds == 0 whose puzzle hash is still a legitimately recognised member of dig-rewards-coin's action set (as the brief states). Cost to reach the refusal: refuse_non_terminating_epoch_seconds does exactly one ChainSource::coin_spend lookup (O(1) socket round-trip, confirmed against dig-chainsource-interface-0.3.3's trait -- no generation walk) plus one cheap RewardDistributor::from_launcher_solution decode. No hazardous replay is reachable before the refusal fires.

S4 -- Money / wrap

chain_port.rs's report_from_snapshot computes shares via dig_rewards_coin::clawback::recoverable_base_units (u128 intermediate, domain-checked withdrawal_share_bps <= 10_000, confirmed in the registry source at clawback.rs:153-163), not chia-sdk-driver's plain-u64 multiply that #3286 flagged. chain_port.rs's own test adapter_source_never_imports_withdraw_committed_incentives greps the pre-#[cfg(test)] region of both adapter files for the literal string and fails the build if either references withdraw_committed_incentives -- confirmed present and scoped correctly (excludes its own test module, so it cannot self-satisfy). Nothing in this diff reaches the wire from that function.

S5 -- Honest failure

GuardedReadError has two variants (this crate's own refusal, and the reader's), both distinct from Ok(None) (absence) by construction and by doc comment. guarded_read_error_to_port_error maps NonTerminatingEpochSeconds to ChainPortError::Other (never Unavailable, never a zero/empty report) and only RewardsError::ChainUnavailable maps to Unavailable -- confirmed against the existing test reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share, which asserts resp.get("result").is_none() and a non-zero distinct error code for a refusal, and ships a documented mutation-probe. ChainPortError::ZeroIdentity in report_from_snapshot additionally refuses a zero launcher_id/store_id outright rather than reporting a zeroed identity as real.

S6 -- The install

Node::install_reward_chain_port (dig-node-core/src/lib.rs:698-701, pre-existing, unchanged shape) is a OnceLock::set(...).is_ok() -- atomic, so a race between two installers resolves to exactly one winner and the loser gets false, never a silent swap. This PR's only call site (server.rs:2150-2169) runs once, synchronously, inside the enable_chain_sync startup branch, and is not RPC-reachable by any caller -- there is no attacker-triggerable path to a second install.

Findings

None LIVE. No defence-in-depth notes beyond what the PR's own module docs already name accurately (the two-bound distinction in S2).

Scope audited

crates/dig-node-service/src/rewards/{mod,chain_source,chain_port}.rs, crates/dig-node-service/src/lib.rs, crates/dig-node-service/src/server.rs (install site), crates/dig-node-service/Cargo.toml, crates/dig-node-service/tests/rewards_chain_port_a3.rs, Cargo.lock, plus registry-cache reads of chia-sdk-driver-0.36.0, dig-rewards-coin-0.5.0, dig-rpc-protocol-0.12.0, dig-chainsource-interface-0.3.3 to verify the upstream hazard and the pre-existing containment this PR composes with.

Not covered

Did not re-audit dig-node-core's pre-existing install_reward_chain_port/reward_chain_port OnceLock plumbing or RPC dispatch (seams::dig_rpc::dispatch) beyond confirming they are unchanged by this diff and already tested; did not run the crate's own test suite locally (relied on the CI-reported green Test + coverage); did not audit dig-wallet's CorroboratedChainSource internals.

Worktree used: D:\worktrees\dig-node-614-secgate (own worktree, created fresh off refs/pull/614/head; did not enter dig-node-3306 or dig-node-3310). No shared checkout mutated.

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

Correctness gate — PASS

Head 6ff2fd98a4cbcc22082e55bca9e1f5b694005838. Required checks confirmed by name, all SUCCESS: Rustfmt, Clippy, Test + coverage, Lint commit messages.

R1 — guard unbypassable. Grepped every read_distributor call site in the workspace: the only production caller is chain_source.rs:89's read_distributor_guarded, which runs refuse_non_terminating_epoch_seconds first (line 526) before ever calling dig_rewards_coin::state::read_distributor. No other file in the crate/workspace calls read_distributor directly. Confirmed.

R2 — guard reads only what's safe. refuse_non_terminating_epoch_secondsread_launch_constantsRewardDistributor::from_launcher_solution (verified against the vendored chia-sdk-driver-0.36.0 source at primitives/action_layer/reward_distributor.rs:440): it only decodes the launcher's own LauncherSolution's key_value_list, no chain reads beyond the one source.coin_spend(launcher_id) call, no generation walk. Confirmed.

R3 — install site. server.rs:2140-2161 sits inside if config.enable_chain_sync {, alongside the census/mirror passes. false from install_reward_chain_port is logged at WARN, never ignored/unwrapped. Confirmed single-install OnceLock invariant not relaxed (also proven by the new test install_reward_chain_port_refuses_a_second_install_with_a_warn).

R4 — runtime-handle constraint. Both production entrypoints that reach serve_with_shutdown build multi-thread runtimes before calling it: entrypoint.rs:1454 (Builder::new_multi_thread()block_on(serve(config))) and win_service.rs:132 (same). Handle::current() at the install site is always multi-thread in production. Confirmed.

R5 — #3303 containment test region-slicing. chain_port.rs's production_region slices each file at its own first #[cfg(test)] marker and scans only the prefix. Since the assertion string and test name (which themselves contain the literal withdraw_committed_incentives) live inside that same #[cfg(test)] mod tests block, they fall in the sliced-off region — the test cannot be satisfied by its own text. Confirmed correct, non-self-defeating.

R6 — honest failure / error mapping. guarded_read_error_to_port_error and reader_error_to_port_error (chain_port.rs) have zero _ => wildcard arms (verified via full-diff grep — the one _ => Ok(())) hit is the guard's own deliberately-permissive pass-through, documented and correct, not part of the port error mapping). GuardedReadError::NonTerminatingEpochSeconds maps to ChainPortError::Other(...), never Unavailable — confirmed against vendored dig-rewards-coin-0.5.0 source that RewardsError::ChainUnavailable is the only variant mapped to Unavailable. No method returns Ok with a zeroed/defaulted report; the four out-of-scope methods answer Err(ChainPortError::Unavailable) explicitly.

R7 — dependency shape. Confirmed by direct read of Cargo.toml: [dependencies] (from line 58) pins chia-sdk-driver/chia-sdk-types to =0.36.0 and chia-puzzle-types to =0.36.1; chia-bls/chia-protocol/chia-sha2 remain caret 0.36.1. [dev-dependencies] (from line 341) carries = pins on chia-consensus/chia-puzzles (new) — fine, dev-deps don't propagate.

R8 — prose. Checked the hang claim against the vendored source: chia-sdk-driver-0.36.0/src/layers/action_layer/actions/reward_distributor/commit_incentives.rs lines 101-111 do run while end_epoch_time > start_epoch_time { ...; start_epoch_time += epoch_seconds; } with no .await in the file — at epoch_seconds == 0 this is a genuine non-yielding infinite loop that tokio::time::timeout cannot preempt (no poll point). The module doc's line-number references (state.rs:1015, MAX_COMMIT_INCENTIVES_BACKFILL_SLOTS at state.rs:73) and the RewardsError::UnreadableDistributorConstants/UnreadableEpochSeconds variant names were all checked against the vendored dig-rewards-coin-0.5.0 source and are accurate.

No blocking findings. No inline threads opened.

Not run: a full local cargo test -p dig-node-service (background cargo check was still building at review time on a cold worktree target dir); relied on the CI Test + coverage run against this exact head SHA plus direct reading of the vendored dependency sources for the claims that check couldn't have caught anyway (doc-prose accuracy, dependency-table shape).

MichaelTaylor3d and others added 9 commits September 14, 2026 05:07
…d 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>
…-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>
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>
…t, 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>
…ll-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>
…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>
- 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>
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>
…e text not runtime behaviour

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/3310-rewards-chain-adapter branch from 10a9044 to 660df1f Compare September 14, 2026 12:10
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 14, 2026 12:45
@MichaelTaylor3d
MichaelTaylor3d merged commit 22ba90b into develop Sep 14, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/3310-rewards-chain-adapter branch September 14, 2026 12:46
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