From b4ff363ad091631bb059035c454e5858c6d17fc9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 06:54:16 -0700 Subject: [PATCH 1/9] chore(mirror): open the mirror-coin batch branch --- crates/dig-node-service/src/mirror/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 8fe6cfca..a013ed4c 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -88,3 +88,7 @@ pub mod runner; pub mod signer; pub mod spends; pub mod states; + +// WIP (loop/batch-mirror): resolving landed mirror spends (#412), feeding the DHT collateral +// pointer (#435), the Disabled-bondability question (#429) and bounding mirror funding inputs +// (#427). This marker is removed by the first real commit of the batch. From 110803b967fe878ebf5b401d775fe95eb937ad81 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:18:44 -0700 Subject: [PATCH 2/9] feat(mirror): resolver, DHT pointer source, funding bound, bondable contract --- crates/dig-node-core/src/lib.rs | 55 +++ crates/dig-node-core/src/peer.rs | 13 +- crates/dig-node-service/src/control.rs | 90 ++++- crates/dig-node-service/src/mirror/funding.rs | 55 +++ .../dig-node-service/src/mirror/lifecycle.rs | 25 ++ crates/dig-node-service/src/mirror/mod.rs | 7 +- .../dig-node-service/src/mirror/pointers.rs | 301 +++++++++++++++ crates/dig-node-service/src/mirror/resolve.rs | 206 +++++++++++ .../src/mirror/resolve_tests.rs | 345 ++++++++++++++++++ crates/dig-node-service/src/mirror/runner.rs | 53 +++ crates/dig-node-service/src/server.rs | 15 + crates/dig-node-service/src/spend_audit.rs | 85 +++++ 12 files changed, 1243 insertions(+), 7 deletions(-) create mode 100644 crates/dig-node-service/src/mirror/pointers.rs create mode 100644 crates/dig-node-service/src/mirror/resolve.rs create mode 100644 crates/dig-node-service/src/mirror/resolve_tests.rs diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 6db96031..a831b1b9 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -95,6 +95,13 @@ pub use seams::content::{bandwidth, verification_ledger, ContentServer}; pub use seams::dig_peer::{ address_book, bootstrap, dht, net, pex, session, HolderClaim, PeerNetwork, }; +/// `dig-dht` itself, re-exported so a consumer implementing [`dht::MirrorCoinPointers`] names +/// `ContentId` through THIS crate rather than declaring its own `dig-dht` dependency. +/// +/// A second declaration is a second version constraint, and a consumer that resolved a different +/// `dig-dht` minor would be handed a `ContentId` that is a different type with the same name — the +/// split-line failure §2.4b exists to prevent, arriving through a trait nobody would think to check. +pub use dig_dht; /// The `RpcDispatch` trait is seam 4's public surface (#1285 W1b-5) — the crate-root /// `handle_rpc`/`handle_rpc_json` free functions delegate to it; most callers keep using those /// stable entry points and never need this trait in scope directly. @@ -441,6 +448,19 @@ pub struct Node { /// on the FFI/consumer path (no peer network, no inbound peer demand), where the gate reads `None` /// and fails CLOSED (no peer-driven pull without a known identity to anchor the neighbourhood to). node_peer_id: OnceLock<[u8; 32]>, + /// This node's UNTRUSTED mirror-coin pointer source (dig-node#422/#435), attached to every DHT + /// announce so a verifier is told WHERE TO LOOK instead of scanning the shared mirror puzzle + /// hash. + /// + /// Installed by the service shell before [`peer::spawn_peer_network`], because the mirror + /// lifecycle that knows which coin bonds which capsule lives in `dig-node-service` and the DHT + /// lives here. A slot rather than a constructor argument for the same reason + /// [`Node::node_peer_id`] is one: the FFI/browser path has no mirror lifecycle and no peer + /// network, and must keep constructing a `Node` without either. + /// + /// `None` is an ORDINARY configuration, never a degraded one — a node with no pointer source + /// announces exactly as it always did, and the verifier's fallback is the hint scan. + mirror_pointers: OnceLock>, } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4515,6 +4535,7 @@ impl Node { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }) } @@ -4662,6 +4683,27 @@ impl Node { pub(crate) fn set_node_peer_id(&self, peer_id: [u8; 32]) { let _ = self.node_peer_id.set(peer_id); } + + /// Install the untrusted mirror-coin pointer source every DHT announce attaches + /// (dig-node#435). Called by the service shell BEFORE the peer network is spawned; a later call + /// is ignored, so the source a running node publishes from cannot be swapped underneath it. + /// + /// Installing one is optional. What it must never do is fail an announce: the pointer is a hint + /// about where to look, and a node that could not produce one still holds and serves its + /// content. + pub fn set_mirror_coin_pointers( + &self, + pointers: std::sync::Arc, + ) { + let _ = self.mirror_pointers.set(pointers); + } + + /// The installed pointer source, if any. + pub(crate) fn mirror_coin_pointers( + &self, + ) -> Option> { + self.mirror_pointers.get().cloned() + } } /// The COMPOSITION-ROOT upcasts (#1285 W1c — the locked "Option A" shape). `Node` stays ONE @@ -4806,6 +4848,7 @@ pub(crate) mod test_support { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }; (Arc::new(node), td) } @@ -5594,6 +5637,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }; (node, td) } @@ -5726,6 +5770,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }; // Missing before the pull. @@ -5792,6 +5837,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -5883,6 +5929,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -5956,6 +6003,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -8631,6 +8679,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), }; let before = handle_rpc( @@ -15486,6 +15535,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), ..node }; // A holder for this EXACT content is known via the DHT. @@ -15536,6 +15586,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -15585,6 +15636,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), ..node }; @@ -15616,6 +15668,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -15656,6 +15709,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -15698,6 +15752,7 @@ mod tests { chat: chat::ChatState::new(), inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), + mirror_pointers: OnceLock::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 6aae3d4b..9ed0189c 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -3067,7 +3067,18 @@ async fn bring_up_dht( initial_ids.len() ); - let dht = crate::dht::DhtHandle::new(service, initial_ids); + // The untrusted mirror-coin pointer source (dig-node#435), when the service shell installed + // one. Until it did, `DhtHandle::new` hard-coded `None` here and EVERY live announce published + // `unverified_mirror_coin_id = None` — the whole collateral-pointer mechanism was built, unit + // tested, and fed by nothing but a test double. + // + // `None` remains fully supported: the FFI path and any node without a mirror lifecycle announce + // exactly as before, and a verifier falls back to the hint scan. + let dht = crate::dht::DhtHandle::with_mirror_pointers( + service, + initial_ids, + node.mirror_coin_pointers(), + ); // The real-time holdings layer (#1429): flood a signed opcode-222 announcement whenever this // node's inventory changes, and fold every peer's verified announcement into our provider set. diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index b718b904..8c213498 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -3588,6 +3588,29 @@ fn collateral_requirement(id: Value) -> Value { /// `Withheld` is the one row that describes a capsule relayed on a stranger's behalf, deliberately /// never advertised and therefore never bonded, so it locks nothing. /// +/// # `Disabled` IS counted, and `Reclaiming` is too (dig-node#429) +/// +/// Both look excludable and neither is, for the same reason: this is FORWARD-LOOKING advice about +/// $DIG the operator must keep available, not a report of $DIG currently locked. +/// +/// `Withheld` is a property of the CAPSULE - held on a stranger's behalf - and no setting makes a +/// relayed capsule bondable, so its pair will never consume collateral and advising for it is +/// advising for a spend that cannot occur. +/// +/// `Disabled` is a node-wide SWITCH (SPEC 25.7), and every row reads `Disabled` while it is off. +/// Were it excluded, the buffer advice would fall to zero for the whole node the moment +/// collateralisation is switched off and leap back the moment it is switched on - telling an +/// operator who is about to enable it that they need nothing, and stranding them short on the very +/// next pass. Under-stating money the operator must hold is the reassuring direction, and it is the +/// one direction a figure like this must never be wrong in. +/// +/// `Reclaiming` is money in motion whose capsule is still served: at an epoch rollover the coin +/// comes home and the SAME pair is bonded again next epoch, so the buffer it needs is unchanged. +/// +/// So the exclusion is not "states where no coin exists right now" - `Unfunded` and `Deferred` have +/// no coin either and are plainly counted. It is "pairs this node will never bond", which today is +/// exactly `Withheld`. +/// /// Named and separated from [`collateral_buffer`] so the distinction is testable without a state /// directory: the caller feeds this into an amount of money, and a count that quietly includes rows /// locking nothing is a wrong figure on a money surface rather than a wrong figure about rows. @@ -3929,6 +3952,22 @@ mod tests { amount_dig_base_units: 1_000, }, ), + // dig-node#429: switched off node-wide, and STILL bondable. A switch is not a + // property of the capsule; flip it and this pair locks collateral on the next pass. + ( + Bond::new("ff".repeat(32), "55".repeat(32)), + BondState::Disabled, + ), + // Money in motion on a pair that is still served: the coin comes home at rollover + // and the same pair is bonded again, so the buffer it needs is unchanged. + ( + Bond::new("ab".repeat(32), "66".repeat(32)), + BondState::Reclaiming { + coin_id: "cd".repeat(32), + epoch: 4, + amount_dig_base_units: 1_000, + }, + ), ], locked_dig_base_units: 1_000, epoch: 4, @@ -3936,14 +3975,59 @@ mod tests { assert_eq!( observation.states.len(), - 4, + 6, "the fixture must carry a row the answer EXCLUDES, or it cannot tell the contract from \ a plain row count" ); assert_eq!( bondable_pairs(&observation), - 3, - "a `Withheld` row locks nothing and is not bondable; the other three are" + 5, + "only `Withheld` is excluded: `Disabled` is a reversible node-wide switch and `Reclaiming` is a served pair whose coin is coming home, and both bond again next pass" + ); + } + + /// **Proves:** dig-node#429's contract question from the side that fails dangerously - a node + /// with collateralisation switched OFF still advises the buffer its pairs will need. + /// + /// **Catches:** excluding `Disabled` alongside `Withheld`, which is the plausible reading of + /// the exclusion's own doc and the one this ticket was opened to settle. The mixed fixture + /// above does detect that mistake as a count, but it cannot show the CONSEQUENCE, which is why + /// this case exists separately: when the switch is off, EVERY row is `Disabled`, so excluding + /// the state answers **zero** and tells a funded operator who is about to re-enable that they + /// need no $DIG at all. + /// + /// A `Withheld` row rides along as the control. Without it this fixture would also pass under + /// "count every row", which is not the contract either. + #[test] + fn a_node_with_collateralisation_switched_off_still_advises_for_the_pairs_it_will_bond() { + use crate::mirror::pass::BondState; + use crate::mirror::plan::Bond; + use crate::mirror::states::BondObservation; + + let observation = BondObservation { + states: vec![ + ( + Bond::new("aa".repeat(32), "11".repeat(32)), + BondState::Disabled, + ), + ( + Bond::new("bb".repeat(32), "22".repeat(32)), + BondState::Disabled, + ), + // The control: a relayed capsule is not bondable whatever the switch says. + ( + Bond::new("cc".repeat(32), "33".repeat(32)), + BondState::Withheld, + ), + ], + locked_dig_base_units: 0, + epoch: 4, + }; + + assert_eq!( + bondable_pairs(&observation), + 2, + "switching collateralisation off must not advise a zero buffer: the switch is reversible and these pairs lock $DIG on the pass after it is switched back on" ); } diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 2b4c9acf..144a9097 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -78,6 +78,19 @@ pub enum FundingError { /// 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), + /// Covering the create needs more inputs than a single bundle may draw + /// ([`MAX_SELECTED_FUNDING_COINS`]). + /// + /// Its own variant rather than an `Insufficient`, because the two send an operator to opposite + /// places: `Insufficient` says *find more $DIG*, and this says *you have the money, it is in + /// too many pieces*. Telling a funded operator they are short is the money-lie class this + /// module is built to avoid. + TooManyInputs { + /// How many coins largest-first selection needed to reach the target. + needed: usize, + /// The bound. + limit: usize, + }, /// A create was asked for at zero collateral. /// /// Refused HERE, ahead of the builder, because zero is the one target for which selection @@ -110,6 +123,10 @@ impl std::fmt::Display for FundingError { "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::TooManyInputs { needed, limit } => write!( + f, + "covering this create needs {needed} $DIG coins and a mirror create may draw at most {limit}; the wallet is not short, its $DIG is in too many pieces. No coin was authenticated and no spend was attempted; consolidating the operator's $DIG into fewer coins clears it" + ), FundingError::ZeroCollateral => { f.write_str("a create at zero collateral stakes nothing and is refused") } @@ -117,6 +134,37 @@ impl std::fmt::Display for FundingError { } } +/// The most $DIG coins one mirror create may draw as inputs (dig-node#427). +/// +/// # Why a bound is REQUIRED here specifically +/// +/// The address selection scans — [`dig_cat_puzzle_hash`] of the operator hash — is **publicly +/// derivable**: the operator puzzle hash is a public value and the CAT curry is canonical, so any +/// stranger can compute where this node's $DIG lives and pay dust to it. Every input that survives +/// selection then costs one `coin_spend` chain read in [`authenticate`], because a candidate's +/// lineage is executed from the spend that created it. Unbounded, that makes the number of chain +/// reads one automated pass performs a function of what an attacker chose to send — a cost this +/// node pays, on a timer, forever. +/// +/// Largest-first selection is not itself the defence. It is the reason the bound is rarely reached +/// on a healthy wallet, but a wallet whose genuine $DIG has been ground into dust reaches it for +/// entirely innocent reasons, and an attacker who out-values the honest coins reaches it on +/// purpose. +/// +/// # Which direction it fails in, stated deliberately +/// +/// It fails **CLOSED**: the create is refused, the bond is not collateralised this pass, and +/// [`FundingError::TooManyInputs`] names the reason and the remedy. That is recoverable — the next +/// pass retries, and consolidating the operator's coins fixes it permanently. Failing open would +/// mean a stranger can make this node perform thousands of chain reads per pass at the price of +/// dust, which is not recoverable by anything the operator can do. +/// +/// The refusal happens AFTER selection and BEFORE authentication, which is the only placement that +/// achieves the point: selection is in-memory over rows already fetched, so it is free, while +/// authentication is the per-input chain read being bounded. Bounding the CANDIDATE set instead +/// would refuse a perfectly fundable create because a stranger sent dust this node never selected. +pub const MAX_SELECTED_FUNDING_COINS: usize = 32; + /// 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 @@ -212,6 +260,13 @@ pub fn select_operator_dig_cats( need_dig_base_units, })?; + if selected.len() > MAX_SELECTED_FUNDING_COINS { + return Err(FundingError::TooManyInputs { + needed: selected.len(), + limit: MAX_SELECTED_FUNDING_COINS, + }); + } + let mut cats = Vec::with_capacity(selected.len()); for record in &selected { cats.push(authenticate(source, record, owner_puzzle_hash)?); diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index eff798ac..becdfe08 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -360,6 +360,31 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { Ok(held_mirrors(&inventory)) } + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError> { + // A malformed id is this node's own bookkeeping being wrong, not the chain being + // unreachable, so it is NOT an `Err`: reporting it as one would count a permanent local + // defect as a transient outage and hide it behind a retry forever. + let Ok(id) = parse_id(coin_id, "coin id") else { + tracing::error!( + target: "mirror", + coin_id = %coin_id, + "an audit record names a coin id this node cannot parse; it is not resolved" + ); + return Ok(None); + }; + + // `Err` stays `Err`: the source failing to answer is not evidence about the coin. + let record = self + .source + .coin_record(id) + .map_err(|e| PassError::Chain(e.to_string()))?; + + // `confirmed_height` is itself optional — a coin the source knows about but has not seen in + // a block yet. That is not a confirmation, and it folds into the same `None` as absence + // because both call for exactly one action: wait for the next pass. + Ok(record.and_then(|r| r.confirmed_height)) + } + fn dig_balance_base_units(&self) -> Result { self.dig_balance.clone() } diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index a013ed4c..bb37bb72 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -83,12 +83,13 @@ pub mod lifecycle; pub mod observe; pub mod pass; pub mod plan; +pub mod pointers; pub mod presence; +pub(crate) mod resolve; +#[cfg(test)] +mod resolve_tests; pub mod runner; pub mod signer; pub mod spends; pub mod states; -// WIP (loop/batch-mirror): resolving landed mirror spends (#412), feeding the DHT collateral -// pointer (#435), the Disabled-bondability question (#429) and bounding mirror funding inputs -// (#427). This marker is removed by the first real commit of the batch. diff --git a/crates/dig-node-service/src/mirror/pointers.rs b/crates/dig-node-service/src/mirror/pointers.rs new file mode 100644 index 00000000..34d58b4a --- /dev/null +++ b/crates/dig-node-service/src/mirror/pointers.rs @@ -0,0 +1,301 @@ +//! The production [`MirrorCoinPointers`] source (dig-node#435, epic #422): what this node CLAIMS +//! bonds each capsule it announces. +//! +//! # The gap this closes +//! +//! `dig-node-core` built the whole pointer mechanism — the trait, the attach on +//! `announce_provider_with_collateral`, the `with_mirror_pointers` constructor, and the epoch +//! rollover re-announce — and the only implementation anywhere was a test double under +//! `#[cfg(test)]`. Production called `DhtHandle::new`, which hard-codes `None`, so **every live +//! announce published `unverified_mirror_coin_id = None`** and the rollover re-announce returned `0` +//! on its first line every tick. The mechanism was complete and fed by nothing. +//! +//! # The claim is UNTRUSTED, and this type cannot make it trusted +//! +//! Publishing a coin id tells a verifier WHERE TO LOOK — one coin to fetch instead of a scan of the +//! mirror puzzle hash, which every node's coins share — and never WHAT THE COIN IS. A verifier +//! accepts a coin on the coin's OWN evidence, and nothing published here enters that judgement +//! (NC-12). So the worst a wrong pointer can do is cost a lookup, and that is the property that +//! makes reading it from a cached observation acceptable at all. +//! +//! # It reads the published observation, not the chain +//! +//! The answer comes from [`BondSnapshot`] — the observation the last mirror pass published, the +//! SAME one `control.mirror.bondStates` serves — rather than from a `dig_mirror_coin::list` of its +//! own. Two reasons, and the second is the load-bearing one: +//! +//! 1. An announce is on the DHT's timer, and `list` is a scan of a puzzle hash anyone may add to. +//! Performing it per announce would make discovery cost a chain scan per content id. +//! 2. A second read is a second ANSWER. A pointer derived independently could name a different coin +//! from the one §25.8's surface reports for the same bond, and an operator comparing the two +//! would be looking at a disagreement this node manufactured. +//! +//! The snapshot already carries exactly what is needed: `BondState::Bonded` holds the coin id, and +//! it is produced only from a coin the chain observation actually resolved. +//! +//! # Only a CURRENT-epoch `Bonded` row yields a pointer +//! +//! A mirror coin bonds `(store, root, owner, epoch)`, and **dig-dht has no clock** — `republish` +//! re-attaches whatever pointer was recorded at announce time. A row bonded under a previous epoch +//! therefore names a coin that no longer advertises anything, and publishing it would make a +//! correctly-collateralised node read as uncollateralised. Every other state — `Pending`, +//! `Reclaiming`, `Unfunded`, `Withheld`, … — has no coin that is bonding this capsule right now, and +//! answering `None` for them is the honest and fully supported case. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use dig_node_core::dht::MirrorCoinPointers; +use dig_node_core::dig_dht; + +use super::lifecycle::BondSnapshot; +use super::pass::BondState; + +/// The epoch reported before any pass has published an observation. +/// +/// `u64::MAX` rather than `0`, because `0` is a real epoch number and colliding with it would make +/// the rollover comparison miss a genuine rollover exactly once. A sentinel no real epoch can take +/// makes the first pointer-bearing observation always look like a change, which is what it is. +const EPOCH_UNKNOWN: u64 = u64::MAX; + +/// This node's mirror-coin pointers, read from the last published bond observation. +#[derive(Debug)] +pub struct SnapshotMirrorPointers { + snapshot: BondSnapshot, + /// The last epoch successfully READ, so a momentarily unreadable snapshot reports the epoch it + /// last knew rather than an "unknown" that would trigger a pointless full re-announce. + last_known_epoch: AtomicU64, +} + +impl SnapshotMirrorPointers { + /// Read pointers from the observation `snapshot` publishes. + pub fn new(snapshot: BondSnapshot) -> Self { + Self { + snapshot, + last_known_epoch: AtomicU64::new(EPOCH_UNKNOWN), + } + } +} + +impl MirrorCoinPointers for SnapshotMirrorPointers { + fn epoch(&self) -> u64 { + let read = self + .snapshot + .read() + .ok() + .and_then(|slot| slot.as_ref().map(|o| o.epoch)) + // A negative epoch is not an epoch. Reported as unknown rather than coerced, because + // coercing it to `0` would silently claim the first epoch. + .and_then(|epoch| u64::try_from(epoch).ok()); + + match read { + Some(epoch) => { + self.last_known_epoch.store(epoch, Ordering::Relaxed); + epoch + } + None => self.last_known_epoch.load(Ordering::Relaxed), + } + } + + fn coin_id_for(&self, content: &dig_dht::ContentId) -> Option<[u8; 32]> { + let slot = self.snapshot.read().ok()?; + let observation = slot.as_ref()?; + + observation.states.iter().find_map(|(bond, state)| { + let BondState::Bonded { coin_id, epoch, .. } = state else { + return None; // no coin is bonding this capsule right now + }; + if *epoch != observation.epoch { + return None; // a previous epoch's coin advertises nothing today + } + let (Some(store), Some(root)) = (hex32(&bond.store_id), hex32(&bond.root)) else { + return None; + }; + if dig_dht::ContentId::capsule(store, root) != *content { + return None; + } + hex32(coin_id) + }) + } +} + +/// Decode a canonical lowercase 64-hex id into 32 bytes. +/// +/// A malformed id yields `None` — no pointer — never a panic and never a truncated guess. The ids in +/// an observation are canonicalised by the mirror pass, so this failing at all would mean a producer +/// changed; answering `None` degrades discovery to the hint scan, which is the same fully supported +/// state a node with no coins is in. +fn hex32(id: &str) -> Option<[u8; 32]> { + let bytes = hex::decode(id).ok()?; + <[u8; 32]>::try_from(bytes.as_slice()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mirror::lifecycle::new_snapshot; + use crate::mirror::plan::Bond; + use crate::mirror::states::BondObservation; + + fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s + } + + fn bytes(tag: &str) -> [u8; 32] { + hex32(&id(tag)).expect("a 64-hex fixture id") + } + + fn publish(states: Vec<(Bond, BondState)>, epoch: i64) -> BondSnapshot { + let snapshot = new_snapshot(); + *snapshot.write().expect("snapshot") = Some(BondObservation { + states, + locked_dig_base_units: 0, + epoch, + }); + snapshot + } + + fn bonded(coin: &str, epoch: i64) -> BondState { + BondState::Bonded { + coin_id: id(coin), + epoch, + amount_dig_base_units: 1_010, + } + } + + /// **Proves:** an announce for a capsule this node has bonded in the CURRENT epoch carries that + /// coin's id, and an announce for a capsule it has not carries none. + /// + /// **Catches:** the defect the whole ticket is about — a production source that answers `None` + /// for everything, which is indistinguishable from the shipped `DhtHandle::new` behaviour and + /// which a fixture with no bonded row could not tell apart. + /// + /// The fixture deliberately holds THREE rows: the bonded one, a `Pending` one for a different + /// capsule, and a bonded row for a different capsule. Without the third, "return the only coin + /// you have, whatever was asked" — the nearest wrong implementation — passes; with it, a + /// pointer source that ignores the requested `ContentId` returns the wrong coin. + #[test] + fn a_current_epoch_bonded_capsule_publishes_its_coin_and_only_its_coin() { + let snapshot = publish( + vec![ + (Bond::new(id("aa"), id("11")), bonded("c1", 7)), + (Bond::new(id("bb"), id("22")), bonded("c2", 7)), + (Bond::new(id("cc"), id("33")), BondState::Pending), + ], + 7, + ); + let pointers = SnapshotMirrorPointers::new(snapshot); + + assert_eq!( + pointers.coin_id_for(&dig_dht::ContentId::capsule(bytes("aa"), bytes("11"))), + Some(bytes("c1")), + "the pointer must name the coin bonding the capsule that was ASKED about" + ); + assert_eq!( + pointers.coin_id_for(&dig_dht::ContentId::capsule(bytes("bb"), bytes("22"))), + Some(bytes("c2")), + "a second bonded capsule gets its own coin, not the first one's" + ); + assert_eq!( + pointers.coin_id_for(&dig_dht::ContentId::capsule(bytes("cc"), bytes("33"))), + None, + "a create that has not confirmed bonds nothing yet, and claiming a coin for it would \ + send a verifier to fetch a coin that does not exist" + ); + } + + /// **Proves:** a coin bonded under a PREVIOUS epoch publishes no pointer. + /// + /// **Catches:** the failure `reannounce_on_epoch_rollover` was written for, arriving through the + /// other door. dig-dht has no clock, so a stale pointer is re-attached by `republish` forever; + /// a verifier then fetches a coin that no longer advertises the current epoch and reads a + /// correctly-collateralised node as uncollateralised. A source that returned the coin id + /// whenever a row is `Bonded` looks perfectly correct on the day it ships and is wrong from the + /// next rollover onwards, which is precisely why it is asserted rather than left to the + /// re-announce. + /// + /// The same capsule is asserted twice — once against the epoch it was bonded in and once + /// against the epoch that followed — so this cannot pass against a source that answers `None` + /// for everything. + #[test] + fn a_coin_from_a_previous_epoch_publishes_no_pointer() { + let capsule = dig_dht::ContentId::capsule(bytes("aa"), bytes("11")); + let row = vec![(Bond::new(id("aa"), id("11")), bonded("c1", 7))]; + + let current = SnapshotMirrorPointers::new(publish(row.clone(), 7)); + assert_eq!( + current.coin_id_for(&capsule), + Some(bytes("c1")), + "the control: in its own epoch this coin is exactly the right pointer" + ); + + let rolled = SnapshotMirrorPointers::new(publish(row, 8)); + assert_eq!( + rolled.coin_id_for(&capsule), + None, + "one epoch later the same coin advertises nothing, and pointing at it is worse than \ + pointing at nothing" + ); + assert_eq!( + rolled.epoch(), + 8, + "the epoch reported is the observation's, so the rollover re-announce can see it change" + ); + } + + /// **Proves:** before any pass has published, the source answers no pointers and an epoch no + /// real epoch can equal. + /// + /// **Catches:** seeding the epoch to `0`. `0` is a real epoch number, so a node whose first + /// observation lands in epoch 0 would compare equal to its pre-observation state and SKIP the + /// re-announce that first attaches its pointers — the mechanism silently never starting, which + /// is the same class of defect as it never being fed at all. + #[test] + fn before_the_first_pass_there_are_no_pointers_and_the_epoch_cannot_collide_with_a_real_one() { + let pointers = SnapshotMirrorPointers::new(new_snapshot()); + + assert_eq!( + pointers.coin_id_for(&dig_dht::ContentId::capsule(bytes("aa"), bytes("11"))), + None, + "an unobserved node claims nothing; announcing without a pointer is ordinary" + ); + assert_eq!( + pointers.epoch(), + EPOCH_UNKNOWN, + "the pre-observation epoch must differ from every epoch a pass can publish" + ); + assert_ne!( + EPOCH_UNKNOWN, 0, + "epoch 0 is a real epoch, so it cannot double as the sentinel" + ); + } + + /// **Proves:** a store-level content id gets no pointer. + /// + /// **Catches:** matching on the store id alone. A mirror coin bonds one `(store, root, epoch)` + /// tuple, so a pointer attached to the whole-store announce would claim that one root's coin + /// collateralises every generation of that store — a claim this node does not hold and cannot + /// support, on the announce a fetcher of ANY generation would read. + #[test] + fn the_whole_store_announce_carries_no_coin_because_no_coin_bonds_a_whole_store() { + let pointers = SnapshotMirrorPointers::new(publish( + vec![(Bond::new(id("aa"), id("11")), bonded("c1", 7))], + 7, + )); + + assert_eq!( + pointers.coin_id_for(&dig_dht::ContentId::capsule(bytes("aa"), bytes("11"))), + Some(bytes("c1")), + "the control: the capsule announce does carry the coin" + ); + assert_eq!( + pointers.coin_id_for(&dig_dht::ContentId::store(bytes("aa"))), + None, + "a coin bonds one generation, never the store" + ); + } +} diff --git a/crates/dig-node-service/src/mirror/resolve.rs b/crates/dig-node-service/src/mirror/resolve.rs new file mode 100644 index 00000000..9b685d8d --- /dev/null +++ b/crates/dig-node-service/src/mirror/resolve.rs @@ -0,0 +1,206 @@ +//! Resolving a mirror spend the chain has since confirmed (dig-node#412 step 6). +//! +//! # The gap this closes +//! +//! A mirror spend is broadcast in one pass and confirms during a LATER one. The audit record's +//! confirmation entry point took a [`RecordedSpend`](crate::spend_audit::RecordedSpend), and a pass +//! drops every handle it opened when it ends — so nothing could ever record the outcome, and every +//! successfully broadcast mirror spend settled `unresolved` on drop. `dign spends` therefore showed +//! a node whose money had demonstrably moved as a node that did not know what it had done. +//! +//! This module is the reader that closes it, over +//! [`SpendJournal::resolve_landed`](crate::spend_audit::SpendJournal::resolve_landed) — the id-keyed +//! entry point that exists inside `spend_audit` because the write path is private to that module. +//! +//! # Resolution is POSITIVE, never inferred +//! +//! Two inferences are available here and both are wrong: +//! +//! - **"the broadcast succeeded, so it landed."** Reaching a mempool is not confirmation, and +//! `Confirmed` carries a height and a coin id INSIDE the variant precisely so that a record +//! cannot hold a confirmation without one. +//! - **"the coin disappeared from the owned set, so our reclaim landed."** The mirror puzzle hash +//! is shared by every mirror coin of every node, so a coin leaving the set proves that SOMEONE +//! spent it. A short or truncated scan is also indistinguishable from a spend. And in any case +//! there would be nothing to pass as the confirmed coin id. +//! +//! So the only key used is the coin's PRESENCE, read through +//! [`MirrorEffects::coin_confirmation`](super::runner::MirrorEffects::coin_confirmation), whose +//! three answers stay three: +//! +//! | answer | meaning | action | +//! |---|---|---| +//! | `Ok(Some(height))` | the chain has the coin, in a block | resolve to `Confirmed` | +//! | `Ok(None)` | the chain does not have it, or has it with no height yet | resolve NOTHING | +//! | `Err(_)` | the chain could not be asked | resolve NOTHING | +//! +//! **An `Err` must never resolve anything, and must never resolve anything the other way either.** +//! A chain source that is down for an hour is exactly the condition under which a resolver that +//! "concluded" would produce a wrong answer on every open record at once. +//! +//! # The two operations have different keys, and one of them is `None` by design +//! +//! A **reclaim** records `intended_coin_id = Some(reclaimed_coin_id(coin))`, so it already has the +//! key: one [`coin_confirmation`](super::runner::MirrorEffects::coin_confirmation) read. +//! +//! A **create** records `intended_coin_id = None`, because the created coin's parent is whichever +//! funding input the builder drew from and this node does not know which. Its key is therefore the +//! coin's APPEARANCE in the pass's own chain observation, matched on the `(store, root, epoch)` the +//! record bonds — the three terms the record carries structurally. That match yields a coin id, +//! which is then confirmed positively like any other. Nothing is invented: the coin id comes from +//! the chain, never from the record. +//! +//! # An AMBIGUOUS claim resolves nothing +//! +//! Two open records can name one coin. Two reclaim attempts of the same mirror coin derive the same +//! child id, and at most one of those bundles can have landed; §25.4.6's in-flight suppression +//! deliberately does not suppress on `Unresolved`, so two open creates for one `(store, root, +//! epoch)` are reachable too. Resolving both would tell a person that two spends created one coin, +//! which is false about at least one of them — so a coin claimed by more than one open record, or +//! already attributed to a `Confirmed` record, resolves NONE of them. They stay `unresolved`, which +//! is what they honestly are: this node signed twice and cannot tell which signature landed. + +use std::collections::{HashMap, HashSet}; + +use crate::spend_audit::{kinds, Resolution, SpendJournal, SpendStatus, TargetCoinId}; + +use super::plan::HeldMirror; +use super::runner::MirrorEffects; + +/// What one resolution sweep did. Reported rather than only logged, so a test can assert that an +/// unreadable chain resolved nothing rather than merely assert that it did not crash. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct ResolveSummary { + /// Records promoted to `Confirmed` this sweep. + pub recorded: usize, + /// Open records left alone because the chain could not be asked about their coin. + pub chain_unreadable: usize, + /// Open records left alone because more than one of them claims the same coin. + pub ambiguous: usize, +} + +/// Promote every open mirror-coin spend whose coin the chain now shows. +/// +/// `on_chain` is the pass's OWN observation, passed in rather than re-read, so the sweep and the +/// plan see one chain reading — the same rule the pass follows for the disk and the balance. +pub(super) fn resolve_landed_spends( + journal: &SpendJournal, + effects: &E, + on_chain: &[HeldMirror], +) -> ResolveSummary { + let mut summary = ResolveSummary::default(); + + let ledger = match journal.log().ledger() { + Ok(ledger) => ledger, + Err(e) => { + // Resolve nothing, exactly as an unreadable ledger suppresses no create. The records + // stay open and the next pass tries again; nothing is written on a read this node + // could not take. + tracing::warn!( + target: "mirror", + error = %e, + "the spend audit record could not be read; no landed mirror spend is resolved this pass" + ); + return summary; + } + }; + + // Coins already attributed to a settled record. A second record must not be confirmed against + // a coin some other spend is already recorded as having created. + let attributed: HashSet<&str> = ledger + .records + .iter() + .filter(|r| matches!(r.status, SpendStatus::Confirmed { .. })) + .filter_map(|r| r.intended_coin_id.as_ref().map(|c| c.0.as_str())) + .collect(); + + // Every open mirror-coin record, paired with the coin id it claims — from its own + // `intended_coin_id` for a reclaim, or from the chain observation for a create. + let mut claims: HashMap> = HashMap::new(); + for record in &ledger.records { + if record.kind.as_str() != kinds::MIRROR_COIN { + continue; + } + if !matches!( + record.status, + SpendStatus::Submitted | SpendStatus::Unresolved { .. } + ) { + continue; + } + let coin_id = match &record.intended_coin_id { + Some(target) => target.0.clone(), + None => { + let (Some(store_id), Some(bond)) = (&record.store_id, &record.bond) else { + continue; // a record written before the bond was carried structurally + }; + let Some(found) = on_chain + .iter() + .find(|m| m.store_id == *store_id && m.root == bond.root && m.epoch == bond.epoch) + else { + continue; // no coin for this bond yet: nothing positive to confirm against + }; + found.coin_id.clone() + } + }; + claims.entry(coin_id).or_default().push(&record.id); + } + + for (coin_id, claimants) in claims { + if claimants.len() > 1 || attributed.contains(coin_id.as_str()) { + summary.ambiguous += claimants.len(); + tracing::warn!( + target: "mirror", + coin_id = %coin_id, + claimants = claimants.len(), + "more than one automated spend claims this coin; none is resolved, because at most \ + one of them created it and this node cannot tell which" + ); + continue; + } + let id = claimants[0]; + + match effects.coin_confirmation(&coin_id) { + Ok(Some(height)) => { + match journal.resolve_landed(id, TargetCoinId(coin_id.clone()), height) { + Ok(Resolution::Recorded) => { + summary.recorded += 1; + tracing::info!( + target: "mirror", + spend_id = %id, + coin_id = %coin_id, + height, + "an automated mirror spend is confirmed on chain" + ); + } + Ok(other) => tracing::debug!( + target: "mirror", + spend_id = %id, + outcome = ?other, + "the audit record was not open for resolution" + ), + Err(e) => tracing::error!( + target: "mirror", + spend_id = %id, + error = %e, + "FAILED to append the confirmation of an automated mirror spend" + ), + } + } + // Absent, or present with no height yet. Both mean "not confirmed", and both leave the + // record open for a later pass rather than settling it as anything. + Ok(None) => {} + Err(e) => { + summary.chain_unreadable += 1; + tracing::warn!( + target: "mirror", + spend_id = %id, + coin_id = %coin_id, + error = %e, + "the chain could not be asked about this coin; the spend stays unresolved" + ); + } + } + } + + summary +} diff --git a/crates/dig-node-service/src/mirror/resolve_tests.rs b/crates/dig-node-service/src/mirror/resolve_tests.rs new file mode 100644 index 00000000..4931f5bc --- /dev/null +++ b/crates/dig-node-service/src/mirror/resolve_tests.rs @@ -0,0 +1,345 @@ +//! Tests for [`super::resolve`], in their own file because the fixtures are long enough that +//! inlining them would bury the module they document. + +use std::cell::RefCell; +use std::collections::HashMap; + +use super::plan::HeldMirror; +use super::resolve::resolve_landed_spends; +use super::runner::{MirrorEffects, ObservedCapsule, PassError}; +use crate::mirror::plan::{Bond, ReclaimReason}; +use crate::spend_audit::{ + kinds, Asset, AuditedBond, Authority, SpendIntent, SpendJournal, SpendKind, SpendLog, + SpendStatus, Submission, TargetCoinId, +}; + +/// A height that is not a small number, not zero, and not equal to anything else in a fixture. +/// +/// Chosen so an implementation that hard-codes a height, reuses an epoch, or defaults to zero +/// cannot accidentally agree with the assertion. It is the real mainnet height of the mirror coin +/// dig-node#412 piece 4 was verified against, which also makes it recognisable in a failure. +const LANDED_HEIGHT: u32 = 9_224_641; + +fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s +} + +/// A chain double that keeps the three answers of +/// [`MirrorEffects::coin_confirmation`] genuinely apart. +/// +/// `confirmations` holds the coins it can see and at what height; `unaskable` holds the coins it +/// cannot be asked about at all. Anything in neither is honestly absent. A double that could only +/// say "yes" and "no" could not express the `Err` case, and the `Err` case is where a resolver is +/// most likely to do harm. +#[derive(Default)] +struct Chain { + confirmations: HashMap, + unaskable: Vec, + asked: RefCell>, +} + +impl MirrorEffects for Chain { + fn observe_disk(&self) -> Result, PassError> { + Ok(Vec::new()) + } + fn observe_chain(&self) -> Result, PassError> { + Ok(Vec::new()) + } + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError> { + self.asked.borrow_mut().push(coin_id.to_string()); + if self.unaskable.iter().any(|c| c == coin_id) { + return Err(PassError::Chain("the chain could not be asked".to_string())); + } + Ok(self.confirmations.get(coin_id).copied()) + } + fn dig_balance_base_units(&self) -> Result { + Ok(0) + } + fn reclaim(&self, _: &HeldMirror, _: ReclaimReason) -> Result<(), PassError> { + panic!("resolution spends nothing") + } + fn create(&self, _: &Bond, _: i64, _: u64) -> Result<(), PassError> { + panic!("resolution spends nothing") + } +} + +fn intent(store: &str, root: &str, epoch: i64) -> SpendIntent { + SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "advertise a held capsule".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror.collateralisation".to_string(), + }, + asset: Asset::Dig, + amount_mojos: 1_010, + fee_mojos: 0, + store_id: Some(id(store)), + bond: Some(AuditedBond { + root: id(root), + epoch, + }), + } +} + +/// Open a spend and settle it the way a real pass does: broadcast, then drop the handle at the end +/// of the pass. That is what leaves `unresolved`, and it is the state under test. +/// +/// Built through the real producer rather than by writing a JSONL line, so a fixture cannot encode +/// a record shape the node never actually writes. +fn open_spend( + journal: &SpendJournal, + store: &str, + root: &str, + epoch: i64, + target: Option<&str>, +) -> String { + let recorded = journal.begin(intent(store, root, epoch)); + let audit_id = recorded.id().to_string(); + journal.submitted( + &recorded, + Submission { + intended_coin_id: target.map(|t| TargetCoinId(id(t))), + funding_coin_ids: Vec::new(), + }, + ); + drop(recorded); + audit_id +} + +fn status_of(log: &SpendLog, audit_id: &str) -> SpendStatus { + log.ledger() + .expect("ledger") + .records + .into_iter() + .find(|r| r.id == audit_id) + .expect("the record must exist") + .status +} + +fn mirror(coin: &str, store: &str, root: &str, epoch: i64) -> HeldMirror { + HeldMirror { + coin_id: id(coin), + store_id: id(store), + root: id(root), + epoch, + collateral_dig_base_units: 1_010, + } +} + +fn journal(dir: &std::path::Path) -> (SpendJournal, SpendLog) { + let log = SpendLog::at(dir.join("spend-audit.jsonl")); + (SpendJournal::new(log.clone()), log) +} + +/// **Proves:** a reclaim whose coin the chain now shows becomes `Confirmed`, at the height the +/// CHAIN reported and against the coin id the record already carried. +/// +/// **Catches:** the defect this whole module exists for — nothing calling `confirmed`, leaving every +/// broadcast mirror spend `unresolved` forever. It also catches a resolver that confirms on the +/// broadcast alone, because the fixture carries a SECOND spend, broadcast identically, whose coin +/// the chain does not have: an implementation that resolves what it submitted rather than what +/// landed confirms both and fails on the second assertion. +/// +/// The height is asserted by value. A resolver that wrote `0`, or the epoch, or the record's own +/// revision, would satisfy "it is confirmed" and fail here. +#[test] +fn a_landed_reclaim_is_confirmed_at_the_height_the_chain_reported() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + let landed = open_spend(&journal, "aa", "11", 7, Some("c1")); + let still_flying = open_spend(&journal, "bb", "22", 7, Some("c2")); + + let chain = Chain { + confirmations: HashMap::from([(id("c1"), LANDED_HEIGHT)]), + ..Chain::default() + }; + + let summary = resolve_landed_spends(&journal, &chain, &[]); + + assert_eq!(summary.recorded, 1, "exactly the one landed spend resolves"); + assert_eq!( + status_of(&log, &landed), + SpendStatus::Confirmed { + height: LANDED_HEIGHT, + coin_id: TargetCoinId(id("c1")), + }, + "the height and the coin come from the chain read, not from the attempt" + ); + assert!( + matches!(status_of(&log, &still_flying), SpendStatus::Unresolved { .. }), + "a spend the chain has no coin for stays unresolved; broadcasting is not landing" + ); +} + +/// **Proves:** a chain that cannot ANSWER resolves nothing, and is kept distinct from a chain that +/// answers "absent". +/// +/// **Catches:** folding `Err` into `Ok(None)` or, far worse, into a confirmation. A source that is +/// down for an hour would otherwise settle every open record on this node at once, on no evidence. +/// +/// The fixture varies ONE actor and keeps an honest control: two identical spends, one whose coin +/// the chain confirms and one whose coin it cannot be asked about. Without the control this test +/// would also pass against a resolver that never resolves anything at all — which is the nearest +/// wrong implementation, and the one a naive "make Err safe" fix produces. +#[test] +fn a_chain_that_cannot_answer_resolves_nothing_while_its_neighbour_still_resolves() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + let unaskable = open_spend(&journal, "aa", "11", 7, Some("c1")); + let answerable = open_spend(&journal, "bb", "22", 7, Some("c2")); + + let chain = Chain { + confirmations: HashMap::from([(id("c2"), LANDED_HEIGHT)]), + unaskable: vec![id("c1")], + ..Chain::default() + }; + + let summary = resolve_landed_spends(&journal, &chain, &[]); + + assert_eq!( + summary.chain_unreadable, 1, + "an unanswerable read is counted as an outage, never as an absence" + ); + assert!( + matches!(status_of(&log, &unaskable), SpendStatus::Unresolved { .. }), + "a read that failed is not evidence about the coin, in either direction" + ); + assert_eq!( + summary.recorded, 1, + "the control must still resolve, or this test passes against a resolver that does nothing" + ); + assert!( + matches!(status_of(&log, &answerable), SpendStatus::Confirmed { .. }), + "one unreadable coin must not suppress resolution of an unrelated one" + ); +} + +/// **Proves:** a CREATE is confirmed only against a coin the chain observation actually produced, +/// matched on all three of `(store, root, epoch)`. +/// +/// **Catches:** the invented coin id. A create records `intended_coin_id: None` by design, so any +/// resolver that needs an id is tempted to derive one; the fixture's chain observation holds a coin +/// for the SAME store at a DIFFERENT root, so a matcher keyed on the store alone — the obvious +/// narrowing — confirms the wrong coin and fails here. The epoch axis is varied for the same +/// reason: a coin from last epoch is a real coin of this node's, and confirming this epoch's create +/// against it would report a bond that was never made. +#[test] +fn a_create_is_confirmed_only_against_a_coin_that_matches_store_root_and_epoch() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + let create = open_spend(&journal, "aa", "11", 7, None); + + let wrong_root = mirror("c9", "aa", "99", 7); + let wrong_epoch = mirror("c8", "aa", "11", 6); + let chain = Chain { + confirmations: HashMap::from([ + (id("c9"), LANDED_HEIGHT), + (id("c8"), LANDED_HEIGHT), + (id("c7"), LANDED_HEIGHT), + ]), + ..Chain::default() + }; + + let summary = resolve_landed_spends(&journal, &chain, &[wrong_root.clone(), wrong_epoch]); + assert_eq!( + summary.recorded, 0, + "no coin matches all three terms, so nothing may be confirmed" + ); + assert!( + matches!(status_of(&log, &create), SpendStatus::Unresolved { .. }), + "a create with no matching coin stays unresolved rather than borrowing a neighbour's" + ); + assert!( + !chain.asked.borrow().contains(&id("c9")), + "a coin at the wrong root is not even a candidate, so it is never asked about" + ); + + // Now the real coin appears, alongside the two decoys that must still be ignored. + let right = mirror("c7", "aa", "11", 7); + let summary = resolve_landed_spends(&journal, &chain, &[wrong_root, right]); + assert_eq!(summary.recorded, 1); + assert_eq!( + status_of(&log, &create), + SpendStatus::Confirmed { + height: LANDED_HEIGHT, + coin_id: TargetCoinId(id("c7")), + }, + "the confirmed coin id is the one the chain observation carried, never a derived one" + ); +} + +/// **Proves:** when two open records claim one coin, NEITHER is confirmed. +/// +/// **Catches:** resolving both, which asserts that two spends each created the same coin — false +/// about at least one of them, on the record whose whole purpose is to be true about money. It also +/// catches "resolve the first one", which is a coin flip dressed as an answer. +/// +/// Reachable in production, not contrived: §25.4.6's in-flight suppression deliberately does not +/// suppress on `Unresolved` (a suppression that never lifts leaves a bond permanently +/// uncollateralised), so two open creates for one `(store, root, epoch)` are exactly what a pass +/// following the current rule produces after a broadcast whose fate is unknown. +#[test] +fn two_open_spends_claiming_one_coin_resolve_neither() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + let first = open_spend(&journal, "aa", "11", 7, None); + let second = open_spend(&journal, "aa", "11", 7, None); + + let chain = Chain { + confirmations: HashMap::from([(id("c7"), LANDED_HEIGHT)]), + ..Chain::default() + }; + + let summary = resolve_landed_spends(&journal, &chain, &[mirror("c7", "aa", "11", 7)]); + + assert_eq!(summary.recorded, 0); + assert_eq!(summary.ambiguous, 2, "both claimants are reported, not one"); + for audit_id in [&first, &second] { + assert!( + matches!(status_of(&log, audit_id), SpendStatus::Unresolved { .. }), + "this node signed twice and cannot tell which signature landed; saying so is the \ + honest answer, and picking one is a fabrication" + ); + } +} + +/// **Proves:** a record that never reached the network is never confirmed, even when a coin +/// matching its bond is on chain. +/// +/// **Catches:** a resolver keyed on the bond alone. A `Pending` record means nothing was handed to +/// a mempool, so a coin for that bond was created by some OTHER spend — a previous pass, or another +/// node at the same puzzle hash — and attributing it here would credit this attempt with a coin it +/// did not make and release the funding coins it still holds reserved. +#[test] +fn a_spend_that_never_reached_the_network_is_not_confirmed_by_a_coin_that_matches_its_bond() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + // Begin and hold the handle: the record is `Pending`, and nothing was submitted. + let recorded = journal.begin(intent("aa", "11", 7)); + let audit_id = recorded.id().to_string(); + + let chain = Chain { + confirmations: HashMap::from([(id("c7"), LANDED_HEIGHT)]), + ..Chain::default() + }; + let summary = resolve_landed_spends(&journal, &chain, &[mirror("c7", "aa", "11", 7)]); + + assert_eq!(summary.recorded, 0); + assert_eq!( + status_of(&log, &audit_id), + SpendStatus::Pending, + "a spend that never left this node cannot have created a coin" + ); + drop(recorded); +} diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 8b57f77c..25d0e429 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -108,6 +108,22 @@ pub trait MirrorEffects { /// The mirror coins this wallet owns — `dig_mirror_coin::list(source, owner_puzzle_hash)`. fn observe_chain(&self) -> Result, PassError>; + /// What the chain says about `coin_id`, for resolving a spend this node broadcast in an + /// EARLIER pass (dig-node#412 step 6). + /// + /// **Three answers, and they must stay three.** `Ok(Some(height))` is the chain showing the + /// coin in a block — the only answer that may confirm anything. `Ok(None)` is the chain not + /// showing it, or showing it with no height yet, which are the same instruction: wait. `Err` is + /// the chain failing to ANSWER, which is not a verdict about the coin at all — folding it into + /// `Ok(None)` would turn an outage into a fleet-wide "nothing confirmed", and folding it the + /// other way would confirm spends on no evidence. + /// + /// Deliberately keyed on a coin id rather than shaped as "did my spend land": the caller + /// derives the coin id positively (see [`super::resolve`]), and an implementation that decided + /// landedness for itself would be a second answer to the one question this record exists to + /// answer honestly. + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError>; + /// Spendable $DIG, in base units. fn dig_balance_base_units(&self) -> Result; @@ -189,6 +205,12 @@ pub struct PassRunner { effects: E, presence: super::presence::PresenceTracker, log: SpendLog, + /// The one writer of the audit record, for resolving spends an earlier pass broadcast. + /// + /// Held beside `log` rather than replacing it: the in-flight suppression READS the ledger and + /// wants nothing else, while resolution WRITES it, and keeping the reader unable to write is + /// what stops a future change to the suppression rule from acquiring a write path by accident. + journal: crate::spend_audit::SpendJournal, settling_window_ms: u64, } @@ -198,6 +220,7 @@ impl PassRunner { Self { effects, presence: super::presence::PresenceTracker::new(), + journal: crate::spend_audit::SpendJournal::new(log.clone()), log, settling_window_ms: super::presence::SETTLING_WINDOW_MS, } @@ -221,6 +244,15 @@ impl PassRunner { self.presence } + /// Write the audit record through `journal` instead of the default one over this runner's log. + /// + /// Exists so a test can pin the clock. A journal over a DIFFERENT log would make the runner + /// read one record and write another, so callers pass a journal over the same path. + pub fn with_journal(mut self, journal: crate::spend_audit::SpendJournal) -> Self { + self.journal = journal; + self + } + /// Use a non-default settling window (§25.5). pub fn with_settling_window_ms(mut self, window_ms: u64) -> Self { self.settling_window_ms = window_ms; @@ -245,6 +277,13 @@ impl PassRunner { .observe(&on_disk_held, ctx.now_unix_ms, self.settling_window_ms); let on_chain = self.effects.observe_chain()?; + + // BEFORE the in-flight set is derived, so a create this sweep confirms stops suppressing + // itself in the same pass rather than one pass later. Never `?`: resolution is bookkeeping + // about spends that have already happened, and a sweep that could not complete must not + // stop the pass from reclaiming money that is sitting on chain. + super::resolve::resolve_landed_spends(&self.journal, &self.effects, &on_chain); + let in_flight = in_flight_creates(&self.log, ctx.current_epoch); // NOT `?`. The balance prices creates and nothing else, so a wallet that cannot report its // $DIG must degrade the create half rather than abort the pass — aborting here would leave a @@ -503,6 +542,10 @@ mod tests { /// balance observation is unreachable from any fixture, and an error path no double can /// take reads as covered while never having been run once. balance_fails: bool, + /// Heights the chain reports per coin id, for the landed-spend resolver. + confirmations: std::collections::HashMap, + /// Coin ids the chain cannot be ASKED about, kept apart from ones it reports absent. + confirmation_fails: Vec, } impl MirrorEffects for FakeEffects { @@ -514,6 +557,13 @@ mod tests { Ok(self.chain.clone()) } + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError> { + if self.confirmation_fails.iter().any(|c| c == coin_id) { + return Err(PassError::Chain("the chain could not be asked".to_string())); + } + Ok(self.confirmations.get(coin_id).copied()) + } + fn dig_balance_base_units(&self) -> Result { if self.balance_fails { return Err(PassError::Wallet("the wallet is locked".to_string())); @@ -1193,6 +1243,9 @@ mod tests { fn observe_chain(&self) -> Result, PassError> { Err(PassError::Chain("no source".to_string())) } + fn coin_confirmation(&self, _: &str) -> Result, PassError> { + panic!("a pass that cannot see the chain resolves nothing") + } fn dig_balance_base_units(&self) -> Result { Ok(u64::MAX) } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index e2aec190..a5fe6384 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2155,6 +2155,21 @@ where // path (`dig-runtime`) never routes through `serve_with_shutdown`, so the browser's // node keeps installing no P2P content — its in-process trust boundary is unchanged. if dig_node_core::peer::peer_network_enabled() { + // The untrusted mirror-coin pointers every DHT announce attaches (dig-node#435), installed + // BEFORE the bring-up reads them. The DHT lives in dig-node-core and the mirror lifecycle + // that knows which coin bonds which capsule lives here, so this shell is the one place that + // can join them. Until it did, the pointer mechanism was built, unit tested, and fed only by + // a test double: every live announce published no coin id at all. + // + // Installed unconditionally rather than behind the broadcast switch. The pointer is read + // from the observation a pass publishes, so a node that creates no coins simply has no + // `Bonded` row and answers `None` — the ordinary, fully supported case — while a node whose + // coins were created before the switch was turned off still points at them correctly. + state + .node + .set_mirror_coin_pointers(std::sync::Arc::new( + crate::mirror::pointers::SnapshotMirrorPointers::new(state.mirror_bonds.clone()), + )); dig_node_core::peer::spawn_peer_network(state.node.clone()); } diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 7b32bf19..20a77b9f 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -876,6 +876,91 @@ impl SpendJournal { } } +/// What an id-keyed resolution attempt did. +/// +/// Four outcomes rather than a `bool`, because three of them are "nothing was written" for reasons +/// that call for different responses: a missing id is a bug in the caller, an already-terminal +/// record is the ordinary case on a second pass, and a record that never reached the network is a +/// refusal. Collapsing them would make the one that matters — a resolver silently writing nothing, +/// forever — indistinguishable from the healthy case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Resolution { + /// A `Confirmed` revision was appended. + Recorded, + /// No record in the ledger carries that id. + NoSuchSpend, + /// The record is settled already, or never left this node. Nothing was written. + NotOpen, +} + +impl SpendJournal { + /// Resolve a spend recorded in an EARLIER pass to [`SpendStatus::Confirmed`], keyed by its + /// audit id. + /// + /// # Why this exists AT ALL, and why it is here rather than in the producer + /// + /// [`RecordedSpend`] is the only handle [`Self::confirmed`] accepts, and a mirror pass drops + /// every handle it opened when the pass ends. A mirror spend is broadcast in one pass and + /// confirms during the NEXT one, so by the time the chain can answer, the handle that could + /// have recorded the answer no longer exists — which is why `confirmed` had zero production + /// callers and every successfully broadcast mirror spend settled as + /// [`SpendStatus::Unresolved`] on drop (dig-node#412). This is the id-keyed entry point that a + /// later pass can use. + /// + /// It is INSIDE this module by necessity, not by preference. [`SpendLog::append`] is + /// module-private, and `Confirmed` is producible only through this type — so a resolver living + /// anywhere else could not write the file at all, and making the write path public to let it + /// would be a second producer of `Confirmed`, which is the one status the module's honesty + /// rules are built around. + /// + /// # What it will NOT do + /// + /// `height` and `coin_id` are the caller's OBSERVATION of the chain, exactly as they are for + /// [`Self::confirmed`], and this method adds no inference of its own. It refuses: + /// + /// - an id it cannot find — [`Resolution::NoSuchSpend`], never an append that invents a record; + /// - a record that is already `Confirmed` or `Failed`, so a terminal outcome is never rewritten; + /// - a record still `Pending` — nothing was handed to the network, so no coin of this spend can + /// be on chain, and a confirmation against one would be attributing a stranger's coin. + /// + /// That leaves exactly [`SpendStatus::Submitted`] and [`SpendStatus::Unresolved`]: the two + /// states in which a signed bundle exists and its fate is genuinely unknown. + /// + /// An `Err` is an I/O failure reading or appending. It resolves nothing, which is the direction + /// that costs a retry rather than a false confirmation. + pub fn resolve_landed( + &self, + id: &str, + coin_id: TargetCoinId, + height: u32, + ) -> std::io::Result { + let ledger = self.log.ledger()?; + let Some(current) = ledger.records.iter().find(|r| r.id == id) else { + return Ok(Resolution::NoSuchSpend); + }; + if !matches!( + current.status, + SpendStatus::Submitted | SpendStatus::Unresolved { .. } + ) { + return Ok(Resolution::NotOpen); + } + + // A full snapshot at the next revision, carried forward from the record on disk — the same + // rule `RecordedSpend::write` follows. Rebuilding it from anything narrower would drop the + // funding coin ids this spend consumed, and those are what the next pass reserves against. + let mut next = current.clone(); + next.revision += 1; + next.updated_ms = (self.clock)(); + next.status = SpendStatus::Confirmed { + height, + coin_id: coin_id.clone(), + }; + next.intended_coin_id = Some(coin_id); + self.log.append(&next)?; + Ok(Resolution::Recorded) + } +} + /// A chain-side listing of the coins an owner actually holds — the check that local bookkeeping is /// honest. /// From 0d2498443864de7212e4ca791f69f1024cdf531a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:30:52 -0700 Subject: [PATCH 3/9] test(mirror): decision-level tests for the resolver, pointers, bound and bondable contract --- SPEC.md | 45 +++++- crates/dig-node-service/src/mirror/funding.rs | 136 ++++++++++++++++++ 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index 581d1c87..9bf07b03 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8439,6 +8439,35 @@ 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. +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 + planned against, every mirror-coin entry that is `submitted` or `unresolved` is resolved as + follows, and only as follows: + + | operation | its positive key | how the height is obtained | + |---|---|---| + | reclaim | the `intended_coin_id` the submission recorded | a coin read on that id | + | create | the coin in step 2's observation matching `(store, root, epoch)` | a coin read on THAT coin's id | + + A create records no `intended_coin_id` — the created coin's parent is whichever funding input the + builder drew from — so its key MUST be the coin's appearance in the chain observation. A coin id + MUST NOT be derived, guessed, or otherwise invented for this purpose. + + The coin read has THREE outcomes and they MUST stay three: a height (resolve to `confirmed`); the + coin absent, or present with no height (resolve nothing); the source unable to answer (resolve + nothing, and NOT as an absence). A source that cannot be reached is not evidence about a coin in + either direction. + + Disappearance MUST NOT be used as a key. The mirror puzzle hash is shared by every mirror coin, + so a coin leaving the owned set proves only that SOMEONE spent it, and a short scan is + indistinguishable from a spend. + + Where more than one open entry claims one coin — two reclaim attempts of the same coin derive the + same child id, and step 6 deliberately does not suppress on `unresolved` — NONE of them is + resolved. At most one of those bundles created the coin, and this node cannot tell which; the + entries remain `unresolved`, which is what they are. + A confirmed create is `Confirmed { height, coin_id }` in the audit record, observed on the created coin. The `intended_coin_id` is recorded at submission so §23.5's reconcile accounts for it. @@ -8472,10 +8501,12 @@ cannot observe a half-written file the node produced itself. ### 25.6. The DHT pointer, and epoch rollover -> **PENDING — not yet implemented.** This subsection is normative and is NOT satisfied by -> code as of this section's introduction. Tracked as dig-node#377 step 7 (the dig-dht 0.12.1 → 0.15 bump and the -> announce-seam attach). Until it lands, a reader MUST NOT -> rely on the behaviour described here. +> **IMPLEMENTED.** The announce seam attaches the pointer +> (`dig_node_core::dht::announce_inventory_ids_with_pointers`), the rollover re-announce is +> `DhtHandle::reannounce_on_epoch_rollover`, and the node's production pointer source is +> `dig_node_service::mirror::pointers::SnapshotMirrorPointers`, which reads the observation the last +> pass published. A node with no observation, or with no coin for a capsule, publishes no pointer; +> that is an ordinary configuration and not a fault. After a create confirms, the node attaches the coin id to its DHT provider record (`dig_dht::ProviderRecord::unverified_mirror_coin_id`) for that content, and it MUST re-announce on @@ -8483,6 +8514,12 @@ epoch rollover once the new epoch's coin confirms — dig-dht has no clock and r whatever was recorded at announce time, so an un-refreshed pointer goes stale one epoch after publication and a correctly-collateralised node reads as uncollateralised. +Only a coin bonding the CURRENT epoch may be published. A coin from a previous epoch advertises +nothing, and pointing at it makes a correctly-collateralised node read as uncollateralised — the +same failure the rollover re-announce exists to prevent, reached without any rollover. A whole-store +announce carries no pointer at all: a coin bonds one `(store, root, epoch)` tuple and cannot speak +for every generation of a store. + The pointer is an UNTRUSTED convenience (NC-12): it tells a verifier where to look, never what the coin is. Its absence MUST NOT degrade discovery or be treated as a fault. A verifier — this node when it checks others, and others when they check this node — accepts a coin as bonding diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 144a9097..f3fb06b0 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -411,6 +411,142 @@ mod tests { ); } + /// **Proves:** a create needing more inputs than [`MAX_SELECTED_FUNDING_COINS`] is refused + /// BEFORE any lineage read, and refused as `TooManyInputs` rather than as a shortfall. + /// + /// **Catches:** the unbounded selection of dig-node#427. The scan address is publicly derivable + /// — the operator puzzle hash is public and the CAT curry is canonical — so any stranger can pay + /// dust to it, and every input that survives selection costs one `coin_spend` in + /// [`authenticate`]. Unbounded, the number of chain reads one automated pass performs is chosen + /// by whoever sent the dust, on a timer, forever. + /// + /// # The fixture is built from the bound itself, from BOTH sides + /// + /// A single "lots of dust" case cannot tell a bound from a coincidence, and a case only over + /// the limit cannot tell a correct bound from one that refuses everything. So the same wallet + /// is asked for two amounts, chosen from `MAX_SELECTED_FUNDING_COINS` rather than picked to + /// look large: + /// + /// - **at the bound** — exactly `MAX_SELECTED_FUNDING_COINS` coins cover it, and selection must + /// pass through to authentication (observable as a `coin_spend` having happened); + /// - **one over** — one more coin is needed, and it must be refused with the count and the + /// limit, having read no lineage at all. + /// + /// The at-bound case is what makes this test load-bearing: `selected.len() >= LIMIT`, the + /// off-by-one, is green against an over-only fixture and red here. + /// + /// `coin_spend` answers `Ok(None)` rather than panicking, so reaching authentication produces + /// an ordinary `Unauthenticated` refusal. A panic would prove the same thing about the over + /// case and make the at-bound case unable to report anything but a crash. + #[test] + fn a_create_needing_more_inputs_than_the_bound_is_refused_before_any_lineage_read() { + use std::cell::RefCell; + type _CoinRecord = dig_chainsource_interface::CoinRecord; + + /// A wallet whose $DIG is in `count` coins of one base unit each. + struct Dusted { + count: u64, + owner: Bytes32, + lineage_reads: RefCell, + } + + impl ChainSource for Dusted { + type Error = std::io::Error; + fn coin_record(&self, _: Bytes32) -> Result, Self::Error> { + unreachable!("selection reads by puzzle hash, never by coin id") + } + fn coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + _: bool, + ) -> Result, Self::Error> { + assert_eq!( + puzzle_hash, + dig_cat_puzzle_hash(self.owner), + "the scan must be the CAT-wrapped operator hash, which is what makes it \ + publicly derivable and therefore dustable" + ); + Ok((0..self.count) + .map(|i| { + let mut parent = [0u8; 32]; + parent[..8].copy_from_slice(&i.to_be_bytes()); + _CoinRecord { + coin: chia_protocol::Coin::new(Bytes32::new(parent), puzzle_hash, 1), + confirmed_height: Some(1), + spent_height: None, + timestamp: None, + coinbase: false, + } + }) + .collect()) + } + fn coin_records_by_parent(&self, _: Bytes32) -> Result, Self::Error> { + unreachable!() + } + fn coin_spend( + &self, + _: Bytes32, + ) -> Result, Self::Error> { + *self.lineage_reads.borrow_mut() += 1; + Ok(None) + } + 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!() + } + } + + let owner = owner(0x33); + let at_bound = MAX_SELECTED_FUNDING_COINS as u64; + let over_bound = at_bound + 1; + + // Plenty of coins available either way: the wallet is NOT short, which is the whole point. + let source = Dusted { + count: over_bound + 10, + owner, + lineage_reads: RefCell::new(0), + }; + + let over = select_operator_dig_cats(&source, owner, over_bound, &HashSet::new()); + assert_eq!( + over, + Err(FundingError::TooManyInputs { + needed: over_bound as usize, + limit: MAX_SELECTED_FUNDING_COINS, + }), + "a funded wallet whose $DIG is in too many pieces is refused for THAT reason; \ + reporting a shortfall would send the operator looking for money they already have" + ); + assert_eq!( + *source.lineage_reads.borrow(), + 0, + "the refusal must happen before authentication, or the bound does not bound the chain \ + reads it exists to bound" + ); + + let at = select_operator_dig_cats(&source, owner, at_bound, &HashSet::new()); + assert!( + matches!(at, Err(FundingError::Unauthenticated { .. })), + "exactly at the bound the selection must PASS THROUGH to authentication; it refused \ + with {at:?} instead, so the bound is off by one and rejects a fundable create" + ); + assert_eq!( + *source.lineage_reads.borrow(), + 1, + "reaching authentication is observed rather than assumed: one lineage read happened, \ + and it stopped at the first unauthenticatable coin" + ); + } + /// 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 From 0557913beac57272cd6add59eb8fa45c9d005518 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:39:03 -0700 Subject: [PATCH 4/9] test(spend-audit): pin the writer's pending guard directly, not through the sweep that masks it --- .../src/mirror/resolve_tests.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/dig-node-service/src/mirror/resolve_tests.rs b/crates/dig-node-service/src/mirror/resolve_tests.rs index 4931f5bc..95e9d53b 100644 --- a/crates/dig-node-service/src/mirror/resolve_tests.rs +++ b/crates/dig-node-service/src/mirror/resolve_tests.rs @@ -320,6 +320,17 @@ fn two_open_spends_claiming_one_coin_resolve_neither() { /// a mempool, so a coin for that bond was created by some OTHER spend — a previous pass, or another /// node at the same puzzle hash — and attributing it here would credit this attempt with a coin it /// did not make and release the funding coins it still holds reserved. +/// +/// # Two guards enforce this, and only ONE of them is load-bearing +/// +/// [`super::resolve::resolve_landed_spends`] skips a `Pending` record when choosing what to look +/// up, and [`SpendJournal::resolve_landed`] refuses one when asked to write. The second masks the +/// first: relaxing the sweep's filter alone changes nothing observable, because the write still +/// refuses. So this test asserts the WRITER directly as well as through the sweep — an assertion +/// only through the sweep would pin a coincidence and stay green when the real guard was removed. +/// +/// The sweep's filter is kept anyway, and deliberately: it is what stops a `Pending` record costing +/// a chain read per pass to reach a refusal that was decidable for free. #[test] fn a_spend_that_never_reached_the_network_is_not_confirmed_by_a_coin_that_matches_its_bond() { let dir = tempfile::tempdir().expect("tempdir"); @@ -341,5 +352,20 @@ fn a_spend_that_never_reached_the_network_is_not_confirmed_by_a_coin_that_matche SpendStatus::Pending, "a spend that never left this node cannot have created a coin" ); + + // The authoritative guard, asked directly. The sweep above cannot exercise it, because the + // sweep's own filter reaches the same answer first. + assert_eq!( + journal + .resolve_landed(&audit_id, TargetCoinId(id("c7")), LANDED_HEIGHT) + .expect("the record is readable"), + crate::spend_audit::Resolution::NotOpen, + "the writer must refuse a record that never reached the network, whatever asks it" + ); + assert_eq!( + status_of(&log, &audit_id), + SpendStatus::Pending, + "and refusing must write nothing at all" + ); drop(recorded); } From c6800497afdeba34436771ba9ff349e25e5b60f7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:46:58 -0700 Subject: [PATCH 5/9] =?UTF-8?q?chore(release):=20dig-node=200.190.0=20?= =?UTF-8?q?=E2=80=94=20the=20mirror-coin=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dig-node-core/src/lib.rs | 14 +++++++------- crates/dig-node-service/src/mirror/mod.rs | 1 - crates/dig-node-service/src/mirror/resolve.rs | 7 +++---- .../dig-node-service/src/mirror/resolve_tests.rs | 5 ++++- crates/dig-node-service/src/server.rs | 8 +++----- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index a831b1b9..fa860488 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -74,6 +74,13 @@ mod forwarded_ask_tests; pub mod seams; pub mod tier0_live; pub mod tier0_prefetch; +/// `dig-dht` itself, re-exported so a consumer implementing [`dht::MirrorCoinPointers`] names +/// `ContentId` through THIS crate rather than declaring its own `dig-dht` dependency. +/// +/// A second declaration is a second version constraint, and a consumer that resolved a different +/// `dig-dht` minor would be handed a `ContentId` that is a different type with the same name — the +/// split-line failure §2.4b exists to prevent, arriving through a trait nobody would think to check. +pub use dig_dht; /// The `CapsuleStore` trait is seam 6's public surface (#1285 W1b-4) — bring it into scope to call /// `cache_list_cached`/`cache_remove_cached`/`cache_fetch_and_cache`/`gap_fill_generation`/ /// `maybe_backfill_capsule`/`set_self_ref`/`arc_self` on a `Node`. @@ -95,13 +102,6 @@ pub use seams::content::{bandwidth, verification_ledger, ContentServer}; pub use seams::dig_peer::{ address_book, bootstrap, dht, net, pex, session, HolderClaim, PeerNetwork, }; -/// `dig-dht` itself, re-exported so a consumer implementing [`dht::MirrorCoinPointers`] names -/// `ContentId` through THIS crate rather than declaring its own `dig-dht` dependency. -/// -/// A second declaration is a second version constraint, and a consumer that resolved a different -/// `dig-dht` minor would be handed a `ContentId` that is a different type with the same name — the -/// split-line failure §2.4b exists to prevent, arriving through a trait nobody would think to check. -pub use dig_dht; /// The `RpcDispatch` trait is seam 4's public surface (#1285 W1b-5) — the crate-root /// `handle_rpc`/`handle_rpc_json` free functions delegate to it; most callers keep using those /// stable entry points and never need this trait in scope directly. diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index bb37bb72..baa651d3 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -92,4 +92,3 @@ pub mod runner; pub mod signer; pub mod spends; pub mod states; - diff --git a/crates/dig-node-service/src/mirror/resolve.rs b/crates/dig-node-service/src/mirror/resolve.rs index 9b685d8d..833b63c0 100644 --- a/crates/dig-node-service/src/mirror/resolve.rs +++ b/crates/dig-node-service/src/mirror/resolve.rs @@ -133,10 +133,9 @@ pub(super) fn resolve_landed_spends( let (Some(store_id), Some(bond)) = (&record.store_id, &record.bond) else { continue; // a record written before the bond was carried structurally }; - let Some(found) = on_chain - .iter() - .find(|m| m.store_id == *store_id && m.root == bond.root && m.epoch == bond.epoch) - else { + let Some(found) = on_chain.iter().find(|m| { + m.store_id == *store_id && m.root == bond.root && m.epoch == bond.epoch + }) else { continue; // no coin for this bond yet: nothing positive to confirm against }; found.coin_id.clone() diff --git a/crates/dig-node-service/src/mirror/resolve_tests.rs b/crates/dig-node-service/src/mirror/resolve_tests.rs index 95e9d53b..d01b880c 100644 --- a/crates/dig-node-service/src/mirror/resolve_tests.rs +++ b/crates/dig-node-service/src/mirror/resolve_tests.rs @@ -173,7 +173,10 @@ fn a_landed_reclaim_is_confirmed_at_the_height_the_chain_reported() { "the height and the coin come from the chain read, not from the attempt" ); assert!( - matches!(status_of(&log, &still_flying), SpendStatus::Unresolved { .. }), + matches!( + status_of(&log, &still_flying), + SpendStatus::Unresolved { .. } + ), "a spend the chain has no coin for stays unresolved; broadcasting is not landing" ); } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index a5fe6384..952cc1d5 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2165,11 +2165,9 @@ where // from the observation a pass publishes, so a node that creates no coins simply has no // `Bonded` row and answers `None` — the ordinary, fully supported case — while a node whose // coins were created before the switch was turned off still points at them correctly. - state - .node - .set_mirror_coin_pointers(std::sync::Arc::new( - crate::mirror::pointers::SnapshotMirrorPointers::new(state.mirror_bonds.clone()), - )); + state.node.set_mirror_coin_pointers(std::sync::Arc::new( + crate::mirror::pointers::SnapshotMirrorPointers::new(state.mirror_bonds.clone()), + )); dig_node_core::peer::spawn_peer_network(state.node.clone()); } From 9c5df16344aaadeba27dc84375b868ab5567b333 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 11:15:11 -0700 Subject: [PATCH 6/9] fix(mirror): unmangle five operator-facing strings, document the input bound Five string literals carried runs of ~18 spaces from lost backslash continuations -- one of them the `TooManyInputs` message an operator reads when a create cannot be funded, rendering "may draw at most 32". Valid Rust, invisible to fmt and clippy, and the fourth instance of this class found today (dig_ecosystem#3190 tracks the mechanical guard). Also states on its face that MAX_SELECTED_FUNDING_COINS = 32 is an UNMEASURED judgement rather than a derived limit, and which direction it fails in. The cheap attack on this path is not the bound -- it is the abort-on-unauthenticatable coin, filed as #461 and pre-existing. Refs #427 #461 --- crates/dig-node-service/src/control.rs | 8 ++++---- crates/dig-node-service/src/mirror/funding.rs | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 8c213498..18a92f3f 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -2836,7 +2836,7 @@ const _: () = assert!( fn reserve_batch_refusal(len: usize) -> Option { (len > MAX_RESERVE_COIN_IDS).then(|| { format!( - "params.coin_ids holds {len} ids, above the {MAX_RESERVE_COIN_IDS} this node will reserve in one call. Split the request; a bundle that legitimately needs more inputs than this could not fit in a block anyway" + "params.coin_ids holds {len} ids, above the {MAX_RESERVE_COIN_IDS} this node will reserve in one call. Split the request; a bundle that legitimately needs more inputs than this could not fit in a block anyway" ) }) } @@ -3982,7 +3982,7 @@ mod tests { assert_eq!( bondable_pairs(&observation), 5, - "only `Withheld` is excluded: `Disabled` is a reversible node-wide switch and `Reclaiming` is a served pair whose coin is coming home, and both bond again next pass" + "only `Withheld` is excluded: `Disabled` is a reversible node-wide switch and `Reclaiming` is a served pair whose coin is coming home, and both bond again next pass" ); } @@ -4027,7 +4027,7 @@ mod tests { assert_eq!( bondable_pairs(&observation), 2, - "switching collateralisation off must not advise a zero buffer: the switch is reversible and these pairs lock $DIG on the pass after it is switched back on" + "switching collateralisation off must not advise a zero buffer: the switch is reversible and these pairs lock $DIG on the pass after it is switched back on" ); } @@ -4206,7 +4206,7 @@ mod tests { ); assert!( !is_open_control_read("control.wallet.arrivals"), - "the arrival cursor names this node's own watched puzzle hashes to a caller that supplied nothing, so it must stay behind the control token" + "the arrival cursor names this node's own watched puzzle hashes to a caller that supplied nothing, so it must stay behind the control token" ); } diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index f3fb06b0..6f0ffda1 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -125,7 +125,7 @@ impl std::fmt::Display for FundingError { ), FundingError::TooManyInputs { needed, limit } => write!( f, - "covering this create needs {needed} $DIG coins and a mirror create may draw at most {limit}; the wallet is not short, its $DIG is in too many pieces. No coin was authenticated and no spend was attempted; consolidating the operator's $DIG into fewer coins clears it" + "covering this create needs {needed} $DIG coins and a mirror create may draw at most {limit}; the wallet is not short, its $DIG is in too many pieces. No coin was authenticated and no spend was attempted; consolidating the operator's $DIG into fewer coins clears it" ), FundingError::ZeroCollateral => { f.write_str("a create at zero collateral stakes nothing and is refused") @@ -163,6 +163,12 @@ impl std::fmt::Display for FundingError { /// achieves the point: selection is in-memory over rows already fetched, so it is free, while /// authentication is the per-input chain read being bounded. Bounding the CANDIDATE set instead /// would refuse a perfectly fundable create because a stranger sent dust this node never selected. +/// UNMEASURED JUDGEMENT, stated so nobody reads it as a derived limit: nobody has measured how many +/// $DIG coins a real operator wallet holds. 32 is chosen to bound the per-create chain reads, and if +/// a legitimate wallet routinely exceeds it this fails CLOSED on that operator -- they see +/// `TooManyInputs` and consolidate, rather than a spend going wrong. That direction is the safe one, +/// and an attacker cannot cheaply force it (see dig-node#461 for the cheap attack that DOES exist on +/// this path, which is the abort-on-unauthenticatable coin, not this bound). pub const MAX_SELECTED_FUNDING_COINS: usize = 32; /// The puzzle hash the operator's ordinary $DIG coins sit at. From 5825759399664e219ebb4d6fc156b784d63d707b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 11:15:27 -0700 Subject: [PATCH 7/9] chore(release): 0.193.0 -- rebased past #458, which took 0.190.0 Clean rebase, zero conflicts, no `dropping` line -- and the bump commit still reads correctly in the log while being a no-op against the new main. Only the file on disk shows it. 0.191.0 and 0.192.0 are claimed by sibling lanes still in flight. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2eba9957..4e713c05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.195.0" +version = "0.196.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 37dc3503..b3a635ff 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.195.0" +version = "0.196.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From 762e855ebb29623a9c39c20a3c059fe313cf1a5c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 13:58:51 -0700 Subject: [PATCH 8/9] fix(mirror): decide "open" in one place, and refuse a torn ledger before any chain read Two defects in the resolver, one found by review and one by its own test. 1. THE OPEN SET dropped `Failed { stage: Broadcast }`. The `matches!` at the sweep and its twin in the writer each re-listed status variants, so a spend that failed AFTER its bundle reached a mempool -- where `money_may_have_moved()` is true -- was treated as settled and never chased. That is the defect this module exists to fix, reproduced one variant over. `is_terminal()` is NOT the right predicate either: it admits `Pending`, a spend that never left this node, which no coin can be attributed to. Two tests caught that when I tried it. The question is neither "has it settled" nor "is it terminal" but "may the bundle have REACHED THE NETWORK", so that is now a named predicate, `SpendStatus::may_have_reached_the_network`, written as an exhaustive `match` for the same reason `FailureStage::money_may_have_moved` is: a new variant must be a compile error, not a silent fold into whichever arm a `matches!` happened to list. 2. `ResolveSummary::unreadable_lines` was DECLARED and documented as meaningful and never SET, so a torn audit line read as a shorter ledger rather than a refusal -- a measured zero from an unwired counter. A lost line corrupts all three things the folded ledger decides: the `Confirmed` set that stops one coin being attributed twice, the claimant count whose `> 1` guard then fails OPEN for the survivor of a dropped rival, and the per-id revision fold, which can present a `Confirmed` record as `Submitted` when the LATEST line is the one lost. `funding::committed_funding_coin_ids` already refuses a whole selection on this signal; resolving is a money write and gets the same treatment, before any chain read so it cannot be mistaken for "the chain had nothing to say". Refs #412 #435 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/resolve.rs | 36 ++++- .../src/mirror/resolve_tests.rs | 148 ++++++++++++++++++ crates/dig-node-service/src/spend_audit.rs | 28 +++- 3 files changed, 204 insertions(+), 8 deletions(-) diff --git a/crates/dig-node-service/src/mirror/resolve.rs b/crates/dig-node-service/src/mirror/resolve.rs index 833b63c0..eca25a9c 100644 --- a/crates/dig-node-service/src/mirror/resolve.rs +++ b/crates/dig-node-service/src/mirror/resolve.rs @@ -77,6 +77,9 @@ pub(super) struct ResolveSummary { pub chain_unreadable: usize, /// Open records left alone because more than one of them claims the same coin. pub ambiguous: usize, + /// Entries the audit record lost. Non-zero means the sweep refused outright and resolved + /// nothing, so a test can tell a refusal apart from an empty ledger. + pub unreadable_lines: usize, } /// Promote every open mirror-coin spend whose coin the chain now shows. @@ -105,6 +108,26 @@ pub(super) fn resolve_landed_spends( } }; + // A LOST LINE IS A REFUSAL, NOT A SHORTER LEDGER — and the refusal happens HERE, before any + // chain read, so it cannot be mistaken for "the chain had nothing to say". + // + // The same folded ledger decides three things below, and a dropped line corrupts all three: the + // `Confirmed` set that stops one coin being attributed twice; the claimant count, whose `> 1` + // guard then fails OPEN for the survivor of a dropped rival; and the per-id revision fold, which + // can present a `Confirmed` record as `Submitted` when it is the LATEST line that was lost. + // + // `funding::committed_funding_coin_ids` already refuses a whole selection on the same signal for + // the same reason. Resolving is a money write; it gets the same treatment. + if ledger.unreadable_lines > 0 { + tracing::warn!( + target: "mirror", + unreadable_lines = ledger.unreadable_lines, + "the spend audit record has unreadable entries; no landed mirror spend is resolved this pass" + ); + summary.unreadable_lines = ledger.unreadable_lines; + return summary; + } + // Coins already attributed to a settled record. A second record must not be confirmed against // a coin some other spend is already recorded as having created. let attributed: HashSet<&str> = ledger @@ -121,10 +144,15 @@ pub(super) fn resolve_landed_spends( if record.kind.as_str() != kinds::MIRROR_COIN { continue; } - if !matches!( - record.status, - SpendStatus::Submitted | SpendStatus::Unresolved { .. } - ) { + // The open set is "the bundle MAY HAVE REACHED THE NETWORK", decided in one place by + // `SpendStatus::may_have_reached_the_network`. Re-listing the variants here dropped + // `Failed { stage: Broadcast }` -- a spend that failed AFTER the bundle went to a mempool, + // which is an unknown wearing a failure's name; treating it as settled is how a spend that + // actually landed stops being chased. + // + // NOT `!is_terminal()`, which is a different question and admits `Pending` -- a spend that + // never left this node, and which no coin can be attributed to. + if !record.status.may_have_reached_the_network() { continue; } let coin_id = match &record.intended_coin_id { diff --git a/crates/dig-node-service/src/mirror/resolve_tests.rs b/crates/dig-node-service/src/mirror/resolve_tests.rs index d01b880c..5903d397 100644 --- a/crates/dig-node-service/src/mirror/resolve_tests.rs +++ b/crates/dig-node-service/src/mirror/resolve_tests.rs @@ -372,3 +372,151 @@ fn a_spend_that_never_reached_the_network_is_not_confirmed_by_a_coin_that_matche ); drop(recorded); } + +/// **Proves:** an audit record with an unparseable entry resolves NOTHING, and does not even ask the +/// chain. +/// +/// **Catches:** the sweep reading the folded ledger less carefully than its own sibling does. +/// [`crate::mirror::funding::committed_funding_coin_ids`] refuses an entire selection on a non-zero +/// [`crate::spend_audit::SpendLedger::unreadable_lines`], for the reason it states in its own +/// comment: the lost lines may be exactly the ones naming a committed coin. The same folded ledger +/// decides three things here, and a lost line corrupts all three — the `Confirmed` set that stops a +/// coin being attributed twice, the claimant count whose `> 1` guard then fails OPEN for the +/// survivor of a dropped rival, and the per-id revision fold, which can present a `Confirmed` +/// record as `Submitted` when it is the LATEST line that was lost. +/// +/// # The fixture varies ONE thing +/// +/// The record is a reclaim whose coin the chain genuinely confirms — the exact input +/// [`a_landed_reclaim_is_confirmed_at_the_height_the_chain_reported`] resolves successfully. The +/// only difference is one corrupt line appended after it. So a green here cannot come from there +/// being nothing to resolve: without the guard the sweep confirms this record, and the assertion on +/// `asked` shows the refusal happens at the ledger rather than incidentally at the chain read. +#[test] +fn an_unparseable_audit_entry_resolves_nothing_and_asks_the_chain_nothing() { + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + let audit_id = open_spend(&journal, "aa", "11", 7, Some("c9")); + + // A crash mid-append: the ordinary case this module already models. Written by hand because a + // truncated line is by definition not something the producer can emit. + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(log.path()) + .expect("the audit log exists"); + f.write_all(b"{\"id\":\"trunc\",\"revi\n") + .expect("append a torn line"); + } + + let chain = Chain { + confirmations: HashMap::from([(id("c9"), LANDED_HEIGHT)]), + ..Chain::default() + }; + + let summary = resolve_landed_spends(&journal, &chain, &[]); + + assert_eq!( + summary.unreadable_lines, 1, + "the refusal is reported, not only logged" + ); + assert_eq!( + summary.recorded, 0, + "a lost line is a refusal, not a shorter ledger" + ); + assert!( + matches!(status_of(&log, &audit_id), SpendStatus::Unresolved { .. }), + "a record left open by an unreadable ledger is the honest state" + ); + assert!( + chain.asked.borrow().is_empty(), + "the refusal must happen at the ledger, before any chain read — otherwise it is only \ + accidentally safe" + ); +} + +/// **Proves:** a create whose broadcast call ERRORED after the network had already admitted the +/// bundle is still chased and resolved, while a spend that failed at SIGNING never is. +/// +/// **Catches:** the open set hard-coded as `Submitted | Unresolved` instead of asked of +/// [`SpendStatus::is_terminal`]. `mirror/lifecycle.rs`'s `Err` arm — the sibling of the very `Ok` +/// arm this resolver follows up — writes `Failed { stage: Broadcast }`, and +/// [`crate::spend_audit::FailureStage::money_may_have_moved`] already says that stage is an unknown +/// wearing a failure's name. Hard-coding the pair therefore left exactly the input class the module +/// documents as most needing to be chased permanently unchased: the coin sits on chain while +/// `dign spends` reports a failure for money that moved, which is dig-node#412's symptom surviving +/// the fix for it. +/// +/// # Two records, opposite directions +/// +/// Widening on `is_terminal` must not widen to everything. `Failed { Signing }` is terminal because +/// no signed bundle ever existed, and its bond is deliberately given a coin on chain too — a +/// resolver that widened by dropping the filter rather than by asking the stage confirms it and +/// fails here. The writer is asserted directly as well, because the sweep's filter and +/// [`SpendJournal::resolve_landed`]'s guard mask each other. +#[test] +fn a_create_whose_broadcast_errored_after_admission_is_resolved_but_a_signing_failure_is_not() { + use crate::spend_audit::FailureStage; + + let dir = tempfile::tempdir().expect("tempdir"); + let (journal, log) = journal(dir.path()); + + // The `Err` arm of `mirror/lifecycle.rs`'s broadcast: `failed`, never `submitted`. So there is + // no `intended_coin_id`, and the create path's `(store, root, epoch)` key is the only key — + // which is why this must be a create rather than a reclaim. + let broadcast = journal.begin(intent("aa", "11", 7)); + let broadcast_id = broadcast.id().to_string(); + journal.failed(&broadcast, FailureStage::Broadcast, "connection reset"); + drop(broadcast); + + let signing = journal.begin(intent("bb", "22", 7)); + let signing_id = signing.id().to_string(); + journal.failed(&signing, FailureStage::Signing, "no spendable $DIG"); + drop(signing); + + let chain = Chain { + confirmations: HashMap::from([(id("c1"), LANDED_HEIGHT), (id("c2"), LANDED_HEIGHT)]), + ..Chain::default() + }; + + let summary = resolve_landed_spends( + &journal, + &chain, + &[mirror("c1", "aa", "11", 7), mirror("c2", "bb", "22", 7)], + ); + + assert_eq!( + summary.recorded, 1, + "exactly the broadcast failure is chased" + ); + assert_eq!( + status_of(&log, &broadcast_id), + SpendStatus::Confirmed { + height: LANDED_HEIGHT, + coin_id: TargetCoinId(id("c1")), + }, + "a bundle the network admitted before the call errored did move money, and the record must \ + say so" + ); + assert!( + matches!( + status_of(&log, &signing_id), + SpendStatus::Failed { + stage: FailureStage::Signing, + .. + } + ), + "nothing was ever signed, so a coin matching this bond was created by some OTHER spend" + ); + + // The authoritative guard, asked directly: the sweep's filter and the writer's mask each other. + assert_eq!( + journal + .resolve_landed(&signing_id, TargetCoinId(id("c2")), LANDED_HEIGHT) + .expect("the record is readable"), + crate::spend_audit::Resolution::NotOpen, + "the writer must refuse a spend that never reached the network, whatever asks it" + ); +} diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 20a77b9f..1738ce0b 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -298,6 +298,27 @@ impl SpendStatus { /// a `Failed` entry whose stage [may have moved money](FailureStage::money_may_have_moved) — it /// is an unknown wearing a failure's name, and calling it settled is how a spend that actually /// landed stops being chased. + /// May this spend's bundle have REACHED THE NETWORK — i.e. is it worth asking the chain about? + /// + /// Distinct from [`is_terminal`](Self::is_terminal), and the distinction is the whole point. + /// `Pending` is NOT terminal (nothing has settled it) but also never left this node, so no coin + /// can be attributed to it; resolving one would confirm a spend that was never broadcast. + /// `Failed { stage: Broadcast }` is the mirror case — a failure by name, but the bundle may + /// already sit in a mempool, so it MUST stay chaseable. + /// + /// Written as an exhaustive `match`, like + /// [`FailureStage::money_may_have_moved`](FailureStage::money_may_have_moved) and for the same + /// reason: a new variant must be a compile error here, forcing whoever adds it to decide which + /// side it falls on. A `matches!` at a call site routes around that, which is exactly how + /// `Failed { stage: Broadcast }` came to be dropped from the resolver's open set. + pub fn may_have_reached_the_network(&self) -> bool { + match self { + SpendStatus::Submitted | SpendStatus::Unresolved { .. } => true, + SpendStatus::Failed { stage, .. } => stage.money_may_have_moved(), + SpendStatus::Pending | SpendStatus::Confirmed { .. } => false, + } + } + pub fn is_terminal(&self) -> bool { match self { SpendStatus::Confirmed { .. } => true, @@ -938,10 +959,9 @@ impl SpendJournal { let Some(current) = ledger.records.iter().find(|r| r.id == id) else { return Ok(Resolution::NoSuchSpend); }; - if !matches!( - current.status, - SpendStatus::Submitted | SpendStatus::Unresolved { .. } - ) { + // OPEN here means "the bundle may have reached the network", which is NOT the same question + // as `is_terminal`. See `SpendStatus::may_have_reached_the_network`. + if !current.status.may_have_reached_the_network() { return Ok(Resolution::NotOpen); } From 7e1d6d6f8a2e0074d4635ac1002c254a2365778c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:13:05 -0700 Subject: [PATCH 9/9] test(mirror): prove the lifecycle self-heals -- convergence over passes, not one pass Closes #464 --- .../src/mirror/converge_tests.rs | 566 ++++++++++++++++++ crates/dig-node-service/src/mirror/mod.rs | 2 + 2 files changed, 568 insertions(+) create mode 100644 crates/dig-node-service/src/mirror/converge_tests.rs diff --git a/crates/dig-node-service/src/mirror/converge_tests.rs b/crates/dig-node-service/src/mirror/converge_tests.rs new file mode 100644 index 00000000..1aebcbad --- /dev/null +++ b/crates/dig-node-service/src/mirror/converge_tests.rs @@ -0,0 +1,566 @@ +//! Convergence of the mirror lifecycle (dig-node#464) — the two headline invariants, proven as +//! PROPERTIES OVER SUCCESSIVE PASSES rather than as the outcome of one. +//! +//! # What these tests assert that the rest of the suite does not +//! +//! Every other test here asserts what ONE pass decides. That is the right unit for a decision, and +//! it is the wrong unit for self-healing: a test that drives a failure and asserts the failure has +//! proven the failure, not the healing. The claim this ticket makes is about the SECOND pass — +//! *"even if the first attempt failed"* — so every test below runs the pass again, after the +//! failure, and asserts the desired state is reached. +//! +//! The two invariants, stated as convergence: +//! +//! * a `.dig` store on disk with no coin gets a coin, N passes later; +//! * a mirror coin with no matching store on disk is spent, N passes later. +//! +//! # Why N is small, and where it stops being small +//! +//! **N = 2 for every hazard driven here**, and that is not a tuning choice — it falls out of the +//! architecture. A pass is a pure function of two observations (`observe_disk`, `observe_chain`) +//! and re-derives the whole desired state each round, so a failed attempt is not remembered as a +//! failure: it simply is not in the observation next time, and the same decision is taken again. +//! Nothing carries a retry counter because nothing needs one; the pass IS the retry. +//! +//! The one place N is larger is the in-flight suppression (§25.4.6), which is deliberately keyed on +//! an audit record rather than on an observation — because a broadcast create is invisible on chain +//! for a confirmation window, and a second pass inside that window would pay for a duplicate coin. +//! That suppression is bounded by the EPOCH, not by the round: see +//! [`a_stuck_open_record_suppresses_only_until_the_epoch_rolls`], which pins the bound from both +//! sides. +//! +//! # The double is a WORLD, not a recorder +//! +//! [`World`] applies effects to its own chain: a successful create makes a coin appear, a +//! successful reclaim makes one disappear. A recording double cannot express convergence at all — +//! it answers the same thing on pass 2 as on pass 1, so "the coin exists now" is unassertable and +//! the strongest available assertion degrades to "it tried again", which is the weaker claim this +//! ticket exists to reject. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use super::plan::{Bond, HeldMirror, ReclaimReason}; +use super::runner::{MirrorEffects, ObservedCapsule, PassContext, PassError, PassRunner}; +use crate::spend_audit::{ + kinds, Asset, AuditedBond, Authority, SpendIntent, SpendJournal, SpendKind, SpendLog, +}; +use dig_node_control_interface::results::CollateralRequirementResult; +use dig_node_core::CapsuleProvenance; + +const NOW_EPOCH: i64 = 100; +const REQUIRED: u64 = 1_000; + +/// A confirmation height that is not zero, not an epoch, and not any other literal in this file, so +/// an implementation that reused one of those could not accidentally agree with an assertion. +const LANDED_HEIGHT: u32 = 9_224_641; + +fn id(tag: &str) -> String { + let mut s = tag.to_string(); + while s.len() < 64 { + s.push('0'); + } + s.truncate(64); + s +} + +fn bond(store: &str, root: &str) -> Bond { + Bond::new(id(store), id(root)) +} + +fn coin(tag: &str, store: &str, root: &str, epoch: i64) -> HeldMirror { + HeldMirror { + coin_id: id(tag), + store_id: id(store), + root: id(root), + epoch, + collateral_dig_base_units: REQUIRED, + } +} + +/// A chain and a disk that CHANGE when the node acts on them. +/// +/// The failure schedules are counters rather than id lists on purpose: the interesting fixture is +/// "the first attempt fails and the next one does not", which is a statement about attempt ORDER. +/// An id list would fail every attempt on that id forever, and a test built on one could only ever +/// assert that the node kept trying — never that it arrived. +#[derive(Default)] +struct World { + disk: RefCell>, + chain: RefCell>, + balance: Cell, + /// Make the BALANCE READ fail while this is positive, decremented per pass that reads it. + balance_read_failures: Cell, + /// Fail this many create attempts before the first one is allowed to succeed. + create_failures: Cell, + /// Fail this many reclaim attempts before the first one is allowed to succeed. + reclaim_failures: Cell, + /// Distinguishes the coins successive creates produce, so a duplicate is visible as a duplicate. + minted: Cell, +} + +impl World { + fn holding(bonds: &[Bond]) -> Rc { + let world = World::default(); + *world.disk.borrow_mut() = bonds + .iter() + .map(|b| ObservedCapsule { + bond: b.clone(), + provenance: CapsuleProvenance::Held, + }) + .collect(); + world.balance.set(REQUIRED * 100); + Rc::new(world) + } + + fn with_coins(self: Rc, coins: &[HeldMirror]) -> Rc { + *self.chain.borrow_mut() = coins.to_vec(); + self + } + + /// Does the chain show a coin bonding this `(store, root)` for `epoch`? + fn has_coin_for(&self, bond: &Bond, epoch: i64) -> bool { + self.chain + .borrow() + .iter() + .any(|c| c.store_id == bond.store_id && c.root == bond.root && c.epoch == epoch) + } + + fn coin_count(&self) -> usize { + self.chain.borrow().len() + } +} + +impl MirrorEffects for World { + fn observe_disk(&self) -> Result, PassError> { + Ok(self.disk.borrow().clone()) + } + + fn observe_chain(&self) -> Result, PassError> { + Ok(self.chain.borrow().clone()) + } + + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError> { + Ok(self + .chain + .borrow() + .iter() + .any(|c| c.coin_id == coin_id) + .then_some(LANDED_HEIGHT)) + } + + fn dig_balance_base_units(&self) -> Result { + let remaining = self.balance_read_failures.get(); + if remaining > 0 { + self.balance_read_failures.set(remaining - 1); + return Err(PassError::Wallet("the wallet is locked".to_string())); + } + Ok(self.balance.get()) + } + + fn reclaim(&self, mirror: &HeldMirror, _reason: ReclaimReason) -> Result<(), PassError> { + let remaining = self.reclaim_failures.get(); + if remaining > 0 { + self.reclaim_failures.set(remaining - 1); + return Err(PassError::Wallet("no fee coin".to_string())); + } + self.chain + .borrow_mut() + .retain(|c| c.coin_id != mirror.coin_id); + Ok(()) + } + + fn create(&self, bond: &Bond, epoch: i64, amount: u64) -> Result<(), PassError> { + let remaining = self.create_failures.get(); + if remaining > 0 { + self.create_failures.set(remaining - 1); + return Err(PassError::Wallet("no selectable coin".to_string())); + } + let minted = self.minted.get() + 1; + self.minted.set(minted); + self.chain.borrow_mut().push(HeldMirror { + coin_id: id(&format!("c{minted}")), + store_id: bond.store_id.clone(), + root: bond.root.clone(), + epoch, + collateral_dig_base_units: amount, + }); + Ok(()) + } +} + +/// The runner OWNS its effects and exposes no accessor, so a test that must both drive the world +/// and read it afterwards shares one through an [`Rc`]. Delegating rather than adding an accessor +/// keeps the production type's surface unchanged: an accessor would exist only for tests, and the +/// next reader could not tell that from a capability something else relies on. +impl MirrorEffects for Rc { + fn observe_disk(&self) -> Result, PassError> { + World::observe_disk(self) + } + + fn observe_chain(&self) -> Result, PassError> { + World::observe_chain(self) + } + + fn coin_confirmation(&self, coin_id: &str) -> Result, PassError> { + World::coin_confirmation(self, coin_id) + } + + fn dig_balance_base_units(&self) -> Result { + World::dig_balance_base_units(self) + } + + fn reclaim(&self, mirror: &HeldMirror, reason: ReclaimReason) -> Result<(), PassError> { + World::reclaim(self, mirror, reason) + } + + fn create(&self, bond: &Bond, epoch: i64, amount: u64) -> Result<(), PassError> { + World::create(self, bond, epoch, amount) + } +} + +fn known() -> CollateralRequirementResult { + CollateralRequirementResult::Known { + epoch: NOW_EPOCH as u64, + protocol_version: 1, + required_per_store_dig_base_units: REQUIRED, + stores: 1, + owners: 1, + multiplier_micros: 1_000_000, + handicap_dig_base_units: 0, + } +} + +fn ctx_at(epoch: i64) -> PassContext { + PassContext { + now_unix_ms: 1_000_000, + current_epoch: epoch, + requirement: known(), + margin_bp: 0, + creates_enabled: true, + } +} + +fn ctx() -> PassContext { + ctx_at(NOW_EPOCH) +} + +/// A runner over a real, empty audit log, with the presence debounce already satisfied. +/// +/// The log is REAL rather than absent so the in-flight suppression is genuinely exercised: a test +/// whose ledger could not be read would suppress nothing for the wrong reason, and would then pass +/// under an implementation that suppressed forever. +fn runner(world: &Rc, log: SpendLog) -> PassRunner> { + PassRunner::new(Rc::clone(world), log).with_settling_window_ms(0) +} + +fn log_at(dir: &std::path::Path, tag: &str) -> SpendLog { + SpendLog::at(dir.join(format!("{tag}.jsonl"))) +} + +fn create_intent(store: &str, root: &str, epoch: i64) -> SpendIntent { + SpendIntent { + kind: SpendKind::new(kinds::MIRROR_COIN), + purpose: "create a mirror coin".to_string(), + authority: Authority { + principal: "node".to_string(), + grant: "mirror-collateral".to_string(), + }, + asset: Asset::Dig, + amount_mojos: REQUIRED, + fee_mojos: 0, + store_id: Some(id(store)), + bond: Some(AuditedBond { + root: id(root), + epoch, + }), + } +} + +/// **INVARIANT 1, after a failed create: a held store gets a coin, and N is 2.** +/// +/// The first create is refused outright, exactly as an unselectable funding coin refuses it. The +/// second pass is the assertion: nothing recorded the refusal, so the same bond is decided again +/// from the same two observations and the coin appears. +/// +/// The control that makes this discriminating is the SECOND held bond, whose create is never +/// refused. Without it, an implementation that created everything twice — or that ignored the +/// failure entirely — would look identical to one that healed. +#[test] +fn a_refused_create_is_retried_by_the_next_pass_and_the_store_ends_bonded() { + let dir = tempfile::tempdir().expect("a temp dir"); + let refused = bond("aa", "11"); + let control = bond("bb", "22"); + + let world = World::holding(&[refused.clone(), control.clone()]); + world.create_failures.set(1); + let mut runner = runner(&world, log_at(dir.path(), "refused-create")); + + let first = runner.run(&ctx()).expect("the pass observes"); + assert_eq!( + first.created, + Vec::::new(), + "the pass stops cleanly at the refused create rather than continuing past it: {first:?}" + ); + assert!( + !world.has_coin_for(&refused, NOW_EPOCH), + "and the refused bond genuinely has no coin after pass 1" + ); + + let second = runner.run(&ctx()).expect("the pass observes"); + + assert_eq!( + second.created, + vec![refused.clone(), control.clone()], + "pass 2 re-derives both creates from the same observations; the failure was not remembered" + ); + assert!( + world.has_coin_for(&refused, NOW_EPOCH), + "N = 2: the store whose first create failed is bonded on the very next pass" + ); + assert!( + world.has_coin_for(&control, NOW_EPOCH), + "and the control bond, which was never refused, is bonded too" + ); + + let third = runner.run(&ctx()).expect("the pass observes"); + assert_eq!( + third.created, + Vec::::new(), + "and the node then STOPS: a coin the chain shows is not paid for twice. An implementation \ + that retried from a record instead of from the observation is red here, not above: \ + {third:?}" + ); + assert_eq!( + world.coin_count(), + 2, + "two stores, two coins, no duplicate bought by the retry" + ); +} + +/// **INVARIANT 2, after a failed reclaim: an orphaned coin is spent, and N is 2.** +/// +/// A reclaim consults no ledger, no balance, no requirement and no switch — it is decided from the +/// disk and the chain alone — so the retry needs nothing to lift. This test pins that: the first +/// reclaim fails, and the second pass spends the coin with no intervening change of any kind. +/// +/// The control is the SECOND orphaned coin, whose reclaim is never refused, asserting the failing +/// one did not stop it. A single-coin fixture cannot see that. +#[test] +fn a_failed_reclaim_is_retried_by_the_next_pass_and_the_orphan_ends_spent() { + let dir = tempfile::tempdir().expect("a temp dir"); + let orphan = coin("dd", "aa", "11", NOW_EPOCH); + let other = coin("ee", "bb", "22", NOW_EPOCH); + + // Nothing on disk: both coins bond stores this node no longer holds. + let world = World::holding(&[]).with_coins(&[orphan.clone(), other.clone()]); + world.reclaim_failures.set(1); + let mut runner = runner(&world, log_at(dir.path(), "failed-reclaim")); + + let first = runner.run(&ctx()).expect("the pass observes"); + assert_eq!( + first.reclaim_failures.len(), + 1, + "the first reclaim failed, as the fixture arranged: {first:?}" + ); + assert_eq!( + first.reclaimed, + vec![other.clone()], + "and the failure did not stop the reclaim behind it" + ); + assert_eq!( + world.coin_count(), + 1, + "so exactly one coin is still on chain after pass 1" + ); + + let second = runner.run(&ctx()).expect("the pass observes"); + + assert_eq!( + second.reclaimed, + vec![orphan.clone()], + "N = 2: the coin whose reclaim failed is spent on the very next pass, with nothing changed" + ); + assert_eq!( + world.coin_count(), + 0, + "and no mirror coin outlives the store it advertises" + ); + + let third = runner.run(&ctx()).expect("the pass observes"); + assert!( + third.reclaimed.is_empty() && third.reclaim_failures.is_empty(), + "a coin already spent is not re-spent: the retry is driven by the chain, not by a record" + ); +} + +/// **A shortfall heals when the money arrives, and nothing records the shortfall.** +/// +/// `funding.rs` refuses the whole create when short, and `plan::split_by_funds` stops at the first +/// unaffordable bond. Neither writes anything: the refusal happens before a record is opened, so +/// there is nothing that could suppress the retry. This asserts the whole of that as one property — +/// the wallet is topped up between passes and the bond converges at N = 2. +#[test] +fn an_unfunded_bond_is_created_on_the_pass_after_the_money_arrives() { + let dir = tempfile::tempdir().expect("a temp dir"); + let waiting = bond("aa", "11"); + + let world = World::holding(std::slice::from_ref(&waiting)); + world.balance.set(REQUIRED - 1); // short by one base unit, and by nothing else + let mut runner = runner(&world, log_at(dir.path(), "unfunded")); + + let first = runner.run(&ctx()).expect("the pass observes"); + assert!( + first.created.is_empty(), + "a wallet one base unit short creates nothing: {first:?}" + ); + assert!( + first.stopped_at.is_none(), + "and being short is not reported as a failure of the pass" + ); + + world.balance.set(REQUIRED); + let second = runner.run(&ctx()).expect("the pass observes"); + + assert_eq!( + second.created, + vec![waiting.clone()], + "N = 2: the pass after the money arrives creates the coin, with no operator action" + ); + assert!(world.has_coin_for(&waiting, NOW_EPOCH)); +} + +/// **An unreadable WALLET degrades the create half only, and heals; reclaims never waited.** +/// +/// The balance read is what prices creates, so a wallet that cannot be read must not abort the +/// pass — rule 1 of `pass.rs` reached through the observation instead of through the gate. The +/// fixture holds BOTH halves at once: an orphaned coin to reclaim and a held store to bond. Pass 1 +/// must reclaim and not create; pass 2, with the wallet readable, must create. +/// +/// Asserting only the create half would pass under an implementation that returned `Err` from the +/// whole pass, which is the regression that strands collateral on an exhausted node. +#[test] +fn an_unreadable_wallet_defers_creates_reclaims_anyway_and_heals_next_pass() { + let dir = tempfile::tempdir().expect("a temp dir"); + let waiting = bond("aa", "11"); + let orphan = coin("dd", "bb", "22", NOW_EPOCH); + + let world = + World::holding(std::slice::from_ref(&waiting)).with_coins(std::slice::from_ref(&orphan)); + world.balance_read_failures.set(1); + let mut runner = runner(&world, log_at(dir.path(), "wallet-unreadable")); + + let first = runner + .run(&ctx()) + .expect("an unreadable WALLET is not an unreadable pass"); + assert_eq!( + first.reclaimed, + vec![orphan], + "the orphan comes home even though the wallet could not be read: {first:?}" + ); + assert!( + first.created.is_empty(), + "and nothing is created, because nothing could be priced against a balance" + ); + + let second = runner.run(&ctx()).expect("the pass observes"); + assert_eq!( + second.created, + vec![waiting.clone()], + "N = 2: the pass after the wallet answers creates the coin" + ); + assert!(world.has_coin_for(&waiting, NOW_EPOCH)); +} + +/// **A failed broadcast retries, and the retry is safe because the CHAIN decides — not the record.** +/// +/// A broadcast failure is an unknown wearing a failure's name: the bundle may already sit in a +/// mempool. So the retry must be safe for a reason stronger than "the record says it failed", and +/// the reason is that a coin which DID land is visible in `observe_chain` and covers the bond +/// through `plan`, leaving nothing to create. +/// +/// Both directions are driven, because only the pair discriminates. A one-sided test would pass +/// under an implementation that never retried at all. +#[test] +fn a_broadcast_failure_retries_when_no_coin_landed_and_stands_down_when_one_did() { + let dir = tempfile::tempdir().expect("a temp dir"); + let subject = bond("aa", "11"); + + // Direction 1: the bundle really did not land. Nothing is on chain, so the bond is uncovered + // and the next pass creates it. + let world = World::holding(std::slice::from_ref(&subject)); + world.create_failures.set(1); + let mut nothing_landed = runner(&world, log_at(dir.path(), "broadcast-nothing-landed")); + nothing_landed.run(&ctx()).expect("the pass observes"); + let retried = nothing_landed.run(&ctx()).expect("the pass observes"); + assert_eq!( + retried.created, + vec![subject.clone()], + "no coin landed, so the bond is still uncovered and is created again: {retried:?}" + ); + + // Direction 2: the bundle DID land, in the window between the failure and this pass. The coin + // is in the observation, and the same code must now decide NOT to create. + let world = World::holding(std::slice::from_ref(&subject)) + .with_coins(&[coin("dd", "aa", "11", NOW_EPOCH)]); + let mut it_landed = runner(&world, log_at(dir.path(), "broadcast-it-landed")); + let report = it_landed.run(&ctx()).expect("the pass observes"); + assert!( + report.created.is_empty(), + "a coin the chain shows covers the bond, whatever a record says of the attempt: {report:?}" + ); + assert_eq!( + world.coin_count(), + 1, + "and the wallet did not pay for a second coin" + ); +} + +/// **The one suppression that is NOT re-derived from observation, bounded from both sides.** +/// +/// §25.4.6 suppresses a create whose audit record is open (`Pending` or `Submitted`) for the +/// current epoch. That record is a ledger entry, not an observation, so it does NOT lift when the +/// next pass looks at the chain — and if the bundle never lands, nothing settles it. This is the +/// one hazard in dig-node#464 whose N is not 2. +/// +/// **N is bounded by the EPOCH, not by the round.** The suppression is keyed on the record's own +/// epoch, so it lapses at the rollover: `MIRROR_EPOCH_LENGTH_MS / MIRROR_ROUND_LENGTH_MS` +/// = 1,008 passes in the worst case — seven days of an uncollateralised store, then healing with no +/// operator action. +/// +/// Pinned from BOTH sides, because a bound tested only from below can only confirm itself: the +/// create is suppressed WITHIN the epoch (so the test is not vacuous) and taken at the NEXT epoch +/// (so the bound is real rather than infinite). An implementation whose suppression never lifted +/// would pass the first assertion and fail the second. +#[test] +fn a_stuck_open_record_suppresses_only_until_the_epoch_rolls() { + let dir = tempfile::tempdir().expect("a temp dir"); + let log = log_at(dir.path(), "stuck-record"); + let journal = SpendJournal::new(log.clone()); + let stuck = bond("aa", "11"); + + // An open record for this epoch that nothing will ever settle — the shape a hard kill leaves + // between `begin` and any outcome, and the shape a `Submitted` bundle that never confirms + // leaves indefinitely. Held alive so no `Drop` resolves it. + let _open = journal.begin(create_intent("aa", "11", NOW_EPOCH)); + + let world = World::holding(std::slice::from_ref(&stuck)); + let mut runner = runner(&world, log); + + let within = runner.run(&ctx()).expect("the pass observes"); + assert!( + within.created.is_empty(), + "within the epoch the open record suppresses the create, by design: {within:?}" + ); + + // Not a shorter round and not a retry counter: the same node, one epoch later. + let after = runner + .run(&ctx_at(NOW_EPOCH + 1)) + .expect("the pass observes"); + assert_eq!( + after.created, + vec![stuck.clone()], + "the suppression LAPSES at the rollover, so the bound is one epoch rather than forever" + ); + assert!(world.has_coin_for(&stuck, NOW_EPOCH + 1)); +} diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index baa651d3..529b7025 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -78,6 +78,8 @@ //! `*_mojos` and come from separate coins so a fee can never shave collateral. pub mod advertise; +#[cfg(test)] +mod converge_tests; pub mod funding; pub mod lifecycle; pub mod observe;