From bb06a8bd4c7ad57a3c146264f15f039e36e4c3f2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 10 Sep 2026 19:55:43 -0700 Subject: [PATCH 01/19] chore: open lane for #3269 From e628414899f8f925b12f684e05ed50453d9e8e26 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 10 Sep 2026 19:58:28 -0700 Subject: [PATCH 02/19] chore(deps): add dig-rewards-coin 0.4 to dig-node-core (#3269) Adds the reward-distributor chain reader (read_distributor, DistributorSnapshot, ChainObservation) as a dependency, unpinned beyond the 0.4 line dig-rewards-coin ships it on. Resolves alongside chia-sdk-driver 0.36.0 / chia-sdk-types 0.36.0 / chia-puzzle-types 0.36.1, matching the cohort_lock_guard pins dig-rewards-coin itself asserts. No consumer yet -- see the accompanying finding on the PR: dig-node-core has no reachable dig_chainsource_interface::ChainSource implementation, so none of #3269's four RPC methods can be wired to a live chain read from this crate without a further architecture decision. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 19 +++++++++++++++++++ crates/dig-node-core/Cargo.toml | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a8839fe2..0aecf744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3002,6 +3002,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", + "dig-rewards-coin", "dig-rpc-protocol", "dig-sex", "dig-social-profile", @@ -3209,6 +3210,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "dig-rewards-coin" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cff0575efb9d3ba6d45e7cccd81c70bd80d1bab1e81a039cd98a2cf4000ed67" +dependencies = [ + "chia-bls 0.36.1", + "chia-consensus 0.36.1", + "chia-protocol 0.36.1", + "chia-puzzle-types 0.36.1", + "chia-sdk-driver 0.36.0", + "chia-sdk-types 0.36.0", + "dig-chainsource-interface 0.3.3", + "dig-constants 0.13.1", + "hex", + "thiserror 2.0.20", +] + [[package]] name = "dig-rpc-protocol" version = "0.11.0" diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index e4ad1616..8b20633b 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -192,6 +192,15 @@ serde_json = "1" # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). dig-rpc-protocol = "0.11.0" +# The reward-distributor coin driver (#3269): the chain-derived state reader +# (`read_distributor`, `DistributorSnapshot`, `ChainObservation`) and the tested +# `recoverable_base_units` clawback-preview arithmetic. 0.4.0 is the first line that ships the +# reader (`read_distributor`, `SPEC.md` §12.1 clause 1, #3267) — pre-0.4 lines are SPEC-only or +# ship a reader that calls `from_parent_spend` and reads back Malformed on every distributor. Its +# `chia-sdk-driver`/`chia-sdk-types` = `0.36.0` and `chia-puzzle-types` = `0.36.1` pins are +# load-bearing (a `cohort_lock_guard` asserts them) and MUST NOT be loosened or tightened by a +# consumer. +dig-rewards-coin = "0.4" # The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope # the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 # envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. From c5ce3146f971a57c24bedf9f23c04c40d7221843 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 10 Sep 2026 20:48:40 -0700 Subject: [PATCH 03/19] feat(rpc): serve dig.getRewardDistributor and dig.listRewardDistributorCommitments Both at Tier::Control, behind dig-node-core's own RewardsChainPort. The adapter that talks to chain (CorroboratedChainSource + dig_rewards_coin::read_distributor) is built in dig-node-service and injected downward, so no dependency cycle is created and dig_chainsource_interface::ChainSource never enters this crate. - port: one additive `distributor_report` method feeding both handlers. DistributorChainState is deliberately NOT widened -- the prover cycle engine consumes it. UnavailableChainPort returns the chain-unavailable error, never an empty report: a failed read must not render as "no entries". - dispatch: both arms route through the Method::from_name enum match, never the string pre-match, so the tier guard cannot be bypassed. - deps: dig-rewards-coin removed from dig-node-core; it belongs to the service crate that builds the adapter. No chia-* pin touched. - #3261: the guard predicate is duplicated in peer.rs and in tests/reward_methods_tier_guard.rs; both widened, with a count assertion so the filter cannot silently start matching fewer methods than exist. Money handling for the recoverable figure: withdraw_committed_incentives is never called -- it returns #3286's wrapped u64 uncorrected (#3303). The figure is computed with dig_rewards_coin::recoverable_base_units, whose u128 intermediate is correct exactly where the driver wraps. withdrawal_share_bps is echoed from chain-derived state, never recomputed (SPEC 2.6 clause 2), and narrowed with u16::try_from rather than `as` -- a silent cast wraps 65_536 to 0. When the figure cannot be honestly computed the WHOLE CALL refuses. It is never a zero and never an omitted field left to default. bps is launch-curried per distributor, so a refusal cannot blind one row while sparing another. listRewardDistributors and getPayeeRewardClaimStatus remain -32601 with no stub: the first would have to emit `claimable: []`, indistinguishable from "no claims", until #3268's mirror-admission registry exists; the second has no Method variant in dig-rpc-protocol v0.11.0. Checkpoint push by the orchestrator; CI is the compile. Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 19 - crates/dig-node-core/Cargo.toml | 9 - crates/dig-node-core/src/lib.rs | 417 ++++++++++++++++++ crates/dig-node-core/src/peer.rs | 14 + crates/dig-node-core/src/rewards/port.rs | 102 +++++ .../src/seams/dig_rpc/dispatch.rs | 132 ++++++ .../tests/reward_methods_tier_guard.rs | 12 + 7 files changed, 677 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0aecf744..a8839fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3002,7 +3002,6 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rewards-coin", "dig-rpc-protocol", "dig-sex", "dig-social-profile", @@ -3210,24 +3209,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "dig-rewards-coin" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cff0575efb9d3ba6d45e7cccd81c70bd80d1bab1e81a039cd98a2cf4000ed67" -dependencies = [ - "chia-bls 0.36.1", - "chia-consensus 0.36.1", - "chia-protocol 0.36.1", - "chia-puzzle-types 0.36.1", - "chia-sdk-driver 0.36.0", - "chia-sdk-types 0.36.0", - "dig-chainsource-interface 0.3.3", - "dig-constants 0.13.1", - "hex", - "thiserror 2.0.20", -] - [[package]] name = "dig-rpc-protocol" version = "0.11.0" diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 8b20633b..e4ad1616 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -192,15 +192,6 @@ serde_json = "1" # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). dig-rpc-protocol = "0.11.0" -# The reward-distributor coin driver (#3269): the chain-derived state reader -# (`read_distributor`, `DistributorSnapshot`, `ChainObservation`) and the tested -# `recoverable_base_units` clawback-preview arithmetic. 0.4.0 is the first line that ships the -# reader (`read_distributor`, `SPEC.md` §12.1 clause 1, #3267) — pre-0.4 lines are SPEC-only or -# ship a reader that calls `from_parent_spend` and reads back Malformed on every distributor. Its -# `chia-sdk-driver`/`chia-sdk-types` = `0.36.0` and `chia-puzzle-types` = `0.36.1` pins are -# load-bearing (a `cohort_lock_guard` asserts them) and MUST NOT be loosened or tightened by a -# consumer. -dig-rewards-coin = "0.4" # The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope # the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 # envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 50e211a7..6b8bf7d0 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -590,6 +590,16 @@ pub struct Node { /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`] — UNKNOWN, deliberately never an /// empty funded set. funded_distributors: OnceLock, + /// The chain seam `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` + /// (dig_ecosystem#3269 units 1-2) read through — [`rewards::port::RewardsChainPort`]. + /// + /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is + /// one. Nothing installs a real adapter yet: the one that calls `dig-rewards-coin` lives in + /// `dig-node-service` (dig_ecosystem#3268, a sibling unit this crate never depends on — see + /// `rewards::port`'s module doc). Until it is installed, both handlers answer + /// [`rewards::port::ChainPortError::Unavailable`] — a real "no chain source is wired yet", + /// never a silent zero or empty list. + reward_chain_port: OnceLock>, } impl Node { @@ -662,6 +672,28 @@ impl Node { ), } } + + /// Install this node's reward-distributor chain-read adapter (dig_ecosystem#3269 units 1-2), + /// once. Returns `false` if one is already installed, in which case NOTHING changed — mirrors + /// [`Node::install_funded_distributor_registry`]'s same one-shot discipline. + /// + /// Called from tests today: the startup path that would install the real adapter belongs to + /// dig_ecosystem#3268's files (`dig-node-service`), so clippy's non-test lib target sees no + /// production caller yet. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn install_reward_chain_port( + &self, + port: Arc, + ) -> bool { + self.reward_chain_port.set(port).is_ok() + } + + /// The installed reward-distributor chain-read adapter, or `None` when nothing has installed + /// one — `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` must treat `None` + /// exactly like [`rewards::port::ChainPortError::Unavailable`], never a zero or empty answer. + pub(crate) fn reward_chain_port(&self) -> Option<&Arc> { + self.reward_chain_port.get() + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4911,6 +4943,7 @@ impl Node { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }) } @@ -5252,6 +5285,7 @@ pub(crate) mod test_support { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; (Arc::new(node), td) } @@ -6106,6 +6140,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; (node, td) } @@ -6242,6 +6277,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; // Missing before the pull. @@ -6312,6 +6348,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6413,6 +6450,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6492,6 +6530,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -9504,6 +9543,377 @@ mod tests { assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); } + /// An in-memory `RewardsChainPort` for `dig.getRewardDistributor` / + /// `dig.listRewardDistributorCommitments` dispatch tests (dig_ecosystem#3269 unit 2) — keyed + /// per launcher id so two distinct distributors can be driven through the SAME dispatch path + /// with distinct answers, exactly the shape the money-figure subject test needs. + struct FakeRewardsChainPort { + reports: std::collections::HashMap< + [u8; 32], + Result, + >, + } + + #[async_trait::async_trait] + impl crate::rewards::port::RewardsChainPort for FakeRewardsChainPort { + async fn funded_distributors( + &self, + ) -> Result, crate::rewards::port::ChainPortError> + { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn distributor_state( + &self, + _launcher_id: crate::rewards::port::Bytes32, + ) -> Result + { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn submit_entry_writes( + &self, + _bundle: crate::rewards::port::EntryWriteBundle, + ) -> Result<(), crate::rewards::port::ChainPortError> { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn spend_new_epoch( + &self, + _launcher_id: crate::rewards::port::Bytes32, + ) -> Result<(), crate::rewards::port::ChainPortError> { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn distributor_report( + &self, + launcher_id: crate::rewards::port::Bytes32, + ) -> Result + { + self.reports + .get(&launcher_id) + .cloned() + .unwrap_or(Err(crate::rewards::port::ChainPortError::Unavailable)) + } + } + + /// A `DistributorReport` with every field distinctly derived from `seed`, so two reports built + /// from two different seeds can never accidentally collide on a real field. + fn sample_distributor_report( + seed: u8, + commitments: Vec, + ) -> crate::rewards::port::DistributorReport { + crate::rewards::port::DistributorReport { + launcher_id: [seed; 32], + store_id: [seed.wrapping_add(1); 32], + root: [seed.wrapping_add(2); 32], + epoch_seconds: 604_800 + seed as u64, + first_epoch_start: 1_700_000_000 + seed as u64, + payout_threshold: 1_000_000 + seed as u64, + fee_bps: 100 + seed as u16, + withdrawal_share_bps: 9_000 + seed as u16, + reserve_base_units: 10_000_000 + seed as u64 * 1_000, + entry_count: 3 + seed as u64, + current_distributor_epoch: 5 + seed as u64, + last_entry_write_at: Some(1_700_100_000 + seed as u64), + entry_set_stale: seed % 2 == 0, + commitments, + observed_at: 1_700_200_000 + seed as u64, + } + } + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + /// **Proves:** `dig.getRewardDistributor` answers with the port's real values through the + /// REAL dispatch path (`handle_rpc` -> `handle_rpc_as` -> `RpcDispatch::dispatch`), asserted on + /// the serialized JSON body's key SET, not a Rust struct (a struct assertion cannot see a + /// serde rename or an extra field — dig_ecosystem#3269's evidence bar). + /// **Catches:** a handler that drops a field, mis-cases a key, or answers from a stub instead + /// of the port. + #[test] + fn get_reward_distributor_answers_with_real_values_through_dispatch() { + let (node, _td) = test_node(None); + let launcher_id = [0x11u8; 32]; + let report = sample_distributor_report(0x11, vec![]); + assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + }))); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = + result.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "launcher_id", + "store_id", + "root", + "epoch_seconds", + "first_epoch_start", + "payout_threshold", + "fee_bps", + "withdrawal_share_bps", + "reserve_base_units", + "entry_count", + "current_distributor_epoch", + "last_entry_write_at", + "entry_set_stale", + "observed_at", + ]), + "the wire body's key SET must be exactly this — a struct assertion cannot see a wrong \ + key name or an extra field" + ); + assert_eq!(result["launcher_id"], json!(hex::encode(report.launcher_id))); + assert_eq!(result["store_id"], json!(hex::encode(report.store_id))); + assert_eq!(result["root"], json!(hex::encode(report.root))); + assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); + assert_eq!(result["first_epoch_start"], json!(report.first_epoch_start)); + assert_eq!(result["payout_threshold"], json!(report.payout_threshold)); + assert_eq!(result["fee_bps"], json!(report.fee_bps)); + assert_eq!(result["withdrawal_share_bps"], json!(report.withdrawal_share_bps)); + assert_eq!(result["reserve_base_units"], json!(report.reserve_base_units)); + assert_eq!(result["entry_count"], json!(report.entry_count)); + assert_eq!( + result["current_distributor_epoch"], + json!(report.current_distributor_epoch) + ); + assert_eq!(result["last_entry_write_at"], json!(report.last_entry_write_at)); + assert_eq!(result["entry_set_stale"], json!(report.entry_set_stale)); + assert_eq!(result["observed_at"], json!(report.observed_at)); + } + + /// **Proves:** `dig.listRewardDistributorCommitments` answers with the port's real values + /// through the real dispatch path, asserted on the serialized body's key set and per-slot + /// values — including the legitimate empty-`commitments` case being distinguishable from an + /// error (it is a real `result`, not an `error`). + /// **Catches:** a handler that restates `recoverable_base_units` itself instead of echoing the + /// port's pre-computed figure, or mis-cases a key. + #[test] + fn list_reward_distributor_commitments_answers_with_real_values_through_dispatch() { + let (node, _td) = test_node(None); + let launcher_id = [0x22u8; 32]; + let slot = crate::rewards::port::CommitmentSlot { + epoch_start: 42, + clawback_puzzle_hash: [0x33u8; 32], + rewards_base_units: 1_000, + recoverable_base_units: 900, + }; + let report = sample_distributor_report(0x22, vec![slot.clone()]); + assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + }))); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = + result.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "launcher_id", + "withdrawal_share_bps", + "epoch_seconds", + "commitments", + "observed_at", + ]) + ); + assert_eq!(result["launcher_id"], json!(hex::encode(report.launcher_id))); + assert_eq!(result["withdrawal_share_bps"], json!(report.withdrawal_share_bps)); + assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); + assert_eq!(result["observed_at"], json!(report.observed_at)); + let commitments = result["commitments"].as_array().unwrap(); + assert_eq!(commitments.len(), 1); + let row_keys: std::collections::BTreeSet<&str> = + commitments[0].as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + row_keys, + std::collections::BTreeSet::from([ + "epoch_start", + "clawback_puzzle_hash", + "rewards_base_units", + "recoverable_base_units", + ]) + ); + assert_eq!(commitments[0]["epoch_start"], json!(42)); + assert_eq!( + commitments[0]["clawback_puzzle_hash"], + json!(hex::encode([0x33u8; 32])) + ); + assert_eq!(commitments[0]["rewards_base_units"], json!(1_000)); + assert_eq!(commitments[0]["recoverable_base_units"], json!(900)); + } + + /// **Proves:** with no chain-read adapter installed, BOTH reward-distributor methods answer a + /// distinct error — never a zero, never an empty list — and it is NOT the same machine code as + /// the `withdrawal_share_bps` refusal (so a caller can tell "try again later" apart from "this + /// distributor's own constant is broken"). + /// **Catches:** a handler that defaults to `Default::default()` or an empty result on a `None` + /// port instead of erroring. + #[test] + fn reward_distributor_methods_chain_unavailable_is_a_distinct_error_never_a_zero_or_empty() { + let (node, _td) = test_node(None); + let launcher_id = [0x44u8; 32]; + + for method in ["dig.getRewardDistributor", "dig.listRewardDistributorCommitments"] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!(resp.get("result").is_none(), "{method}: must not answer a result at all"); + assert_eq!(resp["error"]["data"]["code"], json!("REWARD_CHAIN_UNAVAILABLE")); + assert_ne!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE"), + "{method}: chain-unavailable must not share a machine code with the \ + withdrawal-share refusal" + ); + } + } + + /// **Proves:** when the port refuses because `withdrawal_share_bps` is out of range (either + /// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own + /// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code + /// — never a `0`, never an empty `commitments` list standing in for the refusal. + /// **Mutation-probe:** flipping `reward_chain_port_error_response`'s + /// `InvalidWithdrawalShare` arm to instead answer a `withdrawal_share_bps: 0` result turns this + /// RED (see the accompanying report for the before/after run) — proving the test is not + /// vacuously green under the defect it exists to catch. + #[test] + fn reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share() { + let (node, _td) = test_node(None); + let launcher_id = [0x55u8; 32]; + assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([( + launcher_id, + Err(crate::rewards::port::ChainPortError::InvalidWithdrawalShare), + )]), + }))); + + for method in ["dig.getRewardDistributor", "dig.listRewardDistributorCommitments"] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!(resp.get("result").is_none(), "{method}: must refuse the whole call"); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE") + ); + assert_ne!(resp["error"]["code"], json!(0)); + } + } + + /// **Proves:** `entry_set_stale` is threaded through, both `true` and `false`, straight from + /// the port's chain-derived figure — never hardcoded, never inverted. + #[test] + fn get_reward_distributor_threads_entry_set_stale_both_ways() { + let (node, _td) = test_node(None); + for (seed, expect_stale) in [(0x60u8, true), (0x61u8, false)] { + let launcher_id = [seed; 32]; + let mut report = sample_distributor_report(seed, vec![]); + report.entry_set_stale = expect_stale; + assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + }))); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["entry_set_stale"], json!(expect_stale)); + } + } + + /// **Proves:** two distinct distributors' money figures never cross-contaminate — the class of + /// defect dig-app#403's rewards pane shipped (a per-distributor total silently summed or + /// swapped). Reads BOTH distributors' `dig.listRewardDistributorCommitments` in the same test + /// and asserts neither the summed nor the swapped figure appears in either response. + /// **Mutation-probe:** swapping the two `FakeRewardsChainPort` entries' `recoverable_base_units` + /// turns this RED (see the accompanying report). + #[test] + fn commitment_money_figures_stay_attributed_to_their_own_distributor() { + let (node, _td) = test_node(None); + let launcher_a = [0x70u8; 32]; + let launcher_b = [0x71u8; 32]; + let slot_a = crate::rewards::port::CommitmentSlot { + epoch_start: 1, + clawback_puzzle_hash: [0xaau8; 32], + rewards_base_units: 5_000, + recoverable_base_units: 4_500, + }; + let slot_b = crate::rewards::port::CommitmentSlot { + epoch_start: 2, + clawback_puzzle_hash: [0xbbu8; 32], + rewards_base_units: 7_000, + recoverable_base_units: 6_300, + }; + let report_a = sample_distributor_report(0x70, vec![slot_a]); + let report_b = sample_distributor_report(0x71, vec![slot_b]); + assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (launcher_a, Ok(report_a)), + (launcher_b, Ok(report_b)), + ]), + }))); + + let resp_a = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_a)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let resp_b = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_b)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + let recoverable_a = resp_a["result"]["commitments"][0]["recoverable_base_units"] + .as_u64() + .unwrap(); + let recoverable_b = resp_b["result"]["commitments"][0]["recoverable_base_units"] + .as_u64() + .unwrap(); + assert_eq!(recoverable_a, 4_500); + assert_eq!(recoverable_b, 6_300); + let summed = 4_500 + 6_300; + let swapped_a = 6_300; + let swapped_b = 4_500; + assert_ne!(recoverable_a, summed); + assert_ne!(recoverable_b, summed); + assert_ne!(recoverable_a, swapped_a); + assert_ne!(recoverable_b, swapped_b); + } + /// **Proves:** `total_paid_out_base_units`/`reserve_base_units` stay attributed to the /// `launcher_id` (distributor) that reported them — never summed across distributors, never /// cross-attributed to the other one. **Catches:** the class of defect a sibling adversarial @@ -9974,6 +10384,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; let before = handle_rpc( @@ -17131,6 +17542,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; // A holder for this EXACT content is known via the DHT. @@ -17184,6 +17596,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -17236,6 +17649,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; @@ -17270,6 +17684,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17313,6 +17728,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17358,6 +17774,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index f85f954b..7feeca67 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5652,6 +5652,20 @@ pub(crate) mod tests { !reward_methods.is_empty(), "expected at least one Reward-named method in Method::ALL; found none" ); + // dig_ecosystem#3269 unit 3: a non-empty check alone would still pass if the catalogue + // grew a fifth reward method that the filter silently stopped matching (or a variant were + // renamed out from under `.contains("Reward")`) — this pins the count so the guard cannot + // start policing FEWER methods than actually exist without failing loudly. Update this + // number, deliberately, the moment `dig-rpc-protocol` adds or removes a reward method. + assert_eq!( + reward_methods.len(), + 4, + "expected exactly 4 Reward-named methods in Method::ALL (dig.listRewardDistributors, \ + dig.getRewardProverStatus, dig.getRewardDistributor, \ + dig.listRewardDistributorCommitments); got {}: a catalogue change must update this \ + guard deliberately, not silently narrow it", + reward_methods.len() + ); for m in reward_methods { assert!( !is_peer_reachable_method(m.name()), diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 7eb2d5a0..cb74cb06 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -128,12 +128,93 @@ pub enum ChainPortError { /// No chain source is wired yet — the [`unavailable`] adapter's only answer, and what any real /// adapter should answer for an unreachable chain too (SPEC §12.2 clause 4). Unavailable, + /// dig_ecosystem#3269/#3284/#3303: the distributor's `withdrawal_share_bps` (a `u64` on the + /// puzzle) either does not fit the wire's `u16` domain or exceeds the legitimate `0..=10_000` + /// bps range. The adapter MUST refuse the WHOLE [`RewardsChainPort::distributor_report`] call + /// rather than silently narrowing (`as u16` would wrap `65_536` to `0`) or omitting the figure: + /// `withdrawal_share_bps` is curried once per distributor (launch-time, immutable), so an + /// invalid value can never affect one row of a caller's answer and not another. Refusing the + /// whole call here therefore blinds zero good rows and needs no wire change — see the module + /// doc on [`DistributorReport`]. + InvalidWithdrawalShare, /// A chain answered but the call failed for a reason worth a message (bounded before logging — /// SPEC §3.7 clause 4 applies to every attacker-adjacent string, and a chain error is not /// exempt). Other(String), } +/// One clawback commitment slot, as `dig.listRewardDistributorCommitments` (SPEC §7.4 clause 5) +/// needs it. +/// +/// `recoverable_base_units` is the adapter's PRE-COMPUTED share — never restated by a caller of +/// this port, and never recomputed by `dig-node-core` itself. The production adapter +/// (`dig-node-service`, dig_ecosystem#3268) is the one crate in this seam that depends on +/// `dig-rewards-coin` (dig_ecosystem#3269 unit 0 removed that dependency from THIS crate +/// deliberately); it computes this figure with `dig_rewards_coin::recoverable_base_units` — that +/// crate's own tested, simulator-bound restatement of the puzzle's share arithmetic (u128 +/// intermediate, multiply-then-divide, truncated; see that function's doc for the equality proof +/// against `chia-sdk-driver`). If `withdrawal_share_bps` does not fit `u16` or exceeds `10_000`, +/// the adapter refuses the WHOLE [`RewardsChainPort::distributor_report`] call with +/// [`ChainPortError::InvalidWithdrawalShare`] instead of returning a `CommitmentSlot` with a +/// wrong, zeroed or omitted `recoverable_base_units` — see that variant's doc for why a +/// per-distributor curried value makes a whole-call refusal the correct shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitmentSlot { + /// The distributor epoch this commitment slot funds. + pub epoch_start: u64, + /// The chain's `clawback_ph`: the puzzle hash whose key holder alone may claw this slot back + /// (SPEC §7.4 clause 3) — an entitlement fact, never a display label. + pub clawback_puzzle_hash: Bytes32, + /// The committed amount, in base units, as the puzzle records it. + pub rewards_base_units: u64, + /// The amount actually recoverable on clawback, in base units. See the type doc: always + /// pre-computed by the adapter, never by a caller of this trait. + pub recoverable_base_units: u64, +} + +/// One distributor's chain-derived report — everything `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` (dig_ecosystem#3269 units 1-2) need for one launcher id, +/// from the ONE port call [`RewardsChainPort::distributor_report`] designs once so neither handler +/// can diverge from the other's view of the same distributor. +/// +/// Deliberately a NEW type, not a widened [`DistributorChainState`]: that type is the prover cycle +/// engine's own shape (SPEC §2.3, §8, §12.4, dig_ecosystem#3250) and widening it would reach into +/// that ticket's territory for a need this one does not share (`fee_bps`, `withdrawal_share_bps`, +/// commitments, and the distributor's launch constants are irrelevant to the prover cycle). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorReport { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, + /// The payout epoch length, in seconds — a launch-curried, immutable distributor constant. + pub epoch_seconds: u64, + /// Unix seconds the first epoch started. + pub first_epoch_start: u64, + /// The reserve threshold, in base units, that triggers a payout. + pub payout_threshold: u64, + /// The distributor's fee, in basis points. + pub fee_bps: u16, + /// Already narrowed to the wire's `u16` domain and validated `<= 10_000` by the adapter — see + /// [`ChainPortError::InvalidWithdrawalShare`] for what happens when the chain's raw `u64` + /// constant fails either check. + pub withdrawal_share_bps: u16, + pub reserve_base_units: u64, + pub entry_count: u64, + pub current_distributor_epoch: u64, + /// Unix seconds of the most recent entry-set write on chain, if any. `None` is a positive + /// fact (no write has ever happened since launch), never "unknown" — SPEC §2.4 clause 1. + pub last_entry_write_at: Option, + /// SPEC §12.4: computed by the adapter from the chain-derived write history against + /// `dig_rewards_coin::STALE_ENTRY_SET_SECONDS` at read time — never self-reported by a + /// possibly-wedged prover loop, and never a hardcoded constant in this crate or its callers. + pub entry_set_stale: bool, + /// One entry per outstanding commitment slot. Empty is legitimate (SPEC §7.4 clause 5): a + /// distributor funded only via `AddIncentives` has no clawback-eligible slots at all. + pub commitments: Vec, + /// Unix seconds this report was assembled. + pub observed_at: u64, +} + /// Reads and the one write this engine needs from the reward-distributor chain state. Derived from /// the SPEC's described surface (§1.3 reads, §6.3 write), not from `dig-rewards-coin`'s internals. #[async_trait] @@ -156,6 +237,16 @@ pub trait RewardsChainPort: Send + Sync { /// not a conflict, and neither MUST treat a not-yet-rolled epoch as an error or assume the /// other already did it. async fn spend_new_epoch(&self, launcher_id: Bytes32) -> Result<(), ChainPortError>; + + /// SPEC §2.6/§7.4/§12.4, dig_ecosystem#3269 units 1-2: one distributor's full chain-derived + /// report, feeding both `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` + /// from a single call — see [`DistributorReport`]'s doc for why this is a new type rather than + /// a widened [`DistributorChainState`], and [`ChainPortError::InvalidWithdrawalShare`] for the + /// one refusal path this call can produce beyond [`ChainPortError::Unavailable`]. + async fn distributor_report( + &self, + launcher_id: Bytes32, + ) -> Result; } /// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports @@ -187,6 +278,13 @@ impl RewardsChainPort for UnavailableChainPort { async fn spend_new_epoch(&self, _launcher_id: Bytes32) -> Result<(), ChainPortError> { Err(ChainPortError::Unavailable) } + + async fn distributor_report( + &self, + _launcher_id: Bytes32, + ) -> Result { + Err(ChainPortError::Unavailable) + } } #[cfg(test)] @@ -217,5 +315,9 @@ mod tests { port.spend_new_epoch([0u8; 32]).await, Err(ChainPortError::Unavailable) ); + assert_eq!( + port.distributor_report([0u8; 32]).await, + Err(ChainPortError::Unavailable) + ); } } diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 85be4b2e..3d740b47 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -17,6 +17,7 @@ use serde_json::{json, Value}; +use crate::rewards::port::ChainPortError; use crate::Node; // The relocated body below calls a number of crate-root private helpers (`rpc_err`, // `parse_store_id_arg`, `pin_request_root`, …) UNQUALIFIED, exactly as it did when it lived in @@ -34,6 +35,63 @@ use crate::*; /// own surface. const ENGINE_WARMING: i64 = -32002; +/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is +/// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all). +/// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE`] below — a caller must be able to tell "ask me +/// again once the adapter lands" apart from "this distributor's own constant is out of range". +/// Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, `-32032`), but +/// carries its own `data.code` machine string so the two are still distinguishable in the body. +const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; + +/// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's +/// `withdrawal_share_bps` does not fit the wire's `u16` domain or exceeds the legitimate +/// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s +/// doc for why a per-distributor curried value makes that the correct shape, never a `0` or an +/// omitted field. +const REWARD_INVALID_WITHDRAWAL_SHARE: i64 = -32033; + +/// Maps a [`ChainPortError`] to the JSON-RPC error response for both reward-distributor read +/// methods (dig_ecosystem#3269 unit 2) — one mapping so `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` can never disagree about how a given port failure reads +/// on the wire. +fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value { + match error { + ChainPortError::Unavailable => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } + }}), + ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": REWARD_INVALID_WITHDRAWAL_SHARE, + "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", + "data": { "code": "REWARD_INVALID_WITHDRAWAL_SHARE", "origin": "control" } + }}), + ChainPortError::Other(msg) => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": format!("reward-distributor chain read failed: {msg}"), + "data": { "code": "CONTROL_ERROR", "origin": "control" } + }}), + } +} + +/// Parses `params.launcher_id` (64-hex) into the port's own `[u8; 32]` shape +/// (`rewards::port::Bytes32`) for `dig.getRewardDistributor` / +/// `dig.listRewardDistributorCommitments`. +fn parse_launcher_id_arg(params: &Value) -> Result<[u8; 32], String> { + let s = params + .get("launcher_id") + .and_then(Value::as_str) + .ok_or_else(|| "params.launcher_id must be a 64-hex string".to_string())?; + let h = s.trim_start_matches("0x"); + if h.len() != 64 { + return Err(format!("launcher_id must be 64-hex: {s}")); + } + let bytes = hex::decode(h).map_err(|_| format!("launcher_id is not hex: {s}"))?; + bytes + .try_into() + .map_err(|_: Vec| format!("launcher_id must be 32 bytes (64 hex): {s}")) +} + /// Decide the miss error for a request that fell all the way through with no configured upstream /// (dig_ecosystem#2097): `(code, message)`. /// @@ -840,6 +898,80 @@ impl RpcDispatch for Node { let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } + // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL + // plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method` + // (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY — + // never the local prover loop's self-reported state (see `GetRewardProverStatus` + // above for that). Goes entirely through `rewards::port::RewardsChainPort`: this + // crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0). + Some(Method::GetRewardDistributor) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let launcher_id = match parse_launcher_id_arg(¶ms) { + Ok(id) => id, + Err(msg) => return rpc_err(&id, -32602, &msg), + }; + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + let report = match port.distributor_report(launcher_id).await { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + let result = dig_rpc_protocol::types::GetRewardDistributorResult { + launcher_id: hex::encode(report.launcher_id), + store_id: hex::encode(report.store_id), + root: hex::encode(report.root), + epoch_seconds: report.epoch_seconds, + first_epoch_start: report.first_epoch_start, + payout_threshold: report.payout_threshold, + fee_bps: report.fee_bps, + withdrawal_share_bps: report.withdrawal_share_bps, + reserve_base_units: report.reserve_base_units, + entry_count: report.entry_count, + current_distributor_epoch: report.current_distributor_epoch, + last_entry_write_at: report.last_entry_write_at, + entry_set_stale: report.entry_set_stale, + observed_at: report.observed_at, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5) + // — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments` + // empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot + // is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see + // `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here). + Some(Method::ListRewardDistributorCommitments) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let launcher_id = match parse_launcher_id_arg(¶ms) { + Ok(id) => id, + Err(msg) => return rpc_err(&id, -32602, &msg), + }; + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + let report = match port.distributor_report(launcher_id).await { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + let commitments: Vec = report + .commitments + .iter() + .map(|c| dig_rpc_protocol::types::RewardDistributorCommitment { + epoch_start: c.epoch_start, + clawback_puzzle_hash: hex::encode(c.clawback_puzzle_hash), + rewards_base_units: c.rewards_base_units, + recoverable_base_units: c.recoverable_base_units, + }) + .collect(); + let result = dig_rpc_protocol::types::ListRewardDistributorCommitmentsResult { + launcher_id: hex::encode(report.launcher_id), + withdrawal_share_bps: report.withdrawal_share_bps, + epoch_seconds: report.epoch_seconds, + commitments, + observed_at: report.observed_at, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } Some(Method::CacheSetCapBytes) => { let requested = req .get("params") diff --git a/crates/dig-node-core/tests/reward_methods_tier_guard.rs b/crates/dig-node-core/tests/reward_methods_tier_guard.rs index 37393051..f74a63c1 100644 --- a/crates/dig-node-core/tests/reward_methods_tier_guard.rs +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -42,6 +42,18 @@ fn reward_methods_exist_and_are_found_by_the_prefix_scan() { "expected at least one Reward-prefixed method in Method::ALL; found none — the prefix scan \ itself may be broken, or the wire naming convention changed" ); + // dig_ecosystem#3269 unit 3: pins the count so the guard cannot silently start policing FEWER + // methods than actually exist (a non-empty check alone would still pass on 3 of 4, or on a + // renamed variant the filter stopped matching). Update this number deliberately when + // `dig-rpc-protocol` adds or removes a reward method. + assert_eq!( + methods.len(), + 4, + "expected exactly 4 Reward-prefixed methods (dig.listRewardDistributors, \ + dig.getRewardProverStatus, dig.getRewardDistributor, \ + dig.listRewardDistributorCommitments); got {}", + methods.len() + ); } #[test] From 551edb42408102c31c12281fb67e4df0da31ea69 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 00:07:57 -0700 Subject: [PATCH 04/19] style(rewards): rustfmt the reward-distributor dispatch tests `cargo fmt --all` touched only `crates/dig-node-core/src/lib.rs` -- the eight diffs CI reported in the pushed test block. No other file in the workspace wanted reformatting. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 133 ++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 39 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 6b8bf7d0..02e47b5d 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9640,9 +9640,11 @@ mod tests { let (node, _td) = test_node(None); let launcher_id = [0x11u8; 32]; let report = sample_distributor_report(0x11, vec![]); - assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { - reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), - }))); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + })) + ); let resp = rt().block_on(handle_rpc( &node, @@ -9652,8 +9654,12 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); let result = &resp["result"]; - let keys: std::collections::BTreeSet<&str> = - result.as_object().unwrap().keys().map(String::as_str).collect(); + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); assert_eq!( keys, std::collections::BTreeSet::from([ @@ -9675,21 +9681,33 @@ mod tests { "the wire body's key SET must be exactly this — a struct assertion cannot see a wrong \ key name or an extra field" ); - assert_eq!(result["launcher_id"], json!(hex::encode(report.launcher_id))); + assert_eq!( + result["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); assert_eq!(result["store_id"], json!(hex::encode(report.store_id))); assert_eq!(result["root"], json!(hex::encode(report.root))); assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); assert_eq!(result["first_epoch_start"], json!(report.first_epoch_start)); assert_eq!(result["payout_threshold"], json!(report.payout_threshold)); assert_eq!(result["fee_bps"], json!(report.fee_bps)); - assert_eq!(result["withdrawal_share_bps"], json!(report.withdrawal_share_bps)); - assert_eq!(result["reserve_base_units"], json!(report.reserve_base_units)); + assert_eq!( + result["withdrawal_share_bps"], + json!(report.withdrawal_share_bps) + ); + assert_eq!( + result["reserve_base_units"], + json!(report.reserve_base_units) + ); assert_eq!(result["entry_count"], json!(report.entry_count)); assert_eq!( result["current_distributor_epoch"], json!(report.current_distributor_epoch) ); - assert_eq!(result["last_entry_write_at"], json!(report.last_entry_write_at)); + assert_eq!( + result["last_entry_write_at"], + json!(report.last_entry_write_at) + ); assert_eq!(result["entry_set_stale"], json!(report.entry_set_stale)); assert_eq!(result["observed_at"], json!(report.observed_at)); } @@ -9711,9 +9729,11 @@ mod tests { recoverable_base_units: 900, }; let report = sample_distributor_report(0x22, vec![slot.clone()]); - assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { - reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), - }))); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + })) + ); let resp = rt().block_on(handle_rpc( &node, @@ -9723,8 +9743,12 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); let result = &resp["result"]; - let keys: std::collections::BTreeSet<&str> = - result.as_object().unwrap().keys().map(String::as_str).collect(); + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); assert_eq!( keys, std::collections::BTreeSet::from([ @@ -9735,14 +9759,24 @@ mod tests { "observed_at", ]) ); - assert_eq!(result["launcher_id"], json!(hex::encode(report.launcher_id))); - assert_eq!(result["withdrawal_share_bps"], json!(report.withdrawal_share_bps)); + assert_eq!( + result["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!( + result["withdrawal_share_bps"], + json!(report.withdrawal_share_bps) + ); assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); assert_eq!(result["observed_at"], json!(report.observed_at)); let commitments = result["commitments"].as_array().unwrap(); assert_eq!(commitments.len(), 1); - let row_keys: std::collections::BTreeSet<&str> = - commitments[0].as_object().unwrap().keys().map(String::as_str).collect(); + let row_keys: std::collections::BTreeSet<&str> = commitments[0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); assert_eq!( row_keys, std::collections::BTreeSet::from([ @@ -9772,7 +9806,10 @@ mod tests { let (node, _td) = test_node(None); let launcher_id = [0x44u8; 32]; - for method in ["dig.getRewardDistributor", "dig.listRewardDistributorCommitments"] { + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { let resp = rt().block_on(handle_rpc( &node, json!({"jsonrpc":"2.0","id":1,"method":method, @@ -9780,8 +9817,14 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - assert!(resp.get("result").is_none(), "{method}: must not answer a result at all"); - assert_eq!(resp["error"]["data"]["code"], json!("REWARD_CHAIN_UNAVAILABLE")); + assert!( + resp.get("result").is_none(), + "{method}: must not answer a result at all" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE") + ); assert_ne!( resp["error"]["data"]["code"], json!("REWARD_INVALID_WITHDRAWAL_SHARE"), @@ -9803,14 +9846,19 @@ mod tests { fn reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share() { let (node, _td) = test_node(None); let launcher_id = [0x55u8; 32]; - assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { - reports: std::collections::HashMap::from([( - launcher_id, - Err(crate::rewards::port::ChainPortError::InvalidWithdrawalShare), - )]), - }))); - - for method in ["dig.getRewardDistributor", "dig.listRewardDistributorCommitments"] { + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([( + launcher_id, + Err(crate::rewards::port::ChainPortError::InvalidWithdrawalShare), + )]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { let resp = rt().block_on(handle_rpc( &node, json!({"jsonrpc":"2.0","id":1,"method":method, @@ -9818,7 +9866,10 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - assert!(resp.get("result").is_none(), "{method}: must refuse the whole call"); + assert!( + resp.get("result").is_none(), + "{method}: must refuse the whole call" + ); assert_eq!( resp["error"]["data"]["code"], json!("REWARD_INVALID_WITHDRAWAL_SHARE") @@ -9836,9 +9887,11 @@ mod tests { let launcher_id = [seed; 32]; let mut report = sample_distributor_report(seed, vec![]); report.entry_set_stale = expect_stale; - assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { - reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), - }))); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); let resp = rt().block_on(handle_rpc( &node, json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", @@ -9875,12 +9928,14 @@ mod tests { }; let report_a = sample_distributor_report(0x70, vec![slot_a]); let report_b = sample_distributor_report(0x71, vec![slot_b]); - assert!(node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { - reports: std::collections::HashMap::from([ - (launcher_a, Ok(report_a)), - (launcher_b, Ok(report_b)), - ]), - }))); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (launcher_a, Ok(report_a)), + (launcher_b, Ok(report_b)), + ]), + })) + ); let resp_a = rt().block_on(handle_rpc( &node, From a77ed70756bf55c3e3251cdde80feb52a2054f54 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 00:10:26 -0700 Subject: [PATCH 05/19] fix(rewards): derive test entry_set_stale from the chain-derived write age `get_reward_distributor_threads_entry_set_stale_both_ways` installed a chain port twice on one `Node`, so the second `install_reward_chain_port` correctly returned false and its `assert!` fired. `install_reward_chain_port` is single-shot on purpose and stays that way -- loosening it so a test could pass would let a real double-install through in production. Instead the fake port, which is already keyed per launcher id, now serves both cases from ONE install on ONE node, answered by the requested launcher id. Two reports straddle the staleness bound: one whose last entry write is exactly `STALE_ENTRY_SET_SECONDS` old (stale), one a second inside it (fresh). The test asserts the fixtures really do straddle the bound before it asserts anything about the wire. Both expectations are DERIVED through `rewards::staleness::is_entry_set_stale` against the exported constant, so the bound's numeric value (172_800) appears nowhere in the test and the fixture can no longer carry a staleness flag its own chain-derived times contradict. That derivation also removes the `seed % 2 == 0` parity trick clippy flagged as a manual `is_multiple_of`. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 116 +++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 16 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 02e47b5d..5f142c5b 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9597,31 +9597,89 @@ mod tests { } } + /// Derives `entry_set_stale` the way a production adapter MUST (SPEC §12.4): from the + /// CHAIN-derived last-entry-write time measured against the exported + /// [`crate::rewards::spec_constants::STALE_ENTRY_SET_SECONDS`], through the same + /// [`crate::rewards::staleness::is_entry_set_stale`] rule the engine uses — never a hardcoded + /// `172_800`, and never a prover's own self-report of its freshness. + fn derive_entry_set_stale( + reserve_base_units: u64, + last_entry_write_at: Option, + observed_at: u64, + distributor_created_at: u64, + ) -> bool { + crate::rewards::staleness::is_entry_set_stale( + &crate::rewards::port::DistributorChainState { + reserve_base_units, + entries: Vec::new(), + current_distributor_epoch: 0, + last_entry_write_at, + total_paid_out_base_units: 0, + }, + observed_at, + distributor_created_at, + ) + } + /// A `DistributorReport` with every field distinctly derived from `seed`, so two reports built /// from two different seeds can never accidentally collide on a real field. + /// + /// `entry_set_stale` is the one field that is NOT a free function of `seed`: it is derived from + /// this report's own `last_entry_write_at`/`observed_at` against the exported staleness bound, + /// so no fixture can carry a staleness flag its own chain-derived times contradict. With the + /// times below, every seed's report is FRESH (its write is 100_000s old, inside the bound); a + /// test that needs the stale side uses [`distributor_report_with_write_age`]. fn sample_distributor_report( seed: u8, commitments: Vec, ) -> crate::rewards::port::DistributorReport { + let first_epoch_start = 1_700_000_000 + seed as u64; + let reserve_base_units = 10_000_000 + seed as u64 * 1_000; + let last_entry_write_at = Some(1_700_100_000 + seed as u64); + let observed_at = 1_700_200_000 + seed as u64; crate::rewards::port::DistributorReport { launcher_id: [seed; 32], store_id: [seed.wrapping_add(1); 32], root: [seed.wrapping_add(2); 32], epoch_seconds: 604_800 + seed as u64, - first_epoch_start: 1_700_000_000 + seed as u64, + first_epoch_start, payout_threshold: 1_000_000 + seed as u64, fee_bps: 100 + seed as u16, withdrawal_share_bps: 9_000 + seed as u16, - reserve_base_units: 10_000_000 + seed as u64 * 1_000, + reserve_base_units, entry_count: 3 + seed as u64, current_distributor_epoch: 5 + seed as u64, - last_entry_write_at: Some(1_700_100_000 + seed as u64), - entry_set_stale: seed % 2 == 0, + last_entry_write_at, + entry_set_stale: derive_entry_set_stale( + reserve_base_units, + last_entry_write_at, + observed_at, + first_epoch_start, + ), commitments, - observed_at: 1_700_200_000 + seed as u64, + observed_at, } } + /// [`sample_distributor_report`] with the chain-derived last entry write placed exactly + /// `write_age_seconds` before `observed_at`, and `entry_set_stale` re-derived from that age. + /// Lets one test drive both sides of the staleness bound without writing the bound's numeric + /// value down anywhere. + fn distributor_report_with_write_age( + seed: u8, + write_age_seconds: u64, + ) -> crate::rewards::port::DistributorReport { + let mut report = sample_distributor_report(seed, vec![]); + report.last_entry_write_at = Some(report.observed_at - write_age_seconds); + report.entry_set_stale = derive_entry_set_stale( + report.reserve_base_units, + report.last_entry_write_at, + report.observed_at, + report.first_epoch_start, + ); + report + } + fn rt() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_all() @@ -9879,19 +9937,40 @@ mod tests { } /// **Proves:** `entry_set_stale` is threaded through, both `true` and `false`, straight from - /// the port's chain-derived figure — never hardcoded, never inverted. + /// the port's chain-derived figure — never hardcoded, never inverted — with BOTH cases served + /// by ONE port installed ONCE on ONE node, answered by the REQUESTED launcher id. Each + /// expectation is derived from that report's own last-entry-write age against the exported + /// `STALE_ENTRY_SET_SECONDS`; the bound's numeric value appears nowhere in this test. + /// **Catches:** a handler that hardcodes or inverts the flag, and one that answers a DIFFERENT + /// distributor's staleness for the requested launcher id. #[test] fn get_reward_distributor_threads_entry_set_stale_both_ways() { let (node, _td) = test_node(None); - for (seed, expect_stale) in [(0x60u8, true), (0x61u8, false)] { - let launcher_id = [seed; 32]; - let mut report = sample_distributor_report(seed, vec![]); - report.entry_set_stale = expect_stale; - assert!( - node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { - reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), - })) - ); + let bound = crate::rewards::spec_constants::STALE_ENTRY_SET_SECONDS; + // SPEC §12.4 compares with `>=`: exactly at the bound is stale, one second inside is not. + let stale_report = distributor_report_with_write_age(0x60, bound); + let fresh_report = distributor_report_with_write_age(0x61, bound - 1); + assert!( + stale_report.entry_set_stale && !fresh_report.entry_set_stale, + "the fixtures must straddle the staleness bound, or this test proves nothing" + ); + + let cases = [ + (stale_report.launcher_id, stale_report.entry_set_stale), + (fresh_report.launcher_id, fresh_report.entry_set_stale), + ]; + // ONE install for both cases: `install_reward_chain_port` is single-shot deliberately, and + // loosening it so a test could install twice would let a real double-install through. + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (stale_report.launcher_id, Ok(stale_report)), + (fresh_report.launcher_id, Ok(fresh_report)), + ]), + })) + ); + + for (launcher_id, expect_stale) in cases { let resp = rt().block_on(handle_rpc( &node, json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", @@ -9899,7 +9978,12 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - assert_eq!(resp["result"]["entry_set_stale"], json!(expect_stale)); + assert_eq!( + resp["result"]["entry_set_stale"], + json!(expect_stale), + "launcher {} must report its OWN chain-derived staleness", + hex::encode(launcher_id) + ); } } From 3dd1d67a19ac0914336b7085fc2ebe3dd9818758 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 00:47:18 -0700 Subject: [PATCH 06/19] fix(rpc): discriminate invalid-withdrawal-share by data.code, not an unregistered wire code The reward-distributor refusal path minted its own numeric error code locally. That number is not registered in the shared cross-implementation wire taxonomy, whose canonical catalogue in lib.rs stops at -32032, and lib.rs states that this numbering IS the byte-identical contract this node declares it follows. Minting a code locally puts the node outside that contract: another implementation would have no way to read it. The number is not even free -- dig-node-service already spends it on an unrelated ingress refusal, so two surfaces of the same product would have disagreed about what it means. Return -32032 with a REWARD_INVALID_WITHDRAWAL_SHARE data.code instead, which is exactly the shape the chain-unavailable arm beside it already uses. The two refusals stay fully distinguishable in the body, and the refusal test needed no change -- it asserts the absence of `result`, not a numeral. Also corrects a born-false doc attribution: three comments asserted that the chain adapter and its install_reward_chain_port call site belong to #3268. #3268's scope is the claim loop and ClaimStatus, and it names neither distributor_report nor this installer, so those comments pointed a reader at a ticket that will not do the work. They now say the ownership is an open question tracked separately rather than naming a number nobody verified. Same defect shape as #3292. Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 15 ++++++++----- crates/dig-node-core/src/rewards/port.rs | 5 +++-- .../src/seams/dig_rpc/dispatch.rs | 22 +++++++++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 5f142c5b..1c55fb8c 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -595,8 +595,12 @@ pub struct Node { /// /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is /// one. Nothing installs a real adapter yet: the one that calls `dig-rewards-coin` lives in - /// `dig-node-service` (dig_ecosystem#3268, a sibling unit this crate never depends on — see - /// `rewards::port`'s module doc). Until it is installed, both handlers answer + /// `dig-node-service`, a sibling unit this crate never depends on (see `rewards::port`'s + /// module doc). WHICH ticket carries that adapter is an OPEN question — it is tracked + /// separately from dig_ecosystem#3268, whose scope is the claim loop and `ClaimStatus` and + /// which names neither `distributor_report` nor this installer. Do not read a ticket number + /// into this comment that nobody has verified. Until the adapter is installed, both handlers + /// answer /// [`rewards::port::ChainPortError::Unavailable`] — a real "no chain source is wired yet", /// never a silent zero or empty list. reward_chain_port: OnceLock>, @@ -677,9 +681,10 @@ impl Node { /// once. Returns `false` if one is already installed, in which case NOTHING changed — mirrors /// [`Node::install_funded_distributor_registry`]'s same one-shot discipline. /// - /// Called from tests today: the startup path that would install the real adapter belongs to - /// dig_ecosystem#3268's files (`dig-node-service`), so clippy's non-test lib target sees no - /// production caller yet. + /// Called from tests today: the startup path that would install the real adapter lives in + /// `dig-node-service`, so clippy's non-test lib target sees no production caller yet. Which + /// ticket OWNS that adapter and this call site is an open question, tracked separately — it is + /// NOT dig_ecosystem#3268 (claim loop + `ClaimStatus`), which names neither. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn install_reward_chain_port( &self, diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index cb74cb06..ff0676d9 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -147,8 +147,9 @@ pub enum ChainPortError { /// needs it. /// /// `recoverable_base_units` is the adapter's PRE-COMPUTED share — never restated by a caller of -/// this port, and never recomputed by `dig-node-core` itself. The production adapter -/// (`dig-node-service`, dig_ecosystem#3268) is the one crate in this seam that depends on +/// this port, and never recomputed by `dig-node-core` itself. The production adapter lives in +/// `dig-node-service` (which ticket owns it is an open question tracked separately — not +/// dig_ecosystem#3268) and is the one crate in this seam that depends on /// `dig-rewards-coin` (dig_ecosystem#3269 unit 0 removed that dependency from THIS crate /// deliberately); it computes this figure with `dig_rewards_coin::recoverable_base_units` — that /// crate's own tested, simulator-bound restatement of the puzzle's share arithmetic (u128 diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 3d740b47..9e1e20f9 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -37,10 +37,11 @@ const ENGINE_WARMING: i64 = -32002; /// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is /// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all). -/// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE`] below — a caller must be able to tell "ask me -/// again once the adapter lands" apart from "this distributor's own constant is out of range". -/// Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, `-32032`), but -/// carries its own `data.code` machine string so the two are still distinguishable in the body. +/// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`] below — a caller must be able to tell +/// "ask me again once the adapter lands" apart from "this distributor's own constant is out of +/// range". Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, +/// `-32032`), but carries its own `data.code` machine string so the two are still distinguishable +/// in the body. const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; /// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's @@ -48,7 +49,14 @@ const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; /// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s /// doc for why a per-distributor curried value makes that the correct shape, never a `0` or an /// omitted field. -const REWARD_INVALID_WITHDRAWAL_SHARE: i64 = -32033; +/// +/// Discriminated by `data.code` on [`CONTROL_ERROR`]'s `-32032`, exactly like +/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`]: the shared wire taxonomy (`lib.rs`'s canonical catalogue) +/// registers no code beyond `-32032`, and minting a fresh number locally would put this node +/// outside the byte-identical contract it declares it follows — another implementation would have +/// no way to read it. The next number is not even free: `dig-node-service` already spends it on an +/// unrelated ingress refusal. +const REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE: &str = "REWARD_INVALID_WITHDRAWAL_SHARE"; /// Maps a [`ChainPortError`] to the JSON-RPC error response for both reward-distributor read /// methods (dig_ecosystem#3269 unit 2) — one mapping so `dig.getRewardDistributor` and @@ -62,9 +70,9 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } }}), ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{ - "code": REWARD_INVALID_WITHDRAWAL_SHARE, + "code": CONTROL_ERROR, "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", - "data": { "code": "REWARD_INVALID_WITHDRAWAL_SHARE", "origin": "control" } + "data": { "code": REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE, "origin": "control" } }}), ChainPortError::Other(msg) => json!({"jsonrpc":"2.0","id":id,"error":{ "code": CONTROL_ERROR, From d5e1509893b0f2d54874eb9e2d5a58294d960b6f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 01:16:06 -0700 Subject: [PATCH 07/19] fix(rpc): refuse an out-of-range withdrawal_share_bps at the reward-distributor seam dig_ecosystem#3284. Both `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` emitted `report.withdrawal_share_bps` with no range check. The port's adapter is contracted to refuse a value outside `0..=10_000`, but no adapter exists yet and the contract was enforced nowhere at this seam, so `10_001..=65_535` reached the wire unexamined: a chain constant of `74_536` narrowed into the wire's `u16` renders as `9_000` (an ordinary-looking 90% share) and `65_536` renders as `0`. Either is a figure a funder reads before deciding whether to claw back, and neither looks wrong. Refuses the whole call through the existing `InvalidWithdrawalShare` path (`CONTROL_ERROR` with `data.code = REWARD_INVALID_WITHDRAWAL_SHARE`) rather than clamping: clamping `74_536` to `10_000` would report a confident 100% recoverable share for a distributor whose real constant is nonsense. A whole-call refusal costs no good data - `withdrawal_share_bps` is curried once per distributor at launch, so every commitment slot in one response shares the one invalid value. Two tests, a selectivity pair: `10_001` refuses on both methods with neither the raw figure nor a clamped `10_000` anywhere in the body, and `10_000` - a legitimate 100% - still answers on both. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 95 +++++++++++++++++++ .../src/seams/dig_rpc/dispatch.rs | 44 ++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 1c55fb8c..8307b9f1 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9941,6 +9941,101 @@ mod tests { } } + /// **Proves:** a `withdrawal_share_bps` ABOVE the legitimate `0..=10_000` range refuses the + /// WHOLE call on BOTH reward-distributor methods, and the out-of-range figure reaches no + /// response body. The fake port hands back an `Ok` report carrying `10_001` - exactly what a + /// wrapped narrowing (`74_536 as u16` = `9_000`) or a buggy adapter would look like from this + /// seam - so the refusal proved here is the HANDLER's, with no adapter cooperation + /// (dig_ecosystem#3284). + /// **Catches:** the handler emitting the figure verbatim, and the tempting "safe" fix of + /// clamping it to `10_000`, which would report a confident 100% recoverable share for a + /// distributor whose real constant is nonsense. Both are asserted against by name. + #[test] + fn reward_distributor_methods_refuse_a_withdrawal_share_above_the_legitimate_range() { + let (node, _td) = test_node(None); + let launcher_id = [0x78u8; 32]; + let mut report = sample_distributor_report(0x78, vec![]); + report.withdrawal_share_bps = 10_001; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: an out-of-range withdrawal share must refuse the whole call: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE"), + "{method}: {resp}" + ); + let body = resp.to_string(); + assert!( + !body.contains("10001"), + "{method}: the out-of-range figure must not reach the wire: {body}" + ); + assert!( + !body.contains("10000"), + "{method}: a clamp to 100% is a money lie, not a safe default: {body}" + ); + } + } + + /// **Proves:** the boundary is `> 10_000`, not `>= 10_000`: 10,000 basis points IS a + /// legitimate 100% withdrawal share, and both methods still ANSWER for it. + /// **Catches:** the refusal above widening into a blanket refusal - an off-by-one that would + /// blind every distributor whose funder takes the whole share, while the refusal test above + /// stayed green. Neither test alone shows the guard is selective. + #[test] + fn reward_distributor_methods_answer_at_the_ten_thousand_bps_boundary() { + let (node, _td) = test_node(None); + let launcher_id = [0x79u8; 32]; + let mut report = sample_distributor_report(0x79, vec![]); + // Stated as a LITERAL, not read from the dispatch module's constant: the bound is a + // contract figure (10,000 bps = 100%), so a change to that constant must fail here. + report.withdrawal_share_bps = 10_000; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("error").is_none(), + "{method}: a 100% share is legitimate and must be answered: {resp}" + ); + assert_eq!( + resp["result"]["withdrawal_share_bps"], + json!(10_000), + "{method}: the boundary value must be reported verbatim: {resp}" + ); + } + } + /// **Proves:** `entry_set_stale` is threaded through, both `true` and `false`, straight from /// the port's chain-derived figure — never hardcoded, never inverted — with BOTH cases served /// by ONE port installed ONCE on ONE node, answered by the REQUESTED launcher id. Each diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 9e1e20f9..d1b22fef 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -17,7 +17,7 @@ use serde_json::{json, Value}; -use crate::rewards::port::ChainPortError; +use crate::rewards::port::{ChainPortError, DistributorReport}; use crate::Node; // The relocated body below calls a number of crate-root private helpers (`rpc_err`, // `parse_store_id_arg`, `pin_request_root`, …) UNQUALIFIED, exactly as it did when it lived in @@ -82,6 +82,32 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value } } +/// The largest legitimate `withdrawal_share_bps`: 10,000 basis points IS 100%, so this is an +/// inclusive bound and `10_000` itself is a valid distributor constant, not an error. +const MAX_WITHDRAWAL_SHARE_BPS: u16 = 10_000; + +/// Refuses a report whose `withdrawal_share_bps` is outside the legitimate `0..=10_000` range +/// before any part of it reaches the wire (dig_ecosystem#3284). +/// +/// The port's own adapter is contracted to refuse this (`ChainPortError::InvalidWithdrawalShare`), +/// but that contract is enforced NOWHERE at this seam unless it is checked here: a chain constant +/// of `74_536` narrowed into the wire's `u16` renders as `9_000` — an ordinary-looking 90% share — +/// and `65_536` renders as `0`. Either is a figure a funder reads before deciding whether to claw +/// back, and neither looks wrong, so the handler cannot delegate the check to the thing it is +/// reading from. +/// +/// Refuses rather than CLAMPS deliberately. Clamping `74_536` to `10_000` would hand back a +/// confident "100% recoverable" for a distributor whose real constant is nonsense — a money lie +/// dressed as a safe default. Refusing the WHOLE call costs no good data either: +/// `withdrawal_share_bps` is curried once per distributor at launch, so every commitment slot in +/// one response shares the one invalid value and there is no honest row to keep. +fn range_checked_report(report: DistributorReport) -> Result { + if report.withdrawal_share_bps > MAX_WITHDRAWAL_SHARE_BPS { + return Err(ChainPortError::InvalidWithdrawalShare); + } + Ok(report) +} + /// Parses `params.launcher_id` (64-hex) into the port's own `[u8; 32]` shape /// (`rewards::port::Bytes32`) for `dig.getRewardDistributor` / /// `dig.listRewardDistributorCommitments`. @@ -921,7 +947,13 @@ impl RpcDispatch for Node { let Some(port) = node.reward_chain_port() else { return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); }; - let report = match port.distributor_report(launcher_id).await { + // Range-check at THIS seam, not only in the adapter: see + // `range_checked_report` for why an out-of-range share must refuse here. + let report = match port + .distributor_report(launcher_id) + .await + .and_then(range_checked_report) + { Ok(report) => report, Err(e) => return reward_chain_port_error_response(&id, &e), }; @@ -957,7 +989,13 @@ impl RpcDispatch for Node { let Some(port) = node.reward_chain_port() else { return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); }; - let report = match port.distributor_report(launcher_id).await { + // Range-check at THIS seam, not only in the adapter: see + // `range_checked_report` for why an out-of-range share must refuse here. + let report = match port + .distributor_report(launcher_id) + .await + .and_then(range_checked_report) + { Ok(report) => report, Err(e) => return reward_chain_port_error_response(&id, &e), }; From 0b07be693a2baa25c428b8516ca7a9155ae65030 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 01:17:39 -0700 Subject: [PATCH 08/19] test(probe): mutation probe -- swap two distributors' reports in the fake TEMPORARY, reverted in the next commit. Proves `commitment_money_figures_stay_attributed_to_their_own_distributor` is not vacuously green: with the two reports swapped behind their launcher ids the test must go RED against the green baseline at a77ed707. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 44 +++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 8307b9f1..454128d0 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -5429,9 +5429,20 @@ mod tests { /// ([`RESOURCE_UNAVAILABLE`] and [`RESOURCE_NOT_AVAILABLE`]) are correctly read as one condition /// under two names rather than as a collision. /// - /// Deliberately NOT exhaustive yet: the chat band (`-32050`..`-32052`) is undeclared upstream - /// entirely. That is pre-existing and out of this change; adding it is a follow-up that has to - /// resolve the condition, not the table. + /// Holds the numbers this crate emits that `dig_rpc_protocol::ErrorCode::ALL` does NOT declare, + /// plus the locally-named constants for numbers it DOES declare (so a local re-spelling of a + /// canonical condition cannot drift from the owner's name). What makes the table complete is + /// not this list: it is + /// [`every_wire_code_this_crate_mentions_is_classified`], which SCANS these sources and + /// requires every `-32xxx` it finds to be canonically declared or listed here. Forgetting to + /// register a number is therefore what fails, which is the whole point - the previous version + /// of this guard asserted `len() >= 10`, a measure of SIZE rather than completeness, and stayed + /// green at 1134/3402 tests while this crate emitted `-32033`, a number that was already + /// `dig-node-service`'s `ControlIngressLimited`. + /// + /// The chat band (`-32050`..`-32052`) is no longer a gap: `dig-rpc-protocol` 0.11 declares + /// `NoIdentity`/`NoPeerNetwork`/`SendFailed` for exactly those numbers, so the taxonomy answers + /// the collision question for them and the scan classifies them canonically. /// /// `content_serve::SERVE_UNREADABLE` used to be named here as a second `-32000` gap. It was not /// one: its code field's only sink answered `502` from the message and never read the number, so @@ -5456,6 +5467,19 @@ mod tests { (CONTROL_UNAUTHORIZED, "UNAUTHORIZED"), (CONTROL_NOT_SUPPORTED, "NOT_SUPPORTED"), (CONTROL_ERROR, "CONTROL_ERROR"), + // The two numbers below are written as LITERALS because each lives behind a constant in a + // private module (`seams::capsule::push_capsule`, `seams::dig_rpc::dispatch`) that this + // test module cannot name. Both are undeclared upstream, so the canonical leg has nothing + // to compare them against and the condition string exists only to make a local collision + // between them visible. + // + // `-32001`: the push surface's authorization refusal. `seams::dig_rpc::errors` deliberately + // emits it with NO `data.code` (an invented machine name is worse than an absent one), so + // this condition name is internal to this guard and is not a wire name. + (-32001, "PUSH_AUTHORITY_REFUSED (local, undeclared upstream)"), + // `-32002`: `ENGINE_WARMING` - the peer tier has genuinely not been consulted yet. Distinct + // from `-32004`, which means it WAS consulted and the content is still not found. + (-32002, "ENGINE_WARMING (local, undeclared upstream)"), ]; /// **Proves:** no number this node emits is already spoken for — neither by @@ -5482,9 +5506,13 @@ mod tests { fn no_local_wire_code_collides_with_a_different_canonical_code() { // Side effects first: a table that has silently shrunk to nothing, or lost the code under // review, would make every assertion below vacuously true. - assert!( - LOCAL_WIRE_CODES.len() >= 10, - "the local wire-code table lost entries; a shrinking table makes this guard vacuous" + // An EXACT count, not a floor: a floor cannot see a table that grew by an entry nobody + // checked, and `>= 10` is what let a real collision through. Changing this number is a + // deliberate act that says the table below was re-read. + assert_eq!( + LOCAL_WIRE_CODES.len(), + 12, + "the local wire-code table changed size; re-read it and update this count" ); // `CONTENT_MISS_INCONCLUSIVE` deliberately LEFT this table: `dig-rpc-protocol` 0.10 declares // it, so it is no longer a local number and the owner answers the collision question for it. @@ -10115,8 +10143,8 @@ mod tests { assert!( node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { reports: std::collections::HashMap::from([ - (launcher_a, Ok(report_a)), - (launcher_b, Ok(report_b)), + (launcher_a, Ok(report_b)), + (launcher_b, Ok(report_a)), ]), })) ); From a937f818bb94df3a7f630011151d05a494cabbb7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 01:18:30 -0700 Subject: [PATCH 09/19] Revert "test(probe): mutation probe -- swap two distributors' reports in the fake" Reverts 0b07be69. The probe commit was pushed onto a branch a SECOND writer is committing to concurrently (3dd1d67a, d5e15098 appeared under this worktree's HEAD mid-task), so the probe is withdrawn rather than left to redden that writer's CI. The tree is byte-identical to d5e15098. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 44 ++++++--------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 454128d0..8307b9f1 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -5429,20 +5429,9 @@ mod tests { /// ([`RESOURCE_UNAVAILABLE`] and [`RESOURCE_NOT_AVAILABLE`]) are correctly read as one condition /// under two names rather than as a collision. /// - /// Holds the numbers this crate emits that `dig_rpc_protocol::ErrorCode::ALL` does NOT declare, - /// plus the locally-named constants for numbers it DOES declare (so a local re-spelling of a - /// canonical condition cannot drift from the owner's name). What makes the table complete is - /// not this list: it is - /// [`every_wire_code_this_crate_mentions_is_classified`], which SCANS these sources and - /// requires every `-32xxx` it finds to be canonically declared or listed here. Forgetting to - /// register a number is therefore what fails, which is the whole point - the previous version - /// of this guard asserted `len() >= 10`, a measure of SIZE rather than completeness, and stayed - /// green at 1134/3402 tests while this crate emitted `-32033`, a number that was already - /// `dig-node-service`'s `ControlIngressLimited`. - /// - /// The chat band (`-32050`..`-32052`) is no longer a gap: `dig-rpc-protocol` 0.11 declares - /// `NoIdentity`/`NoPeerNetwork`/`SendFailed` for exactly those numbers, so the taxonomy answers - /// the collision question for them and the scan classifies them canonically. + /// Deliberately NOT exhaustive yet: the chat band (`-32050`..`-32052`) is undeclared upstream + /// entirely. That is pre-existing and out of this change; adding it is a follow-up that has to + /// resolve the condition, not the table. /// /// `content_serve::SERVE_UNREADABLE` used to be named here as a second `-32000` gap. It was not /// one: its code field's only sink answered `502` from the message and never read the number, so @@ -5467,19 +5456,6 @@ mod tests { (CONTROL_UNAUTHORIZED, "UNAUTHORIZED"), (CONTROL_NOT_SUPPORTED, "NOT_SUPPORTED"), (CONTROL_ERROR, "CONTROL_ERROR"), - // The two numbers below are written as LITERALS because each lives behind a constant in a - // private module (`seams::capsule::push_capsule`, `seams::dig_rpc::dispatch`) that this - // test module cannot name. Both are undeclared upstream, so the canonical leg has nothing - // to compare them against and the condition string exists only to make a local collision - // between them visible. - // - // `-32001`: the push surface's authorization refusal. `seams::dig_rpc::errors` deliberately - // emits it with NO `data.code` (an invented machine name is worse than an absent one), so - // this condition name is internal to this guard and is not a wire name. - (-32001, "PUSH_AUTHORITY_REFUSED (local, undeclared upstream)"), - // `-32002`: `ENGINE_WARMING` - the peer tier has genuinely not been consulted yet. Distinct - // from `-32004`, which means it WAS consulted and the content is still not found. - (-32002, "ENGINE_WARMING (local, undeclared upstream)"), ]; /// **Proves:** no number this node emits is already spoken for — neither by @@ -5506,13 +5482,9 @@ mod tests { fn no_local_wire_code_collides_with_a_different_canonical_code() { // Side effects first: a table that has silently shrunk to nothing, or lost the code under // review, would make every assertion below vacuously true. - // An EXACT count, not a floor: a floor cannot see a table that grew by an entry nobody - // checked, and `>= 10` is what let a real collision through. Changing this number is a - // deliberate act that says the table below was re-read. - assert_eq!( - LOCAL_WIRE_CODES.len(), - 12, - "the local wire-code table changed size; re-read it and update this count" + assert!( + LOCAL_WIRE_CODES.len() >= 10, + "the local wire-code table lost entries; a shrinking table makes this guard vacuous" ); // `CONTENT_MISS_INCONCLUSIVE` deliberately LEFT this table: `dig-rpc-protocol` 0.10 declares // it, so it is no longer a local number and the owner answers the collision question for it. @@ -10143,8 +10115,8 @@ mod tests { assert!( node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { reports: std::collections::HashMap::from([ - (launcher_a, Ok(report_b)), - (launcher_b, Ok(report_a)), + (launcher_a, Ok(report_a)), + (launcher_b, Ok(report_b)), ]), })) ); From 2f81a0e1d14b2c45325ec0739eccb3c835adc154 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 01:19:13 -0700 Subject: [PATCH 10/19] test(rpc): derive the wire-code registry from the sources, so forgetting one fails `no_local_wire_code_collides_with_a_different_canonical_code` passed at 1134/3402 while this crate really was emitting `-32033`, a number that is already `dig-node-service`'s `ControlIngressLimited`. It passed because the number was never added to `LOCAL_WIRE_CODES` - and the guard's own side-effect assertion was `len() >= 10`, which measures the table's SIZE, not its completeness. A code nobody registers is invisible to a guard built over the registry. Adds `every_wire_code_this_crate_mentions_is_classified`, which SCANS this crate's sources for every `-32xxx` in a non-comment line and requires each to be either declared by the taxonomy owner (resolved through `seams::dig_rpc::errors::taxonomy_code`, so the taxonomy is not restated) or registered in `LOCAL_WIRE_CODES`. The list is therefore derived from the emitting sites and cannot fall behind them: forgetting to register a number is what fails. Chose the derived form over a hand-maintained "everything we emit" list because such a list is the same defect one level up. Also: registers the two numbers this crate occupies that upstream does not declare (`-32001` push authorization, `-32002` `ENGINE_WARMING`), both previously unregistered; replaces the `len() >= 10` floor with an exact count; and drops the table doc's now-false claim that the `-32050..-32052` chat band is undeclared upstream - `dig-rpc-protocol` 0.11 declares all three. The test carries its own non-vacuity checks: a scanner-liveness floor plus three codes known to be present, and a proof that the classifier rejects the number that slipped through, built by arithmetic because writing it as a literal would itself be a mention the scan must classify. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 8307b9f1..07dffd9b 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -5527,6 +5527,124 @@ mod tests { } } + /// Every `-32xxx` wire number that appears anywhere in this crate's own sources, DISCOVERED by + /// reading them rather than by hand-listing them - the list is derived from the emitting sites, + /// so it cannot fall behind them. + /// + /// Comment-only lines are skipped: the docs in this crate legitimately DISCUSS numbers it does + /// not emit (another implementation's assignment, a rejected proposal), and requiring those to + /// be registered would push the guard towards being weakened rather than kept. + /// + /// A code mentioned in live code but never actually emitted - a test asserting one, say - is + /// still required to be classified. That is deliberate: classification is cheap, and the + /// alternative is teaching the scanner to recognise an "emitting site", which is exactly the + /// judgement call a forgotten registration hides behind. + fn wire_codes_mentioned_in_this_crate() -> std::collections::BTreeSet { + fn scan_line(line: &str, out: &mut std::collections::BTreeSet) { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") { + return; + } + // Scanned as BYTES, not by slicing the &str: these sources carry non-ASCII prose, and + // an arbitrary byte index into a `&str` is not guaranteed to be a char boundary. + let bytes = line.as_bytes(); + for start in 0..bytes.len().saturating_sub(5) { + if &bytes[start..start + 3] != b"-32" { + continue; + } + let digits = &bytes[start + 3..start + 6]; + // Exactly three digits: `-32000`..`-32999` is the band. A longer digit run is some + // other number that merely begins this way and is not a wire code. + if !digits.iter().all(u8::is_ascii_digit) + || bytes.get(start + 6).is_some_and(u8::is_ascii_digit) + { + continue; + } + let text = std::str::from_utf8(&bytes[start..start + 6]) + .expect("six ASCII bytes are valid UTF-8"); + if let Ok(code) = text.parse::() { + out.insert(code); + } + } + } + + fn walk(dir: &std::path::Path, out: &mut std::collections::BTreeSet) { + let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {path:?}: {e}")); + for line in text.lines() { + scan_line(line, out); + } + } + } + } + + let mut found = std::collections::BTreeSet::new(); + walk( + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")), + &mut found, + ); + found + } + + /// **Proves:** every wire number these sources contain is accounted for - either declared by + /// the taxonomy owner (`dig_rpc_protocol::ErrorCode::ALL`, resolved through + /// `seams::dig_rpc::errors::taxonomy_code` so the taxonomy is never restated here) or listed in + /// [`LOCAL_WIRE_CODES`] as a number this crate occupies that upstream does not declare. + /// + /// **Catches:** the defect that defeated the collision guard beside it - a number nobody + /// registered is invisible to a guard built over a registry. `-32033` was emitted by this crate + /// while already being `dig-node-service`'s `ControlIngressLimited`, and the old + /// `LOCAL_WIRE_CODES.len() >= 10` assertion stayed green because the number was never added. + /// Under THIS test it would have gone red without anyone remembering to register it. + #[test] + fn every_wire_code_this_crate_mentions_is_classified() { + let found = wire_codes_mentioned_in_this_crate(); + + // Scanner-liveness first: a walk that read nothing, or a comment filter that ate every + // line, would make the loop below vacuously true - the same failure mode being fixed here. + assert!( + found.len() >= 25, + "the source scan found only {} wire codes; it is not reading these sources", + found.len() + ); + for known in [CONTROL_ERROR, RESOURCE_NOT_AVAILABLE, -32602] { + assert!( + found.contains(&known), + "the scan missed {known}, which is emitted in these sources; it is not reading what it claims" + ); + } + + for number in &found { + let canonical = crate::seams::dig_rpc::errors::taxonomy_code(*number); + let local = LOCAL_WIRE_CODES.iter().find(|(n, _)| n == number); + assert!( + canonical.is_some() || local.is_some(), + "wire code {number} appears in this crate's sources but is neither declared by dig-rpc-protocol nor registered in LOCAL_WIRE_CODES - register it (with the condition it names) or emit a declared code instead" + ); + } + + // Non-vacuity, without writing the number as a literal: writing `-32033` here would itself + // be a mention this scan must classify, which is the mechanism having teeth. Built by + // arithmetic instead, it shows the classifier REJECTS the number that slipped through, so + // re-introducing it anywhere in these sources fails the loop above. + let slipped_through = CONTROL_ERROR - 1; + assert!( + crate::seams::dig_rpc::errors::taxonomy_code(slipped_through).is_none() + && !LOCAL_WIRE_CODES.iter().any(|(n, _)| *n == slipped_through), + "{slipped_through} must be unclassified, or this test cannot fail on it" + ); + assert!( + !found.contains(&slipped_through), + "{slipped_through} is back in these sources; it is already another surface's code" + ); + } + /// A per-THREAD counting allocator, installed process-wide only for the test binary. /// /// The #2160 acceptance bar is MEASURED, not reasoned: the peak-RSS test drives one cold decode From 18b8b4e1621ac1f6411ce79adf4aa464e6a8c6f2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 05:07:36 -0700 Subject: [PATCH 11/19] fix(rewards): make the seam's docs true, inject the chain port from the service crate Two repairs to the two commits before this one, then three doc-truth findings. Repairs: * the wire-code assertion message lost its line continuations, baking a 10+ space run into the string - the exact defect `continuation_guard` exists to catch, and the only red on the branch. Restated as one line, so there is no continuation to lose. The message a reader gets at the moment they are told a wire code is unregistered is now a clean sentence. * the out-of-range withdrawal-share test asserted the digits `10000` appear nowhere in the body, which the refusal MESSAGE legitimately contains ("must fit u16 and be <= 10000"). Matched in a JSON value position (`:10000`, `:10001`) instead, so the assertion is about the figure rather than the prose. Findings: * `install_reward_chain_port` is now `pub`. Its own doc said the installing startup path lives in `dig-node-service`, while `pub(crate)` meant that crate could not call it - both could not be true. The decided architecture builds the adapter in the service crate and injects it downward, so the setter is the injection point and says so, citing dig_ecosystem#3310 (which names both `distributor_report` and this function). The single-install `OnceLock` discipline is untouched: `set(port).is_ok()` stays, and a second install still returns `false` and changes nothing. The `allow(dead_code)` is dropped - it was hiding the unreachability, not standing in for an absent caller. * the two mutation-probe doc comments cited "the accompanying report", an artifact that does not exist in this repository. Each now states the mechanism and the expected outcome - which edit, which command, which assertion fails - so a reader holding only the repo can re-run it. * `rewards::port` asserted in the present tense that `dig-node-service` depends on `dig-rewards-coin` and computes `recoverable_base_units` with it. It does not: that manifest has no such dependency and no adapter exists. Rewritten in the future tense against #3310, and it now also names the seam-side range check so the two statements of that rule cannot be read as rivals. * three comments attributed the funder-registry installer to #3268, whose scope is the claim loop and `ClaimStatus` and which names neither the registry nor the call. They now say the owner is tracked separately and is not #3268; the installer work itself stays out of this change. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 64 ++++++++++++++++-------- crates/dig-node-core/src/rewards/port.rs | 25 +++++---- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 07dffd9b..4d77f55c 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -583,9 +583,11 @@ pub struct Node { /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is /// one: the FFI/browser path has no state directory and must keep constructing a `Node` /// without one. Nothing installs it in production yet — nothing in dig-node funds a - /// distributor today (`rewards::port`'s module doc, blocker 2), and the startup wiring that - /// would call [`Node::install_funded_distributor_registry`] with the node's state directory - /// belongs to dig_ecosystem#3268. Until then the slot stays empty, and + /// distributor today (`rewards::port`'s module doc, blocker 2). WHICH ticket owns the startup + /// wiring that would call [`Node::install_funded_distributor_registry`] with the node's state + /// directory is tracked separately, and it is NOT dig_ecosystem#3268, whose scope is the claim + /// loop and `ClaimStatus` and which names neither this registry nor that call. Until a ticket + /// wires it the slot stays empty, and /// [`Node::funded_distributors_read`] answers /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`] — UNKNOWN, deliberately never an /// empty funded set. @@ -640,9 +642,11 @@ impl Node { /// if a registry is already installed, in which case NOTHING changed — a second install must /// not be able to swap a live registry for an inert one behind a caller's back. /// - /// Called from tests today: the startup path that would install a real one lives in - /// dig_ecosystem#3268's files, so clippy's non-test lib target sees no production caller yet. - /// `allow(dead_code)` stands in for that missing caller — remove it when #3268 wires the call. + /// Called from tests today: no production startup path installs one, so clippy's non-test + /// lib target sees no production caller and `allow(dead_code)` stands in for it. Remove the + /// attribute when that wiring lands. Its owning ticket is tracked separately and is NOT + /// dig_ecosystem#3268 (claim loop + `ClaimStatus`), which names neither this registry nor this + /// call — do not read the attribute as a claim about #3268's scope. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn install_funded_distributor_registry( &self, @@ -681,12 +685,17 @@ impl Node { /// once. Returns `false` if one is already installed, in which case NOTHING changed — mirrors /// [`Node::install_funded_distributor_registry`]'s same one-shot discipline. /// - /// Called from tests today: the startup path that would install the real adapter lives in - /// `dig-node-service`, so clippy's non-test lib target sees no production caller yet. Which - /// ticket OWNS that adapter and this call site is an open question, tracked separately — it is - /// NOT dig_ecosystem#3268 (claim loop + `ClaimStatus`), which names neither. - #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn install_reward_chain_port( + /// `pub` because this is the INJECTION POINT, and the adapter is built one crate UP: + /// `dig-node-service` constructs it over `dig-rewards-coin` and injects it downward + /// (dig_ecosystem#3310, which names both `distributor_report` and this function). A + /// `pub(crate)` setter made that architecture unbuildable while its own doc described it, and + /// the `#[cfg_attr(not(test), allow(dead_code))]` that used to sit here was hiding the + /// unreachability rather than standing in for a merely-absent caller. + /// + /// Being callable from outside does NOT relax the single-install discipline: a second install + /// must not be able to swap a live adapter for an inert one behind a caller's back, so the + /// second call returns `false` and changes nothing. + pub fn install_reward_chain_port( &self, port: Arc, ) -> bool { @@ -5625,7 +5634,7 @@ mod tests { let local = LOCAL_WIRE_CODES.iter().find(|(n, _)| n == number); assert!( canonical.is_some() || local.is_some(), - "wire code {number} appears in this crate's sources but is neither declared by dig-rpc-protocol nor registered in LOCAL_WIRE_CODES - register it (with the condition it names) or emit a declared code instead" + "wire code {number} is in these sources but is neither declared by dig-rpc-protocol nor registered in LOCAL_WIRE_CODES" ); } @@ -6168,7 +6177,8 @@ mod tests { } /// dig_ecosystem#3285: a node with no funder registry installed — which is EVERY production - /// node until #3268 wires one — must read UNKNOWN, never an empty funded set. + /// node today, since no startup path installs one and the ticket that will is tracked + /// separately (it is not #3268) — must read UNKNOWN, never an empty funded set. /// **Catches:** a `funded_distributors_read` that defaults to `FundsNothing`, or a `Vec`/ /// `Option` return that a caller would render as `[]`. #[test] @@ -10019,10 +10029,13 @@ mod tests { /// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own /// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code /// — never a `0`, never an empty `commitments` list standing in for the refusal. - /// **Mutation-probe:** flipping `reward_chain_port_error_response`'s - /// `InvalidWithdrawalShare` arm to instead answer a `withdrawal_share_bps: 0` result turns this - /// RED (see the accompanying report for the before/after run) — proving the test is not - /// vacuously green under the defect it exists to catch. + /// **Mutation-probe, re-runnable from this repo alone:** in `seams::dig_rpc::dispatch`, + /// replace `reward_chain_port_error_response`'s `InvalidWithdrawalShare` arm with a `result` + /// carrying `withdrawal_share_bps: 0`, then run + /// `cargo test -p dig-node-core --lib reward_distributor_methods_`. This test fails at its + /// FIRST assertion, `resp.get("result").is_none()`, for `dig.getRewardDistributor`: a refusal + /// has become an answer. Restoring the arm returns it to green with the rest of the suite + /// untouched. That is the defect it exists to catch, so it is not vacuously green. #[test] fn reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share() { let (node, _td) = test_node(None); @@ -10100,13 +10113,16 @@ mod tests { json!("REWARD_INVALID_WITHDRAWAL_SHARE"), "{method}: {resp}" ); + // Matched in a JSON VALUE position (`:10001`), not anywhere in the body: the + // refusal MESSAGE legitimately names the bound it enforces, and asserting on the + // bare digits would fail on the honest text while saying nothing about the figure. let body = resp.to_string(); assert!( - !body.contains("10001"), + !body.contains(":10001"), "{method}: the out-of-range figure must not reach the wire: {body}" ); assert!( - !body.contains("10000"), + !body.contains(":10000"), "{method}: a clamp to 100% is a money lie, not a safe default: {body}" ); } @@ -10209,8 +10225,12 @@ mod tests { /// defect dig-app#403's rewards pane shipped (a per-distributor total silently summed or /// swapped). Reads BOTH distributors' `dig.listRewardDistributorCommitments` in the same test /// and asserts neither the summed nor the swapped figure appears in either response. - /// **Mutation-probe:** swapping the two `FakeRewardsChainPort` entries' `recoverable_base_units` - /// turns this RED (see the accompanying report). + /// **Mutation-probe, re-runnable from this repo alone:** swap the two + /// `FakeRewardsChainPort` entries' `recoverable_base_units` (give `slot_a` `slot_b`'s figure + /// and vice versa) and run `cargo test -p dig-node-core --lib commitment_money_figures`. This + /// test fails on the assertion that distributor A's response does not carry B's figure; + /// undoing the swap returns it to green. No other test notices the swap, which is why this + /// one exists. #[test] fn commitment_money_figures_stay_attributed_to_their_own_distributor() { let (node, _td) = test_node(None); diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index ff0676d9..80b44c76 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -147,18 +147,23 @@ pub enum ChainPortError { /// needs it. /// /// `recoverable_base_units` is the adapter's PRE-COMPUTED share — never restated by a caller of -/// this port, and never recomputed by `dig-node-core` itself. The production adapter lives in -/// `dig-node-service` (which ticket owns it is an open question tracked separately — not -/// dig_ecosystem#3268) and is the one crate in this seam that depends on -/// `dig-rewards-coin` (dig_ecosystem#3269 unit 0 removed that dependency from THIS crate -/// deliberately); it computes this figure with `dig_rewards_coin::recoverable_base_units` — that -/// crate's own tested, simulator-bound restatement of the puzzle's share arithmetic (u128 -/// intermediate, multiply-then-divide, truncated; see that function's doc for the equality proof -/// against `chia-sdk-driver`). If `withdrawal_share_bps` does not fit `u16` or exceeds `10_000`, -/// the adapter refuses the WHOLE [`RewardsChainPort::distributor_report`] call with +/// this port, and never recomputed by `dig-node-core` itself. NO production adapter exists yet, +/// and no crate in this seam depends on `dig-rewards-coin` today: the adapter that WILL compute +/// this figure is dig_ecosystem#3310's, in `dig-node-service` (that ticket names both +/// `distributor_report` and `Node::install_reward_chain_port` explicitly), and as of this writing +/// that crate's manifest declares no such dependency. When it lands it will be the one crate in +/// this seam that depends on `dig-rewards-coin` (dig_ecosystem#3269 unit 0 removed that dependency +/// from THIS crate deliberately), and it will compute this figure with +/// `dig_rewards_coin::recoverable_base_units` — that crate's own tested, simulator-bound +/// restatement of the puzzle's share arithmetic (u128 intermediate, multiply-then-divide, +/// truncated; see that function's doc for the equality proof against `chia-sdk-driver`). If +/// `withdrawal_share_bps` does not fit `u16` or exceeds `10_000`, that adapter must refuse the +/// WHOLE [`RewardsChainPort::distributor_report`] call with /// [`ChainPortError::InvalidWithdrawalShare`] instead of returning a `CommitmentSlot` with a /// wrong, zeroed or omitted `recoverable_base_units` — see that variant's doc for why a -/// per-distributor curried value makes a whole-call refusal the correct shape. +/// per-distributor curried value makes a whole-call refusal the correct shape. The same range is +/// ALSO enforced at the dispatch seam (`seams::dig_rpc::dispatch`'s `range_checked_report`, +/// dig_ecosystem#3284), so an adapter that forgets cannot put an out-of-range share on the wire. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitmentSlot { /// The distributor epoch this commitment slot funds. From 26f74dd81ead4b4e818eed8fd588d5edc458ca22 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 05:21:07 -0700 Subject: [PATCH 12/19] docs(rewards): the reader shipped and #3249 can never be the adapter -- repoint port.rs Finding E's defect class, in the one file of the three it was reported in that this lane owns. `rewards/port.rs`'s module doc was written against `dig-rewards-coin` 0.2.0 and still asserted, in the present tense, that the crate "ships no chain reader -- blocker 1". It does. Verified first-hand rather than from a report: `index.crates.io` lists 0.4.1 as the latest publish, and 0.4.0 in the local registry cache carries `state::read_distributor(&impl ChainSource, launcher_id) -> Result, RewardsError>` and `clawback::recoverable_base_units(rewards_base_units, withdrawal_share_bps) -> Option`, the latter refusing above `10_000` bps exactly as this seam's own range check does. No dependency is added: the crate stays absent from `dig-node-core`'s manifest, which is the injected-downward architecture, and the doc now says so as an instruction rather than as a pending chore. The two `UnavailableChainPort` citations deferred the production adapter to #3249, the `dig-rewards-coin` DRIVER ticket. That is a dead pointer, not a stale one: the driver does no socket I/O -- its reader takes a caller-supplied `ChainSource` -- so it can never be the adapter, and a blocker filed on a ticket that structurally cannot ship the change is never read. Repointed to #3310, which owns the `RewardsChainPort` adapter in `dig-node-service` and names both `distributor_report` and `install_reward_chain_port`; #3307's claim seam is a different trait and is not what these sentences are about. The correction is recorded in the doc rather than silently applied, because the next reader will otherwise re-derive the same wrong pointer from the ticket title. Two dependent claims corrected with it: the paragraph that said no adapter could answer any of the four trait methods "yet" now makes the narrower true claim (none can live in THIS crate, which holds no `ChainSource`), and the settlement condition that waited on "a reader (0.3.0+) and the funder-ownership registry" now records that the reader half is met and blocker 2 is not. Out of fence, reported upward rather than edited: the same dead #3249 pointer sits in `rewards/mod.rs:11,:16`, `rewards/spec_constants.rs:7` and `rewards/writes.rs:244`, and the first two also carry the SPEC-only claim about `dig-rewards-coin` that this commit retires. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/rewards/port.rs | 53 +++++++++++++++++------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 80b44c76..11ebaace 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -7,11 +7,27 @@ //! the production adapter reports [`ChainPortError::Unavailable`] and runs no cycles. See //! [`UnavailableChainPort`] for that adapter. //! -//! # dig_ecosystem#3269 unit 2 — the driver shipped, but still with no reader (blocking finding) +//! # The reader HAS shipped (corrected: the text here was written against 0.2.0) //! -//! `dig-rewards-coin` 0.2.0 is published and adds real types — `DistributorSnapshot` / -//! `DistributorSlots` (its `state` module) plus `clawback`, `comment`, `constants`, `eligibility`, -//! `entries`, `epoch`, `fund`, `launch`, `payout`. **It still ships no chain reader — blocker 1.** +//! `dig-rewards-coin` publishes **0.4.1** (latest on the crates.io index as this was written; 0.4.0 +//! read first-hand from the local registry cache, since this crate deliberately does not depend on +//! it) and it ships the chain reader the paragraph below said it withheld: +//! `state::read_distributor(&impl ChainSource, launcher_id) -> Result, +//! RewardsError>`, plus `clawback::recoverable_base_units(rewards_base_units, +//! withdrawal_share_bps) -> Option`, which refuses above `10_000` bps exactly as this seam's +//! own range check does. **Blocker 1 is CLOSED**, and what follows it described a crate two +//! releases old; it is kept only because the SHAPE argument it makes still holds. +//! +//! What dig-node still lacks is an ADAPTER, which is a different thing from a reader: +//! `read_distributor` takes a caller-supplied `ChainSource` and does no socket I/O of its own, so +//! something must hold the chain source, call the reader and map its answers onto this trait. That +//! is dig_ecosystem#3310's job, in `dig-node-service`, injected down through +//! [`crate::Node::install_reward_chain_port`]. It is NOT #3249, the driver ticket: a crate that +//! does no I/O can never be the adapter, so every "until #3249 lands" written about an adapter was +//! a pointer at a ticket that structurally cannot ship it — and a blocker filed on such a ticket +//! is never read. +//! +//! The historical 0.2.0 finding, for the reasoning it carries: //! 0.2.0's own `state.rs:1-31` module doc says so directly: SPEC §12.1's `read_distributor` "does not //! publish one, deliberately" — the implementation that existed applied //! `RewardDistributor::from_parent_spend` to the eve coin's spend (the launch inner puzzle) instead @@ -41,24 +57,24 @@ //! before any of `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` / //! `dig.listRewardDistributors`'s `funded` half can answer honestly. //! -//! So a "real" `RewardsChainPort` adapter over 0.2.0 cannot honestly answer ANY of the four trait -//! methods with live chain data yet: `funded_distributors` has no identity source, and -//! `distributor_state`/`submit_entry_writes`/`spend_new_epoch` all need the withheld reader (a spend -//! needs the live singleton coin `read_distributor` would supply). Writing one anyway — either by +//! So no adapter can honestly live HERE, which is a narrower claim than the one this paragraph used +//! to make: `funded_distributors` still has no identity source (blocker 2, below, and still true), +//! and the reader 0.4 does ship needs a `ChainSource` that `dig-node-core` deliberately does not +//! hold — wiring one up in this crate would re-add the `dig-rewards-coin` dependency unit 0 removed. Writing one anyway — either by //! reimplementing `read_distributor` myself or by inventing a funded-distributor registry with no //! writer — would be exactly the kind of restated, unreviewed money-shape work SPEC §0.1 clause 1 and //! this crate's own withholding of a broken reader argue against, and is the shape fork this ticket's //! kernel invariant 6 says to escalate rather than guess. Escalated to the L1, and settled: no new -//! adapter and no dispatch arm land until a reader (0.3.0+) and the funder-ownership registry both -//! exist. **`dig.listRewardDistributors` stays `-32601` deliberately** — serving it through +//! adapter and no dispatch arm land here until the funder-ownership registry exists and #3310's +//! adapter lands in `dig-node-service`. The reader half of that condition is now met. **`dig.listRewardDistributors` stays `-32601` deliberately** — serving it through //! `UnavailableChainPort` was considered and rejected: it would be a false capability signal (a //! feature-probe or `rpc.discover` reading the method as implemented when it always errors) and the //! exact "dispatch surface with no function behind it" pattern DIG-Network/dig-node#593 was the last //! PR allowed to land on. `UnavailableChainPort` remains the only production adapter for now — still //! correct, since every real call would fail for one of the two reasons above regardless. No //! `dig-rewards-coin` dependency is added by this unit: an unused dependency with no consumer is -//! inert weight and would want whichever version ships the reader (0.3.0+), not 0.2 — add it in the -//! unit that actually consumes it. +//! inert weight; the version it will want is whatever is current when #3310 adds it in +//! `dig-node-service`, the unit that actually consumes it. Do not add it here. use super::admission::AdmittedPeer; use async_trait::async_trait; @@ -255,13 +271,18 @@ pub trait RewardsChainPort: Send + Sync { ) -> Result; } -/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// The production adapter until dig_ecosystem#3310 lands: reports /// [`ChainPortError::Unavailable`] on every call and runs no cycles. /// /// This is the named state `ChainSourceUnavailable` (SPEC §2.3), not a silent no-op — a no-op that -/// reported progress would be the exact honesty violation §2.4 forbids. When #3249 ships, this -/// adapter is replaced with one that calls the real driver through this same trait; nothing above -/// this seam changes. +/// reported progress would be the exact honesty violation §2.4 forbids. #3310 replaces it with an +/// adapter built in `dig-node-service` over `dig-rewards-coin`'s reader and injected through +/// [`crate::Node::install_reward_chain_port`]; nothing above this seam changes. +/// +/// This used to cite #3249, the `dig-rewards-coin` DRIVER ticket. That was a dead pointer: the +/// driver crate does no socket I/O — `read_distributor` takes a caller-supplied `ChainSource` — so +/// it can never be this adapter, and #3310 is the ticket that owns it (it names both +/// `distributor_report` and the install call). pub struct UnavailableChainPort; #[async_trait] From 74ae5f94419a22b28aac855ef3c1abf6fa444629 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 11 Sep 2026 05:28:03 -0700 Subject: [PATCH 13/19] test(rpc): restore the wire-code registry hunks a sibling revert removed Salvage, not new work. The derived scan landed in 2f81a0e1, but the three hunks it depends on - the two unregistered codes it needs registered (`-32001` push authorization, `-32002` `ENGINE_WARMING`), the exact-count assertion replacing the `len() >= 10` floor, and the table doc - were swept into a sibling lane's probe commit (0b07be69) and removed again by its revert (a937f818) while this lane's edits were uncommitted. The scan was therefore correct and RED: `-32002` really was unclassified. Verified locally at this tree rather than from a CI log: `every_wire_code_this_crate_mentions_is_classified`, `no_local_wire_code_collides_with_a_different_canonical_code` and `no_lost_string_continuation_leaves_a_multi_space_run_mid_sentence` all pass, and `cargo fmt --check` is clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 40 ++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 4d77f55c..68e21a89 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -5438,9 +5438,18 @@ mod tests { /// ([`RESOURCE_UNAVAILABLE`] and [`RESOURCE_NOT_AVAILABLE`]) are correctly read as one condition /// under two names rather than as a collision. /// - /// Deliberately NOT exhaustive yet: the chat band (`-32050`..`-32052`) is undeclared upstream - /// entirely. That is pre-existing and out of this change; adding it is a follow-up that has to - /// resolve the condition, not the table. + /// Holds the numbers this crate emits that `dig_rpc_protocol::ErrorCode::ALL` does NOT declare, + /// plus the locally-named constants for numbers it DOES declare (so a local re-spelling of a + /// canonical condition cannot drift from the owner's name). What makes it COMPLETE is not this + /// list: it is [`every_wire_code_this_crate_mentions_is_classified`], which scans these sources + /// and requires every `-32xxx` it finds to be canonically declared or listed here. Forgetting to + /// register a number is therefore what fails - the previous version of this guard asserted + /// `len() >= 10`, a measure of SIZE rather than completeness, and stayed green at 1134/3402 + /// while this crate emitted `-32033`, already `dig-node-service`'s `ControlIngressLimited`. + /// + /// The chat band (`-32050`..`-32052`) is no longer a gap: `dig-rpc-protocol` 0.11 declares + /// `NoIdentity`/`NoPeerNetwork`/`SendFailed` for exactly those numbers, so the taxonomy answers + /// the collision question for them and the scan classifies them canonically. /// /// `content_serve::SERVE_UNREADABLE` used to be named here as a second `-32000` gap. It was not /// one: its code field's only sink answered `502` from the message and never read the number, so @@ -5465,6 +5474,21 @@ mod tests { (CONTROL_UNAUTHORIZED, "UNAUTHORIZED"), (CONTROL_NOT_SUPPORTED, "NOT_SUPPORTED"), (CONTROL_ERROR, "CONTROL_ERROR"), + // The two below are LITERALS because each lives behind a constant in a private module + // (`seams::capsule::push_capsule`, `seams::dig_rpc::dispatch`) this test module cannot + // name. Both are undeclared upstream, so the canonical leg has nothing to compare them + // against and the condition string exists only to make a local collision visible. + // + // `-32001`: the push surface's authorization refusal. `seams::dig_rpc::errors` deliberately + // emits it with NO `data.code` (an invented machine name is worse than an absent one), so + // this condition name is internal to this guard and is not a wire name. + ( + -32001, + "PUSH_AUTHORITY_REFUSED (local, undeclared upstream)", + ), + // `-32002`: `ENGINE_WARMING` - the peer tier has genuinely not been consulted yet. Distinct + // from `-32004`, which means it WAS consulted and the content is still not found. + (-32002, "ENGINE_WARMING (local, undeclared upstream)"), ]; /// **Proves:** no number this node emits is already spoken for — neither by @@ -5491,9 +5515,13 @@ mod tests { fn no_local_wire_code_collides_with_a_different_canonical_code() { // Side effects first: a table that has silently shrunk to nothing, or lost the code under // review, would make every assertion below vacuously true. - assert!( - LOCAL_WIRE_CODES.len() >= 10, - "the local wire-code table lost entries; a shrinking table makes this guard vacuous" + // An EXACT count, not a floor: a floor cannot see a table that grew by an entry nobody + // checked, and `>= 10` is what let a real collision through. Changing this number is a + // deliberate act that says the table below was re-read. + assert_eq!( + LOCAL_WIRE_CODES.len(), + 12, + "the local wire-code table changed size; re-read it and update this count" ); // `CONTENT_MISS_INCONCLUSIVE` deliberately LEFT this table: `dig-rpc-protocol` 0.10 declares // it, so it is no longer a local number and the owner answers the collision question for it. From d5d3ac52b5e4a73a87162eeebfc734b4290513ea Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 13 Sep 2026 01:08:31 -0700 Subject: [PATCH 14/19] docs(rewards): repoint dead #3249 citations and drop the stale SPEC-only claim The four cited sites pointed at #3249, the `dig-rewards-coin` DRIVER ticket. That crate does no socket I/O -- `state::read_distributor` takes a caller-supplied `&impl ChainSource` -- so it structurally cannot ever ship the chain adapter these comments were waiting on. Repointed by seam: - `writes.rs` and `mod.rs` (the `RewardsChainPort` seam) -> #3310, which names both `distributor_report` and `install_reward_chain_port`. - `mod.rs` also notes the claim-side chain adapter is tracked in #3307. `mod.rs` and `spec_constants.rs` additionally claimed the crate is "SPEC-only" with an empty `pub mod distributor {}`. False since 0.2.0 and three releases stale: 0.4.1 ships `state::read_distributor(&impl ChainSource, launcher_id) -> Result, RewardsError>`, `DistributorSnapshot`, `ChainObservation` and `clawback::recoverable_base_units`, and has no `distributor` module at all. Both now say what is true: the crate ships the reader and the share arithmetic; what dig-node lacks is the adapter wiring that reader to a `ChainSource`. The `spec_constants.rs` migration note no longer attributes the constants to #3249 either: 0.4.1's `constants` module carries distributor-side values only, not these prover-side ones, so no ticket is claimed to be about to publish them. Comments only -- no code, test or dependency change. Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/rewards/mod.rs | 15 +++++++++++---- .../dig-node-core/src/rewards/spec_constants.rs | 8 +++++--- crates/dig-node-core/src/rewards/writes.rs | 3 ++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs index 787d0c7e..3a372794 100644 --- a/crates/dig-node-core/src/rewards/mod.rs +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -8,13 +8,20 @@ //! ([`staleness`]). //! //! The chain seam ([`port`]'s `RewardsChainPort`) is UNIMPLEMENTED pending -//! DIG-Network/dig_ecosystem#3249 — `dig-rewards-coin` is SPEC-only today (its `distributor` -//! module is an empty placeholder). The production adapter wired into this crate is +//! DIG-Network/dig_ecosystem#3310. What is missing is an ADAPTER, not a reader: +//! `dig-rewards-coin` 0.4.1 already ships `state::read_distributor(&impl ChainSource, +//! launcher_id) -> Result, RewardsError>` plus `ChainObservation` and +//! `clawback::recoverable_base_units`, but that reader does no socket I/O of its own — it takes a +//! caller-supplied `ChainSource` — so something must hold the chain source, drive the reader and +//! map its answers onto this trait. That adapter is built in `dig-node-service` and injected down +//! through [`crate::Node::install_reward_chain_port`] (#3310); the claim-side chain adapter is +//! tracked separately in #3307. Until then the production adapter wired into this crate is //! `port::UnavailableChainPort`, which runs no cycles and reports //! `port::ChainPortError::Unavailable` rather than a silent no-op. Every value this engine //! compares against the SPEC's numeric bounds lives in [`spec_constants`], tagged with its -//! clause, so #3249 landing its own constants is a single, deliberate migration rather than a -//! scattered one. +//! clause, so should the crate ever publish these prover-side numbers itself — 0.4.1's +//! `constants` module carries distributor-side values only, not these — the migration is a +//! single, deliberate one rather than a scattered one. //! //! # The worst-case spend, stated where a human reads it //! diff --git a/crates/dig-node-core/src/rewards/spec_constants.rs b/crates/dig-node-core/src/rewards/spec_constants.rs index 69a60b3b..fe10513a 100644 --- a/crates/dig-node-core/src/rewards/spec_constants.rs +++ b/crates/dig-node-core/src/rewards/spec_constants.rs @@ -3,9 +3,11 @@ //! # Byte-identical contract //! //! Every value below is copied verbatim from the normative spec, each tagged with the clause it -//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` is -//! still SPEC-only (`pub mod distributor {}`, DIG-Network/dig_ecosystem#3249): the moment #3249 -//! lands and publishes these as its own constants, this file MUST be deleted and every reference +//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` does +//! not publish these prover-side numbers: 0.4.1 ships the reader (`state::read_distributor`) and +//! the share arithmetic (`clawback::recoverable_base_units`), and its `constants` module carries +//! distributor-side values only. Should the crate ever publish these as its own constants, this +//! file MUST be deleted and every reference //! MUST move to `dig_rewards_coin::*`. That migration is the parent's call, not this lane's — do //! not relitigate it here and do not let a second copy of any of these numbers exist anywhere else //! in this crate. diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index 7f5874eb..89bfcd16 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -241,7 +241,8 @@ pub trait WriteBoundStore: Send + Sync { /// The fail-closed default until a real backend is wired: every call errors, so /// [`PersistedEntryWriter::decide`] refuses to submit anything rather than run the write bounds /// unbounded across a restart. This is deliberately the production default TODAY — the chain port -/// itself is `UnavailableChainPort` until #3249 lands, so this adapter costs nothing operationally +/// itself is `UnavailableChainPort` until #3310 lands the `RewardsChainPort` adapter (it names +/// both `distributor_report` and `install_reward_chain_port`), so this adapter costs nothing operationally /// yet and closes the money hole the moment either seam is wired. pub struct NoPersistence; From 4bfc9a75ea357f644751dac5674967e2da616201 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 13 Sep 2026 11:06:44 -0700 Subject: [PATCH 15/19] chore(deps): consume the dig-rpc-protocol 0.12 cascade (#3269) Bumps dig-rpc-protocol 0.11.0 -> 0.12, dig-download 0.23 -> 0.24, dig-peer-selector 0.12 -> 0.13, and dig-peer 0.14 -> 0.15 (both the direct and transitive pins) in dig-node-core, and updates dependency_tree.rs's version-literal assertion to match. cargo tree -i confirms exactly one of each resolves. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 31 +++++++++++++------ crates/dig-node-core/Cargo.toml | 26 +++++++++++++--- crates/dig-node-core/tests/dependency_tree.rs | 19 ++++++------ 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8839fe2..6fcf8000 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,16 +2689,16 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9a6e23899a1a58ff8f070307897799b0142b3d9447652676ffae7c131da7c" +checksum = "d705dda562d63c03a1a481087c3b9f632b90982676ea8bb4e55f2cd04c4465fc" dependencies = [ "async-trait", "dig-constants 0.11.2", "dig-dht", "dig-nat", "dig-peer", - "dig-rpc-protocol", + "dig-rpc-protocol 0.12.0", "futures", "serde", "serde_json", @@ -3002,7 +3002,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rpc-protocol", + "dig-rpc-protocol 0.12.0", "dig-sex", "dig-social-profile", "dig-store-cache", @@ -3064,7 +3064,7 @@ dependencies = [ "dig-node-control-interface", "dig-node-core", "dig-node-service", - "dig-rpc-protocol", + "dig-rpc-protocol 0.11.0", "dig-stun", "dig-urn-resolver", "dig-wallet", @@ -3145,15 +3145,15 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6d28173f5ac2fb725d70d81491918bb9dbdc1691745bf044524aee8484333ef" +checksum = "01657d5ef42a4ebf038d53b3b398997057417558cd6c059f4f58716e68676f34" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", "dig-message", "dig-nat", - "dig-rpc-protocol", + "dig-rpc-protocol 0.12.0", "dig-tls", "serde", "serde_json", @@ -3185,9 +3185,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac1005c43d63ca61d3ca6391cf6d3ff08b7138bb22e237ae76c5157d7677652" +checksum = "a3be02a4acba35b9580f3655c95cf4e127031e100e684069a17fb2829bb4e68b" dependencies = [ "dig-dht", "dig-nat", @@ -3220,6 +3220,17 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "dig-rpc-protocol" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb22a4741239303c9558b00d20c0cb6d9afad965382656bece3d4bbe9641f24" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "dig-runtime" version = "0.4.0" diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index e4ad1616..656af50a 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -191,7 +191,14 @@ serde_json = "1" # per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). -dig-rpc-protocol = "0.11.0" +# +# Moved to 0.12 (dig_ecosystem#3269, final leg): 0.12.0 is the release this node adopts the reward +# RPC surface from (`Method::ListRewardDistributors`, `Method::GetPayeeRewardClaimStatus`, and the +# `GetRewardProverStatusResult.statuses` shape change to `Half`). `dig-peer` +# (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) below all moved onto this line +# in the same batch, so exactly one `dig-rpc-protocol` still resolves (asserted by +# `crates/dig-node-core/tests/dependency_tree.rs`). +dig-rpc-protocol = "0.12" # The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope # the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 # envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. @@ -461,7 +468,10 @@ dig-pex = "0.1.1" # Moved to 0.23 (dig_ecosystem#3269): 0.23.0 is the release that re-exports `dig-rpc-protocol` 0.11's # `ModuleInfo`, closing the two-shapes split this crate's own `dig-rpc-protocol = "0.11.0"` line above # opened against dig-download's prior 0.22-line dependency on `dig-rpc-protocol` 0.10.3. -dig-download = "0.23" +# +# Moved to 0.24 (dig_ecosystem#3269, final leg): 0.24.0 is on `dig-rpc-protocol` 0.12, matching this +# crate's own move to 0.12 above. +dig-download = "0.24" # -- The shared peer client (#1283/#1576) ------------------------------------------------------------- # `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed # RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the @@ -476,7 +486,10 @@ dig-download = "0.23" # # Moved to 0.14 (dig_ecosystem#3269), alongside dig-download's move to 0.23 above, for the same # reason: 0.14.0 is on `dig-rpc-protocol` 0.11, keeping exactly one version resolving. -dig-peer = "0.14" +# +# Moved to 0.15 (dig_ecosystem#3269, final leg): 0.15.0 is on `dig-rpc-protocol` 0.12, matching this +# crate's own move to 0.12 above. +dig-peer = "0.15" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation @@ -509,7 +522,10 @@ dig-peer = "0.14" # 0.23.0, dig-peer-selector 0.12.0). Every prior `dig-peer-selector` release — through 0.11.1 — # stayed on `dig-peer ^0.13`, which is what pinned this crate's `dig-peer` line above at 0.13 and # kept two `dig-rpc-protocol` versions resolving simultaneously. -dig-peer-selector = "0.12" +# +# Moved to 0.13 (dig_ecosystem#3269, final leg): 0.13.0 is on `dig-peer ^0.15`, matching this crate's +# own move to `dig-peer = "0.15"` above and closing the cascade at `dig-rpc-protocol` 0.12. +dig-peer-selector = "0.13" # The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity # is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in # the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the @@ -593,7 +609,7 @@ rcgen = "0.13" # # Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which # fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.23", features = ["testkit"] } +dig-download = { version = "0.24", features = ["testkit"] } # Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the # serve-observability tests (#1595) assert what an operator would actually see in the node log — # and that no payload byte or proof ever reaches it. diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 54b40044..2665d69f 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,11 +96,12 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.11 line that +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.12 line that /// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`), the /// recursive-ask contract this node adopted (`GetAvailabilityParams::budget_ms` / `::ask_id`, /// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`), AND (#3269) the -/// reward RPC surface (`Method::GetRewardProverStatus` et al., all `Tier::Control`). +/// reward RPC surface (`Method::GetRewardProverStatus`, `Method::ListRewardDistributors`, +/// `Method::GetPayeeRewardClaimStatus`, all `Tier::Control`). /// /// **Catches:** the obligation-8 skew directly. Before the #1576 cascade, dig-download consumed /// dig-rpc-protocol 0.5 while dig-peer 0.4 pulled 0.3.1, so a tree containing both held TWO `ModuleInfo` @@ -109,11 +110,11 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// is the point: a consumer's own lock can pin an old patch even when every caret dep and every /// higher-layer bump looks correct. /// -/// **Cascade closed (#3269):** `dig-node-core` depends on 0.11.0 directly; `dig-peer` (0.14.0), -/// `dig-download` (0.23.0) and `dig-peer-selector` (0.12.0) all now resolve `dig-rpc-protocol` -/// 0.11 too, so `cargo metadata` resolves exactly one line. This assertion is deliberately left at -/// exactly-one/0.11 (never widened to accept a set — see #836/#1576); if a future dependency bump -/// reopens the split, this test goes red again on purpose. +/// **Cascade closed (#3269, final leg):** `dig-node-core` depends on 0.12 directly; `dig-peer` +/// (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) all now resolve +/// `dig-rpc-protocol` 0.12 too, so `cargo metadata` resolves exactly one line. This assertion is +/// deliberately left at exactly-one/0.12 (never widened to accept a set — see #836/#1576); if a +/// future dependency bump reopens the split, this test goes red again on purpose. #[test] fn the_workspace_carries_exactly_one_module_wire_crate() { let versions = locked_versions("dig-rpc-protocol"); @@ -124,9 +125,9 @@ fn the_workspace_carries_exactly_one_module_wire_crate() { majors means two `ModuleInfo` shapes across the module pull's trust boundary" ); assert!( - versions[0].starts_with("0.11."), + versions[0].starts_with("0.12."), "the availability contract plus the #3269 reward RPC surface this node adopted ship in \ - dig-rpc-protocol 0.11; the workspace resolved {} — on an earlier line the canonical items \ + dig-rpc-protocol 0.12; the workspace resolved {} — on an earlier line the canonical items \ simply do not exist and this node would be back to declaring its own", versions[0] ); From 538169ed1675933504fd0b3164ab2a23b794e603 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 13 Sep 2026 12:34:55 -0700 Subject: [PATCH 16/19] feat(rpc): Half migration, listRewardDistributors, getPayeeRewardClaimStatus, zero-id refusal Unit 1b: migrates GetRewardProverStatusResult.statuses to Half (dig-rpc-protocol 0.12.0 breaking change), fixing the six existing tests that asserted the old bare-Vec wire shape. Unit 2: implements dig.listRewardDistributors, returning ListRewardDistributorsResult { funded, claimable } as two independently-consulted Half halves. funded is read from the local FundedDistributorRegistry (exhaustive match, no wildcard) and each identity's (store_id, root) resolved through RewardsChainPort::distributor_report; a per-item chain failure refuses the whole call rather than emit a partial/fabricated ref. claimable is honestly NotConsulted -- no such tracking exists in this crate yet. Unit 3: adds dig.getPayeeRewardClaimStatus, dispatched through the Method::from_name match (never the string pre-match, which would bypass the tier guard -- #3261). Tier::Control, not peer-reachable. subject is always the literal PayeeSubject::Payee; claim_log is NotConsulted (no claim log exists in this crate; the adapter is #3310's). No monetary amount or payout puzzle hash anywhere in the body. Unit 5: audited every touched/added reward handler for rendering a zero launcher_id/store_id as though real. Found range_checked_report enforced only the withdrawal_share_bps range, not identity zero-checks, for GetRewardDistributor, ListRewardDistributorCommitments and the new ListRewardDistributors -- inconsistent with GetRewardProverStatus's existing zeroed-identity guard. Added ChainPortError::ZeroIdentity and a range_checked_report check that refuses the whole call on a zeroed launcher_id or store_id (dig-rewards-coin@v0.4.0 refuses to create a distributor with one, per #3308/#3309), with a regression test proving all three handlers refuse rather than render. reward_methods_tier_guard.rs and peer.rs's sibling count guard bumped 4 -> 5 for the two new Reward-named methods. Every new/migrated method is proven through the real handle_rpc -> handle_rpc_as -> RpcDispatch::dispatch path, asserting the serialized JSON body. Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 311 +++++++++++++++++- crates/dig-node-core/src/peer.rs | 13 +- crates/dig-node-core/src/rewards/port.rs | 9 + .../src/seams/dig_rpc/dispatch.rs | 142 +++++++- .../tests/reward_methods_tier_guard.rs | 11 +- 5 files changed, 467 insertions(+), 19 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 68e21a89..db045443 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9427,9 +9427,14 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); - let statuses = resp["result"]["statuses"] + assert_eq!( + resp["result"]["statuses"]["outcome"], + json!("consulted"), + "the in-process registry read always succeeds: {resp}" + ); + let statuses = resp["result"]["statuses"]["items"] .as_array() - .expect("result.statuses is an array"); + .expect("result.statuses.items is an array"); assert_eq!(statuses.len(), 1, "one registered handle: {resp}"); let s = &statuses[0]; @@ -9502,9 +9507,10 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); + assert_eq!(resp["result"]["statuses"]["outcome"], json!("consulted")); assert_eq!( - resp["result"], - json!({"statuses": []}), + resp["result"]["statuses"]["items"], + json!([]), "explicit empty list: {resp}" ); } @@ -9585,9 +9591,9 @@ mod tests { crate::download::RequestProvenance::FirstParty, ))); - let statuses = resp["result"]["statuses"] + let statuses = resp["result"]["statuses"]["items"] .as_array() - .expect("result.statuses is an array"); + .expect("result.statuses.items is an array"); assert_eq!( statuses.len(), 2, @@ -9691,7 +9697,7 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - let filtered = filtered["result"]["statuses"].as_array().unwrap(); + let filtered = filtered["result"]["statuses"]["items"].as_array().unwrap(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0]["launcher_id"], json!(hex::encode(a))); @@ -9701,7 +9707,10 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); + assert_eq!( + all["result"]["statuses"]["items"].as_array().unwrap().len(), + 2 + ); } /// An in-memory `RewardsChainPort` for `dig.getRewardDistributor` / @@ -10014,6 +10023,290 @@ mod tests { assert_eq!(commitments[0]["recoverable_base_units"], json!(900)); } + /// **Proves:** `dig.listRewardDistributors` is CONTROL-tier and NOT peer-reachable, and is + /// dispatched through the `Method` enum match rather than the pre-`Method::from_name` string + /// block (dig_ecosystem#3269 unit 2; #3261's money-hole rule). + #[test] + fn list_reward_distributors_is_control_tier_and_not_peer_reachable() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.listRewardDistributors"), + Some(Method::ListRewardDistributors) + ); + assert_eq!( + Method::ListRewardDistributors.tier(), + dig_rpc_protocol::Tier::Control + ); + assert!(!peer::is_peer_reachable_method( + "dig.listRewardDistributors" + )); + } + + /// **Proves:** with no funder registry installed, `dig.listRewardDistributors` answers + /// `NotConsulted` for BOTH halves — never `Consulted { items: [] }`, which would claim this + /// node checked and found nothing (SPEC §12.5 clause 6's "reassuring zero"). + /// **Catches:** a handler defaulting an unconfigured registry to an empty, "consulted" list. + #[test] + fn list_reward_distributors_with_no_registry_is_not_consulted_on_both_halves() { + let (node, _td) = test_node(None); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + resp["result"]["funded"]["outcome"], + json!("not_consulted"), + "unconfigured registry must read UNKNOWN, never an empty funded set: {resp}" + ); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted"), + "no claimable tracking exists in this crate yet — must never fabricate a checked zero: {resp}" + ); + } + + /// **Proves:** a registry that genuinely funds nothing (`FundsNothing`, the one legitimate + /// empty case) renders `funded` as `Consulted { items: [] }` — a real, checked empty list, not + /// `NotConsulted` — while `claimable` still reads `NotConsulted` since nothing here tracks it. + #[test] + fn list_reward_distributors_with_a_genuinely_empty_registry_is_consulted_empty() { + let state_dir = tempfile::tempdir().unwrap(); + // An intact record naming nobody is the ONE legitimate empty answer (`FundsNothing`) — + // distinct from no record ever written at all, which reads `NotConfigured`. + std::fs::write( + state_dir + .path() + .join(crate::rewards::funded::FUNDED_DISTRIBUTORS_FILE), + r#"{"version": 1, "distributors": []}"#, + ) + .unwrap(); + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + assert_eq!(resp["result"]["funded"]["items"], json!([])); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + + /// **Proves:** a funded identity resolved through the chain port renders as a real + /// `RewardDistributorRef` inside `funded.items`, through the REAL dispatch path. + /// **Catches:** a handler that fabricates `store_id`/`root` instead of resolving them via + /// `RewardsChainPort::distributor_report`. + #[test] + fn list_reward_distributors_resolves_a_funded_identity_through_the_chain_port() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x55u8; 32], + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let report = sample_distributor_report(0x55, vec![]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([([0x55u8; 32], Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + let items = resp["result"]["funded"]["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!( + items[0]["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!(items[0]["store_id"], json!(hex::encode(report.store_id))); + assert_eq!(items[0]["root"], json!(hex::encode(report.root))); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + + /// **Proves:** a funded identity whose per-item chain report fails refuses the WHOLE call + /// (the same `ChainPortError` response the sibling reward-distributor handlers use), rather + /// than emitting a partial list or a fabricated ref (dig_ecosystem#3308/#3309). + #[test] + fn list_reward_distributors_refuses_the_whole_call_on_a_chain_report_failure() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x66u8; 32], + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::new(), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "a per-item chain failure must refuse the whole call, not answer a partial result: {resp}" + ); + assert!( + resp.get("error").is_some(), + "expected an error response: {resp}" + ); + } + + /// **Proves:** `dig.getPayeeRewardClaimStatus` is CONTROL-tier, NOT peer-reachable, dispatched + /// through the `Method` enum match, and its exact serialized JSON body: `subject` is the + /// literal `"payee"`, `claim_log` is `NotConsulted` (no claim log exists in this crate yet), + /// and there is never a monetary amount or payout puzzle hash anywhere in the body. + #[test] + fn get_payee_reward_claim_status_answers_the_exact_wire_shape() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.getPayeeRewardClaimStatus"), + Some(Method::GetPayeeRewardClaimStatus) + ); + assert_eq!( + Method::GetPayeeRewardClaimStatus.tier(), + dig_rpc_protocol::Tier::Control + ); + assert!(!peer::is_peer_reachable_method( + "dig.getPayeeRewardClaimStatus" + )); + + let (node, _td) = test_node(None); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getPayeeRewardClaimStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from(["subject", "claim_log"]), + "no monetary amount, no payout puzzle hash — ever: {resp}" + ); + assert_eq!(result["subject"], json!("payee")); + assert_eq!(result["claim_log"]["outcome"], json!("not_consulted")); + assert!( + result["claim_log"].get("claims_submitted_count").is_none(), + "claims_submitted_count must live INSIDE Consulted only, never beside NotConsulted: {resp}" + ); + } + + /// **Proves:** dig_ecosystem#3269 unit 5 — a chain-derived report with a ZEROED `launcher_id` + /// (never a real distributor's identity, only what an uninitialised slot hex-encodes to, and + /// `dig-rewards-coin@v0.4.0` refuses to create one this way — #3308/#3309) refuses the WHOLE + /// call for every reward-distributor read method, rather than rendering the zero as though it + /// were real. + /// **Catches:** a handler that hex-encodes whatever the port returns with no identity check. + #[test] + fn reward_distributor_methods_refuse_a_zeroed_identity_rather_than_render_it() { + let launcher_id = [0u8; 32]; + let report = sample_distributor_report(0, vec![]); + assert_eq!( + report.launcher_id, [0u8; 32], + "fixture premise: seed 0 zeroes launcher_id" + ); + + let (node, _td) = test_node(None); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method} must refuse a zeroed identity, not render it: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "{method}: {resp}" + ); + } + + // Same guard for the new list method, which resolves identity through the SAME + // `distributor_report` + `range_checked_report` path. + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id, + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + assert!(node.install_funded_distributor_registry(registry)); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "dig.listRewardDistributors must refuse a zeroed identity, not render it: {resp}" + ); + assert_eq!(resp["error"]["data"]["code"], json!("REWARD_ZERO_IDENTITY")); + } + /// **Proves:** with no chain-read adapter installed, BOTH reward-distributor methods answer a /// distinct error — never a zero, never an empty list — and it is NOT the same machine code as /// the `withdrawal_share_bps` refusal (so a caller can tell "try again later" apart from "this @@ -10355,7 +10648,7 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - let statuses = resp["result"]["statuses"].as_array().unwrap(); + let statuses = resp["result"]["statuses"]["items"].as_array().unwrap(); assert_eq!(statuses.len(), 2); let find = |launcher_id: [u8; 32]| { diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 7feeca67..31fb22cd 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5653,17 +5653,20 @@ pub(crate) mod tests { "expected at least one Reward-named method in Method::ALL; found none" ); // dig_ecosystem#3269 unit 3: a non-empty check alone would still pass if the catalogue - // grew a fifth reward method that the filter silently stopped matching (or a variant were + // grew a sixth reward method that the filter silently stopped matching (or a variant were // renamed out from under `.contains("Reward")`) — this pins the count so the guard cannot // start policing FEWER methods than actually exist without failing loudly. Update this // number, deliberately, the moment `dig-rpc-protocol` adds or removes a reward method. + // + // Bumped 4 -> 5 (dig_ecosystem#3269, final leg): dig-rpc-protocol 0.12.0 added + // `dig.getPayeeRewardClaimStatus`, whose name also contains "Reward". assert_eq!( reward_methods.len(), - 4, - "expected exactly 4 Reward-named methods in Method::ALL (dig.listRewardDistributors, \ + 5, + "expected exactly 5 Reward-named methods in Method::ALL (dig.listRewardDistributors, \ dig.getRewardProverStatus, dig.getRewardDistributor, \ - dig.listRewardDistributorCommitments); got {}: a catalogue change must update this \ - guard deliberately, not silently narrow it", + dig.listRewardDistributorCommitments, dig.getPayeeRewardClaimStatus); got {}: a \ + catalogue change must update this guard deliberately, not silently narrow it", reward_methods.len() ); for m in reward_methods { diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 11ebaace..3d888b3f 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -153,6 +153,15 @@ pub enum ChainPortError { /// whole call here therefore blinds zero good rows and needs no wire change — see the module /// doc on [`DistributorReport`]. InvalidWithdrawalShare, + /// dig_ecosystem#3269 unit 5: the report's `launcher_id` or `store_id` is the all-zero + /// `Bytes32` — never a real distributor's or module's identity, only what an + /// uninitialised/never-assigned slot hex-encodes to. `dig-rewards-coin@v0.4.0` itself refuses + /// to create a distributor with a zero identity (#3308/#3309), so a chain-derived report + /// naming one is not a legitimate "young distributor" case the way a zeroed `root` alone can + /// be — it is a malformed answer, and every reward-distributor read handler (`GetRewardDistributor`, + /// `ListRewardDistributorCommitments`, `ListRewardDistributors`) refuses the whole call rather + /// than render it, matching the zeroed-identity guard `GetRewardProverStatus` already applies. + ZeroIdentity, /// A chain answered but the call failed for a reason worth a message (bounded before logging — /// SPEC §3.7 clause 4 applies to every attacker-adjacent string, and a chain error is not /// exempt). diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index d1b22fef..78548869 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -58,6 +58,11 @@ const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; /// unrelated ingress refusal. const REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE: &str = "REWARD_INVALID_WITHDRAWAL_SHARE"; +/// dig_ecosystem#3269 unit 5: the machine code for [`ChainPortError::ZeroIdentity`] — a +/// zeroed `launcher_id`/`store_id` refused rather than rendered, matching +/// `REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`'s sibling shape. +const REWARD_ZERO_IDENTITY_MACHINE: &str = "REWARD_ZERO_IDENTITY"; + /// Maps a [`ChainPortError`] to the JSON-RPC error response for both reward-distributor read /// methods (dig_ecosystem#3269 unit 2) — one mapping so `dig.getRewardDistributor` and /// `dig.listRewardDistributorCommitments` can never disagree about how a given port failure reads @@ -74,6 +79,11 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", "data": { "code": REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE, "origin": "control" } }}), + ChainPortError::ZeroIdentity => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "distributor report carries a zeroed launcher_id or store_id, which is never a real identity", + "data": { "code": REWARD_ZERO_IDENTITY_MACHINE, "origin": "control" } + }}), ChainPortError::Other(msg) => json!({"jsonrpc":"2.0","id":id,"error":{ "code": CONTROL_ERROR, "message": format!("reward-distributor chain read failed: {msg}"), @@ -105,6 +115,11 @@ fn range_checked_report(report: DistributorReport) -> Result MAX_WITHDRAWAL_SHARE_BPS { return Err(ChainPortError::InvalidWithdrawalShare); } + // dig_ecosystem#3269 unit 5: a zeroed `launcher_id` or `store_id` is never a real distributor's + // or module's identity (see `ChainPortError::ZeroIdentity`'s doc) — refuse rather than render. + if report.launcher_id == [0u8; 32] || report.store_id == [0u8; 32] { + return Err(ChainPortError::ZeroIdentity); + } Ok(report) } @@ -929,7 +944,18 @@ impl RpcDispatch for Node { }) .map(reward_prover_status_to_wire) .collect(); - let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; + // dig-rpc-protocol 0.12.0 migration: `statuses` moved from a bare `Vec` to + // `Half` (SPEC §12.5 clause 6's "reassuring zero" rule). + // This registry read never fails — it is an in-process `RwLock` read, not a + // fallible chain or disk read — so it is always `Consulted`, dated at the + // moment this response was assembled. + use crate::rewards::state::Clock as _; + let result = dig_rpc_protocol::types::GetRewardProverStatusResult { + statuses: dig_rpc_protocol::types::Half::Consulted { + observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + items: statuses, + }, + }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL @@ -1018,6 +1044,120 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } + // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — CONTROL plane, + // same guard shape as the other reward handlers above. Two independently-consulted + // halves (`funded` / `claimable`), each a `Half` — SPEC §12.5 + // clause 6's "reassuring zero" rule applies to EACH half separately. + // + // `funded`: this node's OWN identity registry + // (`rewards::funded::FundedDistributorRegistry`, dig_ecosystem#3285) says WHICH + // launcher ids this node funds; `Node::funded_distributors_read` is matched with no + // wildcard arm so a future variant added to `FundedDistributorsRead` fails this match + // at compile time instead of silently falling into the wrong half. Once the identity + // set answers, each launcher id's CURRENT `(store_id, root)` is resolved through the + // same chain-read port `dig.getRewardDistributor` uses + // (`RewardsChainPort::distributor_report`) — `FundedDistributor` carries identity + // only (see that module's doc), never a root, so a ref cannot be assembled from the + // registry alone. A per-item chain-report failure refuses the WHOLE call with the + // same `ChainPortError` response the sibling handlers use, rather than emitting a + // partial list or a fabricated store_id/root (dig_ecosystem#3308/#3309: a zero hash + // rendered as though real is the same defect family as an empty list standing in for + // an error). + // + // `claimable`: distributors this node holds a MIRROR claim to but does not fund. No + // such tracking exists anywhere in this crate today (see the module search backing + // this comment — dig_ecosystem#3269 unit 2 wires only the funder side), so this half + // is honestly `NotConsulted`: nothing looked, because nothing here can look yet. It + // is NOT `Consulted { items: [] }` — that would claim this node checked and found no + // claimable distributor, which is not true; the truth is no check exists. + Some(Method::ListRewardDistributors) => { + use crate::rewards::state::Clock as _; + let now = crate::rewards::state::SystemClock.now_unix_seconds(); + + let identities: Vec = + match node.funded_distributors_read() { + crate::rewards::funded::FundedDistributorsRead::Funded(v) => v, + crate::rewards::funded::FundedDistributorsRead::FundsNothing => Vec::new(), + crate::rewards::funded::FundedDistributorsRead::NotConfigured(_) + | crate::rewards::funded::FundedDistributorsRead::PersistedStateCorrupt { + .. + } + | crate::rewards::funded::FundedDistributorsRead::IoFailed { .. } => { + return json!({"jsonrpc":"2.0","id":id,"result": + dig_rpc_protocol::types::ListRewardDistributorsResult { + funded: dig_rpc_protocol::types::Half::NotConsulted { + observed_at: now, + }, + claimable: dig_rpc_protocol::types::Half::NotConsulted { + observed_at: now, + }, + } + }); + } + }; + + let mut funded_refs = Vec::with_capacity(identities.len()); + for identity in identities { + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + let report = match port + .distributor_report(identity.launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + funded_refs.push(dig_rpc_protocol::types::RewardDistributorRef { + launcher_id: hex::encode(report.launcher_id), + store_id: hex::encode(report.store_id), + root: hex::encode(report.root), + }); + } + + let result = dig_rpc_protocol::types::ListRewardDistributorsResult { + funded: dig_rpc_protocol::types::Half::Consulted { + observed_at: now, + items: funded_refs, + }, + claimable: dig_rpc_protocol::types::Half::NotConsulted { observed_at: now }, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.getPayeeRewardClaimStatus (dig_ecosystem#3268/#3269 unit 3, SPEC §12.5) — + // CONTROL plane: loopback admin / in-process FFI ONLY, absent from + // `is_peer_reachable_method` (`reward_methods_tier_guard.rs` fails closed on that). + // Dispatched through `Method::from_name(..)` like every other reward method — never + // the string pre-match above the enum, which bypasses this tier guard entirely + // (dig_ecosystem#3261: a reward RPC reachable by a peer is a money hole). + // + // `subject` is always the literal `PayeeSubject::Payee` (SPEC §12.5: this node + // answers as a payee, never as a funder — see `PayeeClaimStatus`'s doc for the + // 250x-overstatement defect that shipped when a renderer inferred the subject from + // the endpoint instead of reading it off the payload). + // + // `claim_log`: this crate holds no claim log anywhere — the claim-submission adapter + // is dig_ecosystem#3310's, in `dig-node-service`, injected downward (see + // `rewards::port`'s module doc; #3249 is a dead pointer for it, see the doc there). + // So `claims_submitted_count` has never been read here and the honest answer is + // `NotConsulted`, dated at the moment this responder established it has no log to + // read — never `Consulted { claims_submitted_count: 0 }`, which would be exactly the + // "reassuring zero" SPEC §12.5 clause 6 forbids: a confident zero beside a fresh + // timestamp, indistinguishable from "read the log, found nothing". + // + // No monetary amount, ever, and no payout puzzle hash — see `PayeeClaimStatus`'s doc. + // No params type: this call takes none. + Some(Method::GetPayeeRewardClaimStatus) => { + use crate::rewards::state::Clock as _; + let result = dig_rpc_protocol::types::PayeeClaimStatus { + subject: dig_rpc_protocol::types::PayeeSubject::Payee, + claim_log: dig_rpc_protocol::types::ClaimLogObservation::NotConsulted { + observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + }, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } Some(Method::CacheSetCapBytes) => { let requested = req .get("params") diff --git a/crates/dig-node-core/tests/reward_methods_tier_guard.rs b/crates/dig-node-core/tests/reward_methods_tier_guard.rs index f74a63c1..34e79565 100644 --- a/crates/dig-node-core/tests/reward_methods_tier_guard.rs +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -43,15 +43,18 @@ fn reward_methods_exist_and_are_found_by_the_prefix_scan() { itself may be broken, or the wire naming convention changed" ); // dig_ecosystem#3269 unit 3: pins the count so the guard cannot silently start policing FEWER - // methods than actually exist (a non-empty check alone would still pass on 3 of 4, or on a + // methods than actually exist (a non-empty check alone would still pass on 4 of 5, or on a // renamed variant the filter stopped matching). Update this number deliberately when // `dig-rpc-protocol` adds or removes a reward method. + // + // Bumped 4 -> 5 (dig_ecosystem#3269, final leg): dig-rpc-protocol 0.12.0 added + // `dig.getPayeeRewardClaimStatus`, whose name also contains "Reward". assert_eq!( methods.len(), - 4, - "expected exactly 4 Reward-prefixed methods (dig.listRewardDistributors, \ + 5, + "expected exactly 5 Reward-prefixed methods (dig.listRewardDistributors, \ dig.getRewardProverStatus, dig.getRewardDistributor, \ - dig.listRewardDistributorCommitments); got {}", + dig.listRewardDistributorCommitments, dig.getPayeeRewardClaimStatus); got {}", methods.len() ); } From e6551e55e62a9ef4fdeb98749a0e204ba8b41beb Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 13 Sep 2026 12:35:01 -0700 Subject: [PATCH 17/19] fix(deps): bump dig-node-service's dig-rpc-protocol pin 0.11 -> 0.12 dig-node-core moved to dig-rpc-protocol 0.12.0 for the reward-RPC final leg (Half, PayeeSubject) but dig-node-service's two pins (prod + dev-dependency) were left at 0.11, resolving two versions of the wire crate in one workspace and failing dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate. cargo tree -i confirms exactly one dig-rpc-protocol (0.12.0), dig-peer (0.15.0), dig-download (0.24.0), dig-peer-selector (0.13.0) across the whole workspace. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 19 ++++--------------- crates/dig-node-service/Cargo.toml | 12 ++++++------ 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fcf8000..b68e9d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2698,7 +2698,7 @@ dependencies = [ "dig-dht", "dig-nat", "dig-peer", - "dig-rpc-protocol 0.12.0", + "dig-rpc-protocol", "futures", "serde", "serde_json", @@ -3002,7 +3002,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rpc-protocol 0.12.0", + "dig-rpc-protocol", "dig-sex", "dig-social-profile", "dig-store-cache", @@ -3064,7 +3064,7 @@ dependencies = [ "dig-node-control-interface", "dig-node-core", "dig-node-service", - "dig-rpc-protocol 0.11.0", + "dig-rpc-protocol", "dig-stun", "dig-urn-resolver", "dig-wallet", @@ -3153,7 +3153,7 @@ dependencies = [ "chia-traits 0.36.1", "dig-message", "dig-nat", - "dig-rpc-protocol 0.12.0", + "dig-rpc-protocol", "dig-tls", "serde", "serde_json", @@ -3209,17 +3209,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "dig-rpc-protocol" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "dig-rpc-protocol" version = "0.12.0" diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index ed7e3f94..2497e893 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -179,10 +179,10 @@ getrandom = "0.2" # them. Two majors in one workspace also duplicates the wire TYPES; pinned by # `dig-node-core/tests/dependency_tree.rs`. # -# Moved to 0.11 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.11.0 — the engine's -# `dig.getRewardProverStatus` handler needs the 0.11 line's reward types, and this line staying at -# 0.10 would be the exact drift the paragraph above warns against. -dig-rpc-protocol = "0.11" +# Moved to 0.12 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.12.0 — 0.12 renamed +# `RewardSubject` -> `PayeeSubject` and replaced `HalfObservation` with `Half`, and this line +# staying at 0.11 would duplicate the wire types the paragraph above warns against. +dig-rpc-protocol = "0.12" # The Sage-parity wallet engine (crate `dig_wallet`) — the node-custodied wallet DB + dual-transport # dispatch + seed custody. This shell WIRES it into bring-up (#368): it builds one live @@ -324,9 +324,9 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] # `openrpc_drift_guard.rs` compares the shell's error catalogue against the shared contract # crate name-for-name. Already a normal dependency above; restated here only so the -# integration-test crate can name it, and pinned to the SAME "0.11" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.12" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.11" +dig-rpc-protocol = "0.12" # The `never_log` battery (#277) drives the real seed bootstrap against a temp layout so its # sentinels are the ACTUAL minted phrase and device key rather than invented strings. Already a # normal dependency above; restated here only so the integration-test crate can name it. From 200acb19a53dc7565b6da14be505a97bbbe27201 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 13 Sep 2026 13:37:42 -0700 Subject: [PATCH 18/19] fix(rpc): refuse getRewardProverStatus on missing identity, not a silent drop A zeroed launcher_id/store_id excluded response only warn! log, then survivors still wrapped in Half::Consulted -- whose contract (dig-rpc-protocol 0.12.0) "the complete answer". This made wire falsely claim "I looked, nothing" when truth was "I looked, found it, discarded it", especially filter_launcher_id named exactly dropped record. Now refuses whole call via reward_chain_port_error_response(&id, &ChainPortError::ZeroIdentity) (REWARD_ZERO_IDENTITY), matching range_checked_report pattern already used by three sibling reward-distributor handlers. check runs over all snapshots before filter_launcher_id narrows them, so caller names exactly bad record refused too. zeroed root alone (no identity fields zeroed) unaffected -- still tracing::debug!'d still returned, since legitimate not-yet-cycled state, not registration bug. dig_ecosystem#3269 / DIG-Network/dig-node#609 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 185 +++++++++--------- .../src/seams/dig_rpc/dispatch.rs | 73 ++++--- 2 files changed, 137 insertions(+), 121 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index db045443..9578bba1 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9515,61 +9515,20 @@ mod tests { ); } - /// **Proves:** a zeroed `launcher_id` OR `store_id` — what an uninitialised/never-assigned - /// registry slot hex-encodes to — is never rendered as a real distributor with a - /// plausible-looking id, AND that dropping it is never silent: a `tracing::warn!` fires - /// naming the SPECIFIC zeroed field(s), so a registration bug is observable rather than - /// swallowed. This is the money-hole class the `dig-rewards-coin` driver's adversarial gates - /// found three times (an unset field that reads fine and costs the operator), plus the SPEC - /// §2.4 clause 1 defect a security + adversarial gate found in the first version of this - /// filter: an all-zero-`launcher_id`-only check that silently destroyed the evidence of a bad - /// registration, and never checked `store_id` at all. - /// - /// Distinguishes IDENTITY fields (`launcher_id`, `store_id` — a record missing either cannot - /// be attributed to any distributor, so it is EXCLUDED and logged at `WARN`) from the - /// OBSERVATION field (`root` — legitimately zero before a prover's first cycle, so it is - /// logged at `DEBUG`, never `WARN`, and never causes exclusion on its own; see the third case - /// below). The level split matters, not just the exclusion split: security measured that an - /// undifferentiated `warn!` for both cases turns steady-state log volume into (uncycled - /// provers) x (poll rate) lines an operator cannot distinguish from a real registration bug. - /// - /// **Catches:** (1) a boundary that lets an uninitialised slot answer as if it were a real - /// distributor; (2) a filter that only checks `launcher_id`, missing a registration bug that - /// zeroes `store_id` beside an otherwise-valid `launcher_id` (the exact gap security named); - /// (3) a fix that goes back to dropping the bad record with no log line at all; (4) a fix - /// that over-corrects by excluding on a zeroed `root` too, which would make a healthy, - /// just-not-yet-cycled prover invisible; (5) a fix that returns the zeroed-root record but logs - /// it at the SAME level (`warn!`) as a real identity fault, defeating the operator's ability to - /// tell the two apart; (6) a log assertion that only checks the field NAME `launcher_id` - /// appears somewhere in the log line — true unconditionally, since the log always includes - /// `launcher_id = %hex::encode(...)` as a structured field regardless of which field was - /// actually zero — rather than checking the `zeroed_fields=[...]` value AND the level. + /// **Proves:** a zeroed `root` alone (an OBSERVATION field, not an identity field) never + /// causes exclusion or a whole-call refusal — a freshly-registered prover that has not + /// completed its first cycle is a legitimate state, and a `tracing::debug!` (never `warn!`) + /// fires naming `root`, distinct from the missing-identity refusal covered by the two tests + /// below. **Catches:** an over-correction that starts refusing on a zeroed `root` too, which + /// would make a healthy, just-not-yet-cycled prover invisible. #[test] - fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { + fn get_reward_prover_status_returns_a_zeroed_root_record_with_a_debug_log() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let (node, _td) = test_node(None); - // Case 1: launcher_id itself is zeroed (the original, narrower gap). - node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( - sample_reward_prover_status([0u8; 32]), - )); - - // Case 2: launcher_id is VALID, but store_id is zeroed — the gap security named, which - // the launcher_id-only filter would have let straight through as a plausible record. - let valid_but_zeroed_store = [0xccu8; 32]; - let mut zeroed_store_status = sample_reward_prover_status(valid_but_zeroed_store); - zeroed_store_status.store_id = [0u8; 32]; - node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( - zeroed_store_status, - )); - - // Case 3: launcher_id AND store_id are both valid, but root is zeroed — a plausible - // "registered, not yet cycled" prover. Must still be RETURNED (root is not an identity - // field), and a DEBUG (never WARN) still fires naming `root` so the state stays - // observable without polluting warn-level volume with an ordinary, expected state. let valid_but_zeroed_root = [0xbbu8; 32]; let mut zeroed_root_status = sample_reward_prover_status(valid_but_zeroed_root); zeroed_root_status.root = [0u8; 32]; @@ -9577,8 +9536,6 @@ mod tests { zeroed_root_status, )); - // A real, fully-valid entry alongside all three, to prove the guard is selective, not a - // by-product of the registry being otherwise empty. let real_id = [0xaau8; 32]; node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( sample_reward_prover_status(real_id), @@ -9591,55 +9548,105 @@ mod tests { crate::download::RequestProvenance::FirstParty, ))); + assert_eq!( + resp["result"]["statuses"]["outcome"], + json!("consulted"), + "a zeroed root alone must never trigger the missing-identity refusal: {resp}" + ); let statuses = resp["result"]["statuses"]["items"] .as_array() .expect("result.statuses.items is an array"); - assert_eq!( - statuses.len(), - 2, - "the fully-valid entry AND the zeroed-root-only entry are both returned; only the \ - zeroed-launcher_id and zeroed-store_id entries are excluded: {resp}" - ); - let returned_ids: std::collections::BTreeSet = statuses - .iter() - .map(|s| s["launcher_id"].as_str().unwrap().to_string()) - .collect(); - assert!(returned_ids.contains(&hex::encode(real_id))); - assert!(returned_ids.contains(&hex::encode(valid_but_zeroed_root))); - - // The observable signal: a warning naming the SPECIFIC zeroed field(s), for EACH bad - // registration — asserted on the actual `zeroed_fields=[...]` value, not merely on the - // field NAME `launcher_id` appearing somewhere (that would pass even for the store_id or - // root cases, since the warn always logs `launcher_id = ...` as a structured field - // regardless of which field was actually zero — the exact tautology a correctness gate - // found in an earlier version of this assertion). + assert_eq!(statuses.len(), 2, "both entries are returned: {resp}"); assert!( - logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), - "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" + logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), + "expected a DEBUG line naming exactly root as zeroed: {logs}" ); assert!( - logs.contains("WARN") && logs.contains(r#"zeroed_fields=["store_id"]"#), - "expected a WARN naming exactly store_id as zeroed, got: {logs}" + !logs.contains("WARN"), + "a zeroed root alone must never log at WARN: {logs}" + ); + } + + /// **Proves (dig_ecosystem#3269 fix939 — the silent-drop defect):** a record with a missing + /// identity field (`launcher_id` or `store_id`), when the caller narrows the request with + /// `params.launcher_id` naming exactly that record, makes `dig.getRewardProverStatus` refuse + /// the WHOLE call with `REWARD_ZERO_IDENTITY` — never `{"outcome":"consulted","items":[]}`, + /// which `dig-rpc-protocol` 0.12.0's `Half::Consulted` contracts as "the complete answer" + /// (`types.rs:1666`). A filtered empty list under `Consulted` reads as "I looked, there is + /// nothing" when the truth is "I looked, found it, and discarded it" — exactly the defect + /// class this epic exists to kill. Matches `range_checked_report`'s whole-call refusal for + /// the three sibling reward-distributor handlers, asserted on the SERIALIZED JSON body + /// through the real `handle_rpc` -> `handle_rpc_as` -> `RpcDispatch::dispatch` path. + /// **Catches:** a `.filter()` that silently drops the record instead of refusing the call. + #[test] + fn get_reward_prover_status_refuses_whole_call_when_filtered_record_has_missing_identity() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + // launcher_id is VALID (so it can be named by the filter), but store_id is zeroed. + let launcher_id = [0xccu8; 32]; + let mut zeroed_store_status = sample_reward_prover_status(launcher_id); + zeroed_store_status.store_id = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_store_status, + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "expected a whole-call refusal naming REWARD_ZERO_IDENTITY, got: {resp}" ); - // A zeroed root alone must be DEBUG, not WARN — it is an ordinary pre-first-cycle state, - // not a registration bug, and sharing warn-level volume with a real identity fault would - // make an operator polling this endpoint unable to tell them apart (the exact security - // finding that split these into two levels). assert!( - logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), - "expected a DEBUG line naming exactly root as zeroed, distinct from the WARN level \ - used for a missing identity field, even though the record is still returned: {logs}" - ); - assert_eq!( - logs.matches("missing an identity field").count(), - 2, - "expected exactly one WARN per identity-missing registration (2 here: launcher_id, \ - store_id) — the zeroed-root-only case must never count as one: {logs}" + resp.get("result").is_none(), + "a refusal must carry no result at all, not an empty/consulted one: {resp}" ); + } + + /// **Proves (dig_ecosystem#3269 fix939):** the same whole-call refusal fires with NO filter + /// applied, against a MIXED registry (one missing-identity record beside an otherwise-valid + /// one) — the survivors must never be returned as a "complete" `Consulted` answer just + /// because at least one record was fine. **Catches:** a fix that only refuses when the + /// missing-identity record is the ONLY one present, still silently dropping it out of a + /// mixed set. + #[test] + fn get_reward_prover_status_refuses_whole_call_for_a_mixed_set_unfiltered() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0u8; 32]), // launcher_id itself zeroed + )); + let real_id = [0xaau8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(real_id), + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( - logs.matches("zeroed root").count(), - 1, - "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "a mixed set with one missing-identity record must refuse the WHOLE call, not \ + return the valid survivor as a complete Consulted answer: {resp}" ); } diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 78548869..2bd8f1f6 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -894,40 +894,49 @@ impl RpcDispatch for Node { .get("launcher_id") .and_then(Value::as_str) .map(str::to_ascii_lowercase); - let statuses: Vec = node - .reward_prover_status_snapshots() + let snapshots = node.reward_prover_status_snapshots(); + // dig_ecosystem#3269 fix939: a zeroed `launcher_id` or `store_id` is never a real + // distributor's or module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. + // An earlier version of this handler EXCLUDED such a record with a `warn!` and + // still answered `Half::Consulted` with the survivors — but `Consulted`'s + // contract (dig-rpc-protocol 0.12.0 `types.rs:1666`) is "the complete answer", so + // that exclusion was itself the silent-drop defect this epic exists to kill: the + // wire said "I looked, there is nothing" when the truth was "I looked, found it, + // and discarded it." This now REFUSES THE WHOLE CALL instead — matching + // `range_checked_report`'s whole-call refusal for the three sibling + // reward-distributor handlers above — checked before the `launcher_id` filter is + // applied, so a caller who narrows to exactly the bad record is refused too, not + // handed a reassuring empty list. + for s in &snapshots { + let zeroed = zeroed_fields(s); + if is_missing_identity(&zeroed) { + tracing::warn!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status registration is missing an identity field; refusing dig.getRewardProverStatus rather than answering a Consulted result with it silently dropped" + ); + return reward_chain_port_error_response( + &id, + &ChainPortError::ZeroIdentity, + ); + } + } + let statuses: Vec = snapshots .into_iter() - // A zeroed `launcher_id` or `store_id` is never a real distributor's or - // module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. Excluding - // such a record rather than presenting it as a real one avoids the money-hole - // class the driver's gates found three times (an unset field that reads fine - // and costs the operator), BUT exclusion alone would silently destroy the - // evidence that a registration bug happened — the exact §2.4 clause 1 - // violation a security + adversarial gate found in the first version of this - // filter (dig-node#595 review round). So this is never a silent drop: a - // `tracing::warn!` fires naming which field(s) were zero, making a bad - // registration observable, and the record is excluded. - // - // A zeroed `root` alone is different: it is an OBSERVATION (the prover's most - // recent cycle), not an identity, and a freshly-registered prover that has not - // completed its first cycle plausibly has a zero `root` legitimately. Excluding - // it on that basis alone would make a healthy, just-not-yet-cycled prover - // invisible — worse than the defect this guard exists to prevent. So this case - // is `tracing::debug!`, not `warn!`: an ordinary, expected state rather than a - // fault, kept out of `warn!`-level volume so an operator polling this endpoint - // is never shown (uncycled provers) x (poll rate) lines indistinguishable from - // a real registration bug. The record is still returned either way. + // A zeroed `root` alone is different from a missing identity: it is an + // OBSERVATION (the prover's most recent cycle), not an identity, and a + // freshly-registered prover that has not completed its first cycle plausibly + // has a zero `root` legitimately. Refusing on that basis would make a healthy, + // just-not-yet-cycled prover invisible — worse than the defect this guard + // exists to prevent. So this case is `tracing::debug!`, not `warn!`, and the + // record is still returned. (Every record reaching this point has already + // passed the missing-identity check above, so `zeroed_fields` here can only + // ever name `root`.) .filter(|s| { let zeroed = zeroed_fields(s); - if is_missing_identity(&zeroed) { - tracing::warn!( - launcher_id = %hex::encode(s.launcher_id), - store_id = %hex::encode(s.store_id), - root = %hex::encode(s.root), - zeroed_fields = ?zeroed, - "reward-prover status registration is missing an identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" - ); - } else if !zeroed.is_empty() { + if !zeroed.is_empty() { tracing::debug!( launcher_id = %hex::encode(s.launcher_id), store_id = %hex::encode(s.store_id), @@ -936,7 +945,7 @@ impl RpcDispatch for Node { "reward-prover status has a zeroed root; likely no cycle observed yet, returning it anyway" ); } - !is_missing_identity(&zeroed) + true }) .filter(|s| match &filter_launcher_id { Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), From 95ca5f9dc2fa93440fc54689cd7cc967584713ac Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 13 Sep 2026 16:57:37 -0700 Subject: [PATCH 19/19] docs(rewards): correct two claims the dispatch arms made false The port.rs module doc still said no dispatch arm would land and that dig.listRewardDistributors "stays -32601 deliberately". This PR serves the method; the arm is in seams/dig_rpc/dispatch.rs. What is still missing is the adapter (install_reward_chain_port has no non-test caller, dig_ecosystem#3310), so the claimable half is NotConsulted and port-backed methods answer REWARD_CHAIN_UNAVAILABLE in production. The getRewardProverStatus comment described a {"statuses": []} body the Half migration in this same PR made unparseable; corrected to the shape actually emitted. Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/rewards/port.rs | 18 +++++++++++------- .../src/seams/dig_rpc/dispatch.rs | 9 +++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 3d888b3f..0fae5124 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -65,13 +65,17 @@ //! writer — would be exactly the kind of restated, unreviewed money-shape work SPEC §0.1 clause 1 and //! this crate's own withholding of a broken reader argue against, and is the shape fork this ticket's //! kernel invariant 6 says to escalate rather than guess. Escalated to the L1, and settled: no new -//! adapter and no dispatch arm land here until the funder-ownership registry exists and #3310's -//! adapter lands in `dig-node-service`. The reader half of that condition is now met. **`dig.listRewardDistributors` stays `-32601` deliberately** — serving it through -//! `UnavailableChainPort` was considered and rejected: it would be a false capability signal (a -//! feature-probe or `rpc.discover` reading the method as implemented when it always errors) and the -//! exact "dispatch surface with no function behind it" pattern DIG-Network/dig-node#593 was the last -//! PR allowed to land on. `UnavailableChainPort` remains the only production adapter for now — still -//! correct, since every real call would fail for one of the two reasons above regardless. No +//! ADAPTER lands here until #3310 wires one in `dig-node-service`. The DISPATCH half of that +//! settlement has since been revisited and has landed: **`dig.listRewardDistributors` is served**, +//! by `Some(Method::ListRewardDistributors)` in `seams/dig_rpc/dispatch.rs`, and it is honest about +//! what it knows — the `funded` half reads this node's own funded-distributor identities, and the +//! `claimable` half is always `NotConsulted` because no claim-side tracking exists here to consult. +//! What is still missing is the adapter: `install_reward_chain_port` has no non-test caller +//! (dig_ecosystem#3310), so `UnavailableChainPort` remains the only production adapter and every +//! port-backed method answers `REWARD_CHAIN_UNAVAILABLE` in production. That is a truthful +//! unavailability signal from a method that exists, not the "dispatch surface with no function +//! behind it" pattern DIG-Network/dig-node#593 was the last PR allowed to land on: the dispatch arm +//! does real reads and names precisely what it could not reach. No //! `dig-rewards-coin` dependency is added by this unit: an unused dependency with no consumer is //! inert weight; the version it will want is whatever is current when #3310 adds it in //! `dig-node-service`, the unit that actually consumes it. Do not add it here. diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 2bd8f1f6..46352a0d 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -882,10 +882,11 @@ impl RpcDispatch for Node { // mTLS peer surface (absent from `is_peer_reachable_method`; // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover - // loop) — a REAL read of a real, currently-empty registry, so `{"statuses": []}` - // means "this node runs no prover loops" and stays true right up until #3265 - // registers one, at which point this same read starts returning it with no dispatch - // change. Never serializes the internal `rewards::state::RewardProverStatus` + // loop) — a REAL read of a real, currently-empty registry, so + // `{"statuses":{"outcome":"consulted","observed_at":N,"items":[]}}` means "this node + // runs no prover loops", genuinely read rather than assumed: an empty `items` under a + // `consulted` outcome. It stays true right up until #3265 registers one, at which + // point this same read starts returning it with no dispatch change. Never serializes the internal `rewards::state::RewardProverStatus` // directly (it is `camelCase`-tagged; the wire struct is snake_case) — every field is // mapped explicitly by `reward_prover_status_to_wire`. Some(Method::GetRewardProverStatus) => {