From 1ad4d2e47074afae2f352a6b2772cce12ca434a1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 18 Sep 2026 06:31:50 -0700 Subject: [PATCH 1/4] feat(rewards-claim): RealClaimChainPort over dig-rewards-coin 0.7.0 -- WIP, may not compile Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 4 +- crates/dig-node-service/Cargo.toml | 19 +- .../src/rewards_claim/chain_port.rs | 373 ++++++++++++++++++ .../src/rewards_claim/driver.rs | 167 ++++++-- .../src/rewards_claim/engine.rs | 31 ++ .../dig-node-service/src/rewards_claim/mod.rs | 35 +- .../src/rewards_claim/port.rs | 16 +- crates/dig-node-service/src/server.rs | 18 +- crates/dig-node-service/tests/common/mod.rs | 4 + .../tests/common/rewards_fixture.rs | 260 ++++++++++++ .../tests/rewards_chain_port_a3.rs | 253 +----------- .../tests/rewards_claim_chain_port_3347.rs | 178 +++++++++ 12 files changed, 1049 insertions(+), 309 deletions(-) create mode 100644 crates/dig-node-service/src/rewards_claim/chain_port.rs create mode 100644 crates/dig-node-service/tests/common/mod.rs create mode 100644 crates/dig-node-service/tests/common/rewards_fixture.rs create mode 100644 crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs diff --git a/Cargo.lock b/Cargo.lock index 6999284f..ef6dccf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3217,9 +3217,9 @@ dependencies = [ [[package]] name = "dig-rewards-coin" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bb94b1f02239b4ad8072c5b1d37a00cca366cfeca73b34fcecd94a2470fdd0" +checksum = "6b8d7cc1302f08dda82fbba1d1a02e93adc8a0b07888585a71d1a3ab54532f8d" dependencies = [ "chia-bls 0.36.1", "chia-consensus 0.36.1", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 4cc7b516..853c81d3 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -153,6 +153,12 @@ chia-sdk-types = { version = "=0.36.0", features = ["chip-0035", "action-layer"] # Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned # coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. chia-puzzle-types = "=0.36.1" +# `SINGLETON_LAUNCHER_HASH` -- `rewards_claim::chain_port::HintedLauncherIndex` filters a hinted +# coin candidate down to an actual singleton-launcher coin before ever handing its id to +# `dig_rewards_coin::discover_distributor` (dig_ecosystem#3347). Promoted from a dev-only edge to +# a normal one at the SAME `=0.20.3` pin the dev-dependency section below already resolves -- no +# second version enters the tree. +chia-puzzles = "=0.20.3" # `ToTreeHash`, to re-derive an owner puzzle hash from the key a mirror create is built for, so the # signer can refuse spends that are not its own wallet's rather than trusting the call site to have # passed the right key. Part of the same chia set above -- `chia-sdk-driver` already resolves it. @@ -202,11 +208,14 @@ dig-wallet = { path = "../dig-wallet" } # `ChainSource`. This is the ONE crate in this seam that depends on it — `dig-node-core` # deliberately does not (see `dig_node_core::rewards::port`'s module doc) because the # reader takes a `ChainSource` it has no seam to hold; this crate does (`dig-wallet`'s -# `CorroboratedChainSource`), so the adapter lives here. `0.5`, not the `0.4` the ticket -# names (stale): 0.5.0 is the release that already refuses `epoch_seconds == 0` inside -# `read_distributor` itself (see `rewards/chain_source.rs`'s module doc for the exact -# line, and why this crate ALSO refuses at its own edge as defence in depth). -dig-rewards-coin = "0.5" +# `CorroboratedChainSource`), so the adapter lives here. 0.5.0 was the release that first +# refused `epoch_seconds == 0` inside `read_distributor` itself (see `rewards/chain_source.rs`'s +# module doc for the exact line, and why this crate ALSO refuses at its own edge as defence in +# depth). Bumped to 0.7 for dig_ecosystem#3347: 0.7.0 adds `discovery::discover_distributor` (the +# real parent-spend memo decode `rewards_claim::chain_port::RealClaimChainPort` needs) and stays +# on the same chia 0.36 line (chia-sdk-driver `=0.36.0`, chia-protocol 0.36.1) every other +# dependency in this crate is already pinned to. +dig-rewards-coin = "0.7" # HTTP stack: the same axum/tokio the node itself uses, so there is one async runtime # and one server framework across the node and the service shell. `ws` enables diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs new file mode 100644 index 00000000..3cf73013 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -0,0 +1,373 @@ +//! `RealClaimChainPort` -- the production [`super::port::ClaimChainPort`] adapter over +//! `dig-rewards-coin` 0.7.0 and this node's own [`dig_wallet::sage::corroborated_source::CorroboratedChainSource`] +//! (DIG-Network/dig_ecosystem#3347). Until this file existed, [`super::port::UnavailableClaimChainPort`] +//! was the ONLY adapter this trait had, so every real cycle reported `ChainSourceUnavailable` -- +//! see [`super`]'s module doc, "the chain seam", for the history. +//! +//! # What this adapter can and cannot do on 0.7.0 +//! +//! Discovery, comment resolution, the reserve asset id and the chain-curried payout threshold are +//! all real reads. [`RealClaimChainPort::own_entry`] is real for `payout_puzzle_hash` and +//! `counter`, but 0.7.0 exposes no PUBLIC, PURE function that computes an entry's accrued amount +//! without also spending it -- the only place that arithmetic exists is +//! `chia_sdk_driver::RewardDistributorInitiatePayoutAction::spend`, which mutates the distributor +//! and the entry slot as a side effect of computing it. Re-deriving the same +//! `shares * (cumulative_payout - initial_cumulative_payout) / precision` formula here would be +//! the exact hand-rolled-money-arithmetic-in-the-wrong-layer shape DIG-Network/dig_ecosystem#3286 +//! named, and returning `0` would misreport a real accrual as "below threshold" -- so `own_entry` +//! refuses instead, naming the blocker: `dig_ecosystem#3356`. +//! +//! [`RealClaimChainPort::submit_initiate_payout`] refuses for a harder reason: 0.7.0's +//! `payout::initiate_payout` needs an `EntrySlotSource` yielding a `Slot` +//! carrying the CREATING distributor coin's real `LineageProof`, and `state.rs`'s own module doc +//! says its reader fabricates a dummy (all-zero) `LineageProof` for exactly this shape -- +//! `DistributorSnapshot` is a READ model, not a spendable one. Building a real proof here would be +//! reconstructing spend machinery in the wrong crate layer, so this refuses too, naming the same +//! blocker. +//! +//! Both refusals map, via `ClaimEngine`, to a NAMED `ClaimOutcome::Faulted` -- visible on the +//! status surface, never a silent success. + +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::Bytes32; +use dig_chainsource_interface::ChainSource; + +use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError}; + +use super::port::{ClaimChainPort, ClaimPortError}; +use super::types::{DiscoveredDistributor, OwnEntry}; + +/// DIG-Network/dig_ecosystem#3356 -- the `dig-rewards-coin` 0.8.0 ticket this adapter's two +/// unbuildable methods are blocked on. Named once so both refusal strings (and any future one) +/// stay in agreement about which ticket to point at. +const ACCRUED_AND_SUBMIT_BLOCKER: &str = "dig_ecosystem#3356"; + +/// 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; + +fn bounded(message: impl Into) -> String { + let message = message.into(); + if message.chars().count() <= MAX_ERROR_CHARS { + message + } else { + let truncated: String = message.chars().take(MAX_ERROR_CHARS).collect(); + format!("{truncated}... (truncated)") + } +} + +/// Proposes launcher ids for [`RealClaimChainPort::discover_distributors`] to try -- SPEC 13.1 +/// clause 2 needs a chain-wide enumerator and [`ChainSource`] has none of its own. An index only +/// PROPOSES: every id it returns is still re-verified through `dig_rewards_coin::discover_distributor` +/// inside `discover_distributors`, so an index that lies (or is merely stale) yields nothing, +/// never a forged discovery. +#[async_trait] +pub trait LauncherIndex: Send + Sync { + /// The launcher ids this index currently believes are worth trying. May include ids that turn + /// out not to be DIG rewards distributors at all -- that is `discover_distributors`'s filter to + /// apply, not this trait's. + async fn launcher_ids(&self) -> Result, ClaimPortError>; +} + +/// The real, chain-backed [`ClaimChainPort`] -- generic over the [`ChainSource`] (production: +/// [`dig_wallet::sage::corroborated_source::CorroboratedChainSource`], tests: +/// `dig_chainsource_interface::MockChainSource`) and the [`LauncherIndex`] (production: +/// [`HintedLauncherIndex`], tests: a small fixture in this crate's own test binaries). +pub struct RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + source: Arc, + index: I, +} + +impl RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + /// Wraps an already-constructed chain source and launcher index. 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. + #[must_use] + pub fn new(source: Arc, index: I) -> Self { + Self { source, index } + } +} + +/// Maps a [`GuardedReadError`] (this crate's own `epoch_seconds == 0` refusal, or +/// `dig_rewards_coin::state::read_distributor`'s own error) onto [`ClaimPortError`]. +fn guarded_read_error_to_claim_port_error(error: GuardedReadError) -> ClaimPortError { + match error { + GuardedReadError::NonTerminatingEpochSeconds => ClaimPortError::Other(bounded( + "refused: this distributor's epoch_seconds is 0, which would hang \ + commit_incentives's generation walk (see chain_source.rs's module doc)", + )), + GuardedReadError::Reader(dig_rewards_coin::RewardsError::ChainUnavailable(_)) => { + ClaimPortError::Unavailable + } + GuardedReadError::Reader(other) => ClaimPortError::Other(bounded(other.to_string())), + } +} + +/// Maps a `dig_rewards_coin::RewardsError` from `discover_distributor` onto [`ClaimPortError`] -- +/// the same `ChainUnavailable` split as [`guarded_read_error_to_claim_port_error`], applied to the +/// discovery module's own error type instead of the guarded-read one. +fn rewards_error_to_claim_port_error(error: dig_rewards_coin::RewardsError) -> ClaimPortError { + match error { + dig_rewards_coin::RewardsError::ChainUnavailable(_) => ClaimPortError::Unavailable, + other => ClaimPortError::Other(bounded(other.to_string())), + } +} + +/// Re-derives one launcher id's generation over `source`, verifying it through the real +/// parent-spend memo decode rather than trusting the id alone (SPEC 13.1 clause 6: a discovered +/// distributor's own fields are never caller input). `Ok(None)` covers BOTH "unknown to `source`" +/// and "not a DIG rewards distributor" -- neither is an error (SPEC §1.3). +fn resolve_via_chain( + source: &S, + launcher_id: Bytes32, +) -> Result, ClaimPortError> +where + S: ChainSource, +{ + let discovered = dig_rewards_coin::discover_distributor(source, launcher_id) + .map_err(rewards_error_to_claim_port_error)?; + + Ok(discovered.map(|d| { + let generation = d.generation(); + DiscoveredDistributor { + launcher_id: d.launcher_id(), + store_id: generation.store_id, + root: generation.root, + } + })) +} + +#[async_trait] +impl ClaimChainPort for RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + /// `&'static str` naming this adapter -- see the trait's own doc. Never a default impl, so a + /// new adapter must choose its own name rather than silently inheriting one that describes a + /// different adapter. + fn kind(&self) -> &'static str { + "real-corroborated" + } + + async fn discover_distributors(&self) -> Result, ClaimPortError> { + let candidate_ids = self.index.launcher_ids().await?; + let source = Arc::clone(&self.source); + + tokio::task::spawn_blocking(move || { + let mut discovered = Vec::new(); + for launcher_id in candidate_ids { + // 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. + match resolve_via_chain(source.as_ref(), launcher_id) { + Ok(Some(distributor)) => discovered.push(distributor), + Ok(None) => {} + Err(ClaimPortError::Unavailable) => return Err(ClaimPortError::Unavailable), + Err(other) => return Err(other), + } + } + Ok(discovered) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "discover_distributors task panicked: {join_error}" + ))) + })? + } + + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || resolve_via_chain(source.as_ref(), launcher_id)) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "resolve_launch_comment task panicked: {join_error}" + ))) + })? + } + + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + Ok(snapshot.distributor().info.constants.reserve_asset_id) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!("reserve_asset_id task panicked: {join_error}"))) + })? + } + + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + // Chain-curried, per SPEC §8.3 -- never `dig_rewards_coin::PAYOUT_THRESHOLD_BASE_UNITS` + // (that constant is this distributor's DEFAULT launch value, not what any given + // on-chain distributor was actually launched with; a distributor with a + // non-default threshold would silently mis-evaluate against the literal). + Ok(dig_rewards_coin::payout::payout_threshold_base_units( + snapshot.distributor(), + )) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!("payout_threshold task panicked: {join_error}"))) + })? + } + + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + // SPEC §10.2/§12.5: fresh on EVERY call -- no cache field of any kind on this adapter. + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + + let Some(entry) = snapshot + .slots() + .entries + .iter() + .find(|e| e.payout_puzzle_hash == payout_puzzle_hash) + else { + return Ok(None); + }; + + // See this module's doc: 0.7.0 has no public, pure function to compute what this entry + // has accrued without also spending it, and this adapter refuses to re-derive the + // money arithmetic itself or to fabricate a `0`. + Err(ClaimPortError::Other(bounded(format!( + "accrued amount unreadable on dig-rewards-coin 0.7.0: no public function computes \ + an entry's accrued base units without spending it; blocked on \ + {ACCRUED_AND_SUBMIT_BLOCKER}" + )))) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!("own_entry task panicked: {join_error}"))) + })? + } + + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + // This adapter attaches no fee coin and signs nothing: `InitiatePayout` is permissionless + // (SPEC §7.1, `require_payout_approval = false`) and the reserve pays out via a + // singleton-delegated announcement, not a fee this node fronts. Node policy, not a chain + // read -- so `0` here is not a fabricated chain answer, it is what this adapter charges. + Ok(0) + } + + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + // See this module's doc: 0.7.0 exposes no chain-backed spendable entry slot and no real + // reserve lineage proof -- `DistributorSnapshot` is a read model. Reconstructing one here + // would be hand-rolled spend machinery in the wrong crate layer. Refused, named, visible + // via `ClaimEngine`'s mapping to `ClaimOutcome::Faulted` -- never a silent success. + Err(ClaimPortError::Other(bounded(format!( + "payout submission blocked on {ACCRUED_AND_SUBMIT_BLOCKER}: dig-rewards-coin 0.7.0 has \ + no chain-backed spendable entry slot or real reserve lineage proof" + )))) + } +} + +/// The production [`LauncherIndex`]: proposes every launcher coin this node's own peers have seen +/// hinted with the DIG rewards distributor hint (SPEC 13.1 clause 5's literal, +/// `"Reward Distributor v1"`, tree-hashed here rather than written as a hash literal), filtered to +/// an actual singleton-launcher coin. +/// +/// Every id this proposes is still re-verified through `discover_distributor` by +/// [`RealClaimChainPort::discover_distributors`] -- a hinted coin that is not really a +/// DIG-rewards-launching singleton launcher yields nothing, it is never trusted directly. +pub struct HintedLauncherIndex { + wallet_chain: Arc, +} + +impl HintedLauncherIndex { + /// Wraps the node's own wallet chain transport -- the SAME `Arc` `server.rs` holds as + /// `state.wallet_chain`. + #[must_use] + pub fn new(wallet_chain: Arc) -> Self { + Self { wallet_chain } + } +} + +#[async_trait] +impl LauncherIndex for HintedLauncherIndex { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + use dig_wallet::sage::fallback::ChainFallback; + + // SPEC 13.1 clause 5: the hint is COMPUTED as the tree hash of the literal string, never a + // hash literal -- the identical discipline `dig_rewards_coin::discovery`'s own decode + // applies to the same constant. + let mut allocator = clvmr::Allocator::new(); + let hint_ptr = clvmr::serde::node_from_bytes( + &mut allocator, + &clvm_traits::ToClvm::to_clvm(&"Reward Distributor v1", &mut allocator) + .map_err(|error| { + ClaimPortError::Other(bounded(format!( + "could not allocate the launcher hint literal: {error}" + ))) + })? + .to_bytes(&allocator), + ) + .map_err(|error| { + ClaimPortError::Other(bounded(format!( + "could not re-decode the launcher hint literal: {error}" + ))) + })?; + let hint: chia_protocol::Bytes32 = clvm_utils::tree_hash(&allocator, hint_ptr).into(); + let hint_hex = hex::encode(hint.to_bytes()); + + let coins = self + .wallet_chain + .coin_records_by_hints(&[hint_hex]) + .await + .map_err(|error| ClaimPortError::Other(bounded(error.to_string())))?; + + let launcher_hash_hex = hex::encode(chia_puzzles::SINGLETON_LAUNCHER_HASH); + + Ok(coins + .into_iter() + .filter(|coin| coin.puzzle_hash == launcher_hash_hex) + .filter_map(|coin| { + hex::decode(&coin.coin_id) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()) + .map(Bytes32::from) + }) + .collect()) + } +} diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 6dbf737f..c1fa6362 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -21,13 +21,15 @@ //! runs a cycle, then repeats — the counter is genuinely `0` until the first interval elapses. //! //! # No RPC surface here (SCOPE) -//! [`handle`] is an IN-PROCESS accessor only — a future RPC (blocked on DIG-Network/dig_ecosystem#3249 -//! re-deriving the `ClaimStatus` wire semantics) can read it; this module puts nothing on the wire -//! and adds no RPC method, dispatch-table row or handler. +//! [`handle`] is an IN-PROCESS accessor only — no RPC reads it yet; this module puts nothing on +//! the wire and adds no RPC method, dispatch-table row or handler. //! -//! # The only production adapter is [`super::UnavailableClaimChainPort`] -//! #3249 has not landed, so every real cycle this driver runs reports [`super::ClaimLoopState::ChainSourceUnavailable`] -//! and submits nothing — the honest state, not an invented adapter. +//! # The production adapter is [`super::RealClaimChainPort`] +//! Built from `wallet_chain.corroborated_chain_source(..)` -- the SAME call `server.rs`'s +//! funder-side install already makes -- paired with [`super::HintedLauncherIndex`]. If that source +//! cannot be built (offline, no peers), [`run_claim_driver`] records +//! [`ClaimDriverRefusal::ChainSourceUnbuildable`] and never builds an engine at all, rather than +//! falling back to [`super::UnavailableClaimChainPort`] silently. use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; @@ -37,10 +39,13 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; +use super::chain_port::{HintedLauncherIndex, RealClaimChainPort}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; use super::engine::{ClaimCadences, ClaimEngine, RawConfiguredCadence}; use super::hints::{DistributorHintSource, NoHintSource}; -use super::port::{ClaimChainPort, UnavailableClaimChainPort}; +use super::port::ClaimChainPort; +#[cfg(test)] +use super::port::UnavailableClaimChainPort; use super::types::{ClaimLoopState, ClaimStatus}; /// The in-process accessor onto the running claim loop (SCOPE: never exposed over the wire here). @@ -80,6 +85,11 @@ pub enum ClaimDriverRefusal { /// `enabled = true`, chain sync is on, but this node has no operator wallet to derive /// [`own_payout_puzzle_hash`] from -- there is no puzzle hash to build an engine with at all. NoOperatorWallet, + /// `enabled = true`, chain sync is on, this node HAS an operator wallet, but + /// `wallet_chain.corroborated_chain_source(..)` itself errored (offline, no peers) -- there is + /// no chain source to build [`super::RealClaimChainPort`] with. Never silently substitutes + /// [`super::UnavailableClaimChainPort`] instead (SHAPE: a named refusal, not a fallback). + ChainSourceUnbuildable, } impl ClaimLoopHandle { @@ -139,7 +149,7 @@ impl ClaimLoopHandle { /// "not spawned yet" as a THIRD state distinct from `Idle` -- it is the same state, honestly. static HANDLE: OnceLock = OnceLock::new(); -/// The in-process accessor a future RPC (blocked on #3249) reads. Never wired onto the wire here. +/// The in-process accessor a future RPC could read. Never wired onto the wire here. #[must_use] pub fn handle() -> ClaimLoopHandle { HANDLE.get_or_init(ClaimLoopHandle::default).clone() @@ -162,6 +172,7 @@ async fn drive( P: ClaimChainPort, H: DistributorHintSource, { + let adapter = engine.port_kind(); loop { let interval = next_interval_seconds(cadence_seconds, jitter_seconds, jitter); tokio::time::sleep(Duration::from_secs(interval)).await; @@ -169,15 +180,15 @@ async fn drive( engine.run_cycle(t).await; let status = engine.status(); handle.record(status); - log_cycle(&status, handle.cycles_driven(), &adjustment); + log_cycle(&status, handle.cycles_driven(), &adjustment, adapter); } } /// Emit the ONE record that makes a driven cycle observable in a running node. /// /// Without this, the whole status surface has no reader in a shipped binary: [`handle`] is -/// in-process only and deliberately carries no RPC (deferred to DIG-Network/dig_ecosystem#3249), -/// so a node whose claim loop can never claim a single reward would produce output IDENTICAL to a +/// in-process only and deliberately carries no RPC yet, so a node whose claim loop can never +/// claim a single reward would produce output IDENTICAL to a /// healthy one -- silence. A status nobody can read is a doc claim, not a measurement. /// /// [`ClaimLoopState::Nominal`] is the routine case (`info`). Every other state means this peer is @@ -188,7 +199,12 @@ async fn drive( /// named on every single cycle line, not just in the once-per-spawn WARN `sanitized_schedule` /// itself emits. Without this, an operator reading any one cycle log line has no way to learn the /// schedule in force differs from the one they configured. -fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdjustment) { +fn log_cycle( + status: &ClaimStatus, + cycles_driven: u64, + adjustment: &ScheduleAdjustment, + adapter: &'static str, +) { let (configured_cadence_seconds, effective_cadence_seconds) = adjustment .cadence .map_or((None, None), |(c, e)| (Some(c), Some(e))); @@ -199,6 +215,7 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdju if status.state == ClaimLoopState::Nominal { tracing::info!( target: "rewards_claim", + adapter, state = ?status.state, cycles_driven, distributors_known = status.distributors_known, @@ -213,6 +230,7 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdju } else { tracing::warn!( target: "rewards_claim", + adapter, state = ?status.state, cycles_driven, distributors_known = status.distributors_known, @@ -281,15 +299,17 @@ pub fn own_payout_puzzle_hash(owner_inner_puzzle_hash: Bytes32) -> Bytes32 { /// The real, detached claim-loop task: derive this node's own payout puzzle hash from its operator /// wallet (the same public, no-unseal-required derivation [`crate::server::spawn_mirror_passes`] -/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the only -/// production adapter that exists ([`UnavailableClaimChainPort`] -- see this module's doc), and -/// drive it forever. +/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the production +/// [`super::RealClaimChainPort`] adapter (see this module's doc), and drive it forever. /// /// Never called directly by `server.rs` -- see [`spawn_claim_driver_if`], the tested gate that /// decides WHETHER to call this. `handle` is INJECTED (never the [`handle`] singleton read /// directly) so a test can drive this against a private, non-shared handle instead of the /// process-wide one. -async fn run_claim_driver(handle: ClaimLoopHandle) { +async fn run_claim_driver( + handle: ClaimLoopHandle, + wallet_chain: std::sync::Arc, +) { let paths = dig_wallet::autoseed::default_paths(); let Some(owner_inner_puzzle_hash) = dig_wallet::operator_wallet::operator_puzzle_hash(&paths) else { @@ -304,10 +324,31 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { }; let own_payout_puzzle_hash = own_payout_puzzle_hash(owner_inner_puzzle_hash); + // The SAME call server.rs's funder-side install already makes -- see this module's doc. + // A named refusal, never a silent fallback to `UnavailableClaimChainPort`. + let source = match wallet_chain.corroborated_chain_source(tokio::runtime::Handle::current()) { + Ok(source) => source, + Err(error) => { + tracing::warn!( + target: "rewards_claim", + %error, + "could not build a corroborated chain source, so this node has no chain to claim \ + against; the claim loop is NOT started -- rewards_claim.enabled stays true but no \ + cycle will ever run until this node has peer reads to corroborate against" + ); + handle.set_refusal(ClaimDriverRefusal::ChainSourceUnbuildable); + return; + } + }; + let port = RealClaimChainPort::new( + std::sync::Arc::new(source), + HintedLauncherIndex::new(wallet_chain), + ); + run_claim_driver_in( &crate::state::state_dir(), own_payout_puzzle_hash, - UnavailableClaimChainPort, + port, handle, ) .await; @@ -320,9 +361,8 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// tested gate and the tested [`drive`] loop was previously the only UNTESTED link in the chain, /// and an untested joint is exactly how #594's claim engine shipped complete and inert. /// -/// Generic over `P` so a test can drive this real body against a fake port; production always -/// passes [`UnavailableClaimChainPort`] (see the module doc -- there is deliberately no second -/// production adapter until #3249 lands). +/// Generic over `P` so a test can drive this real body against a fake port; production passes +/// [`super::RealClaimChainPort`] (see the module doc). /// The largest schedule value this driver will honour, in seconds: 31 days. Chosen to sit /// comfortably above every documented default -- [`CLAIM_CADENCE_SECONDS_DEFAULT`] is 86,400s /// (1 day) and [`CLAIM_JITTER_SECONDS_DEFAULT`] is 3,600s (1 hour) -- and above any plausible @@ -528,8 +568,11 @@ async fn run_claim_driver_in_with_clock

( /// Spawn the real claim-loop task, detached, against `handle` -- injected, never the [`handle`] /// singleton read from inside, so the only place the process-wide singleton is named is /// [`spawn_claim_driver_from_config`]. -fn spawn_claim_driver(handle: ClaimLoopHandle) { - tokio::spawn(run_claim_driver(handle)); +fn spawn_claim_driver( + handle: ClaimLoopHandle, + wallet_chain: std::sync::Arc, +) { + tokio::spawn(run_claim_driver(handle, wallet_chain)); } /// Why [`spawn_claim_driver_if`] declined to spawn -- named so the caller can log a reason instead @@ -596,14 +639,20 @@ fn spawn_claim_driver_if( /// Reads [`RewardsClaimConfig::load`] (the node's own state-dir config) and `enable_chain_sync`, /// and calls [`spawn_claim_driver_if`] -- the exact one call `serve_with_shutdown` makes. -pub fn spawn_claim_driver_from_config(enable_chain_sync: bool) { +/// `wallet_chain` is this node's own wallet chain transport, the SAME `Arc` `server.rs` holds as +/// `state.wallet_chain` -- threaded through to [`run_claim_driver`] to build the production +/// [`super::RealClaimChainPort`]. +pub fn spawn_claim_driver_from_config( + enable_chain_sync: bool, + wallet_chain: std::sync::Arc, +) { let cfg = RewardsClaimConfig::load(); // The ONE place the process-wide singleton is read: everything below it takes an injected // handle so it stays testable in-process. let process_handle = handle(); let driver_handle = process_handle.clone(); spawn_claim_driver_if(cfg.enabled, enable_chain_sync, &process_handle, move || { - spawn_claim_driver(driver_handle); + spawn_claim_driver(driver_handle, wallet_chain); }); } @@ -738,7 +787,8 @@ mod tests { return; // this machine HAS an operator wallet; the refusal branch is unreachable here } let handle = ClaimLoopHandle::default(); - run_claim_driver(handle.clone()).await; + run_claim_driver(handle.clone(), Arc::new(dig_wallet::sage::chain::ChainTransport::new())) + .await; assert_eq!( handle.refusal(), Some(ClaimDriverRefusal::NoOperatorWallet), @@ -747,6 +797,54 @@ mod tests { assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); } + /// SHAPE guard: when this node HAS an operator wallet but its wallet chain transport cannot + /// build a corroborated source (offline, no peers -- `ChainTransport::new()`'s bare default), + /// `run_claim_driver` must record the named `ChainSourceUnbuildable` refusal, never fall back + /// to `UnavailableClaimChainPort` silently. Skipped on a machine with no operator wallet at + /// all -- that is the OTHER, earlier refusal, proven above. + #[tokio::test] + async fn an_unbuildable_chain_source_is_a_distinct_named_refusal() { + let paths = dig_wallet::autoseed::default_paths(); + if dig_wallet::operator_wallet::operator_puzzle_hash(&paths).is_none() { + return; // no operator wallet on this machine: the earlier refusal fires first + } + let handle = ClaimLoopHandle::default(); + run_claim_driver(handle.clone(), Arc::new(dig_wallet::sage::chain::ChainTransport::new())) + .await; + assert_eq!( + handle.refusal(), + Some(ClaimDriverRefusal::ChainSourceUnbuildable), + "a chain transport with no peer reads must be a named refusal, not a silent \ + UnavailableClaimChainPort substitution" + ); + assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); + } + + /// SHAPE (6d, the #3310-class trap "compiles, nothing constructs it"): the production factory + /// actually builds a [`RealClaimChainPort`] against a mock source with the fixture-provided + /// [`LauncherIndex`], and its `kind()` reads `"real-corroborated"` -- proving the substitution + /// this ticket makes is reachable, not merely present in the source. Reverting + /// `run_claim_driver_in`'s call in `run_claim_driver` back to `UnavailableClaimChainPort` turns + /// this test red (`kind()` would read `"unavailable"` instead). + #[tokio::test] + async fn the_production_factory_builds_a_real_corroborated_port() { + struct NoLauncherIds; + #[async_trait] + impl super::super::chain_port::LauncherIndex for NoLauncherIds { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + } + + let source = dig_chainsource_interface::MockChainSource::new(); + let port = RealClaimChainPort::new(Arc::new(source), NoLauncherIds); + assert_eq!( + ClaimChainPort::kind(&port), + "real-corroborated", + "the production adapter must name itself, not inherit UnavailableClaimChainPort's name" + ); + } + // ---- A1 + A2: the anti-silence cycle counter through the real drive() loop ------------- /// A fake port whose every call succeeds with an empty/zero answer -- enough to let @@ -791,6 +889,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Ok(()) } + + fn kind(&self) -> &'static str { + "test-empty" + } } fn empty_engine() -> ClaimEngine { @@ -952,6 +1054,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Ok(()) } + + fn kind(&self) -> &'static str { + "test-one-distributor" + } } #[tokio::test] @@ -1339,12 +1445,11 @@ mod tests { driver.abort(); } - /// The same production body against the port production ACTUALLY passes it - /// ([`UnavailableClaimChainPort`], the only adapter until #3249) reports - /// [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven -- the - /// honest state of a real node today. Proves the real adapter path is reached, not only a fake - /// one: a counted cycle whose outcome names the missing chain source, never a reassuring - /// `Nominal` and never silence. + /// The same production body [`run_claim_driver_in`] runs, against [`UnavailableClaimChainPort`] + /// (now only the engine's test double -- production passes [`super::RealClaimChainPort`]), + /// reports [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven: a + /// counted cycle whose outcome names the missing chain source, never a reassuring `Nominal` + /// and never silence. #[tokio::test(start_paused = true)] async fn the_production_adapter_reports_chain_source_unavailable_by_name() { let cadence = 100u64; diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index df110ab5..9aae37b7 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -231,6 +231,13 @@ impl ClaimEngine { self.rotation_cursor } + /// Which [`ClaimChainPort`] adapter this engine is driving against -- surfaced so a running + /// node's own log line can name it (DIG-Network/dig_ecosystem#3347), never left implicit. + #[must_use] + pub fn port_kind(&self) -> &'static str { + self.port.kind() + } + /// F7: restores the persisted aggregate-fee-budget window and cadence clock from `dir` and /// arms this engine to keep persisting them there after every submission and every completed /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). @@ -1140,6 +1147,10 @@ mod tests { .push((launcher_id, payout_puzzle_hash, fee_mojos)); Ok(()) } + + fn kind(&self) -> &'static str { + "test-fake" + } } fn one_distributor( @@ -1452,6 +1463,10 @@ mod tests { ) -> Result<(), ClaimPortError> { self.0.submit_initiate_payout(l, p, f).await } + + fn kind(&self) -> &'static str { + self.0.kind() + } } let port = HintOnlyPort(FakeChainPort::new(vec![d])); @@ -1537,6 +1552,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Err(ClaimPortError::Other("unreachable".into())) } + + fn kind(&self) -> &'static str { + "test-always-faulting-discovery" + } } /// Defect A1/A2 regression -- THE anti-green test for this defect: a port that errors on @@ -2850,6 +2869,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.inner.kind() + } } /// **F1 regression -- the anti-latch test.** `ChainSourceUnavailable` must be a PER-CYCLE @@ -2955,6 +2978,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.inner.kind() + } } /// **F3 regression -- staleness under a fresh timestamp.** Cycle 1 is healthy and submits a @@ -3074,6 +3101,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.0.kind() + } } /// **F4 (non-blocking, cheap) -- a duplicated launcher id must submit EXACTLY ONCE.** Without diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index 7804be4c..e8d17e52 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -19,33 +19,33 @@ //! //! # The chain seam //! -//! `dig-rewards-coin` is v0.1.3, published on crates.io, and still SPEC-only (`src/` is -//! `error.rs` + `lib.rs`); its driver is -//! DIG-Network/dig_ecosystem#3249, still open. So the whole engine here is built against the narrow -//! [`ClaimChainPort`] trait derived from the SPEC's described surface, tested with a full in-memory -//! fake, and the production adapter — until #3249 ships — is [`UnavailableClaimChainPort`], which -//! reports the named state `ChainSourceUnavailable` and runs zero cycles. This mirrors #3250's own -//! `UnavailableChainPort` exactly. When #3249 lands, one adapter is written against -//! `ClaimChainPort` and nothing above this seam changes. +//! `dig-rewards-coin` 0.7.0 ships a real driver (`discovery`, `payout`, `state`), landed by +//! DIG-Network/dig_ecosystem#3249. The production adapter is [`RealClaimChainPort`] +//! (`chain_port.rs`), built over this node's own corroborated chain source; [`UnavailableClaimChainPort`] +//! remains only as the engine's test double now. Two methods still refuse rather than answer: +//! `own_entry`'s accrued amount and `submit_initiate_payout` both need a chain-backed spendable +//! entry slot and a real reserve lineage proof that 0.7.0's read model does not carry — see +//! `chain_port.rs`'s own module doc, blocked on DIG-Network/dig_ecosystem#3356. //! //! A silent no-op that reported progress instead would be the exact defect this ticket exists to -//! prevent (SPEC §2.4): with the unavailable adapter wired, zero claims IS the true state, so the -//! status surface must say so by name, not by omission. +//! prevent (SPEC §2.4): a refused method reports a NAMED [`ClaimPortError`] or +//! [`super::types::ClaimOutcome::Faulted`], never a fabricated success. //! //! # Wired into node startup (DIG-Network/dig_ecosystem#3268) //! [`driver::spawn_claim_driver_from_config`] is the one call `dig-node-service::server`'s //! `serve_with_shutdown` makes: it is gated on `RewardsClaimConfig::enabled` AND //! `Config::enable_chain_sync` (the same flag `spawn_collateral_census` and //! `mirror::bond_verify::spawn_bond_verifier_install` already gate on), and when both are true it -//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever. -//! [`driver::handle`] is the IN-PROCESS accessor a future RPC can read once DIG-Network/dig_ecosystem#3249 -//! lands a real [`ClaimChainPort`] adapter and the `ClaimStatus` wire semantics are re-derived -//! against it — this module puts nothing on the wire itself (see `driver`'s own module doc for -//! why). Until #3249 lands, the only production adapter is still [`UnavailableClaimChainPort`], so -//! every real cycle reports [`ClaimLoopState::ChainSourceUnavailable`] and submits nothing — the -//! honest state, not a silent no-op. +//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever, +//! over a [`RealClaimChainPort`] built from `state.wallet_chain`'s corroborated source. If that +//! source cannot be built (offline, no peers), the loop reports the named refusal +//! `ClaimDriverRefusal::ChainSourceUnbuildable` and runs zero cycles rather than installing +//! [`UnavailableClaimChainPort`] silently. [`driver::handle`] is the IN-PROCESS accessor a future +//! RPC can read against the `ClaimStatus` wire semantics — this module puts nothing on the wire +//! itself (see `driver`'s own module doc for why). mod cadence; +mod chain_port; mod config; mod driver; mod engine; @@ -59,6 +59,7 @@ pub use config::{ RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, }; +pub use chain_port::{HintedLauncherIndex, LauncherIndex, RealClaimChainPort}; pub use driver::{handle, spawn_claim_driver_from_config, ClaimDriverRefusal, ClaimLoopHandle}; pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index f5988f52..9a4c57a3 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -67,11 +67,17 @@ pub trait ClaimChainPort: Send + Sync { payout_puzzle_hash: Bytes32, fee_mojos: u64, ) -> Result<(), ClaimPortError>; + + /// Names which adapter is installed, so a running node's own log line can say which one -- + /// no default impl, so a new adapter must choose its own name rather than silently inheriting + /// one that describes a different adapter. + fn kind(&self) -> &'static str; } -/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports -/// [`ClaimPortError::Unavailable`] on every call and runs zero cycles — the named state -/// `ChainSourceUnavailable` (see the module doc), never a silent no-op. +/// The engine's test double: reports [`ClaimPortError::Unavailable`] on every call and runs zero +/// cycles — the named state `ChainSourceUnavailable` (see the module doc), never a silent no-op. +/// No production path constructs this any more; the real adapter is +/// [`super::chain_port::RealClaimChainPort`]. pub struct UnavailableClaimChainPort; #[async_trait] @@ -115,6 +121,10 @@ impl ClaimChainPort for UnavailableClaimChainPort { ) -> Result<(), ClaimPortError> { Err(ClaimPortError::Unavailable) } + + fn kind(&self) -> &'static str { + "unavailable" + } } #[cfg(test)] diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index ecb5b3bf..0dc1279a 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2231,16 +2231,24 @@ where // (a tested unit, #1864) so it cannot be silently flipped to always- or never-spawn. crate::self_heal::spawn_driver_if_service(); - // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251): drives + // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251, #3347): drives // `rewards_claim::ClaimEngine::run_cycle` on a jittered cadence so // `RewardsClaimConfig::enabled = true` stops being a false statement. Gated the same way the // census and bond-verifier spawns above are -- `enable_chain_sync` already means "this node // talks to the Chia network", and a harness sets it false precisely so nothing dials. The // service-gate lives inside the seam (a tested unit, mirroring `self_heal::spawn_driver_if`) - // so it cannot be silently flipped to always- or never-spawn. The only production chain - // adapter until DIG-Network/dig_ecosystem#3249 lands is `UnavailableClaimChainPort`, so every - // real cycle reports `ChainSourceUnavailable` and submits nothing -- the honest state. - crate::rewards_claim::spawn_claim_driver_from_config(config.enable_chain_sync); + // so it cannot be silently flipped to always- or never-spawn. The production chain adapter is + // `rewards_claim::RealClaimChainPort`, built from this node's own `wallet_chain`'s + // corroborated source (the same call the funder-side reward-chain-port install above makes) -- + // a source that fails to build is a named refusal (`ClaimDriverRefusal::ChainSourceUnbuildable`), + // never a silent `UnavailableClaimChainPort` substitution. Two methods still refuse: + // `own_entry`'s accrued amount and `submit_initiate_payout`, both blocked on + // DIG-Network/dig_ecosystem#3356 (`dig-rewards-coin` 0.7.0 has no chain-backed spendable entry + // slot or real reserve lineage proof). + crate::rewards_claim::spawn_claim_driver_from_config( + config.enable_chain_sync, + state.wallet_chain.clone(), + ); // Best-effort wallet mTLS listener (#368, Sage byte-parity, node-class clients, §5.3). Binds // loopback only on [`DEFAULT_MTLS_PORT`], which is deliberately NOT Sage's own RPC port diff --git a/crates/dig-node-service/tests/common/mod.rs b/crates/dig-node-service/tests/common/mod.rs new file mode 100644 index 00000000..4f1d11ff --- /dev/null +++ b/crates/dig-node-service/tests/common/mod.rs @@ -0,0 +1,4 @@ +//! Shared integration-test fixtures — deliberately thin: each submodule owns one fixture, no +//! cross-fixture coupling. + +pub mod rewards_fixture; diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs new file mode 100644 index 00000000..26f1ed87 --- /dev/null +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -0,0 +1,260 @@ +//! A real DIG rewards distributor, launched once against `chia-sdk-test`'s peer simulator, and a +//! `MockChainSource` loaded from that real state — shared between +//! `tests/rewards_chain_port_a3.rs` (DIG-Network/dig_ecosystem#3310) and +//! `tests/rewards_claim_chain_port_3347.rs` (DIG-Network/dig_ecosystem#3347), which both need the +//! SAME real, decodable launch rather than two independently hand-rolled ones. See +//! `rewards_chain_port_a3.rs`'s original module doc (still the fixture's own doc below) for why +//! this substitution (the network transport, nothing else) is sound. + +use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; +use chia_puzzle_types::CoinProof; +use chia_puzzle_types::Memos; +use chia_puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_sdk_driver::{ + sign_standard_transaction, Cat, Offer, RewardDistributorConstants, RewardDistributorType, + SingleCatSpend, Spend, SpendContext, SpendWithConditions, StandardLayer, +}; +use chia_sdk_test::Simulator; +use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; +use clvm_traits::{clvm_quote, ToClvm}; +use clvmr::NodePtr; +use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_rewards_coin::comment::LaunchComment; +use dig_rewards_coin::constants::{ + MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, +}; +use dig_rewards_coin::launch::launch_dig_distributor; + +/// Small on purpose: the simulator's clock starts at zero. +pub const FIRST_EPOCH_START: u64 = 1_234; +/// A short epoch; these tests are about a report's/adapter's fields, not the epoch length. +pub const TEST_EPOCH_SECONDS: u64 = 1_000; +/// $DIG the funder mints for itself. +const MINTED_BASE_UNITS: u64 = 10_000_000_000; + +/// A fixed, never-launched manager singleton launcher id: curried into the constants table for +/// shape only, never read back off chain by `read_distributor`. +const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); + +/// The `store_id`/`root` this fixture's launch comment carries — asserted against by both +/// consumers of this fixture. +pub const LAUNCH_STORE_ID: Bytes32 = Bytes32::new([0xaa; 32]); +pub const LAUNCH_ROOT: Bytes32 = Bytes32::new([0xbb; 32]); + +/// Everything a real launch produced, named rather than positional. +pub struct LaunchedFixture { + pub sim: Simulator, + pub launcher_id: Bytes32, + pub security_coin_id: Bytes32, + pub distributor_coin_id: Bytes32, + pub reserve_coin_id: Bytes32, + pub reserve_launch_id: Bytes32, + pub reserve_parent_id: Bytes32, + pub launch_comment: LaunchComment, + pub constants: RewardDistributorConstants, +} + +/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via +/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from +/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. +pub fn launch_fixture() -> Result> { + let ctx = &mut SpendContext::new(); + let mut sim = Simulator::new(); + + let funder = sim.bls(MINTED_BASE_UNITS); + let funder_p2 = StandardLayer::new(funder.pk); + let (issue_cat, source_cats) = Cat::single_issuance( + ctx, + funder.coin.coin_id(), + None, + MINTED_BASE_UNITS, + Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), + )?; + funder_p2.spend(ctx, funder.coin, issue_cat)?; + let source_cat = source_cats[0]; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + + let offer_amount = 1; + let launcher_bls = sim.bls(offer_amount); + let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + + let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + SETTLEMENT_PAYMENT_HASH.into(), + source_cat.coin.amount, + Memos::None + )) + .to_clvm(ctx)?; + let cat_inner_spend = funder_p2.delegated_inner_spend( + ctx, + Spend { + puzzle: cat_inner_puzzle, + solution: NodePtr::NIL, + }, + )?; + source_cat.spend( + ctx, + SingleCatSpend { + prev_coin_id: source_cat.coin.coin_id(), + next_coin_proof: CoinProof { + parent_coin_info: source_cat.coin.parent_coin_info, + inner_puzzle_hash: funder.puzzle_hash, + amount: source_cat.coin.amount, + }, + prev_subtotal: 0, + extra_delta: 0, + p2_spend: cat_inner_spend, + revoke: false, + }, + )?; + + let spends = ctx.take(); + let cat_offer_spend = spends + .iter() + .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) + .expect("the CAT offer spend") + .clone(); + for spend in spends { + if spend.coin.coin_id() != source_cat.coin.coin_id() { + ctx.insert(spend); + } + } + + let signature = sign_standard_transaction( + ctx, + launcher_bls.coin, + offer_spend, + &launcher_bls.sk, + &TESTNET11_CONSTANTS, + )?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle { + coin_spends: vec![ + CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), + cat_offer_spend, + ], + aggregated_signature: signature, + }, + )?; + + let constants = RewardDistributorConstants::without_launcher_id( + RewardDistributorType::Managed { + manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, + }, + funder.puzzle_hash, + TEST_EPOCH_SECONDS, + u64::MAX, + MAX_SECONDS_OFFSET, + PAYOUT_THRESHOLD_BASE_UNITS, + false, + 0, + WITHDRAWAL_SHARE_BPS, + source_cat.info.asset_id, + ); + + let launch_comment = LaunchComment::new(LAUNCH_STORE_ID, LAUNCH_ROOT); + + let launched = launch_dig_distributor( + ctx, + &offer, + FIRST_EPOCH_START, + constants, + &TESTNET11_CONSTANTS, + launch_comment, + // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. + 0, + )?; + + sim.spend_coins( + ctx.take(), + &[ + launcher_bls.sk.clone(), + launched.security_coin_secret_key.clone(), + funder.sk.clone(), + ], + )?; + + let launcher_id = launched.distributor.info.constants.launcher_id; + let distributor_coin_id = launched.distributor.coin.coin_id(); + let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); + let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; + let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); + + // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. + // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record + // up after the fact, rather than tracking the security coin id through the launch machinery + // by hand. + let security_coin_id = sim + .coin_state(launcher_id) + .expect("the launcher coin was confirmed by the launch spend") + .coin + .parent_coin_info; + + Ok(LaunchedFixture { + sim, + launcher_id, + security_coin_id, + distributor_coin_id, + reserve_coin_id, + reserve_launch_id, + reserve_parent_id, + launch_comment, + constants: launched.distributor.info.constants, + }) +} + +/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what +/// `read_distributor_guarded`/`read_launch_comment` read — mirrors +/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. +pub fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { + let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; + + // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it + // named directly. + let eve_coin_id = fixture + .sim + .children(fixture.launcher_id) + .first() + .map(|state| state.coin.coin_id()); + + let extra_ids = [ + fixture.security_coin_id, + fixture.reserve_launch_id, + fixture.reserve_parent_id, + fixture.reserve_coin_id, + ]; + + let mut source = MockChainSource::new(); + for id in singleton_members + .iter() + .copied() + .chain(extra_ids.iter().copied()) + .chain(eve_coin_id) + { + if let Some(state) = fixture.sim.coin_state(id) { + source = source.with_coin(id, CoinRecord::from_coin_state(state)); + } + if let Some(spend) = fixture.sim.coin_spend(id) { + source = source.with_spend(id, spend); + } + } + + source = source.with_lineage( + fixture.launcher_id, + SingletonLineage::new( + fixture.distributor_coin_id, + singleton_members.iter().copied(), + ), + ); + + let peak = fixture.sim.height(); + for height in 0..=peak { + source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + } + source.with_peak(peak) +} diff --git a/crates/dig-node-service/tests/rewards_chain_port_a3.rs b/crates/dig-node-service/tests/rewards_chain_port_a3.rs index 01ad05b7..bb7a22f6 100644 --- a/crates/dig-node-service/tests/rewards_chain_port_a3.rs +++ b/crates/dig-node-service/tests/rewards_chain_port_a3.rs @@ -35,258 +35,19 @@ //! itself. That is true of this test as written; it is not a structural guarantee, and would stop //! being true the moment a second source of those fields is added here. +mod common; + use std::sync::Arc; -use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; -use chia_puzzle_types::CoinProof; -use chia_puzzle_types::Memos; -use chia_puzzles::SETTLEMENT_PAYMENT_HASH; -use chia_sdk_driver::{ - sign_standard_transaction, Cat, Offer, RewardDistributorConstants, RewardDistributorType, - SingleCatSpend, Spend, SpendContext, SpendWithConditions, StandardLayer, -}; -use chia_sdk_test::Simulator; -use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; -use clvm_traits::{clvm_quote, ToClvm}; -use clvmr::NodePtr; -use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_chainsource_interface::MockChainSource; use dig_node_core::rewards::port::RewardsChainPort; use dig_node_core::Node; use dig_node_service::rewards::RealRewardsChainPort; -use dig_rewards_coin::comment::LaunchComment; -use dig_rewards_coin::constants::{ - MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, -}; -use dig_rewards_coin::launch::launch_dig_distributor; - -/// Small on purpose: the simulator's clock starts at zero. -const FIRST_EPOCH_START: u64 = 1_234; -/// A short epoch; this test is about the report's fields, not the epoch length. -const TEST_EPOCH_SECONDS: u64 = 1_000; -/// $DIG the funder mints for itself. -const MINTED_BASE_UNITS: u64 = 10_000_000_000; - -/// A fixed, never-launched manager singleton launcher id: curried into the constants table for -/// shape only, never read back off chain by `read_distributor`. -const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); - -/// Everything a real launch produced, named rather than positional. -struct LaunchedFixture { - sim: Simulator, - launcher_id: Bytes32, - security_coin_id: Bytes32, - distributor_coin_id: Bytes32, - reserve_coin_id: Bytes32, - reserve_launch_id: Bytes32, - reserve_parent_id: Bytes32, - launch_comment: LaunchComment, - constants: RewardDistributorConstants, -} - -/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via -/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from -/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. -fn launch_fixture() -> Result> { - let ctx = &mut SpendContext::new(); - let mut sim = Simulator::new(); - - let funder = sim.bls(MINTED_BASE_UNITS); - let funder_p2 = StandardLayer::new(funder.pk); - let (issue_cat, source_cats) = Cat::single_issuance( - ctx, - funder.coin.coin_id(), - None, - MINTED_BASE_UNITS, - Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), - )?; - funder_p2.spend(ctx, funder.coin, issue_cat)?; - let source_cat = source_cats[0]; - sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; - - let offer_amount = 1; - let launcher_bls = sim.bls(offer_amount); - let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( - ctx, - Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), - )?; - let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; - let solution = ctx.serialize(&offer_spend.solution)?; - - let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( - SETTLEMENT_PAYMENT_HASH.into(), - source_cat.coin.amount, - Memos::None - )) - .to_clvm(ctx)?; - let cat_inner_spend = funder_p2.delegated_inner_spend( - ctx, - Spend { - puzzle: cat_inner_puzzle, - solution: NodePtr::NIL, - }, - )?; - source_cat.spend( - ctx, - SingleCatSpend { - prev_coin_id: source_cat.coin.coin_id(), - next_coin_proof: CoinProof { - parent_coin_info: source_cat.coin.parent_coin_info, - inner_puzzle_hash: funder.puzzle_hash, - amount: source_cat.coin.amount, - }, - prev_subtotal: 0, - extra_delta: 0, - p2_spend: cat_inner_spend, - revoke: false, - }, - )?; - - let spends = ctx.take(); - let cat_offer_spend = spends - .iter() - .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) - .expect("the CAT offer spend") - .clone(); - for spend in spends { - if spend.coin.coin_id() != source_cat.coin.coin_id() { - ctx.insert(spend); - } - } - - let signature = sign_standard_transaction( - ctx, - launcher_bls.coin, - offer_spend, - &launcher_bls.sk, - &TESTNET11_CONSTANTS, - )?; - let offer = Offer::from_spend_bundle( - ctx, - &SpendBundle { - coin_spends: vec![ - CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), - cat_offer_spend, - ], - aggregated_signature: signature, - }, - )?; - - let constants = RewardDistributorConstants::without_launcher_id( - RewardDistributorType::Managed { - manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, - }, - funder.puzzle_hash, - TEST_EPOCH_SECONDS, - u64::MAX, - MAX_SECONDS_OFFSET, - PAYOUT_THRESHOLD_BASE_UNITS, - false, - 0, - WITHDRAWAL_SHARE_BPS, - source_cat.info.asset_id, - ); - - let launch_comment = LaunchComment::new(Bytes32::new([0xaa; 32]), Bytes32::new([0xbb; 32])); +use dig_rewards_coin::constants::{PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS}; - let launched = launch_dig_distributor( - ctx, - &offer, - FIRST_EPOCH_START, - constants, - &TESTNET11_CONSTANTS, - launch_comment, - // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. - 0, - )?; - - sim.spend_coins( - ctx.take(), - &[ - launcher_bls.sk.clone(), - launched.security_coin_secret_key.clone(), - funder.sk.clone(), - ], - )?; - - let launcher_id = launched.distributor.info.constants.launcher_id; - let distributor_coin_id = launched.distributor.coin.coin_id(); - let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); - let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; - let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); - - // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. - // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record - // up after the fact, rather than tracking the security coin id through the launch machinery - // by hand. - let security_coin_id = sim - .coin_state(launcher_id) - .expect("the launcher coin was confirmed by the launch spend") - .coin - .parent_coin_info; - - Ok(LaunchedFixture { - sim, - launcher_id, - security_coin_id, - distributor_coin_id, - reserve_coin_id, - reserve_launch_id, - reserve_parent_id, - launch_comment, - constants: launched.distributor.info.constants, - }) -} - -/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what -/// `read_distributor_guarded`/`read_launch_comment` read — mirrors -/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. -fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { - let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; - - // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it - // named directly. - let eve_coin_id = fixture - .sim - .children(fixture.launcher_id) - .first() - .map(|state| state.coin.coin_id()); - - let extra_ids = [ - fixture.security_coin_id, - fixture.reserve_launch_id, - fixture.reserve_parent_id, - fixture.reserve_coin_id, - ]; - - let mut source = MockChainSource::new(); - for id in singleton_members - .iter() - .copied() - .chain(extra_ids.iter().copied()) - .chain(eve_coin_id) - { - if let Some(state) = fixture.sim.coin_state(id) { - source = source.with_coin(id, CoinRecord::from_coin_state(state)); - } - if let Some(spend) = fixture.sim.coin_spend(id) { - source = source.with_spend(id, spend); - } - } - - source = source.with_lineage( - fixture.launcher_id, - SingletonLineage::new( - fixture.distributor_coin_id, - singleton_members.iter().copied(), - ), - ); - - let peak = fixture.sim.height(); - for height in 0..=peak { - source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); - } - source.with_peak(peak) -} +use common::rewards_fixture::{ + launch_fixture, mock_chain_source, FIRST_EPOCH_START, TEST_EPOCH_SECONDS, +}; /// A3: `RealRewardsChainPort::distributor_report` — the real production adapter, driven by a /// `MockChainSource` loaded from a real simulator launch — reports the values launched with, 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 new file mode 100644 index 00000000..5019b4aa --- /dev/null +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -0,0 +1,178 @@ +//! DIG-Network/dig_ecosystem#3347 acceptance: `RealClaimChainPort` — the real claim-side adapter, +//! over the SAME real, decodable simulator launch `rewards_chain_port_a3.rs` uses (shared via +//! `tests/common/rewards_fixture.rs`). +//! +//! # What this proves, and what it does not +//! +//! Discovery (through an untrusted [`LauncherIndex`]), `reserve_asset_id`, and `payout_threshold` +//! are all real reads, proven end to end against a real launch. `own_entry` for an unknown payout +//! puzzle hash correctly reads `Ok(None)` (there is no entry keyed to a hash this fixture never +//! launched with). This file does NOT prove the accrued-amount or submit paths -- see +//! `chain_port.rs`'s own module doc for why both are structurally blocked on +//! DIG-Network/dig_ecosystem#3356 on `dig-rewards-coin` 0.7.0, and refuse by name rather than +//! guessing. + +mod common; + +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::Bytes32; +use dig_chainsource_interface::MockChainSource; +use dig_node_service::rewards_claim::{ClaimChainPort, ClaimPortError, LauncherIndex, RealClaimChainPort}; +use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; + +use common::rewards_fixture::{launch_fixture, mock_chain_source}; + +/// An index that proposes exactly the ids it is built with -- no re-verification of its own; that +/// is `RealClaimChainPort::discover_distributors`'s job, which this file's tests exercise. +struct FixtureLauncherIndex(Vec); + +#[async_trait] +impl LauncherIndex for FixtureLauncherIndex { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + Ok(self.0.clone()) + } +} + +/// The real launcher id discovers with the fixture's own `store_id`/`root` -- proving the SAME +/// memo-decode path `rewards_chain_port_a3.rs` proves for the funder-side adapter, now for the +/// claim-side one. +#[tokio::test(flavor = "multi_thread")] +async fn discover_distributors_returns_exactly_the_real_launch() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + ); + + let discovered = 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); +} + +/// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be +/// dropped, silently, by discovery's own re-verification -- never echoed back and never an error +/// for the whole batch. +#[tokio::test(flavor = "multi_thread")] +async fn a_bogus_index_entry_is_dropped_not_echoed() { + 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::new( + Arc::new(source), + FixtureLauncherIndex(vec![bogus_id, fixture.launcher_id]), + ); + + let discovered = port + .discover_distributors() + .await + .expect("a bogus id must be dropped, not fail the whole discovery"); + + assert_eq!( + discovered.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); +} + +/// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own +/// default-launch literal -- both read from the fixture's OWN launched constants. +#[tokio::test(flavor = "multi_thread")] +async fn reserve_asset_id_and_payout_threshold_are_read_from_chain() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + ); + + let reserve_asset_id = port + .reserve_asset_id(fixture.launcher_id) + .await + .expect("a real launched distributor's reserve asset id must read"); + assert_eq!( + reserve_asset_id, + fixture.constants.reserve_asset_id, + "must be the fixture's OWN minted CAT asset id, not any literal" + ); + + let payout_threshold = port + .payout_threshold(fixture.launcher_id) + .await + .expect("a real launched distributor's payout threshold must read"); + assert_eq!( + payout_threshold, PAYOUT_THRESHOLD_BASE_UNITS, + "must be the chain-curried value the fixture launched with, read via \ + dig_rewards_coin::payout::payout_threshold_base_units, never a literal" + ); +} + +/// `own_entry` for a payout puzzle hash this fixture never launched with reads `Ok(None)` -- +/// "no entry", never a fabricated one and never an error. +#[tokio::test(flavor = "multi_thread")] +async fn own_entry_reads_none_for_an_unknown_payout_puzzle_hash() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + ); + + let entry = port + .own_entry(fixture.launcher_id, Bytes32::from([0x42; 32])) + .await + .expect("a bare launch with no matching entry must read Ok(None), not an error"); + assert_eq!(entry, None); +} + +/// The production adapter names itself -- never inherits `UnavailableClaimChainPort`'s name. +#[tokio::test(flavor = "multi_thread")] +async fn kind_names_the_real_adapter() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + ); + assert_eq!(port.kind(), "real-corroborated"); +} + +/// A source that fails outright must surface `ClaimPortError::Unavailable` from every method, +/// never a silent empty answer that would misreport "the chain has nothing" instead of "the chain +/// could not be read". +#[tokio::test(flavor = "multi_thread")] +async fn a_failing_source_reports_unavailable_everywhere() { + let source = MockChainSource::new().fail_with( + dig_chainsource_interface::ChainSourceError::Transport( + "simulated transport failure".into(), + ), + ); + let port = RealClaimChainPort::new(Arc::new(source), FixtureLauncherIndex(vec![])); + + let launcher_id = Bytes32::from([1u8; 32]); + assert_eq!( + port.reserve_asset_id(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.payout_threshold(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.own_entry(launcher_id, Bytes32::from([2u8; 32])).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.resolve_launch_comment(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); +} From 0c7f9e0ba1c24d894432522bb83543376a520ef5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 18 Sep 2026 07:09:25 -0700 Subject: [PATCH 2/4] fix(rewards-claim): compute the launcher hint via tree_hash, not NodePtr::to_bytes Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/chain_port.rs | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) 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 3cf73013..2ebc1541 100644 --- a/crates/dig-node-service/src/rewards_claim/chain_port.rs +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -254,7 +254,7 @@ where ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) })?; - let Some(entry) = snapshot + let Some(_entry) = snapshot .slots() .entries .iter() @@ -333,21 +333,12 @@ impl LauncherIndex for HintedLauncherIndex { // hash literal -- the identical discipline `dig_rewards_coin::discovery`'s own decode // applies to the same constant. let mut allocator = clvmr::Allocator::new(); - let hint_ptr = clvmr::serde::node_from_bytes( - &mut allocator, - &clvm_traits::ToClvm::to_clvm(&"Reward Distributor v1", &mut allocator) - .map_err(|error| { - ClaimPortError::Other(bounded(format!( - "could not allocate the launcher hint literal: {error}" - ))) - })? - .to_bytes(&allocator), - ) - .map_err(|error| { - ClaimPortError::Other(bounded(format!( - "could not re-decode the launcher hint literal: {error}" - ))) - })?; + let hint_ptr = clvm_traits::ToClvm::to_clvm(&"Reward Distributor v1", &mut allocator) + .map_err(|error| { + ClaimPortError::Other(bounded(format!( + "could not allocate the launcher hint literal: {error}" + ))) + })?; let hint: chia_protocol::Bytes32 = clvm_utils::tree_hash(&allocator, hint_ptr).into(); let hint_hex = hex::encode(hint.to_bytes()); From 5f0ef97adda1500b436e2c551865ad4500f77b53 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 18 Sep 2026 10:14:50 -0700 Subject: [PATCH 3/4] style(rewards-claim): rustfmt Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/chain_port.rs | 8 ++++++-- .../dig-node-service/src/rewards_claim/driver.rs | 14 ++++++++++---- crates/dig-node-service/src/rewards_claim/mod.rs | 2 +- .../tests/rewards_claim_chain_port_3347.rs | 14 +++++++------- 4 files changed, 24 insertions(+), 14 deletions(-) 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 2ebc1541..c922e51c 100644 --- a/crates/dig-node-service/src/rewards_claim/chain_port.rs +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -214,7 +214,9 @@ where }) .await .map_err(|join_error| { - ClaimPortError::Other(bounded(format!("reserve_asset_id task panicked: {join_error}"))) + ClaimPortError::Other(bounded(format!( + "reserve_asset_id task panicked: {join_error}" + ))) })? } @@ -236,7 +238,9 @@ where }) .await .map_err(|join_error| { - ClaimPortError::Other(bounded(format!("payout_threshold task panicked: {join_error}"))) + ClaimPortError::Other(bounded(format!( + "payout_threshold task panicked: {join_error}" + ))) })? } diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index c1fa6362..92da6de2 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -787,8 +787,11 @@ mod tests { return; // this machine HAS an operator wallet; the refusal branch is unreachable here } let handle = ClaimLoopHandle::default(); - run_claim_driver(handle.clone(), Arc::new(dig_wallet::sage::chain::ChainTransport::new())) - .await; + run_claim_driver( + handle.clone(), + Arc::new(dig_wallet::sage::chain::ChainTransport::new()), + ) + .await; assert_eq!( handle.refusal(), Some(ClaimDriverRefusal::NoOperatorWallet), @@ -809,8 +812,11 @@ mod tests { return; // no operator wallet on this machine: the earlier refusal fires first } let handle = ClaimLoopHandle::default(); - run_claim_driver(handle.clone(), Arc::new(dig_wallet::sage::chain::ChainTransport::new())) - .await; + run_claim_driver( + handle.clone(), + Arc::new(dig_wallet::sage::chain::ChainTransport::new()), + ) + .await; assert_eq!( handle.refusal(), Some(ClaimDriverRefusal::ChainSourceUnbuildable), diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index e8d17e52..15f5ff54 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -55,11 +55,11 @@ mod port; mod types; pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +pub use chain_port::{HintedLauncherIndex, LauncherIndex, RealClaimChainPort}; pub use config::{ RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, }; -pub use chain_port::{HintedLauncherIndex, LauncherIndex, RealClaimChainPort}; pub use driver::{handle, spawn_claim_driver_from_config, ClaimDriverRefusal, ClaimLoopHandle}; pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; 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 5019b4aa..2d5bac99 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 @@ -19,7 +19,9 @@ use std::sync::Arc; use async_trait::async_trait; use chia_protocol::Bytes32; use dig_chainsource_interface::MockChainSource; -use dig_node_service::rewards_claim::{ClaimChainPort, ClaimPortError, LauncherIndex, RealClaimChainPort}; +use dig_node_service::rewards_claim::{ + ClaimChainPort, ClaimPortError, LauncherIndex, RealClaimChainPort, +}; use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; use common::rewards_fixture::{launch_fixture, mock_chain_source}; @@ -100,8 +102,7 @@ async fn reserve_asset_id_and_payout_threshold_are_read_from_chain() { .await .expect("a real launched distributor's reserve asset id must read"); assert_eq!( - reserve_asset_id, - fixture.constants.reserve_asset_id, + reserve_asset_id, fixture.constants.reserve_asset_id, "must be the fixture's OWN minted CAT asset id, not any literal" ); @@ -151,11 +152,10 @@ async fn kind_names_the_real_adapter() { /// could not be read". #[tokio::test(flavor = "multi_thread")] async fn a_failing_source_reports_unavailable_everywhere() { - let source = MockChainSource::new().fail_with( - dig_chainsource_interface::ChainSourceError::Transport( + let source = + MockChainSource::new().fail_with(dig_chainsource_interface::ChainSourceError::Transport( "simulated transport failure".into(), - ), - ); + )); let port = RealClaimChainPort::new(Arc::new(source), FixtureLauncherIndex(vec![])); let launcher_id = Bytes32::from([1u8; 32]); From dcda7989a2097b5cff01f52a4b5d584d8eb33203 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 18 Sep 2026 10:31:26 -0700 Subject: [PATCH 4/4] test(rewards-claim): production body reaches a real chain read (3347 acceptance) Co-Authored-By: Claude Sonnet 5 --- .../src/rewards_claim/driver.rs | 7 +- .../src/rewards_claim/engine.rs | 3 +- .../dig-node-service/src/rewards_claim/mod.rs | 5 +- .../tests/rewards_claim_chain_port_3347.rs | 86 ++++++++++++++++++- 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 92da6de2..4dbb582d 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -471,7 +471,12 @@ fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64, S (cadence, jitter, adjustment) } -async fn run_claim_driver_in

( +/// Public ONLY for DIG-Network/dig_ecosystem#3347's acceptance integration test +/// (`tests/rewards_claim_chain_port_3347.rs`), which needs to drive the real production body end +/// to end against a real `RealClaimChainPort`. Not part of this crate's public API otherwise -- +/// every other caller reaches this exclusively through [`spawn_claim_driver_from_config`]. +#[doc(hidden)] +pub async fn run_claim_driver_in

( state_dir: &Path, own_payout_puzzle_hash: Bytes32, port: P, diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 9aae37b7..9aea1184 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -986,7 +986,8 @@ mod tests { } /// A full in-memory fake standing in for the real chain adapter (see the module doc's "chain - /// seam" section) — the ONLY thing #3249 landing changes is which struct implements this trait. + /// seam" section) — #3249 landed `RealClaimChainPort` as the production implementer of this + /// trait; this fake stays as the engine's own unit-test double. struct FakeChainPort { distributors: Mutex>, submitted: Mutex>, diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index 15f5ff54..06f49354 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -60,7 +60,10 @@ pub use config::{ RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, }; -pub use driver::{handle, spawn_claim_driver_from_config, ClaimDriverRefusal, ClaimLoopHandle}; +pub use driver::{ + handle, run_claim_driver_in, spawn_claim_driver_from_config, ClaimDriverRefusal, + ClaimLoopHandle, +}; pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; pub use parser::parse_launch_comment; 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 2d5bac99..b92abe1e 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 @@ -20,7 +20,8 @@ use async_trait::async_trait; use chia_protocol::Bytes32; use dig_chainsource_interface::MockChainSource; use dig_node_service::rewards_claim::{ - ClaimChainPort, ClaimPortError, LauncherIndex, RealClaimChainPort, + run_claim_driver_in, ClaimChainPort, ClaimLoopHandle, ClaimLoopState, ClaimPortError, + LauncherIndex, RealClaimChainPort, RewardsClaimConfig, }; use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; @@ -176,3 +177,86 @@ async fn a_failing_source_reports_unavailable_everywhere() { Err(ClaimPortError::Unavailable) ); } + +/// The 3347 acceptance proof itself: driving the REAL production body +/// (`run_claim_driver_in`, the same function [`dig_node_service::rewards_claim::spawn_claim_driver_from_config`] +/// spawns in production) with a real `RealClaimChainPort` over the fixture's real launch reaches +/// an actual chain read -- not `ClaimLoopState::ChainSourceUnavailable`, and it actually discovers +/// the one real distributor and its one (entry-less) cycle outcome. This is the one test in this +/// file that proves the WIRING, not just the adapter in isolation. +#[tokio::test(start_paused = true)] +async fn a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + ); + + let state_dir_guard = tempfile::tempdir().expect("a temp state dir"); + let state_dir = state_dir_guard.path().to_path_buf(); + let cfg = RewardsClaimConfig { + enabled: true, + cadence_seconds: 3_600, + jitter_seconds: 0, + ..RewardsClaimConfig::default() + }; + cfg.save_to(&state_dir) + .expect("the config must save before the driver reads it"); + + let handle = ClaimLoopHandle::default(); + let handle_for_task = handle.clone(); + let own_payout_puzzle_hash = Bytes32::from([0x42; 32]); + + tokio::spawn(async move { + run_claim_driver_in(&state_dir, own_payout_puzzle_hash, port, handle_for_task).await; + }); + + // Let the spawned task run far enough to register its first `sleep` BEFORE advancing the + // virtual clock -- `tokio::time::advance` only fires timers already registered. + for _ in 0..10 { + tokio::task::yield_now().await; + } + + // The driver's first pass sleeps `cadence_seconds + jitter` before running its first cycle + // (see `driver.rs`'s own `drive` doc) -- jitter is pinned to 0 above, so this is exact. + tokio::time::advance(std::time::Duration::from_secs(3_600)).await; + // Let the woken task actually run its cycle (real chain reads go through + // `tokio::task::spawn_blocking`, which runs on a real OS thread unaffected by the paused + // virtual clock) before reading the handle back. + for _ in 0..50 { + tokio::task::yield_now().await; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + + let status = handle.status(); + // Per `rewards_chain_port_a3.rs`'s own module doc: this fixture's reserve asset is the + // SIMULATOR's own freshly minted CAT, never the real (unmintable-in-a-simulator) + // `dig_mirror_coin::DIG_ASSET_ID` -- `run_claim_driver_in` hardcodes that real asset id, so + // the engine correctly reads this real distributor, sees its asset does not match, and drops + // it as `NotOurs` (SPEC 9.3) BEFORE the entry-slot read -- it is never faulted, never + // read as chain-unavailable, and never fabricated as claimable. That is still real proof the + // production body reached a real chain read through `RealClaimChainPort`: a fabricated, + // no-adapter or wrongly-wired path could not produce "discovered exactly one, asset mismatch, + // cycle completed cleanly" -- it would read either zero known or chain-unavailable instead. + assert_ne!( + status.state, + ClaimLoopState::ChainSourceUnavailable, + "a real, launched fixture must not be read as chain-unavailable" + ); + assert_eq!( + status.distributors_known, 1, + "discovery must find the one real distributor this fixture launched" + ); + assert!( + !status.fault_reported, + "a real asset-id mismatch is a clean NotOurs drop, never a fault" + ); + assert!( + status.last_cycle_at.is_some(), + "a cycle must have actually completed, not merely been scheduled" + ); +}