diff --git a/Cargo.lock b/Cargo.lock index 0a7478c6..058fad93 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]] @@ -2689,9 +2689,9 @@ 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", @@ -3144,9 +3144,9 @@ 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", @@ -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", @@ -3210,9 +3210,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.10.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c46a32c3fc6203773b6f551b21e5475b81b60d8b2c72a3b71c74694b149ade" +checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" dependencies = [ "serde", "serde_json", @@ -3752,7 +3752,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3900,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 +4536,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4787,7 +4787,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5169,7 +5169,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 +5780,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 +5818,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 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7020,7 +7020,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 +7282,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 +8416,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..e4ad1616 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. @@ -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 @@ -496,7 +503,13 @@ dig-peer = "0.13" # 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 @@ -580,7 +593,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-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 3d4702df..d582f173 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -568,6 +568,45 @@ 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. + /// + /// 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() + .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 +4854,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 +5194,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 +5990,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 +6125,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 +6194,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 +6294,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 +6372,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 +9041,414 @@ 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 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, + }, + } + } + + /// **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:** a zeroed `launcher_id` OR `store_id` — what an uninitialised/never-assigned + /// registry slot hex-encodes to — is never rendered as a real distributor with a + /// plausible-looking id, AND that dropping it is never silent: a `tracing::warn!` fires + /// naming the SPECIFIC zeroed field(s), so a registration bug is observable rather than + /// swallowed. This is the money-hole class the `dig-rewards-coin` driver's adversarial gates + /// found three times (an unset field that reads fine and costs the operator), plus the SPEC + /// §2.4 clause 1 defect a security + adversarial gate found in the first version of this + /// filter: an all-zero-`launcher_id`-only check that silently destroyed the evidence of a bad + /// registration, and never checked `store_id` at all. + /// + /// Distinguishes IDENTITY fields (`launcher_id`, `store_id` — a record missing either cannot + /// be attributed to any distributor, so it is EXCLUDED and logged at `WARN`) from the + /// OBSERVATION field (`root` — legitimately zero before a prover's first cycle, so it is + /// logged at `DEBUG`, never `WARN`, and never causes exclusion on its own; see the third case + /// below). The level split matters, not just the exclusion split: security measured that an + /// undifferentiated `warn!` for both cases turns steady-state log volume into (uncycled + /// provers) x (poll rate) lines an operator cannot distinguish from a real registration bug. + /// + /// **Catches:** (1) a boundary that lets an uninitialised slot answer as if it were a real + /// distributor; (2) a filter that only checks `launcher_id`, missing a registration bug that + /// zeroes `store_id` beside an otherwise-valid `launcher_id` (the exact gap security named); + /// (3) a fix that goes back to dropping the bad record with no log line at all; (4) a fix + /// that over-corrects by excluding on a zeroed `root` too, which would make a healthy, + /// just-not-yet-cycled prover invisible; (5) a fix that returns the zeroed-root record but logs + /// it at the SAME level (`warn!`) as a real identity fault, defeating the operator's ability to + /// tell the two apart; (6) a log assertion that only checks the field NAME `launcher_id` + /// appears somewhere in the log line — true unconditionally, since the log always includes + /// `launcher_id = %hex::encode(...)` as a structured field regardless of which field was + /// actually zero — rather than checking the `zeroed_fields=[...]` value AND the level. + #[test] + 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]), + )); + + // Case 2: launcher_id is VALID, but store_id is zeroed — the gap security named, which + // the launcher_id-only filter would have let straight through as a plausible record. + let valid_but_zeroed_store = [0xccu8; 32]; + let mut zeroed_store_status = sample_reward_prover_status(valid_but_zeroed_store); + zeroed_store_status.store_id = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_store_status, + )); + + // Case 3: launcher_id AND store_id are both valid, but root is zeroed — a plausible + // "registered, not yet cycled" prover. Must still be RETURNED (root is not an identity + // field), and a DEBUG (never WARN) still fires naming `root` so the state stays + // observable without polluting warn-level volume with an ordinary, expected state. + let valid_but_zeroed_root = [0xbbu8; 32]; + let mut zeroed_root_status = sample_reward_prover_status(valid_but_zeroed_root); + zeroed_root_status.root = [0u8; 32]; + 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( + sample_reward_prover_status(real_id), + )); + + 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() + .expect("result.statuses is an array"); + assert_eq!( + statuses.len(), + 2, + "the fully-valid entry AND the zeroed-root-only entry are both returned; only the \ + zeroed-launcher_id and zeroed-store_id entries are excluded: {resp}" + ); + let returned_ids: std::collections::BTreeSet = statuses + .iter() + .map(|s| s["launcher_id"].as_str().unwrap().to_string()) + .collect(); + assert!(returned_ids.contains(&hex::encode(real_id))); + assert!(returned_ids.contains(&hex::encode(valid_but_zeroed_root))); + + // The observable signal: a warning naming the SPECIFIC zeroed field(s), for EACH bad + // registration — asserted on the actual `zeroed_fields=[...]` value, not merely on the + // field NAME `launcher_id` appearing somewhere (that would pass even for the store_id or + // root cases, since the warn always logs `launcher_id = ...` as a structured field + // regardless of which field was actually zero — the exact tautology a correctness gate + // found in an earlier version of this assertion). + assert!( + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), + "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" + ); + assert!( + 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("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), + "expected a DEBUG line naming exactly root as zeroed, distinct from the WARN level \ + used for a missing identity field, even though the record is still returned: {logs}" + ); + assert_eq!( + logs.matches("missing an identity field").count(), + 2, + "expected exactly one WARN per identity-missing registration (2 here: launcher_id, \ + store_id) — the zeroed-root-only case must never count as one: {logs}" + ); + assert_eq!( + logs.matches("zeroed root").count(), + 1, + "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" + ); + } + + /// **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:** `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] @@ -9399,6 +9853,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 +17009,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 +17061,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 +17112,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 +17145,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 +17187,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 +17231,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/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/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 1c432b4d..85be4b2e 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,117 @@ async fn resolve_enforced_pin( } } +/// 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 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 +/// 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_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 +} + +/// 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 { + 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), + // 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, + }, + } +} + +/// 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 +770,76 @@ 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() + // A zeroed `launcher_id` or `store_id` is never a real distributor's or + // module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. Excluding + // such a record rather than presenting it as a real one avoids the money-hole + // class the driver's gates found three times (an unset field that reads fine + // and costs the operator), BUT exclusion alone would silently destroy the + // evidence that a registration bug happened — the exact §2.4 clause 1 + // violation a security + adversarial gate found in the first version of this + // filter (dig-node#595 review round). So this is never a silent drop: a + // `tracing::warn!` fires naming which field(s) were zero, making a bad + // registration observable, and the record is excluded. + // + // A zeroed `root` alone is different: it is an OBSERVATION (the prover's most + // recent cycle), not an identity, and a freshly-registered prover that has not + // completed its first cycle plausibly has a zero `root` legitimately. Excluding + // it on that basis alone would make a healthy, just-not-yet-cycled prover + // invisible — worse than the defect this guard exists to prevent. So this case + // is `tracing::debug!`, not `warn!`: an ordinary, expected state rather than a + // fault, kept out of `warn!`-level volume so an operator polling this endpoint + // is never shown (uncycled provers) x (poll rate) lines indistinguishable from + // a real registration bug. The record is still returned either way. + .filter(|s| { + let zeroed = zeroed_fields(s); + if is_missing_identity(&zeroed) { + tracing::warn!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status registration is missing an identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" + ); + } else if !zeroed.is_empty() { + 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) + }) + .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") diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 82418b16..54b40044 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. +/// +/// **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"); @@ -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..37393051 --- /dev/null +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -0,0 +1,70 @@ +//! 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() + ); + } +} 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.