diff --git a/Cargo.lock b/Cargo.lock index 24e835fa..7049ef89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.173.0" +version = "0.175.1" dependencies = [ "async-trait", "axum", @@ -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/Cargo.toml b/Cargo.toml index 45668d1c..84de6bc7 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.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 +# 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..9922f4ad 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7953,12 +7953,33 @@ 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). 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, and +> `control.mirror.*` and `dign spend-audit` MUST show a create's consumed coins rather than an +> 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 +> 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 +8292,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/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/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs new file mode 100644 index 00000000..2b4c9acf --- /dev/null +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -0,0 +1,573 @@ +//! Operator-scoped $DIG selection — where a mirror create's collateral comes from (dig-node#421). +//! +//! # 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: Some(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: Some(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: Some(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 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 + /// 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: Some(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/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 15a4db52..7c47be97 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, it is an +//! advertisement question rather than a funding one, and it is dig-node#426. //! //! # 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,28 @@ 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 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>, + /// 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 +175,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 +187,10 @@ impl<'a, S: ChainSource> NodeMirrorEffects<'a, S> { Self { capsules, dig_balance, + // 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, signer, @@ -200,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()))) @@ -208,30 +244,55 @@ 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. + // 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 { + intended_coin_id: intended, + funding_coin_ids, + }, + ); Ok(()) } Err(e) => { @@ -313,22 +374,111 @@ 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. + // + // 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 + .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 +500,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()); @@ -756,6 +905,74 @@ 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 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/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/src/server.rs b/crates/dig-node-service/src/server.rs index c012caa7..5f974250 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 (dig-node#426); the selector behind it is live. + Vec::new(), &source, owner_puzzle_hash, signer_ref, 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/mirror_intra_pass_reservation.rs b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs new file mode 100644 index 00000000..8afa7ed4 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_intra_pass_reservation.rs @@ -0,0 +1,307 @@ +//! **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::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:?}" + ); +} 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..8cfd5b82 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -0,0 +1,462 @@ +//! **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 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, +}; +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 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 { + /// 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 = 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) + .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], 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"); + + 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], salt(2)); + + 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(); + // 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], salt(1)); + 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(); + // 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], salt(1)); + + let committed: HashSet = [hex::encode(ids[0])].into_iter().collect(); + let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &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, + }, + "only the uncommitted half is 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], salt(1)); + + 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], salt(1)); + + 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(); + // 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], + salt(1), + ); + + 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, + "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.** +/// +/// 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], 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], salt(3)); + + 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], salt(1)); + 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/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![], }, ); 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" + ); + } +}