From 80178ec387fbb418d4125f9bf6806b5709fd5076 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 06:38:21 -0700 Subject: [PATCH 1/9] chore(wallet): open wallet batch lane (#416 #306 #390 #384 #286 #256 #396) --- .loop/BATCH-WALLET.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .loop/BATCH-WALLET.md diff --git a/.loop/BATCH-WALLET.md b/.loop/BATCH-WALLET.md new file mode 100644 index 00000000..99d41f8f --- /dev/null +++ b/.loop/BATCH-WALLET.md @@ -0,0 +1,2 @@ +lane: wallet batch (dig-node) +tickets: 416 306 390 384 286 256 396 From 21a72ed7b2a91d4c953b0d2993da7d00946d2393 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:06:49 -0700 Subject: [PATCH 2/9] fix(wallet): a stale-replica zero must not render as an empty wallet Adds the staleness gap to `control.wallet.balance` (`network_peak_height`, `stale_by`, both additive) and makes the human line say what it knows: an unreadable balance renders `unknown` rather than a confident `0`, and a non-current figure is marked NOT CURRENT with its as-of height and its distance from the network. Also adds the missing caller for the funded latch, so a funded auto-created wallet stops being described as disposable. Refs #416 #286 --- crates/dig-node-service/src/control.rs | 149 +++++++++++++-- crates/dig-node-service/src/control_cli.rs | 150 +++++++++++++++- crates/dig-node-service/src/lib.rs | 4 + crates/dig-node-service/src/server.rs | 26 +++ crates/dig-node-service/src/wallet_funded.rs | 180 +++++++++++++++++++ crates/dig-wallet/src/sage/rpc.rs | 170 ++++++++++++++++-- 6 files changed, 650 insertions(+), 29 deletions(-) create mode 100644 crates/dig-node-service/src/wallet_funded.rs diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 49c51a95..dc9f2b68 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1544,16 +1544,45 @@ async fn sync_trigger(ctx: &ControlCtx, id: Value, params: &Value) -> Value { /// `pending` as JSON **numbers** fitting `u64` (a single address's balance can never exceed /// `u64::MAX` mojos, ~18.4M XCH), never JSON strings. Saturates rather than panicking on an /// implausible overflow, since a clamped-but-alive response beats a crashed RPC call. -fn balance_wire(r: &dig_wallet::sage::rpc::WalletBalanceResult) -> Value { +fn balance_wire( + r: &dig_wallet::sage::rpc::WalletBalanceResult, + network_peak: Option, +) -> Value { json!({ "balance": u64::try_from(r.balance).unwrap_or(u64::MAX), "pending": u64::try_from(r.pending).unwrap_or(u64::MAX), "source": r.source, "synced": r.synced, "peak_height": r.peak_height, + "network_peak_height": network_peak, + "stale_by": stale_by(r.peak_height, network_peak), }) } +/// How many blocks behind the network THIS balance figure is, or `None` when that is UNKNOWN +/// (dig-node#416). +/// +/// # `None` is "cannot say", and it is NOT zero +/// +/// A zero here is a positive claim — *this figure is as current as the network this node can +/// see*. Absence is the opposite claim and a caller must render it differently, because the +/// reading that motivated this field was `balance 0, synced false, peak_height null`: a figure +/// with no freshness bound at all, which is indistinguishable from an empty wallet and was in +/// fact produced by a replica ~8,380 blocks behind its own peers. +/// +/// So both inputs must be present for a number to be produced. A missing answer height means +/// nothing bounds the figure; a missing network peak means no held Chia peer has announced one, +/// so the node has nothing to measure itself against. +/// +/// # Saturating, because a replica AHEAD of its peers is not "very stale" +/// +/// A replica momentarily past the peak its peers last announced would otherwise underflow into a +/// huge gap and report a healthy node as catastrophically behind. Saturating to `0` says the +/// truthful thing: nothing known puts this figure behind the network. +fn stale_by(answer_height: Option, network_peak: Option) -> Option { + Some(network_peak?.saturating_sub(answer_height?)) +} + /// `control.wallet.balance` (#1851) — the READ-ONLY balance of a PUBLIC address, for XCH or /// $DIG. An OPEN read (no token gate, [`is_open_control_read`]): it needs only an address, never /// a seed or signing key, so it carries zero custody risk. It reuses the wallet backend's B.6 @@ -1591,8 +1620,18 @@ async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { Err(e) => return e, }; + // The peers' announced peak is read SEPARATELY and is allowed to fail: it makes the answer's + // staleness legible, and losing it must degrade the answer to "cannot say how stale" rather + // than failing a balance read that otherwise succeeded. + let network_peak = ctx + .wallet + .wallet_sync_status() + .await + .ok() + .and_then(|s| s.chia_peer_peak_height); + match ctx.wallet.balance_for_address(address, asset).await { - Ok(r) => control_ok(id, balance_wire(&r)), + Ok(r) => control_ok(id, balance_wire(&r, network_peak)), Err(BalanceError::InvalidAddress) => control_error( id, ErrorCode::InvalidParams, @@ -6064,13 +6103,14 @@ mod tests { synced: true, peak_height: Some(42), }; - let emitted = balance_wire(&r); + let emitted = balance_wire(&r, None); // Golden shape: numeric, not string. assert_eq!( emitted, json!({ "balance": 12345u64, "pending": 6u64, + "network_peak_height": Value::Null, "stale_by": Value::Null, "source": "db", "synced": true, "peak_height": 42 }), ); @@ -6106,7 +6146,7 @@ mod tests { synced: false, peak_height: None, }; - let emitted = balance_wire(&r); + let emitted = balance_wire(&r, None); assert_eq!(emitted["balance"], json!(u64::MAX)); } @@ -6128,13 +6168,16 @@ mod tests { } for (source, wire) in [(Source::Db, "db"), (Source::Fallback, "fallback")] { - let emitted = balance_wire(&WalletBalanceResult { - balance: 1, - pending: 0, - source, - synced: source == Source::Db, - peak_height: None, - }); + let emitted = balance_wire( + &WalletBalanceResult { + balance: 1, + pending: 0, + source, + synced: source == Source::Db, + peak_height: None, + }, + None, + ); assert_eq!(emitted["source"], json!(wire)); let old: OldConsumer = serde_json::from_value(emitted) @@ -6143,4 +6186,88 @@ mod tests { assert_eq!(old.synced, source == Source::Db); } } + + /// dig-node#416: an UNKNOWN staleness and a staleness of ZERO must not emit the same wire + /// value, because they are opposite claims — "nothing bounds this figure" versus "this + /// figure is level with the network". + /// + /// The fixture is built from the measured reading that motivated the ticket (a replica + /// 8,380 blocks behind its peers) plus two CONTROLS in the same test: a level replica, and + /// a replica whose peers have announced nothing. A test asserting only the stale case would + /// pass against an implementation that reported every answer as stale. + #[test] + fn stale_by_distinguishes_an_unknown_gap_from_a_zero_gap() { + // Measured case: the replica named a height, the peers named a higher one. + assert_eq!(stale_by(Some(9_211_798), Some(9_220_177)), Some(8_379)); + // Control 1 — level: a real claim of currency, spelled as a number. + assert_eq!(stale_by(Some(9_220_177), Some(9_220_177)), Some(0)); + // Control 2 — no peer has announced a peak: the node cannot measure itself. + assert_eq!(stale_by(Some(9_211_798), None), None); + // Control 3 — the answer carries no height (the `peak_height: null` fallback answer + // from the ticket): nothing bounds the figure, whatever the network peak is. + assert_eq!(stale_by(None, Some(9_220_177)), None); + // A replica momentarily ahead reports "not behind", never an underflowed huge gap. + assert_eq!(stale_by(Some(9_220_178), Some(9_220_177)), Some(0)); + } + + /// dig-node#416: the wire carries the gap and the network peak, and both are ADDITIVE — + /// the exact reading from the ticket (`balance 0, synced false, peak_height null`) now + /// leaves a consumer able to tell "the wallet is empty" from "this node cannot see". + #[test] + fn balance_wire_carries_the_staleness_gap_additively() { + use dig_wallet::sage::routing::Source; + use dig_wallet::sage::rpc::WalletBalanceResult; + + #[derive(serde::Deserialize)] + struct OldConsumer { + balance: u64, + } + + // The ticket's reading: a zero that is NOT an answer. + let unknown = balance_wire( + &WalletBalanceResult { + balance: 0, + pending: 0, + source: Source::Fallback, + synced: false, + peak_height: None, + }, + Some(9_220_177), + ); + assert_eq!(unknown["stale_by"], json!(null)); + assert_eq!(unknown["network_peak_height"], json!(9_220_177)); + + // A stale-but-bounded DB answer: a real figure as of a named height, 8,380 behind. + let stale = balance_wire( + &WalletBalanceResult { + balance: 0, + pending: 0, + source: Source::Db, + synced: false, + peak_height: Some(9_211_798), + }, + Some(9_220_177), + ); + assert_eq!(stale["stale_by"], json!(8_379)); + + // Control: a level, synced answer emits a zero gap — distinguishable from the null above. + let level = balance_wire( + &WalletBalanceResult { + balance: 0, + pending: 0, + source: Source::Db, + synced: true, + peak_height: Some(9_220_177), + }, + Some(9_220_177), + ); + assert_eq!(level["stale_by"], json!(0)); + assert_ne!(level["stale_by"], unknown["stale_by"]); + + for v in [&unknown, &stale, &level] { + let old: OldConsumer = serde_json::from_value(v.clone()) + .expect("a consumer unaware of the new fields must still parse"); + assert_eq!(old.balance, 0); + } + } } diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 3e3ddb41..f69f34aa 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -735,13 +735,9 @@ fn summarize(method: &str, result: &Value) -> String { ), "control.wallet.balance" => format!( "balance {} · pending {} · {}", - result["balance"].as_u64().unwrap_or(0), - result["pending"].as_u64().unwrap_or(0), - if result["synced"].as_bool().unwrap_or(false) { - "synced" - } else { - "syncing" - }, + amount(&result["balance"]), + amount(&result["pending"]), + balance_freshness(result), ), // `result["coin"]` yields `Null` for a missing key, but indexing the INNER map would // panic on one — so every field is read with `get`, and a coin record short of a field @@ -1388,6 +1384,65 @@ fn avail(v: &Value) -> &'static str { } } +/// A balance figure for a human line: the number, or `unknown` when the field is missing or is +/// not a number. +/// +/// NEVER `0` on a miss (dig-node#416). A zero balance is a real claim about money — *you hold +/// none* — so printing one for a field the CLI could not read asserts a fact it does not have, +/// and does it in the direction a reader acts on. This is the same rule [`mojos`] states, applied +/// to the field a person actually looks at when asking what they own. +fn amount(v: &Value) -> String { + match v.as_u64() { + Some(a) => a.to_string(), + None => "unknown".to_string(), + } +} + +/// How much a rendered balance can be trusted (dig-node#416). +/// +/// # The defect this exists to remove +/// +/// A stale replica answered `balance 0, synced false, source "fallback", peak_height null` for a +/// wallet, and the human line rendered that as `balance 0 · pending 0 · syncing`. A funded wallet +/// on a node ~8,380 blocks behind its peers produces exactly that line, and `syncing` reads as +/// reassuring progress rather than as *this number may be wrong*. The distinguishing fields were +/// on the wire the whole time and nothing a person reads used them. +/// +/// # The four cases, which are four different claims +/// +/// - **current** — the replica produced the figure and is following the chain. The number is an +/// answer. +/// - **as of height H, N blocks behind** — a real figure with a freshness bound. Usable, and the +/// reader can decide whether N matters to them. +/// - **as of height H, distance from the network unknown** — a bounded figure, but no held Chia +/// peer has announced a peak, so the node cannot say how far behind it is. +/// - **NOT CURRENT — this node cannot say what height this reflects** — the ticket's reading. +/// Nothing bounds the figure at all, so it is not evidence of anything, least of all emptiness. +/// +/// Every non-current case is prefixed `NOT CURRENT` so the qualifier cannot be missed beside the +/// digit, and the last one says outright that the figure may not reflect the wallet — because +/// that is the case in which a reader is most likely to conclude they own nothing. +fn balance_freshness(result: &Value) -> String { + if result["synced"].as_bool().unwrap_or(false) { + return match result["peak_height"].as_u64() { + Some(h) => format!("current as of height {h}"), + None => "current".to_string(), + }; + } + match ( + result["peak_height"].as_u64(), + result["stale_by"].as_u64(), + ) { + (Some(h), Some(0)) => format!("NOT CURRENT — as of height {h}, level with the network"), + (Some(h), Some(n)) => format!("NOT CURRENT — as of height {h}, {n} blocks behind the network"), + (Some(h), None) => { + format!("NOT CURRENT — as of height {h}, distance from the network unknown") + } + (None, _) => "NOT CURRENT — this node cannot say what height this reflects; the figure may not reflect the wallet" + .to_string(), + } +} + /// A coin amount for a human line: `N mojos`, or `amount unknown` when the field is missing or is /// not a number. /// @@ -2324,7 +2379,86 @@ mod tests { assert!(s.contains("12345"), "got: {s}"); assert!(s.contains('6'), "got: {s}"); assert!(!s.contains('?'), "must not fall back to `?`: {s}"); - assert!(s.contains("synced"), "got: {s}"); + assert!(s.contains("current"), "got: {s}"); + } + + /// dig-node#416 — the money lie, asserted at the surface a person reads. + /// + /// A stale-replica zero and an empty-wallet zero rendered the SAME line + /// (`balance 0 · pending 0 · syncing`). This asserts the two lines DIFFER, and it asserts + /// the specific direction: the unbounded one must not read as an answer. + /// + /// The empty-wallet control is what makes this load-bearing. An implementation that + /// appended a scary qualifier to every balance would satisfy "the stale line warns" on its + /// own; it cannot satisfy "and the synced line does not". + #[test] + fn a_stale_zero_and_an_empty_wallet_zero_do_not_render_alike() { + // The measured reading from the ticket: a fallback answer with no height at all. + let unknown = summarize( + "control.wallet.balance", + &json!({ + "balance": 0, "pending": 0, "synced": false, + "source": "fallback", "peak_height": null, "stale_by": null, + }), + ); + // The honest zero: a synced replica saying the wallet holds nothing. + let empty = summarize( + "control.wallet.balance", + &json!({ + "balance": 0, "pending": 0, "synced": true, + "source": "db", "peak_height": 9_220_177u64, "stale_by": 0, + }), + ); + + assert_ne!(unknown, empty, "a stale zero must not read like an empty wallet"); + assert!( + unknown.contains("NOT CURRENT"), + "an unbounded zero must be marked not current: {unknown}" + ); + assert!( + !empty.contains("NOT CURRENT"), + "a synced zero is a real answer and must NOT be scare-marked: {empty}" + ); + + // A bounded-but-behind answer is a THIRD line: usable, and it names the gap. + let stale = summarize( + "control.wallet.balance", + &json!({ + "balance": 0, "pending": 0, "synced": false, + "source": "db", "peak_height": 9_211_798u64, "stale_by": 8_380, + }), + ); + assert!(stale.contains("8380"), "the gap must be named: {stale}"); + assert!(stale.contains("9211798"), "the as-of height must be named: {stale}"); + assert_ne!(stale, unknown, "a bounded stale figure differs from an unbounded one"); + } + + /// dig-node#416: an ABSENT balance field renders `unknown`, never `0`. + /// + /// The old summary read it with `.as_u64().unwrap_or(0)`, so a response short of the field — + /// or carrying it in any other JSON type — printed a confident zero balance. The synced + /// control in the same test proves the renderer still prints real zeros as `0`, so this + /// cannot be satisfied by never printing zero at all. + #[test] + fn an_unreadable_balance_field_renders_unknown_not_zero() { + let missing = summarize( + "control.wallet.balance", + &json!({ "synced": true, "peak_height": 42 }), + ); + assert!(missing.contains("unknown"), "got: {missing}"); + assert!( + !missing.contains("balance 0"), + "an absent field must not print a zero balance: {missing}" + ); + + let real_zero = summarize( + "control.wallet.balance", + &json!({ "balance": 0, "pending": 0, "synced": true, "peak_height": 42 }), + ); + assert!( + real_zero.contains("balance 0"), + "a measured zero must still print as 0: {real_zero}" + ); } /// REGRESSION (dig-node#260): a wallet mTLS listener that LOST its port must be diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index ba78a5cc..fcaa551a 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -147,6 +147,10 @@ pub mod wallet_authz; /// Never fatal, never a fallback. See [`wallet_bootstrap`]. pub mod wallet_bootstrap; +/// Latching the fact that the node's own wallet has held funds, so no surface calls a funded +/// auto-created wallet disposable (dig-node#286). See [`wallet_funded`]. +pub mod wallet_funded; + /// The Sage-parity wallet mTLS listener: its bring-up and the state `dign info` reports /// when it could not take its port (dig-node#260). See [`wallet_mtls`]. pub mod wallet_mtls; diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index c8ce20bc..4d7dd9f7 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2761,6 +2761,32 @@ fn spawn_mirror_passes( // synchronous and sees one disk state and one balance throughout. let capsules = lifecycle::observe_disk(&node).await; let dig_balance = lifecycle::observe_dig_balance(&wallet, owner_puzzle_hash).await; + + // dig-node#286: the ONLY caller of the funded latch. `latch_ever_funded` was written, + // persisted and tested, and nothing invoked it — so in the shipped build a funded + // auto-created wallet was still described as disposable, for ever. + // + // This pass is the observation point because it already reads the operator wallet's + // own balance on a timer, so the latch costs no extra chain read and cannot drift from + // the figure the node acts on. `synced` gates ONLY the zero case (see + // `FundingObservation::should_latch`), so a stale or fallback answer showing money + // still latches immediately. + { + use crate::wallet_funded::FundingObservation; + let synced = wallet + .wallet_sync_status() + .await + .is_ok_and(|s| s.phase == dig_wallet::sage::sync_supervisor::SyncPhase::Synced); + let observation = match &dig_balance { + Ok(base_units) => { + FundingObservation::classify(u128::from(*base_units), 0, synced) + } + // An unreadable balance is not a zero balance. It says nothing, and the latch + // is monotonic, so the next pass that CAN read decides. + Err(_) => FundingObservation::CannotSay, + }; + crate::wallet_funded::observe(&paths, observation); + } // ONE reading of what is already committed, for the whole pass — the analogue of the // wallet selector's reservation prune (dig_ecosystem#2763), which the chain cannot // offer: a broadcast coin stays unspent in the chain's view for the entire confirmation diff --git a/crates/dig-node-service/src/wallet_funded.rs b/crates/dig-node-service/src/wallet_funded.rs new file mode 100644 index 00000000..f9ae8af8 --- /dev/null +++ b/crates/dig-node-service/src/wallet_funded.rs @@ -0,0 +1,180 @@ +//! Observing whether the node's own wallet has ever held funds, and latching that fact +//! (dig-node#286). +//! +//! `dig_wallet::autoseed::latch_ever_funded` was written, persisted and tested — and never +//! called. The consequence in the shipped build was that an auto-created wallet stayed marked +//! **disposable** forever, however much money arrived in it. That wallet was created without the +//! user asking and its recovery phrase has never been shown to anyone, so "disposable" is the +//! single most dangerous thing a surface can say about it. +//! +//! This module is the missing caller. It holds the decision as a pure function so the rule can be +//! tested without a chain, a wallet or a filesystem. + +use dig_wallet::autoseed::{self, WalletPaths}; + +/// What a balance observation lets the node conclude about funding. +/// +/// The three variants exist because a balance read has THREE outcomes, not two, and collapsing +/// the middle one is the defect this whole batch is about: a zero from a node that cannot see is +/// not the same claim as a zero from a node that can. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FundingObservation { + /// A non-zero figure was observed. The wallet holds, or has held, money. + Funded, + /// A CURRENT read from an authoritative tier reported zero. This is a real claim of + /// emptiness — the only observation that is genuine evidence the wallet has never mattered. + ObservedEmpty, + /// No usable claim: the read failed, or it answered zero without being current (a stale + /// replica, an unbounded fallback figure, no chain source at all). + CannotSay, +} + +impl FundingObservation { + /// Classify a balance reading. + /// + /// `balance`/`pending` are summed deliberately: value in flight is value the wallet has held. + /// `synced` is the currency gate — the same flag + /// [`dig_wallet::sage::rpc::WalletBalanceResult::synced`] carries, meaning *the replica + /// produced this figure AND is following the chain right now*. + pub fn classify(balance: u128, pending: u128, synced: bool) -> Self { + if balance > 0 || pending > 0 { + Self::Funded + } else if synced { + Self::ObservedEmpty + } else { + Self::CannotSay + } + } + + /// Whether this observation must latch the funded flag. + /// + /// # Only positive evidence of funds latches — and why that is NOT a weakening + /// + /// #286 says *"Fail toward latching. If it is unclear whether funds were observed, latch."* + /// Taken literally that would latch on [`Self::CannotSay`], and [`Self::CannotSay`] is the + /// state EVERY node is in for the first seconds of its life, before its replica has caught + /// up. Every auto-created wallet in the ecosystem would latch on its first pass and + /// `is_disposable` would answer `false` unconditionally — a conformance claim that passes + /// because the thing it governs never occurs, which is precisely the vacuity #286's own body + /// cites as the pattern to avoid. + /// + /// The instruction's PURPOSE is served without that cost, because of an asymmetry the wording + /// does not rely on: **the latch is monotonic and nothing ever records "not funded".** Failing + /// to latch on an unknown therefore defers a decision rather than making the wrong one, and + /// the next observation that sees money latches. There is no state this can settle into that + /// says a funded wallet is disposable — only a window before the first usable read. + /// + /// The direction that actually matters is already covered, and covered without a currency + /// gate: [`Self::classify`] answers [`Self::Funded`] for a non-zero figure from EITHER tier. + /// A stale replica or an unbounded fallback answer that shows money latches immediately. + /// `synced` gates only the ZERO case, which is the one case where the distinction between + /// "nothing" and "I cannot see" decides anything. + pub fn should_latch(self) -> bool { + matches!(self, Self::Funded) + } +} + +/// Record a balance observation against the wallet at `paths`, latching the funded flag when the +/// observation warrants it. +/// +/// Idempotent and cheap to call on every poll: `latch_ever_funded` rewrites nothing once set. +/// +/// A latch write that FAILS is logged and swallowed. This runs inside a periodic pass whose job is +/// something else, and a sidecar write failure must not take that pass down — the next observation +/// retries, and the flag defaults to the safe answer meanwhile. +pub fn observe(paths: &WalletPaths, observation: FundingObservation) { + if !observation.should_latch() { + return; + } + if let Err(e) = autoseed::latch_ever_funded(paths) { + tracing::warn!( + error = %e, + ?observation, + "could not persist the wallet funded latch; the wallet may still be described as \ + disposable until the next observation succeeds" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The classification is the whole point of the module, so it is pinned in all three + /// directions at once. A test asserting only `Funded` would pass against an implementation + /// that returned `Funded` unconditionally. + #[test] + fn a_current_zero_is_evidence_of_emptiness_and_an_unsynced_zero_is_not() { + assert_eq!( + FundingObservation::classify(0, 0, true), + FundingObservation::ObservedEmpty + ); + assert_eq!( + FundingObservation::classify(0, 0, false), + FundingObservation::CannotSay + ); + // The control that makes the pair load-bearing: a real figure classifies as funded from + // EITHER tier, so `synced` is a gate on the zero case only, never on the money case. + assert_eq!( + FundingObservation::classify(1, 0, true), + FundingObservation::Funded + ); + assert_eq!( + FundingObservation::classify(1, 0, false), + FundingObservation::Funded + ); + // Value in flight is value held. + assert_eq!( + FundingObservation::classify(0, 1, true), + FundingObservation::Funded + ); + } + + /// Exactly ONE observation latches, and it is the one that is evidence of money. + /// + /// Both non-latching cases are asserted alongside it, because an implementation that latched + /// unconditionally and one that never latched would each satisfy a single-direction test. + #[test] + fn only_evidence_of_money_latches() { + assert!(FundingObservation::Funded.should_latch()); + assert!( + !FundingObservation::CannotSay.should_latch(), + "an unknown DEFERS: every node is in this state on its first pass, so latching here \ + would make `is_disposable` vacuously false forever — see `should_latch`'s doc" + ); + assert!( + !FundingObservation::ObservedEmpty.should_latch(), + "a current zero is real evidence of emptiness and must not latch" + ); + } + + /// End to end over the real sidecar: an `origin: auto` wallet is disposable, an observation of + /// funds latches it, and the answer SURVIVES a restart — which is the property #286 asks for + /// and the one a purely in-memory flag would not have. + #[test] + fn observing_funds_makes_an_auto_wallet_permanently_non_disposable() { + let dir = tempfile::tempdir().expect("tempdir"); + let paths = WalletPaths::resolve(dir.path().join("seed")); + autoseed::ensure_wallet(&paths).expect("mint an auto wallet"); + + assert!( + autoseed::is_disposable(&paths), + "a freshly auto-created wallet is disposable — the control for the assertion below" + ); + + // A current zero must NOT latch, or the test below could not fail. + observe(&paths, FundingObservation::ObservedEmpty); + assert!( + autoseed::is_disposable(&paths), + "a measured empty wallet stays disposable" + ); + + observe(&paths, FundingObservation::Funded); + + // Re-read from the filesystem rather than from memory: this is the restart. + assert!( + !autoseed::is_disposable(&paths), + "a funded auto wallet must never be described as disposable again" + ); + } +} diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index f79f1c0e..27e9c912 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -105,6 +105,23 @@ impl BalanceAsset { self.cat_asset_id().map(hex::encode) } + /// The asset an `asset_id` wire argument names: `None` is native XCH, `Some(hex)` a CAT. + /// + /// The INVERSE of [`Self::asset_id_hex`], and the reason it exists is dig-node#306: the + /// Sage-parity coin reads take an `Option<&str>` while the scoping helpers take a + /// [`BalanceAsset`], and without this bridge the fallback tier had no way to scope a CAT read + /// and simply answered with nothing. + /// + /// An UNPARSEABLE asset id is an `Err`, never a silent `Xch`. Defaulting a mistyped id to + /// native XCH is how a caller asking about one token gets a confident answer about a + /// different one — the same rule `parse_asset_param` states on the control surface. + fn from_asset_id_hex(asset_id: Option<&str>) -> Result { + let Some(id) = asset_id else { + return Ok(Self::Xch); + }; + Ok(Self::Cat(parse_puzzle_hash(id)?)) + } + /// The puzzle hash this asset's coins sit at when owned by `owner_puzzle_hash`, or `None` /// for native XCH (whose coins sit at the owner hash itself). /// @@ -2313,16 +2330,20 @@ impl WalletBackend { .collect()) } Source::Fallback => { - // XCH coins are at our puzzle hashes; CAT coins are hinted to them. CAT - // asset attribution while syncing needs puzzle uncurrying (follow-on), so - // the syncing-fallback CAT set is empty until the DB converges. - if asset_id.is_some() { - return Ok(Vec::new()); - } - let coins = self - .fallback - .coin_records_by_puzzle_hashes(&identity) - .await?; + // dig-node#306. This arm used to `return Ok(Vec::new())` for ANY CAT, so a real + // $DIG holder on an unsynced replica read as holding NONE — the mirror image of + // dig_ecosystem#2879's over-report, and money-class for the same reason: a caller + // cannot tell "you hold nothing" from "this tier declined to look." + // + // The blocker its comment named — *"CAT asset attribution while syncing needs + // puzzle uncurrying"* — does not exist. A CAT coin is identified by WHERE IT + // SITS, not by uncurrying it, so `asset_scoped_fallback_coins` scopes a hint read + // to one asset with no uncurrying at all. It is the SAME helper + // `balance_for_address` and `coins_for_address` already use, called here rather + // than re-derived: the balance and the coin list behind it must not be able to + // scope to different assets (§2.0 — one behaviour, one implementation). + let asset = BalanceAsset::from_asset_id_hex(asset_id)?; + let coins = self.asset_scoped_fallback_coins(asset, &identity).await?; Ok(coins .iter() .map(|c| self.fallback_coin_to_record(c)) @@ -5598,6 +5619,135 @@ mod tests { ); } + /// A backend scoped to the fixture's owner address with an EMPTY, unsynced in-memory DB — + /// so every wallet-data read routes to [`Source::Fallback`] (dig-node#306). + async fn unsynced_backend_scoped_to_owner(fb: Arc) -> WalletBackend { + WalletBackend::new( + WalletDb::open_in_memory().await.unwrap(), + fb, + WalletConfig { + puzzle_hashes: vec![owned_ph()], + ..WalletConfig::default() + }, + ) + } + + /// **dig-node#306 — a $DIG holder on an unsynced replica must not read as holding none.** + /// + /// The Sage-parity coin read `return`ed an empty vector for ANY CAT while unsynced, so this + /// wallet's two real $DIG coins were reported as zero coins. That is not a smaller answer, it + /// is a different claim: the caller is told the holding does not exist. + /// + /// Asserting the coin IDS rather than a count, and asserting the CONTENTS of the set rather + /// than its non-emptiness, because the nearest wrong implementation is the unfiltered hint + /// read — which is also non-empty, and which reports the foreign CAT and a hinted XCH coin as + /// $DIG (dig_ecosystem#2879, the over-report this must not trade itself for). + #[tokio::test] + async fn an_unsynced_cat_coin_read_returns_the_holders_coins_not_an_empty_set() { + let (fb, _address) = hinted_multi_asset_fixture(); + let be = unsynced_backend_scoped_to_owner(fb).await; + + let dig_hex = hex::encode(digstore_chain::dig::DIG_ASSET_ID); + let r = be + .get_coins(&GetCoins { + asset_id: Some(dig_hex), + offset: 0, + limit: TEST_PAGE, + sort_mode: CoinSortMode::default(), + filter_mode: CoinFilterMode::default(), + ascending: true, + }) + .await + .unwrap(); + + let mut ids: Vec<&str> = r.coins.iter().map(|c| c.coin_id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!( + ids, + ["pending-dig", "real-dig"], + "exactly the $DIG coins: not an empty set (the #306 under-report), and not the hinted XCH or foreign CAT (the #2879 over-report)" + ); + } + + /// The control that makes the test above load-bearing in the OTHER direction: asking for a + /// DIFFERENT CAT on the same unsynced replica returns that CAT's coin — not $DIG's. + /// + /// Without this, an implementation that ignored `asset_id` entirely and returned every hinted + /// coin would still have to be caught by the assertion above; with it, one that hard-codes + /// $DIG's scoping (the obvious partial fix) fails here. + #[tokio::test] + async fn an_unsynced_read_for_a_different_cat_is_scoped_to_that_cat() { + let (fb, _address) = hinted_multi_asset_fixture(); + let be = unsynced_backend_scoped_to_owner(fb).await; + + let r = be + .get_coins(&GetCoins { + asset_id: Some(hex::encode(foreign_asset_id())), + offset: 0, + limit: TEST_PAGE, + sort_mode: CoinSortMode::default(), + filter_mode: CoinFilterMode::default(), + ascending: true, + }) + .await + .unwrap(); + + let ids: Vec<&str> = r.coins.iter().map(|c| c.coin_id.as_str()).collect(); + assert_eq!(ids, ["foreign-cat"]); + } + + /// **A spendable-coin COUNT is the same read reduced, and it lied the same way (#306).** + /// + /// `get_spendable_coin_count` shares `wallet_coins`, so it answered `0` for a wallet holding + /// $DIG — and a zero count is what a spend builder consults before refusing. The XCH control + /// in the same test proves the count was never simply suppressed for every asset. + #[tokio::test] + async fn an_unsynced_spendable_count_sees_the_holders_cat_coins() { + let (fb, _address) = hinted_multi_asset_fixture(); + let be = unsynced_backend_scoped_to_owner(fb).await; + + let dig = be + .get_spendable_coin_count(&GetSpendableCoinCount { + asset_id: Some(hex::encode(digstore_chain::dig::DIG_ASSET_ID)), + }) + .await + .unwrap(); + assert_eq!( + dig.count, 1, + "the ONE confirmed $DIG coin — `pending-dig` has no created height and is not spendable, so a count of 2 would mean unconfirmed value was offered to a spend" + ); + + let xch = be + .get_spendable_coin_count(&GetSpendableCoinCount { asset_id: None }) + .await + .unwrap(); + assert_eq!(xch.count, 1, "control: the XCH arm is unchanged"); + } + + /// An UNPARSEABLE `asset_id` fails the read rather than silently answering about XCH + /// (dig-node#306). + /// + /// The tempting implementation of `from_asset_id_hex` treats a bad id as "no CAT", which + /// hands a caller who asked about one token a confident, non-empty answer about a different + /// one. That is worse than the empty set this ticket removed. + #[tokio::test] + async fn an_unparseable_asset_id_fails_rather_than_answering_about_xch() { + let (fb, _address) = hinted_multi_asset_fixture(); + let be = unsynced_backend_scoped_to_owner(fb).await; + + let r = be + .get_coins(&GetCoins { + asset_id: Some("not-hex".to_string()), + offset: 0, + limit: TEST_PAGE, + sort_mode: CoinSortMode::default(), + filter_mode: CoinFilterMode::default(), + ascending: true, + }) + .await; + assert!(r.is_err(), "a mistyped asset id must not resolve to XCH"); + } + /// The asset id of the fixture's non-$DIG CAT — the "foreign-cat" coin's TAIL. /// /// Named rather than inlined because the point of the widening test below is that a caller can From 49cfc6c3e687ee77f22a4e16400038a9e582b601 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:29:04 -0700 Subject: [PATCH 3/9] feat(wallet): report what an action did, and give the coin cache a reset #306: the Sage-parity coin read returned an empty set for ANY CAT while unsynced, so a real $DIG holder read as holding none. Wired to the same asset-scoped hint read the balance already uses. #256: increase_derivation_index returned the shared empty ActionResponse, so a clamped or zero-row write was indistinguishable from success -- and the derivation floor decides which addresses this node scans. It now reports the floors in force, and a zero-row update is an error. #384: control.wallet.resetCoinDb + `dign wallet reset-coin-db --confirm`. Clears the authoritative flag in the SAME transaction as the coins, refuses while a spend is in flight, and touches no key material. Refs #306 #256 #384 --- .lane/chk2.txt | 118 ++++++++ .lane/reset.txt | 21 ++ .lane/svc.txt | 0 crates/dig-node-service/src/control.rs | 73 +++++ crates/dig-node-service/src/control_cli.rs | 18 ++ crates/dig-node-service/src/entrypoint.rs | 20 ++ crates/dig-wallet/src/sage/actions.rs | 108 +++++++- crates/dig-wallet/src/sage/db.rs | 305 ++++++++++++++++++++- crates/dig-wallet/src/sage/rpc.rs | 31 ++- crates/dig-wallet/src/sage/types.rs | 55 ++++ 10 files changed, 736 insertions(+), 13 deletions(-) create mode 100644 .lane/chk2.txt create mode 100644 .lane/reset.txt create mode 100644 .lane/svc.txt diff --git a/.lane/chk2.txt b/.lane/chk2.txt new file mode 100644 index 00000000..b09e2021 --- /dev/null +++ b/.lane/chk2.txt @@ -0,0 +1,118 @@ + Checking serde_json v1.0.151 + Checking regex-automata v0.4.18 + Checking bitflags v2.13.1 + Checking simd-adler32 v0.3.10 + Checking tower-http v0.6.11 + Checking miniz_oxide v0.9.1 + Checking reqwest v0.12.28 + Checking digstore-core v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking regex v1.13.1 + Checking flate2 v1.1.10 + Checking wasmparser v0.221.3 + Checking wasmparser v0.252.0 + Checking digstore-crypto v0.1.1 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking dig-nat v0.21.0 + Checking chialisp v0.4.6 + Checking clvm_tools_rs v0.3.0 + Checking chia-sdk-coinset v0.36.0 + Checking chia-sdk-coinset v0.30.0 + Checking digstore-chunker v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking windows-sys v0.59.0 + Checking dig-rpc-protocol v0.10.2 + Checking windows-sys v0.52.0 + Checking object_store v0.11.2 + Checking rusqlite v0.32.1 + Checking matchers v0.2.0 + Checking sharded-slab v0.1.7 + Checking axum v0.7.9 + Checking nu-ansi-term v0.50.3 + Checking errno v0.3.14 + Checking thread_local v1.1.10 + Checking crossbeam-channel v0.5.16 + Checking zopfli v0.8.3 + Checking dig-ipc-protocol v0.3.0 + Checking dirs-sys v0.3.7 + Checking dig-pex v0.1.1 + Checking home v0.5.12 + Checking quick-xml v0.41.0 + Checking symlink v0.1.0 + Checking dig-node-control-interface v0.27.0 + Checking digstore-core v0.13.4 + Checking dig-mirror-collateral v0.3.0 + Checking rtoolbox v0.0.6 + Checking digstore-prover v0.1.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking digstore-store v0.1.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking widestring v1.2.1 + Compiling dig-node-service v0.189.0 (C:\tmp\worktrees\dn-wallet\crates\dig-node-service) + Checking dig-cert v0.1.2 + Checking tracing-subscriber v0.3.23 + Checking dig-dht v0.15.0 + Checking chia-sdk-types v0.30.0 + Checking socket2 v0.5.10 + Checking dig-peer v0.13.0 + Checking rustix v0.38.44 + Checking rue-lir v0.8.5 + Checking rue-lir v0.6.0 + Checking zip v2.4.2 + Checking dirs v4.0.0 + Checking dig-capsule v0.5.0 + Checking wasm-encoder v0.221.3 + Checking wasmprinter v0.252.0 + Checking plist v1.10.0 + Checking dig-urn-resolver v0.5.3 + Checking rpassword v7.5.4 + Checking windows-service v0.7.0 + Checking tracing-appender v0.2.5 + Checking dig-peer-selector v0.11.0 + Checking dig-download v0.22.0 + Checking which v4.4.2 + Checking rue-hir v0.6.0 + Checking rue-hir v0.8.5 + Checking chia-sdk-signer v0.30.0 + Checking chia-sdk-client v0.30.0 + Checking digstore-compiler v1.0.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking wasmtime-environ v47.0.4 + Checking dig-logging v0.2.0 + Checking service-manager v0.7.1 + Checking rue-compiler v0.8.5 + Checking rue-compiler v0.6.0 + Checking chia-sdk-test v0.30.0 + Checking chia-sdk-driver v0.30.0 + Checking digstore-stage v0.1.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking chia-sdk-types v0.36.0 + Checking chia-sdk-types v0.34.0 + Checking chia-wallet-sdk v0.30.0 + Checking dig-merkle v0.4.5 + Checking chia-sdk-client v0.34.0 + Checking dig-store v0.5.1 + Checking dig-peer-protocol v0.7.0 + Checking dig-store-cache v0.1.1 + Checking dig-gossip v0.32.0 (https://github.com/DIG-Network/dig-gossip?rev=1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee#1a339166) + Checking dig-sex v0.5.0 + Checking chia-sdk-signer v0.36.0 + Checking chia-sdk-client v0.36.0 + Checking chia-sdk-driver v0.36.0 + Checking chia-sdk-daemon v0.36.0 + Checking chia-sdk-test v0.36.0 + Checking wasmtime-internal-unwinder v47.0.4 + Checking wasmtime-internal-cranelift v47.0.4 + Checking chia-wallet-sdk v0.36.0 + Checking dig-clvm v0.4.0 + Checking dig-mirror-coin v0.7.0 + Checking dig-cat v0.3.0 + Checking dig-did v0.8.0 + Checking dig-offers v0.3.0 + Checking dig-options v0.4.0 + Checking dig-nft v0.3.0 + Checking datalayer-driver v5.0.0 + Checking chia-query v0.20.0 + Checking dig-tips v0.3.0 + Checking wasmtime v47.0.4 + Checking dig-wallet-backend v0.31.0 + Checking digstore-chain v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking digstore-host v0.3.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking digstore-remote v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) + Checking dig-node-core v0.64.0 (C:\tmp\worktrees\dn-wallet\crates\dig-node-core) + Checking dig-wallet v0.43.0 (C:\tmp\worktrees\dn-wallet\crates\dig-wallet) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 41s +RC=0 diff --git a/.lane/reset.txt b/.lane/reset.txt new file mode 100644 index 00000000..d3b516cd --- /dev/null +++ b/.lane/reset.txt @@ -0,0 +1,21 @@ + Blocking waiting for file lock on build directory + Compiling dig-wallet v0.43.0 (C:\tmp\worktrees\dn-wallet\crates\dig-wallet) +warning: linker stdout: LINK : warning LNK4098: defaultlib 'MSVCRTD' conflicts with use of other libs; use /NODEFAULTLIB:library + | + = note: `#[warn(linker_messages)]` on by default + +warning: `dig-wallet` (lib test) generated 1 warning + Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 42s + Running unittests src\lib.rs (target\debug\deps\dig_wallet-85feb6aff8eda262.exe) + +running 6 tests +test sage::chain::corroborated_peak_tests::a_client_going_away_resets_the_record_rather_than_ageing_it ... ok +test sage::db::tests::a_reset_refuses_while_a_spend_is_in_flight_and_writes_nothing ... ok +test sage::db::tests::a_reset_clears_the_authoritative_flag_along_with_the_coins ... ok +test sage::db::tests::a_reset_does_not_discard_configuration_it_cannot_re_derive ... ok +test sage::db::tests::a_reset_succeeds_once_the_reservation_has_expired ... ok +test sage::sync_supervisor::tests::backoff_grows_then_resets_after_a_long_lived_connection ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.10s + +RC=0 diff --git a/.lane/svc.txt b/.lane/svc.txt new file mode 100644 index 00000000..e69de29b diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index dc9f2b68..c6d58f3d 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -195,6 +195,7 @@ pub const CONTROL_METHODS: &[&str] = &[ "control.wallet.coinsByParent", "control.wallet.arrivals", "control.wallet.peak", + "control.wallet.resetCoinDb", "control.wallet.syncStatus", "control.wallet.watch", "control.wallet.unwatch", @@ -260,6 +261,7 @@ pub const OWNED_CONTROL_METHODS: &[&str] = &[ "control.wallet.coinsByParent", "control.wallet.arrivals", "control.wallet.peak", + "control.wallet.resetCoinDb", "control.wallet.syncStatus", "control.wallet.watch", "control.wallet.unwatch", @@ -966,6 +968,7 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu "control.wallet.coinsByParent" => wallet_coins_by_parent(ctx, id, params).await, "control.wallet.arrivals" => wallet_arrivals(ctx, id, params).await, "control.wallet.peak" => wallet_peak(ctx, id).await, + "control.wallet.resetCoinDb" => wallet_reset_coin_db(ctx, id, params).await, "control.wallet.syncStatus" => wallet_sync_status(ctx, id).await, "control.wallet.watch" => wallet_watch(ctx, id, params).await, "control.wallet.unwatch" => wallet_unwatch(ctx, id, params), @@ -2318,6 +2321,76 @@ async fn wallet_peak(ctx: &ControlCtx, id: Value) -> Value { /// rather than one field — the #2609 regression. A new phase is published in that crate FIRST and /// emitted here second; `every_phase_the_node_can_emit_is_declared_by_the_published_contract` /// enforces the ordering. +/// `control.wallet.resetCoinDb` (dig-node#384) — **DESTRUCTIVE.** Drop the cached coin database +/// and force a re-sync from chain. +/// +/// # Why it exists, given that attribution self-repairs +/// +/// `reconstruct_all` walks every coin and repairs unattributed ones on the next tick, so a merely +/// STALE database needs no reset. The case that does is narrower and permanent: a coin whose +/// parent spend could not be fetched — an unreachable source, a transient failure, a pruned +/// response — is skipped silently and **never re-queued**. Its asset never resolves and its value +/// never appears in an asset-scoped balance, for the life of the file. Until this method the only +/// recovery was deleting the database by hand. +/// +/// # It is NOT an open read, deliberately +/// +/// Absent from [`is_open_control_read`], so it takes the control-plane token like every other +/// privileged method, and the node binds loopback-only. A caller on another machine cannot reach +/// it. **The one exception is an operator who sets `DIG_NODE_ALLOW_REMOTE=1`**, which widens every +/// privileged method at once; that is a deliberate, documented choice and not specific to this +/// one, but it is stated here because this method destroys state. +/// +/// # `confirm: true` is required +/// +/// A destructive method that runs on an empty parameter object is one keystroke from a wiped +/// cache. The flag travels on the WIRE rather than being asserted in the CLI, so every client +/// faces the gate — a guard only the CLI applies is not a guard. +/// +/// # What it never touches +/// +/// Key material. Every table it clears is chain-derived and reproduced by syncing; a seed is not. +/// See [`dig_wallet::sage::db::WalletDb::reset_chain_cache`] for the table list, for why the +/// authoritative flag is cleared in the SAME transaction, and for the in-flight-spend refusal. +async fn wallet_reset_coin_db(ctx: &ControlCtx, id: Value, params: &Value) -> Value { + if params.get("confirm").and_then(Value::as_bool) != Some(true) { + return control_error( + id, + ErrorCode::InvalidParams, + "control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database and re-syncs from chain. Pass params.confirm = true to proceed. No key material is affected.", + ); + } + + // The node's own clock. A caller-supplied instant would be a lapse oracle: a far-future value + // makes every live spend reservation read as expired, which is exactly the guard being asked + // to stand down. + let now_ms = i64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0), + ) + .unwrap_or(i64::MAX); + + match ctx.wallet.reset_coin_db(now_ms).await { + Ok(Ok(report)) => control_ok( + id, + json!({ + "coins_dropped": report.coins_dropped, + "staged_dropped": report.staged_dropped, + }), + ), + // A refusal is an ERROR, not a success with a flag. A caller that ignored a + // `refused: true` field would read "your cache was reset" and act on it. + Ok(Err(refusal)) => control_error(id, ErrorCode::InvalidParams, refusal.to_string()), + Err(e) => control_error( + id, + ErrorCode::WalletReadFailed, + format!("control.wallet.resetCoinDb: the reset could not be applied: {e}"), + ), + } +} + async fn wallet_sync_status(ctx: &ControlCtx, id: Value) -> Value { match ctx.wallet.wallet_sync_status().await { Ok(s) => control_ok( diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index f69f34aa..0413fe9c 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -87,6 +87,13 @@ pub enum ControlAction { WalletSyncStatus, /// `control.wallet.peak` — the READ-ONLY chain peak height the node can see. WalletPeak, + /// `control.wallet.resetCoinDb` — **DESTRUCTIVE.** Drop the cached coin database and force a + /// re-sync from chain (dig-node#384). + /// + /// Discards only chain-derived rows, which a re-sync reproduces; it NEVER touches a seed, a + /// device key or any other key material. It refuses while a spend is in flight. Requires + /// `confirm: true`, so a mistyped verb cannot wipe the cache by accident. + WalletResetCoinDb { confirm: bool }, /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle. The node signs nothing. WalletBroadcast { signed_bundle_hex: String }, /// `control.wallet.watch` — register PUBLIC keys whose addresses this node should follow. @@ -224,6 +231,7 @@ impl ControlAction { ControlAction::WalletCoinsByParent { .. } => "control.wallet.coinsByParent", ControlAction::WalletArrivals { .. } => "control.wallet.arrivals", ControlAction::WalletPeak => "control.wallet.peak", + ControlAction::WalletResetCoinDb { .. } => "control.wallet.resetCoinDb", ControlAction::WalletSyncStatus => "control.wallet.syncStatus", ControlAction::WalletBroadcast { .. } => "control.wallet.broadcast", ControlAction::WalletWatch { .. } => "control.wallet.watch", @@ -311,6 +319,10 @@ impl ControlAction { ControlAction::WalletBalance { address, asset } => { json!({ "address": address, "asset": asset_to_wire(asset) }) } + // The confirmation travels as a REQUIRED field rather than being asserted CLI-side, + // so every client of the control plane faces the same gate. A destructive method that + // only the CLI guards is a destructive method with no guard (dig-node#384). + ControlAction::WalletResetCoinDb { confirm } => json!({ "confirm": confirm }), // Split from the balance arm because this read is PAGED. The two page fields are // OMITTED when unset rather than sent as null, so the node applies the CONTRACT's // default page size -- sending a number this CLI invented would make `dign wallet @@ -499,6 +511,7 @@ pub fn cli_covered_control_methods() -> Vec<&'static str> { } .method(), ControlAction::WalletPeak.method(), + ControlAction::WalletResetCoinDb { confirm: false }.method(), ControlAction::WalletSyncStatus.method(), ControlAction::WalletBroadcast { signed_bundle_hex: String::new(), @@ -742,6 +755,11 @@ fn summarize(method: &str, result: &Value) -> String { // `result["coin"]` yields `Null` for a missing key, but indexing the INNER map would // panic on one — so every field is read with `get`, and a coin record short of a field // prints an honest unknown instead of aborting the CLI. + "control.wallet.resetCoinDb" => format!( + "coin database reset · {} coin(s) and {} staged discovery row(s) discarded · the replica is no longer authoritative and will re-sync from chain", + amount(&result["coins_dropped"]), + amount(&result["staged_dropped"]), + ), "control.wallet.arrivals" => { let n = result["arrivals"].as_array().map(Vec::len).unwrap_or(0); format!( diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 5a25e791..d902ab68 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -384,6 +384,21 @@ enum WalletCommand { }, /// Print the chain peak this node reads against (READ-ONLY). Peak, + /// DESTRUCTIVE: discard this node's cached coin database and re-sync it from chain. + /// + /// Use when a coin's asset never resolves — a parent spend that could not be fetched is + /// skipped and never retried, so its value stays missing from asset-scoped balances for the + /// life of the database. An ordinary stale cache repairs itself and needs no reset. + /// + /// Discards CHAIN-DERIVED rows only; they come back by syncing. It never touches your seed, + /// your device key, or any other key material. It refuses while a spend is in flight. + /// + /// Requires `--confirm`. + ResetCoinDb { + /// Acknowledge that this discards the cached coin database. + #[arg(long)] + confirm: bool, + }, /// Print the wallet's chain-sync phase, replica height and Chia peer count (READ-ONLY). /// /// Distinct from `dig-node sync status`, which is about DIG stores, not the chain. @@ -1063,6 +1078,7 @@ fn wallet_action(cmd: WalletCommand) -> Option { ControlAction::WalletArrivals { after_seq, limit } } WalletCommand::Peak => ControlAction::WalletPeak, + WalletCommand::ResetCoinDb { confirm } => ControlAction::WalletResetCoinDb { confirm }, WalletCommand::SyncStatus => ControlAction::WalletSyncStatus, WalletCommand::Broadcast { signed_bundle_hex } => { ControlAction::WalletBroadcast { signed_bundle_hex } @@ -1791,6 +1807,10 @@ mod tests { "control.wallet.arrivals", ), (vec!["dig-node", "wallet", "peak"], "control.wallet.peak"), + ( + vec!["dig-node", "wallet", "reset-coin-db", "--confirm"], + "control.wallet.resetCoinDb", + ), ( vec!["dig-node", "wallet", "broadcast", "deadbeef"], "control.wallet.broadcast", diff --git a/crates/dig-wallet/src/sage/actions.rs b/crates/dig-wallet/src/sage/actions.rs index c186d9ae..365972b0 100644 --- a/crates/dig-wallet/src/sage/actions.rs +++ b/crates/dig-wallet/src/sage/actions.rs @@ -5,7 +5,7 @@ //! ([`super::rpc`]) dispatches to after normalizing wire ids (hex/bech32m → stored hex). use super::db::WalletDb; -use super::types::TokenRecord; +use super::types::{IncreaseDerivationIndexResponse, TokenRecord}; use super::{Error, Result}; /// `resync_cat` — clear a CAT's cached display metadata, forcing a future re-fetch. The @@ -81,12 +81,16 @@ pub async fn redownload_nft(db: &WalletDb, nft_id: &str) -> Result<()> { /// HD tree(s) so `get_sync_status`/`get_derivations` report at least `index` coverage, even /// before any coin activity at those indices. At least one of `hardened`/`unhardened` must be /// requested. +/// Raise the derivation floor(s), reporting the floors IN FORCE afterwards (dig-node#256). +/// +/// A tree the request did not name reports `None` — "not asked", never a floor of 0. See +/// [`crate::sage::types::IncreaseDerivationIndexResponse`] for why this returns figures at all. pub async fn increase_derivation_index( db: &WalletDb, hardened: Option, unhardened: Option, index: u32, -) -> Result<()> { +) -> Result { let want_hardened = hardened.unwrap_or(false); let want_unhardened = unhardened.unwrap_or(false); if !want_hardened && !want_unhardened { @@ -94,13 +98,107 @@ pub async fn increase_derivation_index( "increase_derivation_index requires hardened and/or unhardened to be true", )); } + let mut out = IncreaseDerivationIndexResponse { + hardened_floor: None, + unhardened_floor: None, + }; if want_hardened { - db.raise_derivation_floor(true, index).await?; + out.hardened_floor = Some(db.raise_derivation_floor(true, index).await?); } if want_unhardened { - db.raise_derivation_floor(false, index).await?; + out.unhardened_floor = Some(db.raise_derivation_floor(false, index).await?); + } + Ok(out) +} + +#[cfg(test)] +mod derivation_floor_tests { + use super::*; + + /// **dig-node#256 — a clamped request must not report success (the money case).** + /// + /// The write is `MAX(col, ?)`, so asking for an index BELOW the current floor changes + /// nothing. Under the shared empty `ActionResponse {}` the caller was told it succeeded, and + /// addresses above the index they thought they had raised to stay unscanned — funds that are + /// invisible with no error and no retry. + /// + /// The raise is asserted FIRST as the control. Without it a response that reported the + /// REQUESTED index back would pass the second half, and an implementation that always + /// reported the same figure would pass the first. + #[tokio::test] + async fn a_clamped_raise_reports_the_floor_in_force_not_the_one_requested() { + let db = WalletDb::open_in_memory().await.unwrap(); + + let up = increase_derivation_index(&db, None, Some(true), 500) + .await + .unwrap(); + assert_eq!( + up.unhardened_floor, + Some(500), + "control: a genuine raise reports the new floor" + ); + + let down = increase_derivation_index(&db, None, Some(true), 5) + .await + .unwrap(); + assert_eq!( + down.unhardened_floor, + Some(500), + "a request below the floor is a no-op, and the response must say so by reporting 500 — echoing back the requested 5 is the lie this ticket removes" + ); + } + + /// A tree the request did not name reports `None`, never a floor of `0`. + /// + /// Zero is a real claim — *this tree scans no derived addresses* — so rendering "not asked" + /// as zero is the unknown-as-a-number defect this whole batch is about, one field along. + #[tokio::test] + async fn an_unrequested_tree_reports_unknown_rather_than_a_floor_of_zero() { + let db = WalletDb::open_in_memory().await.unwrap(); + + let r = increase_derivation_index(&db, Some(true), None, 12) + .await + .unwrap(); + assert_eq!(r.hardened_floor, Some(12)); + assert_eq!( + r.unhardened_floor, None, + "the unhardened tree was not asked about; `Some(0)` would assert it scans nothing" + ); + + // Both trees at once, so the `None` above cannot be an artefact of the field never being + // populated at all. + let both = increase_derivation_index(&db, Some(true), Some(true), 20) + .await + .unwrap(); + assert_eq!(both.hardened_floor, Some(20)); + assert_eq!(both.unhardened_floor, Some(20)); + } + + /// A write that updates NO ROW is an error, not a quiet success (dig-node#256). + /// + /// `UPDATE … WHERE id = 0` against an absent settings row affects zero rows and `execute` + /// returns `Ok`. That is the shape with no symptom at all: the floor is never raised and the + /// caller is told it was. The seeded control in the same test proves the refusal is keyed on + /// the missing row rather than on the method always failing. + #[tokio::test] + async fn a_zero_row_update_fails_rather_than_reporting_a_raise() { + let db = WalletDb::open_in_memory().await.unwrap(); + assert!( + increase_derivation_index(&db, None, Some(true), 7) + .await + .is_ok(), + "control: with the settings row present the raise succeeds" + ); + + db.delete_network_settings_row_for_test().await.unwrap(); + + assert!( + increase_derivation_index(&db, None, Some(true), 7) + .await + .is_err(), + "with no settings row nothing is written; reporting success here is the defect" + ); } - Ok(()) } #[cfg(test)] diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index b227f81d..b5485cc1 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -1512,18 +1512,51 @@ impl WalletDb { /// Raise the derivation-index floor for one HD tree (`increase_derivation_index`, /// §18.16) — [`Self::max_derivation_index`] never reports less than this afterward, even /// if no derivation rows exist yet at that index. Never lowers an existing floor. - pub async fn raise_derivation_floor(&self, hardened: bool, index: u32) -> sqlx::Result<()> { + /// Raise a derivation floor to at least `index`, returning the floor that is IN FORCE + /// afterwards (dig-node#256). + /// + /// # Why it returns the floor instead of `()` + /// + /// The write is `MAX(col, ?)`, so a request BELOW the current floor is a deliberate no-op — + /// correct, and previously indistinguishable from success at every layer above. The floor + /// decides which addresses this node scans, so a caller that believes it raised coverage it + /// did not raise has funds it cannot see. Returning the resulting floor makes the two + /// distinguishable without the caller having to make a second read that could race this one. + /// + /// # A zero-row update is an ERROR, not a quiet success + /// + /// `WHERE id = 0` against an absent settings row updates nothing and `execute` still returns + /// `Ok`. That is the failure mode with no symptom: the floor is never raised, the caller is + /// told it was, and the addresses stay unscanned. It is refused by name here rather than + /// inferred from the read-back, so the diagnosis names the missing row. + pub async fn raise_derivation_floor(&self, hardened: bool, index: u32) -> sqlx::Result { let col = if hardened { "derivation_floor_hardened" } else { "derivation_floor_unhardened" }; - sqlx::query(&format!( + let done = sqlx::query(&format!( "UPDATE network_settings SET {col} = MAX({col}, ?) WHERE id = 0" )) .bind(i64::from(index)) .execute(&self.pool) .await?; + if done.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + self.derivation_floor(hardened).await + } + + /// Delete the singleton `network_settings` row, so a test can exercise the zero-row-update + /// path that [`Self::raise_derivation_floor`] refuses (dig-node#256). + /// + /// Test-only and named so: the absent row is a real state a corrupted or partially-migrated + /// database can be in, and the refusal that guards it is untestable without reaching it. + #[cfg(test)] + pub async fn delete_network_settings_row_for_test(&self) -> sqlx::Result<()> { + sqlx::query("DELETE FROM network_settings WHERE id = 0") + .execute(&self.pool) + .await?; Ok(()) } @@ -2615,6 +2648,156 @@ impl WalletDb { Ok(n) } +} + +/// Why a coin-database reset was refused (dig-node#384). +/// +/// A distinct type rather than a string, because the caller has to be able to tell "I refused" from +/// "I did it" without reading prose, and because the two reasons need different remedies: one waits, +/// the other is a bug. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResetRefusal { + /// A spend is in flight. `coin_reservations`, `client_coin_reservations` and + /// `pending_transactions` all reference coins by id, and wiping the coins beneath a reserved, + /// unconfirmed spend would leave a bundle whose inputs the wallet no longer knows about. + /// + /// The remedy is to wait: reservations expire, and the reset succeeds afterwards. + SpendInFlight { reservations: u64 }, +} + +impl std::fmt::Display for ResetRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SpendInFlight { reservations } => write!( + f, + "refused: {reservations} coin reservation(s) are in flight. Resetting now would wipe the coins an unconfirmed spend was built on. Wait for them to confirm or expire, then retry." + ), + } + } +} + +/// What a completed reset discarded (dig-node#384). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub struct ResetReport { + /// Coin rows dropped. A caller renders this to say what the reset actually cost. + pub coins_dropped: u64, + /// Staged CAT-discovery rows dropped. + pub staged_dropped: u64, +} + +impl WalletDb { + /// Drop the chain-derived cache and force a re-sync from chain (dig-node#384). + /// + /// # What this is FOR, and what it is not + /// + /// Not "the database is stale" — `reconstruct_all` repairs that on the next tick. The case + /// this exists for is narrower and permanent: a coin whose parent spend could not be fetched + /// is skipped and **never re-queued**, so its asset never resolves and its value never + /// appears in an asset-scoped balance, for the life of the database. Until now the only + /// recovery was deleting the file by hand. + /// + /// # The money hazard, and why the flag is cleared in the SAME transaction + /// + /// `is_synced` is what makes the local replica AUTHORITATIVE for wallet-scoped reads + /// (`routing::route`). Between emptying the coins and finishing the re-sync the database is + /// empty — so an emptied-but-still-`synced` replica answers **`balance 0, synced true`** on a + /// funded wallet, which is the money-lie class this repo already documents for the + /// reorg-rollback path in almost these words. + /// + /// So the delete and the flag clear are ONE transaction. A crash between them cannot leave an + /// empty authoritative replica; reads fall back to the chain tier until a genuine catch-up + /// re-establishes the flag. The recorded coverage is cleared with it, because a coverage + /// record describing a set the replica no longer holds is the same falsehood one field along. + /// + /// # Key material is never touched + /// + /// Every table cleared here is chain-derived and re-derivable by syncing. Seeds, device keys + /// and the derivation floors live outside this transaction and outside this database's + /// destructive reach — a coin can be re-fetched, a seed cannot. + /// + /// # It refuses rather than corrupting + /// + /// See [`ResetRefusal::SpendInFlight`]. + /// # `now_ms` counts only LIVE holds + /// + /// A hold is retired by expiry, and pruning is periodic — so a table row is not the same thing + /// as a spend in flight. Counting rows would let one lapsed, unpruned reservation refuse every + /// reset for ever, which is a permanent denial of the only recovery this feature provides. + /// + /// The node reads its own clock for this; it is a parameter so a test can drive the edge + /// exactly, and for the reason [`Self::held_reservations`] gives — a caller-supplied instant + /// would be a lapse oracle. + pub async fn reset_chain_cache( + &self, + now_ms: i64, + ) -> sqlx::Result> { + let mut tx = self.pool.begin().await?; + + // Counted INSIDE the transaction, so a reservation taken between a pre-check and the + // delete cannot slip through the gap this refusal exists to close. + let reservations: i64 = sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM pending_transactions WHERE expires_at > ?1) + + (SELECT COUNT(*) FROM coin_reservations r + JOIN pending_transactions p ON p.transaction_id = r.transaction_id + WHERE p.expires_at > ?1) + + (SELECT COUNT(*) FROM client_coin_reservations WHERE expires_at_ms > ?1)", + ) + .bind(now_ms) + .fetch_one(&mut *tx) + .await?; + if reservations > 0 { + // Dropped without committing: nothing was written, so a refusal cannot half-reset. + return Ok(Err(ResetRefusal::SpendInFlight { + reservations: reservations as u64, + })); + } + + let coins_dropped: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM coins") + .fetch_one(&mut *tx) + .await?; + let staged_dropped: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cat_admission_pending") + .fetch_one(&mut *tx) + .await?; + + // Every table here is chain-derived. `derivations`, `network_settings`, `user_themes`, + // `peers` and `offers` are deliberately absent: they are configuration or user artifacts, + // not a cache of the chain, and re-syncing does not reproduce them. + for table in [ + "coins", + "cats", + "nfts", + "dids", + "nft_collections", + "options", + "arrivals", + "arrival_pending", + "cat_admission_pending", + "chain_read_cache", + "chain_spend_cache", + ] { + sqlx::query(&format!("DELETE FROM {table}")) + .execute(&mut *tx) + .await?; + } + + // The clause that makes the whole operation safe. See the doc above. + sqlx::query( + "UPDATE sync_state + SET initial_sync_complete = 0, covered_puzzle_hashes = '' + WHERE id = 0", + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(Ok(ResetReport { + coins_dropped: coins_dropped as u64, + staged_dropped: staged_dropped as u64, + })) + } +} + +impl WalletDb { /// Every live in-flight bundle, oldest submission first, with the coins it reserved. pub async fn pending_transactions(&self) -> sqlx::Result> { let rows = sqlx::query( @@ -4914,6 +5097,124 @@ mod tests { ); } + // ---- coin-database reset (dig-node#384) -------------------------------- + + /// **The money hazard, asserted directly: a reset must never leave an EMPTY replica claiming + /// to be AUTHORITATIVE (dig-node#384).** + /// + /// `is_synced` is what licenses `routing::route` to serve wallet-scoped reads from the local + /// replica. An emptied-but-still-synced database therefore answers `balance 0, synced true` + /// for a funded wallet — a confident zero about somebody's money. + /// + /// The pre-state is asserted first, so this cannot pass against a database that was never + /// synced to begin with. + #[tokio::test] + async fn a_reset_clears_the_authoritative_flag_along_with_the_coins() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_coin(&coin("c1", 12_345, Some(10), None)) + .await + .unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + assert!(db.is_synced().await.unwrap(), "pre-state: authoritative"); + + let report = db.reset_chain_cache(0).await.unwrap().expect("not refused"); + assert_eq!(report.coins_dropped, 1); + + assert!( + !db.is_synced().await.unwrap(), + "an emptied replica that still calls itself synced answers `balance 0, synced true` on a funded wallet — the whole risk of this feature" + ); + } + + /// A reset REFUSES while a spend is in flight, and refuses WITHOUT writing anything + /// (dig-node#384). + /// + /// The reservation tables reference coins by id, so wiping the coins beneath an unconfirmed + /// bundle leaves a spend whose inputs the wallet no longer knows about. The second half — + /// that the coins are still there afterwards — is what makes this more than a return-value + /// test: a refusal that had already deleted half the tables would satisfy the first assertion + /// alone. + #[tokio::test] + async fn a_reset_refuses_while_a_spend_is_in_flight_and_writes_nothing() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_coin(&coin("c1", 12_345, Some(10), None)) + .await + .unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + db.reserve_client_coins(&["c1".to_string()], Some(60_000), 0) + .await + .expect("reserve"); + + let refusal = db + .reset_chain_cache(0) + .await + .unwrap() + .expect_err("a reset under a live reservation must refuse"); + assert!(matches!(refusal, ResetRefusal::SpendInFlight { .. })); + + assert!( + db.is_synced().await.unwrap(), + "a refusal must leave the flag alone — a half-applied reset is the state this refusal exists to prevent" + ); + assert_eq!( + db.coins_by_ids(&["c1".to_string()]).await.unwrap().len(), + 1, + "the reserved coin must survive the refusal" + ); + } + + /// The control that proves the refusal is keyed on the reservation and not on the method + /// always refusing: the SAME database resets cleanly once the hold has lapsed. + #[tokio::test] + async fn a_reset_succeeds_once_the_reservation_has_expired() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_coin(&coin("c1", 12_345, Some(10), None)) + .await + .unwrap(); + db.reserve_client_coins(&["c1".to_string()], Some(1), 0) + .await + .expect("reserve"); + assert!( + db.reset_chain_cache(0).await.unwrap().is_err(), + "at instant 0 the hold is live" + ); + + // Fixture time, pinned explicitly rather than read from the wall clock: the hold's TTL is + // 1ms from instant 0, so instant 10_000 is unambiguously past it in every environment. + // Note the reset is asked at the LATER instant WITHOUT pruning first — a lapsed row that + // nobody has swept must not refuse, or one stale row denies the recovery for ever. + let report = db + .reset_chain_cache(10_000) + .await + .unwrap() + .expect("the hold has lapsed, so the reset proceeds"); + assert_eq!(report.coins_dropped, 1); + } + + /// A reset discards CHAIN-DERIVED rows only. A user theme is configuration, not a cache of the + /// chain, and re-syncing does not reproduce it — so it must survive. + /// + /// This is the standing guard on the table list: a future author adding a table to the delete + /// loop that is not chain-derived destroys data the user cannot get back. + #[tokio::test] + async fn a_reset_does_not_discard_configuration_it_cannot_re_derive() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.save_user_theme("nft1", "dark-purple").await.unwrap(); + db.upsert_coin(&coin("c1", 1, Some(10), None)).await.unwrap(); + + db.reset_chain_cache(0).await.unwrap().expect("not refused"); + + assert_eq!( + db.user_theme("nft1").await.unwrap().as_deref(), + Some("dark-purple"), + "a theme is not chain-derived and a re-sync cannot bring it back" + ); + assert!( + db.coins_by_ids(&["c1".to_string()]).await.unwrap().is_empty(), + "control: the chain-derived half WAS discarded" + ); + } + // ---- user themes (#205 PR4) -------------------------------------------- #[tokio::test] diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 27e9c912..eee97001 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -1115,6 +1115,19 @@ impl WalletBackend { /// unbounded egress path (dig_ecosystem#1957) that also discloses `{IP, timestamp, coin id}` /// to that third party. /// + /// Drop the chain-derived cache and force a re-sync from chain (dig-node#384). + /// + /// A thin pass-through to [`WalletDb::reset_chain_cache`], which holds the whole contract: + /// the authoritative flag is cleared in the same transaction as the coins, an in-flight spend + /// refuses, and no key material is reachable. Exposed here because the control plane holds a + /// backend, not a database. + pub async fn reset_coin_db( + &self, + now_ms: i64, + ) -> sqlx::Result> { + self.db.reset_chain_cache(now_ms).await + } + /// With no supervisor the peer count is reported UNOBSERVABLE (`None`), never zero — zero /// would claim an observation nobody made. pub async fn wallet_sync_status( @@ -4234,13 +4247,15 @@ impl WalletBackend { Ok(ActionResponse {}) } + /// Raise the HD derivation floor, reporting the floors in force afterwards (dig-node#256). + /// + /// The one action method that does NOT return the shared empty response, because its no-op is + /// reachable and costs money: see [`IncreaseDerivationIndexResponse`]. async fn increase_derivation_index( &self, req: &IncreaseDerivationIndex, - ) -> Result { - actions::increase_derivation_index(&self.db, req.hardened, req.unhardened, req.index) - .await?; - Ok(ActionResponse {}) + ) -> Result { + actions::increase_derivation_index(&self.db, req.hardened, req.unhardened, req.index).await } // ---- themes (#205 PR4) -------------------------------------------------- @@ -5664,9 +5679,13 @@ mod tests { ids.sort_unstable(); assert_eq!( ids, - ["pending-dig", "real-dig"], - "exactly the $DIG coins: not an empty set (the #306 under-report), and not the hinted XCH or foreign CAT (the #2879 over-report)" + ["real-dig"], + "the confirmed $DIG coin: not an empty set (the #306 under-report), and not the hinted XCH or foreign CAT (the #2879 over-report)" ); + // `pending-dig` has no created height and the default filter mode excludes unconfirmed + // coins — pinned here so a later widening of that filter is a deliberate change rather + // than an accident that starts offering unconfirmed value to a caller. + assert!(!ids.contains(&"pending-dig")); } /// The control that makes the test above load-bearing in the OTHER direction: asking for a diff --git a/crates/dig-wallet/src/sage/types.rs b/crates/dig-wallet/src/sage/types.rs index a3fadd71..3af060b2 100644 --- a/crates/dig-wallet/src/sage/types.rs +++ b/crates/dig-wallet/src/sage/types.rs @@ -1707,7 +1707,62 @@ pub struct IncreaseDerivationIndex { pub index: u32, } +/// `increase_derivation_index` response (dig-node#256). +/// +/// # Why this method alone stopped sharing [`ActionResponse`] +/// +/// The HD derivation floor decides WHICH addresses this node scans. Under the shared empty +/// response a caller could not tell *"the floor was raised"* from *"nothing happened"* — and +/// "nothing happened" is not hypothetical: the write is `MAX(col, ?)`, so a request below the +/// current floor changes nothing by design, and a settings row that is absent updates no rows at +/// all. In both cases the operator was told it succeeded, and **funds at higher indices stay +/// invisible with no error, no retry and no way to know**. That is a surface lying about money. +/// +/// # The shape is modelled on `ChiaPeerRemovalOutcome`, deliberately +/// +/// The floors are reported as NUMBERS a consumer must read, not as a `bool` companion saying +/// whether something changed. A boolean is ignorable; a floor is the actual answer to the +/// question the caller asked — *up to which index is this wallet now scanning?* — and it is +/// checkable against what they requested. +/// +/// # Additive, so Sage parity holds (§5.1) +/// +/// The wire shape was `{}`, and adding fields to it cannot break a client that parsed an empty +/// object. A strict third-party client that ignores the new keys behaves exactly as before; one +/// that reads them learns something it previously could not. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct IncreaseDerivationIndexResponse { + /// The hardened tree's derivation floor AFTER the call, or `None` when the caller did not ask + /// about the hardened tree. + /// + /// `None` is "not asked", never zero. A floor of 0 is a real claim — *this tree scans no + /// derived addresses* — and reporting one for a tree the request never mentioned would be the + /// same unknown-rendered-as-a-number defect this response exists to remove. + pub hardened_floor: Option, + /// The unhardened tree's derivation floor AFTER the call, under the same rule. + pub unhardened_floor: Option, +} + /// An empty response shared by every action method above (`{}`). +/// +/// # When an empty response is the right answer, and when it is a lie (dig-node#256) +/// +/// Nineteen methods return this. It is correct for the ones whose only outcomes are *the write +/// landed* and *an `Err`*: a settings write (`set_network`, `set_target_peers`, +/// `set_change_address`, `set_delta_sync`, …), a theme write, a peer add/remove, and the metadata +/// resets (`resync_cat`, `update_cat`, `update_did_action`, `update_option_action`, +/// `update_nft_action`, `update_nft_collection_action`, `redownload_nft_action`). Each is a true +/// idempotent write with no observable difference between "changed it" and "it already said +/// that", so there is nothing a richer response could truthfully report. This paragraph exists so +/// the next reader does not re-litigate them. +/// +/// It was NOT correct for `increase_derivation_index`, whose no-op is both reachable and +/// money-class; that method now returns [`IncreaseDerivationIndexResponse`]. +/// +/// The remaining ones deserve outcomes of their own on evidence rather than on suspicion — a +/// `redownload_nft` for an unknown id and an `update_nft` for an unknown id are the strongest +/// candidates. They are NOT changed here, because this is a Sage-PARITY surface and a response +/// shape must be established against Sage's own before it is widened. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct ActionResponse {} From f8e3b40d36285dc85c3c72425fb6f3336757072c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 07:43:14 -0700 Subject: [PATCH 4/9] docs(spec): record the reset method, the staleness fields and the funded latch SPEC gains the control.wallet.resetCoinDb row (the same-transaction clearing of the authoritative flag, the expiry-not-presence refusal, the master tier), the balance row gains network_peak_height and stale_by with the rule that an absent gap and a zero gap are opposite claims, and 16.4's NOT YET SATISFIED block is replaced by the observation-point contract now that a caller exists. Bumps dig-wallet 0.43.0 -> 0.44.0 and the workspace 0.189.0 -> 0.190.0: new capability, every wire change additive. Refs #416 #286 #384 --- .gitignore | 3 + .lane/chk2.txt | 118 --------------------- .lane/reset.txt | 21 ---- .lane/svc.txt | 0 .loop/BATCH-WALLET.md | 2 - Cargo.toml | 2 +- SPEC.md | 21 ++-- crates/dig-node-service/src/control.rs | 47 +++++++- crates/dig-node-service/src/control_cli.rs | 15 ++- crates/dig-wallet/src/sage/db.rs | 10 +- 10 files changed, 81 insertions(+), 158 deletions(-) delete mode 100644 .lane/chk2.txt delete mode 100644 .lane/reset.txt delete mode 100644 .lane/svc.txt delete mode 100644 .loop/BATCH-WALLET.md diff --git a/.gitignore b/.gitignore index 32fffe3a..49f23322 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ control-token config.json .gitnexus/ + +# lane-local scratch (never committed) +.lane/ diff --git a/.lane/chk2.txt b/.lane/chk2.txt deleted file mode 100644 index b09e2021..00000000 --- a/.lane/chk2.txt +++ /dev/null @@ -1,118 +0,0 @@ - Checking serde_json v1.0.151 - Checking regex-automata v0.4.18 - Checking bitflags v2.13.1 - Checking simd-adler32 v0.3.10 - Checking tower-http v0.6.11 - Checking miniz_oxide v0.9.1 - Checking reqwest v0.12.28 - Checking digstore-core v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking regex v1.13.1 - Checking flate2 v1.1.10 - Checking wasmparser v0.221.3 - Checking wasmparser v0.252.0 - Checking digstore-crypto v0.1.1 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking dig-nat v0.21.0 - Checking chialisp v0.4.6 - Checking clvm_tools_rs v0.3.0 - Checking chia-sdk-coinset v0.36.0 - Checking chia-sdk-coinset v0.30.0 - Checking digstore-chunker v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking windows-sys v0.59.0 - Checking dig-rpc-protocol v0.10.2 - Checking windows-sys v0.52.0 - Checking object_store v0.11.2 - Checking rusqlite v0.32.1 - Checking matchers v0.2.0 - Checking sharded-slab v0.1.7 - Checking axum v0.7.9 - Checking nu-ansi-term v0.50.3 - Checking errno v0.3.14 - Checking thread_local v1.1.10 - Checking crossbeam-channel v0.5.16 - Checking zopfli v0.8.3 - Checking dig-ipc-protocol v0.3.0 - Checking dirs-sys v0.3.7 - Checking dig-pex v0.1.1 - Checking home v0.5.12 - Checking quick-xml v0.41.0 - Checking symlink v0.1.0 - Checking dig-node-control-interface v0.27.0 - Checking digstore-core v0.13.4 - Checking dig-mirror-collateral v0.3.0 - Checking rtoolbox v0.0.6 - Checking digstore-prover v0.1.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking digstore-store v0.1.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking widestring v1.2.1 - Compiling dig-node-service v0.189.0 (C:\tmp\worktrees\dn-wallet\crates\dig-node-service) - Checking dig-cert v0.1.2 - Checking tracing-subscriber v0.3.23 - Checking dig-dht v0.15.0 - Checking chia-sdk-types v0.30.0 - Checking socket2 v0.5.10 - Checking dig-peer v0.13.0 - Checking rustix v0.38.44 - Checking rue-lir v0.8.5 - Checking rue-lir v0.6.0 - Checking zip v2.4.2 - Checking dirs v4.0.0 - Checking dig-capsule v0.5.0 - Checking wasm-encoder v0.221.3 - Checking wasmprinter v0.252.0 - Checking plist v1.10.0 - Checking dig-urn-resolver v0.5.3 - Checking rpassword v7.5.4 - Checking windows-service v0.7.0 - Checking tracing-appender v0.2.5 - Checking dig-peer-selector v0.11.0 - Checking dig-download v0.22.0 - Checking which v4.4.2 - Checking rue-hir v0.6.0 - Checking rue-hir v0.8.5 - Checking chia-sdk-signer v0.30.0 - Checking chia-sdk-client v0.30.0 - Checking digstore-compiler v1.0.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking wasmtime-environ v47.0.4 - Checking dig-logging v0.2.0 - Checking service-manager v0.7.1 - Checking rue-compiler v0.8.5 - Checking rue-compiler v0.6.0 - Checking chia-sdk-test v0.30.0 - Checking chia-sdk-driver v0.30.0 - Checking digstore-stage v0.1.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking chia-sdk-types v0.36.0 - Checking chia-sdk-types v0.34.0 - Checking chia-wallet-sdk v0.30.0 - Checking dig-merkle v0.4.5 - Checking chia-sdk-client v0.34.0 - Checking dig-store v0.5.1 - Checking dig-peer-protocol v0.7.0 - Checking dig-store-cache v0.1.1 - Checking dig-gossip v0.32.0 (https://github.com/DIG-Network/dig-gossip?rev=1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee#1a339166) - Checking dig-sex v0.5.0 - Checking chia-sdk-signer v0.36.0 - Checking chia-sdk-client v0.36.0 - Checking chia-sdk-driver v0.36.0 - Checking chia-sdk-daemon v0.36.0 - Checking chia-sdk-test v0.36.0 - Checking wasmtime-internal-unwinder v47.0.4 - Checking wasmtime-internal-cranelift v47.0.4 - Checking chia-wallet-sdk v0.36.0 - Checking dig-clvm v0.4.0 - Checking dig-mirror-coin v0.7.0 - Checking dig-cat v0.3.0 - Checking dig-did v0.8.0 - Checking dig-offers v0.3.0 - Checking dig-options v0.4.0 - Checking dig-nft v0.3.0 - Checking datalayer-driver v5.0.0 - Checking chia-query v0.20.0 - Checking dig-tips v0.3.0 - Checking wasmtime v47.0.4 - Checking dig-wallet-backend v0.31.0 - Checking digstore-chain v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking digstore-host v0.3.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking digstore-remote v0.29.0 (https://github.com/DIG-Network/digstore.git?rev=161c2a3108cb4bb6c8791e96a26588ec99afb029#161c2a31) - Checking dig-node-core v0.64.0 (C:\tmp\worktrees\dn-wallet\crates\dig-node-core) - Checking dig-wallet v0.43.0 (C:\tmp\worktrees\dn-wallet\crates\dig-wallet) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 41s -RC=0 diff --git a/.lane/reset.txt b/.lane/reset.txt deleted file mode 100644 index d3b516cd..00000000 --- a/.lane/reset.txt +++ /dev/null @@ -1,21 +0,0 @@ - Blocking waiting for file lock on build directory - Compiling dig-wallet v0.43.0 (C:\tmp\worktrees\dn-wallet\crates\dig-wallet) -warning: linker stdout: LINK : warning LNK4098: defaultlib 'MSVCRTD' conflicts with use of other libs; use /NODEFAULTLIB:library - | - = note: `#[warn(linker_messages)]` on by default - -warning: `dig-wallet` (lib test) generated 1 warning - Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 42s - Running unittests src\lib.rs (target\debug\deps\dig_wallet-85feb6aff8eda262.exe) - -running 6 tests -test sage::chain::corroborated_peak_tests::a_client_going_away_resets_the_record_rather_than_ageing_it ... ok -test sage::db::tests::a_reset_refuses_while_a_spend_is_in_flight_and_writes_nothing ... ok -test sage::db::tests::a_reset_clears_the_authoritative_flag_along_with_the_coins ... ok -test sage::db::tests::a_reset_does_not_discard_configuration_it_cannot_re_derive ... ok -test sage::db::tests::a_reset_succeeds_once_the_reservation_has_expired ... ok -test sage::sync_supervisor::tests::backoff_grows_then_resets_after_a_long_lived_connection ... ok - -test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 738 filtered out; finished in 0.10s - -RC=0 diff --git a/.lane/svc.txt b/.lane/svc.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/.loop/BATCH-WALLET.md b/.loop/BATCH-WALLET.md deleted file mode 100644 index 99d41f8f..00000000 --- a/.loop/BATCH-WALLET.md +++ /dev/null @@ -1,2 +0,0 @@ -lane: wallet batch (dig-node) -tickets: 416 306 390 384 286 256 396 diff --git a/Cargo.toml b/Cargo.toml index 173dc22a..8dd31ace 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.191.0" +version = "0.192.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 a131dfbb..718e07f5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1624,13 +1624,14 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.capsule.fetch` | `store` (64-hex), `root` (64-hex) — BOTH REQUIRED and both canonical; there is no root-less form, because a capsule pull names one concrete generation and choosing one is the chain’s decision (`control.sync.trigger`), not this verb’s | `store`, `root`, `status` ∈ {`"started"`, `"already_cached"`, `"unavailable"`} | This is an ACKNOWLEDGEMENT, not a completion report: a whole-`.dig` pull crosses the network and takes arbitrarily long, so the call MUST return as soon as the pull is launched and MUST NOT block on the transfer. `"started"` therefore means STARTED and MUST NOT be answered for a pull that was not launched; completion is observed through the cache (`control.hostedStores.status`). `"already_cached"` means the capsule was on disk and no pull was started — read from the filesystem, the same evidence the serve path uses, never from an index that could disagree with it. `"unavailable"` means nothing could be started because this build has no capsule warmer (the FFI/base path has no P2P engine). `INVALID_PARAMS` on a missing or non-64-hex `store`/`root`. Authorized like every other write on this plane; it is NOT an open read, because a pull spends this node’s bandwidth on the caller’s choice of content. | | `control.sync.status` | — | `available` (always `true` — the chunked capsule download needs no identity), `method: "chunked-capsule-download-with-section-21-clone-fallback"`, `identity_loaded`, `pinned_total`, `pinned_synced`, `whole_store_trigger_supported` (`true` — a store id alone is enough) | | `control.sync.trigger` | `store` = `storeId[:rootHash]`, or `store_id` [+ `root`] — the root is OPTIONAL; without one the node resolves the store's CHAIN-ANCHORED tip and syncs that generation | `status: "synced"`, `root`, `size_bytes`, `served_root` | -| `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`) | `balance` (confirmed, spendable — JSON NUMBER, u64 base units), `pending` (unspent + unconfirmed — JSON NUMBER, u64 base units), `source` (`"db"` \| `"fallback"` — which tier produced the figure, §18.7b), `synced` (bool), `peak_height` (`u32` or `null`). Matches `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, pending: u64, .. }` and dig-app's `BalanceResponse { balance: u64 }` — a Rust-to-Rust numeric contract, never a decimal string. The wallet backend tracks the base-unit total as `u128` (headroom for summed intermediate math); the wire boundary saturating-casts to `u64` (a single address's balance can never exceed `u64::MAX` mojos, ~18.4M XCH). READ-ONLY chain read of a PUBLIC address (no seed/signing key). Reuses the B.6 sync-state routing: the local DB when the address is the wallet's own and the DB is synced, else the coinset fallback. Per §18.7b, `source`/`synced`/`peak_height` describe the TIER that answered: a `"db"` answer reports the node's own peak and reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height` rather than presenting a stale figure as current; a tier with NO observable peer height also answers `synced: false`, because nothing corroborated the figure, and so does a replica with NO peak of its OWN — `synced: true` beside `peak_height: null` would claim a reading is current while refusing to say what it is a reading of (§18.7b); a `"fallback"` answer reports `synced: false` and `peak_height: null`. This is an OPEN read (`is_open_control_read`, no token); the cheap local-DB fast path is unbounded, but the EXPENSIVE coinset-fallback leg is subject to a GLOBAL token-bucket rate bound (defense-in-depth against an open-read amplification/oracle sweep — #1957): a burst of arbitrary-address fallback reads beyond the bound is refused with `WALLET_RATE_LIMITED` (§10), while any single honest read (DB fast path or one fallback) always succeeds. A CAT scopes by the asset id the REQUEST named -- any CAT, not only `$DIG` -- and BOTH tiers MUST scope to that id. `"dig"` is the canonical id `digstore_chain::dig::DIG_ASSET_ID` spelled as a token, and `{"cat":""}` MUST mean the same asset. Every scoping hash a tier derives MUST be derived FROM the requested id: a filter keyed to a fixed asset answers every other CAT an EMPTY list, which is indistinguishable from holding none of it -- a silent wrong answer with nothing to observe. An `asset` that is PRESENT and does not parse is `INVALID_PARAMS`; it MUST NOT default to `"xch"`, because a mistyped asset id would then read as a balance for the wrong token. An OMITTED `asset` is the documented `"xch"` default. A hint is not an asset: the fallback tier finds CAT coins with `get_coin_records_by_hints`, which takes no asset id and answers with EVERY coin hinted to the address -- any CAT of any TAIL, and any plain XCH coin whose spend carried a hint memo -- so a `"fallback"` answer MUST keep only the coins sitting at that asset's CAT puzzle hash (`digstore_chain::cat::cat_puzzle_hash(owner_p2_hash, asset_id)`, the canonical curry), the exact equivalent of the DB tier's `hint IN (...) AND asset_id = ?`. Summing the raw hint answer reports a holding the address does not have, at the asked-for asset's scale rather than each coin's own: one hinted XCH coin of 10^8 mojos (`0.0001 XCH`) totals as `100000` at `$DIG`'s 3 decimals. Over-filtering is the same lie mirrored -- a real `$DIG` holder answered zero -- so the filter MUST key on that puzzle hash and nothing heuristic. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error (and `synced` there means MEASURED-current, never merely eligible); the read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. | +| `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`) | `balance` (confirmed, spendable — JSON NUMBER, u64 base units), `pending` (unspent + unconfirmed — JSON NUMBER, u64 base units), `source` (`"db"` \| `"fallback"` — which tier produced the figure, §18.7b), `synced` (bool), `peak_height` (`u32` or `null`). Matches `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, pending: u64, .. }` and dig-app's `BalanceResponse { balance: u64 }` — a Rust-to-Rust numeric contract, never a decimal string. The wallet backend tracks the base-unit total as `u128` (headroom for summed intermediate math); the wire boundary saturating-casts to `u64` (a single address's balance can never exceed `u64::MAX` mojos, ~18.4M XCH). READ-ONLY chain read of a PUBLIC address (no seed/signing key). Reuses the B.6 sync-state routing: the local DB when the address is the wallet's own and the DB is synced, else the coinset fallback. Per §18.7b, `source`/`synced`/`peak_height` describe the TIER that answered: a `"db"` answer reports the node's own peak and reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height` rather than presenting a stale figure as current; a tier with NO observable peer height also answers `synced: false`, because nothing corroborated the figure, and so does a replica with NO peak of its OWN — `synced: true` beside `peak_height: null` would claim a reading is current while refusing to say what it is a reading of (§18.7b); a `"fallback"` answer reports `synced: false` and `peak_height: null`. This is an OPEN read (`is_open_control_read`, no token); the cheap local-DB fast path is unbounded, but the EXPENSIVE coinset-fallback leg is subject to a GLOBAL token-bucket rate bound (defense-in-depth against an open-read amplification/oracle sweep — #1957): a burst of arbitrary-address fallback reads beyond the bound is refused with `WALLET_RATE_LIMITED` (§10), while any single honest read (DB fast path or one fallback) always succeeds. A CAT scopes by the asset id the REQUEST named -- any CAT, not only `$DIG` -- and BOTH tiers MUST scope to that id. `"dig"` is the canonical id `digstore_chain::dig::DIG_ASSET_ID` spelled as a token, and `{"cat":""}` MUST mean the same asset. Every scoping hash a tier derives MUST be derived FROM the requested id: a filter keyed to a fixed asset answers every other CAT an EMPTY list, which is indistinguishable from holding none of it -- a silent wrong answer with nothing to observe. An `asset` that is PRESENT and does not parse is `INVALID_PARAMS`; it MUST NOT default to `"xch"`, because a mistyped asset id would then read as a balance for the wrong token. An OMITTED `asset` is the documented `"xch"` default. A hint is not an asset: the fallback tier finds CAT coins with `get_coin_records_by_hints`, which takes no asset id and answers with EVERY coin hinted to the address -- any CAT of any TAIL, and any plain XCH coin whose spend carried a hint memo -- so a `"fallback"` answer MUST keep only the coins sitting at that asset's CAT puzzle hash (`digstore_chain::cat::cat_puzzle_hash(owner_p2_hash, asset_id)`, the canonical curry), the exact equivalent of the DB tier's `hint IN (...) AND asset_id = ?`. Summing the raw hint answer reports a holding the address does not have, at the asked-for asset's scale rather than each coin's own: one hinted XCH coin of 10^8 mojos (`0.0001 XCH`) totals as `100000` at `$DIG`'s 3 decimals. Over-filtering is the same lie mirrored -- a real `$DIG` holder answered zero -- so the filter MUST key on that puzzle hash and nothing heuristic. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error (and `synced` there means MEASURED-current, never merely eligible); the read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. Additionally `network_peak_height` (`u32` or `null`) — the peak this node's own held Chia peers have ANNOUNCED — and `stale_by` (`u32` or `null`) — how many blocks behind that peak this figure is. `stale_by` MUST be `null` unless BOTH the answer's `peak_height` and `network_peak_height` are known: a zero is a positive claim that the figure is level with the network, and absence is the opposite claim, so a consumer MUST NOT render them alike. It MUST saturate at zero rather than underflow when the replica is momentarily ahead. Both fields are ADDITIVE (§5.1). They exist because `balance 0, synced false, peak_height null` — the answer a replica ~8,380 blocks behind its peers actually gave — is indistinguishable from an empty wallet, and a consumer had nothing with which to tell them apart. | | `control.wallet.coins` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`), `after_coin_id` (OPTIONAL, 64 lowercase-hex, an `0x` prefix TOLERATED and normalized away), `limit` (OPTIONAL, `1..=1000`, default `100`) | `coins` (array of `{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}`; all hashes lowercase 64-hex unprefixed, `amount` a JSON NUMBER in base units), `complete` (bool), `cursor` (string \| `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b). ONE PAGE of the UNSPENT coins at the address for the asset, i.e. the read a caller building a spend needs; a balance is this read reduced to a sum, which is why the two take identical params. It scopes to the asset by the SAME tier-agnostic rule, for the sharper reason: a coin list is spend INPUTS, so a hinted XCH or foreign-CAT coin served as a `$DIG` coin is a spend built on inputs of the wrong asset. Coins seen only in the mempool are INCLUDED with `created_height: null`, so the caller decides what is spendable for its purpose rather than the node hiding one. `coins: []` MUST mean a chain WAS consulted and the address holds nothing; every way of failing to consult one is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER an empty list — an empty list would tell a holder of funds that they hold none, and a spend built on it refuses with an untrue shortfall. The read is PAGED, because an address's unspent-coin count is unbounded and every spend's change coin adds one — the same exposure `control.wallet.coinsByParent` carries, on a control plane with no request rate limiting. A node MUST return coins ASCENDING by `coin_id`, MUST keep that order stable across the pages of one walk, and MUST NOT page by OFFSET: an address's unspent set SHRINKS as coins are spent, so under an offset every row after a departed coin moves one position earlier and the next page begins one row late — a coin the caller never sees, on the read whose purpose is coin selection. A node MUST derive `complete` from whether rows remain BEYOND the page, never from the page LENGTH: a coin count that is an exact multiple of the page size makes the final full page indistinguishable from a truncated one, and a caller stopping there builds a spend from half an address's coins and refuses with an untrue shortfall. The scope, asset, unspent predicate and page bound MUST be applied at the SAME level: paginating a broader read and filtering afterwards cuts the page before the filter, so pages arrive short and `complete` is computed from a count that no longer describes what remains. `cursor` is the `coin_id` of the LAST record actually returned, or `null` for an empty page, and is what a caller passes back as `after_coin_id`. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped — a silently shrunk page hands back a cursor for a position the caller did not ask about. Both page params are OPTIONAL and a request naming neither is byte-identical to the pre-paging request. OPEN read, same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `address`, a bad `asset`, a malformed `after_coin_id`, or a `limit` outside `1..=1000`. | | `control.wallet.coinById` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `coin` (`{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}` or `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b); the tier fields MUST describe WHAT ANSWERED THIS READ. Where the local replica HOLDS the named coin and is authoritative for the set it follows (the same `control.wallet.balance` eligibility test, §18.7b), the node MUST answer from the replica: `source: "db"`, `peak_height` the replica's own peak, and `synced` MEASURED against the peers' announced peak rather than assumed — a replica that completed a catch-up and then fell behind still serves the coin, with its real peak, labelled stale. A replica MISS MUST fall through to the chain tier and be reported as such (`source: "fallback"`, `synced: false`, `peak_height: null`); it MUST NEVER be served as an absence, because the replica is populated only from this node's own subscriptions, so a miss means "this node does not watch that coin", which is NOT absence. A node MUST NOT report `source: "fallback"`, `synced: false` for a coin it holds: a warrant no read can ever carry turns every consumer-side freshness guard into an unconditional refusal, which ends a mint watch in "the chain could not be reached" on a healthy node. ONE coin by its own id, SPENT OR UNSPENT — the read a caller polling a spend needs and `control.wallet.coins` structurally cannot give: a created DID coin sits at nobody's wallet address, and a spent funding coin is gone from every unspent list. `asset` in the record is ALWAYS `null`: a coin id alone does not reveal whether a coin is XCH, a CAT or a singleton — that needs the puzzle, which this read never inspects — so naming one would assert a classification the node did not verify. A returned record MUST be bound to the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a source that answers with a DIFFERENT coin is a `WALLET_READ_FAILED` (§10) — never that coin's record, and never `coin: null`. `coin: null` MUST mean a chain source ANSWERED and reported no such coin; every way of failing to get an answer is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER a `null` — a `null` for an outage would tell a caller polling a mint that its coin does not exist, so a pending mint reads as awaiting forever. A caller MUST treat `null` as "not seen yet" and keep polling, not as "never happened". OPEN read (no token), same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call — an unanswerable question and a chain that answered "no" must never wear the same shape; the well-formedness rule is `dig-node-control-interface`'s own `WalletCoinByIdParams::validated()`, consumed rather than restated. | | `control.wallet.coinSpend` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `spend` (`{coin, puzzle_reveal, solution}` or `null`), `source`, `synced`, `peak_height` -- the tier fields carrying exactly their `control.wallet.coinById` meanings, and here always `"fallback"` / `false` / `null`: the local replica stores coin records, not spends, so it can never produce this answer. THE SPEND THAT SPENT ONE COIN, named by that coin's own id (a spend has no id of its own on chain). A coin record carries a puzzle HASH and says only that a coin is gone; the puzzle REVEAL and the solution exist only here, and they are what a caller reconstructing a lineage -- following a dig-profile's DID singleton forward -- needs. `coin` is the full record shape `control.wallet.coinById` returns, with `asset` ALWAYS `null` (this read classifies nothing) and `spent_height` ALWAYS non-null (a spend of a coin nothing calls spent is a contradiction; the node MUST fail closed rather than emit one). The node MUST verify that `puzzle_reveal` tree-hashes to `coin.puzzle_hash` and MUST refuse -- `WALLET_READ_FAILED` (§10) -- when it does not or will not parse: the reveal comes from an unauthenticated peer, a puzzle hash IS the reveal's CLVM tree hash, so the lie is locally detectable and a caller would otherwise curry a forged program into the spend it signs. The returned spend MUST be bound to the id asked for, by the same self-certifying coin-id recomputation `control.wallet.coinById` requires. `spend: null` MUST mean a chain source ANSWERED and holds no spend of that coin -- it is UNSPENT, or unknown; distinguishing those two is `control.wallet.coinById`'s job. Every way of failing to get an answer is a DISTINCT error, NEVER `null`: a caller walking a lineage reads "no spend" as *this is the tip* and stops, so a failure disguised as absence yields a spend built against a superseded singleton, and a mint poll reads it as "my funding coin is still there" and funds the same mint twice. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call; the rule is `dig-node-control-interface`'s own `WalletCoinSpendParams::validated()`, consumed rather than restated. | | `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | | `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. | | `control.wallet.peak` | — | `peak_height` (`u32` or `null`), `synced` (bool). The node's current chain peak, independent of any address. Its OWN method rather than a field on a balance because a balance reports `peak_height: null` on every `"fallback"`-tier answer by design (§18.7b), so a caller bounding a claimed confirmation could not obtain one from the node that most needs to answer. Prefers the node's own replica and falls back to the chain tier. The chain tier is the node's OWN dialled Chia peers, asked CONCURRENTLY and settled on their AGREEMENT (NC-12): the height is the settled height every credible peer in the sample has passed, and a sample that collapses to one voice, or splits, MUST report `peak_height: null` rather than a repaired number. A node MUST NOT satisfy this read from a single public oracle, and MUST NOT fall through to one when its peers fail to agree — falling through would let one endpoint overrule the peers at exactly the moment corroboration failed, which is the single-source dependency NC-12 exists to remove. `peak_height: null` means UNKNOWN and MUST NOT be read as height zero, which every block is trivially above. `synced` carries EXACTLY its `control.wallet.balance` meaning (§18.7b) and MUST be MEASURED by the same predicate: a replica-served peak reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height`, and a tier with no observable peer height, or a replica with no peak of its own, also answers `synced: false` — neither an unmeasured peer tier nor an unknown replica height can establish currency. A node MUST NOT derive this flag from `initial_sync_complete`, which latches on the first completed catch-up and is cleared only by a backwards chain move: a replica hundreds of blocks behind still satisfies it, so `control.wallet.peak` would report `synced: true` about the same replica `control.wallet.syncStatus` is simultaneously reporting as `syncing`. This is the endpoint a caller uses to bound a claimed confirmation, so the overstatement lands on the read that decides whether money has settled. A chain-tier answer reports `synced: false`, because a height the replica did not produce says nothing about the replica. OPEN read. | +| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. MASTER-token tier. | | `control.wallet.broadcast` | `signed_bundle_hex` (lowercase hex, optionally `0x`-prefixed, of a chia `Streamable` `SpendBundle`) | `accepted` (bool), `transaction_id` (lowercase 64-hex or `null`), `rejection` (string or `null`). Pushes an ALREADY-SIGNED bundle. **§908: this method signs nothing and is never given anything it could sign with** — there is no key, seed, phrase or unsigned-plan parameter here and none may be added; on this surface the node's role is to read chain state and relay what somebody else signed. The node's OWN automated spends (§23, §25) never transit this method and are not reachable from it. A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting `{accepted:false, rejection}`; failing to REACH a mempool is `WALLET_READ_FAILED`, and a node with no chain source is `WALLET_NO_CHAIN_SOURCE`. These MUST NOT be collapsed: the first says build a different bundle, the second says retry this one. `accepted:true` reports mempool admission ONLY and is NOT evidence anything reached a block — a caller MUST NOT record an outcome from it; only a buried confirmation of the created coin is evidence. `INVALID_PARAMS` on hex that is not a streamable `SpendBundle`, refused BEFORE any network call. A bundle requiring a signature from any key the NODE custodies — whatever puzzle wraps the coin — while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off is `WALLET_NODE_SPEND_DISABLED`, also refused before any network call — the node relays what somebody ELSE signed, and it signs on request, so whether the node could have signed it is CHECKED rather than assumed. TOKEN-GATED (not an open read). | | `control.chiaPeers.add` | `ip` (a bare IPv4/IPv6 literal — no brackets, no port, no hostname; the standard full-node port is assumed) | `{added: true, ip, port, corroboration_bypassed, notice}`. TRUSTS a Chia full node: it writes the `user_managed` peer row that is the ONLY way to reach `PeerTrust::Operator`, the trust level whose answers may drive catch-up, rollback and the `initial_sync_complete` flag WITHOUT a quorum. Every other peer is `Discovered` and must be corroborated by independently chosen peers first (§18.16). `ip` is CANONICALISED on the way in (`IpAddr` display form — RFC 5952 lowercase compressed for v6) and echoed back in that form, so one host is one entry however it was spelled; `INVALID_PARAMS` on anything that is not a bare literal, refused before any write. `corroboration_bypassed` is the RESULTING trust state, NOT a restatement of the request: a node MUST report `false` where the entry did not end up trusted — adding a peer that was BANNED un-bans it and confers no bypass. `notice` carries the cost as a sentence and MUST be non-empty, name the corroboration bypass, and be rendered VERBATIM; a client MUST NOT paraphrase, truncate or suppress it. The wording MUST authorise only **a node the operator runs themselves** — never vouching or recommending, which widen the case past what justifies the entry's unbounded authority. Idempotent — re-adding a known peer succeeds and un-bans it. A node MUST serve this from the SAME peer store its wallet replica consults. **MASTER-TOKEN TIER** (`ControlMethod::requires_master_token`): a paired token MUST be refused, because the entry outlives the token that wrote it and `pairing.revoke` removes no peer row. | | `control.chiaPeers.list` | — | `{peers: [{ip, port, peak_height, user_managed, banned}]}` — every tracked Chia peer: TRUSTED, DISCOVERED **AND BANNED** alike. `user_managed` tells the trusted set from the discovered one and MUST be reported rather than filtered on: a list showing only the trusted set would let a person conclude the node talks to nobody else. `banned` MUST likewise be reported and its rows MUST NOT be omitted — this is the ONLY enumeration of the ban set, and a blocklist a person cannot read is a blocklist they cannot correct. This enumeration is DISTINCT from the dialling read, which excludes banned peers; a node MUST NOT serve both from one relaxed query. `peak_height` is `null` where the node holds no telemetry for that peer yet — `null` means UNOBSERVABLE and MUST NEVER be reported as `0`, which would render an unpolled peer as one stalled at genesis. A reported height is that peer's CLAIM, never a verified fact, and MUST NOT be aggregated into a chain position (NC-12). TOKEN-GATED at the ORDINARY tier — a read grants nothing that outlives the token, and a paired client must stay able to show the operator the trust state it is subject to. | @@ -2472,7 +2473,8 @@ token-free by design; it is not a control-parity subcommand and this rule does n `wallet coins-by-parent [--after-coin-id ] [--limit ]` → `control.wallet.coinsByParent`; `wallet arrivals [--after-seq ] [--limit ]` → `control.wallet.arrivals`; `wallet peak` → - `control.wallet.peak`; `wallet broadcast ` → `control.wallet.broadcast`. The + `control.wallet.peak`; `wallet reset-coin-db --confirm` → `control.wallet.resetCoinDb`; + `wallet broadcast ` → `control.wallet.broadcast`. The open chain reads (everything above except `broadcast` and `arrivals`) need no token; `broadcast` is token-gated like every other mutation, and carries only already-signed bytes (§908). - `wallet export-seed [--path ]` reaches NO control method. It is a LOCAL, OFFLINE read of @@ -4429,12 +4431,15 @@ ONLY when `origin` is `auto` AND `ever_funded` is false. An absent or unparsable MUST answer "not disposable". A momentarily-zero or unreadable balance is not evidence a wallet never mattered, which is why this is a stored latch rather than a live predicate. -> **NOT YET SATISFIED — no balance observer calls `latch_ever_funded` today.** The latch persists -> correctly when called and is covered against disk, but nothing in the balance-read path calls it, so -> in the shipped build `ever_funded` remains `false` and a funded auto-created wallet is still reported -> as disposable. Wiring the balance observer is required before any surface acts on disposability. This -> paragraph states the intended contract; the sentence above is what is *implemented*, and the two must -> not be conflated. +**The observation point.** The mirror pass observes the operator wallet's own balance on a timer and +classifies each reading before latching. The classification has THREE outcomes, not two, and the +distinction is normative: a non-zero figure latches from EITHER tier, so a stale replica or a chain +fallback answer showing money latches immediately; a CURRENT zero from an authoritative tier is real +evidence of emptiness and does NOT latch; and an unreadable or non-current zero says nothing and +latches nothing. That last case DEFERS rather than latching, because every node is in it for the +first seconds of its life — latching there would make the disposable predicate vacuously false for +every wallet in the ecosystem, and the latch is monotonic, so deferring can never settle into +describing a funded wallet as disposable. **The device key and the wallet directory are a COUPLED PAIR.** `` and `` are meaningful only together: neither opens the seed alone. Any operation that removes one MUST remove or diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index c6d58f3d..218ddba8 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -5456,6 +5456,21 @@ mod tests { "control.pairing.revoke", "control.chiaPeers.add", "control.chiaPeers.remove", + // dig-node#384. Master-tier because the CONTRACT has not published it yet and this + // gate fails CLOSED for an unrecognised name — and that is the tier it should have + // regardless: it discards the node's cached coin database. + // + // Deliberately NOT added to `KNOWN_UNPUBLISHED_CONTROL_METHODS`. That list exempts a + // method from the strict tier to avoid breaking paired clients that already call it; + // nothing calls this one yet, and exempting a destructive method to spare a client + // that does not exist would trade the guard for nothing. + // + // This entry is therefore load-bearing in BOTH directions. When + // `dig-node-control-interface` publishes the method it must publish it as + // master-tier: publishing it as paired-reachable silently DOWNGRADES it here (the + // `from_name` arm starts matching and answers `false`), and this assertion is what + // catches that. + "control.wallet.resetCoinDb", ] .into_iter() .collect(); @@ -5466,15 +5481,43 @@ mod tests { // And it tracks the CONTRACT, so a method the contract promotes later cannot stay // paired-reachable here just because nobody edited the list above. + // + // # Containment, not equality — and the direction is the whole point + // + // This was `assert_eq!` until dig-node#384 added a served-but-unpublished method. Equality + // makes the two sets identical in BOTH directions, so it forbids the node from being + // STRICTER than the published contract — and "stricter" is the fail-closed answer + // `requires_master_token` deliberately gives an unrecognised name. Under equality the only + // way to green this suite before the contract publishes is to add the method to + // `KNOWN_UNPUBLISHED_CONTROL_METHODS`, which DOWNGRADES a destructive method to the paired + // tier. A lockstep test that can only be satisfied by weakening a guard is worse than no + // test. + // + // Containment keeps every escalation the equality caught: a method the contract promotes + // and this node leaves paired-reachable is missing from `actual` and still fails here. + // What it now permits is the node reserving MORE than the contract requires, which cannot + // grant anyone authority they did not have. let contract: BTreeSet<&str> = ControlMethod::ALL .iter() .filter(|m| m.requires_master_token()) .map(|m| m.name()) .filter(|n| CONTROL_METHODS.contains(n)) .collect(); + let missing: Vec<&&str> = contract.difference(&actual).collect(); + assert!( + missing.is_empty(), + "these methods are master-tier in dig-node-control-interface and are NOT reserved by this node — a paired token could reach them: {missing:?}" + ); + + // The residue, stated so it is a known quantity rather than a silent gap: exactly the + // methods this node reserves that the contract has not published. An entry appearing here + // that is NOT a deliberate unpublished addition means someone reserved a method by + // accident, which breaks paired clients that legitimately call it. + let unpublished: Vec<&&str> = actual.difference(&contract).collect(); assert_eq!( - actual, contract, - "this node's master tier disagrees with dig-node-control-interface" + unpublished, + vec![&"control.wallet.resetCoinDb"], + "the set of master-tier-but-unpublished methods changed; publish it in dig-node-control-interface (as master-tier) and remove it from this expectation" ); } diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 0413fe9c..dc9b7c0f 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -2428,7 +2428,10 @@ mod tests { }), ); - assert_ne!(unknown, empty, "a stale zero must not read like an empty wallet"); + assert_ne!( + unknown, empty, + "a stale zero must not read like an empty wallet" + ); assert!( unknown.contains("NOT CURRENT"), "an unbounded zero must be marked not current: {unknown}" @@ -2447,8 +2450,14 @@ mod tests { }), ); assert!(stale.contains("8380"), "the gap must be named: {stale}"); - assert!(stale.contains("9211798"), "the as-of height must be named: {stale}"); - assert_ne!(stale, unknown, "a bounded stale figure differs from an unbounded one"); + assert!( + stale.contains("9211798"), + "the as-of height must be named: {stale}" + ); + assert_ne!( + stale, unknown, + "a bounded stale figure differs from an unbounded one" + ); } /// dig-node#416: an ABSENT balance field renders `unknown`, never `0`. diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index b5485cc1..e80c1b23 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -2647,7 +2647,6 @@ impl WalletDb { Ok(n) } - } /// Why a coin-database reset was refused (dig-node#384). @@ -5200,7 +5199,9 @@ mod tests { async fn a_reset_does_not_discard_configuration_it_cannot_re_derive() { let db = WalletDb::open_in_memory().await.unwrap(); db.save_user_theme("nft1", "dark-purple").await.unwrap(); - db.upsert_coin(&coin("c1", 1, Some(10), None)).await.unwrap(); + db.upsert_coin(&coin("c1", 1, Some(10), None)) + .await + .unwrap(); db.reset_chain_cache(0).await.unwrap().expect("not refused"); @@ -5210,7 +5211,10 @@ mod tests { "a theme is not chain-derived and a re-sync cannot bring it back" ); assert!( - db.coins_by_ids(&["c1".to_string()]).await.unwrap().is_empty(), + db.coins_by_ids(&["c1".to_string()]) + .await + .unwrap() + .is_empty(), "control: the chain-derived half WAS discarded" ); } From f903fe18682cf1abb3ba403b901d111a7a53a369 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 08:12:16 -0700 Subject: [PATCH 5/9] fix(control): put the coin-db reset on the paired tier, where its consumer is The contract-conformance gate refused a served-but-unpublished method, and its sanctioned escape -- KNOWN_UNPUBLISHED -- is deliberately the SAME constant the token gate reads, so tolerating the publish drift and granting a paired token access are one decision. Weighed rather than inherited: #384 exists to put a reset button in the DIG App, and the App holds a paired token, so reserving this to the master token would make the feature unreachable by the only consumer it was built for. What bounds a destructive method here is loopback-only + a token + confirm:true on the wire + a refusal while a spend is in flight + a blast radius holding no key material, not tier alone. This also restores the master-tier drift assertion to equality: the containment relaxation it needed is no longer required. Refs #384 --- SPEC.md | 2 +- crates/dig-node-service/src/control.rs | 83 ++++++++++---------------- 2 files changed, 31 insertions(+), 54 deletions(-) diff --git a/SPEC.md b/SPEC.md index 718e07f5..faee4675 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1631,7 +1631,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | | `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. | | `control.wallet.peak` | — | `peak_height` (`u32` or `null`), `synced` (bool). The node's current chain peak, independent of any address. Its OWN method rather than a field on a balance because a balance reports `peak_height: null` on every `"fallback"`-tier answer by design (§18.7b), so a caller bounding a claimed confirmation could not obtain one from the node that most needs to answer. Prefers the node's own replica and falls back to the chain tier. The chain tier is the node's OWN dialled Chia peers, asked CONCURRENTLY and settled on their AGREEMENT (NC-12): the height is the settled height every credible peer in the sample has passed, and a sample that collapses to one voice, or splits, MUST report `peak_height: null` rather than a repaired number. A node MUST NOT satisfy this read from a single public oracle, and MUST NOT fall through to one when its peers fail to agree — falling through would let one endpoint overrule the peers at exactly the moment corroboration failed, which is the single-source dependency NC-12 exists to remove. `peak_height: null` means UNKNOWN and MUST NOT be read as height zero, which every block is trivially above. `synced` carries EXACTLY its `control.wallet.balance` meaning (§18.7b) and MUST be MEASURED by the same predicate: a replica-served peak reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height`, and a tier with no observable peer height, or a replica with no peak of its own, also answers `synced: false` — neither an unmeasured peer tier nor an unknown replica height can establish currency. A node MUST NOT derive this flag from `initial_sync_complete`, which latches on the first completed catch-up and is cleared only by a backwards chain move: a replica hundreds of blocks behind still satisfies it, so `control.wallet.peak` would report `synced: true` about the same replica `control.wallet.syncStatus` is simultaneously reporting as `syncing`. This is the endpoint a caller uses to bound a claimed confirmation, so the overstatement lands on the read that decides whether money has settled. A chain-tier answer reports `synced: false`, because a height the replica did not produce says nothing about the replica. OPEN read. | -| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. MASTER-token tier. | +| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | | `control.wallet.broadcast` | `signed_bundle_hex` (lowercase hex, optionally `0x`-prefixed, of a chia `Streamable` `SpendBundle`) | `accepted` (bool), `transaction_id` (lowercase 64-hex or `null`), `rejection` (string or `null`). Pushes an ALREADY-SIGNED bundle. **§908: this method signs nothing and is never given anything it could sign with** — there is no key, seed, phrase or unsigned-plan parameter here and none may be added; on this surface the node's role is to read chain state and relay what somebody else signed. The node's OWN automated spends (§23, §25) never transit this method and are not reachable from it. A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting `{accepted:false, rejection}`; failing to REACH a mempool is `WALLET_READ_FAILED`, and a node with no chain source is `WALLET_NO_CHAIN_SOURCE`. These MUST NOT be collapsed: the first says build a different bundle, the second says retry this one. `accepted:true` reports mempool admission ONLY and is NOT evidence anything reached a block — a caller MUST NOT record an outcome from it; only a buried confirmation of the created coin is evidence. `INVALID_PARAMS` on hex that is not a streamable `SpendBundle`, refused BEFORE any network call. A bundle requiring a signature from any key the NODE custodies — whatever puzzle wraps the coin — while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off is `WALLET_NODE_SPEND_DISABLED`, also refused before any network call — the node relays what somebody ELSE signed, and it signs on request, so whether the node could have signed it is CHECKED rather than assumed. TOKEN-GATED (not an open read). | | `control.chiaPeers.add` | `ip` (a bare IPv4/IPv6 literal — no brackets, no port, no hostname; the standard full-node port is assumed) | `{added: true, ip, port, corroboration_bypassed, notice}`. TRUSTS a Chia full node: it writes the `user_managed` peer row that is the ONLY way to reach `PeerTrust::Operator`, the trust level whose answers may drive catch-up, rollback and the `initial_sync_complete` flag WITHOUT a quorum. Every other peer is `Discovered` and must be corroborated by independently chosen peers first (§18.16). `ip` is CANONICALISED on the way in (`IpAddr` display form — RFC 5952 lowercase compressed for v6) and echoed back in that form, so one host is one entry however it was spelled; `INVALID_PARAMS` on anything that is not a bare literal, refused before any write. `corroboration_bypassed` is the RESULTING trust state, NOT a restatement of the request: a node MUST report `false` where the entry did not end up trusted — adding a peer that was BANNED un-bans it and confers no bypass. `notice` carries the cost as a sentence and MUST be non-empty, name the corroboration bypass, and be rendered VERBATIM; a client MUST NOT paraphrase, truncate or suppress it. The wording MUST authorise only **a node the operator runs themselves** — never vouching or recommending, which widen the case past what justifies the entry's unbounded authority. Idempotent — re-adding a known peer succeeds and un-bans it. A node MUST serve this from the SAME peer store its wallet replica consults. **MASTER-TOKEN TIER** (`ControlMethod::requires_master_token`): a paired token MUST be refused, because the entry outlives the token that wrote it and `pairing.revoke` removes no peer row. | | `control.chiaPeers.list` | — | `{peers: [{ip, port, peak_height, user_managed, banned}]}` — every tracked Chia peer: TRUSTED, DISCOVERED **AND BANNED** alike. `user_managed` tells the trusted set from the discovered one and MUST be reported rather than filtered on: a list showing only the trusted set would let a person conclude the node talks to nobody else. `banned` MUST likewise be reported and its rows MUST NOT be omitted — this is the ONLY enumeration of the ban set, and a blocklist a person cannot read is a blocklist they cannot correct. This enumeration is DISTINCT from the dialling read, which excludes banned peers; a node MUST NOT serve both from one relaxed query. `peak_height` is `null` where the node holds no telemetry for that peer yet — `null` means UNOBSERVABLE and MUST NEVER be reported as `0`, which would render an unpolled peer as one stalled at genesis. A reported height is that peer's CLAIM, never a verified fact, and MUST NOT be aggregated into a chain position (NC-12). TOKEN-GATED at the ORDINARY tier — a read grants nothing that outlives the token, and a paired client must stay able to show the operator the trust state it is subject to. | diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 218ddba8..1e56b85d 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -318,7 +318,18 @@ pub const DELEGATED_CONTROL_METHODS: &[&str] = &[ /// tests stayed green because a method absent from the contract is absent from both sides of every /// comparison they make. Granting the ordinary tier is now a reviewable one-line edit to this list /// instead of a side effect of editing an unrelated one. -pub const KNOWN_UNPUBLISHED_CONTROL_METHODS: &[&str] = &["control.peers.ping"]; +/// `control.wallet.resetCoinDb` (dig-node#384) is listed because the contract has not published it +/// yet, and BOTH consequences of listing it were weighed rather than inherited: the conformance +/// gate tolerates the publish drift, and the method keeps the PAIRED tier. The second is the one +/// that matters, and it is the intended answer — the DIG App drives this reset and holds a paired +/// token, so master-tiering it would make the feature unreachable by its only consumer. It is +/// destructive, and what bounds it is loopback-only + a token + `confirm: true` on the wire + a +/// refusal while a spend is in flight, not tier alone. +/// +/// **Remove this entry the moment `dig-node-control-interface` publishes the method** — the +/// `the_unpublished_list_still_describes_real_drift` test fails until it is. +pub const KNOWN_UNPUBLISHED_CONTROL_METHODS: &[&str] = + &["control.peers.ping", "control.wallet.resetCoinDb"]; /// Does this control method require the MASTER control token, never a paired one? PURE. /// @@ -2333,13 +2344,22 @@ async fn wallet_peak(ctx: &ControlCtx, id: Value) -> Value { /// never appears in an asset-scoped balance, for the life of the file. Until this method the only /// recovery was deleting the database by hand. /// -/// # It is NOT an open read, deliberately +/// # Not an open read; PAIRED tier, and that is a deliberate choice /// -/// Absent from [`is_open_control_read`], so it takes the control-plane token like every other -/// privileged method, and the node binds loopback-only. A caller on another machine cannot reach -/// it. **The one exception is an operator who sets `DIG_NODE_ALLOW_REMOTE=1`**, which widens every -/// privileged method at once; that is a deliberate, documented choice and not specific to this -/// one, but it is stated here because this method destroys state. +/// Absent from [`is_open_control_read`], so it takes a control-plane token, and the node binds +/// loopback-only — a caller on another machine cannot reach it. **The one exception is an operator +/// who sets `DIG_NODE_ALLOW_REMOTE=1`**, which widens every privileged method at once; not specific +/// to this one, but stated here because this method destroys state. +/// +/// It sits on the PAIRED tier rather than the master tier, via +/// [`KNOWN_UNPUBLISHED_CONTROL_METHODS`]. The master tier is tempting for a destructive method and +/// is the WRONG answer here: dig-node#384 exists to put a reset button in the DIG App, and the App +/// holds a paired token. Master-tiering it would make the feature unreachable by the only consumer +/// it was built for — a guard so tight it removes the capability is not a guard, it is a deletion. +/// +/// What actually bounds the damage is the combination this method does enforce: loopback-only, a +/// token, an explicit `confirm: true` on the wire, a refusal while any spend is in flight, and a +/// blast radius that contains no key material and nothing a re-sync cannot rebuild. /// /// # `confirm: true` is required /// @@ -2357,7 +2377,7 @@ async fn wallet_reset_coin_db(ctx: &ControlCtx, id: Value, params: &Value) -> Va return control_error( id, ErrorCode::InvalidParams, - "control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database and re-syncs from chain. Pass params.confirm = true to proceed. No key material is affected.", + "control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database \n and re-syncs from chain. Pass params.confirm = true to proceed. No key \n material is affected.", ); } @@ -5456,21 +5476,6 @@ mod tests { "control.pairing.revoke", "control.chiaPeers.add", "control.chiaPeers.remove", - // dig-node#384. Master-tier because the CONTRACT has not published it yet and this - // gate fails CLOSED for an unrecognised name — and that is the tier it should have - // regardless: it discards the node's cached coin database. - // - // Deliberately NOT added to `KNOWN_UNPUBLISHED_CONTROL_METHODS`. That list exempts a - // method from the strict tier to avoid breaking paired clients that already call it; - // nothing calls this one yet, and exempting a destructive method to spare a client - // that does not exist would trade the guard for nothing. - // - // This entry is therefore load-bearing in BOTH directions. When - // `dig-node-control-interface` publishes the method it must publish it as - // master-tier: publishing it as paired-reachable silently DOWNGRADES it here (the - // `from_name` arm starts matching and answers `false`), and this assertion is what - // catches that. - "control.wallet.resetCoinDb", ] .into_iter() .collect(); @@ -5481,43 +5486,15 @@ mod tests { // And it tracks the CONTRACT, so a method the contract promotes later cannot stay // paired-reachable here just because nobody edited the list above. - // - // # Containment, not equality — and the direction is the whole point - // - // This was `assert_eq!` until dig-node#384 added a served-but-unpublished method. Equality - // makes the two sets identical in BOTH directions, so it forbids the node from being - // STRICTER than the published contract — and "stricter" is the fail-closed answer - // `requires_master_token` deliberately gives an unrecognised name. Under equality the only - // way to green this suite before the contract publishes is to add the method to - // `KNOWN_UNPUBLISHED_CONTROL_METHODS`, which DOWNGRADES a destructive method to the paired - // tier. A lockstep test that can only be satisfied by weakening a guard is worse than no - // test. - // - // Containment keeps every escalation the equality caught: a method the contract promotes - // and this node leaves paired-reachable is missing from `actual` and still fails here. - // What it now permits is the node reserving MORE than the contract requires, which cannot - // grant anyone authority they did not have. let contract: BTreeSet<&str> = ControlMethod::ALL .iter() .filter(|m| m.requires_master_token()) .map(|m| m.name()) .filter(|n| CONTROL_METHODS.contains(n)) .collect(); - let missing: Vec<&&str> = contract.difference(&actual).collect(); - assert!( - missing.is_empty(), - "these methods are master-tier in dig-node-control-interface and are NOT reserved by this node — a paired token could reach them: {missing:?}" - ); - - // The residue, stated so it is a known quantity rather than a silent gap: exactly the - // methods this node reserves that the contract has not published. An entry appearing here - // that is NOT a deliberate unpublished addition means someone reserved a method by - // accident, which breaks paired clients that legitimately call it. - let unpublished: Vec<&&str> = actual.difference(&contract).collect(); assert_eq!( - unpublished, - vec![&"control.wallet.resetCoinDb"], - "the set of master-tier-but-unpublished methods changed; publish it in dig-node-control-interface (as master-tier) and remove it from this expectation" + actual, contract, + "this node's master tier disagrees with dig-node-control-interface" ); } From a57c75bf3fb5fc852354c9c54481427fcc56412a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 11:37:13 -0700 Subject: [PATCH 6/9] fix(wallet): a reset mid-catch-up can no longer mark the emptied replica synced `reset_chain_cache` is atomic, but the catch-up's own writes are separate transactions that nothing serialises against it: `apply_coin_states` per batch, then `complete_catch_up` for the flag. A reset landing mid-catch-up therefore emptied the coins and cleared `initial_sync_complete`, and the in-flight catch-up set the flag again one statement later -- `balance 0, synced true` on a funded wallet, with no attacker involved. The likelier variant is worse: a partial coin set reported as synced reads as a plausible understated balance. `sync_state` now carries a `reset_epoch` the reset increments. A catch-up observes it before its first write and presents it in the terminal statement, which carries `WHERE reset_epoch = ?`; a catch-up that began before a reset cannot complete afterwards and returns `SyncError::ResetDuringCatchUp` so the supervisor runs a fresh one. `SPEC.md` states this as a MUST, so its "until a genuine catch-up re-establishes the flag" sentence is now backed by code. Also repairs nine string literals mangled by lost `\` continuations, four of them user-facing -- the destructive-reset warning and the `NOT CURRENT` staleness line among them. They are `concat!` fragments now, because `cargo fmt --check` cannot see the mangling and `cargo fmt` has reintroduced it elsewhere. --- SPEC.md | 2 +- crates/dig-node-service/src/control.rs | 19 +- crates/dig-node-service/src/control_cli.rs | 21 +- crates/dig-wallet/src/sage/actions.rs | 5 +- crates/dig-wallet/src/sage/db.rs | 112 +++++++++-- crates/dig-wallet/src/sage/rpc.rs | 11 +- crates/dig-wallet/src/sage/sync.rs | 220 +++++++++++++++++++-- 7 files changed, 349 insertions(+), 41 deletions(-) diff --git a/SPEC.md b/SPEC.md index faee4675..b41d1b1c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1631,7 +1631,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | | `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. | | `control.wallet.peak` | — | `peak_height` (`u32` or `null`), `synced` (bool). The node's current chain peak, independent of any address. Its OWN method rather than a field on a balance because a balance reports `peak_height: null` on every `"fallback"`-tier answer by design (§18.7b), so a caller bounding a claimed confirmation could not obtain one from the node that most needs to answer. Prefers the node's own replica and falls back to the chain tier. The chain tier is the node's OWN dialled Chia peers, asked CONCURRENTLY and settled on their AGREEMENT (NC-12): the height is the settled height every credible peer in the sample has passed, and a sample that collapses to one voice, or splits, MUST report `peak_height: null` rather than a repaired number. A node MUST NOT satisfy this read from a single public oracle, and MUST NOT fall through to one when its peers fail to agree — falling through would let one endpoint overrule the peers at exactly the moment corroboration failed, which is the single-source dependency NC-12 exists to remove. `peak_height: null` means UNKNOWN and MUST NOT be read as height zero, which every block is trivially above. `synced` carries EXACTLY its `control.wallet.balance` meaning (§18.7b) and MUST be MEASURED by the same predicate: a replica-served peak reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height`, and a tier with no observable peer height, or a replica with no peak of its own, also answers `synced: false` — neither an unmeasured peer tier nor an unknown replica height can establish currency. A node MUST NOT derive this flag from `initial_sync_complete`, which latches on the first completed catch-up and is cleared only by a backwards chain move: a replica hundreds of blocks behind still satisfies it, so `control.wallet.peak` would report `synced: true` about the same replica `control.wallet.syncStatus` is simultaneously reporting as `syncing`. This is the endpoint a caller uses to bound a claimed confirmation, so the overstatement lands on the read that decides whether money has settled. A chain-tier answer reports `synced: false`, because a height the replica did not produce says nothing about the replica. OPEN read. | -| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | +| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. A catch-up that was ALREADY RUNNING when the reset landed MUST NOT re-establish it: the node MUST record a reset counter that the reset increments, and a catch-up MUST observe that counter before its first batch and present it again in its terminal write, which MUST NOT take effect if the counter has moved. Without that condition the reset and the catch-up are separate transactions that nothing serialises, and the interrupted catch-up marks the emptied — or partially refilled — replica synced one statement later: the same `balance 0, synced true`, or the likelier understated balance from a partial coin set. A catch-up whose completion is refused this way MUST report an error rather than success. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | | `control.wallet.broadcast` | `signed_bundle_hex` (lowercase hex, optionally `0x`-prefixed, of a chia `Streamable` `SpendBundle`) | `accepted` (bool), `transaction_id` (lowercase 64-hex or `null`), `rejection` (string or `null`). Pushes an ALREADY-SIGNED bundle. **§908: this method signs nothing and is never given anything it could sign with** — there is no key, seed, phrase or unsigned-plan parameter here and none may be added; on this surface the node's role is to read chain state and relay what somebody else signed. The node's OWN automated spends (§23, §25) never transit this method and are not reachable from it. A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting `{accepted:false, rejection}`; failing to REACH a mempool is `WALLET_READ_FAILED`, and a node with no chain source is `WALLET_NO_CHAIN_SOURCE`. These MUST NOT be collapsed: the first says build a different bundle, the second says retry this one. `accepted:true` reports mempool admission ONLY and is NOT evidence anything reached a block — a caller MUST NOT record an outcome from it; only a buried confirmation of the created coin is evidence. `INVALID_PARAMS` on hex that is not a streamable `SpendBundle`, refused BEFORE any network call. A bundle requiring a signature from any key the NODE custodies — whatever puzzle wraps the coin — while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off is `WALLET_NODE_SPEND_DISABLED`, also refused before any network call — the node relays what somebody ELSE signed, and it signs on request, so whether the node could have signed it is CHECKED rather than assumed. TOKEN-GATED (not an open read). | | `control.chiaPeers.add` | `ip` (a bare IPv4/IPv6 literal — no brackets, no port, no hostname; the standard full-node port is assumed) | `{added: true, ip, port, corroboration_bypassed, notice}`. TRUSTS a Chia full node: it writes the `user_managed` peer row that is the ONLY way to reach `PeerTrust::Operator`, the trust level whose answers may drive catch-up, rollback and the `initial_sync_complete` flag WITHOUT a quorum. Every other peer is `Discovered` and must be corroborated by independently chosen peers first (§18.16). `ip` is CANONICALISED on the way in (`IpAddr` display form — RFC 5952 lowercase compressed for v6) and echoed back in that form, so one host is one entry however it was spelled; `INVALID_PARAMS` on anything that is not a bare literal, refused before any write. `corroboration_bypassed` is the RESULTING trust state, NOT a restatement of the request: a node MUST report `false` where the entry did not end up trusted — adding a peer that was BANNED un-bans it and confers no bypass. `notice` carries the cost as a sentence and MUST be non-empty, name the corroboration bypass, and be rendered VERBATIM; a client MUST NOT paraphrase, truncate or suppress it. The wording MUST authorise only **a node the operator runs themselves** — never vouching or recommending, which widen the case past what justifies the entry's unbounded authority. Idempotent — re-adding a known peer succeeds and un-bans it. A node MUST serve this from the SAME peer store its wallet replica consults. **MASTER-TOKEN TIER** (`ControlMethod::requires_master_token`): a paired token MUST be refused, because the entry outlives the token that wrote it and `pairing.revoke` removes no peer row. | | `control.chiaPeers.list` | — | `{peers: [{ip, port, peak_height, user_managed, banned}]}` — every tracked Chia peer: TRUSTED, DISCOVERED **AND BANNED** alike. `user_managed` tells the trusted set from the discovered one and MUST be reported rather than filtered on: a list showing only the trusted set would let a person conclude the node talks to nobody else. `banned` MUST likewise be reported and its rows MUST NOT be omitted — this is the ONLY enumeration of the ban set, and a blocklist a person cannot read is a blocklist they cannot correct. This enumeration is DISTINCT from the dialling read, which excludes banned peers; a node MUST NOT serve both from one relaxed query. `peak_height` is `null` where the node holds no telemetry for that peer yet — `null` means UNOBSERVABLE and MUST NEVER be reported as `0`, which would render an unpolled peer as one stalled at genesis. A reported height is that peer's CLAIM, never a verified fact, and MUST NOT be aggregated into a chain position (NC-12). TOKEN-GATED at the ORDINARY tier — a read grants nothing that outlives the token, and a paired client must stay able to show the operator the trust state it is subject to. | diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 1e56b85d..adc104bc 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -2377,7 +2377,11 @@ async fn wallet_reset_coin_db(ctx: &ControlCtx, id: Value, params: &Value) -> Va return control_error( id, ErrorCode::InvalidParams, - "control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database \n and re-syncs from chain. Pass params.confirm = true to proceed. No key \n material is affected.", + concat!( + "control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached ", + "coin database and re-syncs from chain. Pass params.confirm = true to ", + "proceed. No key material is affected." + ), ); } @@ -2904,7 +2908,13 @@ const _: () = assert!( fn reserve_batch_refusal(len: usize) -> Option { (len > MAX_RESERVE_COIN_IDS).then(|| { format!( - "params.coin_ids holds {len} ids, above the {MAX_RESERVE_COIN_IDS} this node will reserve in one call. Split the request; a bundle that legitimately needs more inputs than this could not fit in a block anyway" + concat!( + "params.coin_ids holds {len} ids, above the {MAX_RESERVE_COIN_IDS} this ", + "node will reserve in one call. Split the request; a bundle that ", + "legitimately needs more inputs than this could not fit in a block anyway" + ), + len = len, + MAX_RESERVE_COIN_IDS = MAX_RESERVE_COIN_IDS ) }) } @@ -4190,7 +4200,10 @@ mod tests { ); assert!( !is_open_control_read("control.wallet.arrivals"), - "the arrival cursor names this node's own watched puzzle hashes to a caller that supplied nothing, so it must stay behind the control token" + concat!( + "the arrival cursor names this node's own watched puzzle hashes to a caller ", + "that supplied nothing, so it must stay behind the control token" + ) ); } diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index dc9b7c0f..91132d49 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -756,7 +756,10 @@ fn summarize(method: &str, result: &Value) -> String { // panic on one — so every field is read with `get`, and a coin record short of a field // prints an honest unknown instead of aborting the CLI. "control.wallet.resetCoinDb" => format!( - "coin database reset · {} coin(s) and {} staged discovery row(s) discarded · the replica is no longer authoritative and will re-sync from chain", + concat!( + "coin database reset · {} coin(s) and {} staged discovery row(s) discarded ", + "· the replica is no longer authoritative and will re-sync from chain" + ), amount(&result["coins_dropped"]), amount(&result["staged_dropped"]), ), @@ -1447,17 +1450,19 @@ fn balance_freshness(result: &Value) -> String { None => "current".to_string(), }; } - match ( - result["peak_height"].as_u64(), - result["stale_by"].as_u64(), - ) { + match (result["peak_height"].as_u64(), result["stale_by"].as_u64()) { (Some(h), Some(0)) => format!("NOT CURRENT — as of height {h}, level with the network"), - (Some(h), Some(n)) => format!("NOT CURRENT — as of height {h}, {n} blocks behind the network"), + (Some(h), Some(n)) => { + format!("NOT CURRENT — as of height {h}, {n} blocks behind the network") + } (Some(h), None) => { format!("NOT CURRENT — as of height {h}, distance from the network unknown") } - (None, _) => "NOT CURRENT — this node cannot say what height this reflects; the figure may not reflect the wallet" - .to_string(), + (None, _) => concat!( + "NOT CURRENT — this node cannot say what height this reflects; the figure may ", + "not reflect the wallet" + ) + .to_string(), } } diff --git a/crates/dig-wallet/src/sage/actions.rs b/crates/dig-wallet/src/sage/actions.rs index 365972b0..1ae80900 100644 --- a/crates/dig-wallet/src/sage/actions.rs +++ b/crates/dig-wallet/src/sage/actions.rs @@ -144,7 +144,10 @@ mod derivation_floor_tests { assert_eq!( down.unhardened_floor, Some(500), - "a request below the floor is a no-op, and the response must say so by reporting 500 — echoing back the requested 5 is the lie this ticket removes" + concat!( + "a request below the floor is a no-op, and the response must say so by ", + "reporting 500 — echoing back the requested 5 is the lie this ticket removes" + ) ); } diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index e80c1b23..d058bb89 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -24,6 +24,21 @@ use super::arrivals::{classify, Arrival, ArrivalBaseline, Verdict}; use super::coverage::CoveredSet; use super::sync::AdmittedPeak; +/// How many times this replica's chain cache has been RESET (dig-node#454). +/// +/// The reset ([`WalletDb::reset_chain_cache`]) empties the coin tables and clears +/// `initial_sync_complete` in ONE transaction. A catch-up's own writes are separate transactions +/// and nothing serialises them against it, so a catch-up that started before a reset could +/// finish afterwards and re-declare the emptied — or partially refilled — replica authoritative. +/// That is `balance 0, synced true` on a funded wallet, or the likelier understated balance. +/// +/// The defence is this counter. A catch-up observes it before its first batch and presents it +/// again at the end; the terminal write carries `WHERE reset_epoch = ?`, so an interrupted +/// catch-up's completion simply does not land. It is a COUNTER rather than a flag because +/// "has a reset happened since I started" cannot be answered by a boolean a second reset clears. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResetEpoch(i64); + /// A handle to the local wallet database. #[derive(Clone)] pub struct WalletDb { @@ -398,7 +413,8 @@ CREATE TABLE IF NOT EXISTS sync_state ( header_hash TEXT, initial_sync_complete INTEGER NOT NULL DEFAULT 0, arrival_baseline_height INTEGER, - covered_puzzle_hashes TEXT + covered_puzzle_hashes TEXT, + reset_epoch INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO sync_state (id, peak_height, header_hash, initial_sync_complete) VALUES (0, NULL, NULL, 0); @@ -652,6 +668,11 @@ const ADD_COLUMN_MIGRATIONS: &[&str] = &[ // on what it said (dig-node#383). NULL means "not examined yet", which is the right reading // for every row written before this column existed: they are re-examined once and then settle. "ALTER TABLE coins ADD COLUMN attribution_examined INTEGER", + // dig-node#454. Counts the coin-database RESETS this replica has undergone, so a catch-up + // that began before one cannot mark the emptied replica synced afterwards. An existing DB + // arrives at the `0` default, which is right: it has been reset zero times, and the first + // reset moves it to 1 exactly as it would on a fresh install. + "ALTER TABLE sync_state ADD COLUMN reset_epoch INTEGER NOT NULL DEFAULT 0", ]; // ---- one-shot data-migration ladder --------------------------------------- @@ -811,10 +832,18 @@ async fn evict_to_budget( ) -> sqlx::Result<()> { let sql = match table { "chain_read_cache" => { - "DELETE FROM chain_read_cache WHERE coin_id IN (SELECT coin_id FROM chain_read_cache ORDER BY last_used_at ASC, coin_id ASC LIMIT MAX(0, (SELECT COUNT(*) FROM chain_read_cache) - ?))" + concat!( + "DELETE FROM chain_read_cache WHERE coin_id IN (SELECT coin_id FROM ", + "chain_read_cache ORDER BY last_used_at ASC, coin_id ASC ", + "LIMIT MAX(0, (SELECT COUNT(*) FROM chain_read_cache) - ?))" + ) } _ => { - "DELETE FROM chain_spend_cache WHERE coin_id IN (SELECT coin_id FROM chain_spend_cache ORDER BY last_used_at ASC, coin_id ASC LIMIT MAX(0, (SELECT COUNT(*) FROM chain_spend_cache) - ?))" + concat!( + "DELETE FROM chain_spend_cache WHERE coin_id IN (SELECT coin_id FROM ", + "chain_spend_cache ORDER BY last_used_at ASC, coin_id ASC ", + "LIMIT MAX(0, (SELECT COUNT(*) FROM chain_spend_cache) - ?))" + ) } }; sqlx::query(sql).bind(budget).execute(pool).await?; @@ -1287,9 +1316,10 @@ impl WalletDb { /// Read the current sync state. pub async fn sync_state(&self) -> sqlx::Result { - let row = sqlx::query( - "SELECT peak_height, header_hash, initial_sync_complete, covered_puzzle_hashes FROM sync_state WHERE id = 0", - ) + let row = sqlx::query(concat!( + "SELECT peak_height, header_hash, initial_sync_complete, ", + "covered_puzzle_hashes FROM sync_state WHERE id = 0" + )) .fetch_one(&self.pool) .await?; Ok(SyncState { @@ -1302,6 +1332,17 @@ impl WalletDb { }) } + /// The reset counter this replica currently sits at — see [`ResetEpoch`]. + /// + /// Read at the START of a catch-up and presented back at its end. Reading it later would + /// defeat the guard entirely: the value would already include the reset being defended against. + pub async fn reset_epoch(&self) -> sqlx::Result { + let epoch: i64 = sqlx::query_scalar("SELECT reset_epoch FROM sync_state WHERE id = 0") + .fetch_one(&self.pool) + .await?; + Ok(ResetEpoch(epoch)) + } + /// Whether the initial catch-up has completed (the routing gate, B.6). pub async fn is_synced(&self) -> sqlx::Result { Ok(self.sync_state().await?.initial_sync_complete) @@ -1402,9 +1443,22 @@ impl WalletDb { /// Clearing the flag (a reorg, a backwards move) deliberately does NOT disarm the baseline — /// [`Self::rollback_above`] walks it back to the fork instead, so the coins that were undone /// become eligible again and nothing below the fork does. - pub async fn complete_catch_up(&self, replay: &CatchUpReplay) -> sqlx::Result<()> { + /// # An in-flight catch-up cannot outlive a reset (dig-node#454) + /// + /// The write is conditioned on the [`ResetEpoch`] the caller observed before it began. If a + /// reset landed in between, the counter has moved, no row matches, and this returns + /// `Ok(false)` having written nothing — the replica stays non-authoritative and reads fall + /// back to the chain tier until a catch-up that ran entirely after the reset finishes. + /// + /// Returns whether the completion was RECORDED. A caller that ignores the answer re-opens the + /// hole, which is why it is a `bool` rather than a silent no-op. + pub async fn complete_catch_up_unless_reset( + &self, + replay: &CatchUpReplay, + observed: ResetEpoch, + ) -> sqlx::Result { let mut tx = self.pool.begin().await?; - sqlx::query( + let result = sqlx::query( "UPDATE sync_state SET peak_height = ?, header_hash = ?, @@ -1414,15 +1468,31 @@ impl WalletDb { arrival_baseline_height, MAX(?, COALESCE((SELECT MAX(created_height) FROM coins), 0)) ) - WHERE id = 0", + WHERE id = 0 AND reset_epoch = ?", ) .bind(i64::from(replay.peak_height())) .bind(replay.header_hash()) .bind(replay.covered().to_storage()) .bind(i64::from(replay.peak_height())) + .bind(observed.0) .execute(&mut *tx) .await?; + let recorded = result.rows_affected() == 1; tx.commit().await?; + Ok(recorded) + } + + /// [`Self::complete_catch_up_unless_reset`] against the CURRENT epoch — i.e. with the + /// reset guard trivially satisfied. + /// + /// Test-only on purpose. A fixture that is not exercising the reset race wants to place a + /// completed catch-up in the database and say nothing about epochs; production must always + /// present the epoch it observed BEFORE its replay, because that is the only value that can + /// notice a reset landing in between. + #[cfg(test)] + pub async fn complete_catch_up(&self, replay: &CatchUpReplay) -> sqlx::Result<()> { + let epoch = self.reset_epoch().await?; + self.complete_catch_up_unless_reset(replay, epoch).await?; Ok(()) } @@ -2669,7 +2739,12 @@ impl std::fmt::Display for ResetRefusal { match self { Self::SpendInFlight { reservations } => write!( f, - "refused: {reservations} coin reservation(s) are in flight. Resetting now would wipe the coins an unconfirmed spend was built on. Wait for them to confirm or expire, then retry." + concat!( + "refused: {reservations} coin reservation(s) are in flight. Resetting ", + "now would wipe the coins an unconfirmed spend was built on. Wait for ", + "them to confirm or expire, then retry." + ), + reservations = reservations ), } } @@ -2780,9 +2855,14 @@ impl WalletDb { } // The clause that makes the whole operation safe. See the doc above. + // `reset_epoch + 1` is what stops an ALREADY-RUNNING catch-up from undoing this + // transaction one statement later (dig-node#454). The delete and the flag clear are + // atomic with respect to a crash, but atomicity says nothing about a concurrent writer + // that observed the pre-reset world; the counter is what makes such a writer detectable. sqlx::query( "UPDATE sync_state - SET initial_sync_complete = 0, covered_puzzle_hashes = '' + SET initial_sync_complete = 0, covered_puzzle_hashes = '', + reset_epoch = reset_epoch + 1 WHERE id = 0", ) .execute(&mut *tx) @@ -5121,7 +5201,10 @@ mod tests { assert!( !db.is_synced().await.unwrap(), - "an emptied replica that still calls itself synced answers `balance 0, synced true` on a funded wallet — the whole risk of this feature" + concat!( + "an emptied replica that still calls itself synced answers `balance 0, ", + "synced true` on a funded wallet — the whole risk of this feature" + ) ); } @@ -5153,7 +5236,10 @@ mod tests { assert!( db.is_synced().await.unwrap(), - "a refusal must leave the flag alone — a half-applied reset is the state this refusal exists to prevent" + concat!( + "a refusal must leave the flag alone — a half-applied reset is the state ", + "this refusal exists to prevent" + ) ); assert_eq!( db.coins_by_ids(&["c1".to_string()]).await.unwrap().len(), diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index eee97001..5adbb728 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -5680,7 +5680,10 @@ mod tests { assert_eq!( ids, ["real-dig"], - "the confirmed $DIG coin: not an empty set (the #306 under-report), and not the hinted XCH or foreign CAT (the #2879 over-report)" + concat!( + "the confirmed $DIG coin: not an empty set (the #306 under-report), and not ", + "the hinted XCH or foreign CAT (the #2879 over-report)" + ) ); // `pending-dig` has no created height and the default filter mode excludes unconfirmed // coins — pinned here so a later widening of that filter is a deliberate change rather @@ -5733,7 +5736,11 @@ mod tests { .unwrap(); assert_eq!( dig.count, 1, - "the ONE confirmed $DIG coin — `pending-dig` has no created height and is not spendable, so a count of 2 would mean unconfirmed value was offered to a spend" + concat!( + "the ONE confirmed $DIG coin — `pending-dig` has no created height and is ", + "not spendable, so a count of 2 would mean unconfirmed value was offered ", + "to a spend" + ) ); let xch = be diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index 99eaf71f..aed3639c 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -74,6 +74,19 @@ pub enum SyncError { /// A catch-up was attempted over a peer this node merely DISCOVERED. Refused: only an /// operator-chosen peer may make the local replica authoritative (see [`PeerTrust`]). UntrustedPeer, + /// The coin database was RESET while this catch-up was in flight, so everything the + /// catch-up replayed was discarded before it could finish. + /// + /// Refused rather than completed. The reset empties `coins` and clears + /// `initial_sync_complete` in one transaction, but the catch-up's own writes are separate + /// transactions that are not serialised against it — so a terminal statement arriving + /// afterwards would re-declare the emptied (or partially refilled) replica authoritative, + /// and `routing::route` would answer money reads out of it: `balance 0, synced true` on a + /// funded wallet, or the likelier understated balance from a partial set. + /// + /// The session is dropped and the supervisor runs a fresh catch-up, which is the only thing + /// that can honestly re-establish the flag. + ResetDuringCatchUp, /// A catch-up ran past [`MAX_CATCH_UP_BATCHES`] without the peer reporting `is_finished`. CatchUpTooLong { /// The bound that was exceeded. @@ -400,6 +413,13 @@ impl std::fmt::Display for SyncError { "refusing to catch up over a discovered peer (only an operator-chosen peer may \ make the local replica authoritative)" ), + SyncError::ResetDuringCatchUp => write!( + f, + concat!( + "the coin database was reset while this catch-up was in flight; its ", + "result was discarded rather than used to mark the emptied replica synced" + ) + ), SyncError::CatchUpTooLong { max } => { write!(f, "catch-up exceeded {max} batches without finishing") } @@ -1095,6 +1115,18 @@ pub async fn initial_sync_with_authority( all.dedup(); all }; + // Observed before this catch-up's FIRST WRITE and presented again in its terminal + // statement, so a reset landing at any point during the replay moves the counter away + // from this value and the completion cannot land (dig-node#454). Reading it at the END + // would defeat the guard entirely: the value would already include the reset. + // + // Taken lazily, at the first write rather than before the first REQUEST, for two + // reasons. It is equally sound — nothing has been written yet either way, so a reset + // that lands before this point leaves a replay that runs wholly after it, which is + // exactly the catch-up entitled to re-establish the flag. And it keeps this function + // free of database work before its first peer round trip, where the only armed timer + // is a caller's outer bound. + let mut epoch_at_first_write: Option = None; let mut previous_height: Option = None; let mut header_hash = genesis_challenge; events.publish(SyncEvent::Start { @@ -1149,6 +1181,9 @@ pub async fn initial_sync_with_authority( } } + if epoch_at_first_write.is_none() { + epoch_at_first_write = Some(db.reset_epoch().await?); + } apply_coin_states(db, &respond.coin_states, &subscribed, derived).await?; events.publish(SyncEvent::PuzzleBatchSynced); @@ -1157,17 +1192,29 @@ pub async fn initial_sync_with_authority( // baseline are armed together from this response's own values. Splitting them is how // the baseline came to be armable by a caller that had replayed nothing // (dig_ecosystem#2548) -- see `WalletDb::complete_catch_up`. - db.complete_catch_up(&CatchUpReplay::finished_at( - authority.ceiling(), - respond.height, - hex::encode(respond.header_hash), - // Coverage recorded as ADDRESSES, matching every reader of - // `covered_puzzle_hashes` (`covers` is a containment test over the wallet's own - // hashes). Recording the union here would make the replica claim coverage of a - // set it does not answer for. - &addresses, - )?) - .await?; + let recorded = db + .complete_catch_up_unless_reset( + &CatchUpReplay::finished_at( + authority.ceiling(), + respond.height, + hex::encode(respond.header_hash), + // Coverage recorded as ADDRESSES, matching every reader of + // `covered_puzzle_hashes` (`covers` is a containment test over the wallet's own + // hashes). Recording the union here would make the replica claim coverage of a + // set it does not answer for. + &addresses, + )?, + epoch_at_first_write.expect( + "the epoch is read before the first write, which precedes any terminal", + ), + ) + .await?; + if !recorded { + // The coin database was reset while this replay was in flight, so everything + // it wrote was discarded. Reporting success here would be the money lie: the + // flag would declare an emptied or partially refilled table authoritative. + return Err(SyncError::ResetDuringCatchUp); + } return Ok(()); } // Continue from where this batch ended. @@ -2995,12 +3042,159 @@ mod tests { assert_eq!( reads_for_one_empty_frame(true).await, 1, - "control: an APPLIED frame runs the pass, so the fixture really does present a candidate row that costs a read" + concat!( + "control: an APPLIED frame runs the pass, so the fixture really does ", + "present a candidate row that costs a read" + ) ); assert_eq!( reads_for_one_empty_frame(false).await, 0, - "a frame dropped before any database write must schedule no work at all; running the pass after it lets a peer buy a whole-replica scan for an empty frame it already knows will be refused" + concat!( + "a frame dropped before any database write must schedule no work at all; ", + "running the pass after it lets a peer buy a whole-replica scan for an ", + "empty frame it already knows will be refused" + ) + ); + } + + // --------------------------------------------------------------------------------------- + // A reset that lands MID-CATCH-UP must not be overwritten by the catch-up it interrupted + // (dig-node#454). The reset transaction is atomic; the catch-up's two writes are not + // serialised against it, so the in-flight catch-up's terminal statement used to re-set + // `initial_sync_complete` over the table the reset had just emptied. + // --------------------------------------------------------------------------------------- + + /// A peer that answers one ordinary batch, then — standing in for the user pressing reset + /// while that batch is being applied — RESETS the coin database before answering the + /// terminal batch. + /// + /// The reset is driven from inside the peer double because that is the only place in this + /// test that runs BETWEEN the catch-up's two writes. `carries_coins` selects which variant + /// the terminal answer produces: an empty one (the zero-balance lie) or a single coin (the + /// partial-set lie, which reads as a plausible understated balance and is the one a user + /// would actually hit). + struct ResetsBeforeFinishing { + db: WalletDb, + calls: std::sync::atomic::AtomicUsize, + carries_coins: bool, + } + + #[async_trait::async_trait] + impl PuzzleStateSource for ResetsBeforeFinishing { + async fn request_puzzle_state( + &self, + puzzle_hashes: Vec, + _previous_height: Option, + _header_hash: Bytes32, + ) -> Result { + let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + // An ordinary, non-terminal batch that really transfers state: the coin below is + // what the reset then deletes, so the fixture has something to lose. + return Ok(RespondPuzzleState { + puzzle_hashes, + coin_states: vec![state(coin(1, OWNED, 700), Some(50), None)], + height: 100, + header_hash: Bytes32::new([9; 32]), + is_finished: false, + }); + } + self.db + .reset_chain_cache(0) + .await + .expect("the reset itself succeeds") + .expect("no spend is in flight, so it cannot refuse"); + Ok(RespondPuzzleState { + puzzle_hashes, + coin_states: if self.carries_coins { + vec![state(coin(2, OWNED, 300), Some(60), None)] + } else { + vec![] + }, + height: 6_000_000, + header_hash: Bytes32::new([9; 32]), + is_finished: true, + }) + } + } + + async fn catch_up_interrupted_by_a_reset( + carries_coins: bool, + ) -> (WalletDb, Result<(), SyncError>) { + let db = WalletDb::open_in_memory().await.unwrap(); + let events = EventBus::default(); + let outcome = initial_sync_with_authority( + &ResetsBeforeFinishing { + db: db.clone(), + calls: std::sync::atomic::AtomicUsize::new(0), + carries_coins, + }, + &db, + vec![Bytes32::new([OWNED; 32])], + Bytes32::new([1; 32]), + "1.2.3.4", + &events, + WriteAuthority::Operator, + &DerivedCats::default(), + ) + .await; + (db, outcome) + } + + /// **Proves (the money lie, zero variant):** a reset landing mid-catch-up leaves the coin + /// table EMPTY, and the catch-up that was already running must not then declare that empty + /// table authoritative — `balance 0, synced true` on a funded wallet. + /// + /// Asserted on the OBSERVABLE PAIR a caller reads, not on an internal counter: the pair is + /// what lies, and a guard that moved elsewhere would still have to keep this pair honest. + #[tokio::test] + async fn a_reset_mid_catch_up_is_not_overwritten_into_an_empty_authoritative_replica() { + let (db, outcome) = catch_up_interrupted_by_a_reset(false).await; + + assert_eq!( + db.balance(None).await.unwrap(), + 0, + "the reset emptied the coins" + ); + assert!( + !db.is_synced().await.unwrap(), + "an emptied replica reported as synced answers `balance 0, synced true` on a funded \ + wallet: reads route to the DB tier and find nothing" + ); + assert!( + matches!(outcome, Err(SyncError::ResetDuringCatchUp)), + "the catch-up must report that its work was discarded so the supervisor runs a fresh \ + one, not return Ok over a replica it did not establish; got {outcome:?}" + ); + } + + /// **Proves (the money lie, PARTIAL variant — the likelier one):** the reset lands after some + /// of the replay has been applied, so the table holds a plausible-looking SUBSET. Reported as + /// synced, that is an understated balance rather than an obvious zero, and far harder to + /// notice. + /// + /// This second case exists because the zero variant alone cannot distinguish "the flag is + /// refused" from "the flag happens to be false because nothing was written". + #[tokio::test] + async fn a_reset_mid_catch_up_is_not_overwritten_into_a_partial_authoritative_replica() { + let (db, outcome) = catch_up_interrupted_by_a_reset(true).await; + + assert_eq!( + db.balance(None).await.unwrap(), + 300, + "control: the post-reset batch really did land, so the table holds a SUBSET of the \ + wallet's coins rather than nothing — this is the state that would read as an \ + understated balance" + ); + assert!( + !db.is_synced().await.unwrap(), + "a partial coin set reported as synced is an understated balance presented as \ + complete" + ); + assert!( + matches!(outcome, Err(SyncError::ResetDuringCatchUp)), + "got {outcome:?}" ); } From f73e1614995f10848768710ce3f3cef6831897f1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 11:52:47 -0700 Subject: [PATCH 7/9] fix(wallet): guard the oracle-tier latch against a reset too, and repair more mangled strings The point-read refresh (`refresh_tracked_coins`) is the other writer of `initial_sync_complete`, and it races the coin-database reset exactly as the catch-up did: it fetches rows, the user resets, and it latches the flag over whatever survived. It now observes the same `reset_epoch` before its own first write and latches coverage plus the flag in one guarded statement. That is what makes the SPEC sentence true as written. Without it the clause "reads fall back until a genuine catch-up re-establishes the flag" would still over-claim, because a refresh could re-establish it a moment after a reset. Seven further string literals across cat_discovery, fallback, quorum and sync_supervisor carried the same lost-backslash mangling; three of those are operator-facing sync log lines. --- Cargo.lock | 4 +- SPEC.md | 5 +- crates/dig-wallet/Cargo.toml | 2 +- crates/dig-wallet/src/sage/cat_discovery.rs | 5 +- crates/dig-wallet/src/sage/db.rs | 99 +++++++++++++++++++ crates/dig-wallet/src/sage/fallback.rs | 14 ++- crates/dig-wallet/src/sage/quorum.rs | 14 ++- crates/dig-wallet/src/sage/rpc.rs | 17 +++- crates/dig-wallet/src/sage/sync_supervisor.rs | 15 ++- 9 files changed, 160 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 461762e4..4d70aa60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.191.0" +version = "0.192.0" dependencies = [ "async-trait", "axum", @@ -3335,7 +3335,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.44.0" +version = "0.45.0" dependencies = [ "async-trait", "axum", diff --git a/SPEC.md b/SPEC.md index b41d1b1c..734e40bb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1631,7 +1631,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | | `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. | | `control.wallet.peak` | — | `peak_height` (`u32` or `null`), `synced` (bool). The node's current chain peak, independent of any address. Its OWN method rather than a field on a balance because a balance reports `peak_height: null` on every `"fallback"`-tier answer by design (§18.7b), so a caller bounding a claimed confirmation could not obtain one from the node that most needs to answer. Prefers the node's own replica and falls back to the chain tier. The chain tier is the node's OWN dialled Chia peers, asked CONCURRENTLY and settled on their AGREEMENT (NC-12): the height is the settled height every credible peer in the sample has passed, and a sample that collapses to one voice, or splits, MUST report `peak_height: null` rather than a repaired number. A node MUST NOT satisfy this read from a single public oracle, and MUST NOT fall through to one when its peers fail to agree — falling through would let one endpoint overrule the peers at exactly the moment corroboration failed, which is the single-source dependency NC-12 exists to remove. `peak_height: null` means UNKNOWN and MUST NOT be read as height zero, which every block is trivially above. `synced` carries EXACTLY its `control.wallet.balance` meaning (§18.7b) and MUST be MEASURED by the same predicate: a replica-served peak reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height`, and a tier with no observable peer height, or a replica with no peak of its own, also answers `synced: false` — neither an unmeasured peer tier nor an unknown replica height can establish currency. A node MUST NOT derive this flag from `initial_sync_complete`, which latches on the first completed catch-up and is cleared only by a backwards chain move: a replica hundreds of blocks behind still satisfies it, so `control.wallet.peak` would report `synced: true` about the same replica `control.wallet.syncStatus` is simultaneously reporting as `syncing`. This is the endpoint a caller uses to bound a claimed confirmation, so the overstatement lands on the read that decides whether money has settled. A chain-tier answer reports `synced: false`, because a height the replica did not produce says nothing about the replica. OPEN read. | -| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. A catch-up that was ALREADY RUNNING when the reset landed MUST NOT re-establish it: the node MUST record a reset counter that the reset increments, and a catch-up MUST observe that counter before its first batch and present it again in its terminal write, which MUST NOT take effect if the counter has moved. Without that condition the reset and the catch-up are separate transactions that nothing serialises, and the interrupted catch-up marks the emptied — or partially refilled — replica synced one statement later: the same `balance 0, synced true`, or the likelier understated balance from a partial coin set. A catch-up whose completion is refused this way MUST report an error rather than success. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | +| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. No sync pass that was ALREADY RUNNING when the reset landed may re-establish it. The node MUST record a reset counter that the reset increments in that same transaction; every writer of `initial_sync_complete` — the address-history catch-up and the oracle-tier point-read refresh alike — MUST observe that counter BEFORE its own first write and present it again in the statement that sets the flag, which MUST NOT take effect if the counter has moved. Without that condition the reset and the sync pass are separate transactions that nothing serialises, and the interrupted pass marks the emptied — or partially refilled — replica synced one statement later: the same `balance 0, synced true`, or the likelier understated balance from a partial coin set. A sync pass whose completion is refused this way MUST report an error rather than success, so a fresh pass runs. A pass that began wholly AFTER the reset is unaffected and re-establishes the flag normally. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | | `control.wallet.broadcast` | `signed_bundle_hex` (lowercase hex, optionally `0x`-prefixed, of a chia `Streamable` `SpendBundle`) | `accepted` (bool), `transaction_id` (lowercase 64-hex or `null`), `rejection` (string or `null`). Pushes an ALREADY-SIGNED bundle. **§908: this method signs nothing and is never given anything it could sign with** — there is no key, seed, phrase or unsigned-plan parameter here and none may be added; on this surface the node's role is to read chain state and relay what somebody else signed. The node's OWN automated spends (§23, §25) never transit this method and are not reachable from it. A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting `{accepted:false, rejection}`; failing to REACH a mempool is `WALLET_READ_FAILED`, and a node with no chain source is `WALLET_NO_CHAIN_SOURCE`. These MUST NOT be collapsed: the first says build a different bundle, the second says retry this one. `accepted:true` reports mempool admission ONLY and is NOT evidence anything reached a block — a caller MUST NOT record an outcome from it; only a buried confirmation of the created coin is evidence. `INVALID_PARAMS` on hex that is not a streamable `SpendBundle`, refused BEFORE any network call. A bundle requiring a signature from any key the NODE custodies — whatever puzzle wraps the coin — while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off is `WALLET_NODE_SPEND_DISABLED`, also refused before any network call — the node relays what somebody ELSE signed, and it signs on request, so whether the node could have signed it is CHECKED rather than assumed. TOKEN-GATED (not an open read). | | `control.chiaPeers.add` | `ip` (a bare IPv4/IPv6 literal — no brackets, no port, no hostname; the standard full-node port is assumed) | `{added: true, ip, port, corroboration_bypassed, notice}`. TRUSTS a Chia full node: it writes the `user_managed` peer row that is the ONLY way to reach `PeerTrust::Operator`, the trust level whose answers may drive catch-up, rollback and the `initial_sync_complete` flag WITHOUT a quorum. Every other peer is `Discovered` and must be corroborated by independently chosen peers first (§18.16). `ip` is CANONICALISED on the way in (`IpAddr` display form — RFC 5952 lowercase compressed for v6) and echoed back in that form, so one host is one entry however it was spelled; `INVALID_PARAMS` on anything that is not a bare literal, refused before any write. `corroboration_bypassed` is the RESULTING trust state, NOT a restatement of the request: a node MUST report `false` where the entry did not end up trusted — adding a peer that was BANNED un-bans it and confers no bypass. `notice` carries the cost as a sentence and MUST be non-empty, name the corroboration bypass, and be rendered VERBATIM; a client MUST NOT paraphrase, truncate or suppress it. The wording MUST authorise only **a node the operator runs themselves** — never vouching or recommending, which widen the case past what justifies the entry's unbounded authority. Idempotent — re-adding a known peer succeeds and un-bans it. A node MUST serve this from the SAME peer store its wallet replica consults. **MASTER-TOKEN TIER** (`ControlMethod::requires_master_token`): a paired token MUST be refused, because the entry outlives the token that wrote it and `pairing.revoke` removes no peer row. | | `control.chiaPeers.list` | — | `{peers: [{ip, port, peak_height, user_managed, banned}]}` — every tracked Chia peer: TRUSTED, DISCOVERED **AND BANNED** alike. `user_managed` tells the trusted set from the discovered one and MUST be reported rather than filtered on: a list showing only the trusted set would let a person conclude the node talks to nobody else. `banned` MUST likewise be reported and its rows MUST NOT be omitted — this is the ONLY enumeration of the ban set, and a blocklist a person cannot read is a blocklist they cannot correct. This enumeration is DISTINCT from the dialling read, which excludes banned peers; a node MUST NOT serve both from one relaxed query. `peak_height` is `null` where the node holds no telemetry for that peer yet — `null` means UNOBSERVABLE and MUST NEVER be reported as `0`, which would render an unpolled peer as one stalled at genesis. A reported height is that peer's CLAIM, never a verified fact, and MUST NOT be aggregated into a chain position (NC-12). TOKEN-GATED at the ORDINARY tier — a read grants nothing that outlives the token, and a paired client must stay able to show the operator the trust state it is subject to. | @@ -4992,7 +4992,8 @@ Beyond the boundary, the supervisor MUST hold all four of the following for an o blocks, however many individually-legal frames they arrived in. * **Fail closed on any backwards move.** When a rollback is applied, or the update's height is below the current peak, `initial_sync_complete` MUST be cleared. Wallet-scoped reads then route to the fallback - tier (§18.7) until a genuine catch-up re-establishes the flag. Without this a single frame makes a funded + tier (§18.7) until a later sync pass re-establishes the flag — an address-history catch-up, or the + oracle-tier point-read refresh, which is the other writer of it. Without this a single frame makes a funded wallet report `balance 0` with `phase: synced`. * **A monotonic replica peak.** `new_peak_wallet` MUST only ADVANCE `sync_state.peak_height`; a backwards claim is refused. That height bounds a claimed confirmation on an OPEN read, so a peer able to lower it diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index d59952f5..ac87a947 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.44.0" +version = "0.45.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index dd668c0a..ef4c499f 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -1001,7 +1001,10 @@ mod tests { // any number above one means the defect is back. assert_eq!( second, 1, - "a second pass may read only the row that has never been read, and must re-read none of the rows it already tried" + concat!( + "a second pass may read only the row that has never been read, and must ", + "re-read none of the rows it already tried" + ) ); assert!( !db.all_coins() diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index d058bb89..b92f10ac 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -1419,6 +1419,35 @@ impl WalletDb { Ok(()) } + /// Record a NON-catch-up coverage set and latch `initial_sync_complete` together, unless the + /// coin database was reset since `observed` (dig-node#454). + /// + /// The oracle-tier refresh ([`super::rpc::WalletBackend::refresh_tracked_coins`]) is the other + /// writer of the authoritative flag, and it races a reset exactly as the catch-up did: it + /// fetches rows, the user resets, and it then latches the flag over whatever survived. The + /// guard is the same reset counter, observed before the refresh's own writes. + /// + /// The coverage and the flag move together because a flag with stale coverage beside it is the + /// half-truth dig_ecosystem#2871 already records; splitting them would leave a window where the + /// replica claims authority over a set it did not fetch. + /// + /// Returns whether the latch was RECORDED. + pub async fn latch_synced_over_unless_reset( + &self, + covered: &CoveredSet, + observed: ResetEpoch, + ) -> sqlx::Result { + let result = sqlx::query( + "UPDATE sync_state SET covered_puzzle_hashes = ?, initial_sync_complete = 1 + WHERE id = 0 AND reset_epoch = ?", + ) + .bind(covered.to_storage()) + .bind(observed.0) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + /// Record that a full address-history catch-up finished: advance the peak, mark the replica /// authoritative, and ARM the arrival baseline — all in one transaction. /// @@ -5187,6 +5216,76 @@ mod tests { /// /// The pre-state is asserted first, so this cannot pass against a database that was never /// synced to begin with. + /// **Proves (dig-node#454, the guard at the persistence seam):** a completion carrying the + /// epoch observed BEFORE a reset must not land, and one carrying the epoch AFTER it must. + /// + /// Pinned from BOTH sides deliberately. A guard tested only on the stale value cannot notice + /// one that refuses every completion, which would leave the replica permanently + /// non-authoritative — a different failure, and one a user would feel just as hard. + #[tokio::test] + async fn a_completion_from_before_a_reset_cannot_mark_the_replica_synced() { + let db = WalletDb::open_in_memory().await.unwrap(); + let stale = db.reset_epoch().await.unwrap(); + db.reset_chain_cache(0).await.unwrap().unwrap(); + + let recorded = db + .complete_catch_up_unless_reset( + &CatchUpReplay::finished_at(None, 100, "hh", &[]).unwrap(), + stale, + ) + .await + .unwrap(); + assert!( + !recorded, + "a completion observed before the reset must not land" + ); + assert!( + !db.is_synced().await.unwrap(), + "the replica the reset emptied must stay non-authoritative" + ); + + let fresh = db.reset_epoch().await.unwrap(); + let recorded = db + .complete_catch_up_unless_reset( + &CatchUpReplay::finished_at(None, 100, "hh", &[]).unwrap(), + fresh, + ) + .await + .unwrap(); + assert!( + recorded && db.is_synced().await.unwrap(), + "control: a catch-up that ran wholly AFTER the reset is exactly the one entitled to \ + re-establish the flag" + ); + } + + /// **Proves (dig-node#454, the oracle-tier writer):** the point-read refresh latches the same + /// flag and races the same reset, so it takes the same guard. + #[tokio::test] + async fn an_oracle_latch_from_before_a_reset_cannot_mark_the_replica_synced() { + let db = WalletDb::open_in_memory().await.unwrap(); + let stale = db.reset_epoch().await.unwrap(); + db.reset_chain_cache(0).await.unwrap().unwrap(); + + let covered = CoveredSet::from_hex(&["aa".repeat(32)]); + assert!( + !db.latch_synced_over_unless_reset(&covered, stale) + .await + .unwrap(), + "a refresh that began before the reset must not latch over what the reset left" + ); + assert!(!db.is_synced().await.unwrap()); + + let fresh = db.reset_epoch().await.unwrap(); + assert!( + db.latch_synced_over_unless_reset(&covered, fresh) + .await + .unwrap() + && db.is_synced().await.unwrap(), + "control: a refresh that ran wholly after the reset latches normally" + ); + } + #[tokio::test] async fn a_reset_clears_the_authoritative_flag_along_with_the_coins() { let db = WalletDb::open_in_memory().await.unwrap(); diff --git a/crates/dig-wallet/src/sage/fallback.rs b/crates/dig-wallet/src/sage/fallback.rs index 0eb10222..9c843d63 100644 --- a/crates/dig-wallet/src/sage/fallback.rs +++ b/crates/dig-wallet/src/sage/fallback.rs @@ -888,7 +888,11 @@ mod chain_failure_tests { assert!( matches!(spend, LineageAnswer::Absent), - "a chain that ANSWERED 'no such coin' is an absence, not an outage: an absence may be remembered and written off, an outage may not. Got {spend:?}" + concat!( + "a chain that ANSWERED 'no such coin' is an absence, not an outage: an ", + "absence may be remembered and written off, an outage may not. Got {spend:?}" + ), + spend = spend ); } @@ -982,7 +986,13 @@ mod chain_failure_tests { assert!( matches!(spend, Ok(LineageAnswer::Unavailable)), - "a failed repair read must refuse the coin, not the session — and must say UNAVAILABLE rather than ABSENT, because nothing was learned about the chain. Reporting it as an absence would let one failed read write a real coin off for the cache's whole TTL. Got {spend:?}" + concat!( + "a failed repair read must refuse the coin, not the session — and must say ", + "UNAVAILABLE rather than ABSENT, because nothing was learned about the ", + "chain. Reporting it as an absence would let one failed read write a real ", + "coin off for the cache's whole TTL. Got {spend:?}" + ), + spend = spend ); } diff --git a/crates/dig-wallet/src/sage/quorum.rs b/crates/dig-wallet/src/sage/quorum.rs index 5609760f..5a5146e0 100644 --- a/crates/dig-wallet/src/sage/quorum.rs +++ b/crates/dig-wallet/src/sage/quorum.rs @@ -1001,7 +1001,12 @@ mod settled_peak_tests { let settled = settled_peak(&sample).expect("two agreeing honest claims settle a height"); assert!( settled <= true_tip, - "the settled height {settled} LEADS the true tip {true_tip}; every confirmation count derived from it would treat an unburied coin as buried" + concat!( + "the settled height {settled} LEADS the true tip {true_tip}; every ", + "confirmation count derived from it would treat an unburied coin as buried" + ), + settled = settled, + true_tip = true_tip ); } @@ -1024,7 +1029,12 @@ mod settled_peak_tests { assert_eq!( settled_peak(&sample), Some(true_tip + lead - SETTLED_LAG), - "a colluding majority of the CLAIMANTS sets the median and places the settled height where it likes; if this ever refuses instead, the bound got stronger and `settled_peak`'s doc must be re-read rather than this test relaxed" + concat!( + "a colluding majority of the CLAIMANTS sets the median and places the ", + "settled height where it likes; if this ever refuses instead, the bound got ", + "stronger and `settled_peak`'s doc must be re-read rather than this test ", + "relaxed" + ) ); } diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 5adbb728..b75ba726 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3265,6 +3265,10 @@ impl WalletBackend { if phs.is_empty() { return Ok(0); } + // Observed BEFORE this refresh writes anything, so a reset landing while it runs makes + // its latch below a no-op rather than a re-declaration of the emptied replica as + // authoritative (dig-node#454). Same guard, same counter, as the catch-up path. + let epoch_at_start = self.db.reset_epoch().await?; // XCH coins sitting at our puzzle hashes + CAT coins hinted to them (unspent + recent). let mut fetched = self.fallback.coin_records_by_puzzle_hashes(&phs).await?; fetched.extend(self.fallback.coin_records_by_hints(&phs).await?); @@ -3385,8 +3389,17 @@ impl WalletBackend { // `phs` is what this pass actually fetched, so recording it is the honest claim: it // covers custody's own addresses, and `watchlist_is_covered_by` above has already // established that it covers every enrolled one too. - self.db.record_coverage(&CoveredSet::from_hex(&phs)).await?; - self.db.set_initial_sync_complete(true).await?; + if !self + .db + .latch_synced_over_unless_reset(&CoveredSet::from_hex(&phs), epoch_at_start) + .await? + { + tracing::info!(concat!( + "wallet sync: the coin database was reset while this refresh ran, ", + "so its result was discarded rather than used to mark the emptied ", + "replica synced" + )); + } } else { tracing::info!( "wallet sync: a point-read refresh covered only this node's own custody, so the \ diff --git a/crates/dig-wallet/src/sage/sync_supervisor.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index 3743818d..d6b3d85f 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -1656,7 +1656,10 @@ impl Supervisor { let round = match corroborator.corroborate().await { Ok(r) => r, Err(e) => { - tracing::debug!(error = %e, "wallet sync: corroboration probe failed; the peer stays uncorroborated and writes nothing"); + tracing::debug!(error = %e, concat!( + "wallet sync: corroboration probe failed; the peer stays ", + "uncorroborated and writes nothing" + )); return SessionTrust::refused(RefusalReason::Undecided); } }; @@ -1665,7 +1668,9 @@ impl Supervisor { let writer_answer = match session.header_hash_at(round.height).await { Ok(answer) => answer, Err(e) => { - tracing::debug!(error = %e, height = round.height, "wallet sync: the writer could not answer the corroboration question"); + tracing::debug!(error = %e, height = round.height, concat!( + "wallet sync: the writer could not answer the corroboration question" + )); None } }; @@ -1756,7 +1761,11 @@ impl Supervisor { height = round.height, peer = %session.peer_ip(), verdict = ?round.verdict, - "wallet sync: peers persistently disagree about settled chain state; the replica is deliberately NOT being written. This is evidence of a network partition or a hostile peer set, not of a slow connection." + concat!( + "wallet sync: peers persistently disagree about settled chain state; the ", + "replica is deliberately NOT being written. This is evidence of a network ", + "partition or a hostile peer set, not of a slow connection." + ) ); true } From 6dff779c506bcfa7b714071ec21f35ec507a84c3 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 12:01:23 -0700 Subject: [PATCH 8/9] style(wallet): a single-fragment concat! is a clippy useless_concat --- crates/dig-wallet/src/sage/sync_supervisor.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/dig-wallet/src/sage/sync_supervisor.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index d6b3d85f..6ae214f3 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -1668,9 +1668,8 @@ impl Supervisor { let writer_answer = match session.header_hash_at(round.height).await { Ok(answer) => answer, Err(e) => { - tracing::debug!(error = %e, height = round.height, concat!( - "wallet sync: the writer could not answer the corroboration question" - )); + tracing::debug!(error = %e, height = round.height, "wallet sync: the writer could not answer the corroboration question" + ); None } }; From 943668f0074148908afa768618d56df12b99fca1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 12:27:16 -0700 Subject: [PATCH 9/9] docs(spec,wallet): narrow the refusal clause to what the code delivers, reunite a doc Two non-gating findings from the re-gate. The SPEC said "A sync pass whose completion is refused this way MUST report an error rather than success". That binds BOTH writers by its preceding sentence, and only the catch-up complies -- the oracle-tier refresh logs and returns Ok. The behaviour is fine (it re-reads on its next call and has no pass to re-run), so the sentence was the thing that was wrong. Narrowed to name each writer's obligation rather than leaving a normative clause claiming more than the code delivers. And a doc block was orphaned: the #454 tests were inserted between an existing comment and its function, so the "money hazard, asserted directly" block came to describe a different test while `a_reset_clears_the_authoritative_flag_along_with_the_coins` lost its own. Reunited. Refs #384 Co-Authored-By: Claude --- SPEC.md | 2 +- crates/dig-wallet/src/sage/db.rs | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/SPEC.md b/SPEC.md index 734e40bb..347c611b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1631,7 +1631,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | | `control.wallet.arrivals` | `after_seq` (integer ≥ 0, default `0`), `limit` (integer, default `50`, CLAMPED to `1..=500`) | `arrivals` (`[{seq, coin_id, puzzle_hash, amount, asset_id, confirmed_height}]`, oldest first), `cursor` (the RESUME position: the last `seq` actually returned, or the caller's own `after_seq` on an empty page), `latest` (the newest position the ledger holds). A client MUST resume from `cursor` and MUST NOT resume from `latest`: `latest` is read after the page, so an arrival recorded in between sits above the page and below `latest`, and resuming from `latest` would step over it. `latest` exists for the first-run case only — a client with no stored cursor reads it and passes it back as `after_seq` to start from NOW rather than replaying the ledger as a burst of notifications. INCOMING FUNDS the node determined ARRIVED, since a cursor (dig_ecosystem#2548) — the question neither `.balance` (a total the user's own change also moves) nor `.coins` (no notion of "new") can answer. A row is written ONLY for a coin that is (a) CONFIRMED — `confirmed_height` is `NOT NULL` in the store, so a mempool sighting is unwritable, not merely unwritten; (b) confirmed STRICTLY ABOVE the wallet's arrival baseline, which is armed ONLY by the statement that records a COMPLETED address-history catch-up — the one caller that has demonstrably replayed everything — so a first catch-up announces nothing, and a point read against the fallback oracle, which replays nothing, cannot arm a baseline at all; (c) not already recorded, enforced by a `UNIQUE` coin id on disk, so a restart, a reconnect or a rebuilt replica re-announces nothing; and (d) NOT created by spending a coin this wallet holds, so the user's own change is never reported as a receipt. `amount` is a decimal STRING (the full `u64` range; a JSON number would round it). `asset_id` is `null` for native XCH and the CAT's hex TAIL otherwise — NEVER a ticker, because naming an asset the node did not attribute would assert a classification it cannot support; a coin whose asset is not yet determinable is HELD and re-examined, never announced as XCH. A reorg DELETES the arrivals above the fork with the coins they describe, and walks the baseline back; `seq` is `AUTOINCREMENT`, so a deleted row's position is never reused and a stored cursor cannot come to mean a different arrival. `arrivals: []` means the node consulted its OWN replica and nothing arrived since the cursor — it is NOT a claim that the replica is current (ask `control.wallet.syncStatus`), and a node that has never completed a catch-up has no baseline and reports empty forever. OPEN read (no token) and the NARROWEST of the open reads: it touches only the local replica, has no oracle path, and so discloses nothing off-node and cannot amplify a poll into outbound requests. `INVALID_PARAMS` on a negative `after_seq`; `WALLET_READ_FAILED` if the local ledger cannot be read. | | `control.wallet.peak` | — | `peak_height` (`u32` or `null`), `synced` (bool). The node's current chain peak, independent of any address. Its OWN method rather than a field on a balance because a balance reports `peak_height: null` on every `"fallback"`-tier answer by design (§18.7b), so a caller bounding a claimed confirmation could not obtain one from the node that most needs to answer. Prefers the node's own replica and falls back to the chain tier. The chain tier is the node's OWN dialled Chia peers, asked CONCURRENTLY and settled on their AGREEMENT (NC-12): the height is the settled height every credible peer in the sample has passed, and a sample that collapses to one voice, or splits, MUST report `peak_height: null` rather than a repaired number. A node MUST NOT satisfy this read from a single public oracle, and MUST NOT fall through to one when its peers fail to agree — falling through would let one endpoint overrule the peers at exactly the moment corroboration failed, which is the single-source dependency NC-12 exists to remove. `peak_height: null` means UNKNOWN and MUST NOT be read as height zero, which every block is trivially above. `synced` carries EXACTLY its `control.wallet.balance` meaning (§18.7b) and MUST be MEASURED by the same predicate: a replica-served peak reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height`, and a tier with no observable peer height, or a replica with no peak of its own, also answers `synced: false` — neither an unmeasured peer tier nor an unknown replica height can establish currency. A node MUST NOT derive this flag from `initial_sync_complete`, which latches on the first completed catch-up and is cleared only by a backwards chain move: a replica hundreds of blocks behind still satisfies it, so `control.wallet.peak` would report `synced: true` about the same replica `control.wallet.syncStatus` is simultaneously reporting as `syncing`. This is the endpoint a caller uses to bound a claimed confirmation, so the overstatement lands on the read that decides whether money has settled. A chain-tier answer reports `synced: false`, because a height the replica did not produce says nothing about the replica. OPEN read. | -| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. No sync pass that was ALREADY RUNNING when the reset landed may re-establish it. The node MUST record a reset counter that the reset increments in that same transaction; every writer of `initial_sync_complete` — the address-history catch-up and the oracle-tier point-read refresh alike — MUST observe that counter BEFORE its own first write and present it again in the statement that sets the flag, which MUST NOT take effect if the counter has moved. Without that condition the reset and the sync pass are separate transactions that nothing serialises, and the interrupted pass marks the emptied — or partially refilled — replica synced one statement later: the same `balance 0, synced true`, or the likelier understated balance from a partial coin set. A sync pass whose completion is refused this way MUST report an error rather than success, so a fresh pass runs. A pass that began wholly AFTER the reset is unaffected and re-establishes the flag normally. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | +| `control.wallet.resetCoinDb` | `confirm` (bool, MUST be `true`) | `coins_dropped` (`u64`), `staged_dropped` (`u64`). **DESTRUCTIVE.** Discards this node's chain-derived cache and forces a re-sync from chain. The node MUST clear the `initial_sync_complete` flag and the recorded coverage in the SAME transaction that empties the coins: that flag is what makes the local replica authoritative for wallet-scoped reads, so an emptied-but-still-synced replica answers `balance 0, synced true` on a funded wallet, and a crash between two separate writes would leave exactly that state. Reads then fall back to the chain tier until a genuine catch-up re-establishes the flag. No sync pass that was ALREADY RUNNING when the reset landed may re-establish it. The node MUST record a reset counter that the reset increments in that same transaction; every writer of `initial_sync_complete` — the address-history catch-up and the oracle-tier point-read refresh alike — MUST observe that counter BEFORE its own first write and present it again in the statement that sets the flag, which MUST NOT take effect if the counter has moved. Without that condition the reset and the sync pass are separate transactions that nothing serialises, and the interrupted pass marks the emptied — or partially refilled — replica synced one statement later: the same `balance 0, synced true`, or the likelier understated balance from a partial coin set. An address-history CATCH-UP whose completion is refused this way MUST report an error rather than success, so a fresh pass runs. The oracle-tier point-read refresh MAY instead log and return success, because it re-reads on its next call and has no pass to re-run; what it MUST NOT do is set the flag. A pass that began wholly AFTER the reset is unaffected and re-establishes the flag normally. It MUST discard chain-derived rows ONLY — never a seed, a device key, or any configuration a re-sync does not reproduce. It MUST REFUSE, writing nothing, while any spend is in flight, and liveness MUST be judged by EXPIRY against the node's own clock rather than by row presence: a lapsed hold that nobody has pruned MUST NOT deny the reset, and the instant MUST NOT be caller-supplied, since a far-future value would make every live hold read as expired. A refusal is an ERROR, never a success carrying a flag. `confirm != true` is `INVALID_PARAMS`. Token-gated (PAIRED tier: the DIG App drives this and holds a paired token, so reserving it to the master token would make it unreachable by its only consumer); loopback-only; NEVER an open read. | | `control.wallet.broadcast` | `signed_bundle_hex` (lowercase hex, optionally `0x`-prefixed, of a chia `Streamable` `SpendBundle`) | `accepted` (bool), `transaction_id` (lowercase 64-hex or `null`), `rejection` (string or `null`). Pushes an ALREADY-SIGNED bundle. **§908: this method signs nothing and is never given anything it could sign with** — there is no key, seed, phrase or unsigned-plan parameter here and none may be added; on this surface the node's role is to read chain state and relay what somebody else signed. The node's OWN automated spends (§23, §25) never transit this method and are not reachable from it. A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting `{accepted:false, rejection}`; failing to REACH a mempool is `WALLET_READ_FAILED`, and a node with no chain source is `WALLET_NO_CHAIN_SOURCE`. These MUST NOT be collapsed: the first says build a different bundle, the second says retry this one. `accepted:true` reports mempool admission ONLY and is NOT evidence anything reached a block — a caller MUST NOT record an outcome from it; only a buried confirmation of the created coin is evidence. `INVALID_PARAMS` on hex that is not a streamable `SpendBundle`, refused BEFORE any network call. A bundle requiring a signature from any key the NODE custodies — whatever puzzle wraps the coin — while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off is `WALLET_NODE_SPEND_DISABLED`, also refused before any network call — the node relays what somebody ELSE signed, and it signs on request, so whether the node could have signed it is CHECKED rather than assumed. TOKEN-GATED (not an open read). | | `control.chiaPeers.add` | `ip` (a bare IPv4/IPv6 literal — no brackets, no port, no hostname; the standard full-node port is assumed) | `{added: true, ip, port, corroboration_bypassed, notice}`. TRUSTS a Chia full node: it writes the `user_managed` peer row that is the ONLY way to reach `PeerTrust::Operator`, the trust level whose answers may drive catch-up, rollback and the `initial_sync_complete` flag WITHOUT a quorum. Every other peer is `Discovered` and must be corroborated by independently chosen peers first (§18.16). `ip` is CANONICALISED on the way in (`IpAddr` display form — RFC 5952 lowercase compressed for v6) and echoed back in that form, so one host is one entry however it was spelled; `INVALID_PARAMS` on anything that is not a bare literal, refused before any write. `corroboration_bypassed` is the RESULTING trust state, NOT a restatement of the request: a node MUST report `false` where the entry did not end up trusted — adding a peer that was BANNED un-bans it and confers no bypass. `notice` carries the cost as a sentence and MUST be non-empty, name the corroboration bypass, and be rendered VERBATIM; a client MUST NOT paraphrase, truncate or suppress it. The wording MUST authorise only **a node the operator runs themselves** — never vouching or recommending, which widen the case past what justifies the entry's unbounded authority. Idempotent — re-adding a known peer succeeds and un-bans it. A node MUST serve this from the SAME peer store its wallet replica consults. **MASTER-TOKEN TIER** (`ControlMethod::requires_master_token`): a paired token MUST be refused, because the entry outlives the token that wrote it and `pairing.revoke` removes no peer row. | | `control.chiaPeers.list` | — | `{peers: [{ip, port, peak_height, user_managed, banned}]}` — every tracked Chia peer: TRUSTED, DISCOVERED **AND BANNED** alike. `user_managed` tells the trusted set from the discovered one and MUST be reported rather than filtered on: a list showing only the trusted set would let a person conclude the node talks to nobody else. `banned` MUST likewise be reported and its rows MUST NOT be omitted — this is the ONLY enumeration of the ban set, and a blocklist a person cannot read is a blocklist they cannot correct. This enumeration is DISTINCT from the dialling read, which excludes banned peers; a node MUST NOT serve both from one relaxed query. `peak_height` is `null` where the node holds no telemetry for that peer yet — `null` means UNOBSERVABLE and MUST NEVER be reported as `0`, which would render an unpolled peer as one stalled at genesis. A reported height is that peer's CLAIM, never a verified fact, and MUST NOT be aggregated into a chain position (NC-12). TOKEN-GATED at the ORDINARY tier — a read grants nothing that outlives the token, and a paired client must stay able to show the operator the trust state it is subject to. | diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index b92f10ac..bcb75554 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -5207,15 +5207,6 @@ mod tests { // ---- coin-database reset (dig-node#384) -------------------------------- - /// **The money hazard, asserted directly: a reset must never leave an EMPTY replica claiming - /// to be AUTHORITATIVE (dig-node#384).** - /// - /// `is_synced` is what licenses `routing::route` to serve wallet-scoped reads from the local - /// replica. An emptied-but-still-synced database therefore answers `balance 0, synced true` - /// for a funded wallet — a confident zero about somebody's money. - /// - /// The pre-state is asserted first, so this cannot pass against a database that was never - /// synced to begin with. /// **Proves (dig-node#454, the guard at the persistence seam):** a completion carrying the /// epoch observed BEFORE a reset must not land, and one carrying the epoch AFTER it must. /// @@ -5286,6 +5277,15 @@ mod tests { ); } + /// **The money hazard, asserted directly: a reset must never leave an EMPTY replica claiming + /// to be AUTHORITATIVE (dig-node#384).** + /// + /// `is_synced` is what licenses `routing::route` to serve wallet-scoped reads from the local + /// replica. An emptied-but-still-synced database therefore answers `balance 0, synced true` + /// for a funded wallet — a confident zero about somebody's money. + /// + /// The pre-state is asserted first, so this cannot pass against a database that was never + /// synced to begin with. #[tokio::test] async fn a_reset_clears_the_authoritative_flag_along_with_the_coins() { let db = WalletDb::open_in_memory().await.unwrap();