diff --git a/Cargo.lock b/Cargo.lock index ba18be2d..c0fa369c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.198.0" +version = "0.206.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index b7e2fc95..5dce89d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.198.0" +version = "0.206.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index a5974fe4..9618172a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8445,6 +8445,26 @@ A pass runs: at start-up (once the wallet and a chain source are available), on the plan's create set until that entry resolves. The audit record is the in-flight ledger; the disk and the chain remain the only steady-state truths. + **A funding-coin reservation is a BOUNDED hold.** The audit record also reserves the funding + coins a non-terminal entry consumed, so a second create in the same confirmation window cannot + re-select them. That reservation MUST expire: it holds a coin for + `FUNDING_RESERVATION_WINDOW_MS` = `2 x MIRROR_ROUND_LENGTH_MS` (20 minutes) measured from the + entry's LAST revision, after which the coin returns to the selectable set. An unbounded hold is a + lockout — a spend that never lands would strand its inputs forever and a genuinely funded + operator wallet would report `Insufficient` permanently. The window is derived: the chain-side + figure is the wallet's own post-broadcast reservation lifetime (10 minutes, roughly a dozen Chia + blocks), and one further round is added because this hold is re-evaluated only once per + `MIRROR_ROUND_LENGTH_MS`, so a threshold equal to the poll interval would release an entry on the + first pass at which its confirmation could even have been observed. A record whose `updated_ms` + is in the FUTURE keeps its hold. + + **Expiry MUST NOT change the record.** The entry stays exactly the `submitted` or `unresolved` it + was, and stays resolvable by step 7 and by §23.5's reconcile indefinitely. Releasing a coin is + not a claim that the spend failed: `unresolved` means "this node signed and does not know what + happened", which remains true afterwards. Writing a `failed` entry to settle the bookkeeping is + forbidden, for the same reason a `confirmed` entry carries its height and coin id inside the + variant. + 7. **Resolves spends an EARLIER pass broadcast.** A mirror spend is broadcast in one pass and confirms during a later one, so the outcome MUST be recorded by an id-keyed resolution over the audit record rather than by the handle that opened it. Before the observation of step 2 is diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 70d5667a..499b76b2 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2812,8 +2812,16 @@ fn spawn_mirror_passes( // 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( + // + // BOUNDED, not merely computed (dig-node#471). The reservation is a time box read from + // the audit record, so a spend that never lands releases its coins after + // `FUNDING_RESERVATION_WINDOW_MS` instead of withholding them forever. The record itself + // is untouched and stays chaseable; only the hold lapses. + // + // One clock reading for the whole pass, alongside the one disk and one balance reading. + let committed = crate::spend_audit::committed_funding_coin_ids( &crate::spend_audit::SpendLog::in_state_dir(), + lifecycle::now_unix_ms(), ) .map_err(|e| crate::mirror::runner::PassError::Wallet(e.to_string())); diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 1738ce0b..0633b70f 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -66,7 +66,7 @@ //! a corrupt audit trail that reads as an empty one is the same lie as a missing entry. use std::cell::{Cell, RefCell}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::io::Write; use std::path::{Path, PathBuf}; @@ -438,6 +438,157 @@ impl SpendRecord { }), } } + + /// Does this record still hold its funding coins out of selection, as of `now_ms`? + /// + /// Two conditions, and they answer different questions. The status must be non-terminal — the + /// bundle's outcome is genuinely unsettled, so its coins may still be consumed. AND the hold + /// must not have lapsed: see [`FUNDING_RESERVATION_WINDOW_MS`] for why a status alone cannot + /// bound this, and how the window is derived. + /// + /// Measured from `updated_ms`, the instant of the LAST revision, rather than from + /// `initiated_ms`. The two differ for a spend that was resolved and then reopened, and it is the + /// last OBSERVATION that says how long the node has been waiting — restarting the clock on new + /// information is the honest reading, and it is also the one that holds longer. + /// + /// `saturating_sub` so a record dated in the future stays HELD rather than lapsing instantly. + pub fn reserves_funding_at(&self, now_ms: u64) -> bool { + !self.status.is_terminal() + && now_ms.saturating_sub(self.updated_ms) < FUNDING_RESERVATION_WINDOW_MS + } +} + +/// How long the audit record holds a spend's funding coins out of selection, measured from the last +/// thing this node OBSERVED about that spend (dig-node#471). +/// +/// # Why a bound is needed at all, when `is_terminal` already answers the question +/// +/// [`SpendStatus::is_terminal`] answers "is any further observation expected to change this", and +/// that is the right question for a spend whose outcome eventually ARRIVES. The resolver added in +/// dig-node#457 promotes only POSITIVELY — on observing the created coin — so a `Submitted` or +/// `Unresolved` spend whose coin never appears is never settled by anything, and a predicate keyed +/// on status alone therefore withholds its coins forever. A genuinely funded operator wallet then +/// reports `Insufficient` permanently. +/// +/// That is reachable with no attacker present: a hard kill between +/// [`SpendJournal::begin`] and any outcome, or a `Submitted` bundle evicted from a mempool without +/// confirming. And unlike §25.4.6's create suppression — keyed on the bond's epoch, so it self-clears +/// at the rollover — nothing here lapses on its own. +/// +/// # How the figure is DERIVED, and where the derivation stops +/// +/// Two named quantities, neither invented here: +/// +/// * **The chain-side figure is ten minutes.** `dig_wallet`'s own post-broadcast +/// `RESERVATION_TTL_MS` holds a pushed bundle's inputs for `10 * 60 * 1000` ms, and its rationale +/// is written out in that crate: Chia blocks are ~52 s apart, so ten minutes is roughly a dozen +/// chances for the spend to land — past the point where a still-unconfirmed bundle is more likely +/// dropped than pending. This module covers the SAME phase of the same lifecycle, so it takes the +/// same figure rather than inventing a second one. Two lifetimes for one phase is the +/// disagreement `CLIENT_RESERVATION_DEFAULT_TTL_MS` was written to resolve, not to repeat. +/// +/// * **This hold is re-evaluated only once per `MIRROR_ROUND_LENGTH_MS`**, which is also ten +/// minutes. A threshold equal to the poll interval aliases badly: a record could be released by +/// the very first pass at which the resolver is even ELIGIBLE to have observed its confirmation. +/// The smallest window that leaves a full round of chain observation AFTER the chain-side figure +/// has elapsed is therefore two rounds. +/// +/// **Twenty minutes, i.e. N = 2 passes.** The second round is the part that is this module's +/// judgement rather than dig-wallet's, and it is stated so nobody reads the whole figure as derived. +/// +/// # Which direction it fails in +/// +/// Releasing too EARLY re-opens dig-node#348's double-select: a second create draws a coin the first +/// bundle can still spend, and the mempool refuses one of them. Releasing too LATE strands spendable +/// money in a wallet that reports `Insufficient`. Neither is free, which is why this is a window and +/// not a flag. +/// +/// The asymmetry that makes the early direction survivable is NOT §25.4.6's suppression, and it is +/// worth stating exactly, because a stuck record is precisely the case that suppression does not +/// cover: `runner.rs` filters `Pending | Submitted` and excludes `Unresolved` deliberately, while +/// `RecordedSpend`'s `Drop` writes `Unresolved` — so a record reaching this window is ALWAYS +/// `Unresolved`, and its bond is NOT suppressed. `mirror/resolve.rs` states the same fact. +/// +/// What actually holds is the mempool rule. A released coin re-drawn by any create collides with a +/// bundle that may still be resident, and Chia's replace-by-fee requires the replacement to spend a +/// SUPERSET of the conflicting coins — which two independent mirror creates never do. So the +/// collision is a REFUSAL, not a double spend: one bundle is rejected, no funds move twice, and the +/// refused create is retried on the next pass like any other. +/// +/// This hold is also strictly MORE conservative than the ecosystem's shipped answer for the same +/// phase: `dig-wallet`'s `RESERVATION_TTL_MS` already releases the same coin at 10 minutes. +/// +/// # What this must NOT be confused with +/// +/// It is emphatically not a shortening of `RESERVATION_TTL_MS`, which the wallet's own docs record +/// as trading a double-select for a LOCKOUT — the strictly worse failure, and the one dig-node#471 +/// is an instance of arriving by another route. +pub const FUNDING_RESERVATION_WINDOW_MS: u64 = 2 * dig_constants::MIRROR_ROUND_LENGTH_MS as u64; + +/// The audit record could not be read, so which coins are already committed is UNKNOWN. +/// +/// Its own type rather than an `io::Error` because the two conditions it covers are different and +/// both are refusals: the file could not be read at all, and the file was read but LOST LINES. A +/// reservation set that silently shrinks is worse than none — the lost lines may be exactly the ones +/// naming a committed coin — so a partial read is an error here and never a shorter answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitmentsUnreadable(pub String); + +impl fmt::Display for CommitmentsUnreadable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let CommitmentsUnreadable(detail) = self; + write!( + f, + "the spend audit record is unreadable ({detail}), so which coins are already committed \ + to an in-flight bundle is unknown; no coin is selected" + ) + } +} + +impl std::error::Error for CommitmentsUnreadable {} + +/// The coins this node has committed to a bundle whose outcome is not yet settled AND whose hold +/// has not yet lapsed (dig-node#421, bounded by dig-node#471). +/// +/// Read from the audit record rather than a side table, because the audit record survives a restart +/// and the window this guards is measured in confirmation times, which outlast a process. +/// +/// # The hold is a TIME BOX, not a status +/// +/// A record whose hold lapses is not rewritten, not settled, and not failed. It stays exactly the +/// `Submitted` or `Unresolved` it was, and stays chaseable by +/// [`SpendJournal::resolve_landed`] and [`reconcile`] indefinitely — `Unresolved` means "this node +/// signed and does not know what happened", and that remains true after the coins are released. +/// Writing a fabricated failure to tidy the bookkeeping would be the money lie `Confirmed`'s shape, +/// which carries its height and coin id INSIDE the variant, exists to make inexpressible. +/// +/// # `now_ms` is a parameter, deliberately +/// +/// One pass takes ONE reading of the clock, in the same way it takes one reading of the disk, the +/// balance and the chain. It is also what lets a test pin fixture time explicitly instead of passing +/// a small number through a wall-clock API and silently exercising only the already-lapsed path. +/// +/// A record dated in the FUTURE — clock skew, or a file written by a machine ahead of this one — +/// yields a saturated elapsed time of zero and stays held. That is the closed direction. +pub fn committed_funding_coin_ids( + log: &SpendLog, + now_ms: u64, +) -> Result, CommitmentsUnreadable> { + let ledger = log + .ledger() + .map_err(|e| CommitmentsUnreadable(e.to_string()))?; + if ledger.unreadable_lines > 0 { + return Err(CommitmentsUnreadable(format!( + "{} entries could not be parsed", + ledger.unreadable_lines + ))); + } + Ok(ledger + .records + .iter() + .filter(|r| r.reserves_funding_at(now_ms)) + .flat_map(|r| r.funding_coin_ids.iter().map(|c| c.0.clone())) + .collect()) } /// Filters over the record. Every field is an AND; an unset field constrains nothing. @@ -1982,4 +2133,194 @@ mod tests { assert_eq!(page.unreadable_lines, 2); assert!(!page.complete); } + + /// **A stuck spend's funding coins are released once its hold lapses — and its record is not + /// touched** (dig-node#471). + /// + /// The fixture varies ONE thing: the OBSERVER's clock. Fixture time is pinned at `NOW` through + /// `with_clock`, so both records are written at exactly the same instant and every difference + /// below is the elapsed time being asked about. Passing a small number through a wall-clock API + /// is how a test group asserts the establishment path while exercising only the expired one. + /// + /// The control is deliberate and it is a SECOND submitted record, not a confirmed one. A + /// confirmed control would be released by the pre-#471 predicate too, so a fix that released + /// EVERYTHING immediately would pass. Two records in the same status, distinguished only by how + /// long ago they were observed, cannot be satisfied that way. + #[test] + fn a_stuck_spend_releases_its_funding_coins_once_the_hold_lapses() { + let log = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + + let stuck = journal.begin(intent()); + journal.submitted( + &stuck, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("11".repeat(32))], + }, + ); + std::mem::forget(stuck); + + let inside = journal.begin(intent()); + journal.submitted( + &inside, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("22".repeat(32))], + }, + ); + std::mem::forget(inside); + + // One millisecond before the window closes, BOTH are held. This is the half most fixes get + // wrong: without it, a fix that releases every coin unconditionally passes the release + // assertion below and nothing notices. + let at_bound = committed_funding_coin_ids(&log, NOW + FUNDING_RESERVATION_WINDOW_MS - 1) + .expect("readable"); + assert!( + at_bound.contains(&"11".repeat(32)) && at_bound.contains(&"22".repeat(32)), + "a coin committed to a spend still inside the confirmation window must NOT be \ + released; releasing it re-opens the double-select dig-node#348 closed" + ); + + // One millisecond after, both lapse. Two passes at MIRROR_ROUND_LENGTH_MS: N = 2. + let lapsed = committed_funding_coin_ids(&log, NOW + FUNDING_RESERVATION_WINDOW_MS) + .expect("readable"); + assert!( + lapsed.is_empty(), + "a spend that never lands must not withhold its funding coins forever; got {lapsed:?}" + ); + + // AND the records are untouched. Releasing the coins is not declaring the spend failed: + // `Unresolved` means "this node signed and does not know what happened", which stays true. + let ledger = log.ledger().expect("readable"); + assert_eq!(ledger.records.len(), 2); + assert!( + ledger + .records + .iter() + .all(|r| r.status == SpendStatus::Submitted), + "the hold lapsed; nothing may have written an outcome this node never observed" + ); + assert!( + ledger + .records + .iter() + .all(|r| r.status.may_have_reached_the_network()), + "a released record stays chaseable by resolve_landed and reconcile" + ); + } + + /// **A record dated in the FUTURE stays held.** + /// + /// Clock skew, or an audit file written by a machine ahead of this one. `saturating_sub` makes + /// the elapsed time zero rather than wrapping to ~584 million years, which would read as + /// long-lapsed and release a coin that was committed moments ago. The closed direction. + #[test] + fn a_record_dated_in_the_future_keeps_its_hold() { + let log = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + + let spend = journal.begin(intent()); + journal.submitted( + &spend, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("33".repeat(32))], + }, + ); + std::mem::forget(spend); + + let committed = committed_funding_coin_ids(&log, NOW - 1).expect("readable"); + assert!( + committed.contains(&"33".repeat(32)), + "a record from the future is not a lapsed record" + ); + } + + /// **A terminal record releases immediately; the window never EXTENDS a hold.** + /// + /// The window bounds a hold from above. It must not become a second reason to withhold a coin + /// that `is_terminal` already released — a `Confirmed` spend's coins are spent on chain, and a + /// `Failed { stage: Signing }` spend never moved money. + #[test] + fn the_window_never_extends_a_hold_a_terminal_status_already_released() { + let log = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + + let confirmed = journal.begin(intent()); + journal.submitted( + &confirmed, + Submission { + intended_coin_id: Some(TargetCoinId("aa".repeat(32))), + funding_coin_ids: vec![FundingCoinId("11".repeat(32))], + }, + ); + journal.confirmed(&confirmed, TargetCoinId("aa".repeat(32)), 100); + + let never_signed = journal.begin(intent()); + journal.submitted( + &never_signed, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("22".repeat(32))], + }, + ); + journal.failed(&never_signed, FailureStage::Signing, "no key"); + + // The control: an open record written at the same instant, still inside its window. Without + // it, an implementation that released everything at `NOW` would satisfy the two assertions + // above and look correct. + let open = journal.begin(intent()); + journal.submitted( + &open, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("33".repeat(32))], + }, + ); + std::mem::forget(open); + + let committed = committed_funding_coin_ids(&log, NOW).expect("readable"); + assert_eq!( + committed, + HashSet::from(["33".repeat(32)]), + "only the open, unlapsed record withholds anything" + ); + } + + /// **A lost line refuses the whole answer, and the window does not change that.** + /// + /// The lost lines may be exactly the ones naming a committed coin, so a reservation set that + /// silently shrinks is worse than none. The fixture keeps a readable, held record beside the + /// corruption: without it, an implementation that returned an empty set on corruption would be + /// indistinguishable from one that refused. + #[test] + fn a_lost_line_refuses_the_committed_set() { + let log = tmp_log(); + let journal = SpendJournal::with_clock(log.clone(), clock); + + let spend = journal.begin(intent()); + journal.submitted( + &spend, + Submission { + intended_coin_id: None, + funding_coin_ids: vec![FundingCoinId("44".repeat(32))], + }, + ); + std::mem::forget(spend); + + { + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(log.path()) + .expect("the log"); + writeln!(f, "not-json").expect("append"); + } + + let refused = committed_funding_coin_ids(&log, NOW).expect_err("a lost line refuses"); + assert_eq!( + refused, + CommitmentsUnreadable("1 entries could not be parsed".to_string()) + ); + } } diff --git a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs new file mode 100644 index 00000000..d624e62a --- /dev/null +++ b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs @@ -0,0 +1,284 @@ +//! **A funding coin committed to a spend that never lands returns to the SELECTABLE set** +//! (dig-node#471). +//! +//! # Why this probe exists beside the unit tests +//! +//! `spend_audit`'s own tests assert what the committed SET contains. That is one layer below the +//! property a person experiences, and the two can disagree: a coin can be absent from the committed +//! set and still be unselectable, and the failure this ticket describes is not "a set has an extra +//! string in it" — it is a genuinely funded operator wallet reporting `Insufficient` forever. +//! +//! So every assertion below runs the real selector, +//! [`select_operator_dig_cats`], over a chain holding real $DIG, and asks the only question that +//! matters: **does a coin come back.** +//! +//! # The fixture varies ONE thing, and it is not the coin +//! +//! Both probes publish the SAME chain and the SAME audit record. The only thing that differs is the +//! instant the committed set is computed at. A fixture that varied the coins, or the statuses, could +//! be satisfied by an implementation that released everything unconditionally — which is the nearest +//! wrong fix to this defect, and the one a release-only test cannot see. +//! +//! # Fixture time is PINNED +//! +//! The journal is driven by an injected clock fixed at [`NOW`], never the wall clock. A record +//! written "now" and queried against a small literal is already expired by ~1.8 billion seconds, so +//! it would assert the release path while never exercising the hold. + +mod support; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; + +use chia_protocol::{Bytes32, CoinSpend}; +use chia_sha2::Sha256; +use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; +use dig_node_service::mirror::funding::select_operator_dig_cats; +use dig_node_service::spend_audit::{ + committed_funding_coin_ids, kinds, Asset, Authority, FundingCoinId, SpendIntent, SpendJournal, + SpendKind, SpendLog, SpendStatus, Submission, FUNDING_RESERVATION_WINDOW_MS, +}; +use support::{ordinary_dig_coins, wallet, Wallet}; + +/// The margined requirement a create is funded for, in $DIG **base units** (1 DIG = 1_000). +const REQUIRED: u64 = 40_000; + +/// A pinned instant. Every audit revision below is written at exactly this millisecond, so the only +/// variable in the probes is the instant the committed set is read at. +const NOW: u64 = 1_767_225_600_000; + +fn clock() -> u64 { + NOW +} + +/// A fixture discriminator, DERIVED rather than spelled as a byte literal — a literal here reads to +/// CodeQL as a hard-coded cryptographic value used as a salt (dig-node#917, #950 are the same false +/// positive). Deterministic, so a failing fixture reproduces. +fn salt(step: u8) -> u8 { + let mut hasher = Sha256::new(); + hasher.update(b"dig-node mirror_funding_reservation_expiry fixture"); + hasher.finalize()[0].wrapping_add(step) +} + +/// A chain holding whatever the test put on it — and nothing else. +/// +/// Every coin comes from a genuine CAT spend, because 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 selection against a fixture that cannot exhibit it. +#[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. + fn fund(&mut self, owner: &Wallet, amounts: &[u64], salt: u8) -> Vec { + let (spend, coins) = ordinary_dig_coins(owner, amounts, salt); + self.spends.insert(spend.coin.coin_id(), spend); + let mut ids = Vec::new(); + for coin in coins { + // A coin id is `(parent, puzzle_hash, amount)`, so two children of ONE spend paying the + // SAME amount to the SAME address are literally one coin. A fixture that thinks it + // published two has published one, which silently defeats every per-coin assertion. + 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 + } +} + +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 log at a path no other probe in this binary can reach. +/// +/// The counter is not decoration. Integration tests in one binary run on parallel THREADS, so two +/// probes deriving the same path append to one file -- and the failure that produces is a ledger +/// with a foreign record in it, which reads as the code under test having written something it +/// never wrote. Measured here on the first run. +fn tmp_log(name: &str) -> SpendLog { + static SEQ: AtomicU64 = AtomicU64::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "dig-node-471-{}-{NOW}-{name}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("a temp dir"); + SpendLog::at(dir.join("spend-audit.jsonl")) +} + +fn intent() -> SpendIntent { + SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "advertise that this node holds the store".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "settings.autoMirror".to_string(), + }, + asset: Asset::Dig, + amount_mojos: REQUIRED, + fee_mojos: 0, + store_id: Some("store-a".to_string()), + bond: None, + } +} + +/// One operator wallet whose ENTIRE $DIG holding is committed to a spend that was submitted at +/// [`NOW`] and never landed, plus the log that records it. +/// +/// The whole holding, deliberately: a wallet with an uncommitted coin to spare could satisfy the +/// selector from that coin and would report success while the committed coin stayed stranded +/// forever. Committing everything is what makes `Insufficient` the observable. +fn wedged_wallet() -> (Chain, Wallet, SpendLog) { + let operator = wallet(0x21); + let mut chain = Chain::default(); + let coins = chain.fund(&operator, &[REQUIRED], salt(1)); + + let log = tmp_log("wedged"); + let journal = SpendJournal::with_clock(log.clone(), clock); + let spend = journal.begin(intent()); + journal.submitted( + &spend, + Submission { + // `None`, which is the create path's real shape: a mirror create's output coin takes + // its parent from whichever input the builder drew from, so this node cannot derive it. + intended_coin_id: None, + funding_coin_ids: coins + .iter() + .map(|c| FundingCoinId(hex::encode(c))) + .collect(), + }, + ); + // The handle is leaked rather than dropped, because `Drop` would append an `Unresolved` + // revision at the CURRENT wall-clock instant and un-pin the fixture's time. The record under + // test is the `Submitted` one written at `NOW`. + std::mem::forget(spend); + + (chain, operator, log) +} + +/// **A coin committed to a spend still INSIDE the confirmation window is NOT released.** +/// +/// This is the half a release-only probe cannot see. A fix that dropped the reservation entirely, +/// or released every non-terminal record immediately, passes the expiry probe below and fails here +/// — and it would re-open the double-select dig-node#348 exists to close, because the original +/// bundle can still be included. +/// +/// One millisecond before the bound rather than at some comfortable midpoint: a bound tested only +/// from well inside it can only confirm itself. +#[test] +fn a_coin_committed_to_a_spend_still_in_flight_is_not_selectable() { + let (chain, operator, log) = wedged_wallet(); + + let committed = committed_funding_coin_ids(&log, NOW + FUNDING_RESERVATION_WINDOW_MS - 1) + .expect("the audit record is readable"); + + let refusal = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &committed) + .expect_err("the wallet's only $DIG is committed to a bundle that may still be included"); + + assert!( + matches!( + refusal, + dig_node_service::mirror::funding::FundingError::Insufficient { .. } + ), + "an in-flight commitment withholds the coin, so the create is refused: {refusal:?}" + ); +} + +/// **The same coin IS selectable once the hold lapses — N = 2 mirror passes.** +/// +/// `FUNDING_RESERVATION_WINDOW_MS` is `2 * MIRROR_ROUND_LENGTH_MS`, and the mirror pass runs every +/// `MIRROR_ROUND_LENGTH_MS`, so the coin returns on the SECOND pass after the last observation. +/// +/// The chain, the wallet and the audit record are byte-for-byte the ones the probe above refused. +/// Only the instant differs, so nothing but the elapsed time can explain the difference. +#[test] +fn a_coin_committed_to_a_spend_that_never_lands_is_selectable_two_passes_later() { + let (chain, operator, log) = wedged_wallet(); + + let n_passes = FUNDING_RESERVATION_WINDOW_MS / dig_constants::MIRROR_ROUND_LENGTH_MS as u64; + assert_eq!( + n_passes, 2, + "N is two mirror passes; state it, do not imply it" + ); + + let committed = committed_funding_coin_ids( + &log, + NOW + n_passes * dig_constants::MIRROR_ROUND_LENGTH_MS as u64, + ) + .expect("the audit record is readable"); + + let cats = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &committed) + .expect("the hold has lapsed, so the operator's genuine $DIG is selectable again"); + assert_eq!( + cats.len(), + 1, + "the wallet was funded all along; it must stop reporting Insufficient" + ); + + // The record is NOT rewritten. Releasing the coins is not declaring the spend failed: + // `Unresolved`/`Submitted` mean "this node signed and does not know what happened", and that + // stays true after the coins are released. A fabricated outcome here would be the money lie + // `Confirmed`'s shape — height and coin id INSIDE the variant — exists to prevent. + let ledger = log.ledger().expect("readable"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].status, SpendStatus::Submitted); + assert!( + ledger.records[0].status.may_have_reached_the_network(), + "a released record stays chaseable by resolve_landed and reconcile" + ); +}