From fea20d7212762db5eaf4dffee2d4f14943d996bd Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 00:44:40 -0700 Subject: [PATCH 1/5] chore(mirror): open the bond/funding audit-residue lane at 0.252.32 Salvage anchor for dig-node#527, #513 and #481. Version claimed above every in-flight branch (highest was 0.252.27 on PR #524). Co-Authored-By: Claude --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ff4bf3ee..5b1441d8 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.252.13" +version = "0.252.32" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From 546593bc5c26ed08df9eecd7c6efc66403dc0836 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 01:25:57 -0700 Subject: [PATCH 2/5] fix(mirror): latch the funding alert on the condition, not on attacker-movable counts `FundingAlertGate::observe` suppressed a repeat unmeasured alert only when the whole `UnmeasuredFunding` value compared equal. `AuthenticationTruncated` carries `skipped` -- `MAX_AUTHENTICATION_ATTEMPTS` minus however many honest coins the bounded walk reached -- and the walk runs over a PUBLIC puzzle hash, so a stranger moves that number by paying one more coin to the operator's $DIG address. The reason value therefore changed between passes, the gate never latched, and a one-time dust spend bought 144 desktop alerts a day: exactly the stream the gate exists to prevent, and the surest way to train an operator to dismiss the alert that matters. Latching now compares `UnmeasuredFunding::alert_key` -- the discriminant plus only those payload fields no stranger can move. `NoCreateAffordable`'s `need_dig_base_units` is derived from the epoch requirement and the plan rather than from the wallet, so it stays IN the key: a changed collateral requirement is a change in what the operator must do, and swallowing it would be its own money defect. `AuthenticationTruncated`'s counts drop out of the key and remain fully available to the alert body, because an operator who is being told still deserves the real figures. The change is private to `funding.rs`: the field type and the comparison, not the public signature of `observe`. Refs dig-node#481 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 177 +++++++++++++++++- 1 file changed, 174 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 8a68d8bd..5e5c5e65 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -622,6 +622,33 @@ pub enum UnmeasuredFunding { }, } +impl UnmeasuredFunding { + /// The identity two unmeasured conditions are compared on to decide whether to alert again. + /// + /// Not the whole value, and not the bare discriminant either -- the two variants differ in + /// exactly the property that matters here, so a rule that treated them alike would be wrong in + /// one direction or the other: + /// + /// * [`UnmeasuredFunding::AuthenticationTruncated`] carries `attempted` and `skipped`, both + /// read off a walk over a PUBLIC puzzle hash. A stranger moves `skipped` by paying one more + /// coin there, so including it lets the attacker set the notification rate. + /// * [`UnmeasuredFunding::NoCreateAffordable`] carries `need_dig_base_units`, derived from the + /// epoch requirement and the plan and never from the wallet. A change in it is a change in + /// what the operator must actually do, and swallowing that is its own money defect: a real + /// funding problem, materially altered, going unreported. + /// + /// So the key excludes the attacker-movable fields and keeps the rest. + fn alert_key(&self) -> UnmeasuredAlertKey { + let kind = std::mem::discriminant(self); + match self { + UnmeasuredFunding::NoCreateAffordable { + need_dig_base_units, + } => (kind, Some(*need_dig_base_units)), + UnmeasuredFunding::AuthenticationTruncated { .. } => (kind, None), + } + } +} + /// What one mirror pass observed about funding — the input the alert gate decides on. /// /// A pass that could not READ the balance is not a pass that found it short: reporting a shortfall @@ -748,9 +775,19 @@ pub struct FundingAlertGate { /// 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, + /// + /// Held as an [`UnmeasuredFunding::alert_key`] rather than as the condition itself, so that + /// the fields a stranger can move cannot re-arm the alert. The type is deliberately private: + /// nothing outside this module reads it, and the public `observe` signature is unchanged. + unmeasured: Option, } +/// What makes two unmeasured conditions the SAME condition, for alerting purposes. +/// +/// The discriminant, plus only those payload fields no stranger can move. See +/// [`UnmeasuredFunding::alert_key`]. +type UnmeasuredAlertKey = (std::mem::Discriminant, Option); + /// 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 @@ -788,10 +825,22 @@ impl FundingAlertGate { // 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) { + // Latched on [`UnmeasuredFunding::alert_key`], never on the whole value. Equality + // over the whole value is not a latch: `AuthenticationTruncated` carries `skipped`, + // which is `MAX_AUTHENTICATION_ATTEMPTS` minus however many honest coins the walk + // reached -- and anyone may move that by paying one more coin to the operator's + // publicly derivable $DIG address. A stranger therefore chose how often the + // operator was notified, which is the 144-a-day stream this gate exists to prevent, + // bought with a one-time dust spend (dig-node#481). + // + // The counts stay fully available to the alert BODY below, because an operator who + // IS being told deserves the real figures. It is only the decision to speak AGAIN + // that ignores the fields a stranger can move. + let key = reason.alert_key(); + if self.unmeasured == Some(key) { return None; } - self.unmeasured = Some(*reason); + self.unmeasured = Some(key); Some(unmeasured_alert(*reason)) } FundingObservation::Healthy => self.clear_and_announce_recovery(), @@ -1803,4 +1852,126 @@ mod tests { bond: None, } } + + /// **A repeat truncation whose counts a STRANGER moved must not re-alert.** + /// + /// The gate latches so that an unattended ten-minute pass cannot become 144 notifications a + /// day. `AuthenticationTruncated` carries `skipped`, which is `MAX_AUTHENTICATION_ATTEMPTS` + /// minus however many honest coins the walk reached - a figure anyone may move by paying one + /// more coin to the operator's publicly derivable $DIG address. Latching on the whole value + /// therefore handed the attacker the latch: a new count each pass, an alert each pass, and the + /// operator trained to dismiss the one alert that mattered. + /// + /// The fixture varies exactly ONE thing between the two passes - one additional planted coin - + /// because that is the cheapest move an attacker has, and a fixture that changed the kind of + /// condition as well could not tell suppression from a coincidence. + #[test] + fn a_truncation_whose_skip_count_moved_does_not_re_alert() { + let mut gate = FundingAlertGate::default(); + + let first = gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::AuthenticationTruncated { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: 40, + }, + )); + assert!( + first.is_some(), + "the transition into a truncated walk is the one pass that must speak" + ); + + let second = gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::AuthenticationTruncated { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: 41, + }, + )); + assert!( + second.is_none(), + concat!( + "one more planted coin changed `skipped` and nothing else; a gate that latches on ", + "the counts does not latch at all, so the attacker sets the notification rate" + ) + ); + } + + /// **A changed REQUIREMENT is real news and must alert, even though the kind is unchanged.** + /// + /// The counterweight that keeps the latch from over-correcting, and it guards a money defect + /// rather than a nuisance. `NoCreateAffordable` carries `need_dig_base_units`, which is derived + /// from the epoch requirement and the plan and never from the wallet, so no stranger can move + /// it. When it changes, what the operator must do has changed -- the amount they must add is + /// different -- and a latch keyed on the bare discriminant would silently swallow that. + /// + /// Both passes are the SAME variant on purpose. A fixture that also changed the variant could + /// not distinguish "the key carries the requirement" from "the key is just the discriminant". + #[test] + fn a_changed_collateral_requirement_alerts_again() { + let mut gate = FundingAlertGate::default(); + + let first = gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::NoCreateAffordable { + need_dig_base_units: 100, + }, + )); + assert!(first.is_some(), "the transition into the condition speaks"); + + let second = gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::NoCreateAffordable { + need_dig_base_units: 5_000, + }, + )); + assert!( + second.is_some(), + concat!( + "the collateral requirement rose fifty-fold and the operator was never told the ", + "new figure; this field is node-derived, so suppressing it reports nothing about ", + "a funding problem that has materially changed" + ) + ); + } + + /// **Suppressing the counts must not suppress a genuine change of condition.** + /// + /// The counterweight to the test above, and it guards a money defect rather than a nuisance: + /// over-suppression means a real, DIFFERENT funding problem going unreported. A wallet that + /// stops being truncated and starts being unable to afford any create at all is a different + /// sentence with a different remedy, and the operator must hear it. + #[test] + fn a_different_unmeasured_kind_still_alerts() { + let mut gate = FundingAlertGate::default(); + + assert!( + gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::AuthenticationTruncated { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: 40, + }, + )) + .is_some(), + "the first truncation speaks" + ); + assert!( + gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::NoCreateAffordable { + need_dig_base_units: 500_000, + }, + )) + .is_some(), + concat!( + "a truncated walk and an unaffordable create are different conditions with ", + "different remedies; latching on the kind must still let the kind CHANGE" + ) + ); + assert!( + gate.observe(&FundingObservation::Unmeasured( + UnmeasuredFunding::AuthenticationTruncated { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: 99, + }, + )) + .is_some(), + "and back again -- the transition is what speaks, in either direction" + ); + } } From 5a71fbe15998ddf2720bcb1266eb46ddaa1665f3 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 01:32:25 -0700 Subject: [PATCH 3/5] fix(mirror): consume the funding skip count in production and report it once `FundingSelection.skipped` was computed on every selection and had ZERO production consumers: the mirror lifecycle funded creates through `select_operator_dig_cats`, whose whole body discards it. The module's own doc claimed "a skip is counted and reported, never swallowed", and that was true of the tests and false of the shipped node. It matters because the same path that passes over a stranger's coin passes over one of this node's OWN coins when lineage handling has a bug. Production then refused with `Insufficient { have_dig_base_units }` -- a confident, understated figure. Unknown is not zero, and an operator reading an unmeasured balance as a measured one tops up money they already hold. Three changes: * The lifecycle funds through `select_operator_dig_cats_detailed` and consumes the skips, naming the store the selector cannot know. It reports on the SUCCESS path too, not only where `CandidatesUnverifiable` already reached the operator -- a funded pass that passed candidates over has still established only a floor. * `skip_report` is the one operator-facing sentence, and it says the total is a FLOOR: "at least that much, not exactly that much", plus what to investigate. A report that quoted the figure flat would restate the lie in a new place. * The per-candidate `tracing::warn!` inside the walk is gone. It was up to `MAX_AUTHENTICATION_ATTEMPTS` lines per selection per create per pass -- about 18,400 a day at one store -- and the count was set by whoever planted the coins, in a module with no rate limit. The walk now reports ONCE, with the id list capped at `SKIP_SAMPLE`, so neither the line count nor the line length is a figure an attacker chooses. The doc quoted above now describes what the code does. Refs dig-node#481 Co-Authored-By: Claude --- crates/dig-node-service/src/mirror/funding.rs | 87 ++++++++++++++++--- .../dig-node-service/src/mirror/lifecycle.rs | 22 ++++- .../tests/mirror_operator_funding.rs | 86 +++++++++++++++++- 3 files changed, 177 insertions(+), 18 deletions(-) diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 5e5c5e65..78c6ccf4 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -331,6 +331,54 @@ pub struct SkippedCandidate { pub reason: String, } +/// How many skipped coin ids one report names before it stops listing them. +/// +/// Coin ids are public, so listing them leaks nothing, and a handful is what makes a report +/// actionable rather than merely alarming -- an operator can look them up on chain. The bound is +/// what keeps the line O(1) in a figure an attacker chooses: without it the aggregation would just +/// move the attacker-driven volume from the line COUNT to the line LENGTH. +pub const SKIP_SAMPLE: usize = 8; + +/// The one operator-facing sentence for a whole selection's passed-over candidates. +/// +/// `None` when nothing was passed over, so a healthy pass says nothing at all. +/// +/// # Why it says "at least" +/// +/// The same path that passes over a stranger's coin passes over one of this node's OWN coins when +/// lineage handling has a bug. Any total derived from a walk that skipped candidates is therefore a +/// FLOOR on what the operator can spend, not the total -- and a confident understated figure is the +/// money lie this module exists to refuse (dig-node#481). An operator told "you have X" acts on X; +/// an operator told "you have at least X, and N coins here could not be proven yours" knows both +/// what they can do and what to investigate. +pub fn skip_report(skipped: &[SkippedCandidate]) -> Option { + if skipped.is_empty() { + return None; + } + let sample: Vec<&str> = skipped + .iter() + .take(SKIP_SAMPLE) + .map(|candidate| candidate.coin_id.as_str()) + .collect(); + let ellipsis = if skipped.len() > SKIP_SAMPLE { + ", ..." + } else { + "" + }; + Some(format!( + concat!( + "{count} coin(s) at the operator's $DIG address could not be proven spendable and ", + "were passed over, so any total this pass reports is a FLOOR -- the operator can ", + "spend AT LEAST that much, not exactly that much. If any of these is one of this ", + "node's own coins, its lineage is not readable from the chain and the shortfall is ", + "not real. Coin ids: {sample}{ellipsis}" + ), + count = skipped.len(), + sample = sample.join(", "), + ellipsis = ellipsis + )) +} + /// The outcome of a funding selection: the coins to spend, and the candidates passed over. #[derive(Debug, Clone)] pub struct FundingSelection { @@ -342,8 +390,14 @@ pub struct FundingSelection { /// 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. +/// The `Vec` half of [`select_operator_dig_cats_detailed`], for callers whose only question is +/// which coins to spend. +/// +/// NOT the funding route. Discarding [`FundingSelection::skipped`] is what made "a skip is counted +/// and reported" true of tests and false of the shipped node (dig-node#481), so the mirror +/// lifecycle calls [`select_operator_dig_cats_detailed`] and consumes the skips. This remains for +/// the many tests that assert on the coins alone; the aggregated operator report is emitted by the +/// selection itself, so nothing is silenced by choosing it. pub fn select_operator_dig_cats( source: &S, owner_puzzle_hash: Bytes32, @@ -379,9 +433,12 @@ pub fn select_operator_dig_cats( /// /// 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 is counted and reported**, never swallowed -- counted into +/// [`FundingSelection::skipped`], which the funding caller consumes, and reported once per +/// selection through [`skip_report`], whose wording makes any total this pass quotes a FLOOR +/// rather than a figure. 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 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 @@ -470,16 +527,11 @@ pub fn select_operator_dig_cats_detailed( authenticated_total = authenticated_total.saturating_add(record.coin.amount); authenticated.push((record.clone(), cat)); } + // Collected, not logged. One line per skipped candidate is up to + // `MAX_AUTHENTICATION_ATTEMPTS` lines per selection per create per pass, and the + // count is chosen by whoever planted the coins -- an attacker-driven log volume in a + // module with no rate limit anywhere. The whole walk is reported ONCE below. 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. @@ -487,6 +539,13 @@ pub fn select_operator_dig_cats_detailed( } } + // ONE line for the whole walk, whatever this selection goes on to do. Placed before the + // refusals below rather than after the `Ok`, because a truncated or uncovered walk is exactly + // when an operator most needs to know their address is carrying coins that are not theirs. + if let Some(report) = skip_report(&skipped) { + tracing::warn!(target: "mirror", skipped = skipped.len(), "{report}"); + } + // 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. diff --git a/crates/dig-node-service/src/mirror/lifecycle.rs b/crates/dig-node-service/src/mirror/lifecycle.rs index 30892fd4..102a1884 100644 --- a/crates/dig-node-service/src/mirror/lifecycle.rs +++ b/crates/dig-node-service/src/mirror/lifecycle.rs @@ -451,13 +451,31 @@ impl MirrorEffects for NodeMirrorEffects<'_, S> { // compile error — so the scope is the guarantee, and it is deliberately narrow. let dig_coins = { let committed = committed.borrow(); - funding::select_operator_dig_cats( + // `_detailed`, so the skips are CONSUMED rather than dropped on the floor. The plain + // wrapper discards `FundingSelection::skipped`, which made "a skip is counted and + // reported" true of the tests and false of the shipped node: the same path that passes + // over a stranger's coin passes over one of this node's own under a lineage bug, and + // the create then refuses with a confident understated total (dig-node#481). + let selection = funding::select_operator_dig_cats_detailed( self.source, self.owner_puzzle_hash, amount_dig_base_units, &committed, ) - .map_err(funding_refusal)? + .map_err(funding_refusal)?; + // Named with the store, which the selector cannot know. Even a FUNDED create says so: + // a pass that covered its requirement while passing over candidates has still only + // established a floor, and staying quiet about that on the success path is how the + // condition goes unnoticed until it is a shortfall. + if let Some(report) = funding::skip_report(&selection.skipped) { + tracing::warn!( + target: "mirror", + store_id = %bond.store_id, + skipped = selection.skipped.len(), + "{report}" + ); + } + selection.cats }; let signer = self diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs index c3c97942..767b2940 100644 --- a/crates/dig-node-service/tests/mirror_operator_funding.rs +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -28,8 +28,8 @@ 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, select_operator_dig_cats_detailed, FundingError, - FundingObservation, FundingRemedy, + dig_cat_puzzle_hash, select_operator_dig_cats, select_operator_dig_cats_detailed, skip_report, + FundingError, FundingObservation, FundingRemedy, SKIP_SAMPLE, }; use support::{ordinary_dig_coins, wallet, Wallet}; @@ -607,3 +607,85 @@ fn coins_a_stranger_paid_in_cannot_turn_a_top_up_into_a_consolidation() { ) ); } + +/// **A FUNDED create still reports what it passed over, and reports it in O(1).** +/// +/// Two properties, and the fixture is built so that the nearest wrong implementation of each fails +/// it. +/// +/// * **The success path speaks.** `FundingSelection::skipped` was computed and had no production +/// consumer at all, so "a skip is counted and reported" was true of the tests and false of the +/// shipped node (dig-node#481). The selection here SUCCEEDS -- the operator's own coins cover the +/// requirement -- because a report emitted only on the refusal paths satisfies a fixture that +/// refuses, and this is the half that was invisible. +/// * **The line is bounded.** The skipped count is chosen by whoever plants the coins, and there is +/// no rate limit in this module, so a report that named every id would move an attacker-driven +/// volume from the line count to the line length rather than removing it. Two runs differing only +/// in how many coins the stranger planted must name the same number of ids. +/// +/// The report must also frame the total as a floor: the same path that passes over a stranger's +/// coin passes over one of this node's own under a lineage bug, so "you have X" would be a +/// confident understatement of the operator's money. +#[test] +fn a_funded_selection_reports_what_it_passed_over_in_one_bounded_line() { + let report_for = |planted: u64| -> String { + let operator = operator(); + let mut chain = Chain::default(); + chain.fund(&operator, &[REQUIRED], salt(1)); + // Every planted coin is larger than the honest one, so all of them are walked first. + let dust: Vec = (1..=planted).map(|n| REQUIRED * 10 + n).collect(); + chain.fund_without_lineage(&operator, &dust, salt(5)); + + let selection = select_operator_dig_cats_detailed( + &chain, + operator.puzzle_hash, + REQUIRED, + &HashSet::new(), + ) + .expect("the operator's own coin covers the requirement, so this pass FUNDS"); + assert_eq!( + selection.skipped.len(), + planted as usize, + "every planted coin should have been walked and passed over" + ); + skip_report(&selection.skipped).expect("a selection that passed candidates over must speak") + }; + + let few = report_for((SKIP_SAMPLE + 4) as u64); + let many = report_for((SKIP_SAMPLE * 5) as u64); + + assert!( + few.contains(&format!("{} coin(s)", SKIP_SAMPLE + 4)), + "the report must state how many were passed over: {few}" + ); + assert!( + few.contains("AT LEAST"), + concat!( + "a total from a walk that skipped candidates is a FLOOR; a report that does not say ", + "so leaves the operator acting on an understated figure as though it were exact: " + ) + ); + + let ids = |report: &str| { + report.split("Coin ids: ").nth(1).map(|tail| { + tail.trim_end_matches(", ...") + .split(", ") + .filter(|id| !id.is_empty()) + .count() + }) + }; + assert_eq!( + ids(&few), + Some(SKIP_SAMPLE), + "the id list is capped at the sample bound: {few}" + ); + assert_eq!( + ids(&many), + ids(&few), + concat!( + "five times as many planted coins named five times as many ids, so the line length is ", + "still a figure the attacker chooses -- the aggregation moved the volume rather than ", + "bounding it" + ) + ); +} From 4fa8702a5882b714264d06f6ef09b307ee3672fb Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 01:35:16 -0700 Subject: [PATCH 4/5] refactor(wallet): rename wallet_funded::FundingObservation to EverFundedEvidence Two types in this crate were called `FundingObservation`, and they are not rivals to centralize -- they are different concepts that happened to share a name (dig-node#481): | | `wallet_funded` | `mirror::funding` | |---|---|---| | subject | the node-custodied wallet | the operator wallet | | question | has it EVER held money | what is spendable THIS pass | | decides | `autoseed::latch_ever_funded` | the operator alert gate | | lifetime | monotonic, permanent | per-pass | Merging them would collapse the node-wallet/operator-wallet boundary that `mirror::funding`'s module doc exists to protect, which is the money lie that module was written to prevent. So the fix is a rename, and the name now states what the type actually decides: it is evidence the wallet has ever held money, not a measurement of funding. The richer, newer `mirror::funding` type keeps the name, which reads correctly for a per-pass measurement. The two are cross-referenced from the renamed type's doc so the next reader cannot re-derive the confusion. Blast radius: `wallet_funded.rs` (18 references, its own module and tests) and `server.rs` (4, one a doc comment) -- the complete set. The gitnexus index for this repo is 338 commits behind, which returns a false-safe empty impact, so the radius was established by grep with a controlled pattern instead. Refs dig-node#481 Co-Authored-By: Claude --- crates/dig-node-service/src/server.rs | 8 +-- crates/dig-node-service/src/wallet_funded.rs | 54 +++++++++++++------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 5e3991d9..8abf44c1 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2833,21 +2833,21 @@ fn spawn_mirror_passes( // This pass is the observation point because it already reads the operator wallet's // own balance on a timer, so the latch costs no extra chain read and cannot drift from // the figure the node acts on. `synced` gates ONLY the zero case (see - // `FundingObservation::should_latch`), so a stale or fallback answer showing money + // `EverFundedEvidence::should_latch`), so a stale or fallback answer showing money // still latches immediately. { - use crate::wallet_funded::FundingObservation; + use crate::wallet_funded::EverFundedEvidence; let synced = wallet .wallet_sync_status() .await .is_ok_and(|s| s.phase == dig_wallet::sage::sync_supervisor::SyncPhase::Synced); let observation = match &dig_balance { Ok(base_units) => { - FundingObservation::classify(u128::from(*base_units), 0, synced) + EverFundedEvidence::classify(u128::from(*base_units), 0, synced) } // An unreadable balance is not a zero balance. It says nothing, and the latch // is monotonic, so the next pass that CAN read decides. - Err(_) => FundingObservation::CannotSay, + Err(_) => EverFundedEvidence::CannotSay, }; crate::wallet_funded::observe(&paths, observation); } diff --git a/crates/dig-node-service/src/wallet_funded.rs b/crates/dig-node-service/src/wallet_funded.rs index f9ae8af8..b0974b6d 100644 --- a/crates/dig-node-service/src/wallet_funded.rs +++ b/crates/dig-node-service/src/wallet_funded.rs @@ -12,13 +12,29 @@ use dig_wallet::autoseed::{self, WalletPaths}; -/// What a balance observation lets the node conclude about funding. +/// What a balance observation lets the node conclude about the wallet ever having held money. +/// +/// # Not to be confused with [`crate::mirror::funding::FundingObservation`] +/// +/// The two were both called `FundingObservation` and are different concepts, which is why this one +/// was renamed rather than merged (dig-node#481). Merging them would collapse the distinction +/// `mirror::funding` exists to protect: +/// +/// | | this type | `mirror::funding::FundingObservation` | +/// |---|---|---| +/// | subject | the NODE-custodied wallet | the OPERATOR wallet | +/// | question | has it EVER held money | what is spendable THIS pass | +/// | decides | [`dig_wallet::autoseed::latch_ever_funded`] | the operator alert gate | +/// | lifetime | monotonic, permanent | per-pass | +/// +/// The name says what it decides: this is evidence about ever having been funded, not a +/// measurement of funding. /// /// The three variants exist because a balance read has THREE outcomes, not two, and collapsing /// the middle one is the defect this whole batch is about: a zero from a node that cannot see is /// not the same claim as a zero from a node that can. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FundingObservation { +pub enum EverFundedEvidence { /// A non-zero figure was observed. The wallet holds, or has held, money. Funded, /// A CURRENT read from an authoritative tier reported zero. This is a real claim of @@ -29,7 +45,7 @@ pub enum FundingObservation { CannotSay, } -impl FundingObservation { +impl EverFundedEvidence { /// Classify a balance reading. /// /// `balance`/`pending` are summed deliberately: value in flight is value the wallet has held. @@ -82,7 +98,7 @@ impl FundingObservation { /// A latch write that FAILS is logged and swallowed. This runs inside a periodic pass whose job is /// something else, and a sidecar write failure must not take that pass down — the next observation /// retries, and the flag defaults to the safe answer meanwhile. -pub fn observe(paths: &WalletPaths, observation: FundingObservation) { +pub fn observe(paths: &WalletPaths, observation: EverFundedEvidence) { if !observation.should_latch() { return; } @@ -106,27 +122,27 @@ mod tests { #[test] fn a_current_zero_is_evidence_of_emptiness_and_an_unsynced_zero_is_not() { assert_eq!( - FundingObservation::classify(0, 0, true), - FundingObservation::ObservedEmpty + EverFundedEvidence::classify(0, 0, true), + EverFundedEvidence::ObservedEmpty ); assert_eq!( - FundingObservation::classify(0, 0, false), - FundingObservation::CannotSay + EverFundedEvidence::classify(0, 0, false), + EverFundedEvidence::CannotSay ); // The control that makes the pair load-bearing: a real figure classifies as funded from // EITHER tier, so `synced` is a gate on the zero case only, never on the money case. assert_eq!( - FundingObservation::classify(1, 0, true), - FundingObservation::Funded + EverFundedEvidence::classify(1, 0, true), + EverFundedEvidence::Funded ); assert_eq!( - FundingObservation::classify(1, 0, false), - FundingObservation::Funded + EverFundedEvidence::classify(1, 0, false), + EverFundedEvidence::Funded ); // Value in flight is value held. assert_eq!( - FundingObservation::classify(0, 1, true), - FundingObservation::Funded + EverFundedEvidence::classify(0, 1, true), + EverFundedEvidence::Funded ); } @@ -136,14 +152,14 @@ mod tests { /// unconditionally and one that never latched would each satisfy a single-direction test. #[test] fn only_evidence_of_money_latches() { - assert!(FundingObservation::Funded.should_latch()); + assert!(EverFundedEvidence::Funded.should_latch()); assert!( - !FundingObservation::CannotSay.should_latch(), + !EverFundedEvidence::CannotSay.should_latch(), "an unknown DEFERS: every node is in this state on its first pass, so latching here \ would make `is_disposable` vacuously false forever — see `should_latch`'s doc" ); assert!( - !FundingObservation::ObservedEmpty.should_latch(), + !EverFundedEvidence::ObservedEmpty.should_latch(), "a current zero is real evidence of emptiness and must not latch" ); } @@ -163,13 +179,13 @@ mod tests { ); // A current zero must NOT latch, or the test below could not fail. - observe(&paths, FundingObservation::ObservedEmpty); + observe(&paths, EverFundedEvidence::ObservedEmpty); assert!( autoseed::is_disposable(&paths), "a measured empty wallet stays disposable" ); - observe(&paths, FundingObservation::Funded); + observe(&paths, EverFundedEvidence::Funded); // Re-read from the filesystem rather than from memory: this is the restart. assert!( From 7e7416e6a569fc726a30dd113fd303c9e2284770 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 01:44:21 -0700 Subject: [PATCH 5/5] docs(spec): state how an unmeasured funding condition is compared, and bound the skip report Two clauses backfilled for behaviour changed in this branch (dig-node#481), so SPEC stops describing a node that no longer exists. 25.12 required an unmeasured observation to raise "once on entering it" and said nothing about what makes two such observations the same condition. The implementation compared the whole value, including the truncated walk's skipped count -- which a stranger moves by paying one coin into the publicly derivable operator address, so the suppression never applied and the attacker set the notification rate. The clause now requires attacker-movable fields to be excluded from that comparison, and requires a re-raise when a figure the operator must act on changes and no stranger can move it, since suppressing that is under-reporting rather than repeat-suppression. 25.11's "counted and reported" is now specific about the report: one bounded message per selection rather than one per candidate, an id list that is itself bounded, reachable on the funding path the node actually uses including where the selection succeeds, and framing any total as a floor. Co-Authored-By: Claude --- SPEC.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/SPEC.md b/SPEC.md index 12500d1f..8c61a483 100644 --- a/SPEC.md +++ b/SPEC.md @@ -9314,6 +9314,16 @@ at this operator's puzzle hash. The node MUST NOT treat an unauthenticated candi * 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. +**The report of passed-over candidates MUST be one bounded message per selection, and MUST frame +any total the pass quotes as a FLOOR.** One message per candidate makes the log volume a figure the +attacker chooses, since the candidate count is set by whoever paid coins into the public address; +the aggregate MUST therefore also bound how many coin ids it names, or the volume moves from the +message count to the message length. The report MUST be reachable on the funding path the node +actually uses, including where the selection SUCCEEDS: the same skip covers a genuine lineage +defect, so a pass that funded while passing candidates over has established only a lower bound on +what the operator can spend, and a total quoted flat would understate their money. + + **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. @@ -9348,6 +9358,20 @@ corrected. The message MUST name the condition and an action the operator can ta 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. +**Whether an *unmeasured* pass is the SAME condition as the last one MUST be decided without +reference to any figure a stranger can move.** The repeat-suppression above is what keeps an +unattended pass timer from becoming a notification stream, so a condition whose identity includes an +attacker-chosen field is not suppressed at all: one coin paid into the publicly derivable operator +address changes the count, the condition compares unequal, and the operator is notified on every +pass indefinitely, for the price of one dust spend. A node MUST therefore exclude the truncated +walk's attempted and skipped counts from that comparison, while still stating them in the message +body. + +Conversely a node MUST re-raise when a figure the operator must ACT on has changed and no stranger +can move it -- in particular the epoch collateral requirement, which is derived from the plan rather +than from the wallet. Suppressing that is not repeat-suppression but under-reporting: the operator +has been told a different amount is needed than the amount now needed. + 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*.