From 4766aa9d1b2489f0e083b1bf297fc69b6b542923 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:44:11 -0700 Subject: [PATCH 01/13] chore(mirror): open the lane for #421 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 crates/dig-node-service/src/mirror/funding.rs diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs new file mode 100644 index 00000000..5ced0ccc --- /dev/null +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -0,0 +1,3 @@ +//! Operator-scoped $DIG CAT coin selection for mirror creates (dig-node#421). +//! +//! Lane opened; implementation follows. From ea83dd087cebd348a0a49f7b83def26cbbb2b181 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:51:49 -0700 Subject: [PATCH 02/13] feat(mirror): operator-scoped $DIG CAT selector over ChainSource (#421) Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 478 +++++++++++++++++- crates/dig-node-service/src/mirror/mod.rs | 1 + .../tests/mirror_operator_funding.rs | 411 +++++++++++++++ crates/dig-node-service/tests/support/mod.rs | 56 ++ crates/dig-wallet/src/sage/mod.rs | 1 + crates/dig-wallet/src/sage/offers.rs | 35 +- crates/dig-wallet/src/sage/rpc.rs | 39 +- crates/dig-wallet/src/sage/selection.rs | 191 +++++++ 8 files changed, 1165 insertions(+), 47 deletions(-) create mode 100644 crates/dig-node-service/tests/mirror_operator_funding.rs create mode 100644 crates/dig-wallet/src/sage/selection.rs diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 5ced0ccc..a03df731 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -1,3 +1,477 @@ -//! Operator-scoped $DIG CAT coin selection for mirror creates (dig-node#421). +//! Operator-scoped $DIG selection — where a mirror create's collateral comes from (dig-node#421). //! -//! Lane opened; implementation follows. +//! # The wallet this reads is not the wallet the node serves +//! +//! A mirror create locks money belonging to the §16.4 OPERATOR wallet — the key +//! [`super::signer::MirrorSigner`] signs with. The only $DIG selector this process previously had +//! was `WalletBackend::select_cats`, which selects over the node-custodied replica's own coin table +//! (`db.unreserved_unspent_coins`). Those are two different wallets holding two different sets of +//! coins, and funding a mirror coin from the replica's set would be a real spend of the wrong +//! wallet's money that returns `Ok` and looks entirely successful. +//! +//! So this module does not read a coin TABLE at all. It reads the chain, at one puzzle hash derived +//! from the operator's own: [`dig_cat_puzzle_hash`]. That derivation is what makes the scope a +//! structural property rather than a convention — there is no filter to forget, because a coin at +//! any other owner's puzzle hash is never read in the first place. +//! +//! # Lineage is the authentication, not a formality +//! +//! A `Cat` is only spendable with a lineage proof, and the proof comes from the spend that CREATED +//! the coin. Anyone may pay a coin to any puzzle hash, so a record returned by the scan is a +//! CANDIDATE: it becomes a spendable $DIG coin only once its creating spend has been executed and +//! `Cat::parse_children` has produced a child matching it. A candidate whose creating spend cannot +//! be read, or does not yield it, is REFUSED — the whole selection, not just that coin. +//! +//! # A shortfall refuses; it never funds a smaller coin +//! +//! `SPEC.md` §25: a mirror coin below the epoch's requirement is collateral that is genuinely locked +//! and does not satisfy the bond — strictly worse than not creating one. Every failure here is +//! therefore a refusal of the whole create, and the pass reports it in `stopped_at`. +//! +//! # Reservations, without a reservation table +//! +//! The wallet's own selector prunes reservations and reads the UNRESERVED unspent set +//! (dig_ecosystem#2763), so a coin committed to an in-flight bundle cannot fund a second one. The +//! chain cannot offer that: a broadcast coin stays unspent in the chain's view for the whole +//! confirmation window, and the mirror pass runs on a round timer inside it. +//! +//! The equivalent record already exists and is durable — [`SpendJournal`](crate::spend_audit) writes +//! the `funding_coin_ids` of every bundle it submits, and +//! [`SpendStatus::is_terminal`](crate::spend_audit::SpendStatus::is_terminal) already answers +//! exactly the right question: whether any further observation is expected to change the outcome. A +//! non-terminal record's funding coins may still be consumed, so they are withheld. See +//! [`committed_funding_coin_ids`]. + +use std::collections::HashSet; + +use chia_protocol::Bytes32; +use chia_puzzle_types::cat::CatArgs; +use chia_sdk_driver::Cat; +use dig_chainsource_interface::ChainSource; +use dig_mirror_coin::DIG_ASSET_ID; +use dig_wallet::sage::selection::select_largest_first; +use dig_wallet::sage::singleton::{resolve_cat, ParentSpend}; + +use crate::spend_audit::SpendLog; + +/// Why a create could not be funded. Every variant is a REFUSAL of the whole create. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FundingError { + /// The chain could not answer. The coin set is UNKNOWN, never empty. + Chain(String), + /// The operator wallet does not hold enough spendable, uncommitted $DIG. + Insufficient { + /// What the scan found at the operator puzzle hash, less anything committed in flight. + have_dig_base_units: u64, + /// The margined requirement the create needs. + need_dig_base_units: u64, + }, + /// A selected candidate could not be proven to be a spendable $DIG coin of this operator. + Unauthenticated { + /// The candidate, so an operator can look it up. + coin_id: String, + /// What could not be established. + reason: String, + }, + /// The audit record could not be read, so what is already committed is UNKNOWN. + /// + /// Fails closed for the reason the whole module does: an unreadable reservation set is + /// indistinguishable from an empty one, and treating it as empty is what double-commits a coin. + CommitmentsUnreadable(String), + /// A create was asked for at zero collateral. + /// + /// Refused HERE, ahead of the builder, because zero is the one target for which selection + /// legitimately returns an empty set — and an empty `Vec` is precisely the short funding + /// set this module exists to never produce. + ZeroCollateral, +} + +impl std::fmt::Display for FundingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FundingError::Chain(e) => { + write!(f, "the operator wallet's $DIG coins are unreadable: {e}") + } + FundingError::Insufficient { + have_dig_base_units, + need_dig_base_units, + } => write!( + f, + "the operator wallet holds {have_dig_base_units} uncommitted DIG base units and \ + the create needs {need_dig_base_units}; no spend was attempted" + ), + FundingError::Unauthenticated { coin_id, reason } => write!( + f, + "coin {coin_id} at the operator address could not be proven spendable $DIG \ + ({reason}), so the whole selection is refused" + ), + FundingError::CommitmentsUnreadable(e) => write!( + f, + "the spend audit record is unreadable ({e}), so which coins are already committed \ + to an in-flight bundle is unknown; no coin is selected" + ), + FundingError::ZeroCollateral => { + f.write_str("a create at zero collateral stakes nothing and is refused") + } + } + } +} + +/// The puzzle hash the operator's ordinary $DIG coins sit at. +/// +/// $DIG is a CAT, so the operator's coins are NEVER at the bare owner puzzle hash: they sit at the +/// canonical CAT wrapping of it under [`DIG_ASSET_ID`]. Scanning the unwrapped hash would find XCH +/// and nothing else — a confident empty answer, which reads as "this wallet has no $DIG". +/// +/// One derivation, used by both directions of the money: the coins a create SPENDS are found here, +/// and the coin a reclaim CREATES is named here. Two copies of a CAT curry is the shape that +/// produces a puzzle hash nobody can spend. +pub fn dig_cat_puzzle_hash(owner_puzzle_hash: Bytes32) -> Bytes32 { + let inner: clvm_utils::TreeHash = owner_puzzle_hash.into(); + CatArgs::curry_tree_hash(DIG_ASSET_ID, inner).into() +} + +/// The coins this node has already committed to a bundle whose outcome is not yet settled. +/// +/// Read from the audit record rather than from a side table, because the audit record is the thing +/// that survives a restart — and the window this guards against is measured in confirmation times, +/// which comfortably outlast a process. +/// +/// The predicate is [`SpendStatus::is_terminal`](crate::spend_audit::SpendStatus::is_terminal), +/// reused rather than restated. It already means "no further observation is expected to change +/// this", which is exactly the question being asked: a `Submitted` spend may still consume its +/// coins, an `Unresolved` one may already have, and a `Failed` one at a stage that +/// [may have moved money](crate::spend_audit::FailureStage::money_may_have_moved) is an unknown +/// wearing a failure's name. Only a `Confirmed` spend, or one that failed before signing, releases +/// its coins — and a `Confirmed` spend's coins are spent on chain anyway, so the scan never offers +/// them. +/// +/// A record with unreadable lines is a refusal, not a shorter answer: the lost lines may be exactly +/// the ones naming a committed coin, and a reservation set that silently shrinks is worse than none. +pub fn committed_funding_coin_ids(log: &SpendLog) -> Result, FundingError> { + let ledger = log + .ledger() + .map_err(|e| FundingError::CommitmentsUnreadable(e.to_string()))?; + if ledger.unreadable_lines > 0 { + return Err(FundingError::CommitmentsUnreadable(format!( + "{} entries could not be parsed", + ledger.unreadable_lines + ))); + } + Ok(ledger + .records + .iter() + .filter(|r| !r.status.is_terminal()) + .flat_map(|r| r.funding_coin_ids.iter().map(|c| c.0.clone())) + .collect()) +} + +/// Select spendable $DIG `Cat`s of the OPERATOR wallet covering `need_dig_base_units`. +/// +/// `need_dig_base_units` is $DIG in base units (1 DIG = 1_000, never mojos) and is the epoch's +/// derived requirement — `apply_safety_margin(required_per_store, margin_bp)`, `SPEC.md` §25.3 — +/// carried in from the planner. Nothing here re-derives it: this function selects coins to cover a +/// number, and has no opinion about what the number should be. +/// +/// `committed` is the output of [`committed_funding_coin_ids`], passed in rather than read here so +/// that one pass takes one reading of the audit record, in the same way it takes one reading of the +/// disk and one of the balance. +pub fn select_operator_dig_cats( + source: &S, + owner_puzzle_hash: Bytes32, + need_dig_base_units: u64, + committed: &HashSet, +) -> Result, FundingError> { + if need_dig_base_units == 0 { + return Err(FundingError::ZeroCollateral); + } + + let records = source + .coin_records_by_puzzle_hash(dig_cat_puzzle_hash(owner_puzzle_hash), false) + .map_err(|e| FundingError::Chain(e.to_string()))?; + + // `include_spent: false` is asked for above; `is_spent` is re-checked because a source that + // honours the flag and one that ignores it are indistinguishable from the returned rows, and + // selecting a spent coin produces a bundle the mempool rejects for reasons that look nothing + // like this. + let candidates: Vec<_> = records + .into_iter() + .filter(|r| !r.is_spent()) + .filter(|r| !committed.contains(&hex::encode(r.coin.coin_id()))) + .collect(); + + let available = candidates + .iter() + .fold(0u64, |sum, r| sum.saturating_add(r.coin.amount)); + + let selected = select_largest_first(candidates, need_dig_base_units, |r| { + (r.coin.amount, r.coin.coin_id()) + }) + .map_err(|_| FundingError::Insufficient { + have_dig_base_units: available, + need_dig_base_units, + })?; + + let mut cats = Vec::with_capacity(selected.len()); + for record in &selected { + cats.push(authenticate(source, record, owner_puzzle_hash)?); + } + Ok(cats) +} + +/// Turn one candidate record into a spendable [`Cat`], or refuse. +/// +/// The lineage proof is reconstructed from the spend that CREATED the coin — which is the spend that +/// SPENT its parent, hence the read on `parent_coin_info`. Executing that spend and matching a child +/// by coin id is what proves the candidate is a real CAT rather than a coin somebody paid to this +/// puzzle hash. +/// +/// The two identity checks after resolution are not redundant with the scan. The scan proves the +/// coin sits at a hash currying $DIG around this operator's inner puzzle; these assert that the +/// resolved CAT AGREES about both. They can only ever fire if the CAT construction and this module's +/// derivation have drifted apart, and that is precisely the condition under which a selection would +/// otherwise hand the builder coins it cannot spend. +fn authenticate( + source: &S, + record: &dig_chainsource_interface::CoinRecord, + owner_puzzle_hash: Bytes32, +) -> Result { + let coin_id = hex::encode(record.coin.coin_id()); + let refuse = |reason: &str| FundingError::Unauthenticated { + coin_id: coin_id.clone(), + reason: reason.to_string(), + }; + + let creating = source + .coin_spend(record.coin.parent_coin_info) + // An `Err` is the source failing to answer, which is a CHAIN failure and not a verdict about + // the coin. Kept distinct so an operator is not told their coin is forged when the truth is + // that the read timed out. + .map_err(|e| FundingError::Chain(e.to_string()))? + .ok_or_else(|| refuse("its creating spend is not on chain"))?; + + let parent = ParentSpend { + coin: creating.coin, + puzzle_reveal: creating.puzzle_reveal.into(), + solution: creating.solution.into(), + }; + let cat = resolve_cat(&parent, record.coin) + .map_err(|e| refuse(&format!("its lineage could not be executed: {e}")))? + .ok_or_else(|| refuse("its creating spend produced no matching CAT child"))?; + + if cat.info.asset_id != DIG_ASSET_ID { + return Err(refuse("the resolved CAT is not $DIG")); + } + if cat.info.p2_puzzle_hash != owner_puzzle_hash { + return Err(refuse("the resolved CAT is owned by a different puzzle hash")); + } + Ok(cat) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spend_audit::{ + kinds, Asset, Authority, FailureStage, FundingCoinId, SpendIntent, SpendJournal, SpendKind, + Submission, TargetCoinId, + }; + + fn owner(seed: u8) -> Bytes32 { + Bytes32::new([seed; 32]) + } + + /// The CAT wrapping is APPLIED, and it is applied around the owner — both halves. + /// + /// Two comparisons rather than one: against the bare owner hash, which catches a derivation that + /// forgot to wrap; and between two different owners, which catches one that wraps a constant. + /// Either mistake alone yields a puzzle hash that scans clean and finds nothing, and neither is + /// visible from a single equality. + #[test] + fn the_operator_scan_hash_wraps_dig_around_this_owner_specifically() { + let a = owner(0x11); + let b = owner(0x22); + assert_ne!( + dig_cat_puzzle_hash(a), + a, + "an unwrapped owner hash holds XCH, and scanning it reports no $DIG at all" + ); + assert_ne!( + dig_cat_puzzle_hash(a), + dig_cat_puzzle_hash(b), + "two operators must not share a scan hash, or one would fund from the other's coins" + ); + assert_eq!( + dig_cat_puzzle_hash(a), + CatArgs::curry_tree_hash(DIG_ASSET_ID, clvm_utils::TreeHash::from(a)).into(), + "the canonical CAT curry, never a hand-rolled one" + ); + } + + /// A create at zero collateral is refused before any coin is read. + #[test] + fn zero_collateral_is_refused_rather_than_selected_as_an_empty_set() { + struct Unusable; + impl ChainSource for Unusable { + type Error = std::io::Error; + fn coin_record(&self, _: Bytes32) -> Result, Self::Error> { + unreachable!("no chain read may happen at zero collateral") + } + fn coin_records_by_puzzle_hash( + &self, + _: Bytes32, + _: bool, + ) -> Result, Self::Error> { + unreachable!("no chain read may happen at zero collateral") + } + fn coin_records_by_parent(&self, _: Bytes32) -> Result, Self::Error> { + unreachable!() + } + fn coin_spend( + &self, + _: Bytes32, + ) -> Result, Self::Error> { + unreachable!() + } + fn resolve_singleton_lineage( + &self, + _: Bytes32, + ) -> Result, Self::Error> + { + unreachable!() + } + fn peak_height(&self) -> Result, Self::Error> { + unreachable!() + } + fn block_timestamp(&self, _: u32) -> Result, Self::Error> { + unreachable!() + } + } + type _CoinRecord = dig_chainsource_interface::CoinRecord; + + assert_eq!( + select_operator_dig_cats(&Unusable, owner(0x11), 0, &HashSet::new()), + Err(FundingError::ZeroCollateral) + ); + } + + /// An in-flight spend's funding coins are WITHHELD; a settled one's are released. + /// + /// The fixture varies ONE thing — the terminal status of the second spend — and keeps a truthful + /// control: a `Confirmed` spend beside a `Submitted` one. A fixture in which every spend were + /// in flight would read as the harsher case and is exactly the one that cannot show a release, + /// because there would be nothing left to release. + #[test] + fn only_non_terminal_spends_withhold_their_funding_coins() { + let dir = tempfile::tempdir().expect("a temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log.clone()); + + let in_flight = journal.begin(intent("in-flight")); + journal.submitted( + &in_flight, + Submission { + intended_coin_id: TargetCoinId("aa".repeat(32)), + funding_coin_ids: vec![FundingCoinId("11".repeat(32))], + }, + ); + + let settled = journal.begin(intent("settled")); + journal.submitted( + &settled, + Submission { + intended_coin_id: TargetCoinId("bb".repeat(32)), + funding_coin_ids: vec![FundingCoinId("22".repeat(32))], + }, + ); + journal.confirmed(&settled, TargetCoinId("bb".repeat(32)), 100); + + let refused_before_signing = journal.begin(intent("never-signed")); + journal.submitted( + &refused_before_signing, + Submission { + intended_coin_id: TargetCoinId("cc".repeat(32)), + funding_coin_ids: vec![FundingCoinId("33".repeat(32))], + }, + ); + journal.failed(&refused_before_signing, FailureStage::Signing, "no key"); + + let committed = committed_funding_coin_ids(&log).expect("readable"); + assert!( + committed.contains(&"11".repeat(32)), + "a submitted spend may still consume its coins, so they are withheld" + ); + assert!( + !committed.contains(&"22".repeat(32)), + "a confirmed spend has settled; its coins are spent on chain and are not withheld twice" + ); + assert!( + !committed.contains(&"33".repeat(32)), + "a failure BEFORE signing claims the money stayed put, so its coins are free again" + ); + assert_eq!(committed.len(), 1); + } + + /// A corrupt audit record REFUSES rather than reporting a smaller committed set. + /// + /// The discriminating fixture is a file with one GOOD line and one bad one: an implementation + /// that skips unparseable lines returns a plausible non-empty set here and would pass a test + /// that only checked "the good coin is present". + #[test] + fn an_unreadable_audit_record_refuses_rather_than_shrinking_the_reservation_set() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("spend-audit.jsonl"); + let log = SpendLog::at(path.clone()); + let journal = SpendJournal::new(log.clone()); + let spend = journal.begin(intent("in-flight")); + journal.submitted( + &spend, + Submission { + intended_coin_id: TargetCoinId("aa".repeat(32)), + funding_coin_ids: vec![FundingCoinId("11".repeat(32))], + }, + ); + + let mut text = std::fs::read_to_string(&path).expect("written"); + text.push_str("{ this is not a spend record\n"); + std::fs::write(&path, text).expect("rewritten"); + + assert!( + matches!( + committed_funding_coin_ids(&log), + Err(FundingError::CommitmentsUnreadable(_)) + ), + "a lost line may be the one naming a committed coin; a silently smaller reservation \ + set is how one coin funds two bundles" + ); + } + + /// A never-written audit record is an EMPTY commitment set, not a refusal. + /// + /// The ordinary case for a node that has never spent automatically. Refusing here would make + /// the very first create on every node impossible. + #[test] + fn a_node_that_has_never_spent_commits_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + assert_eq!( + committed_funding_coin_ids(&log).expect("a missing file is an empty record"), + HashSet::new() + ); + } + + fn intent(purpose: &str) -> SpendIntent { + SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: purpose.to_string(), + authority: Authority { + principal: "node".into(), + grant: "test".into(), + }, + asset: Asset::Dig, + amount_mojos: 1_000, + fee_mojos: 0, + store_id: None, + bond: None, + } + } +} diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index cff25bff..b25af96d 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -77,6 +77,7 @@ //! that confusion is exactly how a money bug ships. Fees, which genuinely are XCH mojos, are named //! `*_mojos` and come from separate coins so a fee can never shave collateral. +pub mod funding; pub mod lifecycle; pub mod observe; pub mod pass; diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs new file mode 100644 index 00000000..821a502c --- /dev/null +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -0,0 +1,411 @@ +//! **A mirror create is funded from the OPERATOR wallet's coins, or not at all** (dig-node#421). +//! +//! The defect this probe exists to prevent is not a crash. `WalletBackend::select_cats` selects over +//! the node-custodied replica's own coin table; the mirror signer signs with the §16.4 operator key. +//! Handing the first set to `dig_mirror_coin::create` produces a real, successful spend of the wrong +//! wallet's money — no error, no red test, and nothing on any surface that looks wrong. +//! +//! # Two wallets, both funded, is the only fixture that can show the difference +//! +//! Every probe below puts genuine $DIG at TWO different owner puzzle hashes on one chain. A fixture +//! with only the operator's coins would pass against a selector that ignored the owner argument +//! entirely and returned every $DIG coin it could see — which is exactly the implementation under +//! suspicion. Varying one actor while keeping a truthful control is what makes the assertions +//! discriminating rather than merely green. +//! +//! # Every coin comes from a genuine CAT spend +//! +//! A `Cat` is spendable only with a lineage proof reconstructed by EXECUTING its creating spend. A +//! hand-built `CoinRecord` never reaches that path, so a probe using one would assert lineage +//! handling against a fixture that cannot exhibit it. `support::ordinary_dig_coins` builds real CAT +//! spends, so a coin either resolves or genuinely does not. + +mod support; + +use std::collections::{HashMap, HashSet}; + +use chia_protocol::{Bytes32, CoinSpend}; +use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; +use dig_node_service::mirror::funding::{ + dig_cat_puzzle_hash, select_operator_dig_cats, FundingError, +}; +use support::{ordinary_dig_coins, wallet, Wallet}; + +/// The margined requirement a create is funded for, in $DIG **base units** (1 DIG = 1_000). +/// +/// Named rather than inlined so that no assertion below can be read as a claim about a particular +/// amount: this stands in for `apply_safety_margin(required_per_store, margin_bp)`, which the +/// planner derives and the selector never re-derives. +const REQUIRED: u64 = 40_000; + +/// A chain holding whatever the test put on it — and nothing else. +#[derive(Default)] +struct Chain { + /// Unspent coin records, by the puzzle hash they pay to. + by_puzzle_hash: HashMap>, + /// The spend that spent each coin — so `coin_spend(parent)` yields a coin's CREATING spend. + spends: HashMap, +} + +impl Chain { + /// Publish `amounts` of ordinary $DIG at `owner`'s address, with their real creating spend. + fn fund(&mut self, owner: &Wallet, amounts: &[u64], salt: u8) -> Vec { + let (spend, coins) = ordinary_dig_coins(owner, amounts, salt); + self.spends.insert(spend.coin.coin_id(), spend); + let mut ids = Vec::new(); + for coin in coins { + ids.push(coin.coin_id()); + self.by_puzzle_hash + .entry(coin.puzzle_hash) + .or_default() + .push(CoinRecord { + coin, + confirmed_height: Some(100), + spent_height: None, + timestamp: Some(1_700_000_000), + coinbase: false, + }); + } + ids + } + + /// Publish coins whose creating spend is NOT on chain — a coin somebody paid to this address. + fn fund_without_lineage(&mut self, owner: &Wallet, amounts: &[u64], salt: u8) { + let (spend, coins) = ordinary_dig_coins(owner, amounts, salt); + // The spend is deliberately NOT recorded, so the candidate cannot be authenticated. + let _ = spend; + for coin in coins { + self.by_puzzle_hash + .entry(coin.puzzle_hash) + .or_default() + .push(CoinRecord { + coin, + confirmed_height: Some(100), + spent_height: None, + timestamp: Some(1_700_000_000), + coinbase: false, + }); + } + } +} + +impl ChainSource for Chain { + type Error = ChainSourceError; + + fn coin_record(&self, _coin_id: Bytes32) -> Result, Self::Error> { + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(self + .by_puzzle_hash + .get(&puzzle_hash) + .cloned() + .unwrap_or_default()) + } + + fn coin_records_by_parent(&self, _parent: Bytes32) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.spends.get(&coin_id).cloned()) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(Some(1_000)) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(Some(1_700_000_000)) + } +} + +/// A chain that cannot answer at all — the fail-closed control. +struct Unreadable; + +impl ChainSource for Unreadable { + type Error = ChainSourceError; + + fn coin_record(&self, _: Bytes32) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } + fn coin_records_by_puzzle_hash( + &self, + _: Bytes32, + _: bool, + ) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } + fn coin_records_by_parent(&self, _: Bytes32) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } + fn coin_spend(&self, _: Bytes32) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } + fn resolve_singleton_lineage( + &self, + _: Bytes32, + ) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } + fn peak_height(&self) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } + fn block_timestamp(&self, _: u32) -> Result, Self::Error> { + Err(ChainSourceError::Transport("no source".into())) + } +} + +/// The §16.4 operator wallet, whose money a mirror create locks. +fn operator() -> Wallet { + wallet(0x21) +} + +/// The node-custodied replica — a DIFFERENT wallet, on the same chain, holding $DIG of its own. +/// +/// This is the wallet `WalletBackend::select_cats` reads. It exists in every fixture purely so that +/// selecting from it is a distinguishable outcome rather than an indistinguishable one. +fn replica() -> Wallet { + wallet(0x77) +} + +/// **The selector reads the OPERATOR's coins, and not the replica's.** +/// +/// The discriminating fixture: the replica is funded so generously that a selector reading its set +/// would succeed with room to spare, while the operator holds exactly enough. The two assertions are +/// therefore independent — the returned coins are the operator's, AND none of them is the replica's +/// — because a selector that returned the union would satisfy the first alone. +#[test] +fn the_selector_funds_from_the_operator_wallet_and_never_from_the_replica() { + let (operator, replica) = (operator(), replica()); + let mut chain = Chain::default(); + let operator_ids = chain.fund(&operator, &[REQUIRED], 0x01); + let replica_ids = chain.fund(&replica, &[REQUIRED * 10], 0x02); + + let cats = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect("the operator holds exactly enough"); + + let chosen: HashSet = cats.iter().map(|c| c.coin.coin_id()).collect(); + assert_eq!( + chosen, + operator_ids.iter().copied().collect::>(), + "the coins selected are the operator's own" + ); + for id in &replica_ids { + assert!( + !chosen.contains(id), + "a replica coin was selected: this is the spend of the wrong wallet's money that \ + dig-node#421 exists to make impossible" + ); + } + for cat in &cats { + assert_eq!( + cat.info.p2_puzzle_hash, operator.puzzle_hash, + "every resolved CAT is owned by the operator's inner puzzle" + ); + } +} + +/// **A replica funded alone cannot fund an operator create.** +/// +/// The mirror image of the probe above, and the one that fails loudly against the defect rather than +/// quietly: with ONLY the replica funded, a selector reading the replica's set returns coins and a +/// correct one refuses. The distinction is invisible in the previous test's success case if the +/// implementation returned a union. +#[test] +fn an_operator_with_no_coins_refuses_even_when_the_replica_is_rich() { + let (operator, replica) = (operator(), replica()); + let mut chain = Chain::default(); + chain.fund(&replica, &[REQUIRED * 10], 0x02); + + let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect_err("the operator holds nothing"); + assert_eq!( + err, + FundingError::Insufficient { + have_dig_base_units: 0, + need_dig_base_units: REQUIRED, + }, + "the operator's own address is empty, and the replica's balance is not its money" + ); +} + +/// **A coin committed to an in-flight bundle is not selected into a second one.** +/// +/// One reservation, one honest control: the operator holds two coins each covering the requirement, +/// and one of them is committed. A fixture reserving BOTH would read as the harsher case and is +/// exactly the one that cannot tell a working reservation filter from a selector that refused for +/// some other reason — there would be no uncommitted coin left to select. +#[test] +fn a_committed_coin_is_withheld_and_the_uncommitted_one_is_taken() { + let operator = operator(); + let mut chain = Chain::default(); + let ids = chain.fund(&operator, &[REQUIRED, REQUIRED], 0x01); + let (committed_id, free_id) = (ids[0], ids[1]); + + let committed: HashSet = [hex::encode(committed_id)].into_iter().collect(); + let cats = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &committed) + .expect("the uncommitted coin covers the requirement"); + + let chosen: Vec = cats.iter().map(|c| c.coin.coin_id()).collect(); + assert_eq!( + chosen, + vec![free_id], + "the committed coin funds a bundle already in flight; selecting it again double-commits it" + ); +} + +/// **Withholding the committed coin can turn a sufficient balance into a REFUSAL.** +/// +/// The previous probe shows a reservation being skipped; this shows it actually costing something. +/// Without it, a filter that merely reordered candidates would pass: here the operator's raw balance +/// covers the requirement and its UNCOMMITTED balance does not, so an unfiltered selector succeeds +/// and a correct one refuses. +#[test] +fn a_reservation_that_makes_the_balance_short_refuses_rather_than_double_spending() { + let operator = operator(); + let mut chain = Chain::default(); + let ids = chain.fund(&operator, &[REQUIRED / 2, REQUIRED / 2], 0x01); + + let committed: HashSet = [hex::encode(ids[0])].into_iter().collect(); + let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &committed) + .expect_err("half the balance is already committed"); + assert_eq!( + err, + FundingError::Insufficient { + have_dig_base_units: REQUIRED / 2, + need_dig_base_units: REQUIRED, + }, + "the committed half is not available, so the wallet is short and no spend is attempted" + ); +} + +/// **A shortfall REFUSES; it never returns a short funding set.** +/// +/// One base unit short, which is the boundary a partial-funding bug actually sits on. A create +/// funded from a short set locks collateral that does not satisfy the bond — money genuinely locked +/// for an advertisement that does not count. +#[test] +fn one_base_unit_short_refuses_rather_than_funding_a_smaller_coin() { + let operator = operator(); + let mut chain = Chain::default(); + chain.fund(&operator, &[REQUIRED - 1], 0x01); + + let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect_err("one unit short"); + assert_eq!( + err, + FundingError::Insufficient { + have_dig_base_units: REQUIRED - 1, + need_dig_base_units: REQUIRED, + } + ); +} + +/// **Exactly at the requirement is funded** — the other side of the same bound. +/// +/// Without this, the probe above is satisfied by an implementation that refuses everything. +#[test] +fn exactly_the_requirement_is_funded() { + let operator = operator(); + let mut chain = Chain::default(); + chain.fund(&operator, &[REQUIRED], 0x01); + + let cats = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect("an exact cover is a cover"); + assert_eq!(cats.iter().map(|c| c.coin.amount).sum::(), REQUIRED); +} + +/// **The target is the amount the caller passes, and the selection tracks it.** +/// +/// Two requirements over one coin set, asserting that a larger requirement draws MORE coins. A +/// selector that ignored its amount argument — taking every coin, or exactly one — would answer the +/// same for both, which is how a create at a hard-coded collateral would go unnoticed. +#[test] +fn the_number_of_coins_drawn_follows_the_requirement_it_was_given() { + let operator = operator(); + let mut chain = Chain::default(); + chain.fund(&operator, &[REQUIRED, REQUIRED, REQUIRED], 0x01); + + let small = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect("covered"); + let large = + select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED * 3, &HashSet::new()) + .expect("covered"); + + assert_eq!(small.len(), 1, "one coin covers one requirement"); + assert_eq!(large.len(), 3, "three are needed to cover three"); +} + +/// **A candidate that cannot be authenticated refuses the WHOLE selection.** +/// +/// Anyone may pay a coin to any puzzle hash. A coin whose creating spend is not on chain cannot have +/// its lineage proof reconstructed, so it is not spendable — and dropping it and proceeding with the +/// rest would fund the create from a short set, which is the failure this crate refuses by design. +/// +/// The fixture keeps a genuine, sufficient coin beside the unauthenticated one, so the refusal is +/// visibly caused by the bad candidate rather than by an empty wallet. +#[test] +fn an_unauthenticatable_candidate_refuses_the_selection_rather_than_being_skipped() { + let operator = operator(); + let mut chain = Chain::default(); + chain.fund(&operator, &[REQUIRED], 0x01); + // Larger, so largest-first reaches it FIRST and a skip would be observable as a success. + chain.fund_without_lineage(&operator, &[REQUIRED * 2], 0x03); + + let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect_err("a candidate could not be proven spendable"); + assert!( + matches!(err, FundingError::Unauthenticated { .. }), + "expected an authentication refusal, got {err:?}" + ); +} + +/// **An unreadable chain is UNKNOWN, never an empty wallet.** +/// +/// The two are one `Ok(vec![])` apart and mean opposite things: an empty answer says this operator +/// holds no $DIG, which is a definite claim a source that failed to answer is in no position to +/// make. The variant is asserted, not merely the failure, because `Insufficient` would be that claim +/// wearing an error's name. +#[test] +fn a_chain_that_cannot_answer_is_unknown_rather_than_a_short_wallet() { + let err = select_operator_dig_cats( + &Unreadable, + operator().puzzle_hash, + REQUIRED, + &HashSet::new(), + ) + .expect_err("the source cannot answer"); + assert!( + matches!(err, FundingError::Chain(_)), + "an unreadable source must not report the wallet as short: {err:?}" + ); +} + +/// The scan hash is the one the operator's coins actually land on. +/// +/// A cheap structural check that keeps the fixtures honest: if `dig_cat_puzzle_hash` and the +/// fixture's CAT construction ever disagreed, every probe above would scan an address holding +/// nothing and the whole file would go green on empty wallets. +#[test] +fn the_fixture_coins_land_on_the_puzzle_hash_the_selector_scans() { + let operator = operator(); + let (_, coins) = ordinary_dig_coins(&operator, &[REQUIRED], 0x01); + assert_eq!( + coins[0].puzzle_hash, + dig_cat_puzzle_hash(operator.puzzle_hash), + "the selector must scan the address the operator's $DIG actually sits at" + ); +} diff --git a/crates/dig-node-service/tests/support/mod.rs b/crates/dig-node-service/tests/support/mod.rs index da8d22fa..7e5a9d7b 100644 --- a/crates/dig-node-service/tests/support/mod.rs +++ b/crates/dig-node-service/tests/support/mod.rs @@ -235,3 +235,59 @@ pub fn xch_spend_paying_the_mirror_hash( (spend, coins) } + +/// Ordinary $DIG CAT coins of `owner` — the coins a mirror CREATE draws its collateral from. +/// +/// Distinct from every fixture above, and the distinction is the whole point of dig-node#421: those +/// build coins at the MIRROR puzzle hash (`P2ParentCoin`, where collateral already sits), while a +/// create spends coins at the owner's ORDINARY $DIG address — the canonical CAT wrapping of the +/// owner's standard puzzle hash. A selector pointed at the wrong one of those two finds nothing and +/// reports an empty wallet. +/// +/// The spend is real: the parent CAT runs through to its conditions, so the returned coins resolve +/// through `Cat::parse_children` exactly as chain-read coins do. `salt` varies the grandparent, so +/// two calls with the same amounts still produce distinct coins. +pub fn ordinary_dig_coins(owner: &Wallet, amounts: &[u64], salt: u8) -> (CoinSpend, Vec) { + let mut ctx = SpendContext::new(); + let parent_amount: u64 = amounts.iter().sum(); + + let cat_puzzle_hash: Bytes32 = + CatArgs::curry_tree_hash(DIG_ASSET_ID, TreeHash::from(owner.puzzle_hash)).into(); + let grandparent_parent = Bytes32::new([salt; 32]); + let grandparent = Coin::new(grandparent_parent, cat_puzzle_hash, parent_amount); + let parent = Coin::new(grandparent.coin_id(), cat_puzzle_hash, parent_amount); + + let lineage_proof = LineageProof { + parent_parent_coin_info: grandparent_parent, + parent_inner_puzzle_hash: owner.puzzle_hash, + parent_amount, + }; + let cat = Cat::new( + parent, + Some(lineage_proof), + CatInfo::new(DIG_ASSET_ID, None, owner.puzzle_hash), + ); + + // Paid to the owner's own inner puzzle hash: an ordinary CAT holding, not collateral. + let mut conditions = Conditions::new(); + for amount in amounts { + conditions = conditions.create_coin(owner.puzzle_hash, *amount, Memos::None); + } + + let inner_spend = StandardLayer::new(owner.public_key) + .spend_with_conditions(&mut ctx, conditions) + .unwrap(); + Cat::spend_all(&mut ctx, &[CatSpend::new(cat, inner_spend)]).unwrap(); + + let spend = ctx + .take() + .into_iter() + .find(|spend| spend.coin == parent) + .expect("the parent CAT spend"); + let coins = amounts + .iter() + .map(|amount| Coin::new(parent.coin_id(), cat_puzzle_hash, *amount)) + .collect(); + + (spend, coins) +} diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index 40ca7a27..095a25f2 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -76,6 +76,7 @@ pub mod quorum; pub mod rate_limit; pub mod routing; pub mod rpc; +pub mod selection; pub mod service; pub mod singleton; pub mod sources; diff --git a/crates/dig-wallet/src/sage/offers.rs b/crates/dig-wallet/src/sage/offers.rs index 0748ce41..5b5bdb61 100644 --- a/crates/dig-wallet/src/sage/offers.rs +++ b/crates/dig-wallet/src/sage/offers.rs @@ -430,33 +430,24 @@ fn select_xch(coins: &[Coin], need: u64) -> Result> { } /// Greedily select CAT coins of `asset_id` (largest first) covering `need`. +/// +/// The FILTER is this function's own — an offer leg names one asset, and a coin of another asset is +/// not a candidate however large it is. The ordering and the refusal are +/// [`super::selection::select_largest_first`]'s, shared with the wallet's row selector and with the +/// mirror lifecycle's operator-scoped selector. fn select_cats(cats: &[Cat], asset_id: Bytes32, need: u64) -> Result> { - let mut sorted: Vec = cats + let candidates: Vec = cats .iter() .filter(|c| c.info.asset_id == asset_id) .copied() .collect(); - sorted.sort_by(|a, b| { - b.coin - .amount - .cmp(&a.coin.amount) - .then(a.coin.coin_id().cmp(&b.coin.coin_id())) - }); - let mut sum = 0u64; - let mut out = Vec::new(); - for c in sorted { - if sum >= need { - break; - } - sum += c.coin.amount; - out.push(c); - } - if sum < need { - return Err(Error::api(format!( - "insufficient CAT to offer: need {need} have {sum}" - ))); - } - Ok(out) + super::selection::select_largest_first(candidates, need, |c| (c.coin.amount, c.coin.coin_id())) + .map_err(|s| { + Error::api(format!( + "insufficient CAT to offer: need {} have {}", + s.need, s.have + )) + }) } #[cfg(test)] diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index be737215..d069f0d9 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -4805,29 +4805,22 @@ fn normalize_singleton_id(id: &str) -> String { } /// Greedily select CAT coin rows (largest first) covering `target`. Errors if they cannot. -fn select_cat_rows(mut rows: Vec, target: u64) -> Result> { - rows.sort_by(|a, b| { - b.amount - .parse::() - .unwrap_or(0) - .cmp(&a.amount.parse::().unwrap_or(0)) - .then(a.coin_id.cmp(&b.coin_id)) - }); - let mut selected = Vec::new(); - let mut total: u64 = 0; - for r in rows { - if total >= target { - break; - } - total += r.amount.parse::().unwrap_or(0); - selected.push(r); - } - if total < target { - return Err(Error::api(format!( - "insufficient CAT balance: have {total}, need {target}" - ))); - } - Ok(selected) +/// +/// The ordering and the refusal are [`super::selection::select_largest_first`]'s, shared with the +/// offer builder and with the mirror lifecycle's operator-scoped selector so that one money +/// algorithm has one implementation. What stays here is the part that is genuinely about this coin +/// SET: the DB stores an amount as a decimal string, and an unparseable one counts as zero — which +/// can only make a selection refuse, never over-fund. +fn select_cat_rows(rows: Vec, target: u64) -> Result> { + super::selection::select_largest_first(rows, target, |r| { + (r.amount.parse::().unwrap_or(0), r.coin_id.clone()) + }) + .map_err(|s| { + Error::api(format!( + "insufficient CAT balance: have {}, need {}", + s.have, s.need + )) + }) } /// Encode a puzzle-hash hex as a bech32m address with `prefix`. diff --git a/crates/dig-wallet/src/sage/selection.rs b/crates/dig-wallet/src/sage/selection.rs new file mode 100644 index 00000000..5325f69a --- /dev/null +++ b/crates/dig-wallet/src/sage/selection.rs @@ -0,0 +1,191 @@ +//! Largest-first coin selection — ONE implementation, for every coin-shaped thing this ecosystem +//! draws spend inputs from. +//! +//! # Why this is a module and not three loops +//! +//! The same fifteen lines had been written three times: over the wallet DB's `CoinRow`s +//! ([`super::rpc`]), over already-resolved `Cat`s ([`super::offers`]), and — the change that +//! prompted the extraction — over chain-read coin records at the operator puzzle hash +//! (dig-node#421). Three copies of a *money* algorithm is the byte-drift shape CLAUDE.md forbids: +//! they were already inconsistent in a way nobody would have chosen deliberately, differing only in +//! the message they print when they refuse. +//! +//! What varies between callers is the coin SET and how to read an amount out of one item. Neither +//! is the algorithm, so both are parameters and the algorithm is written once. +//! +//! # The two properties worth stating +//! +//! **Selection is DETERMINISTIC.** Ordering is descending by amount with an ascending tiebreak on a +//! caller-supplied key, so two nodes — or the same node twice — presented with the same coin set +//! choose the same coins. Without the tiebreak, equal-amount coins order by whatever the source +//! happened to return, and a retry after a restart would select a different funding set for the +//! same spend. +//! +//! **A shortfall REFUSES; it never returns a short set.** The caller is funding a spend, and a +//! partial funding set is not a smaller success — it either fails to build or, worse, builds +//! something that locks the wrong amount. So the outcome is a typed [`Shortfall`] carrying both +//! figures, and each caller phrases it in its own units. + +/// A selection that could not reach its target: what was available, and what was needed. +/// +/// Both figures are carried rather than a formatted string, because the callers speak different +/// units — XCH mojos, $DIG CAT base units — and a message assembled here would name the wrong one +/// for two of the three. Selection knows the arithmetic; it does not know the asset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Shortfall { + /// The total of every candidate that was offered, in the caller's base units. + pub have: u64, + /// The total that was required, in the same units. + pub need: u64, +} + +/// Select the fewest largest items whose amounts cover `target`, or refuse with a [`Shortfall`]. +/// +/// `key` reads one item's `(amount, tiebreak)`. The amount orders descending — largest first, which +/// keeps the number of inputs and therefore the spend size down — and the tiebreak orders ascending +/// so that equal amounts have one stable order rather than the source's incidental one. +/// +/// A `target` of zero yields an empty selection, which is correct arithmetic: nothing is needed, so +/// nothing is chosen. A caller for whom an empty funding set is *not* a valid spend must refuse zero +/// in its own right, where the reason can be stated — this function has no way to know that an empty +/// `Vec` will later be handed to a builder that requires inputs. +/// +/// Totals accumulate with [`u64::saturating_add`]. The alternative overflows on a coin set summing +/// past `u64::MAX`, which panics in a debug build and wraps to a *small* total in a release one — +/// and a wrapped total reads as a shortfall, so saturating is the direction that cannot manufacture +/// a false success. +pub fn select_largest_first( + items: Vec, + target: u64, + key: impl Fn(&T) -> (u64, K), +) -> Result, Shortfall> { + // Decorate-sort-undecorate: `key` is called once per item rather than twice per comparison, + // which matters because a caller's key may parse a string or hash a coin id. + let mut decorated: Vec<(u64, K, T)> = items + .into_iter() + .map(|item| { + let (amount, tiebreak) = key(&item); + (amount, tiebreak, item) + }) + .collect(); + decorated.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + + let available = decorated + .iter() + .fold(0u64, |sum, item| sum.saturating_add(item.0)); + + let mut selected = Vec::new(); + let mut total: u64 = 0; + for (amount, _, item) in decorated { + if total >= target { + break; + } + total = total.saturating_add(amount); + selected.push(item); + } + + if total < target { + // `have` is the total of EVERY candidate, not of the ones walked. They are equal here — + // a shortfall means the walk consumed the whole set — and stating the available total is + // the figure an operator can act on. + return Err(Shortfall { + have: available, + need: target, + }); + } + Ok(selected) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The fixture is deliberately UNSORTED and contains a duplicate amount, because the two + /// properties under test are the ordering itself and the tiebreak — a pre-sorted fixture with + /// distinct amounts is satisfied by an implementation that does not sort at all. + fn coins() -> Vec<(u64, &'static str)> { + vec![(30, "c"), (70, "a"), (30, "b"), (100, "d")] + } + + /// Largest first, so a target reachable from one big coin does not consume three small ones. + #[test] + fn the_largest_coin_is_taken_first() { + let selected = select_largest_first(coins(), 90, |c| (c.0, c.1)).expect("covered"); + assert_eq!( + selected, + vec![(100, "d")], + "100 alone covers 90; taking 70+30 instead would build a two-input spend for no reason" + ); + } + + /// Equal amounts order by the tiebreak, ASCENDING, and the choice is repeatable. + /// + /// Asserted on a target that needs exactly ONE of the two thirty-unit coins, so the two + /// candidates are genuinely interchangeable on amount and only the tiebreak can decide. A test + /// that took both would pass against an implementation with no tiebreak at all. + #[test] + fn equal_amounts_are_broken_by_the_key_not_by_input_order() { + let selected = select_largest_first(coins(), 190, |c| (c.0, c.1)).expect("covered"); + assert_eq!( + selected, + vec![(100, "d"), (70, "a"), (30, "b")], + "the 30-unit coin chosen is 'b', the lower key — not 'c', which came first in the input" + ); + + let mut reversed = coins(); + reversed.reverse(); + let again = select_largest_first(reversed, 190, |c| (c.0, c.1)).expect("covered"); + assert_eq!(again, selected, "input order must not change the selection"); + } + + /// A shortfall REFUSES, and says what was available rather than handing back what it found. + #[test] + fn a_shortfall_refuses_and_reports_both_figures() { + let err = select_largest_first(coins(), 1_000, |c| (c.0, c.1)).expect_err("not covered"); + assert_eq!( + err, + Shortfall { + have: 230, + need: 1_000 + }, + "230 is the whole set; a short Vec would be a spend funded at the wrong amount" + ); + } + + /// Exactly at the target is COVERED — the boundary, from both sides. + /// + /// Pinned in both directions because a bound tested only from below can only confirm itself: an + /// implementation using `>` instead of `>=` refuses the exact-cover case, and one using `<=` + /// instead of `<` accepts a set one unit short. + #[test] + fn the_boundary_holds_from_both_sides() { + assert!( + select_largest_first(coins(), 230, |c| (c.0, c.1)).is_ok(), + "the set totals exactly 230, which covers a target of 230" + ); + assert!( + select_largest_first(coins(), 231, |c| (c.0, c.1)).is_err(), + "one unit over the whole set is a shortfall" + ); + } + + /// An empty candidate set is a shortfall for any non-zero target, and reports `have: 0`. + #[test] + fn an_empty_set_is_a_shortfall_rather_than_an_empty_success() { + let err = + select_largest_first(Vec::<(u64, &str)>::new(), 1, |c| (c.0, c.1)).expect_err("empty"); + assert_eq!(err, Shortfall { have: 0, need: 1 }); + } + + /// A total that would overflow saturates rather than wrapping to a small, false shortfall. + #[test] + fn an_overflowing_total_saturates_and_still_covers() { + let huge = vec![(u64::MAX, "a"), (u64::MAX, "b")]; + let selected = select_largest_first(huge, u64::MAX, |c| (c.0, c.1)).expect("covered"); + assert_eq!( + selected.len(), + 1, + "the first coin alone covers the target, so the sum never has to be taken" + ); + } +} From 7f887affe383516e19a20a28a2d51f2d46f205ac Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 07:56:27 -0700 Subject: [PATCH 03/13] feat(mirror): fund creates from the operator-scoped selector (#421) Co-Authored-By: Claude --- .../dig-node-service/src/mirror/lifecycle.rs | 156 ++++++++++++++---- crates/dig-node-service/src/server.rs | 15 ++ 2 files changed, 143 insertions(+), 28 deletions(-) diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 15a4db52..fd9f071e 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -45,13 +45,20 @@ //! not have: while the broadcaster is absent the capability is //! [`SpendCapability::BroadcasterUnwired`] and never `Available`. //! -//! **Creates do not, yet, and they refuse rather than guess.** `dig_mirror_coin::create` takes the -//! `Cat` inputs from its caller, and selecting them requires a $DIG coin selector scoped to the -//! OPERATOR puzzle hash. The node-custodied [`WalletBackend`](dig_wallet::sage::rpc::WalletBackend) -//! selector is scoped to its own replica instead, so using it would fund a mirror coin from the -//! wrong wallet's coins. [`NodeMirrorEffects::create`] therefore returns a named -//! [`PassError::Wallet`], the pass reports it in `stopped_at`, and §25.8 keeps reporting the bond -//! as uncovered — which is true. The selector is dig-node#421. +//! **Creates select their own collateral, from the OPERATOR wallet.** `dig_mirror_coin::create` +//! takes its `Cat` inputs from its caller, and [`super::funding`] supplies them: it scans the chain +//! at the CAT wrapping of THIS node's operator puzzle hash, reconstructs each candidate's lineage +//! from its creating spend, and refuses the whole create on a shortfall rather than funding a +//! smaller coin (dig-node#421). The node-custodied +//! [`WalletBackend`](dig_wallet::sage::rpc::WalletBackend) selector is scoped to its own replica and +//! is deliberately not used here: it would fund a mirror coin from the wrong wallet's coins, which +//! is a real spend that returns `Ok`. +//! +//! **What a create still needs before one can be attempted.** A mirror advertises WHERE its store +//! can be fetched from, and `dig_mirror_coin::create` refuses an advertisement with no URL. This +//! node has no configured public name yet, so [`NodeMirrorEffects`] is handed an empty URL set and +//! `create` refuses by name, ahead of any chain read. That is the one remaining gap, and it is an +//! advertisement question rather than a funding one. //! //! # Nothing here relaxes the audit shape //! @@ -83,6 +90,7 @@ use crate::spend_audit::{ FailureStage, FundingCoinId, SpendJournal, SpendLog, Submission, TargetCoinId, }; +use super::funding::{self, FundingError}; use super::observe::held_mirrors; use super::plan::{Bond, HeldMirror, ReclaimReason}; use super::runner::{MirrorEffects, ObservedCapsule, PassError, PassReport}; @@ -117,6 +125,14 @@ pub struct NodeMirrorEffects<'a, S: ChainSource> { capsules: Vec, /// Spendable $DIG at the operator address, already read. `Err` defers creates, never reclaims. dig_balance: Result, + /// The coins already committed to a bundle in flight, read ONCE per pass. + /// + /// `Err` defers creates and NEVER reclaims, exactly like `dig_balance`: a reclaim needs no coin + /// selection at all (§25.4.4), so gating it on a funding read would reintroduce the legacy + /// defect where a node that could not fund could not recover either. + committed_coin_ids: Result, PassError>, + /// Where this node advertises its stores can be fetched from. Empty means it cannot advertise. + advertised_urls: Vec, /// The chain, for the owned-coin scan and for the reclaim spends. source: &'a S, /// This node's operator puzzle hash — a public value, derived once at bring-up. @@ -145,6 +161,8 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { pub fn new( capsules: Vec, dig_balance: Result, + committed_coin_ids: Result, PassError>, + advertised_urls: Vec, source: &'a S, owner_puzzle_hash: Bytes32, signer: Option<&'a MirrorSigner>, @@ -155,6 +173,8 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { Self { capsules, dig_balance, + committed_coin_ids, + advertised_urls, source, owner_puzzle_hash, signer, @@ -313,22 +333,103 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { self.sign_and_broadcast(&spends, Some(reclaimed_coin_id(&coin))) } - fn create( - &self, - bond: &Bond, - _epoch: i64, - amount_dig_base_units: u64, - ) -> Result<(), PassError> { - // REFUSED, by name, rather than funded from the wrong wallet. See the module doc: the only - // $DIG selector this process has is scoped to the node-custodied replica, not to the - // operator puzzle hash, and a mirror coin funded from the wrong coins is a real spend that - // looks successful. dig-node#421 is the operator-scoped selector. - Err(PassError::Wallet(format!( - "creating the {} bond needs {} DIG base units selected from the OPERATOR wallet, and \ - this node has no operator-scoped $DIG coin selector yet (dig-node#421); no spend was \ - attempted", - bond.store_id, amount_dig_base_units - ))) + fn create(&self, bond: &Bond, epoch: i64, amount_dig_base_units: u64) -> Result<(), PassError> { + // The advertisement is checked FIRST, ahead of every chain read. `dig_mirror_coin::create` + // refuses an advertisement with no URL, so selecting coins before knowing there is somewhere + // to advertise spends a chain scan to reach a refusal that was decidable for free. + if self.advertised_urls.is_empty() { + return Err(PassError::Wallet(format!( + "creating the {} bond needs at least one URL this node's stores can be fetched \ + from, and none is configured; a mirror with nowhere to fetch from is not a \ + mirror, so no coin was selected and no spend was attempted", + bond.store_id + ))); + } + + let store_launcher_id = parse_id(&bond.store_id, "store id")?; + let root_hash = parse_id(&bond.root, "root hash")?; + + let committed = self.committed_coin_ids.as_ref().map_err(Clone::clone)?; + + // The amount is the planner's — `apply_safety_margin(required_per_store, margin_bp)`, + // §25.3 — carried straight through. Nothing here re-derives it and nothing here has an + // opinion about it: a create at the wrong amount locks money and advertises nothing. + let dig_coins = funding::select_operator_dig_cats( + self.source, + self.owner_puzzle_hash, + amount_dig_base_units, + committed, + ) + .map_err(funding_refusal)?; + + let signer = self + .signer + .ok_or_else(|| PassError::Wallet("no operator wallet is available to sign".into()))?; + + // `fee = 0` with no fee coins, matching the reclaim path. A create gated on selectable XCH + // would leave a node holding $DIG and no XCH unable to bond anything at all, and the next + // pass retries a create the mempool declined under fee pressure. + let spends = super::spends::build_create( + store_launcher_id, + root_hash, + num_bigint::BigInt::from(epoch), + self.advertised_urls.clone(), + amount_dig_base_units, + dig_coins, + signer.synthetic_key(), + Vec::new(), + 0, + ) + .map_err(|e| PassError::Wallet(e.to_string()))?; + + tracing::info!( + target: "mirror", + store_id = %bond.store_id, + root = %bond.root, + epoch, + amount_dig_base_units, + "creating mirror collateral" + ); + + // No intended coin id is stated. A create's output coin takes its parent from whichever + // input the builder draws it from, and this node does not derive that — so naming a + // plausible coin would let §23.5's reconcile confirm this spend against a coin it never + // created. The record resolves `Unresolved` instead, which is the honest reading, and + // §25.4.6's duplicate suppression works from the `(root, epoch)` the record already + // carries rather than from a coin id. + self.sign_and_broadcast(&spends, None) + } +} + +/// A 64-hex id from the planner, as the builder's `Bytes32`. +/// +/// The planner carries store and root as lowercase hex strings, because that is what the disk scan +/// and the control surface speak. A malformed one is a REFUSAL rather than a zeroed hash: a create +/// against `0x00…` would lock real collateral advertising a store that does not exist, and the +/// money would be just as locked as if the advertisement were good. +fn parse_id(hex_id: &str, what: &str) -> Result { + let bytes: [u8; 32] = hex::decode(hex_id) + .ok() + .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .ok_or_else(|| { + PassError::Wallet(format!( + "the {what} {hex_id:?} is not 32 bytes of hex, so no create is attempted for it" + )) + })?; + Ok(Bytes32::new(bytes)) +} + +/// A funding refusal, in the pass's own vocabulary. +/// +/// The chain variant maps to [`PassError::Chain`] and every other to [`PassError::Wallet`], because +/// the two mean different things to whoever reads `stopped_at`: a chain that could not answer is a +/// transient condition of the SOURCE, while an empty or fully-committed wallet is a durable +/// condition of this NODE. Collapsing them would tell an operator to add funds when the truth is +/// that a read timed out. +fn funding_refusal(error: FundingError) -> PassError { + match &error { + FundingError::Chain(_) => PassError::Chain(error.to_string()), + _ => PassError::Wallet(error.to_string()), } } @@ -350,11 +451,10 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { /// unwrapped hash would produce a coin id that can never appear on chain, so the reclaim would stay /// unconfirmed forever while having genuinely succeeded. fn reclaimed_coin_id(mirror: &MirrorCoin) -> TargetCoinId { - use chia_puzzle_types::cat::CatArgs; - - let inner: clvm_utils::TreeHash = mirror.owner_puzzle_hash().into(); - let puzzle_hash: Bytes32 = - CatArgs::curry_tree_hash(dig_mirror_coin::DIG_ASSET_ID, inner).into(); + // The SAME derivation the create path scans with, not a second copy of it. Two hand-rolled CAT + // curries is how one of them ends up naming a puzzle hash nobody can spend, with nothing in the + // tree comparing the two. + let puzzle_hash = funding::dig_cat_puzzle_hash(mirror.owner_puzzle_hash()); let created = chia_protocol::Coin::new(mirror.coin().coin_id(), puzzle_hash, mirror.collateral()); diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index c012caa7..e0a7de4c 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2755,6 +2755,14 @@ fn spawn_mirror_passes( // synchronous and sees one disk state and one balance throughout. let capsules = lifecycle::observe_disk(&node).await; let dig_balance = lifecycle::observe_dig_balance(&wallet, owner_puzzle_hash).await; + // ONE reading of what is already committed, for the whole pass — the analogue of the + // wallet selector's reservation prune (dig_ecosystem#2763), which the chain cannot + // offer: a broadcast coin stays unspent in the chain's view for the entire confirmation + // window, and this loop runs inside it. An `Err` defers creates and never reclaims. + let committed = crate::mirror::funding::committed_funding_coin_ids( + &crate::spend_audit::SpendLog::in_state_dir(), + ) + .map_err(|e| crate::mirror::runner::PassError::Wallet(e.to_string())); match chain.chain_source(tokio::runtime::Handle::current()).await { Ok(source) => { @@ -2775,6 +2783,13 @@ fn spawn_mirror_passes( let effects = NodeMirrorEffects::new( capsules, dig_balance, + committed, + // EMPTY, deliberately. A mirror advertises where its store can be + // fetched from, and this node has no configured public name to + // advertise — so `create` refuses by name rather than publishing an + // advertisement nobody can act on. That is an advertisement gap, not a + // funding one; the operator-scoped selector behind it is live. + Vec::new(), &source, owner_puzzle_hash, signer_ref, From e339892f909aa85c3e26005ccfa3afc40cfac7b0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:00:29 -0700 Subject: [PATCH 04/13] test(mirror): distinct fixture amounts, so two published coins are two coins Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 172 +++++++++--------- SPEC.md | 22 ++- .../tests/mirror_operator_funding.rs | 53 ++++-- 4 files changed, 142 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24e835fa..5bae5296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.173.0" +version = "0.174.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 45668d1c..96220de0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,86 +1,86 @@ -[workspace] -resolver = "2" -# The canonical dig-node repo is a small workspace of the node ENGINE + its two -# DIG-Browser host shells: -# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, -# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, -# cache, P2P. The single node implementation shared by BOTH host -# shells below. (Renamed from `dig-node` so the engine library and -# the produced `dig-node` binary no longer share a name, #216.) -# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control -# plane + CLI + service install. Depends on the engine library. -# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) -# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. -# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS -# signing), brought up by dig-runtime beside the node. -# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates -# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core -# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. -members = [ - "crates/dig-node-core", - "crates/dig-chat-protocol", - "crates/dig-node-service", - "crates/dig-runtime", - "crates/dig-wallet", -] - -[workspace.package] -edition = "2021" -# The RELEASE version of the repo's shipped artifact — the `dig-node` binary -# (`dig-node-service`, which inherits this via `version.workspace = true`). This is -# the version the nightly-release.yml stable channel + version-increment CI reads from -# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a -# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) -# keep their own independent versions — only the released binary tracks the workspace version. -version = "0.173.0" - -# Release hardening, matching digstore: keep integer-overflow checks ON in release. -# The node parses untrusted serialized input and does offset/length arithmetic over -# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. -[profile.release] -overflow-checks = true - -# -- Retired: the dig-gossip vendored-fork patches (L7 peer network) ---------------------------------- -# -# This workspace used to re-declare `[patch.crates-io]` entries redirecting `chia-protocol` and -# `chia-sdk-client` to the ADDITIVE forks dig-gossip vendored, because a git dependency's own patches -# do not apply transitively (cargo honours patches only from the ROOT manifest being built). -# -# dig-gossip 0.23.0 DELETED both forks (dig_ecosystem#2228). The DIG introducer opcodes -# (`RegisterPeer=218` / `RegisterAck=219`), `send_protocol_message` and the DIG rate-limit rows now -# live in the crates.io crate `dig-peer-protocol` 0.6, a superset of plain upstream `chia-protocol`, -# which dig-gossip depends on directly. There is nothing left at that rev to patch to, so the patch -# section — and the #1529 three-rev lockstep it forced — is retired rather than re-pointed. - -# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): the ENTIRE peer stack takes -# dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. -# dig-constants is likewise a plain crates.io dep everywhere now. -# -# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance -# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. -# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", -# which was eleven minors stale by the time anyone read it. -# -# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). -# -# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit -# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the -# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through -# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same -# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while -# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, -# which cleared it.** -# -# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, -# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to -# exactly the line count they had before the move. -# -# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has -# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of -# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. -# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht -# values cross from this crate into both of them. dig-dht 0.15 carries -# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a -# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an -# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and -# bridging two lines with a shim is the §4.1 byte-drift class. +[workspace] +resolver = "2" +# The canonical dig-node repo is a small workspace of the node ENGINE + its two +# DIG-Browser host shells: +# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, +# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, +# cache, P2P. The single node implementation shared by BOTH host +# shells below. (Renamed from `dig-node` so the engine library and +# the produced `dig-node` binary no longer share a name, #216.) +# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control +# plane + CLI + service install. Depends on the engine library. +# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) +# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. +# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS +# signing), brought up by dig-runtime beside the node. +# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates +# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core +# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. +members = [ + "crates/dig-node-core", + "crates/dig-chat-protocol", + "crates/dig-node-service", + "crates/dig-runtime", + "crates/dig-wallet", +] + +[workspace.package] +edition = "2021" +# The RELEASE version of the repo's shipped artifact — the `dig-node` binary +# (`dig-node-service`, which inherits this via `version.workspace = true`). This is +# the version the nightly-release.yml stable channel + version-increment CI reads from +# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a +# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) +# keep their own independent versions — only the released binary tracks the workspace version. +version = "0.174.0" + +# Release hardening, matching digstore: keep integer-overflow checks ON in release. +# The node parses untrusted serialized input and does offset/length arithmetic over +# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. +[profile.release] +overflow-checks = true + +# -- Retired: the dig-gossip vendored-fork patches (L7 peer network) ---------------------------------- +# +# This workspace used to re-declare `[patch.crates-io]` entries redirecting `chia-protocol` and +# `chia-sdk-client` to the ADDITIVE forks dig-gossip vendored, because a git dependency's own patches +# do not apply transitively (cargo honours patches only from the ROOT manifest being built). +# +# dig-gossip 0.23.0 DELETED both forks (dig_ecosystem#2228). The DIG introducer opcodes +# (`RegisterPeer=218` / `RegisterAck=219`), `send_protocol_message` and the DIG rate-limit rows now +# live in the crates.io crate `dig-peer-protocol` 0.6, a superset of plain upstream `chia-protocol`, +# which dig-gossip depends on directly. There is nothing left at that rev to patch to, so the patch +# section — and the #1529 three-rev lockstep it forced — is retired rather than re-pointed. + +# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): the ENTIRE peer stack takes +# dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. +# dig-constants is likewise a plain crates.io dep everywhere now. +# +# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance +# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. +# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", +# which was eleven minors stale by the time anyone read it. +# +# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). +# +# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit +# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the +# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through +# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same +# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while +# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, +# which cleared it.** +# +# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, +# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to +# exactly the line count they had before the move. +# +# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has +# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of +# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. +# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht +# values cross from this crate into both of them. dig-dht 0.15 carries +# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a +# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an +# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and +# bridging two lines with a shim is the §4.1 byte-drift class. diff --git a/SPEC.md b/SPEC.md index 40bd861f..1932d457 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7953,12 +7953,18 @@ itself (SYSTEM.md §4.1). > **Two things in §25 remain PENDING**, tracked as > : > -> * **CREATES are refused, by name.** `dig_mirror_coin::create` takes its `Vec` from the -> caller, and this node has no $DIG coin selector scoped to the OPERATOR puzzle hash — the -> node-custodied selector reads a different wallet's coins. `NodeMirrorEffects::create` therefore -> returns a named error, the pass reports it, and §25.8 keeps reporting the bond as uncovered, -> which is true. Tracked as . **RECLAIMS are -> implemented** and are supported at `fee = 0` with no fee coins, which is §25.4.4. +> * **CREATES select their collateral from the OPERATOR wallet, and are refused for want of an +> ADVERTISED URL.** `mirror::funding::select_operator_dig_cats` scans the chain at the CAT wrapping +> of this node's operator puzzle hash under `dig_mirror_coin::DIG_ASSET_ID`, withholds coins +> committed to a bundle whose audit record is not terminal, selects largest-first, and +> reconstructs each selected candidate's lineage from its creating spend — refusing the WHOLE +> selection on a shortfall, an unauthenticatable candidate, an unreadable chain, or an unreadable +> audit record, never funding a smaller coin (dig-node#421). What is still missing is the +> advertisement: `dig_mirror_coin::create` requires at least one URL its store can be fetched +> from, this node has no configured public name, and `NodeMirrorEffects::create` therefore refuses +> by name BEFORE any chain read. **RECLAIMS are implemented** and are supported at `fee = 0` with +> no fee coins, which is §25.4.4 — and are never gated on any funding read, including the +> committed-coin read. > * **§25.6's DHT pointer is not attached.** `ProviderRecord::unverified_mirror_coin_id` lives in > dig-dht 0.15, and `dig-download` 0.21.0 and `dig-peer-selector` 0.10.0 both require > `dig-dht ^0.13` — semver-incompatible on a `0.x` line, so taking 0.15 here would resolve two @@ -8271,8 +8277,8 @@ one setting to turn off** (§6.0/#207). > oracle read — a real amplification surface, since a paired token is a much weaker predicate than > "trusted". A node whose first pass has not yet completed answers > `unknown { reason: "chain_unreadable" }`, which remains the honest answer and is never an empty -> page. A bond whose create is refused for want of an operator-scoped $DIG selector -> (dig-node#421) reports as uncovered, which is what it is. +> page. A bond whose create is refused — for want of an advertised URL, for want of uncommitted +> operator $DIG, or because the chain could not be read — reports as uncovered, which is what it is. The lifecycle exposes, per `(store, root)`, over the control plane and with a `dign` verb (§8.6 CLI parity): the bond state — `bonded { coin_id, epoch, amount }`, `pending` (in-flight create), diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs index 821a502c..f94ba304 100644 --- a/crates/dig-node-service/tests/mirror_operator_funding.rs +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -52,8 +52,17 @@ impl Chain { fn fund(&mut self, owner: &Wallet, amounts: &[u64], salt: u8) -> Vec { let (spend, coins) = ordinary_dig_coins(owner, amounts, salt); self.spends.insert(spend.coin.coin_id(), spend); - let mut ids = Vec::new(); + let mut ids: Vec = Vec::new(); for coin in coins { + // A coin id is `(parent, puzzle_hash, amount)`. Two children of ONE spend paying the + // SAME amount to the SAME address are therefore literally the same coin, and a fixture + // that thinks it published two has published one. That collapse silently defeats every + // per-coin assertion below -- committing "one of the two" commits both -- so it is a + // fixture failure rather than something a test is left to notice. + assert!( + !ids.contains(&coin.coin_id()), + "two fixture coins collapsed to one id; vary the AMOUNTS, not just the count" + ); ids.push(coin.coin_id()); self.by_puzzle_hash .entry(coin.puzzle_hash) @@ -252,7 +261,10 @@ fn an_operator_with_no_coins_refuses_even_when_the_replica_is_rich() { fn a_committed_coin_is_withheld_and_the_uncommitted_one_is_taken() { let operator = operator(); let mut chain = Chain::default(); - let ids = chain.fund(&operator, &[REQUIRED, REQUIRED], 0x01); + // Distinct amounts, so the two coins are two coins. The COMMITTED one is the larger, so + // largest-first reaches it first and a selector that ignored the commitment would visibly take + // it -- a fixture committing the smaller would be satisfied by one that simply never looked. + let ids = chain.fund(&operator, &[REQUIRED * 2, REQUIRED], 0x01); let (committed_id, free_id) = (ids[0], ids[1]); let committed: HashSet = [hex::encode(committed_id)].into_iter().collect(); @@ -277,18 +289,21 @@ fn a_committed_coin_is_withheld_and_the_uncommitted_one_is_taken() { fn a_reservation_that_makes_the_balance_short_refuses_rather_than_double_spending() { let operator = operator(); let mut chain = Chain::default(); - let ids = chain.fund(&operator, &[REQUIRED / 2, REQUIRED / 2], 0x01); + // The raw balance COVERS the requirement (0.75 + 0.5 = 1.25x) and the uncommitted part does + // not. That is the discriminating shape: an unfiltered selector succeeds here and a correct one + // refuses, whereas a fixture whose raw balance were already short would refuse either way. + let ids = chain.fund(&operator, &[REQUIRED * 3 / 4, REQUIRED / 2], 0x01); let committed: HashSet = [hex::encode(ids[0])].into_iter().collect(); let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &committed) - .expect_err("half the balance is already committed"); + .expect_err("three quarters of the balance is already committed"); assert_eq!( err, FundingError::Insufficient { have_dig_base_units: REQUIRED / 2, need_dig_base_units: REQUIRED, }, - "the committed half is not available, so the wallet is short and no spend is attempted" + "only the uncommitted half is available, so the wallet is short and no spend is attempted" ); } @@ -337,16 +352,30 @@ fn exactly_the_requirement_is_funded() { fn the_number_of_coins_drawn_follows_the_requirement_it_was_given() { let operator = operator(); let mut chain = Chain::default(); - chain.fund(&operator, &[REQUIRED, REQUIRED, REQUIRED], 0x01); + // Distinct amounts for the reason `Chain::fund` asserts, and each below the requirement so the + // count genuinely has to grow: 0.6 + 0.5 + 0.4 = 1.5x, and no two of them cover 1x either. + chain.fund( + &operator, + &[REQUIRED * 3 / 5, REQUIRED / 2, REQUIRED * 2 / 5], + 0x01, + ); - let small = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) - .expect("covered"); - let large = - select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED * 3, &HashSet::new()) + let small = + select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED / 2, &HashSet::new()) .expect("covered"); + let large = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect("covered"); - assert_eq!(small.len(), 1, "one coin covers one requirement"); - assert_eq!(large.len(), 3, "three are needed to cover three"); + assert_eq!( + small.len(), + 1, + "the largest coin alone covers half the requirement" + ); + assert_eq!( + large.len(), + 2, + "no single coin covers the whole requirement, so a second is drawn" + ); } /// **A candidate that cannot be authenticated refuses the WHOLE selection.** From 7170eb7ebb3070e5dccb85fcd41e79c3bbb6ab7b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:09:38 -0700 Subject: [PATCH 05/13] chore(release): bump to 0.174.0 and rustfmt the funding module Repairs the previous commit's whole-file line-ending flip on Cargo.toml: sed -i rewrote the CRLF file as LF, turning a one-line version bump into an 86-line diff. Restored and re-bumped byte-wise. Co-Authored-By: Claude --- Cargo.toml | 172 +++++++++--------- crates/dig-node-service/src/mirror/funding.rs | 4 +- 2 files changed, 89 insertions(+), 87 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 96220de0..fd519d51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,86 +1,86 @@ -[workspace] -resolver = "2" -# The canonical dig-node repo is a small workspace of the node ENGINE + its two -# DIG-Browser host shells: -# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, -# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, -# cache, P2P. The single node implementation shared by BOTH host -# shells below. (Renamed from `dig-node` so the engine library and -# the produced `dig-node` binary no longer share a name, #216.) -# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control -# plane + CLI + service install. Depends on the engine library. -# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) -# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. -# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS -# signing), brought up by dig-runtime beside the node. -# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates -# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core -# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. -members = [ - "crates/dig-node-core", - "crates/dig-chat-protocol", - "crates/dig-node-service", - "crates/dig-runtime", - "crates/dig-wallet", -] - -[workspace.package] -edition = "2021" -# The RELEASE version of the repo's shipped artifact — the `dig-node` binary -# (`dig-node-service`, which inherits this via `version.workspace = true`). This is -# the version the nightly-release.yml stable channel + version-increment CI reads from -# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a -# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) -# keep their own independent versions — only the released binary tracks the workspace version. -version = "0.174.0" - -# Release hardening, matching digstore: keep integer-overflow checks ON in release. -# The node parses untrusted serialized input and does offset/length arithmetic over -# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. -[profile.release] -overflow-checks = true - -# -- Retired: the dig-gossip vendored-fork patches (L7 peer network) ---------------------------------- -# -# This workspace used to re-declare `[patch.crates-io]` entries redirecting `chia-protocol` and -# `chia-sdk-client` to the ADDITIVE forks dig-gossip vendored, because a git dependency's own patches -# do not apply transitively (cargo honours patches only from the ROOT manifest being built). -# -# dig-gossip 0.23.0 DELETED both forks (dig_ecosystem#2228). The DIG introducer opcodes -# (`RegisterPeer=218` / `RegisterAck=219`), `send_protocol_message` and the DIG rate-limit rows now -# live in the crates.io crate `dig-peer-protocol` 0.6, a superset of plain upstream `chia-protocol`, -# which dig-gossip depends on directly. There is nothing left at that rev to patch to, so the patch -# section — and the #1529 three-rev lockstep it forced — is retired rather than re-pointed. - -# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): the ENTIRE peer stack takes -# dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. -# dig-constants is likewise a plain crates.io dep everywhere now. -# -# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance -# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. -# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", -# which was eleven minors stale by the time anyone read it. -# -# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). -# -# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit -# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the -# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through -# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same -# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while -# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, -# which cleared it.** -# -# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, -# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to -# exactly the line count they had before the move. -# -# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has -# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of -# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. -# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht -# values cross from this crate into both of them. dig-dht 0.15 carries -# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a -# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an -# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and -# bridging two lines with a shim is the §4.1 byte-drift class. +[workspace] +resolver = "2" +# The canonical dig-node repo is a small workspace of the node ENGINE + its two +# DIG-Browser host shells: +# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, +# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, +# cache, P2P. The single node implementation shared by BOTH host +# shells below. (Renamed from `dig-node` so the engine library and +# the produced `dig-node` binary no longer share a name, #216.) +# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control +# plane + CLI + service install. Depends on the engine library. +# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) +# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. +# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS +# signing), brought up by dig-runtime beside the node. +# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates +# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core +# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. +members = [ + "crates/dig-node-core", + "crates/dig-chat-protocol", + "crates/dig-node-service", + "crates/dig-runtime", + "crates/dig-wallet", +] + +[workspace.package] +edition = "2021" +# The RELEASE version of the repo's shipped artifact — the `dig-node` binary +# (`dig-node-service`, which inherits this via `version.workspace = true`). This is +# the version the nightly-release.yml stable channel + version-increment CI reads from +# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a +# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) +# keep their own independent versions — only the released binary tracks the workspace version. +version = "0.174.0" + +# Release hardening, matching digstore: keep integer-overflow checks ON in release. +# The node parses untrusted serialized input and does offset/length arithmetic over +# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. +[profile.release] +overflow-checks = true + +# -- Retired: the dig-gossip vendored-fork patches (L7 peer network) ---------------------------------- +# +# This workspace used to re-declare `[patch.crates-io]` entries redirecting `chia-protocol` and +# `chia-sdk-client` to the ADDITIVE forks dig-gossip vendored, because a git dependency's own patches +# do not apply transitively (cargo honours patches only from the ROOT manifest being built). +# +# dig-gossip 0.23.0 DELETED both forks (dig_ecosystem#2228). The DIG introducer opcodes +# (`RegisterPeer=218` / `RegisterAck=219`), `send_protocol_message` and the DIG rate-limit rows now +# live in the crates.io crate `dig-peer-protocol` 0.6, a superset of plain upstream `chia-protocol`, +# which dig-gossip depends on directly. There is nothing left at that rev to patch to, so the patch +# section — and the #1529 three-rev lockstep it forced — is retired rather than re-pointed. + +# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): the ENTIRE peer stack takes +# dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. +# dig-constants is likewise a plain crates.io dep everywhere now. +# +# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance +# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. +# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", +# which was eleven minors stale by the time anyone read it. +# +# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). +# +# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit +# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the +# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through +# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same +# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while +# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, +# which cleared it.** +# +# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, +# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to +# exactly the line count they had before the move. +# +# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has +# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of +# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. +# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht +# values cross from this crate into both of them. dig-dht 0.15 carries +# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a +# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an +# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and +# bridging two lines with a shim is the §4.1 byte-drift class. diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index a03df731..cd47e514 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -263,7 +263,9 @@ fn authenticate( return Err(refuse("the resolved CAT is not $DIG")); } if cat.info.p2_puzzle_hash != owner_puzzle_hash { - return Err(refuse("the resolved CAT is owned by a different puzzle hash")); + return Err(refuse( + "the resolved CAT is owned by a different puzzle hash", + )); } Ok(cat) } From 82018be89f579cb1dad4d90d0c0d8058c3968b96 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:10:28 -0700 Subject: [PATCH 06/13] docs(mirror): name dig-node#426 as the remaining advertised-URL gap Co-Authored-By: Claude --- SPEC.md | 2 +- crates/dig-node-service/src/mirror/lifecycle.rs | 4 ++-- crates/dig-node-service/src/server.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index 1932d457..9b689b59 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7962,7 +7962,7 @@ itself (SYSTEM.md §4.1). > audit record, never funding a smaller coin (dig-node#421). What is still missing is the > advertisement: `dig_mirror_coin::create` requires at least one URL its store can be fetched > from, this node has no configured public name, and `NodeMirrorEffects::create` therefore refuses -> by name BEFORE any chain read. **RECLAIMS are implemented** and are supported at `fee = 0` with +> by name BEFORE any chain read (dig-node#426). **RECLAIMS are implemented** and are supported at `fee = 0` with > no fee coins, which is §25.4.4 — and are never gated on any funding read, including the > committed-coin read. > * **§25.6's DHT pointer is not attached.** `ProviderRecord::unverified_mirror_coin_id` lives in diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index fd9f071e..c5bd1ee8 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -57,8 +57,8 @@ //! **What a create still needs before one can be attempted.** A mirror advertises WHERE its store //! can be fetched from, and `dig_mirror_coin::create` refuses an advertisement with no URL. This //! node has no configured public name yet, so [`NodeMirrorEffects`] is handed an empty URL set and -//! `create` refuses by name, ahead of any chain read. That is the one remaining gap, and it is an -//! advertisement question rather than a funding one. +//! `create` refuses by name, ahead of any chain read. That is the one remaining gap, it is an +//! advertisement question rather than a funding one, and it is dig-node#426. //! //! # Nothing here relaxes the audit shape //! diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index e0a7de4c..5f974250 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2788,7 +2788,7 @@ fn spawn_mirror_passes( // fetched from, and this node has no configured public name to // advertise — so `create` refuses by name rather than publishing an // advertisement nobody can act on. That is an advertisement gap, not a - // funding one; the operator-scoped selector behind it is live. + // funding one (dig-node#426); the selector behind it is live. Vec::new(), &source, owner_puzzle_hash, From 157c7927c8436b4f55c38f7e8a3f042f72c62aca Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:33:46 -0700 Subject: [PATCH 07/13] fix(mirror): a create records the coins it committed (#421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SpendJournal::submitted` was the sole writer of `funding_coin_ids`, and `sign_and_broadcast` called it only when the created coin was derivable. A mirror create is exactly the case where it is not — it passes `intended: None` — so the create path dropped the record entirely and every create contributed an EMPTY id list to `committed_funding_coin_ids`. The `!is_terminal()` filter was never wrong; it was never fed. The reservation therefore held nothing for creates: two creates in one confirmation window re-selected the same coins and broadcast conflicting bundles, and `control.mirror.*` and `dign spend-audit` showed every create as consuming no coins. The two facts a submission carries are independent and are now recorded independently. `Submission::intended_coin_id` becomes `Option` and `sign_and_broadcast` records the submission UNCONDITIONALLY. The coins CONSUMED are read from the signed bundle and are always known; the coin CREATED is `None` for a create, which stays `None` — naming a plausible coin would let the reconcile confirm a spend against a coin it never created, the defect `TargetCoinId` exists to make inexpressible. The discarding branch is now unrepresentable rather than merely unused. `SpendRecord::intended_coin_id` was already `Option` and every reader — `reconcile`'s four arms and `chain_reference` — already handled `None`, so no reader changes. A successful broadcast now resolves `Submitted` before the drop guard rather than `Unresolved` with an empty list; both are non-terminal, so the coins are withheld either way, and the entry no longer understates what the node knows. SPEC.md §25 states the mechanism and its consequence rather than asserting the reservation property abstractly, so the clause is true of the code in this diff. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 172 +++++++++--------- SPEC.md | 11 +- crates/dig-node-service/src/mirror/funding.rs | 102 ++++++++++- .../dig-node-service/src/mirror/lifecycle.rs | 45 ++--- crates/dig-node-service/src/spend_audit.rs | 32 +++- .../dig-node-service/src/spend_audit_cli.rs | 4 +- .../dig-node-service/tests/spend_audit_e2e.rs | 2 +- 8 files changed, 246 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5bae5296..677d130c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.174.0" +version = "0.175.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index fd519d51..720176f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,86 +1,86 @@ -[workspace] -resolver = "2" -# The canonical dig-node repo is a small workspace of the node ENGINE + its two -# DIG-Browser host shells: -# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, -# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, -# cache, P2P. The single node implementation shared by BOTH host -# shells below. (Renamed from `dig-node` so the engine library and -# the produced `dig-node` binary no longer share a name, #216.) -# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control -# plane + CLI + service install. Depends on the engine library. -# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) -# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. -# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS -# signing), brought up by dig-runtime beside the node. -# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates -# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core -# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. -members = [ - "crates/dig-node-core", - "crates/dig-chat-protocol", - "crates/dig-node-service", - "crates/dig-runtime", - "crates/dig-wallet", -] - -[workspace.package] -edition = "2021" -# The RELEASE version of the repo's shipped artifact — the `dig-node` binary -# (`dig-node-service`, which inherits this via `version.workspace = true`). This is -# the version the nightly-release.yml stable channel + version-increment CI reads from -# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a -# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) -# keep their own independent versions — only the released binary tracks the workspace version. -version = "0.174.0" - -# Release hardening, matching digstore: keep integer-overflow checks ON in release. -# The node parses untrusted serialized input and does offset/length arithmetic over -# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. -[profile.release] -overflow-checks = true - -# -- Retired: the dig-gossip vendored-fork patches (L7 peer network) ---------------------------------- -# -# This workspace used to re-declare `[patch.crates-io]` entries redirecting `chia-protocol` and -# `chia-sdk-client` to the ADDITIVE forks dig-gossip vendored, because a git dependency's own patches -# do not apply transitively (cargo honours patches only from the ROOT manifest being built). -# -# dig-gossip 0.23.0 DELETED both forks (dig_ecosystem#2228). The DIG introducer opcodes -# (`RegisterPeer=218` / `RegisterAck=219`), `send_protocol_message` and the DIG rate-limit rows now -# live in the crates.io crate `dig-peer-protocol` 0.6, a superset of plain upstream `chia-protocol`, -# which dig-gossip depends on directly. There is nothing left at that rev to patch to, so the patch -# section — and the #1529 three-rev lockstep it forced — is retired rather than re-pointed. - -# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): the ENTIRE peer stack takes -# dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. -# dig-constants is likewise a plain crates.io dep everywhere now. -# -# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance -# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. -# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", -# which was eleven minors stale by the time anyone read it. -# -# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). -# -# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit -# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the -# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through -# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same -# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while -# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, -# which cleared it.** -# -# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, -# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to -# exactly the line count they had before the move. -# -# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has -# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of -# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. -# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht -# values cross from this crate into both of them. dig-dht 0.15 carries -# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a -# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an -# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and -# bridging two lines with a shim is the §4.1 byte-drift class. +[workspace] +resolver = "2" +# The canonical dig-node repo is a small workspace of the node ENGINE + its two +# DIG-Browser host shells: +# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, +# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, +# cache, P2P. The single node implementation shared by BOTH host +# shells below. (Renamed from `dig-node` so the engine library and +# the produced `dig-node` binary no longer share a name, #216.) +# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control +# plane + CLI + service install. Depends on the engine library. +# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) +# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. +# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS +# signing), brought up by dig-runtime beside the node. +# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates +# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core +# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. +members = [ + "crates/dig-node-core", + "crates/dig-chat-protocol", + "crates/dig-node-service", + "crates/dig-runtime", + "crates/dig-wallet", +] + +[workspace.package] +edition = "2021" +# The RELEASE version of the repo's shipped artifact — the `dig-node` binary +# (`dig-node-service`, which inherits this via `version.workspace = true`). This is +# the version the nightly-release.yml stable channel + version-increment CI reads from +# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a +# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) +# keep their own independent versions — only the released binary tracks the workspace version. +version = "0.175.0" + +# Release hardening, matching digstore: keep integer-overflow checks ON in release. +# The node parses untrusted serialized input and does offset/length arithmetic over +# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. +[profile.release] +overflow-checks = true + +# -- Retired: the dig-gossip vendored-fork patches (L7 peer network) ---------------------------------- +# +# This workspace used to re-declare `[patch.crates-io]` entries redirecting `chia-protocol` and +# `chia-sdk-client` to the ADDITIVE forks dig-gossip vendored, because a git dependency's own patches +# do not apply transitively (cargo honours patches only from the ROOT manifest being built). +# +# dig-gossip 0.23.0 DELETED both forks (dig_ecosystem#2228). The DIG introducer opcodes +# (`RegisterPeer=218` / `RegisterAck=219`), `send_protocol_message` and the DIG rate-limit rows now +# live in the crates.io crate `dig-peer-protocol` 0.6, a superset of plain upstream `chia-protocol`, +# which dig-gossip depends on directly. There is nothing left at that rev to patch to, so the patch +# section — and the #1529 three-rev lockstep it forced — is retired rather than re-pointed. + +# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): the ENTIRE peer stack takes +# dig-nat from crates.io, so cargo resolves ONE dig-nat instance without any git redirect. +# dig-constants is likewise a plain crates.io dep everywhere now. +# +# Do NOT read a version out of this comment. `tests/dependency_tree.rs` asserts the single-instance +# invariant against the resolved LOCK, which is the only claim about a version that cannot go stale. +# An earlier revision of this block asserted "dig-nat 0.7 ... resolves ONE dig-nat 0.7 instance", +# which was eleven minors stale by the time anyone read it. +# +# THE PEER STACK IS ON THE ^0.21 TIER (dig-node#412 step 7, 2026-08-30). +# +# Two walls have now been cleared here in sequence, and both cleared UPSTREAM rather than by an edit +# in this file. The first was chia-bls: the stack required `dig-nat ^0.20` -> `dig-tls ^0.4` (the +# chia-bls 0.36.1 uplift) while dig-gossip was pinned at a rev reaching chia-protocol 0.26 through +# `dig-peer-protocol 0.6.0`. dig-gossip v0.30.0 cleared it. The second was dig-nat itself: that same +# dig-gossip release declared `dig-nat ^0.20`, so the ^0.21 tier resolved TWO dig-nat lines while +# cargo printed success. **dig-gossip v0.32.0 (`main`, rev 1a339166) declares `dig-nat = "0.21"`, +# which cleared it.** +# +# The resolved stack is dig-nat 0.21, dig-dht 0.13, dig-download 0.21, dig-peer 0.13, +# dig-peer-selector 0.10 -- and dig-nat, dig-dht, dig-tls, chia-bls and chia-protocol each resolve to +# exactly the line count they had before the move. +# +# THE ONE THING A FUTURE LANE WILL GET WRONG: **dig-dht 0.15 is NOT takeable, and the blocker has +# moved down a level.** dig-download 0.21.0 and dig-peer-selector 0.10.0 -- the LATEST published of +# each -- both require `dig-dht ^0.13`, measured from the crates.io index and not from a caret. +# Declaring `dig-dht = "0.15"` resolves TWO dig-dht lines while cargo prints success, and dig-dht +# values cross from this crate into both of them. dig-dht 0.15 carries +# `ProviderRecord::unverified_mirror_coin_id` (dig-dht#23), so that field is unreachable here until a +# dig-download AND a dig-peer-selector release against `dig-dht ^0.15` exist -- upstream, never an +# edit in this file. One line per family beats the highest version numbers (CLAUDE.md §2.4b), and +# bridging two lines with a shim is the §4.1 byte-drift class. diff --git a/SPEC.md b/SPEC.md index 9b689b59..dcf86a09 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7959,7 +7959,16 @@ itself (SYSTEM.md §4.1). > committed to a bundle whose audit record is not terminal, selects largest-first, and > reconstructs each selected candidate's lineage from its creating spend — refusing the WHOLE > selection on a shortfall, an unauthenticatable candidate, an unreadable chain, or an unreadable -> audit record, never funding a smaller coin (dig-node#421). What is still missing is the +> audit record, never funding a smaller coin (dig-node#421). The reservation is FED by every +> successful broadcast and not only by one whose created coin is derivable: on a `broadcast` that +> reaches the mempool, `mirror::lifecycle::NodeMirrorEffects::sign_and_broadcast` records a +> `spend_audit::Submission` UNCONDITIONALLY, carrying the coins the signed bundle consumes. The +> coin CREATED is a separate, optional field of that submission — a create names none, because its +> output coin takes its parent from whichever input the builder drew it from and this node does not +> derive it — so an underivable target no longer suppresses the record of the coins consumed. +> Consequently two creates in one confirmation window MUST NOT select the same coin, and +> `control.mirror.*` and `dign spend-audit` MUST show a create's consumed coins rather than an +> empty list. What is still missing is the > advertisement: `dig_mirror_coin::create` requires at least one URL its store can be fetched > from, this node has no configured public name, and `NodeMirrorEffects::create` therefore refuses > by name BEFORE any chain read (dig-node#426). **RECLAIMS are implemented** and are supported at `fee = 0` with diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index cd47e514..2b4c9acf 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -372,7 +372,7 @@ mod tests { journal.submitted( &in_flight, Submission { - intended_coin_id: TargetCoinId("aa".repeat(32)), + intended_coin_id: Some(TargetCoinId("aa".repeat(32))), funding_coin_ids: vec![FundingCoinId("11".repeat(32))], }, ); @@ -381,7 +381,7 @@ mod tests { journal.submitted( &settled, Submission { - intended_coin_id: TargetCoinId("bb".repeat(32)), + intended_coin_id: Some(TargetCoinId("bb".repeat(32))), funding_coin_ids: vec![FundingCoinId("22".repeat(32))], }, ); @@ -391,7 +391,7 @@ mod tests { journal.submitted( &refused_before_signing, Submission { - intended_coin_id: TargetCoinId("cc".repeat(32)), + intended_coin_id: Some(TargetCoinId("cc".repeat(32))), funding_coin_ids: vec![FundingCoinId("33".repeat(32))], }, ); @@ -413,6 +413,100 @@ mod tests { assert_eq!(committed.len(), 1); } + /// A spend whose CREATED coin is underivable still withholds the coins it CONSUMED. + /// + /// This is the create path's shape: `sign_and_broadcast` passes `intended: None` because a + /// mirror create's output coin takes its parent from whichever input the builder drew it from, + /// which this node does not derive. The coins consumed are a different fact, read from the + /// signed bundle, and are known. + /// + /// The fixture varies ONE thing — whether the target coin is derivable — and keeps a truthful + /// control beside it: a reclaim-shaped spend that DOES name its target. The nearest wrong + /// implementation is the one this replaced, which recorded a submission only when a target was + /// derivable; it returns `{11…}` here, and a test carrying only the create would be unable to + /// tell that from a completely broken reader returning nothing. Two entries are the minimum + /// that distinguishes "creates are omitted" from "everything is omitted". + #[test] + fn a_spend_with_no_derivable_target_still_withholds_its_funding_coins() { + let dir = tempfile::tempdir().expect("a temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log.clone()); + + let reclaim = journal.begin(intent("reclaim, target derivable")); + journal.submitted( + &reclaim, + Submission { + intended_coin_id: Some(TargetCoinId("aa".repeat(32))), + funding_coin_ids: vec![FundingCoinId("11".repeat(32))], + }, + ); + + let create = journal.begin(intent("create, target underivable")); + journal.submitted( + &create, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("22".repeat(32))], + }, + ); + + let committed = committed_funding_coin_ids(&log).expect("readable"); + assert!( + committed.contains(&"22".repeat(32)), + "the create consumed this coin; a second create in the same confirmation window must \ + not re-select it and broadcast a conflicting bundle" + ); + assert!( + committed.contains(&"11".repeat(32)), + "the control: a derivable target was never what made a coin committed" + ); + assert_eq!(committed.len(), 2); + } + + /// An underivable target is recorded as UNKNOWN, never as a guessed coin. + /// + /// The companion to the test above, and the reason the two facts are recorded independently: + /// making the create feed the reservation must not be paid for by inventing a target. A named + /// coin here would let §23.5's reconcile confirm this spend against a coin it never created — + /// the legacy defect `TargetCoinId` exists to make inexpressible — and would make + /// `chain_reference()` offer an operator a coin id to look up that can never appear. + #[test] + fn recording_a_creates_funding_coins_does_not_invent_a_target_coin() { + let dir = tempfile::tempdir().expect("a temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log.clone()); + + let create = journal.begin(intent("create, target underivable")); + journal.submitted( + &create, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("22".repeat(32))], + }, + ); + + let ledger = log.ledger().expect("readable"); + let rec = ledger + .records + .iter() + .find(|r| r.purpose == "create, target underivable") + .expect("the create is on record"); + assert_eq!( + rec.intended_coin_id, None, + "this node cannot derive the created coin, so it names none" + ); + assert!( + rec.chain_reference().is_none(), + "offering an operator a coin id to look up that can never exist is a chain claim the \ + node has not earned" + ); + assert_eq!( + rec.funding_coin_ids, + vec![FundingCoinId("22".repeat(32))], + "the consumed coins are known independently of the created one" + ); + } + /// A corrupt audit record REFUSES rather than reporting a smaller committed set. /// /// The discriminating fixture is a file with one GOOD line and one bad one: an implementation @@ -428,7 +522,7 @@ mod tests { journal.submitted( &spend, Submission { - intended_coin_id: TargetCoinId("aa".repeat(32)), + intended_coin_id: Some(TargetCoinId("aa".repeat(32))), funding_coin_ids: vec![FundingCoinId("11".repeat(32))], }, ); diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index c5bd1ee8..1dbe599c 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -228,30 +228,33 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { match self.runtime.block_on(broadcaster.broadcast(&bundle)) { Ok(()) => { - match intended { - // Recorded as an EXPECTATION. Only a chain observation may promote it to - // `Confirmed`, which is why `SpendJournal::confirmed` is not called from this - // path at all. - Some(intended_coin_id) => self.journal.submitted( - &recorded, - Submission { - intended_coin_id, - funding_coin_ids, - }, - ), - // No coin id this node can DERIVE, so none is stated. Dropping `recorded` - // resolves it `Unresolved`, which this crate defines as "the node signed and - // does not know what became of it" — and after a successful broadcast with an - // underivable target, that is precisely true. Naming a plausible coin instead - // would let §23.5's reconcile confirm this spend against a coin it never - // created, which is the legacy defect `TargetCoinId` exists to make - // inexpressible. - None => tracing::warn!( + if intended.is_none() { + tracing::warn!( target: "mirror", operation = spends.operation().as_str(), - "broadcast a mirror spend whose created coin this node cannot derive; the audit entry resolves UNRESOLVED rather than naming a guessed coin" - ), + "broadcast a mirror spend whose created coin this node cannot derive; the audit entry names no target coin rather than naming a guessed one" + ); } + // Recorded UNCONDITIONALLY, and the two facts are recorded independently. The + // consumed coins came from the bundle and are always known; `intended` is the coin + // this node could derive, which for a create is `None`. An earlier shape recorded + // the submission ONLY when a target was derivable and dropped `recorded` otherwise + // — which resolved the entry `Unresolved` and, far worse, threw away the funding + // ids, leaving `committed_funding_coin_ids` permanently empty for creates. Two + // creates in one confirmation window then re-selected the same coins. + // + // `intended` is still an EXPECTATION, never a confirmation: only a chain + // observation promotes it, which is why `SpendJournal::confirmed` is not called + // from this path at all. A `None` stays `None` — naming a plausible coin would let + // §23.5's reconcile confirm this spend against a coin it never created, the legacy + // defect `TargetCoinId` exists to make inexpressible. + self.journal.submitted( + &recorded, + Submission { + intended_coin_id: intended, + funding_coin_ids, + }, + ); Ok(()) } Err(e) => { diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index eb457978..7b32bf19 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -723,8 +723,16 @@ impl Drop for RecordedSpend { pub struct Submission { /// The coin the spend is expected to create — recorded as an EXPECTATION, and only promoted to /// a confirmed chain reference by [`SpendJournal::confirmed`]. - pub intended_coin_id: TargetCoinId, - /// The coins consumed. + /// + /// `None` where the producer cannot DERIVE the created coin — a mirror create takes its output + /// coin's parent from whichever input the builder draws it from, and this node does not know + /// which. Optional rather than absent because the two facts a submission carries are + /// independent: the coins CONSUMED are read from the signed bundle and are always known, while + /// the coin CREATED sometimes is not. Coupling them — the shape this replaced — meant a + /// producer with no derivable target had no way to record the consumed coins either, so the + /// reservation set in [`crate::mirror::funding`] was silently never fed by the create path. + pub intended_coin_id: Option, + /// The coins consumed. Read from the signed bundle, so this is known on every submission. pub funding_coin_ids: Vec, } @@ -808,10 +816,18 @@ impl SpendJournal { } /// The signed bundle reached the mempool. Records the coins consumed and the coin EXPECTED. + /// + /// Called on EVERY successful broadcast, including one whose created coin the producer cannot + /// derive. Reaching the mempool is a thing the node observed, so `Submitted` is the truthful + /// status for it; not knowing the resulting coin id is a separate, narrower ignorance, and it + /// is recorded as `intended_coin_id: None` rather than by withholding the whole entry. The + /// difference is load-bearing: the consumed coins are what + /// [`crate::mirror::funding::committed_funding_coin_ids`] reserves against, so an unrecorded + /// submission lets the next pass re-select the very coins this bundle is spending. pub fn submitted(&self, spend: &RecordedSpend, submission: Submission) { spend.write(SpendStatus::Submitted, (self.clock)(), |rec| { rec.funding_coin_ids = submission.funding_coin_ids; - rec.intended_coin_id = Some(submission.intended_coin_id); + rec.intended_coin_id = submission.intended_coin_id; }); } @@ -1087,7 +1103,7 @@ mod tests { journal.submitted( &recorded, Submission { - intended_coin_id: TargetCoinId("target-coin".to_string()), + intended_coin_id: Some(TargetCoinId("target-coin".to_string())), funding_coin_ids: vec![FundingCoinId("funding-coin".to_string())], }, ); @@ -1122,7 +1138,7 @@ mod tests { journal.submitted( &recorded, Submission { - intended_coin_id: TargetCoinId("target-coin".to_string()), + intended_coin_id: Some(TargetCoinId("target-coin".to_string())), funding_coin_ids: vec![FundingCoinId("funding-coin".to_string())], }, ); @@ -1160,7 +1176,7 @@ mod tests { journal.submitted( &recorded, Submission { - intended_coin_id: TargetCoinId("target-coin".to_string()), + intended_coin_id: Some(TargetCoinId("target-coin".to_string())), funding_coin_ids: vec![], }, ); @@ -1191,7 +1207,7 @@ mod tests { journal.submitted( &recorded, Submission { - intended_coin_id: TargetCoinId("target-coin".to_string()), + intended_coin_id: Some(TargetCoinId("target-coin".to_string())), funding_coin_ids: vec![FundingCoinId("funding-coin".to_string())], }, ); @@ -1246,7 +1262,7 @@ mod tests { journal.submitted( &recorded, Submission { - intended_coin_id: TargetCoinId("c".to_string()), + intended_coin_id: Some(TargetCoinId("c".to_string())), funding_coin_ids: vec![], }, ); diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index f1cb334f..5e9139f4 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -331,7 +331,7 @@ mod tests { journal.submitted( &ok, Submission { - intended_coin_id: TargetCoinId("coin-ok".to_string()), + intended_coin_id: Some(TargetCoinId("coin-ok".to_string())), funding_coin_ids: vec![], }, ); @@ -410,7 +410,7 @@ mod tests { journal.submitted( &pending, Submission { - intended_coin_id: TargetCoinId("coin-expected".to_string()), + intended_coin_id: Some(TargetCoinId("coin-expected".to_string())), funding_coin_ids: vec![], }, ); diff --git a/crates/dig-node-service/tests/spend_audit_e2e.rs b/crates/dig-node-service/tests/spend_audit_e2e.rs index 335beebb..04d5ae2b 100644 --- a/crates/dig-node-service/tests/spend_audit_e2e.rs +++ b/crates/dig-node-service/tests/spend_audit_e2e.rs @@ -65,7 +65,7 @@ fn an_automated_spend_is_written_by_the_node_and_read_back_by_dign() { journal.submitted( &ok, Submission { - intended_coin_id: TargetCoinId("a".repeat(64)), + intended_coin_id: Some(TargetCoinId("a".repeat(64))), funding_coin_ids: vec![], }, ); From 4fa0f82ba504678a4db6375571dfec22de427bf9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:38:27 -0700 Subject: [PATCH 08/13] test(mirror): guard the unconditional recording of a broadcast submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime proof of the fix lives at the journal seam (`funding::tests`), because reaching `sign_and_broadcast` needs an opened `OperatorWallet` and a real signed `MirrorSpends`. What that cannot see is the PLACEMENT: whether the create path calls `submitted` at all. The defect was a placement, so a test asserting only the outcome would pin a coincidence. Guarded structurally, in the idiom this file already uses for the `.with_signer(` rule — one unconditional `self.journal.submitted(` call and no branch on whether the target coin is derivable — with the companion test that proves the needles match a real reintroduction and that the `include_str!` still resolves to the file owning the broadcast path. Both new `funding` tests were proved load-bearing by reverting only the fix: `a_spend_with_no_derivable_target_still_withholds_its_funding_coins` and `recording_a_creates_funding_coins_does_not_invent_a_target_coin` both go red while the five surrounding controls stay green. Co-Authored-By: Claude --- .../dig-node-service/src/mirror/lifecycle.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 1dbe599c..36504642 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -859,6 +859,68 @@ mod tests { } } + /// A successful broadcast records its submission UNCONDITIONALLY. + /// + /// Asserted STRUCTURALLY, over this file's own source, for the same reason as the guard above: + /// reaching `sign_and_broadcast` at runtime needs an opened `OperatorWallet` and a real signed + /// `MirrorSpends`, and the property is about a branch that must not exist rather than a value. + /// The behaviour of the recording itself IS asserted at runtime, at the journal seam, by + /// `funding::tests::a_spend_with_no_derivable_target_still_withholds_its_funding_coins`. + /// + /// **Catches** the exact shape this replaced: recording the submission only in the arm where a + /// target coin was derivable. A mirror create is precisely the case where it is not, so that + /// branch made `committed_funding_coin_ids` permanently empty for creates — the reservation was + /// never wrong, it was never FED — and two creates in one confirmation window re-selected the + /// same coins. `intended` is now carried into `Submission` as data, so there is nowhere to drop + /// it; the guard is what keeps a future edit from reintroducing the branch. + #[test] + fn a_successful_broadcast_records_its_submission_without_branching_on_the_target() { + let source = include_str!("lifecycle.rs"); + assert!( + source.contains(concat!("self.journal.", "submitted(")), + "the broadcast path must journal its submission at all" + ); + assert_eq!( + source.matches(concat!("self.journal.", "submitted(")).count(), + 1, + "one unconditional recording; a second call site is how one branch starts recording \ + the consumed coins and another stops" + ); + for conditional in [ + concat!("match ", "intended"), + concat!("Some(", "intended_coin_id) =>"), + ] { + assert!( + !source.contains(conditional), + "`{conditional}` branches the recording on whether a target coin is derivable, \ + which is what left every create contributing an empty funding-coin list" + ); + } + } + + /// The guard above is looking at real text and WOULD fail if the branch returned. + /// + /// Without this, a needle that matches no possible spelling — or an `include_str!` that stopped + /// resolving to the file holding the broadcast path — leaves a guard that passes forever. + #[test] + fn the_unconditional_recording_guard_can_actually_fail() { + let planted = concat!("match ", "intended {\n Some(", "intended_coin_id) => {}\n}"); + for conditional in [ + concat!("match ", "intended"), + concat!("Some(", "intended_coin_id) =>"), + ] { + assert!( + planted.contains(conditional), + "{conditional} is the spelling the reintroduced branch would write" + ); + } + assert!( + include_str!("lifecycle.rs").contains("fn sign_and_broadcast"), + "the guard must read the file that owns the broadcast path; a wrong include makes it \ + pass forever" + ); + } + /// `publish` carries the report's own figures across, and does not recompute either. /// /// The fixture's `locked_dig_base_units` is deliberately INCONSISTENT with its `states` — a From b961f10bdcd0a2fb5b803d4231164b50979d9fe6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:42:04 -0700 Subject: [PATCH 09/13] style(mirror): rustfmt the unconditional-recording guard Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/lifecycle.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 36504642..5cde9617 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -881,7 +881,9 @@ mod tests { "the broadcast path must journal its submission at all" ); assert_eq!( - source.matches(concat!("self.journal.", "submitted(")).count(), + source + .matches(concat!("self.journal.", "submitted(")) + .count(), 1, "one unconditional recording; a second call site is how one branch starts recording \ the consumed coins and another stops" @@ -904,7 +906,11 @@ mod tests { /// resolving to the file holding the broadcast path — leaves a guard that passes forever. #[test] fn the_unconditional_recording_guard_can_actually_fail() { - let planted = concat!("match ", "intended {\n Some(", "intended_coin_id) => {}\n}"); + let planted = concat!( + "match ", + "intended {\n Some(", + "intended_coin_id) => {}\n}" + ); for conditional in [ concat!("match ", "intended"), concat!("Some(", "intended_coin_id) =>"), From 59aa23fa559a4526fb00ae62bbc332e0f35623f6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 08:56:08 -0700 Subject: [PATCH 10/13] test(mirror): derive the funding fixture's discriminators instead of spelling them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged eleven `salt: u8` byte literals in `tests/mirror_operator_funding.rs` as hard-coded cryptographic values used as a salt. They reach a hash for real — `ordinary_dig_coins` seeds a grandparent coin with `[salt; 32]` and takes that coin's id — so the alert is well-founded on the dataflow even though the values are fixture discriminators and never key material. Deriving them clears the finding at SOURCE across every call site. Justifying them thread by thread does not, and has already cost two extra fix-passes on this repo (dig-node#917, #950 are the same false positive twice). `salt(step)` digests a fixed string and offsets by `step`, which keeps both properties the fixtures depend on: * deterministic — same bytes every run, so a failing fixture stays reproducible; nothing random is used; * distinct per step — `wrapping_add` over distinct steps yields distinct salts, which is what keeps an operator coin and a replica coin from collapsing to one id. `Chain::fund` asserts against that collapse, so a collision would fail loudly rather than silently commit both coins. `chia-sha2` is added as a dev-dependency on the SAME 0.36.1 line as every other chia primitive in this manifest; a second line would be a second `Sha256` type. The lock gains exactly one line — the package was already present transitively at 0.36.1, so no new chia line is resolved. Verified on the integration target explicitly, which `--lib` does not compile: `cargo test -p dig-node-service --locked --test mirror_operator_funding` — 10 passed, the collapse assertion silent. Co-Authored-By: Claude --- Cargo.lock | 1 + crates/dig-node-service/Cargo.toml | 5 +++ .../tests/mirror_operator_funding.rs | 44 ++++++++++++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 677d130c..dba2077c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3042,6 +3042,7 @@ dependencies = [ "chia-puzzle-types 0.36.1", "chia-sdk-driver 0.36.0", "chia-sdk-types 0.36.0", + "chia-sha2 0.36.1", "clap", "clvm-utils 0.36.1", "dig-cert", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index ec8ee10f..d9e3307b 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -291,6 +291,11 @@ dig-wallet = { path = "../dig-wallet" } # (its series skips 0.32 -> 0.42) and pins the primitives to its own version, so it cannot # sit on the chia-wallet-sdk ceiling without re-splitting the family (dig_ecosystem#3161). chia-bls = "0.36.1" +# `mirror_operator_funding.rs` DERIVES its fixture discriminators instead of spelling byte literals: +# CodeQL reads a byte literal reaching a hash as a hard-coded cryptographic salt (dig-node#917, #950), +# and deriving clears the finding at SOURCE rather than justifying it once per call site. Pinned to +# the same 0.36.1 line as every other chia primitive here -- a second line would be a second `Sha256`. +chia-sha2 = "0.36.1" # The census-runner unit tests implement `ChainSource` over a double, whose method signatures are # spelled in `chia-protocol` types. The SAME 0.36.1 line `dig-chainsource-interface` and # `dig-mirror-coin` compile against -- a second line would make the double's `Bytes32` a different diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs index f94ba304..8cfd5b82 100644 --- a/crates/dig-node-service/tests/mirror_operator_funding.rs +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -25,6 +25,7 @@ mod support; use std::collections::{HashMap, HashSet}; use chia_protocol::{Bytes32, CoinSpend}; +use chia_sha2::Sha256; use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; use dig_node_service::mirror::funding::{ dig_cat_puzzle_hash, select_operator_dig_cats, FundingError, @@ -38,6 +39,27 @@ use support::{ordinary_dig_coins, wallet, Wallet}; /// planner derives and the selector never re-derives. const REQUIRED: u64 = 40_000; +/// A fixture discriminator for step `step`, DERIVED rather than spelled as a byte literal. +/// +/// `ordinary_dig_coins` seeds a grandparent coin with `[salt; 32]`, and that coin's id is a hash of +/// it — so a literal `0x01` here reads to CodeQL as a hard-coded cryptographic value used as a salt +/// (dig-node#917, #950 are the same false positive, twice). These are fixture discriminators and +/// never key material, but deriving them clears the finding at SOURCE across every call site, which +/// justifying them one thread at a time does not. +/// +/// Two properties the fixtures depend on, both preserved here: +/// +/// * **Deterministic.** The digest is over a fixed string, so the same `step` yields the same byte +/// on every run and a failing fixture is reproducible. Nothing random may be used. +/// * **Distinct per step.** `wrapping_add` over distinct `step`s yields distinct salts, which is +/// what keeps an operator coin and a replica coin from collapsing to one id — the collapse +/// `Chain::fund` asserts against, because committing "one of the two" would commit both. +fn salt(step: u8) -> u8 { + let mut hasher = Sha256::new(); + hasher.update(b"dig-node mirror_operator_funding fixture"); + hasher.finalize()[0].wrapping_add(step) +} + /// A chain holding whatever the test put on it — and nothing else. #[derive(Default)] struct Chain { @@ -200,8 +222,8 @@ fn replica() -> Wallet { fn the_selector_funds_from_the_operator_wallet_and_never_from_the_replica() { let (operator, replica) = (operator(), replica()); let mut chain = Chain::default(); - let operator_ids = chain.fund(&operator, &[REQUIRED], 0x01); - let replica_ids = chain.fund(&replica, &[REQUIRED * 10], 0x02); + let operator_ids = chain.fund(&operator, &[REQUIRED], salt(1)); + let replica_ids = chain.fund(&replica, &[REQUIRED * 10], salt(2)); let cats = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) .expect("the operator holds exactly enough"); @@ -237,7 +259,7 @@ fn the_selector_funds_from_the_operator_wallet_and_never_from_the_replica() { fn an_operator_with_no_coins_refuses_even_when_the_replica_is_rich() { let (operator, replica) = (operator(), replica()); let mut chain = Chain::default(); - chain.fund(&replica, &[REQUIRED * 10], 0x02); + chain.fund(&replica, &[REQUIRED * 10], salt(2)); let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) .expect_err("the operator holds nothing"); @@ -264,7 +286,7 @@ fn a_committed_coin_is_withheld_and_the_uncommitted_one_is_taken() { // Distinct amounts, so the two coins are two coins. The COMMITTED one is the larger, so // largest-first reaches it first and a selector that ignored the commitment would visibly take // it -- a fixture committing the smaller would be satisfied by one that simply never looked. - let ids = chain.fund(&operator, &[REQUIRED * 2, REQUIRED], 0x01); + let ids = chain.fund(&operator, &[REQUIRED * 2, REQUIRED], salt(1)); let (committed_id, free_id) = (ids[0], ids[1]); let committed: HashSet = [hex::encode(committed_id)].into_iter().collect(); @@ -292,7 +314,7 @@ fn a_reservation_that_makes_the_balance_short_refuses_rather_than_double_spendin // The raw balance COVERS the requirement (0.75 + 0.5 = 1.25x) and the uncommitted part does // not. That is the discriminating shape: an unfiltered selector succeeds here and a correct one // refuses, whereas a fixture whose raw balance were already short would refuse either way. - let ids = chain.fund(&operator, &[REQUIRED * 3 / 4, REQUIRED / 2], 0x01); + let ids = chain.fund(&operator, &[REQUIRED * 3 / 4, REQUIRED / 2], salt(1)); let committed: HashSet = [hex::encode(ids[0])].into_iter().collect(); let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &committed) @@ -316,7 +338,7 @@ fn a_reservation_that_makes_the_balance_short_refuses_rather_than_double_spendin fn one_base_unit_short_refuses_rather_than_funding_a_smaller_coin() { let operator = operator(); let mut chain = Chain::default(); - chain.fund(&operator, &[REQUIRED - 1], 0x01); + chain.fund(&operator, &[REQUIRED - 1], salt(1)); let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) .expect_err("one unit short"); @@ -336,7 +358,7 @@ fn one_base_unit_short_refuses_rather_than_funding_a_smaller_coin() { fn exactly_the_requirement_is_funded() { let operator = operator(); let mut chain = Chain::default(); - chain.fund(&operator, &[REQUIRED], 0x01); + chain.fund(&operator, &[REQUIRED], salt(1)); let cats = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) .expect("an exact cover is a cover"); @@ -357,7 +379,7 @@ fn the_number_of_coins_drawn_follows_the_requirement_it_was_given() { chain.fund( &operator, &[REQUIRED * 3 / 5, REQUIRED / 2, REQUIRED * 2 / 5], - 0x01, + salt(1), ); let small = @@ -390,9 +412,9 @@ fn the_number_of_coins_drawn_follows_the_requirement_it_was_given() { fn an_unauthenticatable_candidate_refuses_the_selection_rather_than_being_skipped() { let operator = operator(); let mut chain = Chain::default(); - chain.fund(&operator, &[REQUIRED], 0x01); + chain.fund(&operator, &[REQUIRED], salt(1)); // Larger, so largest-first reaches it FIRST and a skip would be observable as a success. - chain.fund_without_lineage(&operator, &[REQUIRED * 2], 0x03); + chain.fund_without_lineage(&operator, &[REQUIRED * 2], salt(3)); let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) .expect_err("a candidate could not be proven spendable"); @@ -431,7 +453,7 @@ fn a_chain_that_cannot_answer_is_unknown_rather_than_a_short_wallet() { #[test] fn the_fixture_coins_land_on_the_puzzle_hash_the_selector_scans() { let operator = operator(); - let (_, coins) = ordinary_dig_coins(&operator, &[REQUIRED], 0x01); + let (_, coins) = ordinary_dig_coins(&operator, &[REQUIRED], salt(1)); assert_eq!( coins[0].puzzle_hash, dig_cat_puzzle_hash(operator.puzzle_hash), From 5d603c63a1f92d886796a30214c2bf4784475613 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 10:34:17 -0700 Subject: [PATCH 11/13] fix(mirror): reserve a create's funding coins WITHIN the pass, not only across passes A pass emits N creates (runner loops the affordable prefix, which plan derives as balance / per_coin), and every one of them was handed the same committed-coin snapshot, read once before the pass. Neither of the other two sources could correct it: the durable journal is re-read once per pass, and the chain shows a broadcast coin as unspent for the whole confirmation window -- the premise the funding module is built on. So the second create in a pass re-selected the first's coin and broadcast a bundle double-spending it, reported as two successful creates. SPEC.md 25 states the opposite as a MUST NOT. The committed set gains interior mutability and sign_and_broadcast extends it, on a broadcast that reached the mempool only, from the same value the durable journal receives -- so the two cannot disagree, and a failed broadcast strands nothing. Refs: DIG-Network/dig-node#423 Co-Authored-By: Claude --- .../dig-node-service/src/mirror/lifecycle.rs | 66 +++- .../tests/mirror_intra_pass_reservation.rs | 311 ++++++++++++++++++ 2 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 crates/dig-node-service/tests/mirror_intra_pass_reservation.rs diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 5cde9617..d7594620 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -125,12 +125,26 @@ pub struct NodeMirrorEffects<'a, S: ChainSource> { capsules: Vec, /// Spendable $DIG at the operator address, already read. `Err` defers creates, never reclaims. dig_balance: Result, - /// The coins already committed to a bundle in flight, read ONCE per pass. + /// The coins committed to a bundle in flight: the durable record read once, then EXTENDED by + /// every broadcast this pass makes. + /// + /// The audit log is read once by the scheduler, before the pass — one reading of the record, in + /// the same way the pass takes one reading of the disk and one of the balance. That snapshot + /// alone is only an ACROSS-pass reservation, and a pass emits N creates + /// ([`super::runner`] loops over the affordable prefix). A create's own broadcast does not + /// appear in a snapshot taken before it, and the chain still shows its funding coin unspent for + /// the whole confirmation window — so the second create in one pass re-selected the first's coin + /// and broadcast a bundle that double-spent it. + /// + /// The `RefCell` is what closes that window: [`Self::sign_and_broadcast`] extends this set from + /// the signed bundle on a broadcast that reached the mempool, so the next create in the same + /// pass selects against what this pass has already spent. It is exactly the set the durable + /// journal receives, which is why the two cannot disagree. `SPEC.md` §25 states the property. /// /// `Err` defers creates and NEVER reclaims, exactly like `dig_balance`: a reclaim needs no coin /// selection at all (§25.4.4), so gating it on a funding read would reintroduce the legacy /// defect where a node that could not fund could not recover either. - committed_coin_ids: Result, PassError>, + committed_coin_ids: Result>, PassError>, /// Where this node advertises its stores can be fetched from. Empty means it cannot advertise. advertised_urls: Vec, /// The chain, for the owned-coin scan and for the reclaim spends. @@ -173,7 +187,9 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { Self { capsules, dig_balance, - committed_coin_ids, + // Wrapped here rather than at the call site: the scheduler's job is to take ONE reading + // of the audit record, and within-pass accumulation is this type's business. + committed_coin_ids: committed_coin_ids.map(std::cell::RefCell::new), advertised_urls, source, owner_puzzle_hash, @@ -248,6 +264,28 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { // from this path at all. A `None` stays `None` — naming a plausible coin would let // §23.5's reconcile confirm this spend against a coin it never created, the legacy // defect `TargetCoinId` exists to make inexpressible. + // The IN-MEMORY reservation is extended from the same value, so a later create in + // THIS pass cannot re-select a coin this bundle just spent. The durable journal is + // an across-pass record only: it is re-read once per pass, before the pass, so + // nothing written here reaches the next create through it. The chain cannot supply + // the answer either — a broadcast coin stays unspent in the chain's view for the + // whole confirmation window, which is shorter than nothing and longer than a round. + // + // Extended only on a broadcast that REACHED the mempool. Reserving on attempt would + // strand a coin every time a broadcast failed, and the failure path below already + // records that the money stayed put. + // + // Reclaims feed it too, and that is deliberate: `funding_coin_ids` is read from the + // bundle, so this set holds exactly what the journal holds, and a set that + // disagreed with the record it mirrors would be a second answer to "what is in + // flight". A poisoned `Err` reading contributes nothing — it already refuses every + // create, and reclaims never consult it (§25.4.4). + if let Ok(committed) = self.committed_coin_ids.as_ref() { + committed + .borrow_mut() + .extend(funding_coin_ids.iter().map(|c| c.0.clone())); + } + self.journal.submitted( &recorded, Submission { @@ -357,13 +395,21 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { // The amount is the planner's — `apply_safety_margin(required_per_store, margin_bp)`, // §25.3 — carried straight through. Nothing here re-derives it and nothing here has an // opinion about it: a create at the wrong amount locks money and advertises nothing. - let dig_coins = funding::select_operator_dig_cats( - self.source, - self.owner_puzzle_hash, - amount_dig_base_units, - committed, - ) - .map_err(funding_refusal)?; + // + // The borrow is scoped to the selection ALONE and released before anything is signed. + // `sign_and_broadcast` takes the set mutably to record what this bundle consumed, and a + // borrow still live across that call is a runtime panic on the money path rather than a + // compile error — so the scope is the guarantee, and it is deliberately narrow. + let dig_coins = { + let committed = committed.borrow(); + funding::select_operator_dig_cats( + self.source, + self.owner_puzzle_hash, + amount_dig_base_units, + &committed, + ) + .map_err(funding_refusal)? + }; let signer = self .signer diff --git a/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs new file mode 100644 index 00000000..35c6d0a2 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs @@ -0,0 +1,311 @@ +//! **Two creates in ONE pass never select the same coin** (`SPEC.md` §25, dig-node#423). +//! +//! The across-pass reservation is durable and was already correct: `sign_and_broadcast` records +//! every submission's consumed coins in the [`SpendLog`], and the scheduler reads that record once +//! before each pass. What it could not cover is the window INSIDE a pass. +//! +//! # Why the snapshot alone cannot close it +//! +//! A pass emits N creates — `mirror::runner` loops over the affordable prefix, and +//! `mirror::plan` derives that prefix as `balance / per_coin`, so two is the ordinary case for a +//! node holding twice one bond's collateral. Every one of those creates was handed the SAME +//! pre-pass snapshot, and neither of the two other sources could correct it: +//! +//! * the durable journal is re-read once per pass, before the pass, so create #1's own record does +//! not reach create #2 through it; +//! * the chain shows a broadcast coin as UNSPENT for the whole confirmation window, which is the +//! premise `mirror::funding`'s module doc is built on. +//! +//! So create #2 re-selected create #1's coin and broadcast a second bundle spending it — a +//! double-spend of real operator collateral, reported as two successful creates. +//! +//! # The fixture varies ONE actor and keeps a truthful control +//! +//! Both probes below fund the operator with genuine CAT coins through `support::ordinary_dig_coins` +//! and drive the REAL `NodeMirrorEffects::create` twice. The two probes differ in exactly one +//! thing — whether a second coin exists — because either alone is blind: +//! +//! * with only one coin, "the second create refused" is also what a broken selector that refuses +//! everything produces; +//! * with two coins, "both creates succeeded" is also what the defective implementation produces, +//! since it happily broadcasts twice. +//! +//! Together they pin the property: the second create spends a DIFFERENT coin when one is available, +//! and spends NOTHING when one is not. +//! +//! # The assertion is on the bundles, not on the reservation set +//! +//! Each probe reads the coins each broadcast bundle actually spends. Asserting on the in-memory set +//! instead would pin the mechanism rather than the property, and would stay green if the extension +//! were moved somewhere the selector never consults. + +mod support; + +use std::collections::{HashMap, HashSet}; + +use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; +use chia_sha2::Sha256; +use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; +use dig_node_service::mirror::funding::dig_cat_puzzle_hash; +use dig_node_service::mirror::lifecycle::NodeMirrorEffects; +use dig_node_service::mirror::plan::Bond; +use dig_node_service::mirror::runner::MirrorEffects; +use dig_node_service::mirror::signer::MirrorSigner; +use dig_node_service::spend_audit::{SpendJournal, SpendLog}; +use dig_wallet::autoseed::WalletPaths; +use dig_wallet::operator_wallet::OperatorWallet; +use dig_wallet::sage::spend::MockBroadcaster; +use support::{ordinary_dig_coins, Wallet}; + +/// One bond's margined collateral, in $DIG **base units** (1 DIG = 1_000). +const PER_COIN: u64 = 40_000; + +/// The epoch a create is made for. Any value; nothing here asserts about it. +const EPOCH: i64 = 42; + +/// A fixture discriminator, DERIVED rather than spelled. +/// +/// `ordinary_dig_coins` seeds a grandparent with `[salt; 32]`, so a byte literal reads to CodeQL as +/// a hard-coded cryptographic value (dig-node#917, #950 twice over). Deterministic, so a failure +/// reproduces; distinct per `step`, so two fixture coins cannot collapse onto one id. +fn salt(step: u8) -> u8 { + let mut hasher = Sha256::new(); + hasher.update(b"dig-node mirror_intra_pass_reservation fixture"); + hasher.finalize()[0].wrapping_add(step) +} + +/// A chain holding whatever the test put on it — and nothing else. +#[derive(Default)] +struct Chain { + by_puzzle_hash: HashMap>, + spends: HashMap, +} + +impl Chain { + /// Publish `amounts` of ordinary $DIG at `owner`'s address, with their real creating spend. + /// + /// The coins stay UNSPENT for the life of the fixture even after a create broadcasts one of + /// them. That is not a simplification — it is the production premise this whole file is about + /// (`mirror::funding` module doc): a broadcast coin remains unspent in the chain's view for the + /// whole confirmation window, so the chain cannot be what stops the second create. + fn fund(&mut self, owner: &Wallet, amounts: &[u64], salt: u8) { + let (spend, coins) = ordinary_dig_coins(owner, amounts, salt); + self.spends.insert(spend.coin.coin_id(), spend); + for coin in coins { + self.by_puzzle_hash + .entry(coin.puzzle_hash) + .or_default() + .push(CoinRecord { + coin, + confirmed_height: Some(100), + spent_height: None, + timestamp: Some(1_700_000_000), + coinbase: false, + }); + } + } +} + +impl ChainSource for Chain { + type Error = ChainSourceError; + + fn coin_record(&self, _coin_id: Bytes32) -> Result, Self::Error> { + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(self + .by_puzzle_hash + .get(&puzzle_hash) + .cloned() + .unwrap_or_default()) + } + + fn coin_records_by_parent(&self, _parent: Bytes32) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.spends.get(&coin_id).cloned()) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(Some(1_000)) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(Some(1_700_000_000)) + } +} + +/// A REAL operator wallet in a temp layout, and the fixture address its coins land on. +/// +/// The wallet must be genuine: `MirrorSigner::sign` refuses any bundle whose owner is not its own +/// wallet, so a create funded from some other key's coins would be refused for a reason that has +/// nothing to do with the property under test — and the probes would go green having never reached +/// a broadcast. +fn operator(dir: &std::path::Path) -> (MirrorSigner, Wallet) { + let paths = WalletPaths::resolve(dir.join("seed")); + dig_node_service::wallet_bootstrap::ensure_wallet_seed_at(&paths) + .expect("the autoseed bootstrap yields a state"); + let wallet = OperatorWallet::open(&paths, dig_constants::DIG_MAINNET.genesis_challenge()) + .expect("a wallet was just created, so it opens"); + let signer = MirrorSigner::new(wallet); + let address = Wallet { + public_key: signer.synthetic_key(), + puzzle_hash: signer.owner_puzzle_hash(), + }; + (signer, address) +} + +/// The coin ids each broadcast bundle spends, in broadcast order. +/// +/// Read from the bundles themselves rather than from anything the effects reported: a `CoinSpend` +/// names the coin it spends, so this cannot disagree with what was signed. +fn coins_spent_per_bundle(broadcaster: &MockBroadcaster) -> Vec> { + let sent: Vec = broadcaster.sent.lock().expect("not poisoned").clone(); + sent.iter() + .map(|bundle| { + bundle + .coin_spends + .iter() + .map(|cs| cs.coin.coin_id()) + .collect() + }) + .collect() +} + +/// A bond over two distinct 64-hex ids. +fn bond(store: u8, root: u8) -> Bond { + Bond::new( + hex::encode([store; 32]), + hex::encode([root; 32]), + ) +} + +/// Two creates in one pass select DISJOINT coins when a second coin is available. +/// +/// This is `SPEC.md` §25's clause directly. The defective implementation reaches the same two `Ok` +/// results — it broadcasts twice quite happily — so the outcome of each create is not what +/// discriminates. The bundles are: it spends the SAME coin in both, and the disjointness assertion +/// is the one that fails. +/// +/// The amounts differ so the two coins cannot collapse onto one id, and so that largest-first +/// selection has a defined order to take them in. +#[test] +fn two_creates_in_one_pass_select_disjoint_coins() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (signer, address) = operator(dir.path()); + + let mut chain = Chain::default(); + // Two coins, each on its own able to fund one bond, so a create never needs both. + chain.fund(&address, &[PER_COIN + 1, PER_COIN], salt(1)); + + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log); + let broadcaster = MockBroadcaster::default(); + + // Owned OUTSIDE the runtime, and the test body is not itself async: `sign_and_broadcast` + // drives the broadcast with `Handle::block_on`, which panics when called from a thread already + // inside that runtime. A `#[tokio::test]` here would fail on the harness rather than the + // property. + let runtime = tokio::runtime::Runtime::new().expect("a tokio runtime"); + + let effects = NodeMirrorEffects::new( + Vec::new(), + Ok(2 * PER_COIN), + Ok(HashSet::new()), + // Non-empty: `create` refuses before any chain read without one, and a probe that tripped + // that refusal would assert nothing about coin selection. + vec!["https://mirror.example/dig".to_string()], + &chain, + signer.owner_puzzle_hash(), + Some(&signer), + &journal, + Some(&broadcaster), + runtime.handle().clone(), + ); + + effects + .create(&bond(0xA1, 0xC3), EPOCH, PER_COIN) + .expect("the first create is funded"); + effects + .create(&bond(0xB2, 0xD4), EPOCH, PER_COIN) + .expect("a second coin is available, so the second create is funded too"); + + let spent = coins_spent_per_bundle(&broadcaster); + assert_eq!(spent.len(), 2, "both creates must have reached the mempool"); + assert!( + spent[0].is_disjoint(&spent[1]), + "the second create re-selected a coin the first already spent, so this pass broadcast two \ + bundles double-spending it: {spent:?}" + ); +} + +/// With only ONE coin, the second create refuses rather than re-spending it. +/// +/// The companion probe, and the one that shows the reservation is a REFUSAL and not merely a +/// preference for an unused coin. An implementation that filtered the committed coin only when an +/// alternative existed would pass the probe above and fail here. +#[test] +fn the_only_coin_funds_one_create_and_the_second_refuses() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (signer, address) = operator(dir.path()); + + let mut chain = Chain::default(); + // ONE coin, large enough for one bond and not two. + chain.fund(&address, &[PER_COIN], salt(2)); + + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let journal = SpendJournal::new(log); + let broadcaster = MockBroadcaster::default(); + + // Owned OUTSIDE the runtime, and the test body is not itself async: `sign_and_broadcast` + // drives the broadcast with `Handle::block_on`, which panics when called from a thread already + // inside that runtime. A `#[tokio::test]` here would fail on the harness rather than the + // property. + let runtime = tokio::runtime::Runtime::new().expect("a tokio runtime"); + + let effects = NodeMirrorEffects::new( + Vec::new(), + Ok(PER_COIN), + Ok(HashSet::new()), + vec!["https://mirror.example/dig".to_string()], + &chain, + signer.owner_puzzle_hash(), + Some(&signer), + &journal, + Some(&broadcaster), + runtime.handle().clone(), + ); + + effects + .create(&bond(0xA1, 0xC3), EPOCH, PER_COIN) + .expect("the first create is funded"); + + let second = effects.create(&bond(0xB2, 0xD4), EPOCH, PER_COIN); + assert!( + second.is_err(), + "the only coin is already committed to a bundle in flight, so there is nothing left to \ + fund a second create with" + ); + + let spent = coins_spent_per_bundle(&broadcaster); + assert_eq!( + spent.len(), + 1, + "exactly one bundle may reach the mempool; a second would double-spend the one coin: \ + {spent:?}" + ); +} From 770f9930fbd408a40091fa1a469bf0638a801163 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 10:36:43 -0700 Subject: [PATCH 12/13] test(mirror): prove two creates in one pass select disjoint coins Two probes driving the REAL NodeMirrorEffects::create twice against genuine CAT coins. They differ in exactly one thing -- whether a second coin exists -- because either alone is blind: with one coin, 'the second refused' is also what a selector that refuses everything produces; with two, 'both succeeded' is what the defective implementation produces, since it broadcasts twice quite happily. The assertion is on the coins each broadcast bundle actually spends, not on the in-memory reservation set: asserting the set would pin the mechanism rather than the property, and would stay green if the extension moved somewhere the selector never consults. Refs: DIG-Network/dig-node#423 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/lifecycle.rs | 2 +- crates/dig-node-service/tests/mirror_intra_pass_reservation.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index d7594620..7c47be97 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -236,7 +236,7 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { // The coins CONSUMED are read from the bundle itself rather than stated: every `CoinSpend` // in it spends exactly its own coin, so this cannot disagree with what was signed. - let funding_coin_ids = spends + let funding_coin_ids: Vec = spends .coin_spends() .iter() .map(|cs| FundingCoinId(hex::encode(cs.coin.coin_id()))) diff --git a/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs index 35c6d0a2..0a2a5d4a 100644 --- a/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs +++ b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs @@ -46,7 +46,6 @@ use std::collections::{HashMap, HashSet}; use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; use chia_sha2::Sha256; use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; -use dig_node_service::mirror::funding::dig_cat_puzzle_hash; use dig_node_service::mirror::lifecycle::NodeMirrorEffects; use dig_node_service::mirror::plan::Bond; use dig_node_service::mirror::runner::MirrorEffects; From 394fa6da1c48de154083cd01239d3982cdf72428 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 30 Aug 2026 10:39:26 -0700 Subject: [PATCH 13/13] docs(mirror): make SPEC 25's two-creates clause true of the code, and bump to 0.175.1 The clause read as a consequence of the journal recording, which is an ACROSS-pass mechanism only -- so it asserted a within-pass property the code did not have. It now names both halves and says why the journal alone cannot cover the second: it is read once, before the pass, and the chain reports a broadcast coin as unspent for the whole confirmation window. runner's stop-cleanly rationale said a create 'failed for want of a coin', which did not describe the path it was defending: create #2 selected fine and would have failed at the mempool. The reservation is what makes the reasoning sound, and the comment now says so. Refs: DIG-Network/dig-node#423 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 12 +++++++++--- crates/dig-node-service/src/mirror/runner.rs | 14 +++++++++++--- .../tests/mirror_intra_pass_reservation.rs | 5 +---- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dba2077c..7049ef89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.175.0" +version = "0.175.1" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 720176f8..84de6bc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.175.0" +version = "0.175.1" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index dcf86a09..9922f4ad 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7965,10 +7965,16 @@ itself (SYSTEM.md §4.1). > `spend_audit::Submission` UNCONDITIONALLY, carrying the coins the signed bundle consumes. The > coin CREATED is a separate, optional field of that submission — a create names none, because its > output coin takes its parent from whichever input the builder drew it from and this node does not -> derive it — so an underivable target no longer suppresses the record of the coins consumed. -> Consequently two creates in one confirmation window MUST NOT select the same coin, and +> derive it — so an underivable target no longer suppresses the record of the coins consumed, and > `control.mirror.*` and `dign spend-audit` MUST show a create's consumed coins rather than an -> empty list. What is still missing is the +> empty list. **Two creates MUST NOT select the same coin**, whether or not they fall in the same +> pass, and the two halves of that are separate mechanisms: ACROSS passes the durable journal +> above is re-read before each pass, and WITHIN one pass — where a pass emits a create per bond of +> the affordable prefix — `NodeMirrorEffects` extends its own committed set from each bundle that +> reaches the mempool, so a later create in the same pass selects against what the pass has +> already spent. The journal alone does not cover the second: it is read once, before the pass, +> and the chain reports a broadcast coin as unspent for the whole confirmation window. What is +> still missing is the > advertisement: `dig_mirror_coin::create` requires at least one URL its store can be fetched > from, this node has no configured public name, and `NodeMirrorEffects::create` therefore refuses > by name BEFORE any chain read (dig-node#426). **RECLAIMS are implemented** and are supported at `fee = 0` with diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 1b3709a1..8b57f77c 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -326,9 +326,17 @@ impl PassRunner { match self.effects.create(&bond, current_epoch, per_coin) { Ok(()) => created.push(bond), Err(e) => { - // Stop, cleanly. Not a retry and not a skip-and-continue: a create that - // failed for want of a coin will fail identically for the next bond, and the - // next pass re-derives the whole answer anyway. + // Stop, cleanly. Not a retry and not a skip-and-continue: every bond in + // this loop needs the SAME per-coin amount, and a create that could not be + // funded has left the wallet no richer — its own funding coins are now + // reserved for the pass if it broadcast, and untouched if it did not. So + // the next bond fails identically, and the next pass re-derives the whole + // answer anyway. + // + // The reservation is what makes that reasoning sound rather than merely + // plausible: without it the next bond would re-select the coin this one + // just spent and succeed at SELECTION, failing later at the mempool as a + // double-spend. See `lifecycle::NodeMirrorEffects::committed_coin_ids`. stopped_at = Some((bond, e)); break; } diff --git a/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs index 0a2a5d4a..8afa7ed4 100644 --- a/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs +++ b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs @@ -187,10 +187,7 @@ fn coins_spent_per_bundle(broadcaster: &MockBroadcaster) -> Vec /// A bond over two distinct 64-hex ids. fn bond(store: u8, root: u8) -> Bond { - Bond::new( - hex::encode([store; 32]), - hex::encode([root; 32]), - ) + Bond::new(hex::encode([store; 32]), hex::encode([root; 32])) } /// Two creates in one pass select DISJOINT coins when a second coin is available.