From 0fb90f5435cf3ed20487d913e5c50f8e55489a49 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 05:44:19 -0700 Subject: [PATCH 01/13] chore: open lane for #3269 Bump dig-rpc-protocol to 0.11.0 (adds dig.getRewardProverStatus and the other reward RPC methods to the wire). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 49 ++++++++++++++++++++------------- crates/dig-node-core/Cargo.toml | 2 +- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a7478c6..53b55630 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1948,7 +1948,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2698,7 +2698,7 @@ dependencies = [ "dig-dht", "dig-nat", "dig-peer", - "dig-rpc-protocol", + "dig-rpc-protocol 0.10.3", "futures", "serde", "serde_json", @@ -3002,7 +3002,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rpc-protocol", + "dig-rpc-protocol 0.11.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.10.3", "dig-stun", "dig-urn-resolver", "dig-wallet", @@ -3152,7 +3152,7 @@ dependencies = [ "chia-traits 0.36.1", "dig-message", "dig-nat", - "dig-rpc-protocol", + "dig-rpc-protocol 0.10.3", "dig-tls", "serde", "serde_json", @@ -3219,6 +3219,17 @@ dependencies = [ "serde_repr", ] +[[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-runtime" version = "0.4.0" @@ -3752,7 +3763,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4536,7 +4547,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4787,7 +4798,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5169,7 +5180,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5780,7 +5791,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -5818,9 +5829,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6570,7 +6581,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7020,7 +7031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7282,7 +7293,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8416,7 +8427,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index c7320e10..0c2f6852 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -191,7 +191,7 @@ 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.10.2" +dig-rpc-protocol = "0.11.0" # 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 db360ca376a82173d4f9dc3b44d3844f952ff673 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 05:54:36 -0700 Subject: [PATCH 02/13] test(rewards): fail-closed Reward-tier guard + dig-rpc-protocol 0.11 line assertion - dependency_tree.rs: assert the resolved dig-rpc-protocol line is 0.11 (was 0.10); documents the known-red two-version state pending the dig-peer 0.14.0 / dig-download 0.23.0 cascade (#3269). - reward_methods_tier_guard.rs: fail-closed guard over the live Method::ALL catalogue -- every Reward-named method must be Tier::Control and not peer-reachable, so a fifth reward method added later is caught at the wrong tier automatically rather than inheriting a wrong default (binds #3261's rule node-side). - peer.rs: sibling unit test exercising the real (pub(crate)) is_peer_reachable_method, since an external integration test cannot see it -- same guard, executed against this node's own allowlist rather than only the shared crate's. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/peer.rs | 28 ++++++++ crates/dig-node-core/tests/dependency_tree.rs | 19 +++-- .../tests/reward_methods_tier_guard.rs | 71 +++++++++++++++++++ 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 crates/dig-node-core/tests/reward_methods_tier_guard.rs diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index e83127bc..f85f954b 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5633,6 +5633,34 @@ pub(crate) mod tests { } } + /// **Proves** (dig_ecosystem#3269, binding #3261's rule node-side): every `Method` whose wire + /// name contains `Reward` is absent from THIS node's `is_peer_reachable_method` allowlist — + /// exercising the real `pub(crate)` function, not the crate-level `Method::is_peer_reachable` + /// it delegates to, so a future special-case added HERE (the way `dig.getProviderSnapshot` and + /// `cache.pushCapsule` are special-cased above) is caught too. + /// **Catches:** a reward method reaching a remote peer over mTLS — a money-adjacent read no + /// unauthenticated peer should get, regardless of whether the wrapper's crate-delegation path + /// or a local special-case is what would have let it through. + #[test] + fn reward_methods_are_absent_from_the_node_peer_allowlist() { + let reward_methods: Vec = dig_rpc_protocol::Method::ALL + .iter() + .copied() + .filter(|m| m.name().contains("Reward")) + .collect(); + assert!( + !reward_methods.is_empty(), + "expected at least one Reward-named method in Method::ALL; found none" + ); + for m in reward_methods { + assert!( + !is_peer_reachable_method(m.name()), + "{} must be absent from is_peer_reachable_method", + m.name() + ); + } + } + /// **Proves:** `dig.getProviderSnapshot` is peer-reachable as the ONE deliberate dig-node-LOCAL /// addition beyond the shared `dig-rpc-protocol` allowlist (epic #1934 child 4a) — it is not (yet) /// in that crate's set, so the wrapper allowlists it explicitly, and this test records that as an diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 82418b16..92587e52 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,10 +96,11 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.10 line that -/// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`) AND the +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.11 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`). +/// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`), AND (#3269) the +/// reward RPC surface (`Method::GetRewardProverStatus` et al., 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` @@ -107,6 +108,12 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// that drive the entire pull plan. Asserting the TRANSITIVE lock entry (not the caret dep in a manifest) /// 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. +/// +/// **Known-red (#3269):** `dig-node-core` now depends on 0.11.0 directly, but `dig-peer` 0.13 and +/// `dig-download` 0.22 both still pin caret `"0.10"`, which excludes 0.11 — so `cargo metadata` +/// resolves BOTH lines today. This assertion is deliberately left at exactly-one/0.11 (never widened to +/// accept a set — see #836/#1576) and stays red until the dig-peer 0.14.0 / dig-download 0.23.0 cascade +/// (a separate, already-dispatched lane) republishes on the 0.11 line. #[test] fn the_workspace_carries_exactly_one_module_wire_crate() { let versions = locked_versions("dig-rpc-protocol"); @@ -117,8 +124,10 @@ 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.10."), - "the availability contract this node adopted ships in dig-rpc-protocol 0.10; 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].starts_with("0.11."), + "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 \ + simply do not exist and this node would be back to declaring its own", versions[0] ); } diff --git a/crates/dig-node-core/tests/reward_methods_tier_guard.rs b/crates/dig-node-core/tests/reward_methods_tier_guard.rs new file mode 100644 index 00000000..54218108 --- /dev/null +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -0,0 +1,71 @@ +//! Fail-closed guard (dig_ecosystem#3269, binding #3261's rule node-side): every `Method` variant +//! whose wire name contains `Reward` MUST be `Tier::Control` and MUST NOT be peer-reachable. +//! +//! The companion check — absence from dig-node's OWN peer dispatch allowlist +//! (`is_peer_reachable_method`, `pub(crate)` in `src/peer.rs`, unreachable from an external +//! integration test) — is a sibling unit test inside `peer.rs`'s own `#[cfg(test)] mod tests`: +//! `reward_methods_are_absent_from_the_node_peer_allowlist`. +//! +//! #3261 (a `dig-rpc-protocol` ticket, not this crate's work) replaces that crate's four-member +//! reward-method enumeration with a prefix guard — but the enumeration it replaces lists exactly the +//! four methods that exist TODAY, so a FIFTH reward method added later would pass an enumeration test +//! simply by not being in the list: an enumeration test only proves the enumeration. This test proves +//! the RULE instead, over the live `Method::ALL` catalogue: it does not name any reward method, so a +//! reward method added after this test is written is caught automatically, at the wrong tier, the +//! moment it appears — rather than silently inheriting a wrong default. +//! +//! Promotion (widening a method's reach) is additive and reversible; demotion is breaking and breaks +//! exactly the anonymous callers nobody can enumerate. That asymmetry is why this fails closed: a +//! reward method that is NOT `Tier::Control`, or IS peer-reachable, fails loudly instead of quietly +//! granting a remote peer a money-adjacent read. + +use dig_rpc_protocol::{Method, Tier}; + +/// Every catalogue member whose wire name contains `"Reward"` (case-sensitive — the wire is +/// camelCase, e.g. `dig.getRewardProverStatus`). +fn reward_methods() -> Vec { + Method::ALL + .iter() + .copied() + .filter(|m| m.name().contains("Reward")) + .collect() +} + +#[test] +fn reward_methods_exist_and_are_found_by_the_prefix_scan() { + // A guard that silently matched zero methods would pass on a catalogue where every reward + // method had been renamed out of its `Reward` name, proving nothing. Assert the scan actually + // finds the surface it exists to police. + let methods = reward_methods(); + assert!( + !methods.is_empty(), + "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" + ); +} + +#[test] +fn every_reward_method_is_tier_control() { + for method in reward_methods() { + assert_eq!( + method.tier(), + Tier::Control, + "{} must be Tier::Control (dig_ecosystem#3269) — a reward RPC reachable at a lower tier \ + is a money hole", + method.name() + ); + } +} + +#[test] +fn no_reward_method_is_peer_reachable() { + for method in reward_methods() { + assert!( + !method.is_peer_reachable(), + "{} must NOT be peer-reachable — reachable ONLY from the loopback admin / in-process FFI \ + dispatch, never over the mTLS peer surface", + method.name() + ); + } +} + From f80b50bd383e3bf387cb49cd014c1ece3152176e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 06:09:03 -0700 Subject: [PATCH 03/13] style: remove trailing blank line in reward_methods_tier_guard.rs --- crates/dig-node-core/tests/reward_methods_tier_guard.rs | 1 - 1 file changed, 1 deletion(-) 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 54218108..37393051 100644 --- a/crates/dig-node-core/tests/reward_methods_tier_guard.rs +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -68,4 +68,3 @@ fn no_reward_method_is_peer_reachable() { ); } } - From 2e4e730bc5992d04c6dca34cb34b850681a8c26d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 06:51:42 -0700 Subject: [PATCH 04/13] feat(rpc): serve dig.getRewardProverStatus at Tier::Control Adds the missing handler for PR#595: a new reward_prover_statuses registry + accessors on Node (empty until #3265 spawns a prover loop, so the registry read is real, not a stub), a dispatch.rs arm inside the Method enum match (never the string pre-match), and a field-for-field mapping from dig-node-core's internal rewards::state::RewardProverStatus (camelCase-tagged) onto dig-rpc-protocol 0.11's wire RewardProverStatus (snake_case-tagged struct, camelCase-tagged ProverState value), widening entry_count u32 -> u64 explicitly and hex-encoding the three [u8; 32] identity fields. An all-zero launcher_id (what an uninitialised registry slot hex-encodes to) is omitted at this boundary rather than rendered as a real distributor with a plausible-looking id -- the money-hole class the dig-rewards-coin driver's adversarial gates found three times. Tests (in dig-node-core::lib.rs's existing test module, where the pub(crate) registry accessors are visible) drive the real dispatch entry point (handle_rpc -> RpcDispatch::dispatch -> the Method arm) and assert field-for-field on the serialized JSON body: populated registry, empty registry (-> {"statuses": []}), zero-id omission, tier/peer-reachability, enum-match-not-string-prematch, and launcher_id filtering. The no-health-boolean / no-staleness assertion is by key set, not substring. Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 275 ++++++++++++++++++ .../src/seams/dig_rpc/dispatch.rs | 99 +++++++ 2 files changed, 374 insertions(+) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 3d4702df..f0be1c1d 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -568,6 +568,38 @@ pub struct Node { /// announces exactly as it always did, and a verifier that cannot fetch a pointer withholds /// credit rather than demoting the holder. mirror_pointers: OnceLock>, + /// Registry of this node's live reward-prover [`rewards::state::StatusHandle`]s, read by + /// `dig.getRewardProverStatus` (dig_ecosystem#3269). Nothing spawns a prover loop yet + /// (dig_ecosystem#3265, not landed), so this stays empty and the handler's + /// `{"statuses": []}` answer is a REAL, currently-empty read — SPEC §2.4 clause 1's + /// "not distributing" render — not a hardcoded stub. The day #3265 registers a handle via + /// [`Node::register_reward_prover_status`], the same read starts returning it with no + /// dispatch-side change. + reward_prover_statuses: Arc>>, +} + +impl Node { + /// Register a live reward-prover status handle (dig_ecosystem#3269/#3265) so + /// `dig.getRewardProverStatus` can read it. Additive — registering a second handle for the + /// same distributor is the registrar's mistake to avoid, not this method's to dedupe. + pub(crate) fn register_reward_prover_status(&self, handle: rewards::state::StatusHandle) { + self.reward_prover_statuses + .write() + .expect("reward prover status registry lock poisoned") + .push(handle); + } + + /// Snapshot every registered reward-prover status, in registration order. Empty when nothing + /// has registered — a REAL read of a real (currently empty) registry, see the field doc on + /// `reward_prover_statuses`. + pub(crate) fn reward_prover_status_snapshots(&self) -> Vec { + self.reward_prover_statuses + .read() + .expect("reward prover status registry lock poisoned") + .iter() + .map(rewards::state::StatusHandle::snapshot) + .collect() + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4815,6 +4847,7 @@ impl Node { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }) } @@ -5154,6 +5187,7 @@ pub(crate) mod test_support { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; (Arc::new(node), td) } @@ -5949,6 +5983,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; (node, td) } @@ -6083,6 +6118,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; // Missing before the pull. @@ -6151,6 +6187,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6250,6 +6287,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6327,6 +6365,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -8995,6 +9034,235 @@ mod tests { } } + // -- dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md §2.3/§2.4) ----- + + /// A populated SPEC §2.3 status record with every field a distinct, checkable value — + /// distinguishes a mapping bug (e.g. two fields swapped, or a widening dropped) from an + /// accidental match against a zeroed/default record. + fn sample_reward_prover_status( + launcher_id: [u8; 32], + ) -> crate::rewards::state::RewardProverStatus { + crate::rewards::state::RewardProverStatus { + launcher_id, + store_id: [0x22u8; 32], + root: [0x33u8; 32], + prover_state: crate::rewards::state::ProverState::ChainSourceUnavailable, + prover_state_since: 1_000, + last_cycle_started_at: Some(1_100), + last_cycle_completed_at: Some(1_200), + next_cycle_due_at: Some(1_300), + last_entry_write_at: Some(1_400), + consecutive_cycle_failures: 3, + pending_entry_writes: 5, + observed_at: 1_500, + counters: crate::rewards::state::ProverCounters { + mirrors_seen: 11, + challenges_issued: 22, + challenges_passed: 33, + challenges_failed: 44, + entries_added: 55, + entries_removed: 66, + // Deliberately > u32::MAX / 2 so a truncating (rather than widening) `entry_count` + // conversion would be caught by the exact-value assertion below. + entry_count: 3_000_000_000, + reserve_base_units: 77, + total_paid_out_base_units: 88, + }, + } + } + + /// **Proves:** `dig.getRewardProverStatus` answers through the REAL dispatch entry point + /// (`handle_rpc` → `RpcDispatch::dispatch` → the `Method::GetRewardProverStatus` arm) with a + /// registered handle's values, asserted field-for-field on the SERIALIZED JSON body (snake_case + /// wire keys, camelCase `prover_state` enum value) — not a Rust struct, so a serde rename or a + /// dropped field would be caught. Also asserts the wire body's key set carries none of + /// `alive`/`healthy`/`ok`/`up`/`running` and no staleness field, by KEY SET rather than + /// substring (a substring check would pass under the defect it exists to catch). + /// **Catches:** a field swap, a dropped `entry_count` widening, a reintroduced health boolean. + #[test] + fn get_reward_prover_status_answers_a_real_request_with_real_values() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let launcher_id = [0x11u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(launcher_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, + )); + + let statuses = resp["result"]["statuses"] + .as_array() + .expect("result.statuses is an array"); + assert_eq!(statuses.len(), 1, "one registered handle: {resp}"); + let s = &statuses[0]; + + assert_eq!(s["launcher_id"], json!(hex::encode(launcher_id))); + assert_eq!(s["store_id"], json!(hex::encode([0x22u8; 32]))); + assert_eq!(s["root"], json!(hex::encode([0x33u8; 32]))); + // camelCase VALUE for the enum, on an otherwise snake_case-keyed wire struct (confirmed at + // v0.11.0: only `ProverState` carries `rename_all = "camelCase"`). + assert_eq!(s["prover_state"], json!("chainSourceUnavailable")); + assert_eq!(s["prover_state_since"], json!(1_000)); + assert_eq!(s["last_cycle_started_at"], json!(1_100)); + assert_eq!(s["last_cycle_completed_at"], json!(1_200)); + assert_eq!(s["next_cycle_due_at"], json!(1_300)); + assert_eq!(s["last_entry_write_at"], json!(1_400)); + assert_eq!(s["consecutive_cycle_failures"], json!(3)); + assert_eq!(s["pending_entry_writes"], json!(5)); + assert_eq!(s["observed_at"], json!(1_500)); + + let counters = &s["counters"]; + assert_eq!(counters["mirrors_seen"], json!(11)); + assert_eq!(counters["challenges_issued"], json!(22)); + assert_eq!(counters["challenges_passed"], json!(33)); + assert_eq!(counters["challenges_failed"], json!(44)); + assert_eq!(counters["entries_added"], json!(55)); + assert_eq!(counters["entries_removed"], json!(66)); + // The value proving the widening ran: > u32::MAX, so a truncating cast would not equal this. + assert_eq!(counters["entry_count"], json!(3_000_000_000u64)); + assert_eq!(counters["reserve_base_units"], json!(77)); + assert_eq!(counters["total_paid_out_base_units"], json!(88)); + + // No health boolean, no precomputed staleness (SPEC §2.4) — by KEY SET, not substring. + let keys: std::collections::BTreeSet<&str> = + s.as_object().expect("status is an object").keys().map(String::as_str).collect(); + for banned in ["alive", "healthy", "ok", "up", "running", "stale", "seconds_since_last_run"] + { + assert!(!keys.contains(banned), "banned key {banned:?} present: {keys:?}"); + } + } + + /// **Proves:** with nothing registered, `dig.getRewardProverStatus` answers + /// `{"statuses": []}` — SPEC §2.4 clause 1's "not distributing" render — never blank, `null`, + /// or an omitted `result`. **Catches:** an absent-record case that renders as nothing rather + /// than an explicit empty list a UI can render deterministically. + #[test] + fn get_reward_prover_status_with_no_registered_handle_is_explicit_empty() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + 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!(resp["result"], json!({"statuses": []}), "explicit empty list: {resp}"); + } + + /// **Proves:** an all-zero `launcher_id` — what an uninitialised/never-assigned registry slot + /// hex-encodes to — is OMITTED, never rendered as a real distributor with a plausible-looking + /// 64-hex id. 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. + /// **Catches:** a boundary that lets an uninitialised slot answer as if it were a real + /// distributor. + #[test] + fn get_reward_prover_status_omits_an_all_zero_launcher_id() { + 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]), + )); + // A real, non-zero entry alongside it, to prove the filter 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), + )); + + 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, + )); + + let statuses = resp["result"]["statuses"] + .as_array() + .expect("result.statuses is an array"); + assert_eq!(statuses.len(), 1, "the zero-id entry must be omitted: {resp}"); + assert_eq!(statuses[0]["launcher_id"], json!(hex::encode(real_id))); + } + + /// **Proves:** `dig.getRewardProverStatus` is NOT peer-reachable (CONTROL plane — loopback + /// admin / in-process FFI only), matching `reward_methods_tier_guard.rs`'s enumeration-based + /// guard with a direct, single-method assertion. + /// **Catches:** the method being accidentally allowlisted for the mTLS peer surface. + #[test] + fn get_reward_prover_status_is_not_peer_reachable() { + assert!(!peer::is_peer_reachable_method("dig.getRewardProverStatus")); + } + + /// **Proves:** `dig.getRewardProverStatus` goes through the `Method` enum match (`Tier::Control` + /// per dig-rpc-protocol 0.11), not the string pre-match ahead of it — calling it over the SAME + /// dispatch entry point with no special-casing still resolves to the handler, so a future + /// refactor that moved it back to the pre-match string block would be the only way to break + /// this test's premise, not silently bypass the tier guard. + /// **Catches:** a reintroduction of the method into the pre-`Method::from_name` string match. + #[test] + fn get_reward_prover_status_is_served_via_the_method_enum_not_the_string_prematch() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.getRewardProverStatus"), + Some(Method::GetRewardProverStatus) + ); + assert_eq!(Method::GetRewardProverStatus.tier(), dig_rpc_protocol::Tier::Control); + } + + /// **Proves:** `dig.getRewardProverStatus` restricts to the requested `launcher_id` when the + /// caller supplies one, and returns every registered status when it does not. + /// **Catches:** a filter that ignores the param, or one that requires it. + #[test] + fn get_reward_prover_status_filters_by_launcher_id_when_given() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let a = [0xaau8; 32]; + let b = [0xbbu8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(a), + )); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(b), + )); + + let filtered = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params":{"launcher_id": hex::encode(a)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let filtered = filtered["result"]["statuses"].as_array().unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0]["launcher_id"], json!(hex::encode(a))); + + let all = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); + } + /// **Proves:** `gap_fill_generation` is a cheap no-op when the generation is already held (no /// network, `Ok(())`). **Catches:** a gap-fill that re-pulls an already-held generation. #[tokio::test] @@ -9399,6 +9667,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; let before = handle_rpc( @@ -16554,6 +16823,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; // A holder for this EXACT content is known via the DHT. @@ -16605,6 +16875,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -16655,6 +16926,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; @@ -16687,6 +16959,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -16728,6 +17001,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -16771,6 +17045,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); 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 1c432b4d..1ba381a2 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -143,6 +143,66 @@ async fn resolve_enforced_pin( } } +/// Map dig-node-core's internal (`camelCase`-tagged) reward-prover status onto +/// `dig-rpc-protocol` 0.11's wire type (snake_case-tagged struct; only its `ProverState` VALUE is +/// camelCase) — field by field, explicit and widening where the shapes differ, never a +/// same-name struct-to-struct copy. This subsystem has already shipped a 24x-too-high fee +/// ceiling and a 2x-understated eviction count that a correctness gate passed twice, so every +/// non-identical field below is called out rather than assumed. +fn reward_prover_status_to_wire( + s: crate::rewards::state::RewardProverStatus, +) -> dig_rpc_protocol::types::RewardProverStatus { + dig_rpc_protocol::types::RewardProverStatus { + launcher_id: hex::encode(s.launcher_id), + store_id: hex::encode(s.store_id), + root: hex::encode(s.root), + prover_state: reward_prover_state_to_wire(s.prover_state), + prover_state_since: s.prover_state_since, + last_cycle_started_at: s.last_cycle_started_at, + last_cycle_completed_at: s.last_cycle_completed_at, + next_cycle_due_at: s.next_cycle_due_at, + last_entry_write_at: s.last_entry_write_at, + consecutive_cycle_failures: s.consecutive_cycle_failures, + pending_entry_writes: s.pending_entry_writes, + observed_at: s.observed_at, + counters: dig_rpc_protocol::types::ProverCounters { + mirrors_seen: s.counters.mirrors_seen, + challenges_issued: s.counters.challenges_issued, + challenges_passed: s.counters.challenges_passed, + challenges_failed: s.counters.challenges_failed, + entries_added: s.counters.entries_added, + entries_removed: s.counters.entries_removed, + // Internal `entry_count` is `u32`; the wire field is `u64` — widen explicitly rather + // than a same-name copy, so a future wire narrowing fails to compile instead of + // silently truncating. + entry_count: u64::from(s.counters.entry_count), + reserve_base_units: s.counters.reserve_base_units, + total_paid_out_base_units: s.counters.total_paid_out_base_units, + }, + } +} + +/// The SPEC §2.3 nine-variant closed set is identical between the internal and wire +/// `ProverState`; mapped explicitly (never `transmute`d) so an internal-only variant added +/// without a matching wire variant is a compile error here, not a silent wire mismatch. +fn reward_prover_state_to_wire( + s: crate::rewards::state::ProverState, +) -> dig_rpc_protocol::types::ProverState { + use crate::rewards::state::ProverState as Internal; + use dig_rpc_protocol::types::ProverState as Wire; + match s { + Internal::Idle => Wire::Idle, + Internal::Running => Wire::Running, + Internal::LocalCopyMissing => Wire::LocalCopyMissing, + Internal::ChainSourceUnavailable => Wire::ChainSourceUnavailable, + Internal::Unfunded => Wire::Unfunded, + Internal::FeeBudgetExhausted => Wire::FeeBudgetExhausted, + Internal::EntrySetFull => Wire::EntrySetFull, + Internal::Paused => Wire::Paused, + Internal::Stopped => Wire::Stopped, + } +} + #[async_trait::async_trait] impl RpcDispatch for Node { async fn dispatch( @@ -659,6 +719,45 @@ impl RpcDispatch for Node { "subscriptions": set.stores(), "count": set.len()}}); } + // dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md + // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY, NEVER over the + // 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` + // 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) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let filter_launcher_id = params + .get("launcher_id") + .and_then(Value::as_str) + .map(str::to_ascii_lowercase); + let statuses: Vec = node + .reward_prover_status_snapshots() + .into_iter() + // An all-zero `launcher_id` is never a real distributor's identity — it is + // what an uninitialised/never-assigned registry slot hex-encodes to + // ("0000…0000"), which reads exactly like a valid 64-hex id to every + // consumer including shipped dig-app 15.5.0. Omit it rather than let a slot + // that was never assigned a distributor render as a real one with a + // real-looking id — the money-hole class the driver's gates found three + // times (an unset field that reads fine and costs the operator). This is + // the boundary decision, not a `rewards/**` change: the registry itself is + // never asked to validate what it stores. + .filter(|s| s.launcher_id != [0u8; 32]) + .filter(|s| match &filter_launcher_id { + Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), + None => true, + }) + .map(reward_prover_status_to_wire) + .collect(); + let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } Some(Method::CacheSetCapBytes) => { let requested = req .get("params") From 05a3f611915545bd869b88400bf0615b64749093 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 06:52:30 -0700 Subject: [PATCH 05/13] style: rustfmt the reward-prover-status registry + tests Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 45 +++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index f0be1c1d..8f6cb3f5 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6287,7 +6287,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), - reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6365,7 +6365,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), - reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -9132,11 +9132,25 @@ mod tests { assert_eq!(counters["total_paid_out_base_units"], json!(88)); // No health boolean, no precomputed staleness (SPEC §2.4) — by KEY SET, not substring. - let keys: std::collections::BTreeSet<&str> = - s.as_object().expect("status is an object").keys().map(String::as_str).collect(); - for banned in ["alive", "healthy", "ok", "up", "running", "stale", "seconds_since_last_run"] - { - assert!(!keys.contains(banned), "banned key {banned:?} present: {keys:?}"); + let keys: std::collections::BTreeSet<&str> = s + .as_object() + .expect("status is an object") + .keys() + .map(String::as_str) + .collect(); + for banned in [ + "alive", + "healthy", + "ok", + "up", + "running", + "stale", + "seconds_since_last_run", + ] { + assert!( + !keys.contains(banned), + "banned key {banned:?} present: {keys:?}" + ); } } @@ -9159,7 +9173,11 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); - assert_eq!(resp["result"], json!({"statuses": []}), "explicit empty list: {resp}"); + assert_eq!( + resp["result"], + json!({"statuses": []}), + "explicit empty list: {resp}" + ); } /// **Proves:** an all-zero `launcher_id` — what an uninitialised/never-assigned registry slot @@ -9195,7 +9213,11 @@ mod tests { let statuses = resp["result"]["statuses"] .as_array() .expect("result.statuses is an array"); - assert_eq!(statuses.len(), 1, "the zero-id entry must be omitted: {resp}"); + assert_eq!( + statuses.len(), + 1, + "the zero-id entry must be omitted: {resp}" + ); assert_eq!(statuses[0]["launcher_id"], json!(hex::encode(real_id))); } @@ -9221,7 +9243,10 @@ mod tests { Method::from_name("dig.getRewardProverStatus"), Some(Method::GetRewardProverStatus) ); - assert_eq!(Method::GetRewardProverStatus.tier(), dig_rpc_protocol::Tier::Control); + assert_eq!( + Method::GetRewardProverStatus.tier(), + dig_rpc_protocol::Tier::Control + ); } /// **Proves:** `dig.getRewardProverStatus` restricts to the requested `launcher_id` when the From 99e1989750573f77663337f2af71f1c925219880 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 07:23:32 -0700 Subject: [PATCH 06/13] chore(deps): bump dig-download 0.23, dig-peer 0.14, dig-rpc-protocol 0.11 Closes the two-versions-of-dig-rpc-protocol split (#3269): dig-download 0.23.0 and dig-peer 0.14.0 both now resolve dig-rpc-protocol ^0.11, matching dig-node-core's own dig-rpc-protocol = "0.11.0" line, and dig-node-service's two 0.10 lines (main dep + dev-dependency restatement for openrpc_drift_guard.rs) move to 0.11 to match. Manifests only. cargo update / Cargo.lock intentionally NOT run yet: a fourth capper, dig-peer-selector ("0.11" in dig-node-core/Cargo.toml), still requires dig-peer = "^0.13" in every published version through 0.11.1, so the tree cannot fully resolve until a dig-peer-selector release picks up dig-peer 0.14. CI will stay red on this commit for that reason, which is expected. Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/Cargo.toml | 13 ++++++++++--- crates/dig-node-service/Cargo.toml | 10 +++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 0c2f6852..9274a61d 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -457,7 +457,11 @@ dig-pex = "0.1.1" # checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), # because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no # filename at all) and the one `FileStateStore` test used a 3-character key. -dig-download = "0.22" +# +# 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" # -- 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 @@ -469,7 +473,10 @@ dig-download = "0.22" # module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on # dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by # `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.13" +# +# 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" # -- 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 @@ -580,7 +587,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.22", features = ["testkit"] } +dig-download = { version = "0.23", 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-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 3666c163..e340a965 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -172,7 +172,11 @@ getrandom = "0.2" # longer has — the shell would omit `dig.getModuleInfo` / `dig.fetchModuleRange` while the engine served # them. Two majors in one workspace also duplicates the wire TYPES; pinned by # `dig-node-core/tests/dependency_tree.rs`. -dig-rpc-protocol = "0.10" +# +# 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" # 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 @@ -314,9 +318,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.10" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.11" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.10" +dig-rpc-protocol = "0.11" # 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 9ce00fabbbce9560458c72c8473ac99e5669571c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 13:51:03 -0700 Subject: [PATCH 07/13] feat(rpc): close dig-rpc-protocol 0.11 cascade + attribute payout figures Bump dig-peer-selector 0.11 -> 0.12 (the release that moves onto dig-peer ^0.14) and run cargo update, closing the two-versions-of-dig-rpc-protocol split: Cargo.lock now resolves exactly one dig-rpc-protocol, at 0.11.0, alongside dig-peer 0.14.0, dig-download 0.23.0, dig-peer-selector 0.12.0. Add a subject-attribution test and doc comments to reward_prover_status_to_wire: total_paid_out_base_units and reserve_base_units are per-distributor totals (this distributor's payout to ALL its mirrors, and this distributor's own reserve), never the querying node's own earnings and never summed/cross-attributed across distributors. This is the defect class a sibling adversarial gate found in dig-app#403's rewards pane, which rendered a distributor total as one mirror operator's personal earnings and overstated by up to 250x. Checked: rewards/state.rs, rewards/port.rs and rewards/mod.rs contain no Eligible/verdict/payout_hash symbol, so nothing in this mapping surfaces a persisted EligiblePayoutHash verdict. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 31 +++----- crates/dig-node-core/Cargo.toml | 8 ++- crates/dig-node-core/src/lib.rs | 71 ++++++++++++++++++- .../src/seams/dig_rpc/dispatch.rs | 10 +++ 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53b55630..058fad93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,16 +2689,16 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a85a94865f946f608c06bf1b2259b894c4100f14cb75fa5f0065b8e439fc0f93" +checksum = "b1f9a6e23899a1a58ff8f070307897799b0142b3d9447652676ffae7c131da7c" dependencies = [ "async-trait", "dig-constants 0.11.2", "dig-dht", "dig-nat", "dig-peer", - "dig-rpc-protocol 0.10.3", + "dig-rpc-protocol", "futures", "serde", "serde_json", @@ -3002,7 +3002,7 @@ dependencies = [ "dig-peer", "dig-peer-selector", "dig-pex", - "dig-rpc-protocol 0.11.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.10.3", + "dig-rpc-protocol", "dig-stun", "dig-urn-resolver", "dig-wallet", @@ -3144,15 +3144,15 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5cf9690e3e31508b092cfb72fc92b62f241b479920d7f830d514a5fd4e8cdc" +checksum = "d6d28173f5ac2fb725d70d81491918bb9dbdc1691745bf044524aee8484333ef" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", "dig-message", "dig-nat", - "dig-rpc-protocol 0.10.3", + "dig-rpc-protocol", "dig-tls", "serde", "serde_json", @@ -3184,9 +3184,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "946dee72de59dbe5c9ac00e700899e1a0f258930080e0b22de6019732d0b7389" +checksum = "1ac1005c43d63ca61d3ca6391cf6d3ff08b7138bb22e237ae76c5157d7677652" dependencies = [ "dig-dht", "dig-nat", @@ -3208,17 +3208,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "dig-rpc-protocol" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c46a32c3fc6203773b6f551b21e5475b81b60d8b2c72a3b71c74694b149ade" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[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 9274a61d..e4ad1616 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -503,7 +503,13 @@ dig-peer = "0.14" # above (dig-node#422). Its predecessor 0.10.0 required `^0.13`, and because this crate passes # dig-dht values into the selector, that requirement is what held dig-dht at 0.13; see the dig-dht # entry above. -dig-peer-selector = "0.11" +# +# Moved to 0.12 (dig_ecosystem#3269): 0.12.0 is the release that moves onto `dig-peer ^0.14`, the +# last of the four links in the `dig-rpc-protocol` 0.11 cascade (dig-peer 0.14.0, dig-download +# 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" # 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 diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 8f6cb3f5..ba350dd6 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9062,8 +9062,11 @@ mod tests { challenges_failed: 44, entries_added: 55, entries_removed: 66, - // Deliberately > u32::MAX / 2 so a truncating (rather than widening) `entry_count` - // conversion would be caught by the exact-value assertion below. + // Deliberately in the upper half of `u32`'s range (> 2^31) — the internal field IS + // `u32`, so this cannot exceed `u32::MAX` (that would not compile), but a value + // this large would not survive a mistaken re-narrowing (e.g. an accidental + // `as u32 as u64` round-trip through a signed/other-width type) intact, unlike a + // small value that would pass such a bug undetected. entry_count: 3_000_000_000, reserve_base_units: 77, total_paid_out_base_units: 88, @@ -9288,6 +9291,70 @@ mod tests { assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); } + /// **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 + /// gate found in dig-app#403's rewards pane (dig_ecosystem#3269): a per-distributor total + /// rendered/returned as if it were a single subject's (there, one mirror operator's personal + /// earnings), overstating by however many other mirrors that distributor pays. Proving the + /// VALUE survives the wire hop (`get_reward_prover_status_answers_a_real_request_with_real_values`) + /// does not prove whose money it describes — this test does, with two distributors carrying + /// deliberately different, distinguishable totals. + #[test] + fn get_reward_prover_status_attributes_payout_figures_to_their_own_distributor() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let distributor_a = [0xaau8; 32]; + let distributor_b = [0xbbu8; 32]; + + let mut status_a = sample_reward_prover_status(distributor_a); + status_a.counters.reserve_base_units = 10_000; + status_a.counters.total_paid_out_base_units = 999_000; + + let mut status_b = sample_reward_prover_status(distributor_b); + status_b.counters.reserve_base_units = 42; + status_b.counters.total_paid_out_base_units = 7; + + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new(status_a)); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new(status_b)); + + 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, + )); + let statuses = resp["result"]["statuses"].as_array().unwrap(); + assert_eq!(statuses.len(), 2); + + let find = |launcher_id: [u8; 32]| { + statuses + .iter() + .find(|s| s["launcher_id"] == json!(hex::encode(launcher_id))) + .unwrap_or_else(|| panic!("no status for launcher_id {}", hex::encode(launcher_id))) + }; + let a = find(distributor_a); + let b = find(distributor_b); + + // Each distributor's own figures, untouched. + assert_eq!(a["counters"]["reserve_base_units"], json!(10_000)); + assert_eq!(a["counters"]["total_paid_out_base_units"], json!(999_000)); + assert_eq!(b["counters"]["reserve_base_units"], json!(42)); + assert_eq!(b["counters"]["total_paid_out_base_units"], json!(7)); + + // Never summed across distributors (999_000 + 7) and never cross-attributed (swapped). + let combined = 999_000 + 7; + assert_ne!(a["counters"]["total_paid_out_base_units"], json!(combined)); + assert_ne!(b["counters"]["total_paid_out_base_units"], json!(combined)); + assert_ne!( + a["counters"]["total_paid_out_base_units"], + b["counters"]["total_paid_out_base_units"] + ); + } + /// **Proves:** `gap_fill_generation` is a cheap no-op when the generation is already held (no /// network, `Ok(())`). **Catches:** a gap-fill that re-pulls an already-held generation. #[tokio::test] 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 1ba381a2..8898f021 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -176,6 +176,16 @@ fn reward_prover_status_to_wire( // than a same-name copy, so a future wire narrowing fails to compile instead of // silently truncating. entry_count: u64::from(s.counters.entry_count), + // SUBJECT, not just value (dig_ecosystem#3269, found by a sibling adversarial gate on + // dig-app#403's rewards pane): `reserve_base_units` and `total_paid_out_base_units` are + // per-DISTRIBUTOR figures — this distributor's own reserve, and the total THIS + // distributor has paid out in total to ALL of its mirrors combined. Neither is the + // querying node's own earnings, and `total_paid_out_base_units` is never one mirror's + // share; a caller rendering either as "your earnings" for the operator running this + // node overstates by however many other mirrors this distributor pays (the dig-app + // pane rendered it as personal earnings and overstated by up to 250x). This function + // passes both through unmodified and unaggregated (SPEC §2.4) — it is the caller's job + // to label them as the distributor's totals, never the operator's. reserve_base_units: s.counters.reserve_base_units, total_paid_out_base_units: s.counters.total_paid_out_base_units, }, From 6be80c978c208d0ee1d54bea9b33552ed0af3797 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 14:04:26 -0700 Subject: [PATCH 08/13] fix(rpc): silence dead_code on register_reward_prover_status pending #3265 Clippy's non-test lib target has no production caller for register_reward_prover_status yet, because #3265 (the always-on prover loop that would call it from bring-up) has not landed -- only tests call it today. cfg_attr(not(test), allow(dead_code)) stands in for that missing caller until #3265 wires a real one. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index ba350dd6..bb55c882 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -582,6 +582,13 @@ impl Node { /// Register a live reward-prover status handle (dig_ecosystem#3269/#3265) so /// `dig.getRewardProverStatus` can read it. Additive — registering a second handle for the /// same distributor is the registrar's mistake to avoid, not this method's to dedupe. + /// + /// Only called from tests today: #3265 (the always-on prover loop that would call this from + /// production bring-up) has not landed, so clippy's non-test lib target sees no production + /// caller yet. `allow(dead_code)` here is a stand-in for that missing caller, not a claim the + /// registry itself is unused — remove this attribute the moment #3265 lands and wires a real + /// call site. + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn register_reward_prover_status(&self, handle: rewards::state::StatusHandle) { self.reward_prover_statuses .write() From 3a9538bd982776c86ba4291165471dc7bf2529b5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 14:34:51 -0700 Subject: [PATCH 09/13] fix(rpc): make the all-zero identity guard non-silent and cover all three fields Security (blocking) and the adversarial leg both found the same defect in the zero-launcher_id filter: it checked only launcher_id, so a registration bug that zeroed store_id or root beside a valid launcher_id would pass through as a plausible record, and dropping the bad record silently destroyed the evidence a registration bug happened at all -- SPEC Sec2.4 clause 1's exact prohibition. zeroed_identity_fields() now checks launcher_id, store_id AND root. The dispatch filter still excludes a record with any zeroed field (never renders an uninitialised slot as a real distributor), but first fires a tracing::warn! naming which field(s) were zero, so a bad registration is observable rather than swallowed. Kept isolated in dispatch.rs rather than woven into the wire mapping, since this belongs at #3265's writer once that lands. Replaced get_reward_prover_status_omits_an_all_zero_launcher_id (which proved the omission but not the observability, and never exercised a zeroed store_id/root beside a valid launcher_id) with get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field, covering both a zeroed launcher_id and a zeroed store_id beside a valid launcher_id, and asserting the tracing::warn! output via the crate's existing capture_sync_logs test utility. Fixed a now-false "Known-red" doc comment on tests/dependency_tree.rs::the_workspace_carries_exactly_one_module_wire_crate: the dig-peer 0.14.0 / dig-download 0.23.0 / dig-peer-selector 0.12.0 cascade already landed in this PR's Cargo.toml/Cargo.lock, so the assertion is green, not red. Assertion itself untouched -- still exact-version. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 59 +++++++++++++++---- .../src/seams/dig_rpc/dispatch.rs | 57 ++++++++++++++---- crates/dig-node-core/tests/dependency_tree.rs | 10 ++-- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index bb55c882..28c10f5d 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9190,35 +9190,56 @@ mod tests { ); } - /// **Proves:** an all-zero `launcher_id` — what an uninitialised/never-assigned registry slot - /// hex-encodes to — is OMITTED, never rendered as a real distributor with a plausible-looking - /// 64-hex id. 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. - /// **Catches:** a boundary that lets an uninitialised slot answer as if it were a real - /// distributor. + /// **Proves:** a zeroed `launcher_id`, `store_id` OR `root` — 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 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`/`root` at all. + /// + /// **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` or `root` beside an otherwise-valid `launcher_id` (the exact gap + /// security named — nothing before this test covered it); (3) a fix that goes back to + /// dropping the bad record with no log line at all. #[test] - fn get_reward_prover_status_omits_an_all_zero_launcher_id() { + fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { 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]), )); - // A real, non-zero entry alongside it, to prove the filter is selective, not a + + // 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, + )); + + // A real, fully-valid entry alongside both, 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), )); - let resp = rt.block_on(handle_rpc( + let (resp, logs) = rt.block_on(capture_sync_logs(handle_rpc( &node, json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, - )); + ))); let statuses = resp["result"]["statuses"] .as_array() @@ -9226,9 +9247,25 @@ mod tests { assert_eq!( statuses.len(), 1, - "the zero-id entry must be omitted: {resp}" + "only the fully-valid entry may be returned: {resp}" ); assert_eq!(statuses[0]["launcher_id"], json!(hex::encode(real_id))); + + // The observable signal: a warning naming which field was zero, for EACH bad + // registration — not silence. + assert!( + logs.contains("all-zero identity field") && logs.contains("launcher_id"), + "expected a warning about the zeroed-launcher_id registration, got: {logs}" + ); + assert!( + logs.contains(&hex::encode(valid_but_zeroed_store)), + "expected the zeroed-store_id registration's (valid) launcher_id in the log so an \ + operator can find which registration was bad: {logs}" + ); + assert!( + logs.matches("all-zero identity field").count() >= 2, + "expected one warning per bad registration (2 here), got: {logs}" + ); } /// **Proves:** `dig.getRewardProverStatus` is NOT peer-reachable (CONTROL plane — loopback 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 8898f021..4533e386 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -149,6 +149,30 @@ async fn resolve_enforced_pin( /// same-name struct-to-struct copy. This subsystem has already shipped a 24x-too-high fee /// ceiling and a 2x-understated eviction count that a correctness gate passed twice, so every /// non-identical field below is called out rather than assumed. +/// Names which of a reward-prover status record's identity fields (`launcher_id`, `store_id`, +/// `root`) are all-zero, if any. An all-zero value in any of these is never a real distributor's +/// or module's identity — it's what an unassigned/uninitialised registry slot hex-encodes to +/// ("0000…0000"), which reads exactly like a valid 64-hex id to every consumer including shipped +/// dig-app 15.5.0. +/// +/// Isolated on purpose (dig_ecosystem#3269 security/adversarial gate): this is a +/// registration-bug DETECTOR that belongs, longer-term, at #3265's writer (the code that will +/// actually populate this registry) rather than woven into the wire mapping below — kept here, +/// small and easy to relocate, only because #3265 has not landed yet. +fn zeroed_identity_fields(s: &crate::rewards::state::RewardProverStatus) -> Vec<&'static str> { + let mut zeroed = Vec::new(); + if s.launcher_id == [0u8; 32] { + zeroed.push("launcher_id"); + } + if s.store_id == [0u8; 32] { + zeroed.push("store_id"); + } + if s.root == [0u8; 32] { + zeroed.push("root"); + } + zeroed +} + fn reward_prover_status_to_wire( s: crate::rewards::state::RewardProverStatus, ) -> dig_rpc_protocol::types::RewardProverStatus { @@ -749,16 +773,29 @@ impl RpcDispatch for Node { let statuses: Vec = node .reward_prover_status_snapshots() .into_iter() - // An all-zero `launcher_id` is never a real distributor's identity — it is - // what an uninitialised/never-assigned registry slot hex-encodes to - // ("0000…0000"), which reads exactly like a valid 64-hex id to every - // consumer including shipped dig-app 15.5.0. Omit it rather than let a slot - // that was never assigned a distributor render as a real one with a - // real-looking id — the money-hole class the driver's gates found three - // times (an unset field that reads fine and costs the operator). This is - // the boundary decision, not a `rewards/**` change: the registry itself is - // never asked to validate what it stores. - .filter(|s| s.launcher_id != [0u8; 32]) + // A zeroed `launcher_id`, `store_id` or `root` is never a real distributor's + // or module's identity — see `zeroed_identity_fields`. Excluding it rather + // than presenting it as a real record 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 + // rather than swallowed, even though the record still never reaches a caller. + .filter(|s| { + let zeroed = zeroed_identity_fields(s); + if !zeroed.is_empty() { + 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 has an all-zero identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" + ); + } + zeroed.is_empty() + }) .filter(|s| match &filter_launcher_id { Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), None => true, diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 92587e52..54b40044 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -109,11 +109,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. /// -/// **Known-red (#3269):** `dig-node-core` now depends on 0.11.0 directly, but `dig-peer` 0.13 and -/// `dig-download` 0.22 both still pin caret `"0.10"`, which excludes 0.11 — so `cargo metadata` -/// resolves BOTH lines today. This assertion is deliberately left at exactly-one/0.11 (never widened to -/// accept a set — see #836/#1576) and stays red until the dig-peer 0.14.0 / dig-download 0.23.0 cascade -/// (a separate, already-dispatched lane) republishes on the 0.11 line. +/// **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. #[test] fn the_workspace_carries_exactly_one_module_wire_crate() { let versions = locked_versions("dig-rpc-protocol"); From 134864a9d2dd3b73e89efd13aac3b17308f005ad Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 14:39:32 -0700 Subject: [PATCH 10/13] fix(rpc): correct a born-false "shipped dig-app 15.5.0" doc claim dig-app's latest release is v15.4.0 -- there is no v15.5.0 tag -- and dig-app#403 (the pane that would consume dig.getRewardProverStatus) is OPEN and unmerged. Point the doc comment at the real, unmerged consumer instead so a future reader doesn't take this as evidence a shipped consumer depends on the guard, which would wrongly discourage relocating it to #3265's writer. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/seams/dig_rpc/dispatch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4533e386..68aba73e 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -152,8 +152,8 @@ async fn resolve_enforced_pin( /// Names which of a reward-prover status record's identity fields (`launcher_id`, `store_id`, /// `root`) are all-zero, if any. An all-zero value in any of these is never a real distributor's /// or module's identity — it's what an unassigned/uninitialised registry slot hex-encodes to -/// ("0000…0000"), which reads exactly like a valid 64-hex id to every consumer including shipped -/// dig-app 15.5.0. +/// ("0000…0000"), which reads exactly like a valid 64-hex id to every consumer, including the +/// dig-app consumer in dig-app#403 (unmerged). /// /// Isolated on purpose (dig_ecosystem#3269 security/adversarial gate): this is a /// registration-bug DETECTOR that belongs, longer-term, at #3265's writer (the code that will From 080be3df10ca3e5b73a16c68099243e21fb70014 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 18:24:09 -0700 Subject: [PATCH 11/13] fix(rpc): correct doc placement, assert the zeroed field by name, treat root as an observation Three findings from the correctness gate on PR#595 at 134864a9. 1. The zeroed-identity helper's doc block was spliced onto the end of reward_prover_status_to_wire's block with no separator, so the wire-mapping rationale documented a boolean predicate and the mapping function was left with no doc at all. Each doc block now sits above the item it describes. 2. The log assertion `logs.contains("launcher_id")` was a tautology: the warn emits launcher_id as a structured field on every fire, so the property the guard exists to add -- naming which field was zeroed -- was unasserted. Deleting `zeroed_fields = ?zeroed` from the warn left every assertion green. The test now asserts the zeroed_fields value itself, which the fixture makes exact and disjoint across cases. 3. `root` is an observation, not an identity. A registered prover that has not completed its first cycle plausibly has no root, and a writer that zero-inits it would have made a healthy prover invisible. A zeroed launcher_id or store_id still excludes the record; a zeroed root alone warns and returns. Refs #3269 Co-Authored-By: Claude Opus 5 (1M context) --- crates/dig-node-core/src/lib.rs | 88 +++++++++++++------ .../src/seams/dig_rpc/dispatch.rs | 73 +++++++++------ 2 files changed, 109 insertions(+), 52 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 28c10f5d..4842767c 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9190,21 +9190,31 @@ mod tests { ); } - /// **Proves:** a zeroed `launcher_id`, `store_id` OR `root` — 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 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`/`root` at all. + /// **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) from the OBSERVATION field (`root` — + /// legitimately zero before a prover's first cycle, so it is logged but never causes + /// exclusion on its own; see the fourth case below). /// /// **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` or `root` beside an otherwise-valid `launcher_id` (the exact gap - /// security named — nothing before this test covered it); (3) a fix that goes back to - /// dropping the bad record with no log line at all. + /// 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 log assertion that only checks the field NAME + /// `launcher_id` appears somewhere in the log line — true unconditionally, since the warn + /// always logs `launcher_id = %hex::encode(...)` as a structured field regardless of which + /// field was actually zero — rather than checking the `zeroed_fields=[...]` value itself, + /// which is what actually distinguishes the cases. #[test] fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { let rt = tokio::runtime::Builder::new_current_thread() @@ -9227,7 +9237,17 @@ mod tests { zeroed_store_status, )); - // A real, fully-valid entry alongside both, to prove the guard is selective, not a + // 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), but a warn must still fire naming `root` so the state stays observable. + 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]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + 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( @@ -9246,25 +9266,41 @@ mod tests { .expect("result.statuses is an array"); assert_eq!( statuses.len(), - 1, - "only the fully-valid entry may be returned: {resp}" + 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}" ); - assert_eq!(statuses[0]["launcher_id"], json!(hex::encode(real_id))); - - // The observable signal: a warning naming which field was zero, for EACH bad - // registration — not silence. + 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!( - logs.contains("all-zero identity field") && logs.contains("launcher_id"), - "expected a warning about the zeroed-launcher_id registration, got: {logs}" + logs.contains(r#"zeroed_fields=["launcher_id"]"#), + "expected a warning naming exactly launcher_id as zeroed, got: {logs}" ); assert!( - logs.contains(&hex::encode(valid_but_zeroed_store)), - "expected the zeroed-store_id registration's (valid) launcher_id in the log so an \ - operator can find which registration was bad: {logs}" + logs.contains(r#"zeroed_fields=["store_id"]"#), + "expected a warning naming exactly store_id as zeroed, got: {logs}" ); assert!( - logs.matches("all-zero identity field").count() >= 2, - "expected one warning per bad registration (2 here), got: {logs}" + logs.contains(r#"zeroed_fields=["root"]"#), + "expected a warning naming exactly root as zeroed even though the record is still \ + returned: {logs}" + ); + assert_eq!( + logs.matches("all-zero field").count(), + 3, + "expected exactly one warning per bad registration (3 here: launcher_id, store_id, \ + root) — no more, no fewer: {logs}" ); } 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 68aba73e..3ca68ff6 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -143,23 +143,24 @@ async fn resolve_enforced_pin( } } -/// Map dig-node-core's internal (`camelCase`-tagged) reward-prover status onto -/// `dig-rpc-protocol` 0.11's wire type (snake_case-tagged struct; only its `ProverState` VALUE is -/// camelCase) — field by field, explicit and widening where the shapes differ, never a -/// same-name struct-to-struct copy. This subsystem has already shipped a 24x-too-high fee -/// ceiling and a 2x-understated eviction count that a correctness gate passed twice, so every -/// non-identical field below is called out rather than assumed. -/// Names which of a reward-prover status record's identity fields (`launcher_id`, `store_id`, -/// `root`) are all-zero, if any. An all-zero value in any of these is never a real distributor's -/// or module's identity — it's what an unassigned/uninitialised registry slot hex-encodes to -/// ("0000…0000"), which reads exactly like a valid 64-hex id to every consumer, including the -/// dig-app consumer in dig-app#403 (unmerged). +/// Names which of a reward-prover status record's fields are all-zero, if any, across +/// `launcher_id`, `store_id` (both IDENTITY — an all-zero value is never a real distributor's or +/// module's id; it's what an unassigned/uninitialised registry slot hex-encodes to, which reads +/// exactly like a valid 64-hex id to every consumer, including the dig-app consumer in +/// dig-app#403 (unmerged)) and `root` (an OBSERVATION, not an identity — a registered prover that +/// has not completed its first cycle yet plausibly has no root, so a zeroed `root` alone is not a +/// registration bug the way a zeroed identity field is). +/// +/// Callers decide what to DO with a zeroed field; this only names which ones are zero, so the +/// same detection drives both the exclusion decision (identity fields only, see +/// `reward_prover_status_to_wire`'s call site) and the `tracing::warn!` that fires for a zeroed +/// `root` too, since an operator watching the log still benefits from seeing it named. /// /// Isolated on purpose (dig_ecosystem#3269 security/adversarial gate): this is a /// registration-bug DETECTOR that belongs, longer-term, at #3265's writer (the code that will /// actually populate this registry) rather than woven into the wire mapping below — kept here, /// small and easy to relocate, only because #3265 has not landed yet. -fn zeroed_identity_fields(s: &crate::rewards::state::RewardProverStatus) -> Vec<&'static str> { +fn zeroed_fields(s: &crate::rewards::state::RewardProverStatus) -> Vec<&'static str> { let mut zeroed = Vec::new(); if s.launcher_id == [0u8; 32] { zeroed.push("launcher_id"); @@ -173,6 +174,20 @@ fn zeroed_identity_fields(s: &crate::rewards::state::RewardProverStatus) -> Vec< zeroed } +/// Whether a record's zeroed fields (from [`zeroed_fields`]) include an IDENTITY field +/// (`launcher_id` or `store_id`). A record failing this cannot be attributed to any distributor +/// or module, so it must never be presented as one — unlike a zeroed `root` alone, which is a +/// legitimate "no cycle observed yet" state for an otherwise-real, otherwise-attributable prover. +fn is_missing_identity(zeroed: &[&str]) -> bool { + zeroed.contains(&"launcher_id") || zeroed.contains(&"store_id") +} + +/// Map dig-node-core's internal (`camelCase`-tagged) reward-prover status onto +/// `dig-rpc-protocol` 0.11's wire type (snake_case-tagged struct; only its `ProverState` VALUE is +/// camelCase) — field by field, explicit and widening where the shapes differ, never a +/// same-name struct-to-struct copy. This subsystem has already shipped a 24x-too-high fee +/// ceiling and a 2x-understated eviction count that a correctness gate passed twice, so every +/// non-identical field below is called out rather than assumed. fn reward_prover_status_to_wire( s: crate::rewards::state::RewardProverStatus, ) -> dig_rpc_protocol::types::RewardProverStatus { @@ -773,28 +788,34 @@ impl RpcDispatch for Node { let statuses: Vec = node .reward_prover_status_snapshots() .into_iter() - // A zeroed `launcher_id`, `store_id` or `root` is never a real distributor's - // or module's identity — see `zeroed_identity_fields`. Excluding it rather - // than presenting it as a real record 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 - // rather than swallowed, even though the record still never reaches a caller. + // 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 even when the record still reaches a caller. + // + // 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 a + // root-only zero still warns, but the record is still returned. .filter(|s| { - let zeroed = zeroed_identity_fields(s); + let zeroed = zeroed_fields(s); if !zeroed.is_empty() { 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 has an all-zero identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" + "reward-prover status registration has an all-zero field; excluding it from dig.getRewardProverStatus if the zeroed field is an identity field, rather than presenting it as a real distributor" ); } - zeroed.is_empty() + !is_missing_identity(&zeroed) }) .filter(|s| match &filter_launcher_id { Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), From f657f2cb8d283799c84b006b3b4d4fccf68747b7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 18:27:11 -0700 Subject: [PATCH 12/13] fix(rpc): restore zeroed_fields structured field dropped from the pushed warn The previous commit (080be3df) landed with `zeroed_fields = ?zeroed` missing from the tracing::warn! call in the GetRewardProverStatus filter -- a one-line regression introduced while proving the new log assertion goes red without it, never restored before the commit was made. Without this field the log line never names WHICH field was zero, so an operator sees only that something was excluded, and the test asserting `zeroed_fields=[...]` per case would fail. Restored; all 7 reward-prover-status tests green. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/seams/dig_rpc/dispatch.rs | 1 + 1 file changed, 1 insertion(+) 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 3ca68ff6..728ec9d5 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -812,6 +812,7 @@ impl RpcDispatch for Node { 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 has an all-zero field; excluding it from dig.getRewardProverStatus if the zeroed field is an identity field, rather than presenting it as a real distributor" ); } From 0ae5f30a74fdbc927a812c2512af6ece545998d4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 9 Sep 2026 18:47:59 -0700 Subject: [PATCH 13/13] fix(rpc): split zeroed-field logging by level -- WARN for a missing identity, DEBUG for a zeroed root A zeroed launcher_id or store_id is a real registration bug: the record is excluded and now logs at WARN, naming the exact field(s) via `zeroed_fields=[...]`. A zeroed root alone is an ordinary pre-first-cycle state, not a fault: the record is still returned, and now logs at DEBUG instead of WARN, so an operator polling this endpoint sees warn-level volume proportional to real registration bugs, not to every not-yet-cycled prover on every poll. Updated the doc comments on `zeroed_fields`, the dispatch filter and the test to describe the level split, and extended the regression test to assert on level (WARN vs DEBUG) as well as the `zeroed_fields` value. Proved both directions: flipping the DEBUG branch back to WARN turns the test red on the level assertion; flipping the field-name assertion back to a bare `contains("launcher_id")` would have passed unconditionally (the prior tautology) and is no longer possible since the assertions now pin `zeroed_fields=[...]` plus the level string. Refs #3269 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-core/src/lib.rs | 54 ++++++++++++------- .../src/seams/dig_rpc/dispatch.rs | 29 +++++++--- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 4842767c..d582f173 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9201,20 +9201,24 @@ mod tests { /// 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) from the OBSERVATION field (`root` — - /// legitimately zero before a prover's first cycle, so it is logged but never causes - /// exclusion on its own; see the fourth case below). + /// 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 log assertion that only checks the field NAME - /// `launcher_id` appears somewhere in the log line — true unconditionally, since the warn - /// always logs `launcher_id = %hex::encode(...)` as a structured field regardless of which - /// field was actually zero — rather than checking the `zeroed_fields=[...]` value itself, - /// which is what actually distinguishes the cases. + /// 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. #[test] fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { let rt = tokio::runtime::Builder::new_current_thread() @@ -9239,7 +9243,8 @@ mod tests { // 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), but a warn must still fire naming `root` so the state stays observable. + // 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]; @@ -9284,23 +9289,32 @@ mod tests { // regardless of which field was actually zero — the exact tautology a correctness gate // found in an earlier version of this assertion). assert!( - logs.contains(r#"zeroed_fields=["launcher_id"]"#), - "expected a warning naming exactly launcher_id as zeroed, got: {logs}" + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), + "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" ); assert!( - logs.contains(r#"zeroed_fields=["store_id"]"#), - "expected a warning naming exactly store_id as zeroed, got: {logs}" + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["store_id"]"#), + "expected a WARN naming exactly store_id as zeroed, got: {logs}" ); + // 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(r#"zeroed_fields=["root"]"#), - "expected a warning naming exactly root as zeroed even though the record is still \ - returned: {logs}" + 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("all-zero field").count(), - 3, - "expected exactly one warning per bad registration (3 here: launcher_id, store_id, \ - root) — no more, no fewer: {logs}" + 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}" + ); + assert_eq!( + logs.matches("zeroed root").count(), + 1, + "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" ); } 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 728ec9d5..85be4b2e 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -152,9 +152,11 @@ async fn resolve_enforced_pin( /// registration bug the way a zeroed identity field is). /// /// Callers decide what to DO with a zeroed field; this only names which ones are zero, so the -/// same detection drives both the exclusion decision (identity fields only, see -/// `reward_prover_status_to_wire`'s call site) and the `tracing::warn!` that fires for a zeroed -/// `root` too, since an operator watching the log still benefits from seeing it named. +/// same detection drives both the exclusion decision (identity fields only, see the +/// `GetRewardProverStatus` filter below) and the log level split there: a zeroed identity field +/// is a `tracing::warn!` (a real registration bug, record excluded), while a zeroed `root` alone +/// is a `tracing::debug!` (an ordinary pre-first-cycle state, record still returned) — the two +/// outcomes are opposite, so they must never share one undifferentiated log line or level. /// /// Isolated on purpose (dig_ecosystem#3269 security/adversarial gate): this is a /// registration-bug DETECTOR that belongs, longer-term, at #3265's writer (the code that will @@ -797,23 +799,34 @@ impl RpcDispatch for Node { // 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 even when the record still reaches a caller. + // 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 a - // root-only zero still warns, but the record is still returned. + // 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. .filter(|s| { let zeroed = zeroed_fields(s); - if !zeroed.is_empty() { + 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 has an all-zero field; excluding it from dig.getRewardProverStatus if the zeroed field is an identity field, rather than presenting it as a real distributor" + "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() { + tracing::debug!( + 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 has a zeroed root; likely no cycle observed yet, returning it anyway" ); } !is_missing_identity(&zeroed)