From 55f0696a0fcb86355b54d11dd8db2e539e4b54b2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:12:30 -0700 Subject: [PATCH 01/12] chore(mirror): open the funding lane at 0.201.0 (#461, #463) Stub commit so the lane's state is durable from its first action. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2eba9957..a7f38052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.195.0" +version = "0.201.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 37dc3503..3c037118 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.201.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 9c16f0dfb11471b013854df3a2b89223f54ce890 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:22:20 -0700 Subject: [PATCH 02/12] fix(mirror): skip an unauthenticatable funding candidate instead of refusing the create One coin nobody can authenticate, at the publicly derivable operator $DIG address, carrying a larger declared amount than any honest coin, was walked first by largest-first selection and aborted the whole selection. Cost to the attacker: dust. Cost to the operator: no mirror coin could ever be created. A skipped candidate is counted, reported and logged at warn, and leaves the candidate POOL rather than occupying a selection input slot -- so dust cannot reinstate the denial by volume either. Refs #461 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 145 +++++++++++++++--- .../tests/mirror_operator_funding.rs | 80 ++++++++-- 2 files changed, 191 insertions(+), 34 deletions(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 2b4c9acf..bf52069f 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -166,8 +166,45 @@ pub fn committed_funding_coin_ids(log: &SpendLog) -> Result, Fun .collect()) } +/// A candidate that was passed over, and why — the counted, reportable half of a selection. +/// +/// Carried out of the selection rather than only logged, so that a caller (and a test) can assert +/// how many candidates were passed over. A skip that is invisible to its caller is the silence this +/// type exists to break: the same code path that passes over a stranger's coin also passes over a +/// coin this node genuinely owns when lineage handling has a bug. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedCandidate { + /// The candidate's coin id, hex-encoded, so an operator can look it up on chain. + pub coin_id: String, + /// What could not be established about it. + pub reason: String, +} + +/// The outcome of a funding selection: the coins to spend, and the candidates passed over. +#[derive(Debug, Clone)] +pub struct FundingSelection { + /// The authenticated, spendable $DIG coins covering the requirement. + pub cats: Vec, + /// Candidates at the operator's address that could not be authenticated, in the order walked. + pub skipped: Vec, +} + /// Select spendable $DIG `Cat`s of the OPERATOR wallet covering `need_dig_base_units`. /// +/// The `Vec` half of [`select_operator_dig_cats_detailed`], for callers that fund a spend and +/// have nothing to say about the candidates that were passed over. +pub fn select_operator_dig_cats( + source: &S, + owner_puzzle_hash: Bytes32, + need_dig_base_units: u64, + committed: &HashSet, +) -> Result, FundingError> { + select_operator_dig_cats_detailed(source, owner_puzzle_hash, need_dig_base_units, committed) + .map(|selection| selection.cats) +} + +/// Select spendable $DIG `Cat`s of the OPERATOR wallet, reporting what was passed over. +/// /// `need_dig_base_units` is $DIG in base units (1 DIG = 1_000, never mojos) and is the epoch's /// derived requirement — `apply_safety_margin(required_per_store, margin_bp)`, `SPEC.md` §25.3 — /// carried in from the planner. Nothing here re-derives it: this function selects coins to cover a @@ -176,12 +213,38 @@ pub fn committed_funding_coin_ids(log: &SpendLog) -> Result, Fun /// `committed` is the output of [`committed_funding_coin_ids`], passed in rather than read here so /// that one pass takes one reading of the audit record, in the same way it takes one reading of the /// disk and one of the balance. -pub fn select_operator_dig_cats( +/// +/// # An unauthenticatable candidate is SKIPPED, not fatal (dig-node#461) +/// +/// The scan address is `dig_cat_puzzle_hash(owner)`, derivable by anyone from the operator's public +/// owner puzzle hash, and anyone may pay a coin to any puzzle hash. Noise at a public address is the +/// normal condition of a public address, so a candidate that cannot be authenticated is not this +/// operator's coin and costs one authentication attempt and nothing else. +/// +/// Refusing the whole selection on the first such candidate — which this function used to do — +/// composed with two other facts into a denial of service that cost the attacker dust: selection is +/// largest-first, so a coin with a large declared amount is walked FIRST, and one unspent coin of +/// that shape at the public address meant no honest coin was ever reached, on any pass, forever. +/// +/// Two properties keep the skip from becoming a different failure: +/// +/// * **A skip is counted and reported**, never swallowed. The same path covers a genuine defect in +/// lineage handling, and a selection that quietly discarded the operator's own coins while +/// reporting a shortfall would be indistinguishable from an empty wallet. +/// * **A skip costs no selection budget.** Candidates are authenticated against the POOL, and a +/// failed one is removed from the pool before the requirement is covered again — so the coins +/// handed back are honest coins only, and their number is a function of the honest set alone. An +/// attacker who could spend an input slot per dust coin would reinstate the same denial in a +/// slower form. +/// +/// A chain that cannot ANSWER is still fatal, and deliberately so: an unreadable source is not a +/// verdict about a coin, and treating it as one would silently shrink the wallet. +pub fn select_operator_dig_cats_detailed( source: &S, owner_puzzle_hash: Bytes32, need_dig_base_units: u64, committed: &HashSet, -) -> Result, FundingError> { +) -> Result { if need_dig_base_units == 0 { return Err(FundingError::ZeroCollateral); } @@ -194,29 +257,73 @@ pub fn select_operator_dig_cats( // honours the flag and one that ignores it are indistinguishable from the returned rows, and // selecting a spent coin produces a bundle the mempool rejects for reasons that look nothing // like this. - let candidates: Vec<_> = records + let mut pool: Vec<_> = records .into_iter() .filter(|r| !r.is_spent()) .filter(|r| !committed.contains(&hex::encode(r.coin.coin_id()))) .collect(); - let available = candidates - .iter() - .fold(0u64, |sum, r| sum.saturating_add(r.coin.amount)); - - let selected = select_largest_first(candidates, need_dig_base_units, |r| { - (r.coin.amount, r.coin.coin_id()) - }) - .map_err(|_| FundingError::Insufficient { - have_dig_base_units: available, - need_dig_base_units, - })?; - - let mut cats = Vec::with_capacity(selected.len()); - for record in &selected { - cats.push(authenticate(source, record, owner_puzzle_hash)?); + // Authenticating a candidate costs a chain read, so each is authenticated at most once however + // many times the requirement is covered again. + let mut authenticated: Vec<(String, Cat)> = Vec::new(); + let mut skipped: Vec = Vec::new(); + + loop { + let available = pool + .iter() + .fold(0u64, |sum, r| sum.saturating_add(r.coin.amount)); + + let selected = select_largest_first(pool.clone(), need_dig_base_units, |r| { + (r.coin.amount, r.coin.coin_id()) + }) + .map_err(|_| FundingError::Insufficient { + // The honest total: every candidate proven unauthenticatable has already left the pool, + // so this is what the operator can actually spend rather than what the address happens + // to hold. Reporting the latter would tell an operator their wallet holds money that is + // not theirs. + have_dig_base_units: available, + need_dig_base_units, + })?; + + let mut rejected: Option = None; + let mut cats = Vec::with_capacity(selected.len()); + for record in &selected { + let candidate_id = hex::encode(record.coin.coin_id()); + if let Some((_, cat)) = authenticated.iter().find(|(id, _)| id == &candidate_id) { + cats.push(*cat); + continue; + } + match authenticate(source, record, owner_puzzle_hash) { + Ok(cat) => { + authenticated.push((candidate_id, cat)); + cats.push(cat); + } + Err(FundingError::Unauthenticated { coin_id, reason }) => { + tracing::warn!( + coin_id = %coin_id, + reason = %reason, + concat!( + "a coin at the operator's $DIG address could not be proven spendable ", + "and was passed over; if it is one of this node's own coins, its ", + "lineage is not readable from the chain" + ) + ); + skipped.push(SkippedCandidate { coin_id, reason }); + rejected = Some(record.coin.coin_id()); + break; + } + // A source that cannot answer is not a verdict about the coin. + Err(fatal) => return Err(fatal), + } + } + + match rejected { + // The rejected candidate leaves the POOL, so it can neither be walked again nor occupy + // an input slot, and the requirement is covered again from what remains. + Some(coin_id) => pool.retain(|r| r.coin.coin_id() != coin_id), + None => return Ok(FundingSelection { cats, skipped }), + } } - Ok(cats) } /// Turn one candidate record into a spendable [`Cat`], or refuse. diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs index 8cfd5b82..a797fbef 100644 --- a/crates/dig-node-service/tests/mirror_operator_funding.rs +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -28,7 +28,7 @@ use chia_protocol::{Bytes32, CoinSpend}; use chia_sha2::Sha256; use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; use dig_node_service::mirror::funding::{ - dig_cat_puzzle_hash, select_operator_dig_cats, FundingError, + dig_cat_puzzle_hash, select_operator_dig_cats, select_operator_dig_cats_detailed, FundingError, }; use support::{ordinary_dig_coins, wallet, Wallet}; @@ -400,27 +400,77 @@ fn the_number_of_coins_drawn_follows_the_requirement_it_was_given() { ); } -/// **A candidate that cannot be authenticated refuses the WHOLE selection.** +/// **An unauthenticatable candidate is SKIPPED, and the create still funds from the honest coin.** /// -/// Anyone may pay a coin to any puzzle hash. A coin whose creating spend is not on chain cannot have -/// its lineage proof reconstructed, so it is not spendable — and dropping it and proceeding with the -/// rest would fund the create from a short set, which is the failure this crate refuses by design. +/// The address a create funds from is `dig_cat_puzzle_hash(owner)`, which anyone can derive from +/// the operator's public owner puzzle hash, and selection is largest-first. So a coin nobody can +/// authenticate, carrying a larger declared amount than any honest coin, is examined FIRST on every +/// pass. Aborting the selection there let one dust coin block every mirror create a node would ever +/// make, for as long as it sat unspent (dig-node#461). /// -/// The fixture keeps a genuine, sufficient coin beside the unauthenticated one, so the refusal is -/// visibly caused by the bad candidate rather than by an empty wallet. +/// The fixture places exactly that coin ahead of a genuine, sufficient one — the ordering is what is +/// under test, so a fixture whose bad coin is smaller would pass against the aborting version too. #[test] -fn an_unauthenticatable_candidate_refuses_the_selection_rather_than_being_skipped() { +fn an_unauthenticatable_candidate_is_skipped_and_the_honest_coin_still_funds_the_create() { let operator = operator(); let mut chain = Chain::default(); - chain.fund(&operator, &[REQUIRED], salt(1)); - // Larger, so largest-first reaches it FIRST and a skip would be observable as a success. + let honest = chain.fund(&operator, &[REQUIRED], salt(1)); + // Larger, so largest-first reaches it FIRST: this is the coin the attack relies on. chain.fund_without_lineage(&operator, &[REQUIRED * 2], salt(3)); - let err = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) - .expect_err("a candidate could not be proven spendable"); - assert!( - matches!(err, FundingError::Unauthenticated { .. }), - "expected an authentication refusal, got {err:?}" + let selection = + select_operator_dig_cats_detailed(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect("the operator's own coin covers the requirement"); + + let funded: Vec = selection.cats.iter().map(|c| c.coin.coin_id()).collect(); + assert_eq!( + funded, honest, + "the create must fund from the operator's genuine coin, not refuse because of a stranger's" + ); + assert_eq!( + selection.skipped.len(), + 1, + "the skip is COUNTED, so a genuine lineage bug cannot hide as silence: {:?}", + selection.skipped + ); +} + +/// **Dust cannot exhaust the selection by volume either.** +/// +/// Skipping one bad coin is not enough if a skip costs an input slot: an attacker who can place one +/// coin can place many, and a skip that consumed selection budget would reinstate the same denial in +/// a slower form. The property asserted is that the SELECTED set contains only honest coins — its +/// size is a function of the honest coins alone, however many strangers' coins precede them. +#[test] +fn many_unauthenticatable_candidates_do_not_consume_the_selections_input_budget() { + let operator = operator(); + let mut chain = Chain::default(); + // Two honest coins, neither sufficient alone, so the selection genuinely walks more than one. + let honest = chain.fund(&operator, &[REQUIRED * 2 / 3, REQUIRED / 2], salt(1)); + // Ten strangers' coins, every one larger than either honest coin, so all ten are walked first. + let dust: Vec = (1..=10).map(|n| REQUIRED * 10 + n).collect(); + chain.fund_without_lineage(&operator, &dust, salt(4)); + + let selection = + select_operator_dig_cats_detailed(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect("the operator's two coins cover the requirement between them"); + + let funded: Vec = selection.cats.iter().map(|c| c.coin.coin_id()).collect(); + assert_eq!( + funded.len(), + 2, + "both honest coins are needed and both must be selected" + ); + for id in &funded { + assert!( + honest.contains(id), + "a selected input is not one of the operator's own coins" + ); + } + assert_eq!( + selection.skipped.len(), + 10, + "every stranger's coin is skipped and counted, and none is selected" ); } From 91b203e0f3d868e8db7ab648ab2304ec7a56ee47 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:25:19 -0700 Subject: [PATCH 03/12] feat(mirror): alert the operator once when funding blocks a mirror create The mirror pass runs unattended every ten minutes and refused a create for want of funds silently, forever -- the operator's content stops being bonded while every surface still looks healthy. Policy, stated in the code because how often it fires is the design: alert on the transition into the short state; while short, again only if the remedy changes or the deficit grows 50%; once on recovery; never on an unreadable balance, which also does not count as recovery. Reclaims are NOT covered: lifecycle.rs builds every reclaim at fee = 0 with no fee coins, unconditionally, so a reclaim cannot fail for want of funds. Refs #463 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index bf52069f..792959c8 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -326,6 +326,215 @@ pub fn select_operator_dig_cats_detailed( } } +/// What an operator must actually DO about a funding shortfall. +/// +/// Two remedies, because they are different actions and telling an operator the wrong one wastes +/// their money or their afternoon: a wallet holding too little $DIG needs topping up, and a wallet +/// holding enough $DIG in too many pieces needs consolidating. A message that said only "funding +/// failed" would leave both operators guessing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FundingRemedy { + /// The wallet does not hold enough $DIG. Add funds. + TopUp, + /// The wallet holds enough, in too many coins to spend in one bundle. Consolidate them. + Consolidate, +} + +/// A message for a person, raised when the funding state of the mirror pass CHANGES. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundingAlert { + /// A one-line headline. + pub title: String, + /// The body: what happened, in what amounts, and what to do about it. + pub body: String, + /// The action this alert is asking for, or `None` when it reports a recovery. + pub remedy: Option, +} + +/// What one mirror pass observed about funding — the input the alert gate decides on. +/// +/// Deliberately only three shapes. A pass that could not READ the balance is not a pass that found +/// it short, and is not represented here at all: reporting a shortfall on no evidence is precisely +/// the money lie this crate refuses elsewhere, so the caller maps an unreadable balance to +/// [`FundingObservation::Unknown`], which never alerts and never clears the state either. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FundingObservation { + /// A create was funded, or none was needed. The operator wallet is not blocking anything. + Healthy, + /// A create was refused for want of funds. + Short { + /// What the operator can spend, in $DIG base units. + have_dig_base_units: u64, + /// What the create needed, in the same units. + need_dig_base_units: u64, + /// The action that would clear it. + remedy: FundingRemedy, + }, + /// The funding state could not be established this pass. + Unknown, +} + +impl FundingObservation { + /// Read a pass's funding outcome off the error the selection refused with. + /// + /// The match is exhaustive on purpose. A new [`FundingError`] variant — the bounded-input + /// refusal in flight is one — will not compile until someone decides whether it is a shortfall + /// an operator can act on, and if so which remedy it names. A wildcard arm here would silently + /// classify every future refusal as unknown, which is the shape that ships a surface reporting + /// nothing about a condition it was built to report. + pub fn from_error(error: &FundingError) -> Self { + match error { + FundingError::Insufficient { + have_dig_base_units, + need_dig_base_units, + } => FundingObservation::Short { + have_dig_base_units: *have_dig_base_units, + need_dig_base_units: *need_dig_base_units, + remedy: FundingRemedy::TopUp, + }, + // Not shortfalls. An unreadable chain or audit record says nothing about the balance, + // and a create asked for at zero collateral is a caller defect, not an empty wallet. + FundingError::Chain(_) | FundingError::CommitmentsUnreadable(_) => { + FundingObservation::Unknown + } + FundingError::Unauthenticated { .. } | FundingError::ZeroCollateral => { + FundingObservation::Unknown + } + } + } +} + +/// Decides WHEN the operator is told their node has stopped bonding content (dig-node#463). +/// +/// # The policy, stated here because "how often does this fire" is the whole design +/// +/// The mirror pass runs unattended every ten minutes. A notification per pass is 144 a day, which +/// trains an operator to dismiss them and loses the one that mattered inside the noise. So: +/// +/// * **On the TRANSITION into a funding-short state — once.** Consecutive short passes after that +/// raise nothing. +/// * **While short, again only on a MATERIAL change**: the remedy changes (topping up and +/// consolidating are different actions, so an operator following the old one is being misled), or +/// the deficit grows by at least [`MATERIAL_DEFICIT_GROWTH_PERCENT`] over the deficit last +/// alerted on. Growth by a fixed proportion is self-limiting — each further alert needs a deficit +/// half again as large as the last — so a steadily worsening shortfall cannot become a stream. +/// * **Once on RECOVERY**, so an operator who acted learns it worked without having to watch for it. +/// * **Never on [`FundingObservation::Unknown`]**, which also does not CLEAR the state: an +/// unreadable pass is not evidence of recovery, and treating it as one would re-alert on the next +/// short pass for a shortfall that never went away. +/// +/// The gate holds one pass of state and no clock. It is deliberately not the delivery mechanism: +/// it answers whether to speak, and the caller decides how. +#[derive(Debug, Default)] +pub struct FundingAlertGate { + /// The shortfall the last alert was raised for, or `None` while not in the short state. + alerted: Option<(FundingRemedy, u64)>, +} + +/// How much a deficit must grow, in percent of the last alerted deficit, to speak again. +/// +/// 50% rather than a few percent: this fires while an operator has already been told, and the +/// question it answers is "has this become a materially different problem", not "has the number +/// moved". A small threshold turns a slowly worsening shortfall back into the per-pass stream this +/// gate exists to prevent. +pub const MATERIAL_DEFICIT_GROWTH_PERCENT: u64 = 50; + +impl FundingAlertGate { + /// Feed one pass's observation, and get back the alert to raise — or nothing. + pub fn observe(&mut self, observation: &FundingObservation) -> Option { + match observation { + FundingObservation::Unknown => None, + FundingObservation::Healthy => self.alerted.take().map(|_| FundingAlert { + title: "DIG mirror collateral resumed".into(), + body: concat!( + "The operator wallet can fund mirror collateral again. Your content is being ", + "bonded on the next pass." + ) + .into(), + remedy: None, + }), + FundingObservation::Short { + have_dig_base_units, + need_dig_base_units, + remedy, + } => { + let deficit = need_dig_base_units.saturating_sub(*have_dig_base_units); + let speak = match self.alerted { + None => true, + Some((last_remedy, last_deficit)) => { + last_remedy != *remedy || grew_materially(last_deficit, deficit) + } + }; + if !speak { + return None; + } + self.alerted = Some((*remedy, deficit)); + Some(shortfall_alert( + *have_dig_base_units, + *need_dig_base_units, + *remedy, + )) + } + } + } +} + +/// Whether `deficit` is materially worse than the one already reported. +/// +/// A first shortfall of zero — reachable only if `have` already met `need`, which is not a +/// shortfall — would make every later deficit infinitely larger, so growth from zero is treated as +/// material outright rather than divided by it. +fn grew_materially(last_deficit: u64, deficit: u64) -> bool { + let threshold = last_deficit + .saturating_add(last_deficit.saturating_mul(MATERIAL_DEFICIT_GROWTH_PERCENT) / 100); + deficit > threshold +} + +/// The operator-facing text for a shortfall. +/// +/// $DIG is rendered in whole DIG (1 DIG = 1_000 base units) because that is the unit an operator +/// buys and holds; base units would be a true figure nobody can act on. No coin id and no address +/// appears — a desktop notification is read over a shoulder and shown on a lock screen, and neither +/// figure helps the operator do the thing this message is asking for. +fn shortfall_alert( + have_dig_base_units: u64, + need_dig_base_units: u64, + remedy: FundingRemedy, +) -> FundingAlert { + let short = need_dig_base_units.saturating_sub(have_dig_base_units); + let body = match remedy { + FundingRemedy::TopUp => format!( + concat!( + "Your node cannot bond content: it needs {} DIG of collateral for this epoch and ", + "the operator wallet holds {} DIG that it can spend, so it is {} DIG short. Add ", + "$DIG to the operator wallet. Until then no new content is collateralised and it ", + "earns nothing." + ), + whole_dig(need_dig_base_units), + whole_dig(have_dig_base_units), + whole_dig(short) + ), + FundingRemedy::Consolidate => format!( + concat!( + "Your node cannot bond content: the operator wallet holds enough $DIG for the {} ", + "DIG this epoch requires, but in too many separate coins to spend at once. ", + "Consolidate the wallet's $DIG into fewer coins — adding more will not help." + ), + whole_dig(need_dig_base_units) + ), + }; + FundingAlert { + title: "DIG node cannot bond content".into(), + body, + remedy: Some(remedy), + } +} + +/// Render $DIG base units as whole DIG with three decimal places (1 DIG = 1_000 base units). +fn whole_dig(base_units: u64) -> String { + format!("{}.{:03}", base_units / 1_000, base_units % 1_000) +} + /// Turn one candidate record into a spendable [`Cat`], or refuse. /// /// The lineage proof is reconstructed from the spend that CREATED the coin — which is the spend that @@ -380,6 +589,176 @@ fn authenticate( #[cfg(test)] mod tests { use super::*; + + /// A short pass, repeated. The operator hears about it ONCE. + fn short(have: u64) -> FundingObservation { + FundingObservation::Short { + have_dig_base_units: have, + need_dig_base_units: 100_000, + remedy: FundingRemedy::TopUp, + } + } + + /// **Ten consecutive short passes raise exactly one alert.** + /// + /// The pass runs every ten minutes, so the failure this asserts against is not a wrong message + /// but 144 correct ones a day, which is how the one that matters gets dismissed with the rest. + /// The count is asserted rather than "the first one alerts", because an implementation that + /// alerted on passes one and seven satisfies the weaker claim. + #[test] + fn consecutive_short_passes_alert_once_rather_than_once_per_pass() { + let mut gate = FundingAlertGate::default(); + let raised: Vec = (0..10) + .filter_map(|_| gate.observe(&short(60_000))) + .collect(); + assert_eq!( + raised.len(), + 1, + "one transition into the short state is one alert: {raised:?}" + ); + assert_eq!(raised[0].remedy, Some(FundingRemedy::TopUp)); + assert!( + raised[0].body.contains("40.000"), + "the operator is told how much they are short: {}", + raised[0].body + ); + } + + /// **Recovering and falling short again alerts again.** + /// + /// The pairing matters: a gate that simply latched forever would pass the test above and leave + /// an operator who fixed the problem, and then hit it again, permanently unwarned. Three + /// distinct outcomes are asserted from one sequence — the recovery speaks, and the second + /// shortfall speaks again. + #[test] + fn a_recovery_then_a_second_shortfall_alerts_again() { + let mut gate = FundingAlertGate::default(); + assert!(gate.observe(&short(60_000)).is_some(), "the transition in"); + assert!(gate.observe(&short(60_000)).is_none(), "still short"); + + let recovered = gate.observe(&FundingObservation::Healthy).expect("recovery"); + assert_eq!(recovered.remedy, None, "a recovery asks for no action"); + assert!( + gate.observe(&FundingObservation::Healthy).is_none(), + "a healthy pass after a healthy pass is not news" + ); + + assert!( + gate.observe(&short(60_000)).is_some(), + "falling short again is a new transition and must be reported" + ); + } + + /// **An unreadable pass neither alerts nor clears the state.** + /// + /// Two properties in one sequence, and the second is the one an implementation is likely to + /// miss: treating "unknown" as recovery would re-alert on the very next short pass for a + /// shortfall that never went away, turning an unstable chain read into a notification stream. + #[test] + fn an_unknown_funding_state_is_silent_and_does_not_count_as_a_recovery() { + let mut gate = FundingAlertGate::default(); + assert!(gate.observe(&short(60_000)).is_some()); + assert!( + gate.observe(&FundingObservation::Unknown).is_none(), + "a pass that could not read the balance has nothing to report" + ); + assert!( + gate.observe(&short(60_000)).is_none(), + "the shortfall never went away, so it must not be announced a second time" + ); + } + + /// **A materially worse shortfall speaks; a slightly worse one does not.** + /// + /// Both sides of the bound, from one starting point, because a threshold tested only from below + /// confirms nothing but its own existence. At a 40_000 deficit the bound is 60_000: 59_000 is + /// silent and 61_000 speaks. + #[test] + fn the_deficit_must_grow_materially_before_it_is_reported_again() { + let mut gate = FundingAlertGate::default(); + assert!(gate.observe(&short(60_000)).is_some(), "deficit 40_000"); + assert!( + gate.observe(&short(41_000)).is_none(), + "a deficit of 59_000 is under the 50% bound and is not a new problem" + ); + assert!( + gate.observe(&short(39_000)).is_some(), + "a deficit of 61_000 is over the bound and is worth interrupting for" + ); + } + + /// **A changed remedy speaks even when the deficit has not moved.** + /// + /// Top up and consolidate are opposite instructions. An operator acting on a stale one adds + /// money to a wallet that already had enough, so the remedy is reported on its own account + /// rather than only when an amount crosses a threshold. + #[test] + fn a_changed_remedy_is_reported_even_at_an_unchanged_deficit() { + let mut gate = FundingAlertGate::default(); + assert!(gate.observe(&short(60_000)).is_some()); + let switched = gate + .observe(&FundingObservation::Short { + have_dig_base_units: 60_000, + need_dig_base_units: 100_000, + remedy: FundingRemedy::Consolidate, + }) + .expect("the remedy changed, so the previous instruction is now wrong"); + assert!( + switched.body.contains("Consolidate"), + "the message must name the action that now applies: {}", + switched.body + ); + } + + /// **An unreadable balance is never classified as a shortfall.** + /// + /// `BalanceUnreadable` exists because "we could not read it" and "you do not have enough" are + /// one `Ok` apart and mean opposite things. This asserts the classification at the boundary the + /// alert gate reads from, so a chain blip can never produce a message telling an operator to + /// spend money. + #[test] + fn an_unreadable_source_is_not_reported_to_the_operator_as_a_shortfall() { + assert_eq!( + FundingObservation::from_error(&FundingError::Chain("timeout".into())), + FundingObservation::Unknown + ); + assert_eq!( + FundingObservation::from_error(&FundingError::CommitmentsUnreadable("torn".into())), + FundingObservation::Unknown + ); + assert_eq!( + FundingObservation::from_error(&FundingError::Insufficient { + have_dig_base_units: 1, + need_dig_base_units: 2, + }), + FundingObservation::Short { + have_dig_base_units: 1, + need_dig_base_units: 2, + remedy: FundingRemedy::TopUp, + }, + "a genuine shortfall must still reach the operator" + ); + } + + /// **No coin id and no address reaches a desktop notification.** + /// + /// The test looks for an identifier's SHAPE — a long unbroken run of hex — rather than for a + /// particular string, so it still fires if someone later interpolates a coin id that no fixture + /// here happens to name. Prose is full of hex letters, which is why the run length is what + /// discriminates. + #[test] + fn an_alert_never_carries_a_coin_id_or_an_address() { + let mut gate = FundingAlertGate::default(); + let alert = gate.observe(&short(60_000)).expect("the transition in"); + let text = format!("{} {}", alert.title, alert.body); + for token in text.split_whitespace() { + let hexish = token.trim_matches(|c: char| !c.is_ascii_alphanumeric()); + assert!( + hexish.len() < 16 || !hexish.chars().all(|c| c.is_ascii_hexdigit()), + "an alert is shown on a lock screen and must carry no identifier: {text}" + ); + } + } use crate::spend_audit::{ kinds, Asset, Authority, FailureStage, FundingCoinId, SpendIntent, SpendJournal, SpendKind, Submission, TargetCoinId, From 0ff61471804d929745c82c400b8233cd588f298a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:28:23 -0700 Subject: [PATCH 04/12] style(mirror): rustfmt the funding alert tests Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 792959c8..810857f4 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -636,7 +636,9 @@ mod tests { assert!(gate.observe(&short(60_000)).is_some(), "the transition in"); assert!(gate.observe(&short(60_000)).is_none(), "still short"); - let recovered = gate.observe(&FundingObservation::Healthy).expect("recovery"); + let recovered = gate + .observe(&FundingObservation::Healthy) + .expect("recovery"); assert_eq!(recovered.remedy, None, "a recovery asks for no action"); assert!( gate.observe(&FundingObservation::Healthy).is_none(), From fde43d3d3a652c37dba6f6648375cc83daf202b2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 16:16:36 -0700 Subject: [PATCH 05/12] feat(mirror): deliver the funding alert from the pass, carrying the gate across runners dig-node#463's gate was a library nobody called. Its nine unit tests each drive ONE gate over many observations, so all nine stay green against a node that builds a fresh gate every pass and notifies 144 times a day. - `PassError::Funding(FundingError)` carries the refusal structurally. It was flattened to a string in `funding_refusal`, so the only surface that can tell an operator what to DO would have had to recover the classification from prose. Display delegates, so every consumer that only renders a PassError is unchanged. - `PassRunner` holds the gate beside the presence tracker, and the scheduler carries both across the runners it rebuilds each round. - `PassReport::funding_alert` returns the message; the runner also logs it at warn, so it reaches stderr on a node that cannot write its state dir (#440). - A create that stopped for a NON-funding reason maps to `Unknown`, so an unrelated failure cannot be announced to an operator as a recovery. Two tests cover the wiring itself: the alert fires once across rebuilt runners and never from a stringly refusal, and an unrelated failure neither alerts nor clears a live shortfall. Co-Authored-By: Claude --- .../dig-node-service/src/mirror/lifecycle.rs | 11 +- crates/dig-node-service/src/mirror/runner.rs | 241 +++++++++++++++++- crates/dig-node-service/src/server.rs | 19 +- 3 files changed, 260 insertions(+), 11 deletions(-) diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index becdfe08..30892fd4 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -525,10 +525,12 @@ fn parse_id(hex_id: &str, what: &str) -> Result { /// condition of this NODE. Collapsing them would tell an operator to add funds when the truth is /// that a read timed out. fn funding_refusal(error: FundingError) -> PassError { - match &error { - FundingError::Chain(_) => PassError::Chain(error.to_string()), - _ => PassError::Wallet(error.to_string()), - } + // Carried WHOLE rather than split into `Chain` and `Wallet` by its variant. The distinction is + // not lost: it lives inside the `FundingError`, where the alert gate can read it -- and reads it + // better than a string tag could, since `Chain` and `CommitmentsUnreadable` are both "this pass + // learned nothing" while `Insufficient` and `TooManyInputs` are two different things to tell an + // operator to do. Splitting here is what forced the only consumer to guess from prose. + PassError::Funding(error) } /// The coin a reclaim of `mirror` CREATES, derived rather than guessed. @@ -1242,6 +1244,7 @@ mod tests { )], per_coin_dig_base_units: None, locked_dig_base_units: 4_242, + funding_alert: None, }; publish(&snapshot, &report, 9); diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 25d0e429..c4dbfd92 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -77,6 +77,17 @@ pub enum PassError { Chain(String), /// The wallet could not be read, or a spend could not be made. Wallet(String), + /// A create could not be FUNDED, carried structurally rather than as a message (dig-node#463). + /// + /// The refusal reaches this type already classified — shortfall, too-fragmented, unreadable — + /// and flattening it to a string here would mean the only surface that can tell an operator + /// what to DO about it would have to recover the classification by matching on prose. That is + /// the shape that reports a chain outage as an empty wallet, so the variant is kept whole and + /// [`FundingObservation::from_error`] reads it directly. + /// + /// `Display` delegates, so every existing consumer that only renders a `PassError` is + /// unaffected: the message an operator sees is the `FundingError`'s own. + Funding(super::funding::FundingError), } impl std::fmt::Display for PassError { @@ -85,6 +96,7 @@ impl std::fmt::Display for PassError { PassError::Disk(cause) => write!(f, "the capsule cache could not be scanned: {cause}"), PassError::Chain(cause) => write!(f, "the chain source could not be read: {cause}"), PassError::Wallet(cause) => write!(f, "the operator wallet could not act: {cause}"), + PassError::Funding(cause) => write!(f, "{cause}"), } } } @@ -197,10 +209,20 @@ pub struct PassReport { /// Read from each coin's own `collateral_dig_base_units` rather than from this epoch's /// requirement: a coin created under a previous requirement locks the previous amount. pub locked_dig_base_units: u64, + /// The message to put in front of a person, when THIS pass changed the funding story + /// (dig-node#463). + /// + /// `None` on the overwhelming majority of passes, and that is the feature: the pass runs every + /// ten minutes, so a field that were `Some` whenever funding is short would be 144 notifications + /// a day and would train an operator to ignore the one that mattered. The gate deciding this is + /// [`FundingAlertGate`], and it is carried ACROSS runners for the same reason the presence + /// tracker is -- a gate rebuilt each round has no memory of having spoken, and would speak every + /// round. + pub funding_alert: Option, } -/// Runs reconcile passes. Long-lived: it owns the presence tracker, which is the only state a pass -/// carries between runs. +/// Runs reconcile passes. Long-lived: it owns the presence tracker and the funding alert gate, the +/// only state a pass carries between runs. pub struct PassRunner { effects: E, presence: super::presence::PresenceTracker, @@ -212,6 +234,8 @@ pub struct PassRunner { /// 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, + /// Decides when a funding shortfall is worth telling a person about (dig-node#463). + funding_alerts: super::funding::FundingAlertGate, } impl PassRunner { @@ -223,9 +247,31 @@ impl PassRunner { journal: crate::spend_audit::SpendJournal::new(log.clone()), log, settling_window_ms: super::presence::SETTLING_WINDOW_MS, + funding_alerts: super::funding::FundingAlertGate::default(), } } + /// Adopt an existing funding alert gate, so dig-node#463's once-per-transition rule survives + /// across runners. + /// + /// Exactly the reason [`Self::with_presence`] exists, and exactly the same failure without it: + /// the scheduler rebuilds the runner every round, and a gate that starts empty every round has + /// never spoken, so it speaks. The dedup would then be a no-op that every unit test still + /// passes, because a unit test drives ONE gate over many observations. + pub fn with_funding_gate(mut self, gate: super::funding::FundingAlertGate) -> Self { + self.funding_alerts = gate; + self + } + + /// Hand the funding alert gate back, for the next round's runner. + /// + /// Takes `&mut self` rather than `self`, unlike [`Self::into_presence`], because the scheduler + /// needs BOTH pieces of carried state out of the same runner and two by-value takers cannot + /// both be called. This one goes first and the by-value one last. + pub fn take_funding_gate(&mut self) -> super::funding::FundingAlertGate { + std::mem::take(&mut self.funding_alerts) + } + /// Adopt an existing presence tracker, so §25.5's debounce survives across runners. /// /// A production scheduler rebuilds its [`MirrorEffects`] every round — the chain source is @@ -326,7 +372,7 @@ impl PassRunner { /// Step 4 and step 5: reclaims first, then creates, stopping cleanly. fn execute( - &self, + &mut self, decision: PassDecision, current_epoch: i64, locked_dig_base_units: u64, @@ -383,6 +429,45 @@ impl PassRunner { } } + // What THIS pass learned about the operator wallet, in the three shapes the gate decides + // on. Read off the structured refusal rather than off a message, which is why + // `PassError::Funding` carries the `FundingError` whole. + let observation = match &stopped_at { + Some((_, PassError::Funding(cause))) => { + super::funding::FundingObservation::from_error(cause) + } + // A create stopped for a reason that is not about funding -- no advertised URL, an + // unparseable id, a builder or broadcast failure. It is a real failure and it is + // reported in `stopped_at`, but it is not evidence about the wallet, and letting it + // CLEAR a live shortfall would tell an operator their funding recovered on the strength + // of an unrelated error. + Some(_) => super::funding::FundingObservation::Unknown, + // Nothing stopped. `per_coin` is `Some` exactly when the requirement was known, so this + // is a pass that funded every create it planned -- including a pass that planned none, + // which is the healthy state a node with nothing new to bond sits in. + None if per_coin_dig_base_units.is_some() => { + super::funding::FundingObservation::Healthy + } + // The requirement itself was unknown, so no create was priced and none was refused. + // Silent, and it does not clear a shortfall either. + None => super::funding::FundingObservation::Unknown, + }; + let funding_alert = self.funding_alerts.observe(&observation); + + // Logged HERE as well as returned, because the return value reaches a surface only if + // something renders it, and this line reaches an operator's stderr on a node whose state + // dir it cannot even write (dig-node#440). No coin id and no address: the same rule that + // shapes the alert body, for the same reason. + if let Some(alert) = &funding_alert { + tracing::warn!( + target: "mirror", + title = %alert.title, + remedy = ?alert.remedy, + "{}", + alert.body + ); + } + PassReport { reclaimed, created, @@ -391,6 +476,7 @@ impl PassRunner { states, per_coin_dig_base_units, locked_dig_base_units, + funding_alert, } } } @@ -546,6 +632,13 @@ mod tests { confirmations: std::collections::HashMap, /// Coin ids the chain cannot be ASKED about, kept apart from ones it reports absent. confirmation_fails: Vec, + /// The refusal a failing create returns, so a fixture can distinguish a create that failed + /// for want of FUNDS from one that failed for any other reason. + /// + /// `None` keeps the ordinary non-funding failure the other fixtures rely on. Without this + /// the `PassError::Funding` arm of the alert wiring is unreachable from any double, and an + /// operator-facing path no test can take reads as covered while never having run. + create_funding_failure: Option, } impl MirrorEffects for FakeEffects { @@ -593,7 +686,10 @@ mod tests { amount_dig_base_units, )); if self.create_fails.contains(bond) { - return Err(PassError::Wallet("no selectable coin".to_string())); + return Err(match &self.create_funding_failure { + Some(cause) => PassError::Funding(cause.clone()), + None => PassError::Wallet("no selectable coin".to_string()), + }); } Ok(()) } @@ -644,6 +740,143 @@ mod tests { PassRunner::new(effects, log).with_settling_window_ms(0) } + /// **The funding alert reaches the pass report, once, and CARRIES between runners.** + /// + /// **Proves** the wiring dig-node#463 is actually about. [`FundingAlertGate`] is a pure decision + /// with unit tests of its own, and every one of them drives ONE gate over many observations — so + /// all nine stay green against a node that constructs a fresh gate per pass and notifies 144 + /// times a day. The gate being correct and the gate being USED are different claims, and only + /// this test makes the second one. + /// + /// **Catches** two regressions that are invisible on every other signal: + /// + /// * dropping [`PassRunner::with_funding_gate`] from the scheduler, exactly as + /// `the_presence_tracker_carries_between_runners_and_a_fresh_one_suppresses` catches the same + /// omission for the presence tracker; + /// * flattening the refusal to a string on the way out of `MirrorEffects::create`. The + /// observation is read from `PassError::Funding`'s payload, so a `PassError::Wallet` carrying + /// the same prose produces no alert at all — asserted below as the third pass. + /// + /// The three passes are asserted as a SEQUENCE, and only the sequence discriminates. Pass one + /// alone is satisfied by a gate that speaks every time; pass two alone by a gate that never + /// speaks after the first ever call, whatever it is fed. + #[test] + fn a_funding_shortfall_alerts_once_across_rebuilt_runners_and_never_from_a_stringly_refusal() { + use super::super::funding::{FundingAlertGate, FundingError, FundingRemedy}; + + let capsule = bond("aa", "11"); + // Short by exactly one base unit, so the refusal is unambiguously a shortfall rather than + // an artefact of a wallet with nothing in it. + let shortfall = FundingError::Insufficient { + have_dig_base_units: REQUIRED - 1, + need_dig_base_units: REQUIRED, + }; + // A funded wallet, so the PLAN reaches a create and the only refusal is the one the fixture + // injects. A pass that never planned a create could not alert, and would pass vacuously. + let effects = |funding: Option| FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + balance: REQUIRED * 10, + create_fails: vec![capsule.clone()], + create_funding_failure: funding, + ..FakeEffects::default() + }; + + // One EMPTY audit log, shared: the fake effects never journal, so no pass suppresses the + // next through the in-flight set, and every pass reaches the same create for the same reason. + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let run = |gate, funding| { + let mut pass = runner(effects(funding), log.clone()).with_funding_gate(gate); + let report = pass.run(&ctx()).expect("the pass observes"); + (report.funding_alert, pass.take_funding_gate()) + }; + + // Pass 1: the transition into short. The operator has not been told, so they are told. + let (first, gate) = run(FundingAlertGate::default(), Some(shortfall.clone())); + let first = first.expect("the transition into a funding shortfall must reach the operator"); + assert_eq!( + first.remedy, + Some(FundingRemedy::TopUp), + "a wallet that is genuinely short is told to add $DIG, not to consolidate" + ); + + // Pass 2: the SAME shortfall, a REBUILT runner. Silent only because the gate was carried. + let (second, _gate) = run(gate, Some(shortfall.clone())); + assert_eq!( + second, None, + concat!( + "the second consecutive short pass alerted again, so the gate did not survive the ", + "runner being rebuilt; on the ten-minute pass timer that is 144 notifications a day" + ) + ); + + // Pass 3: the same wallet condition, refused WITHOUT structure. Nothing is alerted, because + // nothing can be classified -- which is why `funding_refusal` keeps the error whole. + let (third, _) = run(FundingAlertGate::default(), None); + assert_eq!( + third, None, + concat!( + "a refusal carrying only prose was classified as a shortfall; the observation must ", + "be read from the structured error, never recovered by matching on a message" + ) + ); + } + + /// **A create that failed for a NON-funding reason does not clear a live shortfall.** + /// + /// **Proves** the `Some(_) => Unknown` arm of the wiring. A pass that stopped because no URL is + /// advertised, or because the builder refused, is a real failure and is reported in + /// `stopped_at` — but it is not evidence about the wallet. + /// + /// **Catches** the plausible simplification that treats "did not stop for funding" as healthy. + /// That version reports a RECOVERY off the back of an unrelated error, telling an operator their + /// funding is fixed when it is not, and then alerts afresh on the next short pass. The recovery + /// message is the one an operator acts on by stopping work, so a false one is worse than silence. + #[test] + fn a_non_funding_create_failure_neither_alerts_nor_clears_a_live_shortfall() { + use super::super::funding::{FundingAlertGate, FundingError}; + + let capsule = bond("aa", "11"); + let shortfall = FundingError::Insufficient { + have_dig_base_units: REQUIRED - 1, + need_dig_base_units: REQUIRED, + }; + let effects = |funding: Option| FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + balance: REQUIRED * 10, + create_fails: vec![capsule.clone()], + create_funding_failure: funding, + ..FakeEffects::default() + }; + + // One EMPTY audit log, shared: the fake effects never journal, so no pass suppresses the + // next through the in-flight set, and every pass reaches the same create for the same reason. + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let run = |gate, funding| { + let mut pass = runner(effects(funding), log.clone()).with_funding_gate(gate); + let report = pass.run(&ctx()).expect("the pass observes"); + (report.funding_alert, pass.take_funding_gate()) + }; + + let (_, gate) = run(FundingAlertGate::default(), Some(shortfall.clone())); + // A create that fails without a funding cause: `PassError::Wallet`, the fixture default. + let (during, gate) = run(gate, None); + assert_eq!( + during, None, + "an unrelated create failure is not a recovery and must not be announced as one" + ); + // The shortfall then persists. If the pass above had CLEARED the state this alerts again. + let (after, _) = run(gate, Some(shortfall)); + assert_eq!( + after, None, + concat!( + "the shortfall was re-announced, so the unrelated failure cleared the gate's ", + "memory; the operator is told about a shortfall that never went away" + ) + ); + } + /// The presence tracker CARRIES between runners, and a fresh one on the second pass suppresses. /// /// **Proves** the reason [`PassRunner::with_presence`] exists. The production scheduler rebuilds diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 70d5667a..9ccb2be3 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2761,6 +2761,11 @@ fn spawn_mirror_passes( let journal = lifecycle::journal(); let mut presence = crate::mirror::presence::PresenceTracker::new(); + // Carried across passes for the same reason the presence tracker is: the runner is rebuilt + // every round, and dig-node#463's "alert once on the transition" rule is memory. A gate + // constructed per pass has never spoken, so it speaks every pass -- 144 notifications a day, + // which is precisely the behaviour the gate exists to prevent. + let mut funding_gate = crate::mirror::funding::FundingAlertGate::default(); loop { let epoch = match current_epoch_now() { @@ -2860,12 +2865,19 @@ fn spawn_mirror_passes( ); let mut pass = PassRunner::new(effects, crate::spend_audit::SpendLog::in_state_dir()) - .with_presence(std::mem::take(&mut presence)); + .with_presence(std::mem::take(&mut presence)) + .with_funding_gate(std::mem::take(&mut funding_gate)); let report = pass.run(&ctx); - (report, pass.into_presence()) + // The gate comes out FIRST: it is taken by `&mut`, and `into_presence` + // consumes the runner. Both must come back even when the pass returned an + // observation error, or a node whose chain source flaps re-alerts on every + // recovery for a shortfall that never went away. + let gate = pass.take_funding_gate(); + (report, pass.into_presence(), gate) }); - let (outcome, carried) = outcome; + let (outcome, carried, carried_gate) = outcome; presence = carried; + funding_gate = carried_gate; match outcome { Ok(report) => { @@ -3535,6 +3547,7 @@ mod tests { states: Vec::new(), per_coin_dig_base_units: None, locked_dig_base_units: 0, + funding_alert: None, } } From 424206f7983b592c5bfbe0d6e9e9eeef954d4b94 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 17:41:00 -0700 Subject: [PATCH 06/12] fix(mirror): authenticate before bounding, so an attacker cannot choose the operator's remedy Both figures the funding selection reports to an operator -- the total they can spend, and whether their money is merely in too many pieces -- were computed over candidates nobody had authenticated, at a puzzle hash anyone can derive. So a stranger who paid 33 small coins into it made an operator holding zero $DIG read 'the operator wallet holds enough $DIG ... adding more will not help', on every pass, forever: no planted coin was ever authenticated, so none was ever removed. Authentication now runs FIRST and the input bound is applied to what survives it. That moves the cost, so the cost is bounded in its own right by a constant, MAX_AUTHENTICATION_ATTEMPTS -- previously one chain round trip per planted coin, per pass, measured linear at 11/51/201 reads for 10/50/200 coins. A truncated walk refuses as CandidatesUnverifiable and states no total, because it does not have one; an understated total sends an operator to buy $DIG they already hold and grew_materially then suppresses the correction. Refs dig-node#469 --- crates/dig-node-service/src/mirror/funding.rs | 750 +++++++++++++----- crates/dig-node-service/src/mirror/pass.rs | 47 ++ crates/dig-node-service/src/mirror/runner.rs | 115 ++- 3 files changed, 701 insertions(+), 211 deletions(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 0ed805ed..0c50f4e3 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -98,6 +98,23 @@ pub enum FundingError { /// COVERED the target and was refused for the shape of the cover, not its size. need_dig_base_units: u64, }, + /// The authentication budget ran out before the requirement was covered, so how much the + /// operator can spend is UNKNOWN. + /// + /// Carries no total, deliberately. Every other refusal here can state one because it walked the + /// whole candidate pool; this one stopped early, so any total it quoted would be the total of a + /// truncated walk — understated by an amount chosen by whoever paid the coins that consumed the + /// budget. An understated total sends an operator to buy $DIG they already hold, and + /// `grew_materially` then suppresses the correction, so silence about the amount is the only + /// honest option (dig-node#469). + CandidatesUnverifiable { + /// How many candidates were authenticated before the budget ran out. + attempted: usize, + /// How many of those could not be proven spendable. + skipped: usize, + /// The margined requirement that was not reached, in $DIG base units. + need_dig_base_units: u64, + }, /// A create was asked for at zero collateral. /// /// Refused HERE, ahead of the builder, because zero is the one target for which selection @@ -123,7 +140,18 @@ impl std::fmt::Display for FundingError { FundingError::Unauthenticated { coin_id, reason } => write!( f, "coin {coin_id} at the operator address could not be proven spendable $DIG \ - ({reason}), so the whole selection is refused" + ({reason}), so it was passed over" + ), + FundingError::CandidatesUnverifiable { + attempted, + skipped, + need_dig_base_units, + } => write!( + f, + "the operator address holds more coins than one pass may authenticate: \ + {attempted} were checked and {skipped} could not be proven spendable, without \ + reaching the {need_dig_base_units} DIG base units this create needs. How much \ + the operator can spend is UNKNOWN, not low; no spend was attempted" ), FundingError::CommitmentsUnreadable(e) => write!( f, @@ -132,7 +160,10 @@ 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} authenticated $DIG coins and a mirror create \ + may draw at most {limit}; the wallet is not short, its $DIG is in too many \ + pieces. 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") @@ -166,18 +197,59 @@ impl std::fmt::Display for FundingError { /// 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. +/// The refusal happens over the AUTHENTICATED candidates only, and never over the raw scan. The +/// scan address is public, so the count of rows at it is chosen by whoever last paid a coin to it; +/// a bound applied to that count is a bound an attacker sets, and the refusal it produces is an +/// operator-facing money statement (`FundingRemedy::Consolidate`) that the same attacker therefore +/// chooses. Authentication is what turns a row into one of this operator's coins, so it runs first +/// and the bound is applied to what survives it (dig-node#469). +/// +/// The per-pass chain reads that ordering could otherwise cost are bounded separately and +/// explicitly, by [`MAX_AUTHENTICATION_ATTEMPTS`] — a constant, rather than a function of how many +/// coins a stranger sent. +/// /// 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). +/// $DIG coins a real operator wallet holds. 32 is chosen to bound the inputs one bundle draws, 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. pub const MAX_SELECTED_FUNDING_COINS: usize = 32; +/// The most candidates one selection may authenticate — the per-pass chain-read bound +/// (dig-node#469). +/// +/// # Why the input bound cannot serve as this bound +/// +/// [`MAX_SELECTED_FUNDING_COINS`] bounds how many coins one bundle SPENDS. It says nothing about +/// how many were examined to find them, and the two diverge exactly under attack: a stranger who +/// pays N coins into the publicly derivable operator address adds N candidates that must each be +/// authenticated — one chain round trip apiece — while contributing nothing to any selection. The +/// input bound is unreached the whole time, because the coins that fail authentication never enter +/// a selection at all. +/// +/// So the read count was previously a function of N, which is attacker-chosen and unbounded, and +/// the pass body runs under `tokio::task::block_in_place` — a worker is held for the whole walk and +/// the next pass cannot start inside it. +/// +/// # Why a constant, and why this one +/// +/// A constant is the property that matters: whatever a stranger pays into the address, one pass +/// costs at most this many reads. 128 is four times the input bound, so a wallet fragmented right +/// up to the point where [`FundingError::TooManyInputs`] is the correct answer still reaches that +/// answer with room for noise, while a wallet with nothing planted in it never comes close — the +/// walk stops the moment the requirement is covered, so a healthy pass pays for the coins it +/// spends and not for this bound. +/// +/// # Which direction it fails in +/// +/// CLOSED, and silently about money. Exhausting the budget yields +/// [`FundingError::CandidatesUnverifiable`], which states no total and maps to +/// [`FundingObservation::Unknown`] — so the pass raises no alert and clears none. That is the +/// honest reading: the walk was truncated, so the wallet was not measured. An attacker who buries +/// the honest coins under 128 larger unauthenticatable ones can stop this node bonding, which is a +/// denial of service and is recorded as one; what they cannot do is make the node tell its operator +/// something false about their money. +pub const MAX_AUTHENTICATION_ATTEMPTS: usize = 128; + /// 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 @@ -292,14 +364,38 @@ pub fn select_operator_dig_cats( /// * **A skip is counted and reported**, never swallowed. The same path covers a genuine defect in /// lineage handling, and a selection that quietly discarded the operator's own coins while /// reporting a shortfall would be indistinguishable from an empty wallet. -/// * **A skip costs no selection budget.** Candidates are authenticated against the POOL, and a -/// failed one is removed from the pool before the requirement is covered again — so the coins -/// handed back are honest coins only, and their number is a function of the honest set alone. An -/// attacker who could spend an input slot per dust coin would reinstate the same denial in a -/// slower form. +/// * **A skip costs no selection budget.** Candidates are authenticated BEFORE anything is +/// selected, so a candidate that fails is never in a selection, never occupies an input slot and +/// never contributes to a total. An attacker who could spend an input slot per dust coin would +/// reinstate the same denial in a slower form. /// /// A chain that cannot ANSWER is still fatal, and deliberately so: an unreadable source is not a /// verdict about a coin, and treating it as one would silently shrink the wallet. +/// +/// # Authentication comes FIRST, and is itself bounded (dig-node#469) +/// +/// Two figures leave this function and become sentences shown to an operator: the total they are +/// told they can spend, and whether their money is merely in too many pieces. Both were previously +/// computed over the raw scan — a set of rows at a PUBLIC puzzle hash, whose size and whose +/// declared amounts are chosen by whoever paid the last coin into it. So an attacker chose which of +/// two opposite instructions the operator was given, and could tell an operator holding nothing +/// that they held enough and that adding more would not help. +/// +/// The order is therefore: authenticate, then decide. Every candidate that reaches a total, a +/// selection or the input bound has had its creating spend executed and its lineage matched, so +/// every figure this function reports is a figure about this operator's own coins. +/// +/// That ordering moves the cost, so the cost is bounded in its own right. +/// [`MAX_AUTHENTICATION_ATTEMPTS`] caps the chain reads one call may make, and the walk stops as +/// soon as the authenticated total covers the requirement — so a funded wallet pays for the coins +/// it spends and nothing more, and an unfunded one pays a constant. A cheap pre-filter on the +/// candidate set would have bounded the work while leaving both figures attacker-chosen, which is +/// the half of the defect that is a money lie rather than a cost. +/// +/// When the cap is reached with the requirement still uncovered, this refuses with +/// [`FundingError::CandidatesUnverifiable`] and states NO total, because it does not have one: +/// coins remain unexamined, and a figure computed from a truncated walk is exactly the understated +/// total this ordering exists to remove. pub fn select_operator_dig_cats_detailed( source: &S, owner_puzzle_hash: Bytes32, @@ -324,80 +420,119 @@ pub fn select_operator_dig_cats_detailed( .filter(|r| !committed.contains(&hex::encode(r.coin.coin_id()))) .collect(); - // Authenticating a candidate costs a chain read, so each is authenticated at most once however - // many times the requirement is covered again. - let mut authenticated: Vec<(String, Cat)> = Vec::new(); + // Walked largest-first, the same order `select_largest_first` selects in, so the coins + // authenticated are the coins a covering selection would draw and the walk can stop the moment + // the requirement is covered. + pool.sort_by(|a, b| { + b.coin + .amount + .cmp(&a.coin.amount) + .then_with(|| a.coin.coin_id().cmp(&b.coin.coin_id())) + }); + + let mut authenticated: Vec<(dig_chainsource_interface::CoinRecord, Cat)> = Vec::new(); + let mut authenticated_total: u64 = 0; let mut skipped: Vec = Vec::new(); - - loop { - let available = pool - .iter() - .fold(0u64, |sum, r| sum.saturating_add(r.coin.amount)); - - let selected = select_largest_first(pool.clone(), need_dig_base_units, |r| { - (r.coin.amount, r.coin.coin_id()) - }) - .map_err(|_| FundingError::Insufficient { - // The honest total: every candidate proven unauthenticatable has already left the pool, - // so this is what the operator can actually spend rather than what the address happens - // to hold. Reporting the latter would tell an operator their wallet holds money that is - // not theirs. - have_dig_base_units: available, - need_dig_base_units, - })?; - - // The bound (dig-node#427) is applied to the CURRENT selection, which by construction - // contains no candidate already proven unauthenticatable -- those left the pool. So a - // skipped coin costs no input slot, and an attacker cannot reinstate dig-node#461 in a - // slower form by dusting the address until the bound alone refuses every create. - if selected.len() > MAX_SELECTED_FUNDING_COINS { - return Err(FundingError::TooManyInputs { - needed: selected.len(), - limit: MAX_SELECTED_FUNDING_COINS, - have_dig_base_units: available, - need_dig_base_units, - }); + let mut attempts: usize = 0; + let mut walked_whole_pool = true; + + for record in &pool { + // Enough of this operator's own money is proven spendable. Every further authentication is + // a chain read that cannot change the answer. + if authenticated_total >= need_dig_base_units { + break; } - - let mut rejected: Option = None; - let mut cats = Vec::with_capacity(selected.len()); - for record in &selected { - let candidate_id = hex::encode(record.coin.coin_id()); - if let Some((_, cat)) = authenticated.iter().find(|(id, _)| id == &candidate_id) { - cats.push(*cat); - continue; + if attempts >= MAX_AUTHENTICATION_ATTEMPTS { + walked_whole_pool = false; + break; + } + attempts += 1; + match authenticate(source, record, owner_puzzle_hash) { + Ok(cat) => { + authenticated_total = authenticated_total.saturating_add(record.coin.amount); + authenticated.push((record.clone(), cat)); } - match authenticate(source, record, owner_puzzle_hash) { - Ok(cat) => { - authenticated.push((candidate_id, cat)); - cats.push(cat); - } - Err(FundingError::Unauthenticated { coin_id, reason }) => { - tracing::warn!( - coin_id = %coin_id, - reason = %reason, - concat!( - "a coin at the operator's $DIG address could not be proven spendable ", - "and was passed over; if it is one of this node's own coins, its ", - "lineage is not readable from the chain" - ) - ); - skipped.push(SkippedCandidate { coin_id, reason }); - rejected = Some(record.coin.coin_id()); - break; - } - // A source that cannot answer is not a verdict about the coin. - Err(fatal) => return Err(fatal), + Err(FundingError::Unauthenticated { coin_id, reason }) => { + tracing::warn!( + coin_id = %coin_id, + reason = %reason, + concat!( + "a coin at the operator's $DIG address could not be proven spendable ", + "and was passed over; if it is one of this node's own coins, its ", + "lineage is not readable from the chain" + ) + ); + skipped.push(SkippedCandidate { coin_id, reason }); } + // A source that cannot answer is not a verdict about the coin. + Err(fatal) => return Err(fatal), } + } - match rejected { - // The rejected candidate leaves the POOL, so it can neither be walked again nor occupy - // an input slot, and the requirement is covered again from what remains. - Some(coin_id) => pool.retain(|r| r.coin.coin_id() != coin_id), - None => return Ok(FundingSelection { cats, skipped }), - } + // The walk was truncated and the requirement is uncovered, so the honest total is UNKNOWN + // rather than low. Refused as its own condition, which reports no amount at all — see the note + // above on why an understated total is worse than silence. + if !walked_whole_pool && authenticated_total < need_dig_base_units { + return Err(FundingError::CandidatesUnverifiable { + attempted: attempts, + skipped: skipped.len(), + need_dig_base_units, + }); + } + + // From here every input is an authenticated coin of this operator, so both figures below are + // statements about this operator's own money. + let cats = select_within_input_bound( + authenticated + .into_iter() + .map(|(record, cat)| (record.coin.amount, record.coin.coin_id(), cat)) + .collect(), + need_dig_base_units, + )?; + + Ok(FundingSelection { cats, skipped }) +} + +/// Cover `need_dig_base_units` from AUTHENTICATED coins, or refuse with the reason and the amount. +/// +/// Split out from [`select_operator_dig_cats_detailed`] because it is where both operator-facing +/// money figures are decided — the total in [`FundingError::Insufficient`] and the *you have enough, +/// it is in too many pieces* of [`FundingError::TooManyInputs`] — and because the caller's +/// authenticated coins cannot be fabricated in a test. A [`Cat`] is only produced by executing a +/// real lineage, so a test driving the whole function can only ever supply candidates that FAIL +/// authentication, and the bound would then be provable from one side and by accident. +/// +/// The input is `(amount, coin_id, payload)` per coin, already proven spendable by the caller. +/// Nothing here re-checks that, and nothing here reads the chain: the ordering that makes these +/// figures honest is the caller's, and this function's job is only to state them. +fn select_within_input_bound( + authenticated: Vec<(u64, Bytes32, T)>, + need_dig_base_units: u64, +) -> Result, FundingError> { + let authenticated_total = authenticated + .iter() + .fold(0u64, |sum, (amount, _, _)| sum.saturating_add(*amount)); + + let selected = select_largest_first(authenticated, need_dig_base_units, |(amount, id, _)| { + (*amount, *id) + }) + .map_err(|shortfall| FundingError::Insufficient { + // Every coin the chain would prove has been offered, so this is what the operator can + // actually spend rather than what the address happens to hold. + have_dig_base_units: shortfall.have, + need_dig_base_units, + })?; + + if selected.len() > MAX_SELECTED_FUNDING_COINS { + return Err(FundingError::TooManyInputs { + needed: selected.len(), + limit: MAX_SELECTED_FUNDING_COINS, + have_dig_base_units: authenticated_total, + need_dig_base_units, + }); } + + Ok(selected.into_iter().map(|(_, _, payload)| payload).collect()) } /// What an operator must actually DO about a funding shortfall. @@ -491,6 +626,12 @@ impl FundingObservation { FundingError::Unauthenticated { .. } | FundingError::ZeroCollateral => { FundingObservation::Unknown } + // A truncated walk is not a measurement of the wallet. `Short` would have to quote a + // total, and the only total available is the one from the coins that happened to be + // walked -- understated by however many the budget did not reach. Reporting it would + // send an operator to buy $DIG they already hold, and `grew_materially` would then + // suppress the correction. So this says nothing, and clears nothing either. + FundingError::CandidatesUnverifiable { .. } => FundingObservation::Unknown, } } } @@ -936,152 +1077,345 @@ 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. + /// A wallet at the operator's $DIG address, whose coins never authenticate. /// - /// **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. + /// Every candidate a test can build is one that FAILS authentication — `coin_spend` answers + /// `Ok(None)`, so no lineage resolves — and that is not a limitation of the fixture but of what + /// a coin IS: a `Cat` exists only once a real creating spend has been executed against it. That + /// is exactly the population an attacker supplies, so it is the right fixture for what a + /// stranger's coins are worth; the honest side of every claim below is asserted separately + /// against [`select_within_input_bound`], which takes authenticated coins directly. + struct Planted { + /// How many coins sit at the address. + count: u64, + /// What each one declares. + amount: u64, + owner: Bytes32, + /// Every `coin_spend` this source was asked for — the per-pass chain-read count. + lineage_reads: std::cell::RefCell, + } + + impl Planted { + fn new(count: u64, amount: u64, owner: Bytes32) -> Self { + Planted { + count, + amount, + owner, + lineage_reads: std::cell::RefCell::new(0), + } + } + + fn reads(&self) -> usize { + *self.lineage_reads.borrow() + } + } + + impl ChainSource for Planted { + 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 plantable" + ); + Ok((0..self.count) + .map(|i| { + let mut parent = [0u8; 32]; + parent[..8].copy_from_slice(&i.to_be_bytes()); + dig_chainsource_interface::CoinRecord { + coin: chia_protocol::Coin::new( + Bytes32::new(parent), + puzzle_hash, + self.amount, + ), + 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!() + } + } + + /// An authenticated coin for [`select_within_input_bound`], identified by its amount. /// - /// # The fixture is built from the bound itself, from BOTH sides + /// The payload is the amount rather than a `Cat`, because the bound and the totals are decided + /// on `(amount, coin_id)` alone and a real `Cat` would add nothing an assertion could read. + fn proven(amount: u64, tag: u8) -> (u64, Bytes32, u64) { + let mut id = [0u8; 32]; + id[0] = tag; + id[1..9].copy_from_slice(&amount.to_be_bytes()); + (amount, Bytes32::new(id), amount) + } + + /// **Proves:** the input bound is decided over AUTHENTICATED coins, from both sides of the + /// limit, and the total it quotes is theirs. /// - /// 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: + /// **Catches:** the off-by-one (`>=` for `>`), which refuses a create that exactly fits and + /// sends a fundable operator to consolidate a wallet that needs nothing done to it. /// - /// - **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. + /// # Why this is asserted here and not through the whole selection (dig-node#469) /// - /// 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. + /// It used to be driven through `select_operator_dig_cats` over a wallet of unauthenticatable + /// dust, which is precisely the shape the bound must no longer respond to — a stranger's coins + /// are not this operator's money, so they cannot make the node say *you have enough*. Driving + /// the bound through candidates that fail authentication could only ever prove the defect. /// - /// `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. + /// The fixture is built FROM the bound: exactly `MAX_SELECTED_FUNDING_COINS` coins must pass, + /// and one more must be refused with the count, the limit, and the operator's real total. An + /// over-only fixture is green against the off-by-one; the at-bound half is what discriminates. #[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; + fn the_input_bound_is_decided_over_authenticated_coins_and_holds_from_both_sides() { + let at_bound = MAX_SELECTED_FUNDING_COINS; 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), + // One base unit each, so covering N base units takes exactly N coins. + let coins = |n: usize| -> Vec<(u64, Bytes32, u64)> { + (0..n).map(|i| proven(1, i as u8)).collect() }; - let over = select_operator_dig_cats(&source, owner, over_bound, &HashSet::new()); + let at = select_within_input_bound(coins(at_bound), at_bound as u64); assert_eq!( - over, - Err(FundingError::TooManyInputs { - needed: over_bound as usize, + at.as_deref().map(<[u64]>::len), + Ok(at_bound), + concat!( + "a create that exactly fits the bound must be funded; refusing it sends an ", + "operator to consolidate a wallet that is already spendable" + ) + ); + + let over = select_within_input_bound(coins(over_bound), over_bound as u64); + assert_eq!( + over.err(), + Some(FundingError::TooManyInputs { + needed: over_bound, limit: MAX_SELECTED_FUNDING_COINS, - have_dig_base_units: source.count, - need_dig_base_units: over_bound, + have_dig_base_units: over_bound as u64, + need_dig_base_units: over_bound as u64, }), - "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" + "one coin over the bound is refused for the SHAPE of the cover, quoting the total the \ + operator genuinely holds" ); + } + + /// **Proves:** an operator holding nothing is never told they hold enough, however many coins a + /// stranger pays into their publicly derivable $DIG address. + /// + /// **Catches:** the dig-node#469 finding 1 — the input bound returning BEFORE authentication + /// began, so a selection composed entirely of unchecked coins produced + /// [`FundingRemedy::Consolidate`] and the message *"the operator wallet holds enough $DIG … + /// adding more will not help"*. An attacker paying 33 small coins to an address anyone can + /// derive chose which of two OPPOSITE instructions the operator was given, and the state does + /// not converge: no planted coin is ever authenticated, so none is ever removed, so the same + /// wrong message is the answer on every pass forever. + /// + /// # The fixture varies ONE actor and keeps a truthful control + /// + /// Two wallets are asked the same question: one with nothing at the address at all, and one + /// with `MAX_SELECTED_FUNDING_COINS + 1` planted coins. The operator's own holdings are + /// identical — nothing — in both, so the ONLY difference is what a stranger did, and the + /// assertion is that it made no difference to what the operator is told. An assertion that the + /// planted case merely "is not `TooManyInputs`" would also pass on an implementation that + /// refused everything; requiring the two to AGREE pins the property to the stranger's coins + /// being worth exactly zero rather than to a blanket refusal. + #[test] + fn coins_a_stranger_planted_never_become_a_statement_about_the_operators_money() { + let owner = owner(0x44); + // One base unit each and a requirement of one more than the bound, so covering it takes + // more coins than a bundle may draw -- the exact shape that produced `Consolidate`. + let need = MAX_SELECTED_FUNDING_COINS as u64 + 1; + + let planted = Planted::new(need, 1, owner); + let refusal = select_operator_dig_cats(&planted, owner, need, &HashSet::new()) + .expect_err("no coin here is spendable by this operator"); + + let empty = Planted::new(0, 1, owner); + let control = select_operator_dig_cats(&empty, owner, need, &HashSet::new()) + .expect_err("an empty address funds nothing"); + 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" + refusal, control, + concat!( + "a stranger changed what this node tells its operator about their own money; ", + "the planted address must be worth exactly what the empty one is" + ) ); - - // The at-bound half asserts what it can now that dig-node#461 landed: an unauthenticatable - // candidate is SKIPPED rather than aborting the selection, so this fixture -- in which - // `coin_spend` answers `Ok(None)` for every coin -- can no longer come back - // `Unauthenticated`. It walks the pool, skips all of it, and ends short. What still - // discriminates the off-by-one is that the bound did NOT speak and that authentication WAS - // reached: under `selected.len() >= LIMIT` this returns `TooManyInputs` having read no - // lineage at all, and both assertions below go red. - let at = select_operator_dig_cats(&source, owner, at_bound, &HashSet::new()); - assert!( - !matches!(at, Err(FundingError::TooManyInputs { .. })), + assert_eq!( + refusal, + FundingError::Insufficient { + have_dig_base_units: 0, + need_dig_base_units: need, + }, + "and the truthful answer is that the operator can spend nothing" + ); + assert_eq!( + FundingObservation::from_error(&refusal), + FundingObservation::Short { + have_dig_base_units: 0, + need_dig_base_units: need, + remedy: FundingRemedy::TopUp, + }, concat!( - "exactly at the bound the selection must PASS THROUGH to authentication; it ", - "refused with {:?} instead, so the bound is off by one and rejects a fundable ", - "create" - ), - at + "the operator must be sent to ADD $DIG; `Consolidate` here tells someone holding ", + "nothing that adding more will not help, and it is an attacker who chose it" + ) ); assert!( - *source.lineage_reads.borrow() > 0, + planted.reads() > 0, + concat!( + "every planted coin must be authenticated and rejected; a refusal reached without ", + "reading any lineage is a verdict about rows a stranger wrote" + ) + ); + } + + /// **Proves:** the reported spendable total counts only coins the chain PROVED, never the + /// address total. + /// + /// **Catches:** the dig-node#469 finding 4 — `available` summed the pool at the top of the + /// iteration, before anything in it was authenticated, so the first-iteration shortfall (the + /// ordinary case) quoted what the address held. An operator who adds the amount they were told + /// is still short, and `grew_materially` then suppresses the correction, so the node goes quiet + /// while they believe they fixed it. + /// + /// # Both directions, because one alone is satisfiable by a constant + /// + /// The planted half asserts an address total of 30,000 base units reports ZERO, which is red + /// against the old arithmetic. On its own it is also green against an implementation that + /// always reports zero — so the honest half asserts, over the same figures, that authenticated + /// coins totalling 20,000 report 20,000. Together they pin the total to the authenticated set. + #[test] + fn the_reported_total_is_what_the_chain_proved_not_what_the_address_holds() { + let owner = owner(0x55); + let need = 40_000; + + // Three coins of 10,000 at the address, none of them this operator's. + let planted = Planted::new(3, 10_000, owner); + assert_eq!( + select_operator_dig_cats(&planted, owner, need, &HashSet::new()).err(), + Some(FundingError::Insufficient { + have_dig_base_units: 0, + need_dig_base_units: need, + }), + concat!( + "the address total was reported as the operator's spendable total; they would be ", + "told to add 10,000 when they are 40,000 short, and the correction is then ", + "suppressed as immaterial" + ) + ); + assert_eq!( + planted.reads(), + 3, + "the honest total is established by reading, not by summing rows" + ); + + // The same shortfall with coins that ARE the operator's: the figure must be theirs. + assert_eq!( + select_within_input_bound(vec![proven(10_000, 1), proven(10_000, 2)], need).err(), + Some(FundingError::Insufficient { + have_dig_base_units: 20_000, + need_dig_base_units: need, + }), + "a proven 20,000 must be reported as 20,000, or the total is a constant rather than a \ + measurement" + ); + } + + /// **Proves:** the chain reads one selection performs are bounded by a CONSTANT, whatever a + /// stranger pays into the address. + /// + /// **Catches:** the dig-node#469 finding 3 — one network round trip per planted coin, per pass, + /// forever. Measured on the pre-fix tree at 11, 51 and 201 reads for 10, 50 and 200 planted + /// coins: linear in an attacker-chosen number, on a ten-minute timer, under + /// `tokio::task::block_in_place` so the worker is held for the whole walk. + /// + /// # Two sizes, because one cannot tell a bound from a coincidence + /// + /// The pool is grown well past [`MAX_AUTHENTICATION_ATTEMPTS`] and then DOUBLED. A single large + /// fixture is green against any limit at or above it; requiring the two counts to be EQUAL is + /// what distinguishes a bound from a fixture that merely did not reach one. + /// + /// The refusal is asserted too. Exhausting the budget leaves the wallet unmeasured, so it must + /// state no total and must classify as [`FundingObservation::Unknown`] — an amount taken from a + /// truncated walk is the understated total this whole ordering exists to remove, and it would + /// raise a wrong alert AND suppress the right one. + #[test] + fn authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends() { + let owner = owner(0x66); + let planted_count = (MAX_AUTHENTICATION_ATTEMPTS * 2) as u64; + let need = planted_count; + + let smaller = Planted::new(planted_count, 1, owner); + let refusal = select_operator_dig_cats(&smaller, owner, need, &HashSet::new()) + .expect_err("nothing here is spendable"); + + let larger = Planted::new(planted_count * 2, 1, owner); + let _ = select_operator_dig_cats(&larger, owner, need, &HashSet::new()) + .expect_err("nothing here is spendable"); + + assert_eq!( + smaller.reads(), + MAX_AUTHENTICATION_ATTEMPTS, + "one pass must never read more than the budget" + ); + assert_eq!( + larger.reads(), + smaller.reads(), + concat!( + "doubling what a stranger planted doubled the chain reads, so the cost of one ", + "pass is still chosen by the attacker rather than by this node" + ) + ); + + assert_eq!( + refusal, + FundingError::CandidatesUnverifiable { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: MAX_AUTHENTICATION_ATTEMPTS, + need_dig_base_units: need, + }, + "a truncated walk refuses as itself, stating no total" + ); + assert_eq!( + FundingObservation::from_error(&refusal), + FundingObservation::Unknown, concat!( - "reaching authentication is observed rather than assumed: no lineage read ", - "happened, so the bound refused before the coins were ever examined" + "an unmeasured wallet must not alert and must not clear a live shortfall; ", + "quoting the total of the coins that happened to be walked would understate it" ) ); } diff --git a/crates/dig-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 19b0bfb0..411dfdaf 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -53,6 +53,34 @@ pub struct PassDecision { pub per_coin_dig_base_units: Option, /// One state per bond the node holds, for the §25.8 surface. pub states: Vec<(Bond, BondState)>, + /// What this pass could NOT afford to create, when the wallet was readable and came up short. + /// + /// `Some` exactly when at least one create was priced and left uncreated for want of funds — + /// [`BondState::Unfunded`]'s condition, carried in a shape the alert gate can decide on. `None` + /// means every planned create was affordable, which includes the ordinary case of a node with + /// nothing new to bond. + /// + /// It exists because "this pass created nothing" has two opposite causes, and the pass that + /// created nothing because it could afford nothing is precisely the shortfall dig-node#463 was + /// built to report. Reading it off the create loop cannot tell them apart; reading it off the + /// funds split can (dig-node#469). + pub funding_shortfall: Option, +} + +/// A pass that could not afford every create it planned, in the two figures an operator needs. +/// +/// Both are stated from the SAME funds split that caused the refusal, so the message cannot +/// disagree with the decision it describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FundingShortfall { + /// The $DIG left over after the affordable creates were funded — what is still spendable + /// towards the ones that were not, in base units. + /// + /// Not the wallet balance: a balance that funded three of five creates is not money available + /// for the remaining two, and quoting it would overstate what the operator has to work with. + pub have_dig_base_units: u64, + /// What the creates left uncreated would cost together, in the same units. + pub need_dig_base_units: u64, } /// What the node can say about one bond right now. @@ -217,11 +245,30 @@ pub fn decide(inputs: &PassInputs<'_>) -> PassDecision { let states = bond_states(inputs, &affordable, split.as_ref(), per_coin, &reclaim); + // The shortfall is read off the split rather than off the states, because the split is what + // decided it: `short` is non-empty exactly when a priced create went unmade for want of funds. + // `have` is the remainder after the affordable prefix was funded — `balance % per_coin` — so + // the deficit these two imply is the money that must actually be ADDED, not the whole cost of + // the unmade creates. + let funding_shortfall = match (per_coin, split.as_ref(), inputs.dig_balance_base_units) { + // `per_coin` of zero is refused as a create everywhere else in this crate, and would make + // the remainder below a division by zero. It reports no shortfall rather than panicking: + // a requirement of zero is a caller defect, not an empty wallet. + (Some(per_coin), Some(split), Some(balance)) if per_coin > 0 && !split.is_funded() => { + Some(FundingShortfall { + have_dig_base_units: balance % per_coin, + need_dig_base_units: split.shortfall_dig_base_units, + }) + } + _ => None, + }; + PassDecision { reclaim, create: affordable, per_coin_dig_base_units: per_coin, states, + funding_shortfall, } } diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index c4dbfd92..c6946499 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -382,6 +382,7 @@ impl PassRunner { create, per_coin_dig_base_units, states, + funding_shortfall: decision_shortfall, } = decision; let mut reclaimed = Vec::new(); @@ -442,9 +443,27 @@ impl PassRunner { // CLEAR a live shortfall would tell an operator their funding recovered on the strength // of an unrelated error. Some(_) => super::funding::FundingObservation::Unknown, - // Nothing stopped. `per_coin` is `Some` exactly when the requirement was known, so this - // is a pass that funded every create it planned -- including a pass that planned none, - // which is the healthy state a node with nothing new to bond sits in. + // Nothing stopped, and the wallet could not afford every create the pass priced. This + // is the ORDINARY shortfall -- a wallet holding less than one create's collateral -- + // and it never reaches `stopped_at` at all, because the create loop is handed the + // affordable prefix and an empty prefix simply does not iterate. + // + // Classifying it `Healthy` (dig-node#469) left the Short alert with no producer for the + // commonest real case, and worse: a pass that could afford nothing CLEARED a live + // shortfall and announced a recovery that had not happened. A pass that never asked the + // wallet for money is not evidence that the wallet has any. + None if decision_shortfall.is_some() => { + let shortfall = decision_shortfall.expect("matched Some directly above"); + super::funding::FundingObservation::Short { + have_dig_base_units: shortfall.have_dig_base_units, + need_dig_base_units: shortfall.need_dig_base_units, + remedy: super::funding::FundingRemedy::TopUp, + } + } + // Nothing stopped and nothing was unaffordable. `per_coin` is `Some` exactly when the + // requirement was known, so this is a pass that funded every create it planned -- + // including a pass that planned none, which is the healthy state a node with nothing + // new to bond sits in. None if per_coin_dig_base_units.is_some() => { super::funding::FundingObservation::Healthy } @@ -822,6 +841,96 @@ mod tests { ); } + /// **A wallet that can afford NOTHING alerts Short, and never announces a false recovery.** + /// + /// **Proves** the producer dig-node#463's Short alert was missing for its commonest real case. + /// A pass reaches `PassError::Funding` only when a create was ATTEMPTED and refused — but a + /// wallet holding less than one create's collateral never attempts one at all: `decide` hands + /// `execute` the affordable prefix, and an empty prefix simply does not iterate. Nothing stops, + /// so the pass classified itself `Healthy` (dig-node#469 finding 2). + /// + /// **Catches** two things, and only the sequence catches the second: + /// + /// * an empty wallet reporting `Healthy`, so the ordinary shortfall is never reported at all; + /// * worse, that same pass CLEARING a live shortfall and announcing *"collateral resumed … + /// your content is being bonded"*. Both clauses are false, and the recovery message is the + /// one an operator acts on by stopping work. It needs no attacker: coins committed to an + /// in-flight bundle are withheld from selection but counted by the balance oracle, so pass N + /// alerts Short and pass N+1 reads a balance under one create and announces recovery. + /// + /// # The control is a pass that genuinely DID recover + /// + /// The third pass funds the create for real. Without it this test is equally green against an + /// implementation that simply never recovers — which would leave an operator who fixed their + /// wallet permanently told it is broken. Varying only the balance between passes two and three + /// is what makes the distinction the wallet's rather than the gate's. + #[test] + fn a_wallet_that_can_afford_nothing_alerts_short_and_never_announces_a_false_recovery() { + use super::super::funding::{FundingAlertGate, FundingRemedy}; + + let capsule = bond("aa", "11"); + // The pass is otherwise ORDINARY: a held bond, a known requirement, nothing injected. The + // only variable is what the wallet holds. + let effects = |balance: u64| FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + balance, + ..FakeEffects::default() + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let run = |gate, balance| { + let mut pass = runner(effects(balance), log.clone()).with_funding_gate(gate); + let report = pass.run(&ctx()).expect("the pass observes"); + (report, pass.take_funding_gate()) + }; + + // Pass 1: a wallet holding nothing, and a bond waiting to be collateralised. + let (first, gate) = run(FundingAlertGate::default(), 0); + assert!( + first.created.is_empty(), + "the fixture must genuinely afford nothing, or the shortfall below is not the one \ + under test" + ); + let alert = first.funding_alert.expect(concat!( + "a wallet that cannot afford a single create raised no alert; this is the commonest ", + "real shortfall there is, and it was being reported as a healthy node" + )); + assert_eq!( + alert.remedy, + Some(FundingRemedy::TopUp), + "an empty wallet is told to add $DIG" + ); + + // Pass 2: the same empty wallet again. Silent -- the operator has already been told, and + // nothing about the shortfall has changed. Silence is what a persisting shortfall sounds + // like; a RECOVERY is what the old classification announced here. + let (second, gate) = run(gate, 0); + assert_eq!( + second.funding_alert, None, + concat!( + "the shortfall was CLEARED by a pass that never asked the wallet for anything, ", + "and the operator was told their content is being bonded again" + ) + ); + + // Pass 3: the wallet is funded and the create is made. NOW a recovery is the truth. + let (third, _) = run(gate, REQUIRED * 10); + assert_eq!( + third.created, + vec![capsule.clone()], + "the control must genuinely fund the create" + ); + let recovery = third + .funding_alert + .expect("an operator who fixed their wallet must be told it worked"); + assert_eq!( + recovery.remedy, None, + "a recovery asks for nothing; reporting one only when the wallet really recovered is \ + the half of this that keeps the fix from silencing recoveries altogether" + ); + } + /// **A create that failed for a NON-funding reason does not clear a live shortfall.** /// /// **Proves** the `Some(_) => Unknown` arm of the wiring. A pass that stopped because no URL is From 8f5421987ae8e3d350bb3b28b9ca4058eac720f7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 17:42:13 -0700 Subject: [PATCH 07/12] fix(mirror): a pass that could afford nothing is a shortfall, not a healthy node A pass reaches PassError::Funding only when a create was ATTEMPTED and refused. A wallet holding less than one create's collateral never attempts one: decide hands execute the affordable prefix, and an empty prefix does not iterate. So nothing stopped, and the pass classified itself Healthy -- leaving dig-node#463's Short alert with no producer for the commonest real shortfall there is, and CLEARING a live one with a false 'collateral resumed / your content is being bonded'. Neither clause is true, and it needs no attacker: coins committed to an in-flight bundle are withheld from selection but counted by the balance oracle. The funds split already knew. decide now carries the shortfall it computed -- the leftover $DIG against the cost of the creates it could not make, so the deficit is the money that must actually be ADDED -- and the classification reads it instead of inferring health from an empty create loop. Refs dig-node#469 --- crates/dig-node-service/src/mirror/pass.rs | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/dig-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 411dfdaf..9cd3a7b0 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -459,6 +459,72 @@ mod tests { } } + /// **Proves:** a partially funded pass reports the money that must actually be ADDED, not the + /// whole cost of the creates it could not make. + /// + /// **Catches:** the two plausible wrong figures, each of which is a false money statement to an + /// operator (dig-node#469): + /// + /// * quoting the WALLET BALANCE as what is available — money that has already been spent on the + /// affordable creates, so the deficit reads as smaller than it is and an operator who adds + /// that much is still short; + /// * quoting zero as available — the deficit then reads as the full cost of every unmade create, + /// sending them to buy $DIG they already hold. + /// + /// # The fixture is chosen so the three candidate figures all DIFFER + /// + /// Three bonds at 1,000 each against a balance of 2,400: two are affordable, one is not, and + /// 400 is left over. So the honest answer is *400 towards the 1,000 still needed*. A balance of + /// 2,000 or 3,000 would make the remainder zero and let a wrong implementation agree by + /// accident; the deliberate 400 is what separates them. + #[test] + fn a_partially_funded_pass_reports_the_leftover_against_what_is_still_needed() { + let held = [bond("aa", "11"), bond("bb", "22"), bond("cc", "33")]; + let requirement = known(); + let mut inputs = inputs(&held, &[], &requirement); + inputs.dig_balance_base_units = Some(REQUIRED * 2 + 400); + + let decision = decide(&inputs); + + assert_eq!( + decision.create.len(), + 2, + "the fixture must genuinely fund some and not others, or it tests nothing" + ); + assert_eq!( + decision.funding_shortfall, + Some(FundingShortfall { + have_dig_base_units: 400, + need_dig_base_units: REQUIRED, + }), + concat!( + "the leftover $DIG and the cost of the unmade create are what an operator needs; ", + "quoting the whole balance understates the deficit and quoting zero overstates it" + ) + ); + } + + /// **Proves:** a pass that funded everything it planned reports NO shortfall. + /// + /// The other half of the pair above, and the reason it is a separate test: without it, an + /// implementation that reports a shortfall on every pass satisfies the partial case and turns + /// the funding alert into the per-pass stream `FundingAlertGate` exists to prevent. A node with + /// nothing new to bond is the ordinary state and must be silent. + #[test] + fn a_pass_that_afforded_everything_reports_no_shortfall() { + let held = [bond("aa", "11")]; + let requirement = known(); + let inputs = inputs(&held, &[], &requirement); + + let decision = decide(&inputs); + + assert_eq!(decision.create.len(), 1, "the create is affordable"); + assert_eq!( + decision.funding_shortfall, None, + "a funded pass must not report a shortfall, or every pass alerts" + ); + } + #[test] fn a_held_capsule_on_a_funded_node_is_created_at_the_margined_requirement() { let held = [bond("aa", "11")]; From f7964fba5212629acde452ca70c1679a03dcef81 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 19:22:12 -0700 Subject: [PATCH 08/12] =?UTF-8?q?docs(spec):=20state=20funding-selection?= =?UTF-8?q?=20authentication=20order=20and=20shortfall=20contract=20(?= =?UTF-8?q?=C2=A725.11,=20=C2=A725.12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC was silent on both, so neither the authenticate-before-you-report ordering nor the empty-wallet-is-short classification had a normative home. Refs dig-node#469 --- SPEC.md | 51 +++++++++++++++++++ crates/dig-node-service/src/mirror/funding.rs | 10 ++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index a5974fe4..9ed7ce37 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8654,3 +8654,54 @@ Changing the value affects only coins created after the change. Bringing an exis means reclaiming and re-creating it — a round trip and a fee — and the node MUST NOT reclaim in response to a configuration edit. + +### 25.11. Funding a create — authentication precedes every figure the operator is told + +The $DIG that funds a create is selected from ONE address: `dig_cat_puzzle_hash(owner)`, the CAT +curry of the operator's own puzzle hash. That address is **publicly derivable** — the owner puzzle +hash is public and the curry is canonical — and anyone may pay any coin to any puzzle hash. So a row +returned by the scan is a CANDIDATE, never a coin, and the number and declared amounts of those rows +are chosen by whoever last paid into the address. + +A candidate becomes one of this operator's coins only by AUTHENTICATION: its creating spend is read +from chain and executed, and a CAT child matching the candidate is produced, of the $DIG asset and +at this operator's puzzle hash. The node MUST NOT treat an unauthenticated candidate as money. + +**The node MUST authenticate before it computes any figure it reports.** In particular: + +* The spendable total in a shortfall MUST be the total of AUTHENTICATED candidates. It MUST NOT be + the address total. An understated total sends an operator to buy $DIG they already hold, and the + §25.12 gate then suppresses the correction as immaterial. +* The input bound MUST be applied to a selection drawn from AUTHENTICATED candidates only. Applied + to the raw scan it is a bound a stranger sets, and the refusal it produces is the operator-facing + claim *the wallet holds enough $DIG, in too many pieces* — so a stranger paying enough small coins + into the address chooses which of two OPPOSITE instructions the operator is given, and an operator + holding nothing is told that adding more will not help. +* A candidate that fails authentication MUST be passed over rather than aborting the selection, MUST + be counted and reported, and MUST NOT occupy an input slot. + +**Authentication costs one chain read per candidate, so it MUST be bounded by a constant** that does +not depend on how many candidates exist. Without such a bound the reads one automated pass performs +are chosen by whoever paid coins into the address, on the pass timer, indefinitely. + +**A walk truncated by that bound leaves the wallet UNMEASURED.** The node MUST refuse with a +condition of its own that states NO total, and that classifies as §25.12's *unknown* — it MUST NOT +report the total of the candidates that happened to be walked, and MUST NOT clear a live shortfall. + +### 25.12. Reporting a funding shortfall to the operator + +The node MUST distinguish three funding observations per pass — *healthy*, *short* (with the amount +and the remedy), and *unknown* — and MUST raise an operator-facing message only on a CHANGE: once on +entering the short state, again only when the remedy changes or the deficit grows materially, and +once on recovery. An *unknown* observation MUST raise nothing and MUST NOT clear a live shortfall. + +**A pass that planned no create because it could afford none is SHORT, not healthy.** The two +conditions that produce an empty create list are opposites — nothing to bond, and nothing +affordable — and only the first is healthy. Classifying the second as healthy leaves the shortfall +with no producer for the commonest real case (a wallet holding less than one create's collateral, +which never attempts a create and so never produces a funding refusal), and worse, CLEARS a live +shortfall and announces a recovery that has not happened. + +The figures a shortfall reports MUST be the money that must actually be ADDED: the $DIG remaining +after the affordable creates were funded, against the cost of those that were not. The wallet +balance alone overstates what is available towards the unmade creates. diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 0c50f4e3..5553c771 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -532,7 +532,10 @@ fn select_within_input_bound( }); } - Ok(selected.into_iter().map(|(_, _, payload)| payload).collect()) + Ok(selected + .into_iter() + .map(|(_, _, payload)| payload) + .collect()) } /// What an operator must actually DO about a funding shortfall. @@ -1204,9 +1207,8 @@ mod tests { let over_bound = at_bound + 1; // One base unit each, so covering N base units takes exactly N coins. - let coins = |n: usize| -> Vec<(u64, Bytes32, u64)> { - (0..n).map(|i| proven(1, i as u8)).collect() - }; + let coins = + |n: usize| -> Vec<(u64, Bytes32, u64)> { (0..n).map(|i| proven(1, i as u8)).collect() }; let at = select_within_input_bound(coins(at_bound), at_bound as u64); assert_eq!( From c33826bb29716734271d5f7b0a5fba7f2a00ee0d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 19:22:12 -0700 Subject: [PATCH 09/12] test(mirror): prove the funding figures on a chain holding the operator's real $DIG The unit fixtures can only build candidates that FAIL authentication -- a Cat exists only once a real creating spend has been executed -- so they prove what a stranger's coins are worth and cannot prove that the operator's own are still counted. These two put genuine CAT lineage beside the planted coins and vary only the stranger, reproducing the audit's probe B and probe C. Refs dig-node#469 --- .../tests/mirror_operator_funding.rs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs index a797fbef..c3c97942 100644 --- a/crates/dig-node-service/tests/mirror_operator_funding.rs +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -29,6 +29,7 @@ use chia_sha2::Sha256; use dig_chainsource_interface::{ChainSource, ChainSourceError, CoinRecord, SingletonLineage}; use dig_node_service::mirror::funding::{ dig_cat_puzzle_hash, select_operator_dig_cats, select_operator_dig_cats_detailed, FundingError, + FundingObservation, FundingRemedy, }; use support::{ordinary_dig_coins, wallet, Wallet}; @@ -510,3 +511,99 @@ fn the_fixture_coins_land_on_the_puzzle_hash_the_selector_scans() { "the selector must scan the address the operator's $DIG actually sits at" ); } + +/// **Probe B — the spendable total is the operator's, not the address's** (dig-node#469). +/// +/// The fixture is the one that matters and the one the unit tests cannot build: the operator has +/// **genuine** $DIG on chain, with real creating spends, AND a stranger has paid a coin into the +/// same publicly derivable address. Varying only the stranger's coin is what makes the assertion +/// about authentication rather than about arithmetic. +/// +/// Before the fix the shortfall was computed from the pool at the top of the iteration, before +/// anything in it was authenticated — so it summed the ADDRESS. An operator who can genuinely spend +/// 20.000 DIG, needing 40.000, was told they held 30.000 and were 10.000 short. They add 10.000, +/// are still short, and the re-alert is then suppressed as immaterial growth: the node goes quiet +/// and stops bonding while they believe they fixed it. +#[test] +fn the_shortfall_counts_only_coins_whose_lineage_the_chain_proved() { + let operator = wallet(1); + let mut chain = Chain::default(); + + // Genuinely the operator's: real CAT spends, so these authenticate. + chain.fund(&operator, &[12_000, 8_000], salt(70)); + // A stranger's coin at the same address: no creating spend, so it never authenticates. + chain.fund_without_lineage(&operator, &[10_000], salt(71)); + + assert_eq!( + select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()).err(), + Some(FundingError::Insufficient { + have_dig_base_units: 20_000, + need_dig_base_units: REQUIRED, + }), + concat!( + "the address total was reported as the operator's spendable total; they are 20.000 ", + "short and would be told 10.000, and the correction is then suppressed" + ) + ); +} + +/// **Probe C — a stranger cannot choose which instruction the operator is given** (dig-node#469). +/// +/// The input bound used to be applied to the raw selection, before authentication began, and the +/// refusal it produced is the operator-facing claim *the wallet holds enough $DIG, in too many +/// pieces — adding more will not help*. The scan address is `dig_cat_puzzle_hash(owner)`, derivable +/// by anyone from a public value, so a stranger paying enough small coins into it chose which of +/// two OPPOSITE remedies an operator was sent to. It did not converge either: no planted coin was +/// ever authenticated, so none was ever removed, and the same wrong message was the answer on every +/// pass. +/// +/// # The honest coin is the control +/// +/// The operator holds one real coin here, far too small to cover the requirement. It is what +/// separates "authenticated coins are counted" from "everything is refused": the answer must be a +/// TOP-UP quoting the operator's own 1.000, not a consolidation, and not a zero. +#[test] +fn coins_a_stranger_paid_in_cannot_turn_a_top_up_into_a_consolidation() { + let operator = wallet(1); + let mut chain = Chain::default(); + + // The operator's own money: real, authenticatable, and nowhere near enough. + chain.fund(&operator, &[1_000], salt(72)); + + // Forty coins a stranger paid in, each distinct and each small enough that covering the + // requirement from them alone takes more inputs than a bundle may draw. That is the shape -- + // and the ONLY shape -- that produced `Consolidate`. + let planted: Vec = (0..40).map(|i| 1_100 + i).collect(); + assert!( + planted.iter().sum::() > REQUIRED, + "the planted coins must appear to cover the requirement, or the old bound never fires" + ); + chain.fund_without_lineage(&operator, &planted, salt(73)); + + let refusal = select_operator_dig_cats(&chain, operator.puzzle_hash, REQUIRED, &HashSet::new()) + .expect_err("the operator cannot cover the requirement from their own coins"); + + assert_eq!( + refusal, + FundingError::Insufficient { + have_dig_base_units: 1_000, + need_dig_base_units: REQUIRED, + }, + concat!( + "the operator must be told they hold their OWN 1.000 and are short; a stranger's coins ", + "are not their money, and quoting zero would ignore the coin they really have" + ) + ); + assert_eq!( + FundingObservation::from_error(&refusal), + FundingObservation::Short { + have_dig_base_units: 1_000, + need_dig_base_units: REQUIRED, + remedy: FundingRemedy::TopUp, + }, + concat!( + "an attacker chose the remedy: `Consolidate` tells an operator who is genuinely short ", + "that adding more $DIG will not help, which is the exact opposite of what they must do" + ) + ); +} From 1e8a4feeba4408d4b46db048b38819cfa3f2df83 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 08:12:25 -0700 Subject: [PATCH 10/12] fix(mirror): no operator money figure is quoted without authentication (#469) Three gate findings on #469, all the same class as the fix the PR was opened for: a figure a stranger can choose, reaching an operator as a fact. G1 -- the decision-shortfall path quoted `balance % per_coin`, derived from `dig_balance_base_units`, the raw sum over the publicly derivable `dig_cat_puzzle_hash(owner)`. Neither balance tier authenticates CAT lineage, so planting one 499-mojo coin had the operator told they were 0.001 DIG short when they were 0.500 short -- and the alert gate then suppressed the correction as immaterial. SPEC 25.11, added by this same PR, forbids exactly that. The pass is still classified SHORT (authentication only ever removes candidates, so a reported balance below one create's cost proves the real one is too); what it no longer does is quote an amount it never authenticated. `FundingShortfall::have_dig_base_units` is REMOVED rather than left unused, so no future caller can render it again. G2 -- exhausting MAX_AUTHENTICATION_ATTEMPTS mapped to `Unknown`, on which the gate returns None and even the `tracing::warn!` in `execute`, gated on the alert being Some, never fired. A stranger burying the honest coins under 128 unauthenticatable ones stopped this node bonding on every pass, forever, and the operator was never told. Refusing to quote a total is right; refusing to speak is a different thing. The constant is unchanged. Both now map to `FundingObservation::Unmeasured`, which alerts once on entry, states no total and no deficit, and clears no live shortfall. G3 -- corrected the false claim that one PASS costs at most MAX_AUTHENTICATION_ATTEMPTS reads. The bound is per SELECTION and `create` runs once per bond, so a pass planning K creates costs up to K x the budget. Documented rather than changed; a per-pass shared budget is filed as follow-up. SPEC 25.11/25.12 updated to name the fourth observation and to require that a blocked pass with no authenticated total is reported without an amount. Co-Authored-By: Claude --- SPEC.md | 43 +++- crates/dig-node-service/src/mirror/funding.rs | 230 ++++++++++++++--- crates/dig-node-service/src/mirror/pass.rs | 46 ++-- crates/dig-node-service/src/mirror/runner.rs | 243 +++++++++++++++++- 4 files changed, 496 insertions(+), 66 deletions(-) diff --git a/SPEC.md b/SPEC.md index e6217d1e..8feca9a1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8753,15 +8753,37 @@ not depend on how many candidates exist. Without such a bound the reads one auto are chosen by whoever paid coins into the address, on the pass timer, indefinitely. **A walk truncated by that bound leaves the wallet UNMEASURED.** The node MUST refuse with a -condition of its own that states NO total, and that classifies as §25.12's *unknown* — it MUST NOT -report the total of the candidates that happened to be walked, and MUST NOT clear a live shortfall. +condition of its own that states NO total — it MUST NOT report the total of the candidates that +happened to be walked, and MUST NOT clear a live shortfall. It MUST classify as §25.12's +*unmeasured*, and MUST therefore be reported to the operator without an amount: an operator whose +node has stopped bonding because a stranger filled the scan address is otherwise never told +anything, on any pass, indefinitely. + +**A pass that authenticated no candidate at all has no spendable total either.** The commonest such +pass is one that priced a create and could afford none, so the selection was never invoked. It is +still SHORT — authentication only ever removes candidates, so a reported balance below one create's +cost proves the authenticated one is below it too — but the SIZE of the gap is not established, and +the node MUST NOT quote the address total, or any figure derived from it, as the spendable one. It +MUST classify as §25.12's *unmeasured*. ### 25.12. Reporting a funding shortfall to the operator -The node MUST distinguish three funding observations per pass — *healthy*, *short* (with the amount -and the remedy), and *unknown* — and MUST raise an operator-facing message only on a CHANGE: once on -entering the short state, again only when the remedy changes or the deficit grows materially, and -once on recovery. An *unknown* observation MUST raise nothing and MUST NOT clear a live shortfall. +The node MUST distinguish four funding observations per pass — *healthy*, *short* (with the amount +and the remedy), *unmeasured* (blocked, with no amount), and *unknown* — and MUST raise an +operator-facing message only on a CHANGE: once on entering the short state, again only when the +remedy changes or the deficit grows materially, and once on recovery. An *unknown* observation MUST +raise nothing and MUST NOT clear a live shortfall. + +An *unmeasured* observation MUST raise a message once on entering it, MUST state NO spendable total +and NO deficit, and MUST NOT clear a live shortfall. Saying nothing about the AMOUNT and saying +nothing AT ALL are different: the second leaves a node that has silently stopped bonding +unreported, and leaves a shortfall already alerted on latched at a figure that can never be +corrected. The message MUST name the condition and an action the operator can take, and MUST NOT +assert a remedy the observation does not establish — in particular a truncated walk MUST NOT tell +an operator to add $DIG, since adding it need not help. + +A *short* observation's spendable total MUST be authenticated (§25.11). A pass that has no +authenticated total is *unmeasured*, never *short with the address total*. **A pass that planned no create because it could afford none is SHORT, not healthy.** The two conditions that produce an empty create list are opposites — nothing to bond, and nothing @@ -8770,6 +8792,9 @@ with no producer for the commonest real case (a wallet holding less than one cre which never attempts a create and so never produces a funding refusal), and worse, CLEARS a live shortfall and announces a recovery that has not happened. -The figures a shortfall reports MUST be the money that must actually be ADDED: the $DIG remaining -after the affordable creates were funded, against the cost of those that were not. The wallet -balance alone overstates what is available towards the unmade creates. +The figures a shortfall reports MUST be the money that must actually be ADDED: the authenticated +$DIG remaining after the affordable creates were funded, against the cost of those that were not. +The wallet balance alone overstates what is available towards the unmade creates, and an +unauthenticated balance is in addition a figure a stranger chooses (§25.11) — so where the +remaining $DIG has not been authenticated, the cost of the unmade creates is reported alone, as an +*unmeasured* observation. diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 5553c771..cd88a56b 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -232,8 +232,23 @@ pub const MAX_SELECTED_FUNDING_COINS: usize = 32; /// /// # Why a constant, and why this one /// -/// A constant is the property that matters: whatever a stranger pays into the address, one pass -/// costs at most this many reads. 128 is four times the input bound, so a wallet fragmented right +/// A constant is the property that matters: whatever a stranger pays into the address, one +/// SELECTION costs at most this many reads. +/// +/// **Per selection, not per pass, and the difference is not cosmetic (dig-node#469).** `create` is +/// called once per bond (`lifecycle::NodeMirrorEffects::create`), each with its own budget, so a +/// pass planning K creates costs up to K x this many reads. The create loop breaks on the first +/// FAILURE, which bounds a pass that cannot fund itself to one selection — but it does not bound +/// the case that matters here: a stranger who plants `MAX_AUTHENTICATION_ATTEMPTS - 1` coins +/// ranked above the honest ones leaves every create still SUCCEEDING, so nothing breaks, while each +/// one pays the full wasted walk, on the pass timer, indefinitely, from a one-time dust spend. +/// +/// That is amplification of a bounded factor rather than an unbounded one — K is the node's own +/// bond count, not an attacker's choice — so it is recorded here rather than silently untrue, and a +/// per-PASS budget shared across the create loop is filed as follow-up rather than taken inside +/// this change. +/// +/// 128 is four times the input bound, so a wallet fragmented right /// up to the point where [`FundingError::TooManyInputs`] is the correct answer still reaches that /// answer with room for noise, while a wallet with nothing planted in it never comes close — the /// walk stops the moment the requirement is covered, so a healthy pass pays for the coins it @@ -241,13 +256,15 @@ pub const MAX_SELECTED_FUNDING_COINS: usize = 32; /// /// # Which direction it fails in /// -/// CLOSED, and silently about money. Exhausting the budget yields +/// CLOSED, and silently about the AMOUNT but not about the condition. Exhausting the budget yields /// [`FundingError::CandidatesUnverifiable`], which states no total and maps to -/// [`FundingObservation::Unknown`] — so the pass raises no alert and clears none. That is the -/// honest reading: the walk was truncated, so the wallet was not measured. An attacker who buries -/// the honest coins under 128 larger unauthenticatable ones can stop this node bonding, which is a -/// denial of service and is recorded as one; what they cannot do is make the node tell its operator -/// something false about their money. +/// [`FundingObservation::Unmeasured`] — so the pass quotes no figure and clears no live shortfall, +/// and it does tell the operator once that the walk was truncated. That is the honest reading: the +/// walk stopped early, so the wallet was not measured, and an operator whose node has stopped +/// bonding needs to hear that even when no number can be attached to it. An attacker who buries the +/// honest coins under 128 larger unauthenticatable ones can stop this node bonding, which is a +/// denial of service and is reported as one; what they cannot do is make the node tell its operator +/// something false about their money, or keep it quiet about the stoppage (dig-node#469). pub const MAX_AUTHENTICATION_ATTEMPTS: usize = 128; /// The puzzle hash the operator's ordinary $DIG coins sit at. @@ -559,16 +576,58 @@ pub struct FundingAlert { pub title: String, /// The body: what happened, in what amounts, and what to do about it. pub body: String, - /// The action this alert is asking for, or `None` when it reports a recovery. + /// The action this alert is asking for, or `None` when no single action is being claimed — + /// a recovery, or a blocked pass whose remedy is not established (see [`unmeasured_alert`]). pub remedy: Option, } +/// Why a pass knows bonding is blocked but cannot say by how much (dig-node#469). +/// +/// # Why this is a shape of its own rather than an amount of zero, or silence +/// +/// Two conditions stop this node bonding without ever producing an AUTHENTICATED total, and each +/// used to resolve to one of the two available lies: +/// +/// * quoting the figure that WAS available — the unauthenticated address total, which a stranger +/// chooses by paying a coin into the publicly derivable scan address, understating the deficit +/// and then having [`FundingAlertGate`] suppress the correction as immaterial; or +/// * saying nothing at all, which leaves an operator whose node has silently stopped bonding with +/// no message on any pass, forever. +/// +/// The truthful third answer is to name the condition and NOT the amount. So an `Unmeasured` +/// observation alerts once on entry, quotes no spendable total, and — like +/// [`FundingObservation::Unknown`] — never clears a live shortfall, because a pass that could not +/// measure the wallet is not evidence that the wallet recovered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnmeasuredFunding { + /// The pass priced a create and could afford none of them, so no candidate was ever + /// authenticated. + /// + /// The SHORT classification is sound even though the amount is not: authentication can only + /// ever REMOVE candidates, so the authenticated total is at most the reported one, and a + /// reported total below one create's cost proves the real one is too. What it does not prove + /// is the size of the gap — which is the figure an operator would act on. + NoCreateAffordable { + /// What one create needs, in $DIG base units. Derived from the epoch requirement and the + /// plan, never from the wallet, so it is a figure no stranger can move. + need_dig_base_units: u64, + }, + /// The authentication walk hit [`MAX_AUTHENTICATION_ATTEMPTS`] before covering the requirement. + AuthenticationTruncated { + /// How many candidates were authenticated before the budget ran out. + attempted: usize, + /// How many of those could not be proven spendable. + skipped: usize, + }, +} + /// What one mirror pass observed about funding — the input the alert gate decides on. /// -/// Deliberately only three shapes. A pass that could not READ the balance is not a pass that found -/// it short, and is not represented here at all: reporting a shortfall on no evidence is precisely -/// the money lie this crate refuses elsewhere, so the caller maps an unreadable balance to -/// [`FundingObservation::Unknown`], which never alerts and never clears the state either. +/// A pass that could not READ the balance is not a pass that found it short: reporting a shortfall +/// on no evidence is precisely the money lie this crate refuses elsewhere, so the caller maps an +/// unreadable balance to [`FundingObservation::Unknown`], which never alerts and never clears the +/// state either. [`FundingObservation::Unmeasured`] sits between the two — bonding is known to be +/// blocked, and by how much is not. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FundingObservation { /// A create was funded, or none was needed. The operator wallet is not blocking anything. @@ -582,6 +641,8 @@ pub enum FundingObservation { /// The action that would clear it. remedy: FundingRemedy, }, + /// Bonding is blocked and the amount is not established. See [`UnmeasuredFunding`]. + Unmeasured(UnmeasuredFunding), /// The funding state could not be established this pass. Unknown, } @@ -633,8 +694,25 @@ impl FundingObservation { // total, and the only total available is the one from the coins that happened to be // walked -- understated by however many the budget did not reach. Reporting it would // send an operator to buy $DIG they already hold, and `grew_materially` would then - // suppress the correction. So this says nothing, and clears nothing either. - FundingError::CandidatesUnverifiable { .. } => FundingObservation::Unknown, + // suppress the correction. + // + // But saying nothing about the AMOUNT and saying nothing AT ALL are different, and the + // second is its own money lie by omission (dig-node#469): a stranger who buries the + // honest coins under `MAX_AUTHENTICATION_ATTEMPTS` larger unauthenticatable ones stops + // this node bonding on every pass, indefinitely, and the operator is never told. Worse, + // it is the state a latched understated shortfall would never be corrected out of. So + // this speaks -- once, naming the truncation and quoting no total -- and still clears + // nothing. + FundingError::CandidatesUnverifiable { + attempted, + skipped, + .. + } => { + FundingObservation::Unmeasured(UnmeasuredFunding::AuthenticationTruncated { + attempted: *attempted, + skipped: *skipped, + }) + } } } } @@ -654,6 +732,9 @@ impl FundingObservation { /// alerted on. Growth by a fixed proportion is self-limiting — each further alert needs a deficit /// half again as large as the last — so a steadily worsening shortfall cannot become a stream. /// * **Once on RECOVERY**, so an operator who acted learns it worked without having to watch for it. +/// * **Once on entering an [`FundingObservation::Unmeasured`] state**, which names the condition +/// and no amount. It is latched separately from the short state and CLEARS neither it nor +/// itself, because a pass that could not measure the wallet is not evidence of recovery. /// * **Never on [`FundingObservation::Unknown`]**, which also does not CLEAR the state: an /// unreadable pass is not evidence of recovery, and treating it as one would re-alert on the next /// short pass for a shortfall that never went away. @@ -664,6 +745,13 @@ impl FundingObservation { pub struct FundingAlertGate { /// The shortfall the last alert was raised for, or `None` while not in the short state. alerted: Option<(FundingRemedy, u64)>, + /// The unmeasured condition last alerted on, or `None` while not in one. + /// + /// Separate from `alerted` because the two are not alternatives: a wallet can be latched short + /// on an authenticated figure AND then become unmeasurable, and that transition is exactly the + /// one an operator must hear about — it is the pass on which the correction they were waiting + /// for stops being possible. + unmeasured: Option, } /// How much a deficit must grow, in percent of the last alerted deficit, to speak again. @@ -675,19 +763,41 @@ pub struct FundingAlertGate { pub const MATERIAL_DEFICIT_GROWTH_PERCENT: u64 = 50; impl FundingAlertGate { + /// Drop every blocked state and announce the recovery, if the node was in one. + /// + /// Both latches clear together because a funded pass is evidence about the wallet as a whole: + /// the walk completed and the money was there, which is the answer to a truncated walk as much + /// as to a plain shortfall. + fn clear_and_announce_recovery(&mut self) -> Option { + // Both takes run before the test: `||` would short-circuit and leave the second latch set, + // which would swallow the next alert about a condition that has in fact just ended. + let was_short = self.alerted.take().is_some(); + let was_unmeasured = self.unmeasured.take().is_some(); + (was_short || was_unmeasured).then(|| FundingAlert { + title: "DIG mirror collateral resumed".into(), + body: concat!( + "The operator wallet can fund mirror collateral again. Your content is being ", + "bonded on the next pass." + ) + .into(), + remedy: None, + }) + } + /// Feed one pass's observation, and get back the alert to raise — or nothing. pub fn observe(&mut self, observation: &FundingObservation) -> Option { match observation { FundingObservation::Unknown => None, - FundingObservation::Healthy => self.alerted.take().map(|_| FundingAlert { - title: "DIG mirror collateral resumed".into(), - body: concat!( - "The operator wallet can fund mirror collateral again. Your content is being ", - "bonded on the next pass." - ) - .into(), - remedy: None, - }), + // Once per entry. Consecutive unmeasured passes are the attacker's steady state, so a + // per-pass message would be 144 a day; a single one that stays true is the signal. + FundingObservation::Unmeasured(reason) => { + if self.unmeasured == Some(*reason) { + return None; + } + self.unmeasured = Some(*reason); + Some(unmeasured_alert(*reason)) + } + FundingObservation::Healthy => self.clear_and_announce_recovery(), FundingObservation::Short { have_dig_base_units, need_dig_base_units, @@ -726,6 +836,60 @@ fn grew_materially(last_deficit: u64, deficit: u64) -> bool { deficit > threshold } +/// The operator-facing text for a blocked pass whose amount is not established (dig-node#469). +/// +/// # The one rule this text obeys +/// +/// It states no spendable total and no deficit, because neither is known — and it says so, rather +/// than leaving an operator to infer an amount from a message that mentions none. A figure here +/// would be the address total or a truncated walk's total, both of which a stranger chooses. +/// +/// It still names an action, because "your node has stopped bonding and we cannot tell you why in +/// numbers" is not something an operator can do anything with. Both conditions are cleared by the +/// same thing — enough of the operator's OWN, spendable $DIG at the operator wallet — so both ask +/// for that, and the truncated case adds the fact that the address is carrying coins that are not +/// theirs, which is what a consolidation into a fresh wallet would resolve. +fn unmeasured_alert(reason: UnmeasuredFunding) -> FundingAlert { + let body = match reason { + UnmeasuredFunding::NoCreateAffordable { + need_dig_base_units, + } => format!( + concat!( + "Your node cannot bond content: it needs {} DIG of collateral for this epoch and ", + "the operator wallet could not fund one. How much of the wallet's $DIG is ", + "actually spendable has not been established this pass, so no figure for the ", + "shortfall is given. Add $DIG to the operator wallet. Until then no new content ", + "is collateralised and it earns nothing." + ), + whole_dig(need_dig_base_units) + ), + UnmeasuredFunding::AuthenticationTruncated { attempted, skipped } => format!( + concat!( + "Your node cannot bond content: the operator address holds more coins than one ", + "pass may check, and {attempted} were checked with {skipped} of them not ", + "provably yours before the budget ran out. How much the wallet can spend is ", + "UNKNOWN, not low, so no figure is given — and adding $DIG may not clear it. ", + "Consolidate the operator wallet's own $DIG into fewer coins. Until then no new ", + "content is collateralised and it earns nothing." + ), + attempted = attempted, + skipped = skipped + ), + }; + FundingAlert { + title: "DIG node cannot bond content".into(), + body, + // No remedy is claimed for the truncated case beyond the body's own words: `TopUp` would be + // the wrong instruction (adding money need not help) and `Consolidate` asserts the wallet + // holds enough, which is exactly what was not established. `NoCreateAffordable` does know + // the direction — the wallet could not fund one create — so it names `TopUp`. + remedy: match reason { + UnmeasuredFunding::NoCreateAffordable { .. } => Some(FundingRemedy::TopUp), + UnmeasuredFunding::AuthenticationTruncated { .. } => None, + }, + } +} + /// The operator-facing text for a shortfall. /// /// $DIG is rendered in whole DIG (1 DIG = 1_000 base units) because that is the unit an operator @@ -1372,9 +1536,11 @@ mod tests { /// what distinguishes a bound from a fixture that merely did not reach one. /// /// The refusal is asserted too. Exhausting the budget leaves the wallet unmeasured, so it must - /// state no total and must classify as [`FundingObservation::Unknown`] — an amount taken from a - /// truncated walk is the understated total this whole ordering exists to remove, and it would - /// raise a wrong alert AND suppress the right one. + /// state no total and must classify as [`FundingObservation::Unmeasured`] — an amount taken + /// from a truncated walk is the understated total this whole ordering exists to remove, and it + /// would raise a wrong alert AND suppress the right one. It must NOT classify as + /// [`FundingObservation::Unknown`] either, which alerts on nothing: that left an operator whose + /// node had permanently stopped bonding with no message on any surface. #[test] fn authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends() { let owner = owner(0x66); @@ -1414,10 +1580,14 @@ mod tests { ); assert_eq!( FundingObservation::from_error(&refusal), - FundingObservation::Unknown, + FundingObservation::Unmeasured(UnmeasuredFunding::AuthenticationTruncated { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: MAX_AUTHENTICATION_ATTEMPTS, + }), concat!( - "an unmeasured wallet must not alert and must not clear a live shortfall; ", - "quoting the total of the coins that happened to be walked would understate it" + "an unmeasured wallet must not quote a total and must not clear a live shortfall ", + "-- and must not be SILENT either, which is what `Unknown` here meant: a node ", + "stopped from bonding indefinitely, with no message on any surface" ) ); } diff --git a/crates/dig-node-service/src/mirror/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 9cd3a7b0..87cb98db 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -67,19 +67,24 @@ pub struct PassDecision { pub funding_shortfall: Option, } -/// A pass that could not afford every create it planned, in the two figures an operator needs. +/// A pass that could not afford every create it planned. /// -/// Both are stated from the SAME funds split that caused the refusal, so the message cannot -/// disagree with the decision it describes. +/// # Why this carries the requirement and NOT the balance (dig-node#469) +/// +/// It once carried a `have_dig_base_units` too — the $DIG left over after the affordable creates +/// were funded, `balance % per_coin`. That figure is derived from `dig_balance_base_units`, the raw +/// sum over `dig_cat_puzzle_hash(owner)`, and NEITHER balance tier authenticates CAT lineage. The +/// address is publicly derivable, so the figure is one a stranger can move by paying a coin into +/// it: planting just under one create's cost has the operator told they are a hair short when they +/// are half a create short, and the alert gate then suppresses the correction as immaterial. +/// +/// So the field is GONE rather than merely unused. An unauthenticated money figure that no caller +/// currently renders is one refactor away from being rendered again; a struct that cannot hold it +/// cannot regress. What survives is the requirement, which is derived from the epoch and the plan +/// and which no stranger can move — see SPEC §25.11. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FundingShortfall { - /// The $DIG left over after the affordable creates were funded — what is still spendable - /// towards the ones that were not, in base units. - /// - /// Not the wallet balance: a balance that funded three of five creates is not money available - /// for the remaining two, and quoting it would overstate what the operator has to work with. - pub have_dig_base_units: u64, - /// What the creates left uncreated would cost together, in the same units. + /// What the creates left uncreated would cost together, in $DIG base units. pub need_dig_base_units: u64, } @@ -247,16 +252,14 @@ pub fn decide(inputs: &PassInputs<'_>) -> PassDecision { // The shortfall is read off the split rather than off the states, because the split is what // decided it: `short` is non-empty exactly when a priced create went unmade for want of funds. - // `have` is the remainder after the affordable prefix was funded — `balance % per_coin` — so - // the deficit these two imply is the money that must actually be ADDED, not the whole cost of - // the unmade creates. - let funding_shortfall = match (per_coin, split.as_ref(), inputs.dig_balance_base_units) { - // `per_coin` of zero is refused as a create everywhere else in this crate, and would make - // the remainder below a division by zero. It reports no shortfall rather than panicking: - // a requirement of zero is a caller defect, not an empty wallet. - (Some(per_coin), Some(split), Some(balance)) if per_coin > 0 && !split.is_funded() => { + // What it reports is the COST of those unmade creates and nothing about the wallet — the + // balance that produced the split is unauthenticated, and no figure derived from it may reach + // an operator (SPEC §25.11, dig-node#469). + let funding_shortfall = match (per_coin, split.as_ref()) { + // `per_coin` of zero is refused as a create everywhere else in this crate; a requirement of + // zero is a caller defect, not an empty wallet, so it reports no shortfall. + (Some(per_coin), Some(split)) if per_coin > 0 && !split.is_funded() => { Some(FundingShortfall { - have_dig_base_units: balance % per_coin, need_dig_base_units: split.shortfall_dig_base_units, }) } @@ -494,12 +497,11 @@ mod tests { assert_eq!( decision.funding_shortfall, Some(FundingShortfall { - have_dig_base_units: 400, need_dig_base_units: REQUIRED, }), concat!( - "the leftover $DIG and the cost of the unmade create are what an operator needs; ", - "quoting the whole balance understates the deficit and quoting zero overstates it" + "the cost of the unmade create is the one figure here no stranger can move; the ", + "leftover balance is an unauthenticated sum over a public address (SPEC 25.11)" ) ); } diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index c6946499..fd892a7b 100644 --- a/crates/dig-node-service/src/mirror/runner.rs +++ b/crates/dig-node-service/src/mirror/runner.rs @@ -452,13 +452,32 @@ impl PassRunner { // commonest real case, and worse: a pass that could afford nothing CLEARED a live // shortfall and announced a recovery that had not happened. A pass that never asked the // wallet for money is not evidence that the wallet has any. + // + // It is `Unmeasured`, NOT `Short`, and the difference is the whole of dig-node#469 one + // surface along. A `Short` quotes a spendable total, and the only total this arm has is + // `dig_balance_base_units` -- the raw sum over `dig_cat_puzzle_hash(owner)`, which + // neither balance tier authenticates. That address is publicly derivable, so a stranger + // who plants one coin just large enough to push the reported balance to a hair under + // one create's cost has the operator told they are 0.001 DIG short when they are 0.500 + // short; they top up the 0.001, and `grew_materially` then suppresses the correction as + // immaterial. SPEC 25.11 -- authentication precedes every figure the operator is told. + // + // The SHORT classification itself is sound and is kept: authentication only ever + // REMOVES candidates, so the authenticated total is at most the reported one, and a + // reported total that cannot fund a create proves the real one cannot either. It is the + // AMOUNT that no honest figure exists for on this path, because no candidate here was + // ever authenticated -- the create loop is handed an empty affordable prefix and never + // iterates, so `authenticate` is never called at all. None if decision_shortfall.is_some() => { let shortfall = decision_shortfall.expect("matched Some directly above"); - super::funding::FundingObservation::Short { - have_dig_base_units: shortfall.have_dig_base_units, - need_dig_base_units: shortfall.need_dig_base_units, - remedy: super::funding::FundingRemedy::TopUp, - } + super::funding::FundingObservation::Unmeasured( + super::funding::UnmeasuredFunding::NoCreateAffordable { + // The requirement, never the wallet. `need` is derived from the epoch + // collateral and the plan, so it is the one figure in this arm that no + // stranger can move. + need_dig_base_units: shortfall.need_dig_base_units, + }, + ) } // Nothing stopped and nothing was unaffordable. `per_coin` is `Some` exactly when the // requirement was known, so this is a pass that funded every create it planned -- @@ -931,6 +950,220 @@ mod tests { ); } + /// **A pass that authenticated nothing never quotes a spendable total to the operator.** + /// + /// **Proves** SPEC §25.11 on the path §25.12 calls the commonest real case. The pass that can + /// afford no create never invokes selection at all, so no candidate is ever authenticated — + /// and the only total available to it is `dig_balance_base_units`, the raw sum over + /// `dig_cat_puzzle_hash(owner)`, which neither balance tier proves lineage for. + /// + /// **Catches** the nearest wrong implementation exactly: classifying this pass as `Short` and + /// rendering `balance % per_coin` as *"the operator wallet holds X DIG that it can spend"*. It + /// is the same defect this PR fixes inside selection, one surface along. + /// + /// # The fixture is the attack, not merely an empty wallet + /// + /// The operator holds NOTHING. A stranger pays `REQUIRED - 1` into the publicly derivable scan + /// address — 999 base units, well under a cent — and the coin has no valid $DIG lineage, so + /// nobody can ever spend it. The reported balance is now 999 against a requirement of 1,000. + /// + /// The wrong version tells the operator they are **0.001 DIG short** when they are **1.000 + /// short**, and the attacker chose that figure. They top up the 0.001, the shortfall persists, + /// and `grew_materially` suppresses the correction as immaterial — so the lie is not merely + /// told once, it is latched. + /// + /// An empty wallet would NOT catch this: at a balance of zero the wrong version renders *"holds + /// 0.000 DIG"*, which is true, and the test would pass under the defect. Varying the one thing + /// a stranger controls is what makes this fixture load-bearing. + /// + /// # The control, without which this test is satisfied by saying nothing anywhere + /// + /// The second half drives the AUTHENTICATED shortfall — `FundingError::Insufficient`, whose + /// figures come from `authenticate` — and asserts it DOES quote the spendable total. An + /// implementation that simply stripped every amount from every funding message passes the first + /// half and fails this one. + #[test] + fn a_pass_that_authenticated_nothing_quotes_no_spendable_total() { + use super::super::funding::{FundingAlertGate, FundingError, FundingRemedy}; + + const PLANTED: u64 = REQUIRED - 1; + /// The clause that asserts a spendable total. Its presence IS the defect. + const SPENDABLE_CLAIM: &str = "that it can spend"; + + let capsule = bond("aa", "11"); + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + + // The attacked pass: the operator's own $DIG is zero, and every base unit the balance + // oracle reports was put there by somebody else. + let mut pass = runner( + FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + balance: PLANTED, + ..FakeEffects::default() + }, + log.clone(), + ) + .with_funding_gate(FundingAlertGate::default()); + let report = pass.run(&ctx()).expect("the pass observes"); + + assert!( + report.created.is_empty(), + "the fixture must afford no create, or the path under test is not the one taken" + ); + let alert = report.funding_alert.expect(concat!( + "a node blocked from bonding raised nothing at all; refusing to quote an ", + "unauthenticated figure must not become refusing to speak" + )); + assert!( + !alert.body.contains(SPENDABLE_CLAIM), + "the operator was told what their wallet can spend, off a total no candidate was \ + authenticated for; a stranger paying 999 base units into a public address chose it. \ + Body was: {}", + alert.body + ); + assert!( + !alert.body.contains("0.001"), + "the deficit quoted is the attacker's arithmetic, not the operator's: they are 1.000 \ + DIG short and were told 0.001. Body was: {}", + alert.body + ); + assert!( + alert.body.contains("1.000"), + "the requirement is the one figure here no stranger can move, and dropping it leaves \ + an operator with nothing to act on. Body was: {}", + alert.body + ); + assert_eq!( + alert.remedy, + Some(FundingRemedy::TopUp), + "the direction IS established even where the amount is not: this wallet could not fund \ + a single create" + ); + + // The control: an AUTHENTICATED shortfall still states its figures. `Insufficient` is + // produced by `authenticate` over proven candidates, so its total is the operator's own. + let mut authenticated = runner( + FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + balance: REQUIRED * 10, + create_fails: vec![capsule.clone()], + create_funding_failure: Some(FundingError::Insufficient { + have_dig_base_units: PLANTED, + need_dig_base_units: REQUIRED, + }), + ..FakeEffects::default() + }, + log, + ) + .with_funding_gate(FundingAlertGate::default()); + let control = authenticated.run(&ctx()).expect("the pass observes"); + let control_alert = control + .funding_alert + .expect("an authenticated shortfall must still alert"); + assert!( + control_alert.body.contains(SPENDABLE_CLAIM), + "an authenticated total is exactly the figure an operator SHOULD be given; silencing \ + every amount is not the fix. Body was: {}", + control_alert.body + ); + } + + /// **Exhausting the authentication budget TELLS the operator, and still clears nothing.** + /// + /// **Proves** the second half of SPEC §25.11's truncated-walk rule. Refusing to quote a total + /// from a truncated walk is right; refusing to say anything is a different thing, and it is the + /// steady state a stranger drives this node into: bury the honest coins under + /// `MAX_AUTHENTICATION_ATTEMPTS` larger unauthenticatable ones and every pass, forever, ends in + /// `CandidatesUnverifiable`. + /// + /// **Catches** the mapping that sent that condition to `Unknown` — where `observe` returns + /// `None`, so even the `tracing::warn!` in `execute`, gated on the alert being `Some`, never + /// fired. The alert channel said nothing, ever, about a node that had stopped bonding. + /// + /// # Why the sequence, and not one pass + /// + /// Pass 1 latches a real, authenticated shortfall. Pass 2 is the truncated walk. This composes + /// the two findings: without the fix the operator's last word is the pass-1 figure, the + /// correction can never arrive because a truncated pass is silent, and the gate stays latched + /// on it. So the test asserts pass 2 SPEAKS and that its message quotes no total — and pass 3, + /// a repeat of the same truncation, is silent, because once per entry is the policy and 144 + /// messages a day is how an operator learns to ignore them. + #[test] + fn a_truncated_authentication_walk_tells_the_operator_without_quoting_a_total() { + use super::super::funding::{FundingAlertGate, FundingError}; + + let capsule = bond("aa", "11"); + let dir = tempfile::tempdir().expect("tempdir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + let run = |gate, failure: FundingError| { + let mut pass = runner( + FakeEffects { + disk: held(std::slice::from_ref(&capsule)), + balance: REQUIRED * 10, + create_fails: vec![capsule.clone()], + create_funding_failure: Some(failure), + ..FakeEffects::default() + }, + log.clone(), + ) + .with_funding_gate(gate); + let report = pass.run(&ctx()).expect("the pass observes"); + (report.funding_alert, pass.take_funding_gate()) + }; + + let truncated = || FundingError::CandidatesUnverifiable { + attempted: super::super::funding::MAX_AUTHENTICATION_ATTEMPTS, + skipped: super::super::funding::MAX_AUTHENTICATION_ATTEMPTS, + need_dig_base_units: REQUIRED, + }; + + // Pass 1: a genuine, authenticated shortfall. The operator is told a figure. + let (first, gate) = run( + FundingAlertGate::default(), + FundingError::Insufficient { + have_dig_base_units: REQUIRED - 1, + need_dig_base_units: REQUIRED, + }, + ); + assert!(first.is_some(), "the control shortfall must latch the gate"); + + // Pass 2: the walk is truncated. This is the pass that was silent. + let (second, gate) = run(gate, truncated()); + let alert = second.expect(concat!( + "the authentication budget was exhausted and the operator was told NOTHING -- not by ", + "the alert, and not by the log line the alert gates. A node that has stopped bonding ", + "because a stranger filled its scan address must say so" + )); + assert!( + !alert.body.contains("short"), + "a truncated walk measured nothing, so it must not describe the wallet as short by \ + any amount. Body was: {}", + alert.body + ); + assert!( + alert.body.contains("UNKNOWN"), + "the operator must be told the figure is unknown rather than low, or they will buy \ + $DIG that cannot help. Body was: {}", + alert.body + ); + assert_eq!( + alert.remedy, None, + concat!( + "a truncated walk establishes no remedy: TopUp is the wrong instruction because ", + "adding money need not help, and Consolidate asserts the wallet holds enough, ", + "which is exactly what was not established" + ) + ); + + // Pass 3: the same truncation persists. Silence -- the operator has been told. + let (third, _) = run(gate, truncated()); + assert_eq!( + third, None, + "the attacker's steady state must not become 144 identical messages a day" + ); + } + /// **A create that failed for a NON-funding reason does not clear a live shortfall.** /// /// **Proves** the `Some(_) => Unknown` arm of the wiring. A pass that stopped because no URL is From db3ca04d94bec13eb25b7e3ae21df29ab8d74dda Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 08:16:14 -0700 Subject: [PATCH 11/12] docs(mirror): point the per-pass authentication budget note at dig-node#481 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index cd88a56b..fe2686e4 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -245,8 +245,9 @@ pub const MAX_SELECTED_FUNDING_COINS: usize = 32; /// /// That is amplification of a bounded factor rather than an unbounded one — K is the node's own /// bond count, not an attacker's choice — so it is recorded here rather than silently untrue, and a -/// per-PASS budget shared across the create loop is filed as follow-up rather than taken inside -/// this change. +/// per-PASS budget shared across the create loop is filed as dig-node#481 rather than taken inside +/// this change — it is not a counter change, because exhausting a shared budget part-way turns +/// later bonds into [`FundingError::CandidatesUnverifiable`], which now SPEAKS to the operator. /// /// 128 is four times the input bound, so a wallet fragmented right /// up to the point where [`FundingError::TooManyInputs`] is the correct answer still reaches that From 3fd69386f58a8fe30cecfc8cafe4972fd4bd6269 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 08:18:04 -0700 Subject: [PATCH 12/12] style(mirror): rustfmt the new truncated-walk observation arm Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index fe2686e4..8a68d8bd 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -705,15 +705,11 @@ impl FundingObservation { // this speaks -- once, naming the truncation and quoting no total -- and still clears // nothing. FundingError::CandidatesUnverifiable { - attempted, - skipped, - .. - } => { - FundingObservation::Unmeasured(UnmeasuredFunding::AuthenticationTruncated { - attempted: *attempted, - skipped: *skipped, - }) - } + attempted, skipped, .. + } => FundingObservation::Unmeasured(UnmeasuredFunding::AuthenticationTruncated { + attempted: *attempted, + skipped: *skipped, + }), } } }