diff --git a/CHANGELOG.md b/CHANGELOG.md index 46528fdc..feca10ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [Unreleased] + +### Reward claim port hardening +- Ban `RewardDistributor::created_slot_value_to_slot` from production code via a workspace + `disallowed-methods` clippy lint (phantom `LineageProof` on a chain-rebuilt distributor); allow + the one legitimate in-process test-fixture use (#3357) +- Refuse, by name, a distributor requiring payout approval in `submit_initiate_payout` before + building or broadcasting anything (#3362) +- Bound hinted launcher discovery candidates per cycle and report every drop via + `Discovery.candidates_dropped` / `ClaimStatus.discovery_candidates_dropped_this_cycle` (#3358) +- Fix a claim-port regression test to use a non-empty launcher index so it actually exercises the + failing chain source's discovery/submit paths (#3363) + ## [0.255.0] - 2026-09-07 ### Chores diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..696a4e1d --- /dev/null +++ b/clippy.toml @@ -0,0 +1,18 @@ +# DIG-Network/dig_ecosystem#3357: bans calling `RewardDistributor::created_slot_value_to_slot` +# from production code anywhere in this workspace. +# +# That method derives a `LineageProof` from the coin it is called ON. For a distributor rebuilt +# from chain (every production read path), that coin is the TIP -- so a `LineageProof` it derives +# for a slot an EARLIER generation created is a well-formed but PHANTOM proof. The entry slot for a +# real spend must come from a fresh, authenticated chain walk instead (see +# `dig_rewards_coin::ChainEntrySlotSource`, or `DistributorSnapshot::entry_slot` / +# `commitment_slots` / `reward_slots` for a read). +# +# This lint bans the CALL, not the receiver class: clippy cannot tell a chain-rebuilt receiver from +# an in-process one (a distributor built fresh in the same process this same generation, e.g. a +# test fixture reading back the reward slots ITS OWN spend just created -- that use is legitimate). +# Every `#[allow(clippy::disallowed_methods)]` against this entry must carry a comment stating WHY +# its receiver is in-process this generation, not chain-rebuilt. +disallowed-methods = [ + { path = "chia_sdk_driver::RewardDistributor::created_slot_value_to_slot", reason = "derives a LineageProof from the coin it is called on; on a distributor rebuilt from chain that coin is the TIP, so any earlier generation's slot is a PHANTOM (dig_ecosystem#3357). Use DistributorSnapshot::entry_slot / commitment_slots / reward_slots or ChainEntrySlotSource. This lint bans the CALL, not the receiver class: it cannot tell a chain-rebuilt receiver from an in-process one. Every #[allow] must state in a comment WHY its receiver is in-process." }, +] diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs index e5bb7aff..d267d53a 100644 --- a/crates/dig-node-service/src/rewards_claim/chain_port.rs +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -17,9 +17,13 @@ //! well-formed but PHANTOM `LineageProof` for a slot an earlier generation created //! (DIG-Network/dig_ecosystem#3357). `initiate_payout`'s returned `conditions` are a CALLER-SIDE //! assertion for a coin the caller would add to the same bundle; this adapter adds no coin of its -//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them -- -//! the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the proof the -//! resulting bundle is accepted without them. +//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them when +//! it proceeds -- the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the +//! proof the resulting bundle is accepted without them for `require_payout_approval = false`. When +//! the chain-curried `require_payout_approval` is `true` instead, dropping `conditions` would be +//! dropping the manager's approval assertion, not a no-op -- [`RealClaimChainPort::submit_initiate_payout`] +//! REFUSES by name in that case, before building anything, rather than broadcasting a bundle this +//! adapter cannot honestly satisfy (DIG-Network/dig_ecosystem#3362). //! //! A silent no-op would be the exact defect this ticket exists to prevent -- a refused method //! reports a NAMED [`ClaimPortError`], never a fabricated success. @@ -38,13 +42,48 @@ use dig_wallet::sage::spend::Broadcaster; use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError}; use super::port::{ClaimChainPort, ClaimPortError}; -use super::types::{DiscoveredDistributor, OwnEntry}; +use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// The longest a chain port's own error text is allowed to carry before it is truncated -- the /// same 200-char discipline [`super::types::ClaimOutcome::Faulted`]'s `reason` field documents, /// applied here at the source so every producer of a bounded string agrees on the bound. const MAX_ERROR_CHARS: usize = 200; +/// DIG-Network/dig_ecosystem#3358: the most hinted launcher candidates +/// [`RealClaimChainPort::discover_distributors`] will decode in one call. +/// +/// # Where the number comes from +/// `dig_rewards_coin`'s own `DECODE_MAX_SERIALIZED_BYTES` bounds ONE candidate's decode at 64 KiB +/// (65_536 bytes); `256 * 65_536 = 16_777_216` bytes -- a 16 MiB decode ceiling for one +/// `discover_distributors` call -- plus 256 parent-spend chain reads, one per candidate. +/// +/// # What this bound does NOT cover -- read this before assuming discovery is safe +/// 1. It does not bound gossip hints: `ClaimEngine::run_cycle`'s hint loop (`engine.rs`, +/// `self.hints.hints()`, feeding `resolve_launch_comment` one candidate at a time) is a +/// SEPARATE, unbounded path -- out of scope for this cap, named here so it is not mistaken for +/// covered. +/// 2. It does not choose WHICH candidates survive: this adapter decodes the index's first N in +/// WHATEVER ORDER the chain transport returned them, and that order is attacker-influenceable +/// (`HintedLauncherIndex` proposes every hinted coin its peers have seen) -- a flood of bogus +/// hinted coins ahead of a legitimate launcher in that order can push the legitimate one past +/// the cap and out of this cycle's candidate set. +/// 3. It does not persist "already decoded and rejected" across calls -- a dropped-for-real +/// candidate is re-attempted (and can be re-dropped) every cycle rather than being remembered +/// and skipped cheaply; left as a follow-up, not implemented here. +/// 4. It does not bound the COST of decoding one candidate -- that is +/// `DECODE_MAX_SERIALIZED_BYTES`'s job, not this cap's. +/// 5. It authenticates nothing -- every surviving candidate is still re-verified through the real +/// memo decode in [`resolve_via_chain`] exactly as before this cap existed; this cap only +/// decides how many candidates get that far. +/// +/// A drop is never silent: [`RealClaimChainPort::discover_distributors`] reports how many +/// candidates it declined via [`Discovery::candidates_dropped`], and +/// [`super::engine::ClaimEngine::run_cycle`] copies that count into +/// [`super::types::ClaimStatus::discovery_candidates_dropped_this_cycle`] and logs a `warn!` when +/// it is nonzero -- a silent cap on discovery is the exact censorship-primitive shape this ticket +/// exists to avoid. +pub const MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE: usize = 256; + fn bounded(message: impl Into) -> String { let message = message.into(); if message.chars().count() <= MAX_ERROR_CHARS { @@ -80,6 +119,11 @@ where source: Arc, index: I, broadcaster: Arc, + /// DIG-Network/dig_ecosystem#3358: how many hinted candidates one `discover_distributors` call + /// will decode -- [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] in production; + /// [`Self::with_candidate_cap`] overrides it for a test that needs a small cap to exercise + /// dropping without decoding hundreds of candidates. + candidate_cap: usize, } impl RealClaimChainPort @@ -89,13 +133,34 @@ where { /// Wraps an already-constructed chain source, launcher index and broadcaster. Takes the source /// by `Arc` (mirroring `rewards::chain_port::RealRewardsChainPort::new`) since a blocking read - /// clones it into a `spawn_blocking` closure on every call. + /// clones it into a `spawn_blocking` closure on every call. Uses the production + /// [`MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`] cap -- see [`Self::with_candidate_cap`] to + /// override it. #[must_use] pub fn new(source: Arc, index: I, broadcaster: Arc) -> Self { Self { source, index, broadcaster, + candidate_cap: MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, + } + } + + /// Same as [`Self::new`] with an explicit candidate cap -- production never calls this; it + /// exists so a test can pin a small cap and prove the drop-and-report behaviour without + /// decoding hundreds of candidates. + #[must_use] + pub fn with_candidate_cap( + source: Arc, + index: I, + broadcaster: Arc, + candidate_cap: usize, + ) -> Self { + Self { + source, + index, + broadcaster, + candidate_cap, } } } @@ -190,13 +255,20 @@ where "real-corroborated" } - async fn discover_distributors(&self) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let candidate_ids = self.index.launcher_ids().await?; let source = Arc::clone(&self.source); + let candidate_cap = self.candidate_cap; tokio::task::spawn_blocking(move || { + // DIG-Network/dig_ecosystem#3358: bound how many candidates one call will decode -- + // see MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE's doc for what this does and does not + // protect. The drop count is REPORTED, never silently absorbed. + let total = candidate_ids.len(); + let candidates_dropped = total.saturating_sub(candidate_cap) as u32; + let mut discovered = Vec::new(); - for launcher_id in candidate_ids { + for launcher_id in candidate_ids.into_iter().take(candidate_cap) { // SPEC 13.1 clause 6: the index only PROPOSES; every id is re-verified through the // real memo decode. An id the decode rejects (unknown to `source`, or a spend that // is not a DIG rewards launch) is DROPPED, never echoed back. @@ -207,7 +279,10 @@ where Err(other) => return Err(other), } } - Ok(discovered) + Ok(Discovery { + distributors: discovered, + candidates_dropped, + }) }) .await .map_err(|join_error| { @@ -336,6 +411,26 @@ where ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) })?; + // DIG-Network/dig_ecosystem#3362: this distributor curries `require_payout_approval = + // true`, meaning `InitiatePayout` needs a manager-signed approval assertion in the same + // bundle. This adapter has no such assertion to attach and, per this module's doc, + // DROPS `initiate_payout`'s returned `conditions` unconditionally -- proceeding here + // would build a bundle the chain rejects, but only AFTER this adapter's caller had + // already reported `Paid` to whatever recorded the attempt. Refuse by name instead, + // before any spend is built. + if snapshot + .distributor() + .info + .constants + .require_payout_approval + { + return Err(ClaimPortError::Other(bounded( + "refused: distributor curries require_payout_approval = true; this adapter \ + carries no approval message (it drops initiate_payout's returned conditions), \ + so the bundle it would build is one the chain rejects after reporting Paid", + ))); + } + // NEVER `snapshot.distributor().created_slot_value_to_slot(..)` -- that derives a // well-formed but PHANTOM `LineageProof` for a slot an earlier generation created // (DIG-Network/dig_ecosystem#3357, this module's doc). The entry slot for THIS spend diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index a62eb998..70729f06 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -715,7 +715,7 @@ mod tests { use super::super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; use super::super::port::ClaimPortError; - use super::super::types::{DiscoveredDistributor, OwnEntry}; + use super::super::types::{DiscoveredDistributor, Discovery, OwnEntry}; // ---- decide_claim_driver / spawn_claim_driver_if (A3) ---------------------------------- @@ -988,10 +988,8 @@ mod tests { #[async_trait] impl ClaimChainPort for EmptyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(Vec::new()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery::default()) } async fn resolve_launch_comment( &self, @@ -1138,14 +1136,15 @@ mod tests { #[async_trait] impl ClaimChainPort for OneDistributorPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(vec![DiscoveredDistributor { - launcher_id: Bytes32::from([9u8; 32]), - store_id: Bytes32::from([0u8; 32]), - root: Bytes32::from([0u8; 32]), - }]) + async fn discover_distributors(&self) -> Result { + Ok(Discovery { + distributors: vec![DiscoveredDistributor { + launcher_id: Bytes32::from([9u8; 32]), + store_id: Bytes32::from([0u8; 32]), + root: Bytes32::from([0u8; 32]), + }], + candidates_dropped: 0, + }) } async fn resolve_launch_comment( &self, diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 9aea1184..0096666a 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -6,10 +6,11 @@ use std::path::{Path, PathBuf}; use chia_protocol::Bytes32; +use super::chain_port::MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE; use super::config::RewardsClaimConfig; use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; -use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus, Discovery}; /// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the /// single raw configured value they both come from. @@ -359,6 +360,7 @@ impl ClaimEngine { self.status.distributors_faulted = 0; self.status.claims_submitted_this_cycle = 0; self.status.no_entry_slot_this_cycle = 0; + self.status.discovery_candidates_dropped_this_cycle = 0; self.status.last_attempt_at = Some(now); // F18: this engine's own view of the persisted fee window for this cycle -- there is no @@ -481,14 +483,32 @@ impl ClaimEngine { // timestamp going stale to notice a wedged discovery path. self.status.fault_reported = true; discovery_failed = true; - Vec::new() + Discovery::default() } }; if !discovery_failed { self.status.last_discovery_at = Some(now); } - let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); + // DIG-Network/dig_ecosystem#3358: a per-cycle reading, never latched -- reset at the top + // of this function alongside every other per-cycle counter. Reported unconditionally, and + // logged when nonzero: a silently shrunk candidate set is exactly the failure this exists + // to prevent. + self.status.discovery_candidates_dropped_this_cycle = discovered.candidates_dropped; + if discovered.candidates_dropped > 0 { + tracing::warn!( + target: "rewards_claim", + dropped = discovered.candidates_dropped, + cap = MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE, + "hinted launcher candidates over the per-cycle cap were NOT decoded" + ); + } + + let mut candidates: Vec = discovered + .distributors + .iter() + .map(|d| d.launcher_id) + .collect(); // F4: a real adapter can plausibly return the same launcher id twice (one distributor // reachable via two of the §1.3 launch comments this node scans, across the // `(store_id, root)` pairs it mirrors). Without this, phase 2 would evaluate it twice and @@ -967,7 +987,7 @@ mod tests { use super::*; use crate::rewards_claim::hints::{DistributorHint, NoHintSource}; use crate::rewards_claim::parser::parse_launch_comment; - use crate::rewards_claim::types::DiscoveredDistributor; + use crate::rewards_claim::types::{DiscoveredDistributor, Discovery}; const DIG_ASSET_ID: Bytes32 = Bytes32::new([9u8; 32]); const OUR_PAYOUT_PUZZLE_HASH: Bytes32 = Bytes32::new([1u8; 32]); @@ -1046,20 +1066,21 @@ mod tests { #[async_trait] impl ClaimChainPort for FakeChainPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(self - .distributors - .lock() - .unwrap() - .values() - .map(|d| DiscoveredDistributor { - launcher_id: d.launcher_id, - store_id: d.store_id, - root: d.root, - }) - .collect()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery { + distributors: self + .distributors + .lock() + .unwrap() + .values() + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + }) + .collect(), + candidates_dropped: 0, + }) } async fn resolve_launch_comment( @@ -1429,10 +1450,8 @@ mod tests { struct HintOnlyPort(FakeChainPort); #[async_trait] impl ClaimChainPort for HintOnlyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(Vec::new()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery::default()) } async fn resolve_launch_comment( &self, @@ -1518,9 +1537,7 @@ mod tests { struct AlwaysFaultingDiscoveryPort; #[async_trait] impl ClaimChainPort for AlwaysFaultingDiscoveryPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { Err(ClaimPortError::Other("simulated chain fault".into())) } async fn resolve_launch_comment( @@ -2829,9 +2846,7 @@ mod tests { #[async_trait] impl ClaimChainPort for FlakyThenHealthyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call_number == 1 { return Err(ClaimPortError::Unavailable); @@ -2938,9 +2953,7 @@ mod tests { #[async_trait] impl ClaimChainPort for HealthyThenUnavailablePort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call_number == 1 { return self.inner.discover_distributors().await; @@ -3062,13 +3075,11 @@ mod tests { #[async_trait] impl ClaimChainPort for DuplicatingDiscoveryPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - let mut v = self.0.discover_distributors().await?; - let doubled = v.clone(); - v.extend(doubled); - Ok(v) + async fn discover_distributors(&self) -> Result { + let mut discovery = self.0.discover_distributors().await?; + let doubled = discovery.distributors.clone(); + discovery.distributors.extend(doubled); + Ok(discovery) } async fn resolve_launch_comment( &self, diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index d4714c58..6146f540 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -70,7 +70,9 @@ pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; pub use parser::parse_launch_comment; pub use port::{ClaimChainPort, ClaimPortError, UnavailableClaimChainPort}; -pub use types::{ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, OwnEntry}; +pub use types::{ + ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, Discovery, OwnEntry, +}; #[cfg(test)] mod tests { diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index 9a4c57a3..1fe473d8 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use chia_protocol::Bytes32; -use super::types::{DiscoveredDistributor, OwnEntry}; +use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// Why a claim-chain call could not complete. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,7 +27,10 @@ pub enum ClaimPortError { pub trait ClaimChainPort: Send + Sync { /// SPEC §13.1: every CHIP-0051 distributor on chain whose launch comment parses per §1.3 — /// before the §9.3 reserve-asset filter, which the engine applies via [`Self::reserve_asset_id`]. - async fn discover_distributors(&self) -> Result, ClaimPortError>; + /// DIG-Network/dig_ecosystem#3358: the returned [`Discovery`] also carries + /// `candidates_dropped` -- a port that bounds how many candidates it will decode this call + /// MUST report how many it declined, never silently shrink the result. + async fn discover_distributors(&self) -> Result; /// Re-derive one launcher id's launch comment from chain (SPEC §13.2 clause 1: a gossip hint is /// untrusted, so it is verified through this same on-chain path, never trusted directly). @@ -82,7 +85,7 @@ pub struct UnavailableClaimChainPort; #[async_trait] impl ClaimChainPort for UnavailableClaimChainPort { - async fn discover_distributors(&self) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { Err(ClaimPortError::Unavailable) } diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index dfe22737..e7cd4e39 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -13,6 +13,23 @@ pub struct DiscoveredDistributor { pub root: Bytes32, } +/// One `discover_distributors` call's result: the distributors found AND how many candidates the +/// port declined to even decode -- DIG-Network/dig_ecosystem#3358. A bound on discovery that +/// silently swallowed the dropped count would be a censorship primitive (a cap that starves the +/// work it protects, never reported); this struct makes that count a first-class, always-present +/// field instead, so `RealClaimChainPort::discover_distributors` can never answer with fewer +/// distributors than it actually decoded without saying so. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Discovery { + /// Every distributor whose candidate id was actually decoded and verified this call. + pub distributors: Vec, + /// Candidates the port's own per-cycle cap declined to decode at all -- SPEC 13.1; + /// [`RealClaimChainPort`](super::chain_port::RealClaimChainPort)'s cap is + /// `MAX_HINTED_LAUNCHER_CANDIDATES_PER_CYCLE`. `0` for every port that has no such cap (every + /// test double and [`super::port::UnavailableClaimChainPort`]). + pub candidates_dropped: u32, +} + /// This node's own entry slot on one distributor (SPEC §10.2): keyed by a payout PUZZLE HASH, never /// a pubkey, re-read fresh before every claim (SPEC §12.5 clause 3) and never cached across cycles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -267,6 +284,14 @@ pub struct ClaimStatus { /// other per-cycle counters, before any early return, so a stalled writer can never leave a /// stale count sitting under a fresh timestamp (never a lifetime latch). pub no_entry_slot_this_cycle: u32, + /// DIG-Network/dig_ecosystem#3358: THIS CYCLE's count of hinted launcher candidates the port's + /// own per-cycle cap declined to decode at all -- distinct from [`Self::no_entry_slot_this_cycle`] + /// (those WERE decoded and simply had no entry). Reset at the top of every `run_cycle`, same + /// per-cycle discipline as every other counter on this struct: a stale nonzero count from a + /// PAST cycle must never sit under a fresh `last_attempt_at`. A nonzero reading here means a + /// legitimate distributor could have been silently excluded from this cycle's candidate set -- + /// see [`Discovery::candidates_dropped`], the field this is copied from. + pub discovery_candidates_dropped_this_cycle: u32, /// Set when a chain call THIS CYCLE returned `ClaimPortError::Other(_)` — reset at the start of /// every `run_cycle` (Defect A1: this used to latch true for the rest of the process's life, /// which would have permanently suppressed every other state once tripped once). @@ -296,6 +321,7 @@ impl Default for ClaimStatus { claims_refused_payout_mismatch: 0, payout_hash_mismatches_this_cycle: 0, no_entry_slot_this_cycle: 0, + discovery_candidates_dropped_this_cycle: 0, fault_reported: false, consecutive_faulted_cycles: 0, state: ClaimLoopState::Idle, diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs index 964bbb66..b8fcd0ff 100644 --- a/crates/dig-node-service/tests/common/rewards_fixture.rs +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -66,6 +66,16 @@ pub struct LaunchedFixture { /// `launch_dig_distributor` against a fresh `Simulator` — trimmed from /// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. pub fn launch_fixture() -> Result> { + launch_fixture_with_approval(false) +} + +/// Same as [`launch_fixture`], but with an explicit `require_payout_approval` -- DIG-Network/dig_ecosystem#3362 +/// needs a REAL simulator launch with the flag curried `true` (a fixture starting where production +/// cannot hides the bug -- a struct literal would never prove the chain-curried value is what the +/// adapter actually reads). +pub fn launch_fixture_with_approval( + require_payout_approval: bool, +) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -163,7 +173,7 @@ pub fn launch_fixture() -> Result> { // from the default, or a port that ignores the chain and returns the default constant // reads as correct by coincidence. See `reserve_asset_id_and_payout_threshold_are_read_from_chain`. PAYOUT_THRESHOLD_BASE_UNITS.saturating_add(1_000_000), - false, + require_payout_approval, 0, WITHDRAWAL_SHARE_BPS, source_cat.info.asset_id, @@ -457,6 +467,18 @@ pub struct FundedFixture { #[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs pub fn launch_funded_admitted_fixture( payout_puzzle_hash: Bytes32, +) -> Result> { + launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, false) +} + +/// Same as [`launch_funded_admitted_fixture`], but with an explicit `require_payout_approval` -- +/// DIG-Network/dig_ecosystem#3362 needs a REAL simulator launch (funded, admitted, above +/// threshold) with the flag curried `true`, not a struct literal a production read path could +/// never actually produce. +#[allow(dead_code)] +pub fn launch_funded_admitted_fixture_with_approval( + payout_puzzle_hash: Bytes32, + require_payout_approval: bool, ) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -553,7 +575,7 @@ pub fn launch_funded_admitted_fixture( u64::MAX, MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, - false, + require_payout_approval, 0, WITHDRAWAL_SHARE_BPS, source_cat.info.asset_id, @@ -618,6 +640,12 @@ pub fn launch_funded_admitted_fixture( )?, ); + // DIG-Network/dig_ecosystem#3357: safe here ONLY because `distributor` is this fixture's own + // freshly-built IN-PROCESS value -- these slots come from ITS OWN `pending_spend` this same + // generation, never from a distributor rebuilt from chain (where this call would derive a + // PHANTOM `LineageProof` for an earlier generation's slot). Production code must never call + // this; see `clippy.toml`'s `disallowed-methods` entry for the ban. + #[allow(clippy::disallowed_methods)] let reward_slots: Vec<_> = distributor .pending_spend .created_reward_slots diff --git a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs index 89da668f..ae3dfbd3 100644 --- a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -26,8 +26,8 @@ use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; use dig_wallet::sage::spend::MockBroadcaster; use common::rewards_fixture::{ - launch_fixture, launch_funded_admitted_fixture, mock_chain_source, - mock_chain_source_for_funded_fixture, + launch_fixture, launch_funded_admitted_fixture, launch_funded_admitted_fixture_with_approval, + mock_chain_source, mock_chain_source_for_funded_fixture, }; /// An index that proposes exactly the ids it is built with -- no re-verification of its own; that @@ -54,15 +54,19 @@ async fn discover_distributors_returns_exactly_the_real_launch() { Arc::new(MockBroadcaster::default()), ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("a real launched distributor must discover"); - assert_eq!(discovered.len(), 1); - assert_eq!(discovered[0].launcher_id, fixture.launcher_id); - assert_eq!(discovered[0].store_id, fixture.launch_comment.store_id); - assert_eq!(discovered[0].root, fixture.launch_comment.root); + assert_eq!(discovery.distributors.len(), 1); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); + assert_eq!( + discovery.distributors[0].store_id, + fixture.launch_comment.store_id + ); + assert_eq!(discovery.distributors[0].root, fixture.launch_comment.root); + assert_eq!(discovery.candidates_dropped, 0); } /// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be @@ -79,17 +83,74 @@ async fn a_bogus_index_entry_is_dropped_not_echoed() { Arc::new(MockBroadcaster::default()), ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("a bogus id must be dropped, not fail the whole discovery"); assert_eq!( - discovered.len(), + discovery.distributors.len(), 1, "an index lie must yield nothing for that id, and never overrule the real one" ); - assert_eq!(discovered[0].launcher_id, fixture.launcher_id); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); +} + +/// DIG-Network/dig_ecosystem#3358: a candidate cap that lands ON the real launcher id must drop it +/// and REPORT the drop -- never silently return fewer distributors than the caller can account for. +/// Uses [`RealClaimChainPort::with_candidate_cap`] pinned to 1 so this proves the drop without +/// decoding hundreds of candidates; the production constant stays +/// [`dig_node_service::rewards_claim::RealClaimChainPort`]'s own default (256). +#[tokio::test(flavor = "multi_thread")] +async fn a_capped_cycle_drops_the_candidate_past_the_cap_and_reports_it() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::with_candidate_cap( + Arc::new(source), + FixtureLauncherIndex(vec![bogus_id, fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + 1, + ); + + let discovery = port + .discover_distributors() + .await + .expect("a capped cycle must still answer, never error, for the candidates it does try"); + + assert_eq!( + discovery.distributors.len(), + 0, + "the real launcher id sits past the cap of 1 and must be dropped, not decoded" + ); + assert_eq!( + discovery.candidates_dropped, 1, + "the one candidate past the cap must be reported, never silently absorbed" + ); +} + +/// The same cap, sized to admit every candidate -- proves the cap itself never drops anything when +/// there is nothing to drop (the companion proof to the capped case above). +#[tokio::test(flavor = "multi_thread")] +async fn a_cap_that_covers_every_candidate_drops_nothing() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::with_candidate_cap( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id, bogus_id]), + Arc::new(MockBroadcaster::default()), + 2, + ); + + let discovery = port + .discover_distributors() + .await + .expect("a real launched distributor must discover"); + + assert_eq!(discovery.distributors.len(), 1); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); + assert_eq!(discovery.candidates_dropped, 0); } /// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own @@ -171,13 +232,25 @@ async fn a_failing_source_reports_unavailable_everywhere() { MockChainSource::new().fail_with(dig_chainsource_interface::ChainSourceError::Transport( "simulated transport failure".into(), )); + let launcher_id = Bytes32::from([1u8; 32]); + // DIG-Network/dig_ecosystem#3363: a NON-empty index -- discovery must reach the failing + // source's own `Unavailable` answer, not stop short on an empty candidate list (which would + // pass this assertion for the wrong reason, without ever driving the source at all). let port = RealClaimChainPort::new( Arc::new(source), - FixtureLauncherIndex(vec![]), + FixtureLauncherIndex(vec![launcher_id]), Arc::new(MockBroadcaster::default()), ); - let launcher_id = Bytes32::from([1u8; 32]); + assert_eq!( + port.discover_distributors().await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.submit_initiate_payout(launcher_id, Bytes32::from([2u8; 32]), 0) + .await, + Err(ClaimPortError::Unavailable) + ); assert_eq!( port.reserve_asset_id(launcher_id).await, Err(ClaimPortError::Unavailable) @@ -376,6 +449,60 @@ async fn submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_t ); } +/// DIG-Network/dig_ecosystem#3362: a distributor that curries `require_payout_approval = true` +/// must be REFUSED, by name, before any bundle is built or broadcast -- this adapter drops +/// `initiate_payout`'s returned `conditions` unconditionally (see `chain_port.rs`'s module doc), so +/// proceeding here would build a bundle the chain would reject anyway, but only after this +/// adapter's caller believed the payout had been submitted. A REAL simulator launch with the flag +/// curried true (never a struct literal -- a fixture starting where production cannot reach hides +/// the bug), funded and admitted so the refusal is proven against an entry that would otherwise be +/// perfectly payable. +#[tokio::test(flavor = "multi_thread")] +async fn a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast() { + let payout_puzzle_hash = Bytes32::from([0x55; 32]); + let fixture = launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, true).expect( + "a funded, admitted distributor with require_payout_approval=true must launch cleanly", + ); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + let entry = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash"); + assert!( + entry.accrued_base_units >= fixture.constants.payout_threshold, + "the entry must clear its own threshold -- proving the refusal fires on a distributor that \ + would otherwise be perfectly payable, not merely an ineligible one" + ); + + let result = port + .submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 0) + .await; + match result { + Err(ClaimPortError::Other(msg)) => { + assert!( + msg.contains("require_payout_approval"), + "the refusal must name the reason: {msg}" + ); + } + other => panic!("expected a named refusal, got {other:?}"), + } + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 0, + "a require_payout_approval=true distributor must never reach the broadcaster" + ); +} + /// DIG-Network/dig_ecosystem#3347's CLOSURE ARTIFACT: drives the whole PRODUCTION BODY /// (`run_claim_driver_in`, the same function `run_claim_driver` calls in production, over a real /// `RealClaimChainPort`) against a real, funded, admitted distributor -- and asserts the payout @@ -412,12 +539,12 @@ async fn a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer() { "half an epoch with one entry must have accrued something" ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("discovery must not error"); assert_eq!( - discovered.len(), + discovery.distributors.len(), 1, "discovery must find the one real distributor this fixture launched" );