diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 9578bba1..c30c1d58 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -10353,6 +10353,125 @@ mod tests { } } + /// **Proves (dig_ecosystem#3342, gate H1):** the wire actually carries the not-a-distributor / + /// chain-unavailable split, through the REAL dispatch path — not just the port-level enum. An + /// installed port answering `Err(NotADistributor)` must reach `data.code == + /// "REWARD_NOT_A_DISTRIBUTOR"`; an installed port answering `Err(Unavailable)` must reach + /// `data.code == "REWARD_CHAIN_UNAVAILABLE"`; the two must differ; and neither response body + /// may contain the substring `"adapter is wired"` — that sentence is reserved for the ONE case + /// where no port is installed at all. + /// **Mutation-probe:** in `seams::dig_rpc::dispatch::reward_chain_port_error_response`, point + /// the `NotADistributor` arm's `data.code` at `REWARD_CHAIN_UNAVAILABLE_MACHINE` (re-collapsing + /// the split) and this test's `assert_ne!` on the two codes fails. + /// **Catches:** a future edit that re-merges the two wire codes while the port-level enum + /// variant, and everything else, stays green. Tests BOTH `dig.getRewardDistributor` and + /// `dig.listRewardDistributorCommitments` -- separate handlers that could drift independently. + #[test] + fn reward_distributor_methods_pin_the_not_a_distributor_wire_code_distinct_from_unavailable() { + let absent_launcher_id = [0x90u8; 32]; + let missing_launcher_id = [0x91u8; 32]; + let outage_launcher_id = [0x92u8; 32]; + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + // A fresh node per method: `install_reward_chain_port` is once-only (backed by a + // `OnceLock`), and the "no port installed" case below must be true independently for + // each method, not just the first one through the loop. + let (node, _td) = test_node(None); + + // Case 1: no port installed at all -- the ONE case allowed to say "adapter is wired". + let absent_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(absent_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + absent_resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method}" + ); + assert!( + absent_resp["error"]["message"] + .as_str() + .unwrap() + .contains("adapter is wired"), + "{method}: no-port-installed case must say so: {absent_resp}" + ); + + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + ( + missing_launcher_id, + Err(crate::rewards::port::ChainPortError::NotADistributor), + ), + ( + outage_launcher_id, + Err(crate::rewards::port::ChainPortError::Unavailable), + ), + ]), + })) + ); + + // Case 2: the chain answered -- no distributor there. + let missing_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":method, + "params":{"launcher_id": hex::encode(missing_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + missing_resp.get("result").is_none(), + "{method}: {missing_resp}" + ); + assert_eq!( + missing_resp["error"]["data"]["code"], + json!("REWARD_NOT_A_DISTRIBUTOR"), + "{method}" + ); + assert!( + !missing_resp.to_string().contains("adapter is wired"), + "{method}: an installed adapter's own answer must never claim none is wired: \ + {missing_resp}" + ); + + // Case 3: the chain source itself could not be reached. + let outage_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":3,"method":method, + "params":{"launcher_id": hex::encode(outage_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + outage_resp.get("result").is_none(), + "{method}: {outage_resp}" + ); + assert_eq!( + outage_resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method}" + ); + assert!( + !outage_resp.to_string().contains("adapter is wired"), + "{method}: an installed adapter's own outage must never claim none is wired: \ + {outage_resp}" + ); + + // The wire distinction actually exists: these two must differ. + assert_ne!( + missing_resp["error"]["data"]["code"], outage_resp["error"]["data"]["code"], + "{method}: not-a-distributor and chain-unavailable must be distinguishable on \ + the wire" + ); + } + } + /// **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 diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 0fae5124..1a98205e 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -148,6 +148,12 @@ 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#3342: the chain source ANSWERED, and no reward distributor exists at the + /// requested `launcher_id`. This is deliberately NOT [`ChainPortError::Unavailable`]: a funder + /// deciding whether to claw back must be able to tell "you have nothing there" (this variant) + /// apart from "we cannot see the chain" (`Unavailable`) — collapsing both onto one shape turns + /// that decision into a guess on a money surface. + NotADistributor, /// 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 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 46352a0d..c5ac95b5 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -35,15 +35,26 @@ 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). +/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269, corrected by dig_ecosystem#3342): the +/// reward-distributor chain read could not complete — either no adapter is installed at all (the +/// `let Some(port) = … else` arms below, via [`reward_chain_port_absent_response`]), or an +/// installed adapter's `ChainPortError::Unavailable` means the chain source itself could not +/// answer. It no longer means "the chain answered and there is nothing there" — that is +/// [`ChainPortError::NotADistributor`], reported under [`REWARD_NOT_A_DISTRIBUTOR_MACHINE`]. /// 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. +/// "the chain could not be reached" 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_NOT_A_DISTRIBUTOR` (dig_ecosystem#3342): the chain source answered, and no reward +/// distributor exists at the requested launcher id. Kept distinct from +/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`] on purpose — see [`ChainPortError::NotADistributor`]'s own +/// doc for why collapsing the two is a money-surface defect, not a cosmetic one. Reuses +/// [`CONTROL_ERROR`]'s numeric code, matching every other reward-distributor machine code here; no +/// wire-protocol change is needed since `data.code` alone carries the distinction. +const REWARD_NOT_A_DISTRIBUTOR_MACHINE: &str = "REWARD_NOT_A_DISTRIBUTOR"; + /// `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 @@ -71,9 +82,14 @@ 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", + "message": "reward-distributor chain read is unavailable: the chain source could not answer", "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } }}), + ChainPortError::NotADistributor => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "no reward distributor exists at this launcher id on chain", + "data": { "code": REWARD_NOT_A_DISTRIBUTOR_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)", @@ -92,6 +108,19 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value } } +/// The response for the ONE case where "no chain-read adapter is wired yet" is actually true: no +/// `rewards::port::RewardsChainPort` has been installed on this `Node` at all +/// (dig_ecosystem#3342). Kept separate from [`reward_chain_port_error_response`] so that +/// function's `Unavailable` arm never has to carry a sentence that is false whenever an installed +/// adapter reports its own `Unavailable` for a chain-source outage. +fn reward_chain_port_absent_response(id: &Value) -> Value { + 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" } + }}) +} + /// 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; @@ -981,7 +1010,7 @@ impl RpcDispatch for Node { 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); + return reward_chain_port_absent_response(&id); }; // Range-check at THIS seam, not only in the adapter: see // `range_checked_report` for why an out-of-range share must refuse here. @@ -1023,7 +1052,7 @@ impl RpcDispatch for Node { 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); + return reward_chain_port_absent_response(&id); }; // Range-check at THIS seam, not only in the adapter: see // `range_checked_report` for why an out-of-range share must refuse here. @@ -1109,7 +1138,7 @@ impl RpcDispatch for Node { 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); + return reward_chain_port_absent_response(&id); }; let report = match port .distributor_report(identity.launcher_id) diff --git a/crates/dig-node-service/src/rewards/chain_port.rs b/crates/dig-node-service/src/rewards/chain_port.rs index 3afca571..e1f6b49d 100644 --- a/crates/dig-node-service/src/rewards/chain_port.rs +++ b/crates/dig-node-service/src/rewards/chain_port.rs @@ -61,6 +61,17 @@ impl RealRewardsChainPort { } } +#[cfg(test)] +impl RealRewardsChainPort { + /// Test-only read of the degradation latch (dig_ecosystem#3342, gate H2) -- a direct load of + /// the real field, not a re-derivation, so a test can prove the latch's actual state rather + /// than scraping it back out of `tracing`'s output. + fn is_degraded(&self) -> bool { + self.report_degraded + .load(std::sync::atomic::Ordering::Relaxed) + } +} + #[async_trait] impl RewardsChainPort for RealRewardsChainPort { async fn funded_distributors(&self) -> Result, ChainPortError> { @@ -105,11 +116,20 @@ impl RewardsChainPort for RealRewardsCha // R4 (dig_ecosystem#3310 gate leg 3, §4): a failing chain source must be observable, not // only correctly typed. `swap` both reads and sets `report_degraded` atomically, so the // warn fires exactly once per failure->success transition even under concurrent callers. + // + // `NotADistributor` (dig_ecosystem#3342, gate H2) is excluded from BOTH the latch and the + // warn: the chain answered fine and simply holds nothing at this launcher id, which is not + // a degradation of the chain source at all. Arming the latch on it would (a) blind a + // GENUINE outage that follows -- the warn only fires on a false->true transition, so the + // real failure would log nothing until some later `Ok` reset it -- and (b) misreport an + // ordinary "not mine" probe as chain trouble. `Ok` still clears the latch as before, + // matching a real recovery. match &result { Ok(_) => { self.report_degraded .store(false, std::sync::atomic::Ordering::Relaxed); } + Err(ChainPortError::NotADistributor) => {} Err(port_error) => { let was_already_degraded = self .report_degraded @@ -141,7 +161,7 @@ where let snapshot = read_distributor_guarded(source, launcher_id) .map_err(guarded_read_error_to_port_error)? - .ok_or(ChainPortError::Unavailable)?; + .ok_or(ChainPortError::NotADistributor)?; let comment = read_launch_comment(source, launcher_id).map_err(launch_comment_error_to_port_error)?; @@ -264,11 +284,16 @@ fn guarded_read_error_to_port_error(error: GuardedReadError) -> ChainPortError { } /// Maps [`LaunchCommentError`] onto [`ChainPortError`] (dig_ecosystem#3310 gate leg 3, R5). -/// `ParentSpendUnavailable` is a chain-source GAP (the source does not yet hold the launcher's -/// parent spend), not a classification of the distributor's identity -- it maps onto the same -/// `Unavailable` `read_distributor_guarded`'s own `Ok(None)` already answers with, not `Other`, -/// which would render it to a caller as a definitive "not a DIG distributor". Every other variant -/// genuinely is a refused/malformed read, or a real classification, so it stays `Other`. +/// `ParentSpendUnavailable` is a chain-source GAP: the source does not yet hold the launcher's +/// parent spend, so this call cannot say anything about the distributor's identity at all -- that +/// is an outage of the read, not an answer from it, so it maps onto [`ChainPortError::Unavailable`] +/// on its own merit (dig_ecosystem#3342: it no longer piggybacks on +/// `read_distributor_guarded`'s `Ok(None)` path, which now reports +/// [`ChainPortError::NotADistributor`] instead -- a chain source that never reached the parent +/// spend is a different failure from one that reached the chain and found no distributor there). +/// It is not `Other`, which would render it to a caller as a definitive "not a DIG distributor". +/// Every other variant genuinely is a refused/malformed read, or a real classification, so it +/// stays `Other`. fn launch_comment_error_to_port_error(error: LaunchCommentError) -> ChainPortError { match error { LaunchCommentError::ParentSpendUnavailable => ChainPortError::Unavailable, @@ -351,6 +376,144 @@ mod tests { ); } + /// dig_ecosystem#3342, the money-surface defect: a chain source that ANSWERS and holds no + /// reward distributor at `launcher_id` is an ABSENCE, never an OUTAGE. An empty + /// `MockChainSource` answers every read successfully with `None`, which + /// `dig_rewards_coin::state::read_distributor` reports as `Ok(None)` -- the chain saying + /// "nothing here", not "I could not look". A funder deciding whether to claw back must be + /// able to tell that apart from an unreachable chain, so it must NOT be `Unavailable`. + #[tokio::test] + async fn an_answering_chain_with_no_distributor_is_an_absence_not_an_outage() { + let source = MockChainSource::new(); + let port = RealRewardsChainPort::::new(Arc::new(source)); + + let result = port.distributor_report([0x22; 32]).await; + + assert_eq!( + result, + Err(ChainPortError::NotADistributor), + "an answering chain that holds no distributor is an absence, not an unreachable \ + chain, got {result:?}" + ); + } + + /// A `ChainSource` that starts answering like an empty chain (every read `Ok` with nothing + /// found -- the `NotADistributor` shape) and can be flipped, mid-test, to fail every read (the + /// `Unavailable` shape). Lets [`a_not_a_distributor_result_never_arms_the_latch_and_never_blinds_a_later_outage`] + /// drive a SINGLE `RealRewardsChainPort` instance through the exact absence-then-outage + /// sequence dig_ecosystem#3342 gate H2 is about, instead of two instances that could never + /// prove the exclusion is scoped to `NotADistributor` alone. + #[derive(Default)] + struct SwitchableChainSource { + failing: std::sync::atomic::AtomicBool, + } + + impl SwitchableChainSource { + fn switch_to_failing(&self) { + self.failing + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + fn guard(&self) -> Result<(), ChainSourceError> { + if self.failing.load(std::sync::atomic::Ordering::SeqCst) { + Err(ChainSourceError::Timeout) + } else { + Ok(()) + } + } + } + + impl dig_chainsource_interface::ChainSource for SwitchableChainSource { + type Error = ChainSourceError; + + fn coin_record( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: chia_protocol::Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + self.guard()?; + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(Vec::new()) + } + + fn coin_spend( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + } + + /// **Proves (dig_ecosystem#3342, gate H2):** a `NotADistributor` result must not arm the + /// degradation latch -- so a genuine outage that follows still transitions the latch + /// false->true and still would warn, exactly as if the `NotADistributor` call had never + /// happened. Before the fix, EVERY `Err(_)` armed the latch, so the outage below would find it + /// already `true` and treat itself as a no-op continuation of an existing degradation. + /// **Mutation-probe:** in `RealRewardsChainPort::distributor_report`, delete the + /// `Err(ChainPortError::NotADistributor) => {}` arm (folding it back into the general `Err` + /// arm) and this test's first `assert!(!port.is_degraded())` goes red. + #[tokio::test] + async fn a_not_a_distributor_result_never_arms_the_latch_and_never_blinds_a_later_outage() { + let source = Arc::new(SwitchableChainSource::default()); + let port = RealRewardsChainPort::::new(Arc::clone(&source)); + + // Phase 1: the chain answers, no distributor here -- an absence, not a degradation. + let absence_result = port.distributor_report([0x33; 32]).await; + assert_eq!(absence_result, Err(ChainPortError::NotADistributor)); + assert!( + !port.is_degraded(), + "a NotADistributor result must never arm the degradation latch" + ); + + // Phase 2: the chain source itself now fails -- a genuine outage. + source.switch_to_failing(); + let outage_result = port.distributor_report([0x33; 32]).await; + assert_eq!( + outage_result, + Err(ChainPortError::Unavailable), + "a failing chain source must still report Unavailable after a prior absence" + ); + assert!( + port.is_degraded(), + "a genuine outage must still arm the latch even after a preceding NotADistributor \ + result -- that is exactly the blinding H2 guards against" + ); + } + /// The adjacent guard this crate's own `epoch_seconds == 0` refusal must keep: that refusal is /// a NAMED distributor-level refusal (`ChainPortError::Other`), never conflated with /// `ChainPortError::Unavailable` -- which must mean the CHAIN SOURCE could not answer, not @@ -371,10 +534,11 @@ mod tests { ); } - /// R5's regression (dig_ecosystem#3310 gate leg 3): a chain-source GAP on the launcher's - /// parent spend must never be reported as the definitive "not a DIG distributor" verdict -- - /// it must agree with the OTHER absence path (`read_distributor_guarded`'s own `Ok(None)`), - /// which answers `Unavailable`. + /// R5's regression (dig_ecosystem#3310 gate leg 3, corrected by dig_ecosystem#3342): a + /// chain-source GAP on the launcher's parent spend must never be reported as the definitive + /// "not a DIG distributor" verdict -- it stands on its own merit as an unreachable read + /// (`Unavailable`), independent of `read_distributor_guarded`'s `Ok(None)` path, which since + /// #3342 answers `ChainPortError::NotADistributor` instead: a genuine absence, not an outage. #[test] fn parent_spend_gap_is_reported_as_unavailable_not_as_a_distributor_identity_verdict() { let mapped = super::launch_comment_error_to_port_error(