diff --git a/Cargo.lock b/Cargo.lock index dd6bdd10..39fc31eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.252.82" +version = "0.252.92" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index dfb3e55b..388a7a6d 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.82" +version = "0.252.92" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index cfec1118..7c92b464 100644 --- a/SPEC.md +++ b/SPEC.md @@ -9347,6 +9347,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. @@ -9381,6 +9391,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*. diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 8a68d8bd..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. @@ -622,6 +681,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 +834,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 +884,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 +1911,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" + ); + } } 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/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!( 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" + ) + ); +}