diff --git a/Cargo.lock b/Cargo.lock index a8839fe2..b68e9d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9a6e23899a1a58ff8f070307897799b0142b3d9447652676ffae7c131da7c" +checksum = "d705dda562d63c03a1a481087c3b9f632b90982676ea8bb4e55f2cd04c4465fc" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3145,9 +3145,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6d28173f5ac2fb725d70d81491918bb9dbdc1691745bf044524aee8484333ef" +checksum = "01657d5ef42a4ebf038d53b3b398997057417558cd6c059f4f58716e68676f34" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", @@ -3185,9 +3185,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac1005c43d63ca61d3ca6391cf6d3ff08b7138bb22e237ae76c5157d7677652" +checksum = "a3be02a4acba35b9580f3655c95cf4e127031e100e684069a17fb2829bb4e68b" dependencies = [ "dig-dht", "dig-nat", @@ -3211,9 +3211,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" +checksum = "5eb22a4741239303c9558b00d20c0cb6d9afad965382656bece3d4bbe9641f24" dependencies = [ "serde", "serde_json", diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index e4ad1616..656af50a 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -191,7 +191,14 @@ serde_json = "1" # per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). -dig-rpc-protocol = "0.11.0" +# +# Moved to 0.12 (dig_ecosystem#3269, final leg): 0.12.0 is the release this node adopts the reward +# RPC surface from (`Method::ListRewardDistributors`, `Method::GetPayeeRewardClaimStatus`, and the +# `GetRewardProverStatusResult.statuses` shape change to `Half`). `dig-peer` +# (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) below all moved onto this line +# in the same batch, so exactly one `dig-rpc-protocol` still resolves (asserted by +# `crates/dig-node-core/tests/dependency_tree.rs`). +dig-rpc-protocol = "0.12" # The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope # the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 # envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. @@ -461,7 +468,10 @@ dig-pex = "0.1.1" # Moved to 0.23 (dig_ecosystem#3269): 0.23.0 is the release that re-exports `dig-rpc-protocol` 0.11's # `ModuleInfo`, closing the two-shapes split this crate's own `dig-rpc-protocol = "0.11.0"` line above # opened against dig-download's prior 0.22-line dependency on `dig-rpc-protocol` 0.10.3. -dig-download = "0.23" +# +# Moved to 0.24 (dig_ecosystem#3269, final leg): 0.24.0 is on `dig-rpc-protocol` 0.12, matching this +# crate's own move to 0.12 above. +dig-download = "0.24" # -- The shared peer client (#1283/#1576) ------------------------------------------------------------- # `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed # RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the @@ -476,7 +486,10 @@ dig-download = "0.23" # # Moved to 0.14 (dig_ecosystem#3269), alongside dig-download's move to 0.23 above, for the same # reason: 0.14.0 is on `dig-rpc-protocol` 0.11, keeping exactly one version resolving. -dig-peer = "0.14" +# +# Moved to 0.15 (dig_ecosystem#3269, final leg): 0.15.0 is on `dig-rpc-protocol` 0.12, matching this +# crate's own move to 0.12 above. +dig-peer = "0.15" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation @@ -509,7 +522,10 @@ dig-peer = "0.14" # 0.23.0, dig-peer-selector 0.12.0). Every prior `dig-peer-selector` release — through 0.11.1 — # stayed on `dig-peer ^0.13`, which is what pinned this crate's `dig-peer` line above at 0.13 and # kept two `dig-rpc-protocol` versions resolving simultaneously. -dig-peer-selector = "0.12" +# +# Moved to 0.13 (dig_ecosystem#3269, final leg): 0.13.0 is on `dig-peer ^0.15`, matching this crate's +# own move to `dig-peer = "0.15"` above and closing the cascade at `dig-rpc-protocol` 0.12. +dig-peer-selector = "0.13" # The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity # is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in # the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the @@ -593,7 +609,7 @@ rcgen = "0.13" # # Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which # fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.23", features = ["testkit"] } +dig-download = { version = "0.24", features = ["testkit"] } # Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the # serve-observability tests (#1595) assert what an operator would actually see in the node log — # and that no payload byte or proof ever reaches it. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 50e211a7..9578bba1 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -583,13 +583,29 @@ pub struct Node { /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is /// one: the FFI/browser path has no state directory and must keep constructing a `Node` /// without one. Nothing installs it in production yet — nothing in dig-node funds a - /// distributor today (`rewards::port`'s module doc, blocker 2), and the startup wiring that - /// would call [`Node::install_funded_distributor_registry`] with the node's state directory - /// belongs to dig_ecosystem#3268. Until then the slot stays empty, and + /// distributor today (`rewards::port`'s module doc, blocker 2). WHICH ticket owns the startup + /// wiring that would call [`Node::install_funded_distributor_registry`] with the node's state + /// directory is tracked separately, and it is NOT dig_ecosystem#3268, whose scope is the claim + /// loop and `ClaimStatus` and which names neither this registry nor that call. Until a ticket + /// wires it the slot stays empty, and /// [`Node::funded_distributors_read`] answers /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`] — UNKNOWN, deliberately never an /// empty funded set. funded_distributors: OnceLock, + /// The chain seam `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` + /// (dig_ecosystem#3269 units 1-2) read through — [`rewards::port::RewardsChainPort`]. + /// + /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is + /// one. Nothing installs a real adapter yet: the one that calls `dig-rewards-coin` lives in + /// `dig-node-service`, a sibling unit this crate never depends on (see `rewards::port`'s + /// module doc). WHICH ticket carries that adapter is an OPEN question — it is tracked + /// separately from dig_ecosystem#3268, whose scope is the claim loop and `ClaimStatus` and + /// which names neither `distributor_report` nor this installer. Do not read a ticket number + /// into this comment that nobody has verified. Until the adapter is installed, both handlers + /// answer + /// [`rewards::port::ChainPortError::Unavailable`] — a real "no chain source is wired yet", + /// never a silent zero or empty list. + reward_chain_port: OnceLock>, } impl Node { @@ -626,9 +642,11 @@ impl Node { /// if a registry is already installed, in which case NOTHING changed — a second install must /// not be able to swap a live registry for an inert one behind a caller's back. /// - /// Called from tests today: the startup path that would install a real one lives in - /// dig_ecosystem#3268's files, so clippy's non-test lib target sees no production caller yet. - /// `allow(dead_code)` stands in for that missing caller — remove it when #3268 wires the call. + /// Called from tests today: no production startup path installs one, so clippy's non-test + /// lib target sees no production caller and `allow(dead_code)` stands in for it. Remove the + /// attribute when that wiring lands. Its owning ticket is tracked separately and is NOT + /// dig_ecosystem#3268 (claim loop + `ClaimStatus`), which names neither this registry nor this + /// call — do not read the attribute as a claim about #3268's scope. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn install_funded_distributor_registry( &self, @@ -662,6 +680,34 @@ impl Node { ), } } + + /// Install this node's reward-distributor chain-read adapter (dig_ecosystem#3269 units 1-2), + /// once. Returns `false` if one is already installed, in which case NOTHING changed — mirrors + /// [`Node::install_funded_distributor_registry`]'s same one-shot discipline. + /// + /// `pub` because this is the INJECTION POINT, and the adapter is built one crate UP: + /// `dig-node-service` constructs it over `dig-rewards-coin` and injects it downward + /// (dig_ecosystem#3310, which names both `distributor_report` and this function). A + /// `pub(crate)` setter made that architecture unbuildable while its own doc described it, and + /// the `#[cfg_attr(not(test), allow(dead_code))]` that used to sit here was hiding the + /// unreachability rather than standing in for a merely-absent caller. + /// + /// Being callable from outside does NOT relax the single-install discipline: a second install + /// must not be able to swap a live adapter for an inert one behind a caller's back, so the + /// second call returns `false` and changes nothing. + pub fn install_reward_chain_port( + &self, + port: Arc, + ) -> bool { + self.reward_chain_port.set(port).is_ok() + } + + /// The installed reward-distributor chain-read adapter, or `None` when nothing has installed + /// one — `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` must treat `None` + /// exactly like [`rewards::port::ChainPortError::Unavailable`], never a zero or empty answer. + pub(crate) fn reward_chain_port(&self) -> Option<&Arc> { + self.reward_chain_port.get() + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4911,6 +4957,7 @@ impl Node { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }) } @@ -5252,6 +5299,7 @@ pub(crate) mod test_support { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; (Arc::new(node), td) } @@ -5390,9 +5438,18 @@ mod tests { /// ([`RESOURCE_UNAVAILABLE`] and [`RESOURCE_NOT_AVAILABLE`]) are correctly read as one condition /// under two names rather than as a collision. /// - /// Deliberately NOT exhaustive yet: the chat band (`-32050`..`-32052`) is undeclared upstream - /// entirely. That is pre-existing and out of this change; adding it is a follow-up that has to - /// resolve the condition, not the table. + /// Holds the numbers this crate emits that `dig_rpc_protocol::ErrorCode::ALL` does NOT declare, + /// plus the locally-named constants for numbers it DOES declare (so a local re-spelling of a + /// canonical condition cannot drift from the owner's name). What makes it COMPLETE is not this + /// list: it is [`every_wire_code_this_crate_mentions_is_classified`], which scans these sources + /// and requires every `-32xxx` it finds to be canonically declared or listed here. Forgetting to + /// register a number is therefore what fails - the previous version of this guard asserted + /// `len() >= 10`, a measure of SIZE rather than completeness, and stayed green at 1134/3402 + /// while this crate emitted `-32033`, already `dig-node-service`'s `ControlIngressLimited`. + /// + /// The chat band (`-32050`..`-32052`) is no longer a gap: `dig-rpc-protocol` 0.11 declares + /// `NoIdentity`/`NoPeerNetwork`/`SendFailed` for exactly those numbers, so the taxonomy answers + /// the collision question for them and the scan classifies them canonically. /// /// `content_serve::SERVE_UNREADABLE` used to be named here as a second `-32000` gap. It was not /// one: its code field's only sink answered `502` from the message and never read the number, so @@ -5417,6 +5474,21 @@ mod tests { (CONTROL_UNAUTHORIZED, "UNAUTHORIZED"), (CONTROL_NOT_SUPPORTED, "NOT_SUPPORTED"), (CONTROL_ERROR, "CONTROL_ERROR"), + // The two below are LITERALS because each lives behind a constant in a private module + // (`seams::capsule::push_capsule`, `seams::dig_rpc::dispatch`) this test module cannot + // name. Both are undeclared upstream, so the canonical leg has nothing to compare them + // against and the condition string exists only to make a local collision visible. + // + // `-32001`: the push surface's authorization refusal. `seams::dig_rpc::errors` deliberately + // emits it with NO `data.code` (an invented machine name is worse than an absent one), so + // this condition name is internal to this guard and is not a wire name. + ( + -32001, + "PUSH_AUTHORITY_REFUSED (local, undeclared upstream)", + ), + // `-32002`: `ENGINE_WARMING` - the peer tier has genuinely not been consulted yet. Distinct + // from `-32004`, which means it WAS consulted and the content is still not found. + (-32002, "ENGINE_WARMING (local, undeclared upstream)"), ]; /// **Proves:** no number this node emits is already spoken for — neither by @@ -5443,9 +5515,13 @@ mod tests { fn no_local_wire_code_collides_with_a_different_canonical_code() { // Side effects first: a table that has silently shrunk to nothing, or lost the code under // review, would make every assertion below vacuously true. - assert!( - LOCAL_WIRE_CODES.len() >= 10, - "the local wire-code table lost entries; a shrinking table makes this guard vacuous" + // An EXACT count, not a floor: a floor cannot see a table that grew by an entry nobody + // checked, and `>= 10` is what let a real collision through. Changing this number is a + // deliberate act that says the table below was re-read. + assert_eq!( + LOCAL_WIRE_CODES.len(), + 12, + "the local wire-code table changed size; re-read it and update this count" ); // `CONTENT_MISS_INCONCLUSIVE` deliberately LEFT this table: `dig-rpc-protocol` 0.10 declares // it, so it is no longer a local number and the owner answers the collision question for it. @@ -5488,6 +5564,124 @@ mod tests { } } + /// Every `-32xxx` wire number that appears anywhere in this crate's own sources, DISCOVERED by + /// reading them rather than by hand-listing them - the list is derived from the emitting sites, + /// so it cannot fall behind them. + /// + /// Comment-only lines are skipped: the docs in this crate legitimately DISCUSS numbers it does + /// not emit (another implementation's assignment, a rejected proposal), and requiring those to + /// be registered would push the guard towards being weakened rather than kept. + /// + /// A code mentioned in live code but never actually emitted - a test asserting one, say - is + /// still required to be classified. That is deliberate: classification is cheap, and the + /// alternative is teaching the scanner to recognise an "emitting site", which is exactly the + /// judgement call a forgotten registration hides behind. + fn wire_codes_mentioned_in_this_crate() -> std::collections::BTreeSet { + fn scan_line(line: &str, out: &mut std::collections::BTreeSet) { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") { + return; + } + // Scanned as BYTES, not by slicing the &str: these sources carry non-ASCII prose, and + // an arbitrary byte index into a `&str` is not guaranteed to be a char boundary. + let bytes = line.as_bytes(); + for start in 0..bytes.len().saturating_sub(5) { + if &bytes[start..start + 3] != b"-32" { + continue; + } + let digits = &bytes[start + 3..start + 6]; + // Exactly three digits: `-32000`..`-32999` is the band. A longer digit run is some + // other number that merely begins this way and is not a wire code. + if !digits.iter().all(u8::is_ascii_digit) + || bytes.get(start + 6).is_some_and(u8::is_ascii_digit) + { + continue; + } + let text = std::str::from_utf8(&bytes[start..start + 6]) + .expect("six ASCII bytes are valid UTF-8"); + if let Ok(code) = text.parse::() { + out.insert(code); + } + } + } + + fn walk(dir: &std::path::Path, out: &mut std::collections::BTreeSet) { + let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {path:?}: {e}")); + for line in text.lines() { + scan_line(line, out); + } + } + } + } + + let mut found = std::collections::BTreeSet::new(); + walk( + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")), + &mut found, + ); + found + } + + /// **Proves:** every wire number these sources contain is accounted for - either declared by + /// the taxonomy owner (`dig_rpc_protocol::ErrorCode::ALL`, resolved through + /// `seams::dig_rpc::errors::taxonomy_code` so the taxonomy is never restated here) or listed in + /// [`LOCAL_WIRE_CODES`] as a number this crate occupies that upstream does not declare. + /// + /// **Catches:** the defect that defeated the collision guard beside it - a number nobody + /// registered is invisible to a guard built over a registry. `-32033` was emitted by this crate + /// while already being `dig-node-service`'s `ControlIngressLimited`, and the old + /// `LOCAL_WIRE_CODES.len() >= 10` assertion stayed green because the number was never added. + /// Under THIS test it would have gone red without anyone remembering to register it. + #[test] + fn every_wire_code_this_crate_mentions_is_classified() { + let found = wire_codes_mentioned_in_this_crate(); + + // Scanner-liveness first: a walk that read nothing, or a comment filter that ate every + // line, would make the loop below vacuously true - the same failure mode being fixed here. + assert!( + found.len() >= 25, + "the source scan found only {} wire codes; it is not reading these sources", + found.len() + ); + for known in [CONTROL_ERROR, RESOURCE_NOT_AVAILABLE, -32602] { + assert!( + found.contains(&known), + "the scan missed {known}, which is emitted in these sources; it is not reading what it claims" + ); + } + + for number in &found { + let canonical = crate::seams::dig_rpc::errors::taxonomy_code(*number); + let local = LOCAL_WIRE_CODES.iter().find(|(n, _)| n == number); + assert!( + canonical.is_some() || local.is_some(), + "wire code {number} is in these sources but is neither declared by dig-rpc-protocol nor registered in LOCAL_WIRE_CODES" + ); + } + + // Non-vacuity, without writing the number as a literal: writing `-32033` here would itself + // be a mention this scan must classify, which is the mechanism having teeth. Built by + // arithmetic instead, it shows the classifier REJECTS the number that slipped through, so + // re-introducing it anywhere in these sources fails the loop above. + let slipped_through = CONTROL_ERROR - 1; + assert!( + crate::seams::dig_rpc::errors::taxonomy_code(slipped_through).is_none() + && !LOCAL_WIRE_CODES.iter().any(|(n, _)| *n == slipped_through), + "{slipped_through} must be unclassified, or this test cannot fail on it" + ); + assert!( + !found.contains(&slipped_through), + "{slipped_through} is back in these sources; it is already another surface's code" + ); + } + /// A per-THREAD counting allocator, installed process-wide only for the test binary. /// /// The #2160 acceptance bar is MEASURED, not reasoned: the peak-RSS test drives one cold decode @@ -6011,7 +6205,8 @@ mod tests { } /// dig_ecosystem#3285: a node with no funder registry installed — which is EVERY production - /// node until #3268 wires one — must read UNKNOWN, never an empty funded set. + /// node today, since no startup path installs one and the ticket that will is tracked + /// separately (it is not #3268) — must read UNKNOWN, never an empty funded set. /// **Catches:** a `funded_distributors_read` that defaults to `FundsNothing`, or a `Vec`/ /// `Option` return that a caller would render as `[]`. #[test] @@ -6106,6 +6301,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; (node, td) } @@ -6242,6 +6438,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; // Missing before the pull. @@ -6312,6 +6509,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6413,6 +6611,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6492,6 +6691,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -9227,9 +9427,14 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); - let statuses = resp["result"]["statuses"] + assert_eq!( + resp["result"]["statuses"]["outcome"], + json!("consulted"), + "the in-process registry read always succeeds: {resp}" + ); + let statuses = resp["result"]["statuses"]["items"] .as_array() - .expect("result.statuses is an array"); + .expect("result.statuses.items is an array"); assert_eq!(statuses.len(), 1, "one registered handle: {resp}"); let s = &statuses[0]; @@ -9302,68 +9507,28 @@ mod tests { crate::download::RequestProvenance::FirstParty, )); + assert_eq!(resp["result"]["statuses"]["outcome"], json!("consulted")); assert_eq!( - resp["result"], - json!({"statuses": []}), + resp["result"]["statuses"]["items"], + json!([]), "explicit empty list: {resp}" ); } - /// **Proves:** a zeroed `launcher_id` OR `store_id` — what an uninitialised/never-assigned - /// registry slot hex-encodes to — is never rendered as a real distributor with a - /// plausible-looking id, AND that dropping it is never silent: a `tracing::warn!` fires - /// naming the SPECIFIC zeroed field(s), so a registration bug is observable rather than - /// swallowed. This is the money-hole class the `dig-rewards-coin` driver's adversarial gates - /// found three times (an unset field that reads fine and costs the operator), plus the SPEC - /// §2.4 clause 1 defect a security + adversarial gate found in the first version of this - /// filter: an all-zero-`launcher_id`-only check that silently destroyed the evidence of a bad - /// registration, and never checked `store_id` at all. - /// - /// Distinguishes IDENTITY fields (`launcher_id`, `store_id` — a record missing either cannot - /// be attributed to any distributor, so it is EXCLUDED and logged at `WARN`) from the - /// OBSERVATION field (`root` — legitimately zero before a prover's first cycle, so it is - /// logged at `DEBUG`, never `WARN`, and never causes exclusion on its own; see the third case - /// below). The level split matters, not just the exclusion split: security measured that an - /// undifferentiated `warn!` for both cases turns steady-state log volume into (uncycled - /// provers) x (poll rate) lines an operator cannot distinguish from a real registration bug. - /// - /// **Catches:** (1) a boundary that lets an uninitialised slot answer as if it were a real - /// distributor; (2) a filter that only checks `launcher_id`, missing a registration bug that - /// zeroes `store_id` beside an otherwise-valid `launcher_id` (the exact gap security named); - /// (3) a fix that goes back to dropping the bad record with no log line at all; (4) a fix - /// that over-corrects by excluding on a zeroed `root` too, which would make a healthy, - /// just-not-yet-cycled prover invisible; (5) a fix that returns the zeroed-root record but logs - /// it at the SAME level (`warn!`) as a real identity fault, defeating the operator's ability to - /// tell the two apart; (6) a log assertion that only checks the field NAME `launcher_id` - /// appears somewhere in the log line — true unconditionally, since the log always includes - /// `launcher_id = %hex::encode(...)` as a structured field regardless of which field was - /// actually zero — rather than checking the `zeroed_fields=[...]` value AND the level. + /// **Proves:** a zeroed `root` alone (an OBSERVATION field, not an identity field) never + /// causes exclusion or a whole-call refusal — a freshly-registered prover that has not + /// completed its first cycle is a legitimate state, and a `tracing::debug!` (never `warn!`) + /// fires naming `root`, distinct from the missing-identity refusal covered by the two tests + /// below. **Catches:** an over-correction that starts refusing on a zeroed `root` too, which + /// would make a healthy, just-not-yet-cycled prover invisible. #[test] - fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { + fn get_reward_prover_status_returns_a_zeroed_root_record_with_a_debug_log() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let (node, _td) = test_node(None); - // Case 1: launcher_id itself is zeroed (the original, narrower gap). - node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( - sample_reward_prover_status([0u8; 32]), - )); - - // Case 2: launcher_id is VALID, but store_id is zeroed — the gap security named, which - // the launcher_id-only filter would have let straight through as a plausible record. - let valid_but_zeroed_store = [0xccu8; 32]; - let mut zeroed_store_status = sample_reward_prover_status(valid_but_zeroed_store); - zeroed_store_status.store_id = [0u8; 32]; - node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( - zeroed_store_status, - )); - - // Case 3: launcher_id AND store_id are both valid, but root is zeroed — a plausible - // "registered, not yet cycled" prover. Must still be RETURNED (root is not an identity - // field), and a DEBUG (never WARN) still fires naming `root` so the state stays - // observable without polluting warn-level volume with an ordinary, expected state. let valid_but_zeroed_root = [0xbbu8; 32]; let mut zeroed_root_status = sample_reward_prover_status(valid_but_zeroed_root); zeroed_root_status.root = [0u8; 32]; @@ -9371,8 +9536,6 @@ mod tests { zeroed_root_status, )); - // A real, fully-valid entry alongside all three, to prove the guard is selective, not a - // by-product of the registry being otherwise empty. let real_id = [0xaau8; 32]; node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( sample_reward_prover_status(real_id), @@ -9385,55 +9548,105 @@ mod tests { 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}" + resp["result"]["statuses"]["outcome"], + json!("consulted"), + "a zeroed root alone must never trigger the missing-identity refusal: {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). + let statuses = resp["result"]["statuses"]["items"] + .as_array() + .expect("result.statuses.items is an array"); + assert_eq!(statuses.len(), 2, "both entries are returned: {resp}"); assert!( - logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), - "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" + logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), + "expected a DEBUG line naming exactly root as zeroed: {logs}" ); assert!( - logs.contains("WARN") && logs.contains(r#"zeroed_fields=["store_id"]"#), - "expected a WARN naming exactly store_id as zeroed, got: {logs}" + !logs.contains("WARN"), + "a zeroed root alone must never log at WARN: {logs}" + ); + } + + /// **Proves (dig_ecosystem#3269 fix939 — the silent-drop defect):** a record with a missing + /// identity field (`launcher_id` or `store_id`), when the caller narrows the request with + /// `params.launcher_id` naming exactly that record, makes `dig.getRewardProverStatus` refuse + /// the WHOLE call with `REWARD_ZERO_IDENTITY` — never `{"outcome":"consulted","items":[]}`, + /// which `dig-rpc-protocol` 0.12.0's `Half::Consulted` contracts as "the complete answer" + /// (`types.rs:1666`). A filtered empty list under `Consulted` reads as "I looked, there is + /// nothing" when the truth is "I looked, found it, and discarded it" — exactly the defect + /// class this epic exists to kill. Matches `range_checked_report`'s whole-call refusal for + /// the three sibling reward-distributor handlers, asserted on the SERIALIZED JSON body + /// through the real `handle_rpc` -> `handle_rpc_as` -> `RpcDispatch::dispatch` path. + /// **Catches:** a `.filter()` that silently drops the record instead of refusing the call. + #[test] + fn get_reward_prover_status_refuses_whole_call_when_filtered_record_has_missing_identity() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + // launcher_id is VALID (so it can be named by the filter), but store_id is zeroed. + let launcher_id = [0xccu8; 32]; + let mut zeroed_store_status = sample_reward_prover_status(launcher_id); + zeroed_store_status.store_id = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_store_status, + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "expected a whole-call refusal naming REWARD_ZERO_IDENTITY, got: {resp}" ); - // A zeroed root alone must be DEBUG, not WARN — it is an ordinary pre-first-cycle state, - // not a registration bug, and sharing warn-level volume with a real identity fault would - // make an operator polling this endpoint unable to tell them apart (the exact security - // finding that split these into two levels). assert!( - logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), - "expected a DEBUG line naming exactly root as zeroed, distinct from the WARN level \ - used for a missing identity field, even though the record is still returned: {logs}" - ); - assert_eq!( - logs.matches("missing an identity field").count(), - 2, - "expected exactly one WARN per identity-missing registration (2 here: launcher_id, \ - store_id) — the zeroed-root-only case must never count as one: {logs}" + resp.get("result").is_none(), + "a refusal must carry no result at all, not an empty/consulted one: {resp}" ); + } + + /// **Proves (dig_ecosystem#3269 fix939):** the same whole-call refusal fires with NO filter + /// applied, against a MIXED registry (one missing-identity record beside an otherwise-valid + /// one) — the survivors must never be returned as a "complete" `Consulted` answer just + /// because at least one record was fine. **Catches:** a fix that only refuses when the + /// missing-identity record is the ONLY one present, still silently dropping it out of a + /// mixed set. + #[test] + fn get_reward_prover_status_refuses_whole_call_for_a_mixed_set_unfiltered() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0u8; 32]), // launcher_id itself zeroed + )); + let real_id = [0xaau8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(real_id), + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( - logs.matches("zeroed root").count(), - 1, - "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "a mixed set with one missing-identity record must refuse the WHOLE call, not \ + return the valid survivor as a complete Consulted answer: {resp}" ); } @@ -9491,7 +9704,7 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - let filtered = filtered["result"]["statuses"].as_array().unwrap(); + let filtered = filtered["result"]["statuses"]["items"].as_array().unwrap(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0]["launcher_id"], json!(hex::encode(a))); @@ -9501,7 +9714,909 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); + assert_eq!( + all["result"]["statuses"]["items"].as_array().unwrap().len(), + 2 + ); + } + + /// An in-memory `RewardsChainPort` for `dig.getRewardDistributor` / + /// `dig.listRewardDistributorCommitments` dispatch tests (dig_ecosystem#3269 unit 2) — keyed + /// per launcher id so two distinct distributors can be driven through the SAME dispatch path + /// with distinct answers, exactly the shape the money-figure subject test needs. + struct FakeRewardsChainPort { + reports: std::collections::HashMap< + [u8; 32], + Result, + >, + } + + #[async_trait::async_trait] + impl crate::rewards::port::RewardsChainPort for FakeRewardsChainPort { + async fn funded_distributors( + &self, + ) -> Result, crate::rewards::port::ChainPortError> + { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn distributor_state( + &self, + _launcher_id: crate::rewards::port::Bytes32, + ) -> Result + { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn submit_entry_writes( + &self, + _bundle: crate::rewards::port::EntryWriteBundle, + ) -> Result<(), crate::rewards::port::ChainPortError> { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn spend_new_epoch( + &self, + _launcher_id: crate::rewards::port::Bytes32, + ) -> Result<(), crate::rewards::port::ChainPortError> { + Err(crate::rewards::port::ChainPortError::Unavailable) + } + + async fn distributor_report( + &self, + launcher_id: crate::rewards::port::Bytes32, + ) -> Result + { + self.reports + .get(&launcher_id) + .cloned() + .unwrap_or(Err(crate::rewards::port::ChainPortError::Unavailable)) + } + } + + /// Derives `entry_set_stale` the way a production adapter MUST (SPEC §12.4): from the + /// CHAIN-derived last-entry-write time measured against the exported + /// [`crate::rewards::spec_constants::STALE_ENTRY_SET_SECONDS`], through the same + /// [`crate::rewards::staleness::is_entry_set_stale`] rule the engine uses — never a hardcoded + /// `172_800`, and never a prover's own self-report of its freshness. + fn derive_entry_set_stale( + reserve_base_units: u64, + last_entry_write_at: Option, + observed_at: u64, + distributor_created_at: u64, + ) -> bool { + crate::rewards::staleness::is_entry_set_stale( + &crate::rewards::port::DistributorChainState { + reserve_base_units, + entries: Vec::new(), + current_distributor_epoch: 0, + last_entry_write_at, + total_paid_out_base_units: 0, + }, + observed_at, + distributor_created_at, + ) + } + + /// A `DistributorReport` with every field distinctly derived from `seed`, so two reports built + /// from two different seeds can never accidentally collide on a real field. + /// + /// `entry_set_stale` is the one field that is NOT a free function of `seed`: it is derived from + /// this report's own `last_entry_write_at`/`observed_at` against the exported staleness bound, + /// so no fixture can carry a staleness flag its own chain-derived times contradict. With the + /// times below, every seed's report is FRESH (its write is 100_000s old, inside the bound); a + /// test that needs the stale side uses [`distributor_report_with_write_age`]. + fn sample_distributor_report( + seed: u8, + commitments: Vec, + ) -> crate::rewards::port::DistributorReport { + let first_epoch_start = 1_700_000_000 + seed as u64; + let reserve_base_units = 10_000_000 + seed as u64 * 1_000; + let last_entry_write_at = Some(1_700_100_000 + seed as u64); + let observed_at = 1_700_200_000 + seed as u64; + crate::rewards::port::DistributorReport { + launcher_id: [seed; 32], + store_id: [seed.wrapping_add(1); 32], + root: [seed.wrapping_add(2); 32], + epoch_seconds: 604_800 + seed as u64, + first_epoch_start, + payout_threshold: 1_000_000 + seed as u64, + fee_bps: 100 + seed as u16, + withdrawal_share_bps: 9_000 + seed as u16, + reserve_base_units, + entry_count: 3 + seed as u64, + current_distributor_epoch: 5 + seed as u64, + last_entry_write_at, + entry_set_stale: derive_entry_set_stale( + reserve_base_units, + last_entry_write_at, + observed_at, + first_epoch_start, + ), + commitments, + observed_at, + } + } + + /// [`sample_distributor_report`] with the chain-derived last entry write placed exactly + /// `write_age_seconds` before `observed_at`, and `entry_set_stale` re-derived from that age. + /// Lets one test drive both sides of the staleness bound without writing the bound's numeric + /// value down anywhere. + fn distributor_report_with_write_age( + seed: u8, + write_age_seconds: u64, + ) -> crate::rewards::port::DistributorReport { + let mut report = sample_distributor_report(seed, vec![]); + report.last_entry_write_at = Some(report.observed_at - write_age_seconds); + report.entry_set_stale = derive_entry_set_stale( + report.reserve_base_units, + report.last_entry_write_at, + report.observed_at, + report.first_epoch_start, + ); + report + } + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + /// **Proves:** `dig.getRewardDistributor` answers with the port's real values through the + /// REAL dispatch path (`handle_rpc` -> `handle_rpc_as` -> `RpcDispatch::dispatch`), asserted on + /// the serialized JSON body's key SET, not a Rust struct (a struct assertion cannot see a + /// serde rename or an extra field — dig_ecosystem#3269's evidence bar). + /// **Catches:** a handler that drops a field, mis-cases a key, or answers from a stub instead + /// of the port. + #[test] + fn get_reward_distributor_answers_with_real_values_through_dispatch() { + let (node, _td) = test_node(None); + let launcher_id = [0x11u8; 32]; + let report = sample_distributor_report(0x11, vec![]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "launcher_id", + "store_id", + "root", + "epoch_seconds", + "first_epoch_start", + "payout_threshold", + "fee_bps", + "withdrawal_share_bps", + "reserve_base_units", + "entry_count", + "current_distributor_epoch", + "last_entry_write_at", + "entry_set_stale", + "observed_at", + ]), + "the wire body's key SET must be exactly this — a struct assertion cannot see a wrong \ + key name or an extra field" + ); + assert_eq!( + result["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!(result["store_id"], json!(hex::encode(report.store_id))); + assert_eq!(result["root"], json!(hex::encode(report.root))); + assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); + assert_eq!(result["first_epoch_start"], json!(report.first_epoch_start)); + assert_eq!(result["payout_threshold"], json!(report.payout_threshold)); + assert_eq!(result["fee_bps"], json!(report.fee_bps)); + assert_eq!( + result["withdrawal_share_bps"], + json!(report.withdrawal_share_bps) + ); + assert_eq!( + result["reserve_base_units"], + json!(report.reserve_base_units) + ); + assert_eq!(result["entry_count"], json!(report.entry_count)); + assert_eq!( + result["current_distributor_epoch"], + json!(report.current_distributor_epoch) + ); + assert_eq!( + result["last_entry_write_at"], + json!(report.last_entry_write_at) + ); + assert_eq!(result["entry_set_stale"], json!(report.entry_set_stale)); + assert_eq!(result["observed_at"], json!(report.observed_at)); + } + + /// **Proves:** `dig.listRewardDistributorCommitments` answers with the port's real values + /// through the real dispatch path, asserted on the serialized body's key set and per-slot + /// values — including the legitimate empty-`commitments` case being distinguishable from an + /// error (it is a real `result`, not an `error`). + /// **Catches:** a handler that restates `recoverable_base_units` itself instead of echoing the + /// port's pre-computed figure, or mis-cases a key. + #[test] + fn list_reward_distributor_commitments_answers_with_real_values_through_dispatch() { + let (node, _td) = test_node(None); + let launcher_id = [0x22u8; 32]; + let slot = crate::rewards::port::CommitmentSlot { + epoch_start: 42, + clawback_puzzle_hash: [0x33u8; 32], + rewards_base_units: 1_000, + recoverable_base_units: 900, + }; + let report = sample_distributor_report(0x22, vec![slot.clone()]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "launcher_id", + "withdrawal_share_bps", + "epoch_seconds", + "commitments", + "observed_at", + ]) + ); + assert_eq!( + result["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!( + result["withdrawal_share_bps"], + json!(report.withdrawal_share_bps) + ); + assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); + assert_eq!(result["observed_at"], json!(report.observed_at)); + let commitments = result["commitments"].as_array().unwrap(); + assert_eq!(commitments.len(), 1); + let row_keys: std::collections::BTreeSet<&str> = commitments[0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + row_keys, + std::collections::BTreeSet::from([ + "epoch_start", + "clawback_puzzle_hash", + "rewards_base_units", + "recoverable_base_units", + ]) + ); + assert_eq!(commitments[0]["epoch_start"], json!(42)); + assert_eq!( + commitments[0]["clawback_puzzle_hash"], + json!(hex::encode([0x33u8; 32])) + ); + assert_eq!(commitments[0]["rewards_base_units"], json!(1_000)); + assert_eq!(commitments[0]["recoverable_base_units"], json!(900)); + } + + /// **Proves:** `dig.listRewardDistributors` is CONTROL-tier and NOT peer-reachable, and is + /// dispatched through the `Method` enum match rather than the pre-`Method::from_name` string + /// block (dig_ecosystem#3269 unit 2; #3261's money-hole rule). + #[test] + fn list_reward_distributors_is_control_tier_and_not_peer_reachable() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.listRewardDistributors"), + Some(Method::ListRewardDistributors) + ); + assert_eq!( + Method::ListRewardDistributors.tier(), + dig_rpc_protocol::Tier::Control + ); + assert!(!peer::is_peer_reachable_method( + "dig.listRewardDistributors" + )); + } + + /// **Proves:** with no funder registry installed, `dig.listRewardDistributors` answers + /// `NotConsulted` for BOTH halves — never `Consulted { items: [] }`, which would claim this + /// node checked and found nothing (SPEC §12.5 clause 6's "reassuring zero"). + /// **Catches:** a handler defaulting an unconfigured registry to an empty, "consulted" list. + #[test] + fn list_reward_distributors_with_no_registry_is_not_consulted_on_both_halves() { + let (node, _td) = test_node(None); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + resp["result"]["funded"]["outcome"], + json!("not_consulted"), + "unconfigured registry must read UNKNOWN, never an empty funded set: {resp}" + ); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted"), + "no claimable tracking exists in this crate yet — must never fabricate a checked zero: {resp}" + ); + } + + /// **Proves:** a registry that genuinely funds nothing (`FundsNothing`, the one legitimate + /// empty case) renders `funded` as `Consulted { items: [] }` — a real, checked empty list, not + /// `NotConsulted` — while `claimable` still reads `NotConsulted` since nothing here tracks it. + #[test] + fn list_reward_distributors_with_a_genuinely_empty_registry_is_consulted_empty() { + let state_dir = tempfile::tempdir().unwrap(); + // An intact record naming nobody is the ONE legitimate empty answer (`FundsNothing`) — + // distinct from no record ever written at all, which reads `NotConfigured`. + std::fs::write( + state_dir + .path() + .join(crate::rewards::funded::FUNDED_DISTRIBUTORS_FILE), + r#"{"version": 1, "distributors": []}"#, + ) + .unwrap(); + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + assert_eq!(resp["result"]["funded"]["items"], json!([])); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + + /// **Proves:** a funded identity resolved through the chain port renders as a real + /// `RewardDistributorRef` inside `funded.items`, through the REAL dispatch path. + /// **Catches:** a handler that fabricates `store_id`/`root` instead of resolving them via + /// `RewardsChainPort::distributor_report`. + #[test] + fn list_reward_distributors_resolves_a_funded_identity_through_the_chain_port() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x55u8; 32], + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let report = sample_distributor_report(0x55, vec![]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([([0x55u8; 32], Ok(report.clone()))]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + let items = resp["result"]["funded"]["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!( + items[0]["launcher_id"], + json!(hex::encode(report.launcher_id)) + ); + assert_eq!(items[0]["store_id"], json!(hex::encode(report.store_id))); + assert_eq!(items[0]["root"], json!(hex::encode(report.root))); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + + /// **Proves:** a funded identity whose per-item chain report fails refuses the WHOLE call + /// (the same `ChainPortError` response the sibling reward-distributor handlers use), rather + /// than emitting a partial list or a fabricated ref (dig_ecosystem#3308/#3309). + #[test] + fn list_reward_distributors_refuses_the_whole_call_on_a_chain_report_failure() { + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id: [0x66u8; 32], + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::new(), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "a per-item chain failure must refuse the whole call, not answer a partial result: {resp}" + ); + assert!( + resp.get("error").is_some(), + "expected an error response: {resp}" + ); + } + + /// **Proves:** `dig.getPayeeRewardClaimStatus` is CONTROL-tier, NOT peer-reachable, dispatched + /// through the `Method` enum match, and its exact serialized JSON body: `subject` is the + /// literal `"payee"`, `claim_log` is `NotConsulted` (no claim log exists in this crate yet), + /// and there is never a monetary amount or payout puzzle hash anywhere in the body. + #[test] + fn get_payee_reward_claim_status_answers_the_exact_wire_shape() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.getPayeeRewardClaimStatus"), + Some(Method::GetPayeeRewardClaimStatus) + ); + assert_eq!( + Method::GetPayeeRewardClaimStatus.tier(), + dig_rpc_protocol::Tier::Control + ); + assert!(!peer::is_peer_reachable_method( + "dig.getPayeeRewardClaimStatus" + )); + + let (node, _td) = test_node(None); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getPayeeRewardClaimStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let result = &resp["result"]; + let keys: std::collections::BTreeSet<&str> = result + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + std::collections::BTreeSet::from(["subject", "claim_log"]), + "no monetary amount, no payout puzzle hash — ever: {resp}" + ); + assert_eq!(result["subject"], json!("payee")); + assert_eq!(result["claim_log"]["outcome"], json!("not_consulted")); + assert!( + result["claim_log"].get("claims_submitted_count").is_none(), + "claims_submitted_count must live INSIDE Consulted only, never beside NotConsulted: {resp}" + ); + } + + /// **Proves:** dig_ecosystem#3269 unit 5 — a chain-derived report with a ZEROED `launcher_id` + /// (never a real distributor's identity, only what an uninitialised slot hex-encodes to, and + /// `dig-rewards-coin@v0.4.0` refuses to create one this way — #3308/#3309) refuses the WHOLE + /// call for every reward-distributor read method, rather than rendering the zero as though it + /// were real. + /// **Catches:** a handler that hex-encodes whatever the port returns with no identity check. + #[test] + fn reward_distributor_methods_refuse_a_zeroed_identity_rather_than_render_it() { + let launcher_id = [0u8; 32]; + let report = sample_distributor_report(0, vec![]); + assert_eq!( + report.launcher_id, [0u8; 32], + "fixture premise: seed 0 zeroes launcher_id" + ); + + let (node, _td) = test_node(None); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method} must refuse a zeroed identity, not render it: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_ZERO_IDENTITY"), + "{method}: {resp}" + ); + } + + // Same guard for the new list method, which resolves identity through the SAME + // `distributor_report` + `range_checked_report` path. + let state_dir = tempfile::tempdir().unwrap(); + let funded = crate::rewards::funded::FundedDistributor { + launcher_id, + store_id: None, + }; + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + assert_eq!( + registry.record(&funded), + crate::rewards::funded::RecordOutcome::Recorded + ); + assert!(node.install_funded_distributor_registry(registry)); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "dig.listRewardDistributors must refuse a zeroed identity, not render it: {resp}" + ); + assert_eq!(resp["error"]["data"]["code"], json!("REWARD_ZERO_IDENTITY")); + } + + /// **Proves:** with no chain-read adapter installed, BOTH reward-distributor methods answer a + /// distinct error — never a zero, never an empty list — and it is NOT the same machine code as + /// the `withdrawal_share_bps` refusal (so a caller can tell "try again later" apart from "this + /// distributor's own constant is broken"). + /// **Catches:** a handler that defaults to `Default::default()` or an empty result on a `None` + /// port instead of erroring. + #[test] + fn reward_distributor_methods_chain_unavailable_is_a_distinct_error_never_a_zero_or_empty() { + let (node, _td) = test_node(None); + let launcher_id = [0x44u8; 32]; + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: must not answer a result at all" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE") + ); + assert_ne!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE"), + "{method}: chain-unavailable must not share a machine code with the \ + withdrawal-share refusal" + ); + } + } + + /// **Proves:** when the port refuses because `withdrawal_share_bps` is out of range (either + /// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own + /// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code + /// — never a `0`, never an empty `commitments` list standing in for the refusal. + /// **Mutation-probe, re-runnable from this repo alone:** in `seams::dig_rpc::dispatch`, + /// replace `reward_chain_port_error_response`'s `InvalidWithdrawalShare` arm with a `result` + /// carrying `withdrawal_share_bps: 0`, then run + /// `cargo test -p dig-node-core --lib reward_distributor_methods_`. This test fails at its + /// FIRST assertion, `resp.get("result").is_none()`, for `dig.getRewardDistributor`: a refusal + /// has become an answer. Restoring the arm returns it to green with the rest of the suite + /// untouched. That is the defect it exists to catch, so it is not vacuously green. + #[test] + fn reward_distributor_methods_refuse_whole_call_on_invalid_withdrawal_share() { + let (node, _td) = test_node(None); + let launcher_id = [0x55u8; 32]; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([( + launcher_id, + Err(crate::rewards::port::ChainPortError::InvalidWithdrawalShare), + )]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: must refuse the whole call" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE") + ); + assert_ne!(resp["error"]["code"], json!(0)); + } + } + + /// **Proves:** a `withdrawal_share_bps` ABOVE the legitimate `0..=10_000` range refuses the + /// WHOLE call on BOTH reward-distributor methods, and the out-of-range figure reaches no + /// response body. The fake port hands back an `Ok` report carrying `10_001` - exactly what a + /// wrapped narrowing (`74_536 as u16` = `9_000`) or a buggy adapter would look like from this + /// seam - so the refusal proved here is the HANDLER's, with no adapter cooperation + /// (dig_ecosystem#3284). + /// **Catches:** the handler emitting the figure verbatim, and the tempting "safe" fix of + /// clamping it to `10_000`, which would report a confident 100% recoverable share for a + /// distributor whose real constant is nonsense. Both are asserted against by name. + #[test] + fn reward_distributor_methods_refuse_a_withdrawal_share_above_the_legitimate_range() { + let (node, _td) = test_node(None); + let launcher_id = [0x78u8; 32]; + let mut report = sample_distributor_report(0x78, vec![]); + report.withdrawal_share_bps = 10_001; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("result").is_none(), + "{method}: an out-of-range withdrawal share must refuse the whole call: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_INVALID_WITHDRAWAL_SHARE"), + "{method}: {resp}" + ); + // Matched in a JSON VALUE position (`:10001`), not anywhere in the body: the + // refusal MESSAGE legitimately names the bound it enforces, and asserting on the + // bare digits would fail on the honest text while saying nothing about the figure. + let body = resp.to_string(); + assert!( + !body.contains(":10001"), + "{method}: the out-of-range figure must not reach the wire: {body}" + ); + assert!( + !body.contains(":10000"), + "{method}: a clamp to 100% is a money lie, not a safe default: {body}" + ); + } + } + + /// **Proves:** the boundary is `> 10_000`, not `>= 10_000`: 10,000 basis points IS a + /// legitimate 100% withdrawal share, and both methods still ANSWER for it. + /// **Catches:** the refusal above widening into a blanket refusal - an off-by-one that would + /// blind every distributor whose funder takes the whole share, while the refusal test above + /// stayed green. Neither test alone shows the guard is selective. + #[test] + fn reward_distributor_methods_answer_at_the_ten_thousand_bps_boundary() { + let (node, _td) = test_node(None); + let launcher_id = [0x79u8; 32]; + let mut report = sample_distributor_report(0x79, vec![]); + // Stated as a LITERAL, not read from the dispatch module's constant: the bound is a + // contract figure (10,000 bps = 100%), so a change to that constant must fail here. + report.withdrawal_share_bps = 10_000; + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher_id, Ok(report))]), + })) + ); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("error").is_none(), + "{method}: a 100% share is legitimate and must be answered: {resp}" + ); + assert_eq!( + resp["result"]["withdrawal_share_bps"], + json!(10_000), + "{method}: the boundary value must be reported verbatim: {resp}" + ); + } + } + + /// **Proves:** `entry_set_stale` is threaded through, both `true` and `false`, straight from + /// the port's chain-derived figure — never hardcoded, never inverted — with BOTH cases served + /// by ONE port installed ONCE on ONE node, answered by the REQUESTED launcher id. Each + /// expectation is derived from that report's own last-entry-write age against the exported + /// `STALE_ENTRY_SET_SECONDS`; the bound's numeric value appears nowhere in this test. + /// **Catches:** a handler that hardcodes or inverts the flag, and one that answers a DIFFERENT + /// distributor's staleness for the requested launcher id. + #[test] + fn get_reward_distributor_threads_entry_set_stale_both_ways() { + let (node, _td) = test_node(None); + let bound = crate::rewards::spec_constants::STALE_ENTRY_SET_SECONDS; + // SPEC §12.4 compares with `>=`: exactly at the bound is stale, one second inside is not. + let stale_report = distributor_report_with_write_age(0x60, bound); + let fresh_report = distributor_report_with_write_age(0x61, bound - 1); + assert!( + stale_report.entry_set_stale && !fresh_report.entry_set_stale, + "the fixtures must straddle the staleness bound, or this test proves nothing" + ); + + let cases = [ + (stale_report.launcher_id, stale_report.entry_set_stale), + (fresh_report.launcher_id, fresh_report.entry_set_stale), + ]; + // ONE install for both cases: `install_reward_chain_port` is single-shot deliberately, and + // loosening it so a test could install twice would let a real double-install through. + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (stale_report.launcher_id, Ok(stale_report)), + (fresh_report.launcher_id, Ok(fresh_report)), + ]), + })) + ); + + for (launcher_id, expect_stale) in cases { + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardDistributor", + "params":{"launcher_id": hex::encode(launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + resp["result"]["entry_set_stale"], + json!(expect_stale), + "launcher {} must report its OWN chain-derived staleness", + hex::encode(launcher_id) + ); + } + } + + /// **Proves:** two distinct distributors' money figures never cross-contaminate — the class of + /// defect dig-app#403's rewards pane shipped (a per-distributor total silently summed or + /// swapped). Reads BOTH distributors' `dig.listRewardDistributorCommitments` in the same test + /// and asserts neither the summed nor the swapped figure appears in either response. + /// **Mutation-probe, re-runnable from this repo alone:** swap the two + /// `FakeRewardsChainPort` entries' `recoverable_base_units` (give `slot_a` `slot_b`'s figure + /// and vice versa) and run `cargo test -p dig-node-core --lib commitment_money_figures`. This + /// test fails on the assertion that distributor A's response does not carry B's figure; + /// undoing the swap returns it to green. No other test notices the swap, which is why this + /// one exists. + #[test] + fn commitment_money_figures_stay_attributed_to_their_own_distributor() { + let (node, _td) = test_node(None); + let launcher_a = [0x70u8; 32]; + let launcher_b = [0x71u8; 32]; + let slot_a = crate::rewards::port::CommitmentSlot { + epoch_start: 1, + clawback_puzzle_hash: [0xaau8; 32], + rewards_base_units: 5_000, + recoverable_base_units: 4_500, + }; + let slot_b = crate::rewards::port::CommitmentSlot { + epoch_start: 2, + clawback_puzzle_hash: [0xbbu8; 32], + rewards_base_units: 7_000, + recoverable_base_units: 6_300, + }; + let report_a = sample_distributor_report(0x70, vec![slot_a]); + let report_b = sample_distributor_report(0x71, vec![slot_b]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + (launcher_a, Ok(report_a)), + (launcher_b, Ok(report_b)), + ]), + })) + ); + + let resp_a = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_a)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let resp_b = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher_b)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + let recoverable_a = resp_a["result"]["commitments"][0]["recoverable_base_units"] + .as_u64() + .unwrap(); + let recoverable_b = resp_b["result"]["commitments"][0]["recoverable_base_units"] + .as_u64() + .unwrap(); + assert_eq!(recoverable_a, 4_500); + assert_eq!(recoverable_b, 6_300); + let summed = 4_500 + 6_300; + let swapped_a = 6_300; + let swapped_b = 4_500; + assert_ne!(recoverable_a, summed); + assert_ne!(recoverable_b, summed); + assert_ne!(recoverable_a, swapped_a); + assert_ne!(recoverable_b, swapped_b); } /// **Proves:** `total_paid_out_base_units`/`reserve_base_units` stay attributed to the @@ -9540,7 +10655,7 @@ mod tests { crate::download::ReadOrigin::Local, crate::download::RequestProvenance::FirstParty, )); - let statuses = resp["result"]["statuses"].as_array().unwrap(); + let statuses = resp["result"]["statuses"]["items"].as_array().unwrap(); assert_eq!(statuses.len(), 2); let find = |launcher_id: [u8; 32]| { @@ -9974,6 +11089,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), }; let before = handle_rpc( @@ -17131,6 +18247,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; // A holder for this EXACT content is known via the DHT. @@ -17184,6 +18301,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -17236,6 +18354,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; @@ -17270,6 +18389,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17313,6 +18433,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -17358,6 +18479,7 @@ mod tests { mirror_pointers: OnceLock::new(), reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), funded_distributors: OnceLock::new(), + reward_chain_port: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index f85f954b..31fb22cd 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5652,6 +5652,23 @@ pub(crate) mod tests { !reward_methods.is_empty(), "expected at least one Reward-named method in Method::ALL; found none" ); + // dig_ecosystem#3269 unit 3: a non-empty check alone would still pass if the catalogue + // grew a sixth reward method that the filter silently stopped matching (or a variant were + // renamed out from under `.contains("Reward")`) — this pins the count so the guard cannot + // start policing FEWER methods than actually exist without failing loudly. Update this + // number, deliberately, the moment `dig-rpc-protocol` adds or removes a reward method. + // + // Bumped 4 -> 5 (dig_ecosystem#3269, final leg): dig-rpc-protocol 0.12.0 added + // `dig.getPayeeRewardClaimStatus`, whose name also contains "Reward". + assert_eq!( + reward_methods.len(), + 5, + "expected exactly 5 Reward-named methods in Method::ALL (dig.listRewardDistributors, \ + dig.getRewardProverStatus, dig.getRewardDistributor, \ + dig.listRewardDistributorCommitments, dig.getPayeeRewardClaimStatus); got {}: a \ + catalogue change must update this guard deliberately, not silently narrow it", + reward_methods.len() + ); for m in reward_methods { assert!( !is_peer_reachable_method(m.name()), diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs index 787d0c7e..3a372794 100644 --- a/crates/dig-node-core/src/rewards/mod.rs +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -8,13 +8,20 @@ //! ([`staleness`]). //! //! The chain seam ([`port`]'s `RewardsChainPort`) is UNIMPLEMENTED pending -//! DIG-Network/dig_ecosystem#3249 — `dig-rewards-coin` is SPEC-only today (its `distributor` -//! module is an empty placeholder). The production adapter wired into this crate is +//! DIG-Network/dig_ecosystem#3310. What is missing is an ADAPTER, not a reader: +//! `dig-rewards-coin` 0.4.1 already ships `state::read_distributor(&impl ChainSource, +//! launcher_id) -> Result, RewardsError>` plus `ChainObservation` and +//! `clawback::recoverable_base_units`, but that reader does no socket I/O of its own — it takes a +//! caller-supplied `ChainSource` — so something must hold the chain source, drive the reader and +//! map its answers onto this trait. That adapter is built in `dig-node-service` and injected down +//! through [`crate::Node::install_reward_chain_port`] (#3310); the claim-side chain adapter is +//! tracked separately in #3307. Until then the production adapter wired into this crate is //! `port::UnavailableChainPort`, which runs no cycles and reports //! `port::ChainPortError::Unavailable` rather than a silent no-op. Every value this engine //! compares against the SPEC's numeric bounds lives in [`spec_constants`], tagged with its -//! clause, so #3249 landing its own constants is a single, deliberate migration rather than a -//! scattered one. +//! clause, so should the crate ever publish these prover-side numbers itself — 0.4.1's +//! `constants` module carries distributor-side values only, not these — the migration is a +//! single, deliberate one rather than a scattered one. //! //! # The worst-case spend, stated where a human reads it //! diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 7eb2d5a0..0fae5124 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -7,11 +7,27 @@ //! the production adapter reports [`ChainPortError::Unavailable`] and runs no cycles. See //! [`UnavailableChainPort`] for that adapter. //! -//! # dig_ecosystem#3269 unit 2 — the driver shipped, but still with no reader (blocking finding) +//! # The reader HAS shipped (corrected: the text here was written against 0.2.0) //! -//! `dig-rewards-coin` 0.2.0 is published and adds real types — `DistributorSnapshot` / -//! `DistributorSlots` (its `state` module) plus `clawback`, `comment`, `constants`, `eligibility`, -//! `entries`, `epoch`, `fund`, `launch`, `payout`. **It still ships no chain reader — blocker 1.** +//! `dig-rewards-coin` publishes **0.4.1** (latest on the crates.io index as this was written; 0.4.0 +//! read first-hand from the local registry cache, since this crate deliberately does not depend on +//! it) and it ships the chain reader the paragraph below said it withheld: +//! `state::read_distributor(&impl ChainSource, launcher_id) -> Result, +//! RewardsError>`, plus `clawback::recoverable_base_units(rewards_base_units, +//! withdrawal_share_bps) -> Option`, which refuses above `10_000` bps exactly as this seam's +//! own range check does. **Blocker 1 is CLOSED**, and what follows it described a crate two +//! releases old; it is kept only because the SHAPE argument it makes still holds. +//! +//! What dig-node still lacks is an ADAPTER, which is a different thing from a reader: +//! `read_distributor` takes a caller-supplied `ChainSource` and does no socket I/O of its own, so +//! something must hold the chain source, call the reader and map its answers onto this trait. That +//! is dig_ecosystem#3310's job, in `dig-node-service`, injected down through +//! [`crate::Node::install_reward_chain_port`]. It is NOT #3249, the driver ticket: a crate that +//! does no I/O can never be the adapter, so every "until #3249 lands" written about an adapter was +//! a pointer at a ticket that structurally cannot ship it — and a blocker filed on such a ticket +//! is never read. +//! +//! The historical 0.2.0 finding, for the reasoning it carries: //! 0.2.0's own `state.rs:1-31` module doc says so directly: SPEC §12.1's `read_distributor` "does not //! publish one, deliberately" — the implementation that existed applied //! `RewardDistributor::from_parent_spend` to the eve coin's spend (the launch inner puzzle) instead @@ -41,24 +57,28 @@ //! before any of `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` / //! `dig.listRewardDistributors`'s `funded` half can answer honestly. //! -//! So a "real" `RewardsChainPort` adapter over 0.2.0 cannot honestly answer ANY of the four trait -//! methods with live chain data yet: `funded_distributors` has no identity source, and -//! `distributor_state`/`submit_entry_writes`/`spend_new_epoch` all need the withheld reader (a spend -//! needs the live singleton coin `read_distributor` would supply). Writing one anyway — either by +//! So no adapter can honestly live HERE, which is a narrower claim than the one this paragraph used +//! to make: `funded_distributors` still has no identity source (blocker 2, below, and still true), +//! and the reader 0.4 does ship needs a `ChainSource` that `dig-node-core` deliberately does not +//! hold — wiring one up in this crate would re-add the `dig-rewards-coin` dependency unit 0 removed. Writing one anyway — either by //! reimplementing `read_distributor` myself or by inventing a funded-distributor registry with no //! writer — would be exactly the kind of restated, unreviewed money-shape work SPEC §0.1 clause 1 and //! this crate's own withholding of a broken reader argue against, and is the shape fork this ticket's //! kernel invariant 6 says to escalate rather than guess. Escalated to the L1, and settled: no new -//! adapter and no dispatch arm land until a reader (0.3.0+) and the funder-ownership registry both -//! exist. **`dig.listRewardDistributors` stays `-32601` deliberately** — serving it through -//! `UnavailableChainPort` was considered and rejected: it would be a false capability signal (a -//! feature-probe or `rpc.discover` reading the method as implemented when it always errors) and the -//! exact "dispatch surface with no function behind it" pattern DIG-Network/dig-node#593 was the last -//! PR allowed to land on. `UnavailableChainPort` remains the only production adapter for now — still -//! correct, since every real call would fail for one of the two reasons above regardless. No +//! ADAPTER lands here until #3310 wires one in `dig-node-service`. The DISPATCH half of that +//! settlement has since been revisited and has landed: **`dig.listRewardDistributors` is served**, +//! by `Some(Method::ListRewardDistributors)` in `seams/dig_rpc/dispatch.rs`, and it is honest about +//! what it knows — the `funded` half reads this node's own funded-distributor identities, and the +//! `claimable` half is always `NotConsulted` because no claim-side tracking exists here to consult. +//! What is still missing is the adapter: `install_reward_chain_port` has no non-test caller +//! (dig_ecosystem#3310), so `UnavailableChainPort` remains the only production adapter and every +//! port-backed method answers `REWARD_CHAIN_UNAVAILABLE` in production. That is a truthful +//! unavailability signal from a method that exists, not the "dispatch surface with no function +//! behind it" pattern DIG-Network/dig-node#593 was the last PR allowed to land on: the dispatch arm +//! does real reads and names precisely what it could not reach. No //! `dig-rewards-coin` dependency is added by this unit: an unused dependency with no consumer is -//! inert weight and would want whichever version ships the reader (0.3.0+), not 0.2 — add it in the -//! unit that actually consumes it. +//! inert weight; the version it will want is whatever is current when #3310 adds it in +//! `dig-node-service`, the unit that actually consumes it. Do not add it here. use super::admission::AdmittedPeer; use async_trait::async_trait; @@ -128,12 +148,108 @@ pub enum ChainPortError { /// No chain source is wired yet — the [`unavailable`] adapter's only answer, and what any real /// adapter should answer for an unreachable chain too (SPEC §12.2 clause 4). Unavailable, + /// dig_ecosystem#3269/#3284/#3303: the distributor's `withdrawal_share_bps` (a `u64` on the + /// puzzle) either does not fit the wire's `u16` domain or exceeds the legitimate `0..=10_000` + /// bps range. The adapter MUST refuse the WHOLE [`RewardsChainPort::distributor_report`] call + /// rather than silently narrowing (`as u16` would wrap `65_536` to `0`) or omitting the figure: + /// `withdrawal_share_bps` is curried once per distributor (launch-time, immutable), so an + /// invalid value can never affect one row of a caller's answer and not another. Refusing the + /// whole call here therefore blinds zero good rows and needs no wire change — see the module + /// doc on [`DistributorReport`]. + InvalidWithdrawalShare, + /// dig_ecosystem#3269 unit 5: the report's `launcher_id` or `store_id` is the all-zero + /// `Bytes32` — never a real distributor's or module's identity, only what an + /// uninitialised/never-assigned slot hex-encodes to. `dig-rewards-coin@v0.4.0` itself refuses + /// to create a distributor with a zero identity (#3308/#3309), so a chain-derived report + /// naming one is not a legitimate "young distributor" case the way a zeroed `root` alone can + /// be — it is a malformed answer, and every reward-distributor read handler (`GetRewardDistributor`, + /// `ListRewardDistributorCommitments`, `ListRewardDistributors`) refuses the whole call rather + /// than render it, matching the zeroed-identity guard `GetRewardProverStatus` already applies. + ZeroIdentity, /// A chain answered but the call failed for a reason worth a message (bounded before logging — /// SPEC §3.7 clause 4 applies to every attacker-adjacent string, and a chain error is not /// exempt). Other(String), } +/// One clawback commitment slot, as `dig.listRewardDistributorCommitments` (SPEC §7.4 clause 5) +/// needs it. +/// +/// `recoverable_base_units` is the adapter's PRE-COMPUTED share — never restated by a caller of +/// this port, and never recomputed by `dig-node-core` itself. NO production adapter exists yet, +/// and no crate in this seam depends on `dig-rewards-coin` today: the adapter that WILL compute +/// this figure is dig_ecosystem#3310's, in `dig-node-service` (that ticket names both +/// `distributor_report` and `Node::install_reward_chain_port` explicitly), and as of this writing +/// that crate's manifest declares no such dependency. When it lands it will be the one crate in +/// this seam that depends on `dig-rewards-coin` (dig_ecosystem#3269 unit 0 removed that dependency +/// from THIS crate deliberately), and it will compute this figure with +/// `dig_rewards_coin::recoverable_base_units` — that crate's own tested, simulator-bound +/// restatement of the puzzle's share arithmetic (u128 intermediate, multiply-then-divide, +/// truncated; see that function's doc for the equality proof against `chia-sdk-driver`). If +/// `withdrawal_share_bps` does not fit `u16` or exceeds `10_000`, that adapter must refuse the +/// WHOLE [`RewardsChainPort::distributor_report`] call with +/// [`ChainPortError::InvalidWithdrawalShare`] instead of returning a `CommitmentSlot` with a +/// wrong, zeroed or omitted `recoverable_base_units` — see that variant's doc for why a +/// per-distributor curried value makes a whole-call refusal the correct shape. The same range is +/// ALSO enforced at the dispatch seam (`seams::dig_rpc::dispatch`'s `range_checked_report`, +/// dig_ecosystem#3284), so an adapter that forgets cannot put an out-of-range share on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitmentSlot { + /// The distributor epoch this commitment slot funds. + pub epoch_start: u64, + /// The chain's `clawback_ph`: the puzzle hash whose key holder alone may claw this slot back + /// (SPEC §7.4 clause 3) — an entitlement fact, never a display label. + pub clawback_puzzle_hash: Bytes32, + /// The committed amount, in base units, as the puzzle records it. + pub rewards_base_units: u64, + /// The amount actually recoverable on clawback, in base units. See the type doc: always + /// pre-computed by the adapter, never by a caller of this trait. + pub recoverable_base_units: u64, +} + +/// One distributor's chain-derived report — everything `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` (dig_ecosystem#3269 units 1-2) need for one launcher id, +/// from the ONE port call [`RewardsChainPort::distributor_report`] designs once so neither handler +/// can diverge from the other's view of the same distributor. +/// +/// Deliberately a NEW type, not a widened [`DistributorChainState`]: that type is the prover cycle +/// engine's own shape (SPEC §2.3, §8, §12.4, dig_ecosystem#3250) and widening it would reach into +/// that ticket's territory for a need this one does not share (`fee_bps`, `withdrawal_share_bps`, +/// commitments, and the distributor's launch constants are irrelevant to the prover cycle). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorReport { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, + /// The payout epoch length, in seconds — a launch-curried, immutable distributor constant. + pub epoch_seconds: u64, + /// Unix seconds the first epoch started. + pub first_epoch_start: u64, + /// The reserve threshold, in base units, that triggers a payout. + pub payout_threshold: u64, + /// The distributor's fee, in basis points. + pub fee_bps: u16, + /// Already narrowed to the wire's `u16` domain and validated `<= 10_000` by the adapter — see + /// [`ChainPortError::InvalidWithdrawalShare`] for what happens when the chain's raw `u64` + /// constant fails either check. + pub withdrawal_share_bps: u16, + pub reserve_base_units: u64, + pub entry_count: u64, + pub current_distributor_epoch: u64, + /// Unix seconds of the most recent entry-set write on chain, if any. `None` is a positive + /// fact (no write has ever happened since launch), never "unknown" — SPEC §2.4 clause 1. + pub last_entry_write_at: Option, + /// SPEC §12.4: computed by the adapter from the chain-derived write history against + /// `dig_rewards_coin::STALE_ENTRY_SET_SECONDS` at read time — never self-reported by a + /// possibly-wedged prover loop, and never a hardcoded constant in this crate or its callers. + pub entry_set_stale: bool, + /// One entry per outstanding commitment slot. Empty is legitimate (SPEC §7.4 clause 5): a + /// distributor funded only via `AddIncentives` has no clawback-eligible slots at all. + pub commitments: Vec, + /// Unix seconds this report was assembled. + pub observed_at: u64, +} + /// Reads and the one write this engine needs from the reward-distributor chain state. Derived from /// the SPEC's described surface (§1.3 reads, §6.3 write), not from `dig-rewards-coin`'s internals. #[async_trait] @@ -156,15 +272,30 @@ pub trait RewardsChainPort: Send + Sync { /// not a conflict, and neither MUST treat a not-yet-rolled epoch as an error or assume the /// other already did it. async fn spend_new_epoch(&self, launcher_id: Bytes32) -> Result<(), ChainPortError>; + + /// SPEC §2.6/§7.4/§12.4, dig_ecosystem#3269 units 1-2: one distributor's full chain-derived + /// report, feeding both `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` + /// from a single call — see [`DistributorReport`]'s doc for why this is a new type rather than + /// a widened [`DistributorChainState`], and [`ChainPortError::InvalidWithdrawalShare`] for the + /// one refusal path this call can produce beyond [`ChainPortError::Unavailable`]. + async fn distributor_report( + &self, + launcher_id: Bytes32, + ) -> Result; } -/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// The production adapter until dig_ecosystem#3310 lands: reports /// [`ChainPortError::Unavailable`] on every call and runs no cycles. /// /// This is the named state `ChainSourceUnavailable` (SPEC §2.3), not a silent no-op — a no-op that -/// reported progress would be the exact honesty violation §2.4 forbids. When #3249 ships, this -/// adapter is replaced with one that calls the real driver through this same trait; nothing above -/// this seam changes. +/// reported progress would be the exact honesty violation §2.4 forbids. #3310 replaces it with an +/// adapter built in `dig-node-service` over `dig-rewards-coin`'s reader and injected through +/// [`crate::Node::install_reward_chain_port`]; nothing above this seam changes. +/// +/// This used to cite #3249, the `dig-rewards-coin` DRIVER ticket. That was a dead pointer: the +/// driver crate does no socket I/O — `read_distributor` takes a caller-supplied `ChainSource` — so +/// it can never be this adapter, and #3310 is the ticket that owns it (it names both +/// `distributor_report` and the install call). pub struct UnavailableChainPort; #[async_trait] @@ -187,6 +318,13 @@ impl RewardsChainPort for UnavailableChainPort { async fn spend_new_epoch(&self, _launcher_id: Bytes32) -> Result<(), ChainPortError> { Err(ChainPortError::Unavailable) } + + async fn distributor_report( + &self, + _launcher_id: Bytes32, + ) -> Result { + Err(ChainPortError::Unavailable) + } } #[cfg(test)] @@ -217,5 +355,9 @@ mod tests { port.spend_new_epoch([0u8; 32]).await, Err(ChainPortError::Unavailable) ); + assert_eq!( + port.distributor_report([0u8; 32]).await, + Err(ChainPortError::Unavailable) + ); } } diff --git a/crates/dig-node-core/src/rewards/spec_constants.rs b/crates/dig-node-core/src/rewards/spec_constants.rs index 69a60b3b..fe10513a 100644 --- a/crates/dig-node-core/src/rewards/spec_constants.rs +++ b/crates/dig-node-core/src/rewards/spec_constants.rs @@ -3,9 +3,11 @@ //! # Byte-identical contract //! //! Every value below is copied verbatim from the normative spec, each tagged with the clause it -//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` is -//! still SPEC-only (`pub mod distributor {}`, DIG-Network/dig_ecosystem#3249): the moment #3249 -//! lands and publishes these as its own constants, this file MUST be deleted and every reference +//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` does +//! not publish these prover-side numbers: 0.4.1 ships the reader (`state::read_distributor`) and +//! the share arithmetic (`clawback::recoverable_base_units`), and its `constants` module carries +//! distributor-side values only. Should the crate ever publish these as its own constants, this +//! file MUST be deleted and every reference //! MUST move to `dig_rewards_coin::*`. That migration is the parent's call, not this lane's — do //! not relitigate it here and do not let a second copy of any of these numbers exist anywhere else //! in this crate. diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs index 7f5874eb..89bfcd16 100644 --- a/crates/dig-node-core/src/rewards/writes.rs +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -241,7 +241,8 @@ pub trait WriteBoundStore: Send + Sync { /// The fail-closed default until a real backend is wired: every call errors, so /// [`PersistedEntryWriter::decide`] refuses to submit anything rather than run the write bounds /// unbounded across a restart. This is deliberately the production default TODAY — the chain port -/// itself is `UnavailableChainPort` until #3249 lands, so this adapter costs nothing operationally +/// itself is `UnavailableChainPort` until #3310 lands the `RewardsChainPort` adapter (it names +/// both `distributor_report` and `install_reward_chain_port`), so this adapter costs nothing operationally /// yet and closes the money hole the moment either seam is wired. pub struct NoPersistence; diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 85be4b2e..46352a0d 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -17,6 +17,7 @@ use serde_json::{json, Value}; +use crate::rewards::port::{ChainPortError, DistributorReport}; use crate::Node; // The relocated body below calls a number of crate-root private helpers (`rpc_err`, // `parse_store_id_arg`, `pin_request_root`, …) UNQUALIFIED, exactly as it did when it lived in @@ -34,6 +35,112 @@ use crate::*; /// own surface. const ENGINE_WARMING: i64 = -32002; +/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is +/// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all). +/// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`] below — a caller must be able to tell +/// "ask me again once the adapter lands" apart from "this distributor's own constant is out of +/// range". Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, +/// `-32032`), but carries its own `data.code` machine string so the two are still distinguishable +/// in the body. +const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; + +/// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's +/// `withdrawal_share_bps` does not fit the wire's `u16` domain or exceeds the legitimate +/// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s +/// doc for why a per-distributor curried value makes that the correct shape, never a `0` or an +/// omitted field. +/// +/// Discriminated by `data.code` on [`CONTROL_ERROR`]'s `-32032`, exactly like +/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`]: the shared wire taxonomy (`lib.rs`'s canonical catalogue) +/// registers no code beyond `-32032`, and minting a fresh number locally would put this node +/// outside the byte-identical contract it declares it follows — another implementation would have +/// no way to read it. The next number is not even free: `dig-node-service` already spends it on an +/// unrelated ingress refusal. +const REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE: &str = "REWARD_INVALID_WITHDRAWAL_SHARE"; + +/// dig_ecosystem#3269 unit 5: the machine code for [`ChainPortError::ZeroIdentity`] — a +/// zeroed `launcher_id`/`store_id` refused rather than rendered, matching +/// `REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`'s sibling shape. +const REWARD_ZERO_IDENTITY_MACHINE: &str = "REWARD_ZERO_IDENTITY"; + +/// Maps a [`ChainPortError`] to the JSON-RPC error response for both reward-distributor read +/// methods (dig_ecosystem#3269 unit 2) — one mapping so `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` can never disagree about how a given port failure reads +/// on the wire. +fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value { + match error { + ChainPortError::Unavailable => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } + }}), + ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", + "data": { "code": REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE, "origin": "control" } + }}), + ChainPortError::ZeroIdentity => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "distributor report carries a zeroed launcher_id or store_id, which is never a real identity", + "data": { "code": REWARD_ZERO_IDENTITY_MACHINE, "origin": "control" } + }}), + ChainPortError::Other(msg) => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": format!("reward-distributor chain read failed: {msg}"), + "data": { "code": "CONTROL_ERROR", "origin": "control" } + }}), + } +} + +/// The largest legitimate `withdrawal_share_bps`: 10,000 basis points IS 100%, so this is an +/// inclusive bound and `10_000` itself is a valid distributor constant, not an error. +const MAX_WITHDRAWAL_SHARE_BPS: u16 = 10_000; + +/// Refuses a report whose `withdrawal_share_bps` is outside the legitimate `0..=10_000` range +/// before any part of it reaches the wire (dig_ecosystem#3284). +/// +/// The port's own adapter is contracted to refuse this (`ChainPortError::InvalidWithdrawalShare`), +/// but that contract is enforced NOWHERE at this seam unless it is checked here: a chain constant +/// of `74_536` narrowed into the wire's `u16` renders as `9_000` — an ordinary-looking 90% share — +/// and `65_536` renders as `0`. Either is a figure a funder reads before deciding whether to claw +/// back, and neither looks wrong, so the handler cannot delegate the check to the thing it is +/// reading from. +/// +/// Refuses rather than CLAMPS deliberately. Clamping `74_536` to `10_000` would hand back a +/// confident "100% recoverable" for a distributor whose real constant is nonsense — a money lie +/// dressed as a safe default. Refusing the WHOLE call costs no good data either: +/// `withdrawal_share_bps` is curried once per distributor at launch, so every commitment slot in +/// one response shares the one invalid value and there is no honest row to keep. +fn range_checked_report(report: DistributorReport) -> Result { + if report.withdrawal_share_bps > MAX_WITHDRAWAL_SHARE_BPS { + return Err(ChainPortError::InvalidWithdrawalShare); + } + // dig_ecosystem#3269 unit 5: a zeroed `launcher_id` or `store_id` is never a real distributor's + // or module's identity (see `ChainPortError::ZeroIdentity`'s doc) — refuse rather than render. + if report.launcher_id == [0u8; 32] || report.store_id == [0u8; 32] { + return Err(ChainPortError::ZeroIdentity); + } + Ok(report) +} + +/// Parses `params.launcher_id` (64-hex) into the port's own `[u8; 32]` shape +/// (`rewards::port::Bytes32`) for `dig.getRewardDistributor` / +/// `dig.listRewardDistributorCommitments`. +fn parse_launcher_id_arg(params: &Value) -> Result<[u8; 32], String> { + let s = params + .get("launcher_id") + .and_then(Value::as_str) + .ok_or_else(|| "params.launcher_id must be a 64-hex string".to_string())?; + let h = s.trim_start_matches("0x"); + if h.len() != 64 { + return Err(format!("launcher_id must be 64-hex: {s}")); + } + let bytes = hex::decode(h).map_err(|_| format!("launcher_id is not hex: {s}"))?; + bytes + .try_into() + .map_err(|_: Vec| format!("launcher_id must be 32 bytes (64 hex): {s}")) +} + /// Decide the miss error for a request that fell all the way through with no configured upstream /// (dig_ecosystem#2097): `(code, message)`. /// @@ -775,10 +882,11 @@ impl RpcDispatch for Node { // mTLS peer surface (absent from `is_peer_reachable_method`; // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover - // loop) — a REAL read of a real, currently-empty registry, so `{"statuses": []}` - // means "this node runs no prover loops" and stays true right up until #3265 - // registers one, at which point this same read starts returning it with no dispatch - // change. Never serializes the internal `rewards::state::RewardProverStatus` + // loop) — a REAL read of a real, currently-empty registry, so + // `{"statuses":{"outcome":"consulted","observed_at":N,"items":[]}}` means "this node + // runs no prover loops", genuinely read rather than assumed: an empty `items` under a + // `consulted` outcome. It stays true right up until #3265 registers one, at which + // point this same read starts returning it with no dispatch change. Never serializes the internal `rewards::state::RewardProverStatus` // directly (it is `camelCase`-tagged; the wire struct is snake_case) — every field is // mapped explicitly by `reward_prover_status_to_wire`. Some(Method::GetRewardProverStatus) => { @@ -787,40 +895,49 @@ impl RpcDispatch for Node { .get("launcher_id") .and_then(Value::as_str) .map(str::to_ascii_lowercase); - let statuses: Vec = node - .reward_prover_status_snapshots() + let snapshots = node.reward_prover_status_snapshots(); + // dig_ecosystem#3269 fix939: a zeroed `launcher_id` or `store_id` is never a real + // distributor's or module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. + // An earlier version of this handler EXCLUDED such a record with a `warn!` and + // still answered `Half::Consulted` with the survivors — but `Consulted`'s + // contract (dig-rpc-protocol 0.12.0 `types.rs:1666`) is "the complete answer", so + // that exclusion was itself the silent-drop defect this epic exists to kill: the + // wire said "I looked, there is nothing" when the truth was "I looked, found it, + // and discarded it." This now REFUSES THE WHOLE CALL instead — matching + // `range_checked_report`'s whole-call refusal for the three sibling + // reward-distributor handlers above — checked before the `launcher_id` filter is + // applied, so a caller who narrows to exactly the bad record is refused too, not + // handed a reassuring empty list. + for s in &snapshots { + let zeroed = zeroed_fields(s); + if is_missing_identity(&zeroed) { + tracing::warn!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status registration is missing an identity field; refusing dig.getRewardProverStatus rather than answering a Consulted result with it silently dropped" + ); + return reward_chain_port_error_response( + &id, + &ChainPortError::ZeroIdentity, + ); + } + } + let statuses: Vec = snapshots .into_iter() - // A zeroed `launcher_id` or `store_id` is never a real distributor's or - // module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. Excluding - // such a record rather than presenting it as a real one avoids the money-hole - // class the driver's gates found three times (an unset field that reads fine - // and costs the operator), BUT exclusion alone would silently destroy the - // evidence that a registration bug happened — the exact §2.4 clause 1 - // violation a security + adversarial gate found in the first version of this - // filter (dig-node#595 review round). So this is never a silent drop: a - // `tracing::warn!` fires naming which field(s) were zero, making a bad - // registration observable, and the record is excluded. - // - // A zeroed `root` alone is different: it is an OBSERVATION (the prover's most - // recent cycle), not an identity, and a freshly-registered prover that has not - // completed its first cycle plausibly has a zero `root` legitimately. Excluding - // it on that basis alone would make a healthy, just-not-yet-cycled prover - // invisible — worse than the defect this guard exists to prevent. So this case - // is `tracing::debug!`, not `warn!`: an ordinary, expected state rather than a - // fault, kept out of `warn!`-level volume so an operator polling this endpoint - // is never shown (uncycled provers) x (poll rate) lines indistinguishable from - // a real registration bug. The record is still returned either way. + // A zeroed `root` alone is different from a missing identity: it is an + // OBSERVATION (the prover's most recent cycle), not an identity, and a + // freshly-registered prover that has not completed its first cycle plausibly + // has a zero `root` legitimately. Refusing on that basis would make a healthy, + // just-not-yet-cycled prover invisible — worse than the defect this guard + // exists to prevent. So this case is `tracing::debug!`, not `warn!`, and the + // record is still returned. (Every record reaching this point has already + // passed the missing-identity check above, so `zeroed_fields` here can only + // ever name `root`.) .filter(|s| { let zeroed = zeroed_fields(s); - if is_missing_identity(&zeroed) { - tracing::warn!( - launcher_id = %hex::encode(s.launcher_id), - store_id = %hex::encode(s.store_id), - root = %hex::encode(s.root), - zeroed_fields = ?zeroed, - "reward-prover status registration is missing an identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" - ); - } else if !zeroed.is_empty() { + if !zeroed.is_empty() { tracing::debug!( launcher_id = %hex::encode(s.launcher_id), store_id = %hex::encode(s.store_id), @@ -829,7 +946,7 @@ impl RpcDispatch for Node { "reward-prover status has a zeroed root; likely no cycle observed yet, returning it anyway" ); } - !is_missing_identity(&zeroed) + true }) .filter(|s| match &filter_launcher_id { Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), @@ -837,7 +954,218 @@ impl RpcDispatch for Node { }) .map(reward_prover_status_to_wire) .collect(); - let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; + // dig-rpc-protocol 0.12.0 migration: `statuses` moved from a bare `Vec` to + // `Half` (SPEC §12.5 clause 6's "reassuring zero" rule). + // This registry read never fails — it is an in-process `RwLock` read, not a + // fallible chain or disk read — so it is always `Consulted`, dated at the + // moment this response was assembled. + use crate::rewards::state::Clock as _; + let result = dig_rpc_protocol::types::GetRewardProverStatusResult { + statuses: dig_rpc_protocol::types::Half::Consulted { + observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + items: statuses, + }, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL + // plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method` + // (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY — + // never the local prover loop's self-reported state (see `GetRewardProverStatus` + // above for that). Goes entirely through `rewards::port::RewardsChainPort`: this + // crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0). + Some(Method::GetRewardDistributor) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let launcher_id = match parse_launcher_id_arg(¶ms) { + Ok(id) => id, + Err(msg) => return rpc_err(&id, -32602, &msg), + }; + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + // Range-check at THIS seam, not only in the adapter: see + // `range_checked_report` for why an out-of-range share must refuse here. + let report = match port + .distributor_report(launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + let result = dig_rpc_protocol::types::GetRewardDistributorResult { + launcher_id: hex::encode(report.launcher_id), + store_id: hex::encode(report.store_id), + root: hex::encode(report.root), + epoch_seconds: report.epoch_seconds, + first_epoch_start: report.first_epoch_start, + payout_threshold: report.payout_threshold, + fee_bps: report.fee_bps, + withdrawal_share_bps: report.withdrawal_share_bps, + reserve_base_units: report.reserve_base_units, + entry_count: report.entry_count, + current_distributor_epoch: report.current_distributor_epoch, + last_entry_write_at: report.last_entry_write_at, + entry_set_stale: report.entry_set_stale, + observed_at: report.observed_at, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5) + // — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments` + // empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot + // is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see + // `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here). + Some(Method::ListRewardDistributorCommitments) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let launcher_id = match parse_launcher_id_arg(¶ms) { + Ok(id) => id, + Err(msg) => return rpc_err(&id, -32602, &msg), + }; + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + // Range-check at THIS seam, not only in the adapter: see + // `range_checked_report` for why an out-of-range share must refuse here. + let report = match port + .distributor_report(launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + let commitments: Vec = report + .commitments + .iter() + .map(|c| dig_rpc_protocol::types::RewardDistributorCommitment { + epoch_start: c.epoch_start, + clawback_puzzle_hash: hex::encode(c.clawback_puzzle_hash), + rewards_base_units: c.rewards_base_units, + recoverable_base_units: c.recoverable_base_units, + }) + .collect(); + let result = dig_rpc_protocol::types::ListRewardDistributorCommitmentsResult { + launcher_id: hex::encode(report.launcher_id), + withdrawal_share_bps: report.withdrawal_share_bps, + epoch_seconds: report.epoch_seconds, + commitments, + observed_at: report.observed_at, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — CONTROL plane, + // same guard shape as the other reward handlers above. Two independently-consulted + // halves (`funded` / `claimable`), each a `Half` — SPEC §12.5 + // clause 6's "reassuring zero" rule applies to EACH half separately. + // + // `funded`: this node's OWN identity registry + // (`rewards::funded::FundedDistributorRegistry`, dig_ecosystem#3285) says WHICH + // launcher ids this node funds; `Node::funded_distributors_read` is matched with no + // wildcard arm so a future variant added to `FundedDistributorsRead` fails this match + // at compile time instead of silently falling into the wrong half. Once the identity + // set answers, each launcher id's CURRENT `(store_id, root)` is resolved through the + // same chain-read port `dig.getRewardDistributor` uses + // (`RewardsChainPort::distributor_report`) — `FundedDistributor` carries identity + // only (see that module's doc), never a root, so a ref cannot be assembled from the + // registry alone. A per-item chain-report failure refuses the WHOLE call with the + // same `ChainPortError` response the sibling handlers use, rather than emitting a + // partial list or a fabricated store_id/root (dig_ecosystem#3308/#3309: a zero hash + // rendered as though real is the same defect family as an empty list standing in for + // an error). + // + // `claimable`: distributors this node holds a MIRROR claim to but does not fund. No + // such tracking exists anywhere in this crate today (see the module search backing + // this comment — dig_ecosystem#3269 unit 2 wires only the funder side), so this half + // is honestly `NotConsulted`: nothing looked, because nothing here can look yet. It + // is NOT `Consulted { items: [] }` — that would claim this node checked and found no + // claimable distributor, which is not true; the truth is no check exists. + Some(Method::ListRewardDistributors) => { + use crate::rewards::state::Clock as _; + let now = crate::rewards::state::SystemClock.now_unix_seconds(); + + let identities: Vec = + match node.funded_distributors_read() { + crate::rewards::funded::FundedDistributorsRead::Funded(v) => v, + crate::rewards::funded::FundedDistributorsRead::FundsNothing => Vec::new(), + crate::rewards::funded::FundedDistributorsRead::NotConfigured(_) + | crate::rewards::funded::FundedDistributorsRead::PersistedStateCorrupt { + .. + } + | crate::rewards::funded::FundedDistributorsRead::IoFailed { .. } => { + return json!({"jsonrpc":"2.0","id":id,"result": + dig_rpc_protocol::types::ListRewardDistributorsResult { + funded: dig_rpc_protocol::types::Half::NotConsulted { + observed_at: now, + }, + claimable: dig_rpc_protocol::types::Half::NotConsulted { + observed_at: now, + }, + } + }); + } + }; + + let mut funded_refs = Vec::with_capacity(identities.len()); + for identity in identities { + let Some(port) = node.reward_chain_port() else { + return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + }; + let report = match port + .distributor_report(identity.launcher_id) + .await + .and_then(range_checked_report) + { + Ok(report) => report, + Err(e) => return reward_chain_port_error_response(&id, &e), + }; + funded_refs.push(dig_rpc_protocol::types::RewardDistributorRef { + launcher_id: hex::encode(report.launcher_id), + store_id: hex::encode(report.store_id), + root: hex::encode(report.root), + }); + } + + let result = dig_rpc_protocol::types::ListRewardDistributorsResult { + funded: dig_rpc_protocol::types::Half::Consulted { + observed_at: now, + items: funded_refs, + }, + claimable: dig_rpc_protocol::types::Half::NotConsulted { observed_at: now }, + }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } + // dig.getPayeeRewardClaimStatus (dig_ecosystem#3268/#3269 unit 3, SPEC §12.5) — + // CONTROL plane: loopback admin / in-process FFI ONLY, absent from + // `is_peer_reachable_method` (`reward_methods_tier_guard.rs` fails closed on that). + // Dispatched through `Method::from_name(..)` like every other reward method — never + // the string pre-match above the enum, which bypasses this tier guard entirely + // (dig_ecosystem#3261: a reward RPC reachable by a peer is a money hole). + // + // `subject` is always the literal `PayeeSubject::Payee` (SPEC §12.5: this node + // answers as a payee, never as a funder — see `PayeeClaimStatus`'s doc for the + // 250x-overstatement defect that shipped when a renderer inferred the subject from + // the endpoint instead of reading it off the payload). + // + // `claim_log`: this crate holds no claim log anywhere — the claim-submission adapter + // is dig_ecosystem#3310's, in `dig-node-service`, injected downward (see + // `rewards::port`'s module doc; #3249 is a dead pointer for it, see the doc there). + // So `claims_submitted_count` has never been read here and the honest answer is + // `NotConsulted`, dated at the moment this responder established it has no log to + // read — never `Consulted { claims_submitted_count: 0 }`, which would be exactly the + // "reassuring zero" SPEC §12.5 clause 6 forbids: a confident zero beside a fresh + // timestamp, indistinguishable from "read the log, found nothing". + // + // No monetary amount, ever, and no payout puzzle hash — see `PayeeClaimStatus`'s doc. + // No params type: this call takes none. + Some(Method::GetPayeeRewardClaimStatus) => { + use crate::rewards::state::Clock as _; + let result = dig_rpc_protocol::types::PayeeClaimStatus { + subject: dig_rpc_protocol::types::PayeeSubject::Payee, + claim_log: dig_rpc_protocol::types::ClaimLogObservation::NotConsulted { + observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + }, + }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } Some(Method::CacheSetCapBytes) => { diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 54b40044..2665d69f 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,11 +96,12 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.11 line that +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.12 line that /// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`), the /// recursive-ask contract this node adopted (`GetAvailabilityParams::budget_ms` / `::ask_id`, /// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`), AND (#3269) the -/// reward RPC surface (`Method::GetRewardProverStatus` et al., all `Tier::Control`). +/// reward RPC surface (`Method::GetRewardProverStatus`, `Method::ListRewardDistributors`, +/// `Method::GetPayeeRewardClaimStatus`, all `Tier::Control`). /// /// **Catches:** the obligation-8 skew directly. Before the #1576 cascade, dig-download consumed /// dig-rpc-protocol 0.5 while dig-peer 0.4 pulled 0.3.1, so a tree containing both held TWO `ModuleInfo` @@ -109,11 +110,11 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// is the point: a consumer's own lock can pin an old patch even when every caret dep and every /// higher-layer bump looks correct. /// -/// **Cascade closed (#3269):** `dig-node-core` depends on 0.11.0 directly; `dig-peer` (0.14.0), -/// `dig-download` (0.23.0) and `dig-peer-selector` (0.12.0) all now resolve `dig-rpc-protocol` -/// 0.11 too, so `cargo metadata` resolves exactly one line. This assertion is deliberately left at -/// exactly-one/0.11 (never widened to accept a set — see #836/#1576); if a future dependency bump -/// reopens the split, this test goes red again on purpose. +/// **Cascade closed (#3269, final leg):** `dig-node-core` depends on 0.12 directly; `dig-peer` +/// (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) all now resolve +/// `dig-rpc-protocol` 0.12 too, so `cargo metadata` resolves exactly one line. This assertion is +/// deliberately left at exactly-one/0.12 (never widened to accept a set — see #836/#1576); if a +/// future dependency bump reopens the split, this test goes red again on purpose. #[test] fn the_workspace_carries_exactly_one_module_wire_crate() { let versions = locked_versions("dig-rpc-protocol"); @@ -124,9 +125,9 @@ fn the_workspace_carries_exactly_one_module_wire_crate() { majors means two `ModuleInfo` shapes across the module pull's trust boundary" ); assert!( - versions[0].starts_with("0.11."), + versions[0].starts_with("0.12."), "the availability contract plus the #3269 reward RPC surface this node adopted ship in \ - dig-rpc-protocol 0.11; the workspace resolved {} — on an earlier line the canonical items \ + dig-rpc-protocol 0.12; the workspace resolved {} — on an earlier line the canonical items \ simply do not exist and this node would be back to declaring its own", versions[0] ); diff --git a/crates/dig-node-core/tests/reward_methods_tier_guard.rs b/crates/dig-node-core/tests/reward_methods_tier_guard.rs index 37393051..34e79565 100644 --- a/crates/dig-node-core/tests/reward_methods_tier_guard.rs +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -42,6 +42,21 @@ fn reward_methods_exist_and_are_found_by_the_prefix_scan() { "expected at least one Reward-prefixed method in Method::ALL; found none — the prefix scan \ itself may be broken, or the wire naming convention changed" ); + // dig_ecosystem#3269 unit 3: pins the count so the guard cannot silently start policing FEWER + // methods than actually exist (a non-empty check alone would still pass on 4 of 5, or on a + // renamed variant the filter stopped matching). Update this number deliberately when + // `dig-rpc-protocol` adds or removes a reward method. + // + // Bumped 4 -> 5 (dig_ecosystem#3269, final leg): dig-rpc-protocol 0.12.0 added + // `dig.getPayeeRewardClaimStatus`, whose name also contains "Reward". + assert_eq!( + methods.len(), + 5, + "expected exactly 5 Reward-prefixed methods (dig.listRewardDistributors, \ + dig.getRewardProverStatus, dig.getRewardDistributor, \ + dig.listRewardDistributorCommitments, dig.getPayeeRewardClaimStatus); got {}", + methods.len() + ); } #[test] diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index ed7e3f94..2497e893 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -179,10 +179,10 @@ getrandom = "0.2" # them. Two majors in one workspace also duplicates the wire TYPES; pinned by # `dig-node-core/tests/dependency_tree.rs`. # -# Moved to 0.11 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.11.0 — the engine's -# `dig.getRewardProverStatus` handler needs the 0.11 line's reward types, and this line staying at -# 0.10 would be the exact drift the paragraph above warns against. -dig-rpc-protocol = "0.11" +# Moved to 0.12 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.12.0 — 0.12 renamed +# `RewardSubject` -> `PayeeSubject` and replaced `HalfObservation` with `Half`, and this line +# staying at 0.11 would duplicate the wire types the paragraph above warns against. +dig-rpc-protocol = "0.12" # The Sage-parity wallet engine (crate `dig_wallet`) — the node-custodied wallet DB + dual-transport # dispatch + seed custody. This shell WIRES it into bring-up (#368): it builds one live @@ -324,9 +324,9 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] # `openrpc_drift_guard.rs` compares the shell's error catalogue against the shared contract # crate name-for-name. Already a normal dependency above; restated here only so the -# integration-test crate can name it, and pinned to the SAME "0.11" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.12" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.11" +dig-rpc-protocol = "0.12" # The `never_log` battery (#277) drives the real seed bootstrap against a temp layout so its # sentinels are the ACTUAL minted phrase and device key rather than invented strings. Already a # normal dependency above; restated here only so the integration-test crate can name it.