diff --git a/Cargo.lock b/Cargo.lock index 0d26ba4c..71aa8d7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.217.0" +version = "0.222.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2a394e81..63b51984 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.217.0" +version = "0.222.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index 08cde5b2..8feca9a1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8722,3 +8722,79 @@ 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 — 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 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 +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 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 6f0ffda1..8a68d8bd 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -90,6 +90,30 @@ pub enum FundingError { needed: usize, /// The bound. limit: usize, + /// What the operator can spend, in $DIG base units. Carried because this refusal is the + /// only producer of [`FundingRemedy::Consolidate`], and an operator-facing message that + /// says *you have enough, it is in too many pieces* has to be able to say how much. + have_dig_base_units: u64, + /// The margined requirement, in the same units. Never greater than `have` here: selection + /// 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. /// @@ -116,16 +140,30 @@ 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, "the spend audit record is unreadable ({e}), so which coins are already committed \ to an in-flight bundle is unknown; no coin is selected" ), - FundingError::TooManyInputs { needed, limit } => write!( + 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") @@ -159,18 +197,77 @@ 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 +/// 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 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 +/// 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 the AMOUNT but not about the condition. Exhausting the budget yields +/// [`FundingError::CandidatesUnverifiable`], which states no total and maps to +/// [`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. /// /// $DIG is a CAT, so the operator's coins are NEVER at the bare owner puzzle hash: they sit at the @@ -220,8 +317,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 @@ -230,12 +364,62 @@ 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 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, need_dig_base_units: u64, committed: &HashSet, -) -> Result, FundingError> { +) -> Result { if need_dig_base_units == 0 { return Err(FundingError::ZeroCollateral); } @@ -248,21 +432,112 @@ 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 + // 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(); + 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; + } + 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)); + } + 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), + } + } + + // 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, r| sum.saturating_add(r.coin.amount)); + .fold(0u64, |sum, (amount, _, _)| sum.saturating_add(*amount)); - let selected = select_largest_first(candidates, need_dig_base_units, |r| { - (r.coin.amount, r.coin.coin_id()) + let selected = select_largest_first(authenticated, need_dig_base_units, |(amount, id, _)| { + (*amount, *id) }) - .map_err(|_| FundingError::Insufficient { - have_dig_base_units: available, + .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, })?; @@ -270,14 +545,391 @@ pub fn select_operator_dig_cats( return Err(FundingError::TooManyInputs { needed: selected.len(), limit: MAX_SELECTED_FUNDING_COINS, + have_dig_base_units: authenticated_total, + need_dig_base_units, }); } - let mut cats = Vec::with_capacity(selected.len()); - for record in &selected { - cats.push(authenticate(source, record, owner_puzzle_hash)?); + Ok(selected + .into_iter() + .map(|(_, _, payload)| payload) + .collect()) +} + +/// 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 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. +/// +/// 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. + 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, + }, + /// Bonding is blocked and the amount is not established. See [`UnmeasuredFunding`]. + Unmeasured(UnmeasuredFunding), + /// 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, + }, + // A funded wallet whose $DIG is in too many pieces (dig-node#427). It IS a shortfall + // an operator can act on, and it is the ONLY producer of `Consolidate` -- before this + // arm existed that remedy was reachable in tests and by nothing else, so the branch of + // `shortfall_alert` telling an operator to consolidate could never be shown to one. + // + // `have >= need` here, so the deficit is zero, and that is correct rather than a + // degenerate case: the operator is not missing money, and an alert that quoted a + // deficit would send them to buy $DIG they already hold. + FundingError::TooManyInputs { + 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::Consolidate, + }, + // 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 + } + // 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. + // + // 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, + }), + } + } +} + +/// 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. +/// * **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. +/// +/// 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)>, + /// 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. +/// +/// 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 { + /// 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, + // 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, + 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 deficit of zero is reachable and means something specific: [`FundingRemedy::Consolidate`], a +/// wallet that holds enough $DIG in too many pieces. Zero stays zero under this arithmetic, so a +/// consolidate state alerts once on entry and then stays quiet however many passes it persists for +/// — which is the intended behaviour, since the remedy never changes while the coins do not. +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 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, + }, } - Ok(cats) +} + +/// 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. @@ -334,6 +986,178 @@ 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, @@ -417,139 +1241,351 @@ 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; + 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; - /// A wallet whose $DIG is in `count` coins of one base unit each. - struct Dusted { - count: u64, - owner: Bytes32, - lineage_reads: RefCell, - } + // 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() }; - 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 at = select_within_input_bound(coins(at_bound), at_bound as u64); + assert_eq!( + 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 owner = owner(0x33); - let at_bound = MAX_SELECTED_FUNDING_COINS as u64; - let over_bound = at_bound + 1; + 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: over_bound as u64, + need_dig_base_units: over_bound as u64, + }), + "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"); - // Plenty of coins available either way: the wallet is NOT short, which is the whole point. - let source = Dusted { - count: over_bound + 10, - owner, - lineage_reads: RefCell::new(0), - }; + let empty = Planted::new(0, 1, owner); + let control = select_operator_dig_cats(&empty, owner, need, &HashSet::new()) + .expect_err("an empty address funds nothing"); - let over = select_operator_dig_cats(&source, owner, over_bound, &HashSet::new()); assert_eq!( - over, - Err(FundingError::TooManyInputs { - needed: over_bound as usize, - limit: MAX_SELECTED_FUNDING_COINS, + 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" + ) + ); + 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!( + "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!( + 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, }), - "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" + 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!( - *source.lineage_reads.borrow(), - 0, - "the refusal must happen before authentication, or the bound does not bound the chain \ - reads it exists to bound" + planted.reads(), + 3, + "the honest total is established by reading, not by summing rows" ); - let at = select_operator_dig_cats(&source, owner, at_bound, &HashSet::new()); - assert!( - matches!(at, Err(FundingError::Unauthenticated { .. })), - "exactly at the bound the selection must PASS THROUGH to authentication; it refused \ - with {at:?} instead, so the bound is off by one and rejects a fundable create" + // 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::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); + 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!( - *source.lineage_reads.borrow(), - 1, - "reaching authentication is observed rather than assumed: one lineage read happened, \ - and it stopped at the first unauthenticatable coin" + 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::Unmeasured(UnmeasuredFunding::AuthenticationTruncated { + attempted: MAX_AUTHENTICATION_ATTEMPTS, + skipped: MAX_AUTHENTICATION_ATTEMPTS, + }), + concat!( + "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/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/pass.rs b/crates/dig-node-service/src/mirror/pass.rs index 19b0bfb0..87cb98db 100644 --- a/crates/dig-node-service/src/mirror/pass.rs +++ b/crates/dig-node-service/src/mirror/pass.rs @@ -53,6 +53,39 @@ 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. +/// +/// # 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 { + /// What the creates left uncreated would cost together, in $DIG base units. + pub need_dig_base_units: u64, } /// What the node can say about one bond right now. @@ -217,11 +250,28 @@ 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. + // 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 { + need_dig_base_units: split.shortfall_dig_base_units, + }) + } + _ => None, + }; + PassDecision { reclaim, create: affordable, per_coin_dig_base_units: per_coin, states, + funding_shortfall, } } @@ -412,6 +462,71 @@ 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 { + need_dig_base_units: REQUIRED, + }), + concat!( + "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)" + ) + ); + } + + /// **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")]; diff --git a/crates/dig-node-service/src/mirror/runner.rs b/crates/dig-node-service/src/mirror/runner.rs index 25d0e429..fd892a7b 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, @@ -336,6 +382,7 @@ impl PassRunner { create, per_coin_dig_base_units, states, + funding_shortfall: decision_shortfall, } = decision; let mut reclaimed = Vec::new(); @@ -383,6 +430,82 @@ 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, 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. + // + // 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::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 -- + // 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 +514,7 @@ impl PassRunner { states, per_coin_dig_base_units, locked_dig_base_units, + funding_alert, } } } @@ -546,6 +670,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 +724,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 +778,447 @@ 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 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 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 + /// 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 499b76b2..b202ef43 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() { @@ -2868,12 +2873,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) => { @@ -3543,6 +3555,7 @@ mod tests { states: Vec::new(), per_coin_dig_base_units: None, locked_dig_base_units: 0, + funding_alert: None, } } diff --git a/crates/dig-node-service/tests/mirror_operator_funding.rs b/crates/dig-node-service/tests/mirror_operator_funding.rs index 8cfd5b82..c3c97942 100644 --- a/crates/dig-node-service/tests/mirror_operator_funding.rs +++ b/crates/dig-node-service/tests/mirror_operator_funding.rs @@ -28,7 +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, FundingError, + dig_cat_puzzle_hash, select_operator_dig_cats, select_operator_dig_cats_detailed, FundingError, + FundingObservation, FundingRemedy, }; use support::{ordinary_dig_coins, wallet, Wallet}; @@ -400,27 +401,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" ); } @@ -460,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" + ) + ); +}