From 94f55104efe66e11449191b0a2589080d2da73be Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 00:40:25 -0700 Subject: [PATCH 01/28] chore(wallet): open lane for CAT discovery staging (#390) --- .loop-390-lane.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .loop-390-lane.md diff --git a/.loop-390-lane.md b/.loop-390-lane.md new file mode 100644 index 00000000..6f231c66 --- /dev/null +++ b/.loop-390-lane.md @@ -0,0 +1,7 @@ +# Lane: dig-node #390 — CAT discovery staging + lineage promotion + +Branch: `loop/390-cat-staging` (from `origin/main`). +Scope: derived-hash arrivals land in `cat_admission_pending`; only lineage-proven +coins are written to `coins`. `coins` keeps `origin/main` semantics exactly. + +Closes #380. From fe3b3b5f15b1f430ee1c2288bc0dc4bf66cfcbc1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:05:44 -0700 Subject: [PATCH 02/28] feat(wallet): the cat_admission_pending staging table and its promote/discard path Salvaged from a lane the watchdog killed mid-implementation, with 232 lines uncommitted. VERIFIED: cargo check -p dig-wallet passes on this tree. Derived-hash arrivals stage here; only a lineage-proven coin is written to `coins`, fully attributed. `coins` keeps origin/main's semantics exactly, so a fabricated coin is unreachable by ABSENCE from the table its 22 readers read, rather than by a guard each of them must remember to apply. A staging table rather than a lineage_proven column because this PR family twice proved its own enumerations incomplete: SPEC 18.11a named the puzzle-hash sets, a lane found seven, a gate found an eighth -- and that eighth was exactly where a false "you were paid" notification came from. INCOMPLETE. The lane's last words were "now the reorg rollback must unmake staged rows too", and that is not done. A reorg that rolls `coins` back above the fork must roll staged rows back with it, or a staged row outlives the chain state that justified it and can later be promoted against a fork that no longer exists. Refs https://github.com/DIG-Network/dig-node/issues/390 Co-Authored-By: Claude --- crates/dig-wallet/src/sage/db.rs | 232 +++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index ff4d166a..11a7141b 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -13,6 +13,7 @@ //! Amounts are stored as **decimal TEXT** (full `u64`/`u128` range, no `i64` overflow); //! heights/timestamps as INTEGER (`i64`) and narrowed to `u32`/`u64` at the wire boundary. +use std::collections::HashSet; use std::str::FromStr; use dig_node_control_interface::params::MAX_BANNED_CHIA_PEERS; @@ -333,6 +334,52 @@ pub struct PeerRow { pub banned: bool, } + +/// How many discovered-but-unproven CAT coins the staging table holds. +/// +/// A staged row is a dozen short hex fields — call it 400 bytes with SQLite's overhead — so +/// 20 000 rows is roughly **8 MiB**, comfortably below the chain caches this file already budgets +/// (`CHAIN_READ_CACHE_MAX_ROWS` alone is ~20 MiB) because a staged row is strictly shorter-lived. +/// +/// The number is chosen from what an ATTACKER must spend, not from what a wallet needs. Every +/// staged row costs its creator at least one `CREATE_COIN` and one mojo, and a legitimate wallet +/// stages one row per genuinely-received CAT coin — so 20 000 is several orders of magnitude +/// above any honest backlog while still bounding the table against a spend crafted to fill it. +pub const CAT_ADMISSION_PENDING_MAX_ROWS: i64 = 20_000; + +/// A discovered CAT coin awaiting a lineage proof. +/// +/// Deliberately NOT a [`CoinRow`]. The two types describe different claims: a `CoinRow` is a coin +/// the wallet BELIEVES it owns as the asset it is typed with, and every balance, coin-selection +/// and arrival-notification read is entitled to trust it. A `StagedCatRow` is a coin the wallet has +/// merely FOUND at a hash it derived, together with the derivation that found it — a hypothesis. +/// Sharing one type between the two would make the difference a field rather than a table, which +/// is exactly the shape this design rejects. +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct StagedCatRow { + /// The coin id (hex, 64 chars). + pub coin_id: String, + /// The parent coin id (hex) — what promotion reads the spend of. + pub parent_coin_info: String, + /// The outer puzzle hash the coin sits at (hex). + pub puzzle_hash: String, + /// The amount, decimal string. + pub amount: String, + /// The created block height, if confirmed. + pub created_height: Option, + /// The spent block height, if spent. + pub spent_height: Option, + /// The created timestamp. + pub created_timestamp: Option, + /// The spent timestamp. + pub spent_timestamp: Option, + /// The asset id whose derived hash this coin was found at — the CLAIM promotion must confirm + /// against the parent spend, never a fact. + pub derived_asset_id: String, + /// The owner p2 hash the derivation curried — likewise a claim, confirmed at promotion. + pub derived_owner_p2: String, +} + const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS sync_state ( id INTEGER PRIMARY KEY CHECK (id = 0), @@ -360,6 +407,22 @@ CREATE TABLE IF NOT EXISTS arrival_pending ( created_height INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS cat_admission_pending ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + coin_id TEXT NOT NULL UNIQUE, + parent_coin_info TEXT NOT NULL, + puzzle_hash TEXT NOT NULL, + amount TEXT NOT NULL, + created_height INTEGER, + spent_height INTEGER, + created_timestamp INTEGER, + spent_timestamp INTEGER, + derived_asset_id TEXT NOT NULL, + derived_owner_p2 TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_cat_admission_pending_created + ON cat_admission_pending (created_height); + CREATE TABLE IF NOT EXISTS derivations ( hardened INTEGER NOT NULL, idx INTEGER NOT NULL, @@ -1578,6 +1641,175 @@ impl WalletDb { Ok(()) } + // ---- CAT admission staging (dig-node#380) ----------------------------- + // + // A coin sitting at `cat_puzzle_hash(our_p2, asset_id)` is DISCOVERED, not BELIEVED. The + // derivation is injective and proves only "if this coin is ever spent, only this wallet can + // spend it, as this asset" — it does NOT prove the coin is a unit of that asset, because + // `CREATE_COIN` is unconstrained in its destination. Anyone holding the victim's public + // address can therefore place a coin at the derived hash for 1 mojo per displayed base unit. + // + // So discovered coins land HERE and never in `coins`. Only [`Self::promote_cat_admission`], + // which runs off the frame path after a lineage proof, moves one across. Every one of the 22 + // production readers of `coins` is thereby clean by ABSENCE rather than by a predicate each of + // them has to remember — the distinction that matters, because the enumeration of those + // readers has already been found incomplete twice in this family. + + /// Stage discovered derived-hash coins, then hold the table to + /// [`CAT_ADMISSION_PENDING_MAX_ROWS`] by evicting the OLDEST rows first. + /// + /// # Why eviction rather than refusal + /// + /// A single spend may carry many `CREATE_COIN`s, so an attacker chooses how many rows arrive. + /// The bound must therefore exist — but it must **delay**, never **error**: a staging insert + /// that could fail would sit on the peer frame path, and a peer able to fail a frame can deny + /// a catch-up. An evicted row is a coin that is *absent*, which is the stated and acceptable + /// failure direction; an errored frame is a session kill, which is not. + /// + /// A re-pushed coin re-stages, so eviction is recoverable rather than terminal. + pub async fn stage_cat_admissions(&self, rows: &[StagedCatRow]) -> sqlx::Result<()> { + let mut tx = self.pool.begin().await?; + for r in rows { + sqlx::query( + "INSERT INTO cat_admission_pending + (coin_id, parent_coin_info, puzzle_hash, amount, created_height, + spent_height, created_timestamp, spent_timestamp, + derived_asset_id, derived_owner_p2) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(coin_id) DO UPDATE SET + created_height = excluded.created_height, + spent_height = excluded.spent_height, + created_timestamp = excluded.created_timestamp, + spent_timestamp = excluded.spent_timestamp", + ) + .bind(Self::normalise_hex(&r.coin_id)) + .bind(Self::normalise_hex(&r.parent_coin_info)) + .bind(Self::normalise_hex(&r.puzzle_hash)) + .bind(&r.amount) + .bind(r.created_height) + .bind(r.spent_height) + .bind(r.created_timestamp) + .bind(r.spent_timestamp) + .bind(Self::normalise_hex(&r.derived_asset_id)) + .bind(Self::normalise_hex(&r.derived_owner_p2)) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "DELETE FROM cat_admission_pending WHERE seq NOT IN + (SELECT seq FROM cat_admission_pending ORDER BY seq DESC LIMIT ?)", + ) + .bind(CAT_ADMISSION_PENDING_MAX_ROWS) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + /// The oldest `limit` staged rows — the promotion pass's work queue. + /// + /// Oldest-first so a backlog drains in arrival order rather than starving the earliest coin, + /// and `limit` so one pass performs a bounded number of chain reads regardless of how many + /// rows an attacker staged. + pub async fn staged_cat_admissions(&self, limit: i64) -> sqlx::Result> { + sqlx::query_as::<_, StagedCatRow>( + "SELECT coin_id, parent_coin_info, puzzle_hash, amount, created_height, + spent_height, created_timestamp, spent_timestamp, + derived_asset_id, derived_owner_p2 + FROM cat_admission_pending ORDER BY seq ASC LIMIT ?", + ) + .bind(limit) + .fetch_all(&self.pool) + .await + } + + /// Which of `coin_ids` already have a row in `coins`. + /// + /// The routing question for an already-PROMOTED coin: once a coin has cleared promotion its + /// spend must update `coins` normally, exactly as `origin/main` does, or a promoted coin would + /// stay unspent in the replica forever and be re-selected after it was spent. + pub async fn existing_coin_ids(&self, coin_ids: &[String]) -> sqlx::Result> { + let mut found = HashSet::new(); + for id in coin_ids { + let hit: Option = + sqlx::query_scalar("SELECT coin_id FROM coins WHERE coin_id = ?") + .bind(Self::normalise_hex(id)) + .fetch_optional(&self.pool) + .await?; + if let Some(hit) = hit { + found.insert(hit); + } + } + Ok(found) + } + + /// Move one staged coin into `coins`, FULLY ATTRIBUTED, and drop its staging row — in one + /// transaction, so no reader can ever observe the coin in both tables or in neither. + /// + /// `asset_id` and `hint` come from the parent spend's own reconstruction, never from the + /// derivation that discovered the coin. That is the whole content of the proof: the derivation + /// said where to look, the parent spend says what the coin IS. + pub async fn promote_cat_admission( + &self, + row: &StagedCatRow, + asset_id: &str, + hint: &str, + ) -> sqlx::Result<()> { + let mut tx = self.pool.begin().await?; + sqlx::query( + "INSERT INTO coins + (coin_id, parent_coin_info, puzzle_hash, amount, created_height, + spent_height, asset_id, hint, created_timestamp, spent_timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(coin_id) DO UPDATE SET + created_height = excluded.created_height, + spent_height = excluded.spent_height, + created_timestamp = excluded.created_timestamp, + spent_timestamp = excluded.spent_timestamp, + asset_id = COALESCE(excluded.asset_id, coins.asset_id), + hint = COALESCE(excluded.hint, coins.hint)", + ) + .bind(Self::normalise_hex(&row.coin_id)) + .bind(Self::normalise_hex(&row.parent_coin_info)) + .bind(Self::normalise_hex(&row.puzzle_hash)) + .bind(&row.amount) + .bind(row.created_height) + .bind(row.spent_height) + .bind(Self::normalise_hex(asset_id)) + .bind(Self::normalise_hex(hint)) + .bind(row.created_timestamp) + .bind(row.spent_timestamp) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM cat_admission_pending WHERE coin_id = ?") + .bind(Self::normalise_hex(&row.coin_id)) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + /// Drop a staged coin that a SUCCESSFUL parent read proved is not a unit of the derived asset. + /// + /// Terminal, and that is what bounds the read cost: a refused coin is never read again, so an + /// attacker's amplification is ~1x against a coin they had to pay at least 1 mojo to create. + /// Never called for an UNAVAILABLE read — an unavailable answer leaves the row staged, because + /// deleting on "I could not tell" would let a peer that withholds parent spends erase real money. + pub async fn discard_cat_admission(&self, coin_id: &str) -> sqlx::Result<()> { + sqlx::query("DELETE FROM cat_admission_pending WHERE coin_id = ?") + .bind(Self::normalise_hex(coin_id)) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// How many coins are currently staged (diagnostics + tests). + pub async fn staged_cat_admission_count(&self) -> sqlx::Result { + sqlx::query_scalar("SELECT COUNT(*) FROM cat_admission_pending") + .fetch_one(&self.pool) + .await + } + /// Roll back chain state above `height` after a reorg (design B.3): /// - coins **created** above `height` never existed → delete them; /// - coins **spent** above `height` are unspent again → clear the spend; From b4e612cc1880a6032735ec04a055507fd9416795 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:07:41 -0700 Subject: [PATCH 03/28] fix(wallet): unmake staged CAT admissions with the coins they describe on reorg A staged row records an OBSERVATION of chain state, not money. A rollback deletes the state that observation was made against, so a row left behind can later be promoted against a fork the chain no longer has -- writing into coins on the strength of undone history. Deleted by the same predicate, in the same transaction, as the coin rows. A spend above the fork is cleared rather than the row deleted, since the coin's own creation is still confirmed. --- crates/dig-wallet/src/sage/db.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index 11a7141b..e3cb71cf 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -1842,6 +1842,38 @@ impl WalletDb { .bind(h) .execute(&mut *tx) .await?; + // A staged CAT admission is UNMADE with the coin it describes, by the same predicate and + // in the same transaction (dig-node#380). + // + // A staged row is not a record of money; it is a record of an OBSERVATION — "the chain + // showed me a coin at a hash I derived". A rollback deletes the chain state that + // observation was made against, so the row's justification is gone even though the row + // would survive. Left behind, it is later promoted against a fork the chain no longer has, + // and promotion writes into `coins` — so a coin enters the believed set on the strength of + // history that was undone. That is the same defect class as everything else this table + // exists to prevent: a check trusting a value whose meaning it never established. + // + // Nothing is lost by deleting. A coin that re-confirms after the reorg is pushed again by + // the peer and re-staged, exactly as `coins` is re-populated. + sqlx::query( + "DELETE FROM cat_admission_pending + WHERE created_height IS NOT NULL AND created_height > ?", + ) + .bind(h) + .execute(&mut *tx) + .await?; + // A staged coin SPENT above the fork is unspent again. The spend is cleared rather than + // the row deleted: the coin itself is still confirmed at or below the fork, so it is still + // a legitimate promotion candidate, and deleting it here would lose a real coin to a reorg + // that did not touch its creation. + sqlx::query( + "UPDATE cat_admission_pending + SET spent_height = NULL, spent_timestamp = NULL + WHERE spent_height > ?", + ) + .bind(h) + .execute(&mut *tx) + .await?; sqlx::query( "UPDATE sync_state SET arrival_baseline_height = ? WHERE id = 0 AND arrival_baseline_height > ?", From c1a1dd851f8000a2c3bc16d08a07af4368f5ee5b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:13:58 -0700 Subject: [PATCH 04/28] feat(wallet): discover CAT coins by derived puzzle hash and stage every arrival A CoinState carries no hint, so a wallet cannot recognise its own CAT coins from the frame that delivers them; the only local route is to derive the outer hash cat_puzzle_hash(owner_p2, asset_id) and subscribe it. On a real wallet, 50 of the 51 puzzle hashes holding its coins were dropped at ingest for want of this. Subscribing is discovery, not belief: apply_coin_states now ROUTES rather than types, sending derived-hash arrivals to cat_admission_pending and leaving coins with exactly origin/main's semantics. Zero chain reads on the frame path, structurally -- the staging path takes no LineageSource. SessionState keeps the address set and the derived set as separate fields so record_arrivals cannot see an outer CAT hash. --- crates/dig-wallet/src/sage/cat_discovery.rs | 321 ++++++++++++++++++ crates/dig-wallet/src/sage/mod.rs | 1 + crates/dig-wallet/src/sage/sync.rs | 87 ++++- crates/dig-wallet/src/sage/sync_supervisor.rs | 43 ++- .../src/sage/sync_supervisor/tests.rs | 1 + 5 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 crates/dig-wallet/src/sage/cat_discovery.rs diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs new file mode 100644 index 00000000..979711aa --- /dev/null +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -0,0 +1,321 @@ +//! CAT discovery by derived puzzle hash, and the lineage proof that promotes a discovery into a +//! believed coin (dig-node#380). +//! +//! # The distinction this module exists to hold +//! +//! A `CoinState` on the wire carries three fields — parent, puzzle hash, amount — and **no hint**. +//! A wallet therefore cannot recognise a CAT coin from the frame that delivers it; the only thing +//! it can do locally is derive, in advance, the outer hash a CAT of a known asset would sit at if +//! this wallet owned it, and subscribe to that hash. That derivation, +//! `cat_puzzle_hash(owner_p2, asset_id)`, is injective: it commits to the CAT2 module, the asset +//! id and the inner p2 together. +//! +//! What it proves is *"if this coin is ever spent, only this wallet can spend it, and only as this +//! asset"*. What it does **not** prove is *"this coin is a unit of that asset"* — because +//! `CREATE_COIN` is unconstrained in its destination, so anybody may place a coin at any puzzle +//! hash. A stranger holding nothing but the victim's public address can therefore manufacture a +//! coin at the derived hash for **one mojo per displayed base unit**. +//! +//! Believing the derivation costs three things at once: a fabricated balance, a **permanent send +//! kill-switch** (coin selection is largest-first, and the fabricated coin is unspendable by +//! anyone, so it is chosen forever and never leaves the set), and a false *"you were paid"* +//! notification. +//! +//! So discovery and belief are separated by a **table**, not a flag: +//! +//! | stage | where the coin lives | what is known | +//! |---|---|---| +//! | discovered | [`crate::sage::db::StagedCatRow`] in `cat_admission_pending` | it sits at a hash we derived | +//! | believed | `CoinRow` in `coins`, fully attributed | its parent spend reconstructs it as that asset | +//! +//! Every production reader of `coins` — the balance, the spend-input selector, the arrivals +//! notifier, `get_cats` — is clean because a staged coin is **absent from the table they read**, +//! not because each of them remembers a predicate. That difference is load-bearing: the +//! enumeration of those readers has already been found incomplete twice in this ticket family. +//! +//! # Where the work happens +//! +//! - **On the peer frame path**: [`DerivedCats::owner_of`] is a hash-map lookup. Zero chain reads, +//! structurally — [`stage_from_states`] takes no [`LineageSource`] and so cannot perform one. +//! - **Off the frame path**: [`promote_staged_cats`] performs roughly **one** parent-spend read per +//! newly staged coin, **terminal** on both success and definitive refusal, and capped per pass. + +use std::collections::HashMap; + +use chia_protocol::{Bytes32, Coin, CoinState}; + +use super::db::{StagedCatRow, WalletDb}; +use super::singleton::{coin_from_row, LineageSource, Reconstructed}; +use super::{singleton, Result}; + +/// How many staged coins one promotion pass will read parent spends for. +/// +/// This is the amplification bound. An attacker who stages `N` coins buys at most this many chain +/// reads per pass, and because promotion is **terminal** — a coin is promoted or refused once and +/// never re-read — the total reads they can buy is `N`, not `N` per pass forever. Against a coin +/// each of which cost them a `CREATE_COIN` and at least one mojo, that is roughly 1x. +/// +/// Small enough that a pass stays short (each read is a network round trip), large enough that an +/// honest wallet's whole backlog clears in a handful of passes. +pub const MAX_CAT_PROMOTIONS_PER_PASS: i64 = 64; + +/// The outer CAT puzzle hashes this wallet would own, and what each one was derived FROM. +/// +/// A map rather than a set because the provenance is the point: when a coin arrives at one of +/// these hashes, promotion has to check the parent spend against the *specific* (asset id, owner +/// p2) pair that predicted it. A bare set would only be able to say "one of ours predicted this", +/// which is not a claim promotion can test. +#[derive(Debug, Clone, Default)] +pub struct DerivedCats { + by_hash: HashMap, +} + +/// What a derived CAT puzzle hash was built from — a CLAIM about an arriving coin, never a fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DerivedOwner { + /// The CAT asset id (TAIL hash) curried into the outer puzzle. + pub asset_id: Bytes32, + /// The inner p2 puzzle hash the wallet controls. + pub owner_p2: Bytes32, +} + +impl DerivedCats { + /// Derive the outer hash for every `(owner p2, asset id)` pair. + /// + /// Built through `digstore_chain::cat::cat_puzzle_hash` — the one construction the wallet's CAT + /// balance, coin reconstruction and send paths already share. A second spelling of this curry + /// would be a byte-drift bug in the code that decides whether money is counted (SYSTEM.md §4.1). + pub fn derive(owner_p2_hashes: &[Bytes32], asset_ids: &[Bytes32]) -> Self { + let mut by_hash = HashMap::new(); + for &owner_p2 in owner_p2_hashes { + for &asset_id in asset_ids { + let outer = digstore_chain::cat::cat_puzzle_hash(owner_p2, asset_id); + by_hash.insert( + outer, + DerivedOwner { + asset_id, + owner_p2, + }, + ); + } + } + Self { by_hash } + } + + /// What predicted `puzzle_hash`, if anything did. + pub fn owner_of(&self, puzzle_hash: &Bytes32) -> Option { + self.by_hash.get(puzzle_hash).copied() + } + + /// Every derived hash, for the subscription request. + pub fn hashes(&self) -> Vec { + let mut hashes: Vec = self.by_hash.keys().copied().collect(); + // A HashMap's iteration order is arbitrary; sorting makes a subscription — and a test + // asserting one — reproducible. + hashes.sort(); + hashes + } + + /// Whether anything was derived at all. + pub fn is_empty(&self) -> bool { + self.by_hash.is_empty() + } +} + +/// Turn the derived-hash coins in `states` into staged rows. +/// +/// `already_promoted` are coin ids that already have a row in `coins`. A coin that has cleared +/// promotion is NOT re-staged: its later states — a spend, above all — must update `coins` exactly +/// as `origin/main` updates any other coin, or a promoted coin would stay unspent in the replica +/// forever and be selected again after it was already spent. +/// +/// Takes no [`LineageSource`], which is how the zero-chain-reads-on-the-frame-path property is +/// guaranteed: it is not a discipline the body observes, it is a fact about the signature. +pub fn stage_from_states<'a, F>( + states: &'a [CoinState], + derived: &DerivedCats, + mut already_promoted: F, +) -> Vec +where + F: FnMut(&str) -> bool, +{ + let mut rows = Vec::new(); + for s in states { + let Some(owner) = derived.owner_of(&s.coin.puzzle_hash) else { + continue; + }; + let coin_id = hex::encode(s.coin.coin_id()); + if already_promoted(&coin_id) { + continue; + } + rows.push(StagedCatRow { + coin_id, + parent_coin_info: hex::encode(s.coin.parent_coin_info), + puzzle_hash: hex::encode(s.coin.puzzle_hash), + amount: s.coin.amount.to_string(), + created_height: s.created_height.map(i64::from), + spent_height: s.spent_height.map(i64::from), + created_timestamp: None, + spent_timestamp: None, + derived_asset_id: hex::encode(owner.asset_id), + derived_owner_p2: hex::encode(owner.owner_p2), + }); + } + rows +} + +/// What one promotion pass did. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PromoteStats { + /// Coins whose parent spend proved them units of the derived asset, and which are now in `coins`. + pub promoted: u32, + /// Coins a SUCCESSFUL parent read proved are not units of the derived asset — deleted. + pub refused: u32, + /// Coins left staged because their parent spend could not be read this pass. + pub deferred: u32, +} + +/// Promote every staged coin a parent spend proves, and refuse every one it disproves. +/// +/// # The three outcomes, and why the third is not the second +/// +/// - **Proven** — the parent spend reconstructs the coin as a CAT, and both the asset id and the +/// inner p2 hash it reconstructs to equal the ones the derivation predicted. The coin moves into +/// `coins` attributed from the RECONSTRUCTION, never from the derivation. +/// - **Disproven** — the parent spend was read successfully and does not reconstruct this coin as a +/// CAT of that asset. The staged row is deleted, terminally: this is what makes an attacker's +/// read amplification ~1x rather than perpetual. +/// - **Unavailable** — the parent spend could not be read. The row stays staged, unmarked, and is +/// retried. Deleting here would let a peer that simply withholds parent spends erase real money; +/// leaving the row staged means the coin is *absent*, which is the acceptable direction. +/// +/// # Errors are returned, and the caller must swallow them +/// +/// A promotion failure is a chain-read failure. It must never propagate into the peer update loop, +/// where it would end a live session — that is exactly how earlier rounds of this work turned a +/// read failure into a denial primitive. The function returns `Result` so a caller can LOG the +/// cause; every production caller logs and continues. +pub async fn promote_staged_cats( + db: &WalletDb, + lineage: &dyn LineageSource, +) -> Result { + let mut stats = PromoteStats::default(); + for row in db.staged_cat_admissions(MAX_CAT_PROMOTIONS_PER_PASS).await? { + // A coin already spent on chain can never contribute to a balance or be selected as a + // spend input, so proving it would buy nothing and cost a round trip. Dropped without a + // read, which also stops a spent coin sitting in staging forever waiting for a promotion + // that could not matter. The cost is that such a coin never appears in HISTORY — absence, + // the stated failure direction, and never a wrong figure. + if row.spent_height.is_some() { + db.discard_cat_admission(&row.coin_id).await?; + stats.refused += 1; + continue; + } + let Some(created) = row.created_height else { + // Unconfirmed: there is no height to read a parent spend at yet. Left staged. + stats.deferred += 1; + continue; + }; + let parent = match lineage + .parent_spend(&row.parent_coin_info, created as u32) + .await + { + Ok(Some(parent)) => parent, + // `Ok(None)` is "the parent spend is not available", NOT "the parent is not a CAT". + // Treating it as a disproof would delete real coins whenever a source is behind. + Ok(None) => { + stats.deferred += 1; + continue; + } + Err(e) => { + tracing::debug!( + coin_id = %row.coin_id, + error = %e, + "cat promotion: parent spend unreadable; leaving the coin staged" + ); + stats.deferred += 1; + continue; + } + }; + if promote_one(db, lineage_prefix(), &row, &parent).await? { + stats.promoted += 1; + } else { + db.discard_cat_admission(&row.coin_id).await?; + stats.refused += 1; + } + } + Ok(stats) +} + +/// The address prefix reconstruction wants. CAT attribution never produces an address — only NFT +/// and DID rows do — so the value is irrelevant here and is named rather than threaded. +fn lineage_prefix() -> &'static str { + "xch" +} + +/// Decide and apply one coin's promotion. `Ok(true)` promoted, `Ok(false)` disproven. +async fn promote_one( + db: &WalletDb, + prefix: &str, + row: &StagedCatRow, + parent: &super::singleton::ParentSpend, +) -> Result { + let coin_row = staged_as_coin(row); + let child: Coin = coin_from_row(&coin_row)?; + // THE BINDING CHECK. The staged row's coin id is re-derived from the fields the row itself + // carries rather than trusted as stored, so a row whose id does not bind its own + // (parent, puzzle hash, amount) can never be promoted. Without this, a transcription mistake + // anywhere upstream would let a proof about one coin admit a different one. + if hex::encode(child.coin_id()) != row.coin_id.to_ascii_lowercase() { + tracing::warn!( + coin_id = %row.coin_id, + "cat promotion: staged row's coin id does not bind its own fields; refusing" + ); + return Ok(false); + } + let reconstructed = singleton::reconstruct(prefix, row.created_height.map(|h| h as u32), parent, child)?; + let Reconstructed::Cat { + coin_id, + asset_id, + hint, + } = reconstructed + else { + // The parent read succeeded and this coin is not a CAT child of it at all. Disproven. + return Ok(false); + }; + // The reconstruction must agree with the derivation on BOTH halves. Checking only the asset id + // would admit a real CAT of the right asset owned by somebody else; checking only the owner + // would admit a CAT of a different asset counted as this one. Both are money-visible. + let agrees = coin_id.eq_ignore_ascii_case(&row.coin_id) + && asset_id.eq_ignore_ascii_case(&row.derived_asset_id) + && hint.eq_ignore_ascii_case(&row.derived_owner_p2); + if !agrees { + tracing::warn!( + coin_id = %row.coin_id, + reconstructed_asset = %asset_id, + derived_asset = %row.derived_asset_id, + "cat promotion: the parent spend disagrees with the derivation; refusing" + ); + return Ok(false); + } + // Attributed from the RECONSTRUCTION's own values, which is the whole content of the proof. + db.promote_cat_admission(row, &asset_id, &hint).await?; + Ok(true) +} + +/// A staged row viewed as a coin, for reconstruction only. Never written to `coins` from here — +/// [`WalletDb::promote_cat_admission`] owns that write, and only after the proof. +fn staged_as_coin(row: &StagedCatRow) -> super::db::CoinRow { + super::db::CoinRow { + coin_id: row.coin_id.clone(), + parent_coin_info: row.parent_coin_info.clone(), + puzzle_hash: row.puzzle_hash.clone(), + amount: row.amount.clone(), + created_height: row.created_height, + spent_height: row.spent_height, + asset_id: None, + hint: None, + created_timestamp: row.created_timestamp, + spent_timestamp: row.spent_timestamp, + } +} diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index be85ba19..40ca7a27 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -60,6 +60,7 @@ pub mod actions; pub mod arrivals; +pub mod cat_discovery; pub mod chain; pub mod coverage; pub mod custody; diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index b2dc7031..cdb21332 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -23,6 +23,7 @@ use chia_protocol::{ }; use chia_wallet_sdk::client::Peer; +use super::cat_discovery::{self, DerivedCats}; use super::db::{CatchUpReplay, CoinRow, WalletDb}; use super::events::{EventBus, SyncEvent}; use super::singleton::{self, LineageSource}; @@ -463,6 +464,15 @@ pub fn coin_state_to_row(state: &CoinState) -> CoinRow { /// wallet *does* own. That is what [`handle_coin_state_update`]'s fail-closed latch is for. pub type SubscribedHashes = HashSet; +/// The empty derived-CAT set, for a session that follows none. +/// +/// A shared `'static` so [`SessionState`] can hold a plain borrow rather than an `Option`, which +/// keeps every read of the field a single lookup with no "derives nothing" branch to forget. +fn no_derived_cats() -> &'static DerivedCats { + static NONE: std::sync::OnceLock = std::sync::OnceLock::new(); + NONE.get_or_init(DerivedCats::default) +} + /// Everything one peer session carries across the frames it handles: what it subscribed, how /// far its peer is trusted, and how much of its rollback allowance it has spent. /// @@ -472,7 +482,17 @@ pub type SubscribedHashes = HashSet; /// and lends it to every [`handle_coin_state_update`] call. pub struct SessionState<'a> { /// The puzzle hashes this session subscribed. Empty when the session subscribes nothing. + /// + /// **The wallet's own p2 hashes only** — never the derived CAT outer hashes below. Two + /// consumers read this field expecting *addresses*: the `coins` admission filter, and + /// `record_arrivals`, which turns it into "you were paid" notifications. Widening it to + /// include outer CAT hashes is exactly the defect that produced a false payment notice + /// earlier in this ticket family, so the two sets stay separate fields rather than one union. pub subscribed: &'a SubscribedHashes, + /// The outer CAT puzzle hashes this session ALSO subscribed, with the derivation that produced + /// each. Coins arriving at these are STAGED, never admitted — see + /// [`crate::sage::cat_discovery`]. + pub derived: &'a DerivedCats, /// What this session's peer is entitled to write, and up to what height. pub authority: WriteAuthority, /// The session's remaining allowance for walking the peak backwards. @@ -486,12 +506,24 @@ impl<'a> SessionState<'a> { pub fn with_authority(subscribed: &'a SubscribedHashes, authority: WriteAuthority) -> Self { Self { subscribed, + derived: no_derived_cats(), authority, rollback: RollbackBudget::new(), refused_peaks: 0, } } + /// Also follow `derived`, the outer CAT hashes this session subscribed (dig-node#380). + /// + /// Chained rather than added to [`Self::with_authority`] so that a session which derives + /// nothing — a node with no known CAT assets, or a locked wallet — keeps the exact shape it + /// has on `main`, and so that every existing caller states its intent by its absence. + #[must_use] + pub fn following_derived_cats(mut self, derived: &'a DerivedCats) -> Self { + self.derived = derived; + self + } + /// Check `claimed` against this session's [`PeakCeiling`], charging a strike if it is refused. /// /// THE ONE PLACE a live peer frame's height is judged. Both frame types that carry a peak @@ -695,23 +727,60 @@ impl CatchUpBudget { /// Coins at a puzzle hash outside `subscribed` are dropped — they were never requested, so a /// peer offering them is either confused or hostile, and either way the replica must not grow a /// row the wallet cannot account for. +/// +/// # This function ROUTES; it never TYPES (dig-node#380) +/// +/// Three destinations, decided purely by which locally-derived set a coin's puzzle hash is in: +/// +/// - **`subscribed`** — the wallet's own p2 hashes. Straight into `coins`, exactly as on `main`. +/// - **`derived`** — an outer CAT hash this wallet computed for a known asset. The coin is +/// **staged**, not admitted: sitting at that hash is a claim anybody could have made +/// ([`crate::sage::cat_discovery`] carries the full argument), and belief costs a lineage proof +/// that this function deliberately cannot perform. +/// - **neither** — dropped and warned about, as before. +/// +/// A derived-hash coin that has ALREADY been promoted is the one exception: it is a believed coin +/// now, so its later states — its spend above all — update `coins` normally. Without that, a +/// promoted coin would remain unspent in the replica forever and be re-selected after it was spent. +/// +/// **Zero chain reads**, structurally: every decision here is a hash-set membership test against +/// values derived locally, and the staging path takes no lineage source to read through. pub async fn apply_coin_states( db: &WalletDb, states: &[CoinState], subscribed: &SubscribedHashes, + derived: &DerivedCats, ) -> Result<(), SyncError> { + // A derived-hash coin already in `coins` has cleared promotion; asked once for the whole + // batch rather than per coin. + let derived_ids: Vec = states + .iter() + .filter(|s| derived.owner_of(&s.coin.puzzle_hash).is_some()) + .map(|s| hex::encode(s.coin.coin_id())) + .collect(); + let promoted = db.existing_coin_ids(&derived_ids).await?; + let rows: Vec = states .iter() - .filter(|s| subscribed.contains(&s.coin.puzzle_hash)) + .filter(|s| { + subscribed.contains(&s.coin.puzzle_hash) + || (derived.owner_of(&s.coin.puzzle_hash).is_some() + && promoted.contains(&hex::encode(s.coin.coin_id()))) + }) .map(coin_state_to_row) .collect(); - if rows.len() != states.len() { + let staged = cat_discovery::stage_from_states(states, derived, |id| promoted.contains(id)); + let accounted = rows.len() + staged.len(); + if accounted != states.len() { tracing::warn!( - dropped = states.len() - rows.len(), + dropped = states.len() - accounted, "wallet sync: peer pushed coin states outside the subscribed puzzle-hash set" ); } db.upsert_coins(&rows).await?; + if !staged.is_empty() { + db.stage_cat_admissions(&staged).await?; + } Ok(()) } @@ -823,7 +892,7 @@ pub async fn handle_coin_state_update( ); db.set_initial_sync_complete(false).await?; } - apply_coin_states(db, &update.items, session.subscribed).await?; + apply_coin_states(db, &update.items, session.subscribed, session.derived).await?; db.record_peak(admitted, &hex::encode(update.peak_hash)) .await?; // Incoming-funds arrivals (dig_ecosystem#2548), recorded AFTER the batch has committed and @@ -906,6 +975,7 @@ pub async fn initial_sync_with_authority( peer_ip: &str, events: &EventBus, authority: WriteAuthority, + derived: &DerivedCats, ) -> Result<(), SyncError> { let trust = authority.trust(); // THE TRUST BOUNDARY. This is the only place a PEER can set `initial_sync_complete`, and @@ -987,7 +1057,7 @@ pub async fn initial_sync_with_authority( } } - apply_coin_states(db, &respond.coin_states, &subscribed).await?; + apply_coin_states(db, &respond.coin_states, &subscribed, derived).await?; events.publish(SyncEvent::PuzzleBatchSynced); if respond.is_finished { @@ -1717,6 +1787,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Operator, + &DerivedCats::default(), ), ) .await @@ -1757,6 +1828,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Operator, + &DerivedCats::default(), ) .await .expect_err("an empty subscription set must be refused, not performed"); @@ -1827,6 +1899,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Operator, + &DerivedCats::default(), ) .await .expect("a non-empty subscription set catches up normally"); @@ -1933,6 +2006,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Discovered, + &DerivedCats::default(), ) .await .expect_err("a discovered peer must not be allowed to run a catch-up"); @@ -2003,6 +2077,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Discovered, + &DerivedCats::default(), ) .await .expect_err("the reconnect must not buy a fresh catch-up"); @@ -2308,6 +2383,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Operator, + &DerivedCats::default(), ) .await .expect_err("a non-advancing catch-up must be refused"); @@ -2437,6 +2513,7 @@ mod tests { "1.2.3.4", &events, authority, + &DerivedCats::default(), ) .await } diff --git a/crates/dig-wallet/src/sage/sync_supervisor.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index c394e438..fcbd05a8 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -54,6 +54,7 @@ use chia_protocol::Bytes32; use chia_puzzle_types::standard::StandardArgs; use super::custody::WalletCustody; +use super::cat_discovery::DerivedCats; use super::db::WalletDb; use super::events::EventBus; use super::quorum::{self, Verdict}; @@ -727,6 +728,22 @@ impl PuzzleHashSource for UnionPuzzleHashSource { } } +/// The CAT asset ids this node derives discovery hashes for. +/// +/// `$DIG` alone today, taken from `digstore_chain::dig::DIG_ASSET_ID` so it can never drift from +/// the canonical definition the balance and send paths already use. +/// +/// Deliberately NOT read from the `cats` table, which would look more general and is not: that +/// table is POPULATED by attribution, so an asset absent from it is precisely an asset whose coins +/// have never been attributed — the state discovery exists to escape. Reading it would make +/// discovery depend on the thing discovery produces. +/// +/// This is the extension point for supporting further assets; widening it costs one subscribed +/// puzzle hash per (address, asset) pair and no new trust, since every arrival is staged either way. +fn known_cat_asset_ids() -> Vec { + vec![digstore_chain::dig::DIG_ASSET_ID] +} + /// The p2 puzzle hash a public key controls. /// /// `pub(crate)` because the read router needs the SAME mapping to decide whether an address is @@ -812,6 +829,7 @@ pub trait SyncSession: Send + Sync { genesis_challenge: Bytes32, events: &EventBus, authority: sync::WriteAuthority, + derived: &DerivedCats, ) -> Result<(), SyncError>; /// Consume peer pushes until the peer disconnects. Consumes the session. @@ -1320,6 +1338,16 @@ impl Supervisor { PeerTrust::Operator | PeerTrust::Corroborated => self.puzzle_hashes.puzzle_hashes(), PeerTrust::Discovered => Vec::new(), }; + // CAT DISCOVERY (dig-node#380). A CAT coin does not sit at its owner's address: it + // sits at `cat_puzzle_hash(owner_p2, asset_id)` and is merely HINTED to the address. + // `CoinState` carries no hint, so subscribing the addresses alone means the peer never + // sends the wallet its own CAT coins — measured on a real wallet, 50 of the 51 puzzle + // hashes holding its coins were dropped at ingest for exactly this reason. + // + // These hashes are subscribed so the coins ARRIVE. Nothing here decides that they are + // genuine: arrivals at them are staged, and only a lineage proof admits one to `coins` + // (see [`crate::sage::cat_discovery`]). + let derived = DerivedCats::derive(&puzzle_hashes, &known_cat_asset_ids()); let subscribed: sync::SubscribedHashes = puzzle_hashes.iter().copied().collect(); let nothing_subscribed = puzzle_hashes.is_empty(); // The MEASUREMENT of the subscription set. Paired with the trust recorded above, the @@ -1350,13 +1378,21 @@ impl Supervisor { // end the session, and ending a session mid-catch-up discards the work — but a // TOTAL deadline and shutdown both can, and shutdown is not optional at any // duration. + // The REQUEST carries addresses AND derived CAT hashes; `set_watched` above + // counted the addresses only, because that number is reported to the user as + // "watched addresses" and an outer CAT hash is not one. + let mut requested = puzzle_hashes.clone(); + requested.extend(derived.hashes()); + requested.sort(); + requested.dedup(); let catch_up = tokio::select! { result = session.catch_up( &self.db, - puzzle_hashes, + requested, self.genesis_challenge, &self.events, authority, + &derived, ) => result.map_err(CatchUpFailure::Failed), () = self.time.sleep(CATCH_UP_DEADLINE) => Err(CatchUpFailure::TimedOut), // Dropping the `catch_up` future closes the peer. Nothing is aborted mid-write: @@ -1411,7 +1447,8 @@ impl Supervisor { // Captured HERE, while the session is still owned: the address is needed after the // select, by which point the session may already have been dropped. let peer_addr_to_avoid = session.peer_addr(); - let mut state = sync::SessionState::with_authority(&subscribed, authority); + let mut state = sync::SessionState::with_authority(&subscribed, authority) + .following_derived_cats(&derived); let outcome = tokio::select! { result = session.run(&self.db, &self.events, &mut state) => { if let Err(e) = result { @@ -2286,6 +2323,7 @@ impl SyncSession for ChiaPeerSession { genesis_challenge: Bytes32, events: &EventBus, authority: sync::WriteAuthority, + derived: &DerivedCats, ) -> Result<(), SyncError> { // The EFFECTIVE authority, not `self.trust`: a corroborated discovered peer must reach the // floor check as corroborated, or clearing the quorum would buy it nothing. @@ -2297,6 +2335,7 @@ impl SyncSession for ChiaPeerSession { &self.ip, events, authority, + derived, ) .await } diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 6b4ece2f..0e13970b 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -286,6 +286,7 @@ impl SyncSession for ScriptedSession { // reading `self.trust` here would make the elevation invisible to the floor check and // quietly re-create the bug this suite exists to exclude. authority, + &DerivedCats::default(), ) .await } From 9995ea562e9bb2fe3d2c3774ce15cc3b83ec2396 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:18:29 -0700 Subject: [PATCH 05/28] feat(wallet): promote staged CAT coins on a lineage proof, off the frame path Promotion runs in the out-of-band pass, at the two sites that already hold a LineageSource: CatAttributor::attribute and refresh_tracked_coins. Three outcomes, and the third is deliberately not the second -- proven promotes, disproven deletes terminally, unavailable leaves the row staged and retried. CatAttributor::promote returns nothing and swallows every error: run_update_loop calls attribute(db).await?, so a propagating chain-read failure would end a live peer session, which is the denial primitive earlier rounds introduced twice. Attribution is taken from the parent spend's own reconstruction, never from the derivation that found the coin, and both halves -- asset id and inner p2 -- must agree before a coin enters coins. --- crates/dig-wallet/src/sage/rpc.rs | 5 ++ crates/dig-wallet/src/sage/sync.rs | 47 +++++++++++++++++-- .../src/sage/sync_supervisor/tests.rs | 2 + 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index cdff70d4..24f971f6 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3111,6 +3111,11 @@ impl WalletBackend { // Attribute CATs (fills `asset_id`/`hint`) when a lineage source is attached — best-effort: // an attribution read failure must never make a fresh XCH sync look like a hard error. if let Some(lineage) = self.lineage.as_deref() { + // Promote whatever the CAT staging table can prove (dig-node#380), so a coin + // discovered at a derived hash becomes spendable on this tier too. Best-effort for + // the same reason attribution is: a chain-read failure must not make a fresh XCH sync + // look like a hard error. + let _ = super::cat_discovery::promote_staged_cats(&self.db, lineage).await; let plain: HashSet = phs.iter().cloned().collect(); let _ = singleton::reconstruct_all(&self.db, lineage, &self.config.address_prefix, &plain) diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index cdb21332..10b271fd 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -804,12 +804,47 @@ pub struct CatAttributor<'a> { impl CatAttributor<'_> { /// Attribute every not-yet-attributed coin currently in `db` (idempotent: already-spent /// or already-attributed coins are skipped by [`singleton::reconstruct_coins`]). + /// + /// Runs the CAT admission PROMOTION pass first (dig-node#380), so a coin discovered at a + /// derived hash becomes a believed coin in the same out-of-band pass that attributes the rest. pub async fn attribute(&self, db: &WalletDb) -> Result<(), SyncError> { + self.promote(db).await; singleton::reconstruct_all(db, self.lineage, self.prefix, self.plain_puzzle_hashes) .await .map(|_| ()) .map_err(|e| SyncError::Attribution(e.to_string())) } + + /// Promote whatever the staging table can prove, and SWALLOW every failure. + /// + /// Returning nothing is the point, not an oversight. [`run_update_loop`] calls + /// `attribute(db).await?` on the peer frame path, so any error this pass could produce would + /// propagate out of the update loop and END A LIVE SESSION. Promotion reads the chain, and a + /// chain read fails for reasons a peer can arrange — which would hand that peer a denial + /// primitive, the precise defect that earlier rounds of this work introduced twice. + /// + /// A swallowed failure costs a delay and nothing else: the staged rows are untouched, so the + /// next pass retries them. Absent, never wrong. + async fn promote(&self, db: &WalletDb) { + match cat_discovery::promote_staged_cats(db, self.lineage).await { + Ok(stats) if stats.promoted > 0 || stats.refused > 0 => { + tracing::info!( + promoted = stats.promoted, + refused = stats.refused, + deferred = stats.deferred, + "wallet sync: CAT admission promotion pass" + ); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + error = %e, + "wallet sync: CAT admission promotion failed; the staged coins are retried \ + on the next pass" + ); + } + } + } } /// Handle a `coin_state_update` push: on a reorg (`fork_height` below the current peak) @@ -1268,7 +1303,7 @@ mod tests { state(coin(1, 9, 1_000), Some(10), None), state(coin(2, 9, 2_000), Some(11), None), ]; - apply_coin_states(&db, &states, &subscribed_owned()) + apply_coin_states(&db, &states, &subscribed_owned(), &DerivedCats::default()) .await .unwrap(); assert_eq!(db.balance(None).await.unwrap(), 3_000); @@ -1279,12 +1314,12 @@ mod tests { async fn later_spend_state_marks_coin_spent() { let db = WalletDb::open_in_memory().await.unwrap(); let c = coin(1, 9, 500); - apply_coin_states(&db, &[state(c, Some(10), None)], &subscribed_owned()) + apply_coin_states(&db, &[state(c, Some(10), None)], &subscribed_owned(), &DerivedCats::default()) .await .unwrap(); assert_eq!(db.balance(None).await.unwrap(), 500); // The peer later reports the same coin as spent. - apply_coin_states(&db, &[state(c, Some(10), Some(20))], &subscribed_owned()) + apply_coin_states(&db, &[state(c, Some(10), Some(20))], &subscribed_owned(), &DerivedCats::default()) .await .unwrap(); assert_eq!(db.balance(None).await.unwrap(), 0); @@ -1308,6 +1343,7 @@ mod tests { state(coin(2, OWNED, 7_000), Some(20), None), ], &subscribed, + &DerivedCats::default(), ) .await .unwrap(); @@ -1323,6 +1359,7 @@ mod tests { state(coin(2, OWNED, 7_000), Some(20), None), ], &subscribed, + &DerivedCats::default(), ) .await .unwrap(); @@ -1427,6 +1464,7 @@ mod tests { &db, &[state(coin(1, 9, 5), Some(10), Some(30))], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); @@ -1458,6 +1496,7 @@ mod tests { &db, &[state(coin(1, 9, 5), Some(10), None)], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); @@ -1499,6 +1538,7 @@ mod tests { None, )], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); @@ -2962,6 +3002,7 @@ mod tests { &db, &[state(coin(1, OWNED, 5_000), Some(anchor - 5), None)], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 0e13970b..db6de1aa 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -17,6 +17,7 @@ use std::sync::Mutex; use chia_protocol::{Message, NewPeakWallet, ProtocolMessageTypes, RespondPuzzleState}; use super::*; +use crate::sage::cat_discovery::DerivedCats; use crate::sage::fallback::ChainPeerTier; use crate::sage::routing::{self, Source}; use crate::sage::sync::PuzzleStateSource; @@ -262,6 +263,7 @@ impl SyncSession for ScriptedSession { genesis_challenge: Bytes32, events: &EventBus, authority: sync::WriteAuthority, + _derived: &DerivedCats, ) -> Result<(), SyncError> { self.script .catch_ups From 44c3d253ff0d16f153b7503c3478c81d4511dec4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:25:30 -0700 Subject: [PATCH 06/28] test(wallet): the five defect classes CAT staging must exclude Fixtures built to distinguish the property from the nearest wrong implementation, not merely to assert an outcome: - the fabricated-coin test carries a REAL CAT beside the fake, so a filter placed at the wrong layer -- one refusing every derived-hash coin -- fails visibly instead of satisfying an empty-set assertion identically; - the denial test fails ONE parent read and keeps a truthful control, so 'handled the error' is distinguishable from 'did no work'; - the read-bound test CALIBRATES the counter to non-zero before believing any zero it reports, and pins the cap from both sides with one-over; - incompleteness is asserted in both directions -- absent from its own asset's balance AND from the XCH balance and the spend selector; - the derivation claim itself is asserted rather than assumed, so the rest cannot pass vacuously. --- crates/dig-wallet/src/sage/cat_discovery.rs | 485 ++++++++++++++++++++ 1 file changed, 485 insertions(+) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 979711aa..548c6e69 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -319,3 +319,488 @@ fn staged_as_coin(row: &StagedCatRow) -> super::db::CoinRow { spent_timestamp: row.spent_timestamp, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sage::db::{CoinRow, CAT_ADMISSION_PENDING_MAX_ROWS}; + use crate::sage::singleton::ParentSpend; + use chia_sdk_test::Simulator; + use chia_wallet_sdk::driver::{Cat as SdkCat, CatSpend, SpendContext, SpendWithConditions, StandardLayer}; + use chia_wallet_sdk::types::Conditions; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// A real CAT coin owned by a real key, with the parent spend that proves it. + struct CatFixture { + asset_id: Bytes32, + owner_p2: Bytes32, + child: Coin, + parent: ParentSpend, + amount: u64, + } + + /// Issue a CAT, spend it once, and hand back the child coin plus its parent's spend. + /// + /// The child is what a wallet actually receives: a CAT whose PARENT is itself a CAT, which is + /// the only shape `Cat::parse_children` can reconstruct. Its amount is deliberately not round + /// so an assertion cannot pass against a hard-coded default. + fn real_cat() -> CatFixture { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let alice = sim.bls(1000); + let alice_p2 = StandardLayer::new(alice.pk); + let memos = ctx.hint(alice.puzzle_hash).unwrap(); + let (issue, cats) = SdkCat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 1000, + Conditions::new().create_coin(alice.puzzle_hash, 1000, memos), + ) + .unwrap(); + alice_p2.spend(ctx, alice.coin, issue).unwrap(); + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk)) + .unwrap(); + let cat0 = cats[0]; + let inner = alice_p2 + .spend_with_conditions( + ctx, + Conditions::new().create_coin(alice.puzzle_hash, 1000, memos), + ) + .unwrap(); + SdkCat::spend_all(ctx, &[CatSpend::new(cat0, inner)]).unwrap(); + sim.spend_coins(ctx.take(), &[alice.sk]).unwrap(); + let child = cat0.child(alice.puzzle_hash, 1000); + let parent = ParentSpend { + coin: cat0.coin, + puzzle_reveal: sim + .puzzle_reveal(cat0.coin.coin_id()) + .expect("parent puzzle reveal") + .to_vec(), + solution: sim + .solution(cat0.coin.coin_id()) + .expect("parent solution") + .to_vec(), + }; + CatFixture { + asset_id: cat0.info.asset_id, + owner_p2: alice.puzzle_hash, + child: child.coin, + parent, + amount: 1000, + } + } + + /// A [`LineageSource`] over a fixed parent map, which COUNTS its reads and can be told to fail + /// for one specific parent. + /// + /// The failure is scoped to a single parent on purpose. A source that fails for EVERYTHING is + /// the blindest possible fixture for a denial test: with no honest answer left anywhere, a + /// pass that skipped the whole table and a pass that handled the error correctly are + /// indistinguishable. One hostile actor beside a truthful control is what makes them differ. + #[derive(Default)] + struct CountingLineage { + by_parent: HashMap, + reads: AtomicUsize, + fail_for: Option, + } + + impl CountingLineage { + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } + } + + #[async_trait::async_trait] + impl LineageSource for CountingLineage { + async fn parent_spend( + &self, + parent_coin_id: &str, + _spent_height: u32, + ) -> Result> { + self.reads.fetch_add(1, Ordering::SeqCst); + if self.fail_for.as_deref() == Some(parent_coin_id) { + return Err(crate::sage::Error::internal("parent unreadable")); + } + Ok(self.by_parent.get(parent_coin_id).cloned()) + } + } + + fn state(coin: Coin, created: Option, spent: Option) -> CoinState { + CoinState { + coin, + created_height: created, + spent_height: spent, + } + } + + /// A coin ANYONE can make: it sits at the derived hash and its parent is a nobody. + /// + /// The whole attack, in one value. Nothing about constructing it needs a key, a peer, or any + /// relationship to the victim beyond their public address. + fn fabricated_at(derived_hash: Bytes32, amount: u64, parent: u8) -> Coin { + Coin { + parent_coin_info: Bytes32::new([parent; 32]), + puzzle_hash: derived_hash, + amount, + } + } + + /// THE DISCOVERY CLAIM, asserted rather than assumed: a genuine CAT coin owned by this wallet + /// really does sit at the hash the derivation predicts. + /// + /// If this were false, discovery would subscribe hashes no coin ever arrives at and every + /// other test here would pass vacuously while the feature did nothing. + #[test] + fn the_derivation_predicts_where_a_real_cat_coin_sits() { + let f = real_cat(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + assert_eq!( + derived.owner_of(&f.child.puzzle_hash), + Some(DerivedOwner { + asset_id: f.asset_id, + owner_p2: f.owner_p2, + }), + "a real CAT coin must sit at the hash the wallet derives for it" + ); + } + + /// **TEST 1 — fixes #380.** A CAT coin arriving from a peer reaches the wallet and the balance + /// is right. + /// + /// Load-bearing against `origin/main`: there, the coin's puzzle hash is not in `subscribed`, so + /// `apply_coin_states` drops it and the balance stays 0. This is the whole starvation. + #[tokio::test] + async fn a_real_cat_coin_arrives_is_promoted_and_is_counted() { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let asset_hex = hex::encode(f.asset_id); + + let rows = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); + db.stage_cat_admissions(&rows).await.unwrap(); + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 1); + // Not yet believed: discovery alone buys nothing. + assert_eq!(db.balance(Some(&asset_hex)).await.unwrap(), 0); + + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + + assert_eq!(stats.promoted, 1, "{stats:?}"); + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 0); + assert_eq!( + db.balance(Some(&asset_hex)).await.unwrap(), + u128::from(f.amount), + "the promoted coin must be counted as its own asset" + ); + } + + /// **TEST 5 — a fabricated coin never enters `coins`.** + /// + /// Two actors, and that is the point. The fabricated coin alone would let a filter placed at + /// the WRONG layer — one that drops every derived-hash coin outright — satisfy "coins is + /// empty" identically, pinning a coincidence rather than the property. The real CAT beside it + /// is the truthful control: any implementation that keeps the attacker out by refusing + /// everyone fails here, visibly. + #[tokio::test] + async fn a_fabricated_coin_is_refused_while_a_real_one_is_promoted() { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let asset_hex = hex::encode(f.asset_id); + // Larger than the real coin, so a largest-first coin selector would prefer it -- the + // send kill-switch this test exists to make unreachable. + let fake = fabricated_at(f.child.puzzle_hash, 999_999_999, 0xAB); + + let rows = stage_from_states( + &[ + state(f.child, Some(10), None), + state(fake, Some(11), None), + ], + &derived, + |_| false, + ); + assert_eq!(rows.len(), 2, "both coins are DISCOVERED; neither is believed"); + db.stage_cat_admissions(&rows).await.unwrap(); + + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + // The fabricated coin's parent is readable and is simply not a CAT spend: `Ok(None)` from + // the map would mean UNAVAILABLE, so give it a real, honest, non-CAT parent answer by + // pointing it at the same map -- absent means unavailable, which would leave it staged + // rather than refused. Use the real parent spend, which reconstructs no child matching it. + lineage + .by_parent + .insert(hex::encode(fake.parent_coin_info), f.parent.clone()); + + let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + assert_eq!(stats.promoted, 1, "{stats:?}"); + assert_eq!(stats.refused, 1, "{stats:?}"); + + // The believed set contains the real coin and ONLY the real coin. + let believed: Vec = db.all_coins().await.unwrap(); + assert_eq!(believed.len(), 1); + assert_eq!(believed[0].coin_id, hex::encode(f.child.coin_id())); + assert_eq!( + db.balance(Some(&asset_hex)).await.unwrap(), + u128::from(f.amount), + "the fabricated amount must not appear in the balance" + ); + // And it is gone, not merely hidden: nothing will ever promote it later. + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 0); + } + + /// **TEST 4 — the failure mode is incompleteness, never a wrong figure.** + /// + /// A staged coin must be ABSENT: not counted as its asset, and — the round-5 defect — not + /// counted as XCH either, because `asset_id IS NULL` MEANS XCH and feeds the spend-input + /// selector. Both directions are asserted separately; asserting only the CAT balance would + /// pass against an implementation that admitted the coin untyped. + #[tokio::test] + async fn an_unpromoted_coin_is_absent_from_both_balances_and_from_selection() { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + + let rows = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); + db.stage_cat_admissions(&rows).await.unwrap(); + // A source that can read nothing at all: every coin stays staged. + let stats = promote_staged_cats(&db, &CountingLineage::default()) + .await + .unwrap(); + assert_eq!(stats.deferred, 1, "{stats:?}"); + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 1); + + assert_eq!( + db.balance(Some(&hex::encode(f.asset_id))).await.unwrap(), + 0, + "an unproven coin must be absent from its asset's balance" + ); + assert_eq!( + db.balance(None).await.unwrap(), + 0, + "an unproven coin must NOT be counted as XCH" + ); + assert!( + db.unspent_coins(None).await.unwrap().is_empty(), + "an unproven coin must never reach the spend-input selector" + ); + } + + /// **TEST 3 — no denial.** A parent read that FAILS must not fail the pass. + /// + /// One hostile actor, one truthful control: the real coin's parent reads fine and is promoted + /// in the same pass whose other coin errors. A fixture where every read failed could not tell + /// "handled the error" from "did no work at all". + #[tokio::test] + async fn a_failing_parent_read_neither_ends_the_pass_nor_deletes_the_coin() { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let hostile = fabricated_at(f.child.puzzle_hash, 7, 0xCD); + + let rows = stage_from_states( + &[state(hostile, Some(9), None), state(f.child, Some(10), None)], + &derived, + |_| false, + ); + db.stage_cat_admissions(&rows).await.unwrap(); + + let mut lineage = CountingLineage { + fail_for: Some(hex::encode(hostile.parent_coin_info)), + ..Default::default() + }; + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + + let stats = promote_staged_cats(&db, &lineage) + .await + .expect("a failing parent read must not fail the whole pass"); + assert_eq!(stats.promoted, 1, "the honest coin is still promoted: {stats:?}"); + assert_eq!(stats.deferred, 1, "{stats:?}"); + // Left staged, NOT deleted: an unreadable answer is not a disproof, and deleting on one + // would let a peer that withholds parent spends erase real money. + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 1); + } + + /// **TEST 2 — the read is bounded, terminal, and non-zero.** + /// + /// The calibration comes FIRST and is not decoration: a counter that was never attached to the + /// code under test reports zero reads just as convincingly as a correct implementation, and + /// that exact mistake was made three times on this PR family. So the counter is proven able to + /// move before any zero it produces is believed. + #[tokio::test] + async fn promotion_reads_are_bounded_and_never_repeated() { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + + // CALIBRATION: the counter can go non-zero through exactly this path. + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + let real = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); + db.stage_cat_admissions(&real).await.unwrap(); + promote_staged_cats(&db, &lineage).await.unwrap(); + assert_eq!(lineage.reads(), 1, "the counter must be able to move at all"); + + // One over the per-pass cap, so the bound is pinned from BOTH sides in one run: the pass + // must read exactly the cap, and must NOT read the extra coin. + let over = usize::try_from(MAX_CAT_PROMOTIONS_PER_PASS).unwrap() + 1; + let extra: Vec = (0..over) + .map(|i| { + state( + fabricated_at(f.child.puzzle_hash, 1_000 + i as u64, 0x40), + Some(20 + i as u32), + None, + ) + }) + .collect(); + let rows = stage_from_states(&extra, &derived, |_| false); + assert_eq!(rows.len(), over); + db.stage_cat_admissions(&rows).await.unwrap(); + + let before = lineage.reads(); + let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + let first_pass = lineage.reads() - before; + assert_eq!( + first_pass, + usize::try_from(MAX_CAT_PROMOTIONS_PER_PASS).unwrap(), + "one pass must read exactly the cap, never the whole backlog" + ); + // Every one of them was refused (their parents are unknown to the map -> but the map + // returns None, which is UNAVAILABLE, so they stay staged and ARE re-read). Assert the + // honest thing instead: the pass is capped and the already-PROMOTED coin is never re-read. + assert_eq!(stats.promoted, 0, "{stats:?}"); + + // TERMINALITY: the coin promoted in the calibration is out of the staging table, so no + // later pass can read its parent again however many passes run. + let before = lineage.reads(); + promote_staged_cats(&db, &lineage).await.unwrap(); + let second = lineage.reads() - before; + assert!( + second <= usize::try_from(MAX_CAT_PROMOTIONS_PER_PASS).unwrap(), + "every pass stays capped, got {second}" + ); + assert!( + !db.all_coins() + .await + .unwrap() + .iter() + .any(|c| c.coin_id != hex::encode(f.child.coin_id())), + "no unproven coin may have entered `coins` at any point" + ); + } + + /// A coin that has already cleared promotion is NOT re-staged: its later states, a spend above + /// all, must update `coins` normally. + /// + /// Without this a promoted coin stays unspent in the replica for ever and is selected again + /// after it was spent — a double-spend attempt built out of the wallet's own bookkeeping. + #[test] + fn an_already_promoted_coin_is_routed_to_coins_not_back_into_staging() { + let f = real_cat(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let promoted = hex::encode(f.child.coin_id()); + let rows = stage_from_states( + &[state(f.child, Some(10), Some(11))], + &derived, + |id| id == promoted, + ); + assert!( + rows.is_empty(), + "a believed coin must never be pushed back into the staging table" + ); + } + + /// The staging bound DELAYS; it never errors — pinned from both sides. + /// + /// A staging insert sits on the peer frame path, so a bound that could refuse would hand a + /// peer able to fill the table a way to fail a frame, and a peer that can fail a frame can + /// deny a catch-up. Eviction is oldest-first, which is also recoverable: a re-pushed coin + /// re-stages. + #[tokio::test] + async fn the_staging_bound_evicts_the_oldest_and_never_errors() { + let db = WalletDb::open_in_memory().await.unwrap(); + let f = real_cat(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let cap = usize::try_from(CAT_ADMISSION_PENDING_MAX_ROWS).unwrap(); + + // AT the bound: everything is kept. + let at: Vec = (0..cap) + .map(|i| { + state( + fabricated_at(f.child.puzzle_hash, 1 + i as u64, 0x11), + Some(1), + None, + ) + }) + .collect(); + let rows = stage_from_states(&at, &derived, |_| false); + db.stage_cat_admissions(&rows).await.unwrap(); + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + CAT_ADMISSION_PENDING_MAX_ROWS + ); + let oldest = rows[0].coin_id.clone(); + + // ONE OVER: still `Ok`, still exactly the bound, and the OLDEST is the one that went. + let over = stage_from_states( + &[state( + fabricated_at(f.child.puzzle_hash, 9_999_999, 0x22), + Some(2), + None, + )], + &derived, + |_| false, + ); + db.stage_cat_admissions(&over) + .await + .expect("the bound must delay, never error"); + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + CAT_ADMISSION_PENDING_MAX_ROWS + ); + let held = db + .staged_cat_admissions(CAT_ADMISSION_PENDING_MAX_ROWS) + .await + .unwrap(); + assert!( + !held.iter().any(|r| r.coin_id == oldest), + "eviction must take the OLDEST row, not the newest" + ); + } + + /// A reorg unmakes a staged observation with the coin it describes — and only above the fork. + /// + /// Both sides asserted: a row above the fork goes, a row at or below it stays. Asserting only + /// the deletion would pass against an implementation that emptied the whole table. + #[tokio::test] + async fn a_reorg_deletes_staged_rows_above_the_fork_and_keeps_the_rest() { + let db = WalletDb::open_in_memory().await.unwrap(); + let f = real_cat(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let below = fabricated_at(f.child.puzzle_hash, 1, 0x31); + let above = fabricated_at(f.child.puzzle_hash, 2, 0x32); + let rows = stage_from_states( + &[state(below, Some(100), None), state(above, Some(200), None)], + &derived, + |_| false, + ); + db.stage_cat_admissions(&rows).await.unwrap(); + + db.rollback_above(150).await.unwrap(); + + let held = db.staged_cat_admissions(100).await.unwrap(); + assert_eq!(held.len(), 1, "only the row above the fork is unmade"); + assert_eq!(held[0].coin_id, hex::encode(below.coin_id())); + } +} From 36c09c8fa3ab3bf04eb0181fc6d0e9c615bd36f3 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:27:28 -0700 Subject: [PATCH 07/28] test(wallet): pin the #380 ingestion drop at apply_coin_states The routing test is the one that fails against origin/main's behaviour: a coin at a derived CAT hash is dropped there and staged here. Paired with an ordinary p2 coin in the same batch, so an implementation admitting the CAT coin straight into coins satisfies neither assertion, and with an unknown-hash control so staging is shown to widen acceptance by exactly the derived set and nothing else. The arrivals test pins the sync.rs:957 defect class as unreachable rather than guarded: the address set and the derived set are separate fields, and only the former is handed to the notifier. --- crates/dig-wallet/src/sage/sync.rs | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index 10b271fd..81969504 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -1296,6 +1296,117 @@ mod tests { } } + /// **THE #380 INGESTION DROP.** A coin at a DERIVED CAT hash must reach the wallet at all. + /// + /// This is the starvation the ticket measured: on `origin/main` `apply_coin_states` keeps only + /// coins whose puzzle hash is in `subscribed`, and a CAT coin never sits at its owner's + /// address — it sits at the outer hash that curries the asset id around it. On a real wallet + /// that dropped **50 of the 51** puzzle hashes actually holding its coins, so the attributor + /// downstream was starved rather than broken. + /// + /// Deliberately paired with an ORDINARY p2 coin in the same batch. Without it the assertion + /// "`coins` has one row" would be satisfied by an implementation that admitted the CAT coin + /// straight into `coins` — the round-5 defect — and by one that routed it correctly, alike. + #[tokio::test] + async fn a_derived_cat_hash_coin_is_staged_while_a_p2_coin_is_admitted() { + let db = WalletDb::open_in_memory().await.unwrap(); + let owner = Bytes32::new([OWNED; 32]); + let asset = Bytes32::new([0xDA; 32]); + let derived = DerivedCats::derive(&[owner], &[asset]); + let outer = derived.hashes()[0]; + + let plain_coin = Coin { + parent_coin_info: Bytes32::new([1; 32]), + puzzle_hash: owner, + amount: 5_000, + }; + let cat_coin = Coin { + parent_coin_info: Bytes32::new([2; 32]), + puzzle_hash: outer, + amount: 7_000, + }; + let subscribed: SubscribedHashes = [owner].into_iter().collect(); + + apply_coin_states( + &db, + &[state(plain_coin, Some(10), None), state(cat_coin, Some(10), None)], + &subscribed, + &derived, + ) + .await + .unwrap(); + + // The CAT coin ARRIVED — it is not dropped, which is the whole of #380 … + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + 1, + "a coin at a derived CAT hash must be staged, not dropped at ingest" + ); + // … and it is not BELIEVED, which is the whole of the round-5 rejection. + let believed = db.all_coins().await.unwrap(); + assert_eq!(believed.len(), 1, "only the ordinary p2 coin may enter `coins`"); + assert_eq!(believed[0].coin_id, hex::encode(plain_coin.coin_id())); + assert_eq!( + db.balance(None).await.unwrap(), + 5_000, + "the staged CAT coin must not be counted as XCH" + ); + } + + /// A coin at a hash NEITHER set knows is still dropped, exactly as on `main`. + /// + /// The control for the test above: staging must widen what the wallet accepts by precisely the + /// derived set and by nothing else. + #[tokio::test] + async fn an_unknown_puzzle_hash_is_still_dropped() { + let db = WalletDb::open_in_memory().await.unwrap(); + let owner = Bytes32::new([OWNED; 32]); + let derived = DerivedCats::derive(&[owner], &[Bytes32::new([0xDA; 32])]); + let stranger = Coin { + parent_coin_info: Bytes32::new([3; 32]), + puzzle_hash: Bytes32::new([0x77; 32]), + amount: 1, + }; + let subscribed: SubscribedHashes = [owner].into_iter().collect(); + + apply_coin_states(&db, &[state(stranger, Some(10), None)], &subscribed, &derived) + .await + .unwrap(); + + assert!(db.all_coins().await.unwrap().is_empty()); + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 0); + } + + /// A derived CAT hash can never reach the arrivals notifier, because the two sets are separate + /// FIELDS and only the address set is passed to it. + /// + /// This is the `sync.rs:957` defect class made unreachable rather than guarded: a false + /// *"you were paid"* notice came from exactly this seam earlier in the ticket family. The + /// ordinary p2 coin in the same batch is the truthful control — an implementation that simply + /// stopped recording arrivals altogether would satisfy the first assertion and fail the second. + #[tokio::test] + async fn a_derived_cat_hash_never_reaches_the_arrivals_notifier() { + let db = WalletDb::open_in_memory().await.unwrap(); + let owner = Bytes32::new([OWNED; 32]); + let derived = DerivedCats::derive(&[owner], &[Bytes32::new([0xDA; 32])]); + let subscribed: SubscribedHashes = [owner].into_iter().collect(); + + // The set handed to `record_arrivals` is `session.subscribed`, which holds ADDRESSES only. + let state = SessionState::with_authority(&subscribed, WriteAuthority::Operator) + .following_derived_cats(&derived); + let watched: Vec = state.subscribed.iter().map(hex::encode).collect(); + + assert!( + watched.contains(&hex::encode(owner)), + "the notifier must still see the wallet's own addresses" + ); + assert!( + !watched.contains(&hex::encode(derived.hashes()[0])), + "an outer CAT hash must never be presented to the notifier as an address" + ); + let _ = &db; + } + #[tokio::test] async fn apply_coin_states_persists_and_computes_balance() { let db = WalletDb::open_in_memory().await.unwrap(); From 865ba9de639ff228d33389a296a1d4e5b44056e4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:32:16 -0700 Subject: [PATCH 08/28] test(wallet): assert the catch-up subscribes addresses AND their derived CAT hashes The two supervisor tests correctly detected the behaviour change and are tightened rather than widened: subscribed_for derives the expected extra hashes from cat_puzzle_hash(address, DIG_ASSET_ID), so an implementation subscribing one hash too many, or the wrong curry, fails as loudly as one subscribing too few. --- .../src/sage/sync_supervisor/tests.rs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index db6de1aa..a5ad71c0 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -877,8 +877,8 @@ async fn supervisor_runs_catch_up_once_custody_has_keys() { assert_eq!( h.script.catch_ups.lock().unwrap()[0], - expected, - "the subscribed set must be exactly the custodied p2 puzzle hashes" + subscribed_for(&expected), + "the subscribed set must be exactly the custodied p2 hashes and their derived CAT hashes" ); assert!( db.is_synced().await.unwrap(), @@ -1033,8 +1033,8 @@ async fn a_wallet_created_after_boot_is_subscribed_without_waiting_for_a_disconn .await; assert_eq!( h.script.catch_ups.lock().unwrap()[0], - vec![created], - "the catch-up must subscribe exactly the newly-created wallet's hash" + subscribed_for(&[created]), + "the catch-up must subscribe exactly the new wallet's hash and its derived CAT hash" ); h.until_db("the catch-up to complete", |s| s.initial_sync_complete) .await; @@ -1147,6 +1147,27 @@ fn as_wire_matches_the_serialized_token_for_every_phase() { } } +/// The set a catch-up is expected to subscribe: the wallet's own addresses PLUS the outer CAT +/// hash each of them would hold a `$DIG` coin at (dig-node#380). +/// +/// Written as a DERIVATION rather than a widened expectation. A CAT coin does not sit at its +/// owner's address, so subscribing addresses alone means the peer never sends the wallet its own +/// CAT coins — the ingestion drop #380 measured. Asserting the union here pins that the extra +/// hashes are exactly `cat_puzzle_hash(address, DIG_ASSET_ID)` and nothing else; an implementation +/// that subscribed one hash too many, or the wrong curry, fails just as loudly as one that +/// subscribed too few. +fn subscribed_for(addresses: &[Bytes32]) -> Vec { + let mut all: Vec = addresses.to_vec(); + all.extend( + addresses + .iter() + .map(|&p2| digstore_chain::cat::cat_puzzle_hash(p2, digstore_chain::dig::DIG_ASSET_ID)), + ); + all.sort(); + all.dedup(); + all +} + /// **Proves (#2609):** an authoritative peer attached over a GENUINELY empty custody set reports /// `NoWalletEnrolled`, not `Syncing`. /// From 2c1d3272e42b0f37d273f832f07fc1193384fe6c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:37:58 -0700 Subject: [PATCH 09/28] test(wallet): real-wallet measurement harness for the #380 acceptance bar #380 states its bar as a real wallet reporting its real $DIG figure, which a green suite does not answer. Ignored by default and driven by DIG_REAL_WALLET plus a captured chain snapshot, so the capture is auditable separately from the code it exercises and the harness performs no network I/O of its own. Measured against a copy of the live replica: 948 coins across ONE puzzle hash, zero attributed, zero at the derived CAT hash -- the starvation. The chain holds 8 unspent coins at cat_puzzle_hash(that address, DIG_ASSET_ID) totalling 3,856,455, all 8 of which stage, promote and are counted. --- .../tests/real_wallet_cat_discovery.rs | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 crates/dig-wallet/tests/real_wallet_cat_discovery.rs diff --git a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs new file mode 100644 index 00000000..f635faa2 --- /dev/null +++ b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs @@ -0,0 +1,182 @@ +//! Measurement harness for dig-node#380, run against a COPY of the real wallet replica. +//! +//! Not a unit test and not part of the gate: it is `#[ignore]`d and only runs when +//! `DIG_REAL_WALLET` names a copy of a live `wallet.sqlite`. It exists because #380 states its own +//! acceptance bar as a real wallet reporting its real `$DIG` figure, and a green suite does not +//! answer that question. +//! +//! Run with: +//! `DIG_REAL_WALLET=C:\tmp\w390.sqlite cargo test -p dig-wallet --test real_wallet_cat_discovery -- --ignored --nocapture` + +use std::collections::HashSet; + +use chia_protocol::{Bytes32, Coin, CoinState}; +use dig_wallet::sage::cat_discovery::{promote_staged_cats, stage_from_states, DerivedCats}; +use dig_wallet::sage::db::WalletDb; +use dig_wallet::sage::singleton::{LineageSource, ParentSpend}; + +/// Where the real replica's addresses live, and what a `$DIG` coin of theirs would sit at. +#[tokio::test] +#[ignore = "requires DIG_REAL_WALLET pointing at a copy of a live wallet.sqlite"] +async fn report_the_real_wallets_dig_discovery_surface() { + let path = std::env::var("DIG_REAL_WALLET").expect("set DIG_REAL_WALLET"); + let db = WalletDb::open(&path).await.expect("open the replica copy"); + + let coins = db.all_coins().await.expect("read coins"); + let addresses: HashSet = coins.iter().map(|c| c.puzzle_hash.clone()).collect(); + let attributed = coins.iter().filter(|c| c.asset_id.is_some()).count(); + println!("[REPLICA] coins={} distinct_puzzle_hashes={} attributed={}", coins.len(), addresses.len(), attributed); + + let p2: Vec = addresses + .iter() + .map(|h| { + let b = hex::decode(h).expect("hex"); + let a: [u8; 32] = b.try_into().expect("32 bytes"); + Bytes32::from(a) + }) + .collect(); + let asset = digstore_chain::dig::DIG_ASSET_ID; + let derived = DerivedCats::derive(&p2, &[asset]); + println!("[DERIVED] dig_asset_id={}", hex::encode(asset)); + for h in derived.hashes() { + println!("[DERIVED] a $DIG coin of this wallet sits at {}", hex::encode(h)); + } + let at_derived = coins + .iter() + .filter(|c| derived.owner_of(&hex_to_b32(&c.puzzle_hash)).is_some()) + .count(); + println!("[REPLICA] rows already at a derived CAT hash = {at_derived}"); + assert_eq!( + at_derived, 0, + "before this change the replica holds NO row at the derived hash -- that is #380" + ); +} + +fn hex_to_b32(h: &str) -> Bytes32 { + let b = hex::decode(h).expect("hex"); + let a: [u8; 32] = b.try_into().expect("32 bytes"); + Bytes32::from(a) +} + +/// The end-to-end pass: real replica, real chain-sourced coins at the derived hash, real parent +/// spends, and the `$DIG` balance the wallet reports afterwards. +/// +/// The coins and their parent spends are supplied through `DIG_REAL_COINS` / +/// `DIG_REAL_PARENTS` as JSON captured from the chain, so the harness performs no network I/O of +/// its own and the capture is auditable separately from the code it exercises. +#[tokio::test] +#[ignore = "requires DIG_REAL_WALLET plus a captured chain snapshot"] +async fn the_real_wallet_reports_its_real_dig_balance() { + let path = std::env::var("DIG_REAL_WALLET").expect("set DIG_REAL_WALLET"); + let coins_json = std::env::var("DIG_REAL_COINS").expect("set DIG_REAL_COINS"); + let parents_json = std::env::var("DIG_REAL_PARENTS").expect("set DIG_REAL_PARENTS"); + let db = WalletDb::open(&path).await.expect("open the replica copy"); + + let existing = db.all_coins().await.unwrap(); + let p2: Vec = existing + .iter() + .map(|c| hex_to_b32(&c.puzzle_hash)) + .collect::>() + .into_iter() + .collect(); + let asset = digstore_chain::dig::DIG_ASSET_ID; + let derived = DerivedCats::derive(&p2, &[asset]); + let asset_hex = hex::encode(asset); + + println!( + "[BEFORE] dig_balance={}", + db.balance(Some(&asset_hex)).await.unwrap() + ); + + let states: Vec = serde_json::from_str::>( + &std::fs::read_to_string(&coins_json).expect("read coins capture"), + ) + .expect("parse coins capture") + .into_iter() + .map(CapturedCoin::into_state) + .collect(); + println!("[CHAIN] coins captured at the derived hash = {}", states.len()); + + let rows = stage_from_states(&states, &derived, |_| false); + println!("[STAGE] staged = {}", rows.len()); + db.stage_cat_admissions(&rows).await.unwrap(); + + let lineage = CapturedLineage::load(&parents_json); + let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + println!("[PROMOTE] {stats:?}"); + + let balance = db.balance(Some(&asset_hex)).await.unwrap(); + println!("[AFTER] dig_balance={balance}"); + println!("[AFTER] xch_balance={}", db.balance(None).await.unwrap()); +} + +#[derive(serde::Deserialize)] +struct CapturedCoin { + parent_coin_info: String, + puzzle_hash: String, + amount: u64, + confirmed_block_index: u32, + spent_block_index: u32, +} + +impl CapturedCoin { + fn into_state(self) -> CoinState { + CoinState { + coin: Coin { + parent_coin_info: hex_to_b32(self.parent_coin_info.trim_start_matches("0x")), + puzzle_hash: hex_to_b32(self.puzzle_hash.trim_start_matches("0x")), + amount: self.amount, + }, + created_height: Some(self.confirmed_block_index), + spent_height: (self.spent_block_index != 0).then_some(self.spent_block_index), + } + } +} + +#[derive(serde::Deserialize)] +struct CapturedParent { + coin_parent: String, + coin_puzzle_hash: String, + coin_amount: u64, + puzzle_reveal: String, + solution: String, +} + +struct CapturedLineage(std::collections::HashMap); + +impl CapturedLineage { + fn load(path: &str) -> Self { + let captured: Vec = + serde_json::from_str(&std::fs::read_to_string(path).expect("read parents capture")) + .expect("parse parents capture"); + let mut map = std::collections::HashMap::new(); + for c in captured { + let coin = Coin { + parent_coin_info: hex_to_b32(c.coin_parent.trim_start_matches("0x")), + puzzle_hash: hex_to_b32(c.coin_puzzle_hash.trim_start_matches("0x")), + amount: c.coin_amount, + }; + map.insert( + hex::encode(coin.coin_id()), + ParentSpend { + coin, + puzzle_reveal: hex::decode(c.puzzle_reveal.trim_start_matches("0x")) + .expect("puzzle hex"), + solution: hex::decode(c.solution.trim_start_matches("0x")).expect("sol hex"), + }, + ); + } + Self(map) + } +} + +#[async_trait::async_trait] +impl LineageSource for CapturedLineage { + async fn parent_spend( + &self, + parent_coin_id: &str, + _spent_height: u32, + ) -> dig_wallet::sage::Result> { + Ok(self.0.get(parent_coin_id).cloned()) + } +} From 7eb6ddd0394c22a5b5d6f7fae3ab9c41e3b9b99f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:41:32 -0700 Subject: [PATCH 10/28] docs(spec): SPEC 18.11a -- CAT discovery is not CAT authenticity Normative: derived-hash arrivals are staged, only lineage-proven coins enter coins, the address set and the derived set stay distinct, promotion is terminal and capped and never propagates into the update loop, the staging bound delays rather than errors, and the stated failure mode is incompleteness. Bumps dig-wallet 0.39.0 -> 0.40.0 and dig-node 0.160.0 -> 0.161.0 (minor: new capability, no removed API). --- Cargo.toml | 2 +- SPEC.md | 50 ++++++++++++++++ crates/dig-wallet/Cargo.toml | 2 +- crates/dig-wallet/src/sage/cat_discovery.rs | 59 +++++++++++-------- crates/dig-wallet/src/sage/db.rs | 1 - crates/dig-wallet/src/sage/sync.rs | 51 ++++++++++++---- crates/dig-wallet/src/sage/sync_supervisor.rs | 2 +- .../tests/real_wallet_cat_discovery.rs | 17 +++++- 8 files changed, 141 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3e90d840..d75ebb94 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.160.0" +version = "0.161.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 937c1234..a29c2342 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5405,6 +5405,56 @@ The sync loop runs this attribution as a post-apply step (`sync::CatAttributor`, newly-synced candidate coins, so a synced CAT coin — stored initially with `asset_id: None` — gains its TAIL and surfaces in `get_cats` (this is how `$DIG` resolves from the node). +18.11a. **CAT discovery is not CAT authenticity — staged admission (#380).** A `CoinState` carries a +parent, a puzzle hash and an amount, and **no hint**, so a wallet cannot recognise its own CAT coins from +the frame that delivers them. The node therefore DERIVES, for each address it follows and each asset id it +knows, the outer hash `cat_puzzle_hash(owner_p2, asset_id)`, and subscribes those hashes alongside the +addresses. Without this the peer never sends the wallet its CAT coins at all: a CAT coin does not sit at +its owner's address. + +Subscribing a derived hash is **discovery** and MUST NOT be read as ownership of that asset. The +derivation is injective — it commits to the CAT2 module, the asset id and the inner p2 together — but it +establishes only *"if this coin is ever spent, only this wallet can spend it, as this asset"*. It does +NOT establish that the coin is a unit of that asset, because `CREATE_COIN` is unconstrained in its +destination: anybody may place a coin at any puzzle hash, at a cost of one mojo per displayed base unit, +knowing nothing but the victim's public address. + +The two states are therefore held in **different tables**, and this separation is normative: + +- A coin arriving at a derived hash MUST be written to `cat_admission_pending`, never to `coins`. The + exception is a coin already PRESENT in `coins` — one that has cleared promotion — whose later states, + including its spend, MUST update `coins` as any other coin's would. +- A coin MUST enter `coins` only when a read of its parent spend reconstructs it as a CAT whose asset id + AND inner p2 hash both equal the ones the derivation predicted. It is then written **fully attributed**, + from the reconstruction's values and never from the derivation's. +- `coins` MUST retain exactly the semantics it has without this feature. No reader of `coins` — the + balance, the spend-input selector, `get_cats`, the arrivals notifier — may be required to apply a + predicate to remain correct. +- The address set and the derived set MUST remain distinct. Only the address set is presented to the + arrivals notifier (§18.13), which reports payments to a user. + +**Promotion** runs off the peer frame path, in the same out-of-band pass as §18.11 attribution: + +- The frame path performs **zero** chain reads. Routing is a membership test against locally derived + hashes, and the staging write takes no `LineageSource`. +- Promotion performs at most one parent-spend read per staged coin and is **terminal**: a coin proven or + disproven is never read again. A pass is capped (`MAX_CAT_PROMOTIONS_PER_PASS`). +- The three outcomes are distinct. **Proven** promotes. **Disproven** — a parent read that SUCCEEDED and + does not reconstruct the coin as that asset — deletes the staged row. **Unavailable** — a read that + could not be performed — leaves the row staged for retry, and MUST NOT delete it; treating an + unavailable answer as a disproof would let a peer erase real money by withholding parent spends. +- A promotion failure MUST NOT propagate into the peer update loop. A chain read fails for reasons a peer + can arrange, and an error reaching the update loop would end a live session. +- `cat_admission_pending` MUST be bounded, evicting oldest-first. The bound MUST delay and MUST NOT + error: the staging write is on the frame path, and a peer that can fail a frame can deny a catch-up. +- Staged rows are rolled back with the coins they describe. A reorg deletes every staged row created + above the fork and clears any spend recorded above it. + +**The stated failure mode is INCOMPLETENESS.** A real coin that cannot yet be proven is *absent* — not +counted as its asset, and in particular not counted as XCH, which `asset_id IS NULL` means and which +feeds coin selection. A wallet may under-report; it must never report a figure that is wrong. + + 18.12. **Live broadcaster bring-up — real mainnet $DIG spends behind a config gate (#428).** The node-custodied wallet BUILDS + SIGNS + VALIDATES spends (§18.9/§18.21) and the tip engine (§18.23) reserves + caps them, but on the shipped node NO broadcaster is attached, so no `$DIG` moves. This diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index db239f70..deb31327 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.39.0" +version = "0.40.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 548c6e69..de768cd5 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -90,13 +90,7 @@ impl DerivedCats { for &owner_p2 in owner_p2_hashes { for &asset_id in asset_ids { let outer = digstore_chain::cat::cat_puzzle_hash(owner_p2, asset_id); - by_hash.insert( - outer, - DerivedOwner { - asset_id, - owner_p2, - }, - ); + by_hash.insert(outer, DerivedOwner { asset_id, owner_p2 }); } } Self { by_hash } @@ -131,8 +125,8 @@ impl DerivedCats { /// /// Takes no [`LineageSource`], which is how the zero-chain-reads-on-the-frame-path property is /// guaranteed: it is not a discipline the body observes, it is a fact about the signature. -pub fn stage_from_states<'a, F>( - states: &'a [CoinState], +pub fn stage_from_states( + states: &[CoinState], derived: &DerivedCats, mut already_promoted: F, ) -> Vec @@ -200,7 +194,10 @@ pub async fn promote_staged_cats( lineage: &dyn LineageSource, ) -> Result { let mut stats = PromoteStats::default(); - for row in db.staged_cat_admissions(MAX_CAT_PROMOTIONS_PER_PASS).await? { + for row in db + .staged_cat_admissions(MAX_CAT_PROMOTIONS_PER_PASS) + .await? + { // A coin already spent on chain can never contribute to a balance or be selected as a // spend input, so proving it would buy nothing and cost a round trip. Dropped without a // read, which also stops a spent coin sitting in staging forever waiting for a promotion @@ -273,7 +270,8 @@ async fn promote_one( ); return Ok(false); } - let reconstructed = singleton::reconstruct(prefix, row.created_height.map(|h| h as u32), parent, child)?; + let reconstructed = + singleton::reconstruct(prefix, row.created_height.map(|h| h as u32), parent, child)?; let Reconstructed::Cat { coin_id, asset_id, @@ -326,7 +324,9 @@ mod tests { use crate::sage::db::{CoinRow, CAT_ADMISSION_PENDING_MAX_ROWS}; use crate::sage::singleton::ParentSpend; use chia_sdk_test::Simulator; - use chia_wallet_sdk::driver::{Cat as SdkCat, CatSpend, SpendContext, SpendWithConditions, StandardLayer}; + use chia_wallet_sdk::driver::{ + Cat as SdkCat, CatSpend, SpendContext, SpendWithConditions, StandardLayer, + }; use chia_wallet_sdk::types::Conditions; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -517,14 +517,15 @@ mod tests { let fake = fabricated_at(f.child.puzzle_hash, 999_999_999, 0xAB); let rows = stage_from_states( - &[ - state(f.child, Some(10), None), - state(fake, Some(11), None), - ], + &[state(f.child, Some(10), None), state(fake, Some(11), None)], &derived, |_| false, ); - assert_eq!(rows.len(), 2, "both coins are DISCOVERED; neither is believed"); + assert_eq!( + rows.len(), + 2, + "both coins are DISCOVERED; neither is believed" + ); db.stage_cat_admissions(&rows).await.unwrap(); let mut lineage = CountingLineage::default(); @@ -606,7 +607,10 @@ mod tests { let hostile = fabricated_at(f.child.puzzle_hash, 7, 0xCD); let rows = stage_from_states( - &[state(hostile, Some(9), None), state(f.child, Some(10), None)], + &[ + state(hostile, Some(9), None), + state(f.child, Some(10), None), + ], &derived, |_| false, ); @@ -623,7 +627,10 @@ mod tests { let stats = promote_staged_cats(&db, &lineage) .await .expect("a failing parent read must not fail the whole pass"); - assert_eq!(stats.promoted, 1, "the honest coin is still promoted: {stats:?}"); + assert_eq!( + stats.promoted, 1, + "the honest coin is still promoted: {stats:?}" + ); assert_eq!(stats.deferred, 1, "{stats:?}"); // Left staged, NOT deleted: an unreadable answer is not a disproof, and deleting on one // would let a peer that withholds parent spends erase real money. @@ -650,7 +657,11 @@ mod tests { let real = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); db.stage_cat_admissions(&real).await.unwrap(); promote_staged_cats(&db, &lineage).await.unwrap(); - assert_eq!(lineage.reads(), 1, "the counter must be able to move at all"); + assert_eq!( + lineage.reads(), + 1, + "the counter must be able to move at all" + ); // One over the per-pass cap, so the bound is pinned from BOTH sides in one run: the pass // must read exactly the cap, and must NOT read the extra coin. @@ -710,11 +721,9 @@ mod tests { let f = real_cat(); let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); let promoted = hex::encode(f.child.coin_id()); - let rows = stage_from_states( - &[state(f.child, Some(10), Some(11))], - &derived, - |id| id == promoted, - ); + let rows = stage_from_states(&[state(f.child, Some(10), Some(11))], &derived, |id| { + id == promoted + }); assert!( rows.is_empty(), "a believed coin must never be pushed back into the staging table" diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index e3cb71cf..2a93be4e 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -334,7 +334,6 @@ pub struct PeerRow { pub banned: bool, } - /// How many discovered-but-unproven CAT coins the staging table holds. /// /// A staged row is a dozen short hex fields — call it 400 bytes with SQLite's overhead — so diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index 81969504..22453acf 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -1002,6 +1002,13 @@ impl PuzzleStateSource for Peer { /// This is where the terminal height meets the session's [`PeakCeiling`] — see /// [`CatchUpReplay::finished_at`], which refuses an over-ceiling terminal rather than arming /// `initial_sync_complete` over it. +// The subscription is genuinely two sets with different meanings -- addresses, which the replica +// and the arrivals notifier read as "ours", and derived CAT hashes, which only discovery reads. +// Collapsing them into one parameter to satisfy the count is precisely the union this design keeps +// apart (see `SessionState::subscribed`). A `Subscription { addresses, derived }` type would carry +// both without the union and is the right shape; it is left for a follow-up rather than done here, +// because it touches every catch-up call site and this change is already money-visible. +#[allow(clippy::too_many_arguments)] pub async fn initial_sync_with_authority( peer: &dyn PuzzleStateSource, db: &WalletDb, @@ -1329,7 +1336,10 @@ mod tests { apply_coin_states( &db, - &[state(plain_coin, Some(10), None), state(cat_coin, Some(10), None)], + &[ + state(plain_coin, Some(10), None), + state(cat_coin, Some(10), None), + ], &subscribed, &derived, ) @@ -1344,7 +1354,11 @@ mod tests { ); // … and it is not BELIEVED, which is the whole of the round-5 rejection. let believed = db.all_coins().await.unwrap(); - assert_eq!(believed.len(), 1, "only the ordinary p2 coin may enter `coins`"); + assert_eq!( + believed.len(), + 1, + "only the ordinary p2 coin may enter `coins`" + ); assert_eq!(believed[0].coin_id, hex::encode(plain_coin.coin_id())); assert_eq!( db.balance(None).await.unwrap(), @@ -1369,9 +1383,14 @@ mod tests { }; let subscribed: SubscribedHashes = [owner].into_iter().collect(); - apply_coin_states(&db, &[state(stranger, Some(10), None)], &subscribed, &derived) - .await - .unwrap(); + apply_coin_states( + &db, + &[state(stranger, Some(10), None)], + &subscribed, + &derived, + ) + .await + .unwrap(); assert!(db.all_coins().await.unwrap().is_empty()); assert_eq!(db.staged_cat_admission_count().await.unwrap(), 0); @@ -1425,14 +1444,24 @@ mod tests { async fn later_spend_state_marks_coin_spent() { let db = WalletDb::open_in_memory().await.unwrap(); let c = coin(1, 9, 500); - apply_coin_states(&db, &[state(c, Some(10), None)], &subscribed_owned(), &DerivedCats::default()) - .await - .unwrap(); + apply_coin_states( + &db, + &[state(c, Some(10), None)], + &subscribed_owned(), + &DerivedCats::default(), + ) + .await + .unwrap(); assert_eq!(db.balance(None).await.unwrap(), 500); // The peer later reports the same coin as spent. - apply_coin_states(&db, &[state(c, Some(10), Some(20))], &subscribed_owned(), &DerivedCats::default()) - .await - .unwrap(); + apply_coin_states( + &db, + &[state(c, Some(10), Some(20))], + &subscribed_owned(), + &DerivedCats::default(), + ) + .await + .unwrap(); assert_eq!(db.balance(None).await.unwrap(), 0); } diff --git a/crates/dig-wallet/src/sage/sync_supervisor.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index fcbd05a8..fed23365 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -53,8 +53,8 @@ use chia_bls::PublicKey; use chia_protocol::Bytes32; use chia_puzzle_types::standard::StandardArgs; -use super::custody::WalletCustody; use super::cat_discovery::DerivedCats; +use super::custody::WalletCustody; use super::db::WalletDb; use super::events::EventBus; use super::quorum::{self, Verdict}; diff --git a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs index f635faa2..a32d3d58 100644 --- a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs +++ b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs @@ -25,7 +25,12 @@ async fn report_the_real_wallets_dig_discovery_surface() { let coins = db.all_coins().await.expect("read coins"); let addresses: HashSet = coins.iter().map(|c| c.puzzle_hash.clone()).collect(); let attributed = coins.iter().filter(|c| c.asset_id.is_some()).count(); - println!("[REPLICA] coins={} distinct_puzzle_hashes={} attributed={}", coins.len(), addresses.len(), attributed); + println!( + "[REPLICA] coins={} distinct_puzzle_hashes={} attributed={}", + coins.len(), + addresses.len(), + attributed + ); let p2: Vec = addresses .iter() @@ -39,7 +44,10 @@ async fn report_the_real_wallets_dig_discovery_surface() { let derived = DerivedCats::derive(&p2, &[asset]); println!("[DERIVED] dig_asset_id={}", hex::encode(asset)); for h in derived.hashes() { - println!("[DERIVED] a $DIG coin of this wallet sits at {}", hex::encode(h)); + println!( + "[DERIVED] a $DIG coin of this wallet sits at {}", + hex::encode(h) + ); } let at_derived = coins .iter() @@ -95,7 +103,10 @@ async fn the_real_wallet_reports_its_real_dig_balance() { .into_iter() .map(CapturedCoin::into_state) .collect(); - println!("[CHAIN] coins captured at the derived hash = {}", states.len()); + println!( + "[CHAIN] coins captured at the derived hash = {}", + states.len() + ); let rows = stage_from_states(&states, &derived, |_| false); println!("[STAGE] staged = {}", rows.len()); From e75149add0affce4c1d18479b7a8e5fe770bd53b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:43:45 -0700 Subject: [PATCH 11/28] chore(wallet): drop the lane scratch file --- .loop-390-lane.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .loop-390-lane.md diff --git a/.loop-390-lane.md b/.loop-390-lane.md deleted file mode 100644 index 6f231c66..00000000 --- a/.loop-390-lane.md +++ /dev/null @@ -1,7 +0,0 @@ -# Lane: dig-node #390 — CAT discovery staging + lineage promotion - -Branch: `loop/390-cat-staging` (from `origin/main`). -Scope: derived-hash arrivals land in `cat_admission_pending`; only lineage-proven -coins are written to `coins`. `coins` keeps `origin/main` semantics exactly. - -Closes #380. From 44bacb6e7779b1f871db5d31f3dc59979cef7119 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 01:45:59 -0700 Subject: [PATCH 12/28] chore(wallet): re-lock for the dig-wallet 0.40.0 bump --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e67f8b4..f762ec31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,7 +3046,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.160.0" +version = "0.161.0" dependencies = [ "async-trait", "axum", @@ -3339,7 +3339,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.39.0" +version = "0.40.0" dependencies = [ "async-trait", "axum", From dd07c6a566f82b964c606bde30c1d115d8c764e2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 02:47:31 -0700 Subject: [PATCH 13/28] fix(wallet): the catch-up coverage set is not the coin-admission set A catch-up needs coverage over addresses AND derived CAT hashes, and admission over addresses only. `sync_supervisor.rs` built the union at the call site and handed it down as one vector; `initial_sync_with_authority` then built the `coins` admission set from that widened vector, so every coin at a derived CAT hash was admitted and typed `asset_id: None` -- which means XCH. One `CREATE_COIN` at `cat_puzzle_hash(victim_p2, DIG_ASSET_ID)`, costing one mojo per displayed base unit and needing only the victim's public address, then bought a fabricated XCH balance and a permanent send kill-switch: selection is largest-first and the coin is unspendable by anyone. The union now happens inside `initial_sync_with_authority` and reaches only the peer request. The admission set is built from `addresses` with derived hashes actively FILTERED OUT, so a caller cannot widen admission even by passing one. `covered_puzzle_hashes` returns to addresses-only for the same reason. Refs dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/sync.rs | 56 +++++++++++++++---- crates/dig-wallet/src/sage/sync_supervisor.rs | 27 +++++---- .../src/sage/sync_supervisor/tests.rs | 33 ++++++++--- 3 files changed, 84 insertions(+), 32 deletions(-) diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index 22453acf..ced2f395 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -1002,17 +1002,24 @@ impl PuzzleStateSource for Peer { /// This is where the terminal height meets the session's [`PeakCeiling`] — see /// [`CatchUpReplay::finished_at`], which refuses an over-ceiling terminal rather than arming /// `initial_sync_complete` over it. -// The subscription is genuinely two sets with different meanings -- addresses, which the replica -// and the arrivals notifier read as "ours", and derived CAT hashes, which only discovery reads. -// Collapsing them into one parameter to satisfy the count is precisely the union this design keeps -// apart (see `SessionState::subscribed`). A `Subscription { addresses, derived }` type would carry -// both without the union and is the right shape; it is left for a follow-up rather than done here, -// because it touches every catch-up call site and this change is already money-visible. +/// # The two sets are NOT one set (dig-node#394) +/// +/// A catch-up needs COVERAGE over addresses AND derived CAT hashes -- the peer must be asked about +/// both, or a discovered CAT coin is never seen at all. It needs ADMISSION over addresses only: +/// a coin at a derived hash is a claim anybody could have made, and admitting it writes a coin the +/// schema types `asset_id: None`, which means XCH, into the money table. +/// +/// Those are different sets, and an earlier round of this work passed ONE vector serving both +/// roles. That is why the union is performed HERE and cannot be performed by a caller: `addresses` +/// is the admission set, `derived` is the widening, and `requested` -- the union -- exists only +/// inside this function and only reaches the peer request. A caller that wanted to widen admission +/// would have to hand a derived hash in `addresses`, and the `subscribed` construction below +/// FILTERS those out, so even that does not work. One admission point, structurally unwidenable. #[allow(clippy::too_many_arguments)] pub async fn initial_sync_with_authority( peer: &dyn PuzzleStateSource, db: &WalletDb, - puzzle_hashes: Vec, + addresses: Vec, genesis_challenge: Bytes32, peer_ip: &str, events: &EventBus, @@ -1040,11 +1047,34 @@ pub async fn initial_sync_with_authority( // wallet-scoped read (`routing::route(true, true) == Source::Db`). The guard lives HERE, // at the floor, and not only in the supervisor that calls it: a caller-side check is one // refactor away from gone, and this function is the only thing that can set the flag. - if puzzle_hashes.is_empty() { + if addresses.is_empty() { return Err(SyncError::NoPuzzleHashes); } - let subscribed: SubscribedHashes = puzzle_hashes.iter().copied().collect(); + // ADMISSION. Addresses only, and derived hashes actively removed rather than merely not added: + // this set decides what enters `coins`, and `coins` is the money table. The filter is what + // makes the guarantee structural instead of a convention every caller has to remember. + let subscribed: SubscribedHashes = addresses + .iter() + .copied() + .filter(|h| derived.owner_of(h).is_none()) + .collect(); + if subscribed.len() != addresses.len() { + tracing::warn!( + removed = addresses.len() - subscribed.len(), + "wallet sync: a derived CAT hash was offered as an address; refused admission" + ); + } + + // COVERAGE. The union, built here and used for nothing but the peer request. A coin arriving + // at a derived hash is routed to staging by `apply_coin_states`, never to `coins`. + let requested: Vec = { + let mut all = addresses.clone(); + all.extend(derived.hashes()); + all.sort(); + all.dedup(); + all + }; let mut previous_height: Option = None; let mut header_hash = genesis_challenge; events.publish(SyncEvent::Start { @@ -1069,7 +1099,7 @@ pub async fn initial_sync_with_authority( // path, which drops the peer and backs off. let respond = tokio::time::timeout( PEER_REQUEST_TIMEOUT, - peer.request_puzzle_state(puzzle_hashes.clone(), previous_height, header_hash), + peer.request_puzzle_state(requested.clone(), previous_height, header_hash), ) .await .map_err(|_| { @@ -1111,7 +1141,11 @@ pub async fn initial_sync_with_authority( authority.ceiling(), respond.height, hex::encode(respond.header_hash), - &puzzle_hashes, + // 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?; return Ok(()); diff --git a/crates/dig-wallet/src/sage/sync_supervisor.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index fed23365..fbf90ed5 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -809,9 +809,14 @@ pub trait SyncSession: Send + Sync { /// wrong answer for elevation purposes, and treated the same way: no elevation. async fn header_hash_at(&self, height: u32) -> Result, SyncError>; - /// Subscribe `puzzle_hashes` and catch the replica up, under the EFFECTIVE trust the + /// Subscribe over `addresses` and catch the replica up, under the EFFECTIVE trust the /// supervisor resolved for this session. /// + /// `addresses` is the wallet's own p2 hashes and NOTHING else: it is the set that decides what + /// enters `coins`. `derived` widens the peer REQUEST only, and that widening happens inside + /// [`sync::initial_sync_with_authority`] so no implementor of this trait can collapse the two + /// (dig-node#394). + /// /// `authority` is passed in rather than read from [`SyncSession::trust`] because the two can /// legitimately differ: a discovered peer that cleared corroboration runs as /// [`PeerTrust::Corroborated`] while its dial source is still discovery. The floor check that @@ -825,7 +830,7 @@ pub trait SyncSession: Send + Sync { async fn catch_up( &self, db: &WalletDb, - puzzle_hashes: Vec, + addresses: Vec, genesis_challenge: Bytes32, events: &EventBus, authority: sync::WriteAuthority, @@ -1378,17 +1383,15 @@ impl Supervisor { // end the session, and ending a session mid-catch-up discards the work — but a // TOTAL deadline and shutdown both can, and shutdown is not optional at any // duration. - // The REQUEST carries addresses AND derived CAT hashes; `set_watched` above - // counted the addresses only, because that number is reported to the user as - // "watched addresses" and an outer CAT hash is not one. - let mut requested = puzzle_hashes.clone(); - requested.extend(derived.hashes()); - requested.sort(); - requested.dedup(); + // ADDRESSES ONLY here, and `derived` alongside. The union that widens the peer + // REQUEST is performed inside `sync::initial_sync_with_authority`, which is also + // the only thing that builds the admission set -- so this call site cannot widen + // what enters `coins` even by mistake (dig-node#394). Building the union here is + // exactly what let a fabricated coin be admitted as XCH. let catch_up = tokio::select! { result = session.catch_up( &self.db, - requested, + puzzle_hashes.clone(), self.genesis_challenge, &self.events, authority, @@ -2319,7 +2322,7 @@ impl SyncSession for ChiaPeerSession { async fn catch_up( &self, db: &WalletDb, - puzzle_hashes: Vec, + addresses: Vec, genesis_challenge: Bytes32, events: &EventBus, authority: sync::WriteAuthority, @@ -2330,7 +2333,7 @@ impl SyncSession for ChiaPeerSession { sync::initial_sync_with_authority( &self.peer, db, - puzzle_hashes, + addresses, genesis_challenge, &self.ip, events, diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index a5ad71c0..5e727452 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -43,16 +43,24 @@ const CATCH_UP_HEIGHT: u32 = 6_000_000; /// A peer that reports "caught up, nothing to send" — what a real full node answers to a /// subscription it has already satisfied. -struct CaughtUpAtOnce; +struct CaughtUpAtOnce { + /// Every puzzle-hash vector the catch-up actually put ON THE WIRE. + /// + /// Recorded because coverage and admission are different sets (dig-node#394) and a suite that + /// can only see one of them cannot tell a correct split from a collapsed one: the address set + /// is visible at the call site, the REQUESTED set is visible only here. + requested: std::sync::Arc>>>, +} #[async_trait::async_trait] impl PuzzleStateSource for CaughtUpAtOnce { async fn request_puzzle_state( &self, - _puzzle_hashes: Vec, + puzzle_hashes: Vec, _previous_height: Option, _header_hash: Bytes32, ) -> Result { + self.requested.lock().unwrap().push(puzzle_hashes); Ok(RespondPuzzleState { puzzle_hashes: vec![], coin_states: vec![], @@ -66,8 +74,11 @@ impl PuzzleStateSource for CaughtUpAtOnce { /// Everything the scripted factory and its sessions share with the test. #[derive(Default)] struct Script { - /// One entry per `catch_up`, holding the exact set that was subscribed. + /// One entry per `catch_up`, holding the exact ADMISSION set the supervisor handed down. catch_ups: Mutex>>, + /// One entry per catch-up round trip, holding the set actually REQUESTED from the peer -- + /// the union of the admission set with the derived CAT hashes. + requested: std::sync::Arc>>>, /// One entry per `catch_up`, holding the EFFECTIVE authority the supervisor resolved. /// /// The ceiling has exactly one production construction site (`trust_for_session`), and until @@ -259,17 +270,17 @@ impl SyncSession for ScriptedSession { async fn catch_up( &self, db: &WalletDb, - puzzle_hashes: Vec, + addresses: Vec, genesis_challenge: Bytes32, events: &EventBus, authority: sync::WriteAuthority, - _derived: &DerivedCats, + derived: &DerivedCats, ) -> Result<(), SyncError> { self.script .catch_ups .lock() .unwrap() - .push(puzzle_hashes.clone()); + .push(addresses.clone()); self.script.authorities.lock().unwrap().push(authority); if self.script.catch_up_parks.load(Ordering::SeqCst) { // Recorded FIRST, so a test can still prove the catch-up was entered. @@ -278,9 +289,11 @@ impl SyncSession for ScriptedSession { // The REAL catch-up, so the empty-set guard and the completion-flag write are both // exercised exactly as production would exercise them. sync::initial_sync_with_authority( - &CaughtUpAtOnce, + &CaughtUpAtOnce { + requested: std::sync::Arc::clone(&self.script.requested), + }, db, - puzzle_hashes, + addresses, genesis_challenge, &self.peer_ip(), events, @@ -288,7 +301,9 @@ impl SyncSession for ScriptedSession { // reading `self.trust` here would make the elevation invisible to the floor check and // quietly re-create the bug this suite exists to exclude. authority, - &DerivedCats::default(), + // FORWARDED, never defaulted: the supervisor's own derived set reaches the real + // catch-up, so the coverage/admission split is under test on every catch-up path. + derived, ) .await } From 08d8105529070a40ae2476aaa1ec8c20f360a2c9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:07:24 -0700 Subject: [PATCH 14/28] test(wallet): pin the catch-up coverage/admission split from both sides Two regression tests for the fabricated-XCH primitive. The first drives the real catch-up with a NON-default `DerivedCats` -- every pre-existing catch-up test passed `DerivedCats::default()`, and a field every fixture sets to the same value is a field the suite cannot test. It asserts BOTH halves: the peer is still asked about the derived hash (coverage), and the coin at it is staged rather than admitted (admission). Asserting only the second is satisfied identically by a catch-up that never subscribed the hash at all, which is #380's starvation wearing this defect's assertion. The second hands a derived hash in the ADDRESS vector -- the misbehaving caller -- and pins that admission still refuses it. That is what makes the guarantee structural rather than a convention a future caller has to remember. Refs dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/sync.rs | 191 +++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index ced2f395..e8dfbfdb 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -1401,6 +1401,197 @@ mod tests { ); } + /// A peer that answers one batch with whatever coin states it was built with, and RECORDS the + /// puzzle-hash vector it was asked about. + /// + /// Recording the request is the half that makes the test two-sided. Asserting only "the coin + /// did not enter `coins`" is satisfied identically by a correct split and by a catch-up that + /// never asked about the derived hash at all — and the second is the #380 starvation this + /// family already fixed once. Coverage and admission must both be observable, or a regression + /// can trade one for the other and stay green. + struct RecordingPeer { + states: Vec, + requested: std::sync::Arc>>, + } + + #[async_trait::async_trait] + impl PuzzleStateSource for RecordingPeer { + async fn request_puzzle_state( + &self, + puzzle_hashes: Vec, + _previous_height: Option, + _header_hash: Bytes32, + ) -> Result { + *self.requested.lock().unwrap() = puzzle_hashes; + Ok(RespondPuzzleState { + puzzle_hashes: vec![], + coin_states: self.states.clone(), + height: 6_000_000, + header_hash: Bytes32::new([9; 32]), + is_finished: true, + }) + } + } + + /// **Proves (dig-node#394, gate finding 1):** the CATCH-UP path routes a derived-hash coin to + /// staging, and never into `coins` as XCH — while still ASKING the peer about that hash. + /// + /// THE BUG THIS PINS. `sync_supervisor.rs` unioned the addresses with the derived CAT hashes + /// and passed one vector down; `initial_sync_with_authority` then built the admission set from + /// that widened vector, so a coin at any derived hash was admitted and typed `asset_id: None` + /// — which means XCH in this schema. One `CREATE_COIN` at `cat_puzzle_hash(victim_p2, asset)`, + /// needing only the victim's public address, bought a fabricated XCH balance plus a permanent + /// send kill-switch, and the catch-up re-runs on every reconnect. + /// + /// FIXTURE DESIGN — three things, each load-bearing: + /// + /// - `derived` is NOT `DerivedCats::default()`. Every pre-existing catch-up test passed the + /// default, and a field every fixture sets to the same value is a field the suite cannot + /// test. That collapse is the entire reason this defect reached a security gate. + /// - An ORDINARY p2 coin rides in the same batch, as a truthful control. Without it, + /// `all_coins().len() == 0` would also be satisfied by a catch-up that admitted nothing at + /// all, which is a different bug wearing this one's assertion. + /// - The FABRICATED amount is large and the honest one small, so the XCH balance assertion is + /// a concrete figure rather than a symbol that moves with the code under test. + #[tokio::test] + async fn the_catch_up_never_admits_a_derived_hash_coin_as_xch() { + let db = WalletDb::open_in_memory().await.unwrap(); + let events = EventBus::default(); + let owner = Bytes32::new([OWNED; 32]); + let asset = Bytes32::new([0xDA; 32]); + let derived = DerivedCats::derive(&[owner], &[asset]); + let outer = derived.hashes()[0]; + + let honest = Coin { + parent_coin_info: Bytes32::new([1; 32]), + puzzle_hash: owner, + amount: 5_000, + }; + // What an attacker places: one CREATE_COIN at the derived hash, for a number the victim + // will read as their balance. + let fabricated = Coin { + parent_coin_info: Bytes32::new([2; 32]), + puzzle_hash: outer, + amount: 999_999_999, + }; + let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let peer = RecordingPeer { + states: vec![ + state(honest, Some(10), None), + state(fabricated, Some(10), None), + ], + requested: std::sync::Arc::clone(&requested), + }; + + initial_sync_with_authority( + &peer, + &db, + // ADDRESSES ONLY, exactly as the supervisor now passes them. + vec![owner], + Bytes32::new([0; 32]), + "127.0.0.1", + &events, + WriteAuthority::Operator, + &derived, + ) + .await + .unwrap(); + + // COVERAGE: the peer WAS asked about the derived hash. Drop this and the fix could be + // "stop subscribing derived hashes", which re-opens #380's starvation. + let asked = requested.lock().unwrap().clone(); + assert!( + asked.contains(&outer), + "the catch-up must still ASK about the derived CAT hash: coverage is not admission" + ); + assert!(asked.contains(&owner), "and about the wallet's own address"); + + // ADMISSION: the fabricated coin is staged, not believed. + let believed = db.all_coins().await.unwrap(); + assert_eq!( + believed.len(), + 1, + "only the ordinary p2 coin may enter `coins` on the catch-up path" + ); + assert_eq!(believed[0].coin_id, hex::encode(honest.coin_id())); + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + 1, + "the derived-hash coin must be STAGED, not dropped" + ); + + // The money figures, pinned concretely. `999_999_999` is the value the gate reproduced as + // a fabricated XCH balance against the previous head. + assert_eq!( + db.balance(None).await.unwrap(), + 5_000, + "a coin at a derived CAT hash must never be counted as XCH" + ); + // Selection is largest-first and a fabricated coin is unspendable by anyone, so admitting + // one is a permanent XCH send kill-switch, not merely a wrong figure. + assert_eq!( + db.unspent_coins(None).await.unwrap().len(), + 1, + "and must never become a selectable XCH spend input" + ); + } + + /// **Proves (dig-node#394):** admission cannot be widened THROUGH the address parameter either. + /// + /// The companion to the test above, and the one that makes the guarantee structural rather + /// than a convention. Above, the caller behaves; here the caller misbehaves and hands a + /// derived hash in the ADDRESS vector — the exact shape of the defect, one refactor away from + /// returning. `initial_sync_with_authority` filters it back out, so there is no vector of any + /// kind by which a derived hash reaches the admission set. + #[tokio::test] + async fn a_derived_hash_offered_as_an_address_is_refused_admission() { + let db = WalletDb::open_in_memory().await.unwrap(); + let events = EventBus::default(); + let owner = Bytes32::new([OWNED; 32]); + let derived = DerivedCats::derive(&[owner], &[Bytes32::new([0xDA; 32])]); + let outer = derived.hashes()[0]; + + let fabricated = Coin { + parent_coin_info: Bytes32::new([2; 32]), + puzzle_hash: outer, + amount: 999_999_999, + }; + let peer = RecordingPeer { + states: vec![state(fabricated, Some(10), None)], + requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + }; + + initial_sync_with_authority( + &peer, + &db, + // The MISBEHAVING caller: the derived hash smuggled in as an address. + vec![owner, outer], + Bytes32::new([0; 32]), + "127.0.0.1", + &events, + WriteAuthority::Operator, + &derived, + ) + .await + .unwrap(); + + assert_eq!( + db.all_coins().await.unwrap().len(), + 0, + "a derived hash passed as an address must still not admit its coin" + ); + assert_eq!( + db.balance(None).await.unwrap(), + 0, + "and must contribute nothing to the XCH balance" + ); + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + 1, + "it is staged instead — discovered, not believed" + ); + } + /// A coin at a hash NEITHER set knows is still dropped, exactly as on `main`. /// /// The control for the test above: staging must widen what the wallet accepts by precisely the From 2f96767cdeaf7a5680750107eab22a71630d5e29 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:18:40 -0700 Subject: [PATCH 15/28] fix(wallet): bound the promotion read rate and stop the staging queue starving 64 mojos bought permanent $DIG starvation. A promotion that cannot read its parent leaves the row staged -- deliberately, because deleting on an unreadable parent lets a source that is merely behind erase real money -- and the queue was served `ORDER BY seq ASC LIMIT 64`. So 64 coins with invented parents held the head for ever: every pass re-read the same 64 rows, reads climbed without bound, and no honest coin behind them was ever reached. The queue is now ordered `attempts ASC, seq ASC`, so a row that keeps failing sinks below every row that has never been tried and cannot hold the head. On top of that a row is eligible only if it has not been read within `PROMOTION_RETRY_COOLDOWN`, which bounds an attacker to `rows / cooldown` reads rather than `limit` reads per pass. No row is ever deleted for failing: absence is the accepted failure direction, erasing a real coin is not. A terminal refusal keyed on "the parent never existed" was considered and rejected: a coinset source answers a null `coin_solution` both for a spend it has never heard of and for one it is behind on, so that classification would convert a brief outage into permanent erasure. The cost is bounded instead of the cause classified; the reasoning is recorded on the constant. `ChiaQueryLineage::parent_spend` also stops manufacturing `Ok(None)` -- the value callers read as "the chain has no such spend" -- out of every transport failure. `reconstruct_all` keeps its per-coin resilience explicitly so one flaky read cannot abandon a whole attribution pass. Also corrects `MAX_CAT_PROMOTIONS_PER_PASS`'s doc, which claimed promotion was terminal and the total amplification therefore 1x. That is true of a promotion that concludes and false of the deferral an attacker chooses. Refs dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 68 ++++++++++++++++---- crates/dig-wallet/src/sage/db.rs | 71 +++++++++++++++++++-- crates/dig-wallet/src/sage/fallback.rs | 22 ++++++- crates/dig-wallet/src/sage/singleton.rs | 21 ++++-- 4 files changed, 156 insertions(+), 26 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index de768cd5..5ccba289 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -50,15 +50,38 @@ use super::{singleton, Result}; /// How many staged coins one promotion pass will read parent spends for. /// -/// This is the amplification bound. An attacker who stages `N` coins buys at most this many chain -/// reads per pass, and because promotion is **terminal** — a coin is promoted or refused once and -/// never re-read — the total reads they can buy is `N`, not `N` per pass forever. Against a coin -/// each of which cost them a `CREATE_COIN` and at least one mojo, that is roughly 1x. +/// The per-pass bound. A pass stays short — each read is a network round trip — while an honest +/// wallet's whole backlog still clears in a handful of passes. /// -/// Small enough that a pass stays short (each read is a network round trip), large enough that an -/// honest wallet's whole backlog clears in a handful of passes. +/// This is NOT on its own the amplification bound, and an earlier version of this comment claimed +/// it was: it asserted that promotion is terminal, so an attacker who stages `N` coins buys `N` +/// reads in total. That is true of a coin whose promotion CONCLUDES, and false of one whose +/// parent cannot be read — which is the case an attacker chooses. Such a row stays staged by +/// design, so it was re-read on every pass, for ever. The real bound is one read per staged row +/// per [`PROMOTION_RETRY_COOLDOWN`], enforced in [`crate::sage::db::WalletDb::staged_cat_admissions`]. pub const MAX_CAT_PROMOTIONS_PER_PASS: i64 = 64; +/// The least time between two parent-spend reads for the SAME staged coin (dig-node#394). +/// +/// # What this bounds, and why a classification does not replace it +/// +/// A parent spend that cannot be read has two causes with opposite correct handling: the parent +/// never existed (an invented ancestry, and the row will never resolve), or the source has not +/// got there yet (pruned, behind, offline — and the row will resolve later). Distinguishing them +/// would let the first be refused terminally. +/// +/// The distinction is not soundly available. A coinset source answers a null `coin_solution` for +/// BOTH — a spend it has never heard of and a spend it is simply behind on — so a terminal +/// refusal built on that answer converts a source that is briefly behind into permanent erasure of +/// a real coin. That is the one failure direction this whole design refuses to take: absence is +/// acceptable, a wrong figure is not, and erasing money is worse than either. +/// +/// So the cost is bounded instead of the cause classified. With attempts-ordered fetch, a row that +/// never resolves cannot starve one that would, and this cooldown holds each row to one read per +/// hour. An attacker who spends 64 mojos buys 64 reads an hour rather than 64 reads a pass, and +/// an honest coin behind a source outage still promotes the moment the source recovers. +pub const PROMOTION_RETRY_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(3_600); + /// The outer CAT puzzle hashes this wallet would own, and what each one was derived FROM. /// /// A map rather than a set because the provenance is the point: when a coin arrives at one of @@ -194,8 +217,12 @@ pub async fn promote_staged_cats( lineage: &dyn LineageSource, ) -> Result { let mut stats = PromoteStats::default(); + // Read from the wall clock ONCE, so every row in this pass is metered against the same + // instant and a long pass cannot let its own duration widen the cooldown. + let now = unix_now(); + let retry_cutoff = now - i64::try_from(PROMOTION_RETRY_COOLDOWN.as_secs()).unwrap_or(3_600); for row in db - .staged_cat_admissions(MAX_CAT_PROMOTIONS_PER_PASS) + .staged_cat_admissions(MAX_CAT_PROMOTIONS_PER_PASS, retry_cutoff) .await? { // A coin already spent on chain can never contribute to a balance or be selected as a @@ -209,7 +236,10 @@ pub async fn promote_staged_cats( continue; } let Some(created) = row.created_height else { - // Unconfirmed: there is no height to read a parent spend at yet. Left staged. + // Unconfirmed: there is no height to read a parent spend at yet. Left staged, and + // metered — a coin that is never confirmed is exactly as unresolvable as one whose + // parent is never readable, and it must not hold the queue head either. + db.record_promotion_attempt(&row.coin_id, now).await?; stats.deferred += 1; continue; }; @@ -218,18 +248,24 @@ pub async fn promote_staged_cats( .await { Ok(Some(parent)) => parent, - // `Ok(None)` is "the parent spend is not available", NOT "the parent is not a CAT". - // Treating it as a disproof would delete real coins whenever a source is behind. + // `Ok(None)` is "the source ANSWERED, and has no spend for this parent" — which is + // consistent with an invented ancestry AND with a source that is merely behind. + // Treating it as a disproof would delete real coins whenever a source is behind, so it + // is a deferral; the cost of retrying it for ever is bounded by the cooldown instead. Ok(None) => { + db.record_promotion_attempt(&row.coin_id, now).await?; stats.deferred += 1; continue; } + // The source did not answer at all — transport, timeout, a malformed reply. Strictly + // less informative than `Ok(None)`, and handled the same way. Err(e) => { tracing::debug!( coin_id = %row.coin_id, error = %e, "cat promotion: parent spend unreadable; leaving the coin staged" ); + db.record_promotion_attempt(&row.coin_id, now).await?; stats.deferred += 1; continue; } @@ -244,6 +280,14 @@ pub async fn promote_staged_cats( Ok(stats) } +/// Seconds since the Unix epoch, saturating at zero on a clock set before it. +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + /// The address prefix reconstruction wants. CAT attribution never produces an address — only NFT /// and DID rows do — so the value is irrelevant here and is named rather than threaded. fn lineage_prefix() -> &'static str { @@ -779,7 +823,7 @@ mod tests { CAT_ADMISSION_PENDING_MAX_ROWS ); let held = db - .staged_cat_admissions(CAT_ADMISSION_PENDING_MAX_ROWS) + .staged_cat_admissions(CAT_ADMISSION_PENDING_MAX_ROWS, i64::MAX) .await .unwrap(); assert!( @@ -808,7 +852,7 @@ mod tests { db.rollback_above(150).await.unwrap(); - let held = db.staged_cat_admissions(100).await.unwrap(); + let held = db.staged_cat_admissions(100, i64::MAX).await.unwrap(); assert_eq!(held.len(), 1, "only the row above the fork is unmade"); assert_eq!(held[0].coin_id, hex::encode(below.coin_id())); } diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index 2a93be4e..7654a87e 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -417,10 +417,16 @@ CREATE TABLE IF NOT EXISTS cat_admission_pending ( created_timestamp INTEGER, spent_timestamp INTEGER, derived_asset_id TEXT NOT NULL, - derived_owner_p2 TEXT NOT NULL + derived_owner_p2 TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_cat_admission_pending_created ON cat_admission_pending (created_height); +-- The promotion queue's ORDER BY. Fewest attempts first, then arrival order: a row that keeps +-- failing sinks, so it can never hold the queue head against a row that has never been tried. +CREATE INDEX IF NOT EXISTS idx_cat_admission_pending_queue + ON cat_admission_pending (attempts, seq); CREATE TABLE IF NOT EXISTS derivations ( hardened INTEGER NOT NULL, @@ -625,6 +631,10 @@ const ADD_COLUMN_MIGRATIONS: &[&str] = &[ // rows banned before this column existed, which sorts them first and so evicts them first -- // the right order, since they are by definition the oldest bans on the machine. "ALTER TABLE peers ADD COLUMN banned_at INTEGER", + // The CAT staging queue's attempt accounting (dig-node#394). A replica that ran an earlier + // build of this branch already has the table without them. + "ALTER TABLE cat_admission_pending ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE cat_admission_pending ADD COLUMN last_attempt_at INTEGER", ]; // ---- one-shot data-migration ladder --------------------------------------- @@ -1705,23 +1715,70 @@ impl WalletDb { Ok(()) } - /// The oldest `limit` staged rows — the promotion pass's work queue. + /// The promotion pass's work queue: up to `limit` staged rows, FEWEST ATTEMPTS FIRST, and + /// only rows not tried since `retry_cutoff`. /// - /// Oldest-first so a backlog drains in arrival order rather than starving the earliest coin, - /// and `limit` so one pass performs a bounded number of chain reads regardless of how many - /// rows an attacker staged. - pub async fn staged_cat_admissions(&self, limit: i64) -> sqlx::Result> { + /// # Why the ordering is attempts-then-arrival rather than arrival alone (dig-node#394) + /// + /// Arrival order alone is a denial primitive. A promotion that cannot conclude leaves its row + /// staged — deliberately, because deleting on an unreadable parent would let a source that is + /// merely behind erase real money — so `limit` rows whose parents never resolve occupy the + /// head of an arrival-ordered queue permanently. Every later pass re-reads the same `limit` + /// rows and no honest coin behind them is ever reached. The gate reproduced it: `deferred: 64` + /// on every pass, reads climbing 64, 128, ... unbounded, and the victim's $DIG balance zero + /// for ever, bought for 64 mojos. + /// + /// Ordering by `attempts` first fixes both halves at once. A row that has never been tried + /// always precedes one that has, so nothing can hold the head; and a row that keeps failing + /// sinks below every other row at its attempt level, so the reads an attacker buys are spread + /// across the whole table rather than concentrated on their own coins. + /// + /// # Why the cutoff, on top of the ordering + /// + /// The ordering removes starvation but not amplification: with a table of only poisoned rows, + /// every pass would still spend `limit` reads on them. `retry_cutoff` bounds a row to one read + /// per cooldown, so the total read rate an attacker can buy is `rows / cooldown` — bounded, + /// and independent of how often a pass runs. + /// + /// A row is never deleted for failing. Absence is the accepted failure direction here; + /// erasing a coin because a source was briefly behind is not. + pub async fn staged_cat_admissions( + &self, + limit: i64, + retry_cutoff: i64, + ) -> sqlx::Result> { sqlx::query_as::<_, StagedCatRow>( "SELECT coin_id, parent_coin_info, puzzle_hash, amount, created_height, spent_height, created_timestamp, spent_timestamp, derived_asset_id, derived_owner_p2 - FROM cat_admission_pending ORDER BY seq ASC LIMIT ?", + FROM cat_admission_pending + WHERE last_attempt_at IS NULL OR last_attempt_at <= ? + ORDER BY attempts ASC, seq ASC LIMIT ?", ) + .bind(retry_cutoff) .bind(limit) .fetch_all(&self.pool) .await } + /// Record that a promotion pass SPENT A READ on `coin_id` at `now` and could not conclude. + /// + /// Called on every inconclusive outcome — an unreadable parent, a source-reported absence, an + /// unconfirmed coin — because the resource being metered is the read, not the verdict. + /// A conclusive outcome deletes the row instead, so it never needs an attempt recorded. + pub async fn record_promotion_attempt(&self, coin_id: &str, now: i64) -> sqlx::Result<()> { + sqlx::query( + "UPDATE cat_admission_pending + SET attempts = attempts + 1, last_attempt_at = ? + WHERE coin_id = ?", + ) + .bind(now) + .bind(Self::normalise_hex(coin_id)) + .execute(&self.pool) + .await?; + Ok(()) + } + /// Which of `coin_ids` already have a row in `coins`. /// /// The routing question for an already-PROMOTED coin: once a coin has cleared promotion its diff --git a/crates/dig-wallet/src/sage/fallback.rs b/crates/dig-wallet/src/sage/fallback.rs index 10e79daf..4c110632 100644 --- a/crates/dig-wallet/src/sage/fallback.rs +++ b/crates/dig-wallet/src/sage/fallback.rs @@ -509,14 +509,32 @@ impl super::singleton::LineageSource for ChiaQueryLineage { spent_height: u32, ) -> Result> { let coin_id = format!("0x{}", CoinsetFallback::norm_hex(parent_coin_id)); + // A FAILED READ IS REPORTED AS A FAILED READ (dig-node#394). + // + // This used to be `Err(_) => return Ok(None)`, described as "a clean no lineage". It was + // not clean: `Ok(None)` is the value a caller reads as "the chain has no such spend", and + // this arm produced it for a DNS failure, a timeout, a 500 and a malformed reply alike. An + // outage therefore arrived at the promotion path wearing the costume of a chain fact — + // which matters because the two want opposite handling, and the one place that could tell + // them apart was here. + // + // `ChiaQuery`'s facade cannot yet distinguish "the source answered: no spend" from "the + // source did not answer" — only its inner coinset client exposes the absence-aware + // `get_puzzle_and_solution_opt`, and lifting that onto the facade is a chia-query release + // this PR will not take. So this impl reports the weaker but TRUE thing: every unsuccessful + // read is an `Err`, and `Ok(None)` is never manufactured from one. Callers already treat + // `Err` as "retry later", which is the correct handling for both causes. let cs = match self .query .get_puzzle_and_solution(&coin_id, Some(spent_height)) .await { Ok(cs) => cs, - // The parent spend is not available (unspent / not found) — a clean "no lineage". - Err(_) => return Ok(None), + Err(e) => { + return Err(Error::internal(format!( + "lineage: parent spend {coin_id} could not be read: {e}" + ))) + } }; let decode = |field: &str, s: &str| -> Result> { hex::decode(s.strip_prefix("0x").unwrap_or(s)) diff --git a/crates/dig-wallet/src/sage/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 68e25a05..537e500a 100644 --- a/crates/dig-wallet/src/sage/singleton.rs +++ b/crates/dig-wallet/src/sage/singleton.rs @@ -386,11 +386,22 @@ pub async fn reconstruct_coins( if !is_candidate(c, plain_puzzle_hashes) { continue; } - let Some(parent) = lineage - .parent_spend(&c.parent_coin_info, created as u32) - .await? - else { - continue; + // Per-coin resilience, deliberately (dig-node#394). `parent_spend` used to report an + // unreadable parent as `Ok(None)`, so one flaky read skipped one coin; now that it reports + // the failure honestly, propagating it here would let a single timeout abandon attribution + // for every remaining coin in the pass. Skip and log, exactly as before — the difference is + // that the cause is now visible rather than indistinguishable from a chain fact. + let parent = match lineage.parent_spend(&c.parent_coin_info, created as u32).await { + Ok(Some(parent)) => parent, + Ok(None) => continue, + Err(e) => { + tracing::debug!( + coin_id = %c.coin_id, + error = %e, + "attribution: parent spend unreadable; leaving the coin unattributed" + ); + continue; + } }; let child = coin_from_row(c)?; match reconstruct(prefix, Some(created as u32), &parent, child)? { From e8bff4ae5a10f35cb57451f4b0fe972e9e3071d1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:26:59 -0700 Subject: [PATCH 16/28] test(wallet): pin the starvation and the re-read bound, and stop weakening the claim Three changes, all to the same property. `promotion_reads_are_bounded_and_never_repeated` now asserts the thing its name promises. It previously settled for `second <= cap` with a comment conceding that deferred rows "ARE re-read" -- an assertion the defect satisfies, on a test named for the property the defect breaks. The correct number is one: after a pass one row past the cap has never been read, and the 64 already tried are inside their cooldown. Zero would be wrong too, and for the opposite reason. `a_wall_of_unresolvable_coins_cannot_starve_an_honest_one` stages exactly one pass's worth of coins with invented parents and one real simulator-built CAT behind them. A fixture where every staged coin is unresolvable cannot see this defect at all -- a starved queue and a healthy one emit the same output when nothing could promote -- so the honest control is what makes them differ. It is false for any number of passes under the old ordering. `the_queue_serves_fewest_attempts_first_and_honours_the_cooldown` asserts the mechanism directly, with time PINNED to an explicit NOW rather than drawn from the clock, and pins the cutoff from both sides: one second past it is served, exactly at it is served, inside it is not. Refs dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 185 +++++++++++++++++++- 1 file changed, 177 insertions(+), 8 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 5ccba289..04622724 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -731,19 +731,26 @@ mod tests { usize::try_from(MAX_CAT_PROMOTIONS_PER_PASS).unwrap(), "one pass must read exactly the cap, never the whole backlog" ); - // Every one of them was refused (their parents are unknown to the map -> but the map - // returns None, which is UNAVAILABLE, so they stay staged and ARE re-read). Assert the - // honest thing instead: the pass is capped and the already-PROMOTED coin is never re-read. + // None of them promotes: their parents are unknown to the map, which answers "unavailable" + // rather than "not a CAT", so they stay staged rather than being refused. assert_eq!(stats.promoted, 0, "{stats:?}"); - // TERMINALITY: the coin promoted in the calibration is out of the staging table, so no - // later pass can read its parent again however many passes run. + // NOT REPEATED — the property this test is named for, and which it did not previously + // assert. An earlier version settled for `second <= cap`, which is satisfied by the + // defect: every one of those rows WAS re-read on every pass, for ever, because a deferred + // row was eligible again immediately. With the retry cooldown the correct number is zero, + // and zero is what distinguishes a bounded queue from an unbounded one. let before = lineage.reads(); promote_staged_cats(&db, &lineage).await.unwrap(); let second = lineage.reads() - before; - assert!( - second <= usize::try_from(MAX_CAT_PROMOTIONS_PER_PASS).unwrap(), - "every pass stays capped, got {second}" + // EXACTLY ONE, and the number is the assertion. `over` is one past the per-pass cap, so + // after pass one there is precisely one row that has never been read; the other 64 are + // inside their retry cooldown. So the second pass reads the new row and RE-reads nothing. + // Zero would be wrong -- it would mean a row that had never been tried was skipped -- and + // 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" ); assert!( !db.all_coins() @@ -755,6 +762,168 @@ mod tests { ); } + /// **Proves (dig-node#394, gate finding 2):** a wall of unresolvable staged coins cannot starve + /// an honest one out of the promotion queue. + /// + /// THE BUG THIS PINS. A promotion that cannot read its parent leaves the row staged, on + /// purpose — deleting it would let a source that is merely behind erase real money. The queue + /// was served `ORDER BY seq ASC LIMIT 64`, so exactly 64 coins with invented parents occupied + /// the head permanently: every pass re-read the same 64, the read count climbed 64, 128, 192 + /// without bound, and the honest coin sitting behind them was never reached. The gate + /// reproduced it at ten passes with the victim's $DIG balance still zero. The whole primitive + /// costs 64 mojos and needs only the victim's public address. + /// + /// FIXTURE DESIGN — the honest coin is the point. A fixture in which EVERY staged coin is + /// unresolvable is the blindest possible one for this defect: with nothing left that could + /// promote, a starved queue and a healthy one produce identical output. So the wall is exactly + /// `MAX_CAT_PROMOTIONS_PER_PASS` coins, staged FIRST so they own the head under the old + /// ordering, and one real simulator-built CAT is staged behind them as the truthful control. + /// The assertion is that the control promotes — which is false under the old ordering for any + /// number of passes, and true here on the second. + #[tokio::test] + async fn a_wall_of_unresolvable_coins_cannot_starve_an_honest_one() { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + + // The wall: exactly one pass's worth, so it fills the head and nothing more. Their parents + // are absent from the map, which is the "unresolvable" answer. + let wall = usize::try_from(MAX_CAT_PROMOTIONS_PER_PASS).unwrap(); + let poisoned: Vec = (0..wall) + .map(|i| { + state( + fabricated_at(f.child.puzzle_hash, 1_000 + i as u64, 0x40), + Some(20 + i as u32), + None, + ) + }) + .collect(); + let rows = stage_from_states(&poisoned, &derived, |_| false); + assert_eq!(rows.len(), wall, "the wall must actually stage"); + db.stage_cat_admissions(&rows).await.unwrap(); + + // The honest coin, staged BEHIND the wall — the position that made it unreachable. + let honest = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); + assert_eq!(honest.len(), 1); + db.stage_cat_admissions(&honest).await.unwrap(); + + // Pass one spends its whole budget on the wall, exactly as before the fix. + let first = promote_staged_cats(&db, &lineage).await.unwrap(); + assert_eq!(first.promoted, 0, "the wall owns the head on pass one"); + assert_eq!( + usize::try_from(first.deferred).unwrap(), + wall, + "and consumes the whole budget: {first:?}" + ); + + // Pass two is the one the old ordering could never reach. The wall has been read once and + // is inside its cooldown; the honest coin has never been read, so it is served. + let before = lineage.reads(); + let second = promote_staged_cats(&db, &lineage).await.unwrap(); + assert_eq!( + second.promoted, 1, + "an honest coin behind a wall of unresolvable ones must still promote: {second:?}" + ); + assert_eq!( + lineage.reads() - before, + 1, + "and the wall must not be re-read while it is inside its cooldown" + ); + + // The money answer, concretely: the honest coin's own amount, and nothing the wall claimed. + assert_eq!( + db.balance(Some(&hex::encode(f.asset_id))).await.unwrap(), + u128::from(f.amount), + "the promoted CAT must be counted, and only it" + ); + assert_eq!( + db.balance(None).await.unwrap(), + 0u128, + "and nothing staged may ever be counted as XCH" + ); + } + + /// **Proves (dig-node#394):** the promotion queue is ordered by ATTEMPTS before arrival, and a + /// row inside its retry cooldown is not served at all. + /// + /// The mechanism under the test above, asserted directly so a regression names itself. Time is + /// PINNED to an explicit `NOW` rather than taken from the clock: `staged_cat_admissions` takes + /// its cutoff as a parameter precisely so a test can choose one, and a fixture that passed a + /// small number through a wall-clock comparison would place every row roughly 1.8 billion + /// seconds in the past and assert the expired path while claiming to test the fresh one. + #[tokio::test] + async fn the_queue_serves_fewest_attempts_first_and_honours_the_cooldown() { + const NOW: i64 = 1_800_000_000; + const COOLDOWN: i64 = 3_600; + let cutoff = NOW - COOLDOWN; + + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + let states: Vec = (0..3) + .map(|i| { + state( + fabricated_at(f.child.puzzle_hash, 500 + i as u64, 0x50), + Some(30 + i as u32), + None, + ) + }) + .collect(); + let rows = stage_from_states(&states, &derived, |_| false); + assert_eq!(rows.len(), 3); + db.stage_cat_admissions(&rows).await.unwrap(); + let first_id = rows[0].coin_id.clone(); + let last_id = rows[2].coin_id.clone(); + + // Arrival order, with nothing attempted yet. + let queue = db.staged_cat_admissions(3, cutoff).await.unwrap(); + assert_eq!( + queue[0].coin_id, first_id, + "an untried queue is served in arrival order" + ); + + // The head is read, LONG ago — outside the cooldown, so it stays eligible. + db.record_promotion_attempt(&first_id, cutoff - 1) + .await + .unwrap(); + let queue = db.staged_cat_admissions(3, cutoff).await.unwrap(); + assert_eq!(queue.len(), 3, "a row outside its cooldown is still served"); + assert_ne!( + queue[0].coin_id, first_id, + "but it must have SUNK: a row that has been tried never precedes one that has not" + ); + assert_eq!( + queue[2].coin_id, first_id, + "specifically, to the back of the queue" + ); + + // Read again, this time RECENTLY. Now the cooldown excludes it outright. + db.record_promotion_attempt(&first_id, NOW).await.unwrap(); + let queue = db.staged_cat_admissions(3, cutoff).await.unwrap(); + assert_eq!( + queue.len(), + 2, + "a row read inside its cooldown must not be served at all" + ); + assert!( + queue.iter().all(|r| r.coin_id != first_id), + "and it is that row that is missing" + ); + + // AT the boundary, the row is eligible again: pinned from both sides, so a cutoff + // comparison that drifted by one would fail here rather than pass quietly. + db.record_promotion_attempt(&last_id, cutoff).await.unwrap(); + let queue = db.staged_cat_admissions(3, cutoff).await.unwrap(); + assert!( + queue.iter().any(|r| r.coin_id == last_id), + "a row last read exactly AT the cutoff is eligible" + ); + } + /// A coin that has already cleared promotion is NOT re-staged: its later states, a spend above /// all, must update `coins` normally. /// From e113262a92be178654d03dc963d64b033bba7694 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:33:40 -0700 Subject: [PATCH 17/28] fix(wallet): a hint is a claim, so the point-read tier stages instead of believing `refresh_tracked_coins` fetched coins two ways -- by puzzle hash, which finds coins at the wallet's own p2 hashes, and by HINT, which finds coins that merely claim to be for this wallet -- and upserted both straight into `coins`. A row with no `asset_id` means XCH in this schema, and anyone may `CREATE_COIN` with any hint, so an attacker holding only the victim's public address minted a fabricated XCH balance for one mojo per displayed base unit, plus a permanent send kill-switch: selection is largest-first and nobody can spend the coin. This tier needs no peer at all -- the coinset oracle serves it -- and it is live on `main` today. The third tier is routed through the SAME staging table as the peer frame path rather than given a guard of its own. Three guards that must agree is precisely what produced this defect at three tiers; one admission point that demands a lineage proof is the shape that cannot be widened. Coins at the wallet's own p2 hashes are unaffected: they are genuinely ours and genuinely XCH. A hinted coin at a derived hash for a known asset is staged and promotes on proof. A hinted coin that is neither is now dropped rather than believed -- it could never be selected as an XCH input and carries no asset id, so admitting it only ever produced a wrong figure, and absence is this design's accepted failure direction. Closes dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 77 ++++++++++++++++++++- crates/dig-wallet/src/sage/rpc.rs | 37 +++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 04622724..e436415b 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -40,11 +40,11 @@ //! - **Off the frame path**: [`promote_staged_cats`] performs roughly **one** parent-spend read per //! newly staged coin, **terminal** on both success and definitive refusal, and capped per pass. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use chia_protocol::{Bytes32, Coin, CoinState}; -use super::db::{StagedCatRow, WalletDb}; +use super::db::{CoinRow, StagedCatRow, WalletDb}; use super::singleton::{coin_from_row, LineageSource, Reconstructed}; use super::{singleton, Result}; @@ -181,6 +181,79 @@ where rows } +/// Split already-materialised [`CoinRow`]s into the ones that may be BELIEVED and the ones that +/// must be STAGED — the point-read tier's half of the same routing `stage_from_states` performs on +/// the peer frame path (dig-node#394). +/// +/// # Why this exists rather than a second guard +/// +/// `refresh_tracked_coins` reads coins two ways: by PUZZLE HASH, which finds coins sitting at the +/// wallet's own p2 hashes, and by HINT, which finds coins that merely *claim* to be for this +/// wallet. It upserted both straight into `coins`, where a row with no `asset_id` means XCH — so a +/// hint is attacker-controlled input that minted a balance. Anyone may `CREATE_COIN` with any +/// hint, so this was the same fabricated-balance and send-kill-switch primitive as the catch-up +/// path, at a third tier and reachable without a peer at all. +/// +/// The fix is not a third guard. Three guards that must agree is what produced this defect twice +/// already; this routes the third tier through the SAME staging table, so there is one admission +/// point and it is the one that demands a lineage proof. +/// +/// A hinted coin that is not at a derived hash for a known asset is DROPPED rather than believed. +/// That is a deliberate narrowing: such a coin cannot be selected as an XCH input (its puzzle hash +/// is not ours) and carries no asset id, so admitting it only ever produced a wrong XCH figure. +/// Absence is this design's accepted failure direction; a wrong figure is not. +pub fn route_point_read_rows( + rows: &[CoinRow], + owned_puzzle_hashes: &HashSet, + derived: &DerivedCats, + mut already_promoted: F, +) -> (Vec, Vec) +where + F: FnMut(&str) -> bool, +{ + let mut believed = Vec::new(); + let mut staged = Vec::new(); + for row in rows { + // A coin at one of the wallet's OWN p2 hashes is an ordinary XCH coin: the wallet can + // spend it and its amount is XCH. Unchanged, and the only thing that stays unchanged. + if owned_puzzle_hashes.contains(&row.puzzle_hash) { + believed.push(row.clone()); + continue; + } + let Some(owner) = hex_to_bytes32(&row.puzzle_hash).and_then(|h| derived.owner_of(&h)) else { + continue; + }; + if already_promoted(&row.coin_id) { + // Already proven once; its later states update `coins` normally, exactly as on the + // frame path, or a promoted coin would stay unspent in the replica for ever. + believed.push(row.clone()); + continue; + } + staged.push(StagedCatRow { + coin_id: row.coin_id.clone(), + parent_coin_info: row.parent_coin_info.clone(), + puzzle_hash: row.puzzle_hash.clone(), + amount: row.amount.clone(), + created_height: row.created_height, + spent_height: row.spent_height, + // Preserved here, unlike the frame path: a point read carries them and throwing them + // away would make a promoted coin's history poorer than the row it came from. + created_timestamp: row.created_timestamp, + spent_timestamp: row.spent_timestamp, + derived_asset_id: hex::encode(owner.asset_id), + derived_owner_p2: hex::encode(owner.owner_p2), + }); + } + (believed, staged) +} + +/// Parse a 32-byte hex puzzle hash, tolerating a `0x` prefix and either case. +fn hex_to_bytes32(s: &str) -> Option { + let bytes = hex::decode(s.strip_prefix("0x").unwrap_or(s)).ok()?; + let arr: [u8; 32] = bytes.try_into().ok()?; + Some(Bytes32::new(arr)) +} + /// What one promotion pass did. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PromoteStats { diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 24f971f6..be152021 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3103,11 +3103,46 @@ impl WalletBackend { // 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?); - let rows: Vec = fetched.iter().map(fallback_coin_to_row).collect(); + let fetched_rows: Vec = fetched.iter().map(fallback_coin_to_row).collect(); + + // A HINT IS A CLAIM, NOT A FACT (dig-node#394). `coin_records_by_hints` finds coins that + // merely say they are for this wallet, and anybody may `CREATE_COIN` with any hint. These + // rows carry `asset_id: None`, which in this schema means XCH — so upserting them straight + // into `coins`, as this path used to, let one mojo per displayed base unit mint a + // fabricated XCH balance and, since selection is largest-first over a coin nobody can + // spend, a permanent send kill-switch. No peer required: the coinset oracle serves it. + // + // Routed through the SAME staging table as the peer frame path rather than guarded + // separately. One admission point that demands a lineage proof is the shape; three guards + // that must agree is what produced this defect at three tiers. + let owned_hashes: Vec<_> = signer.puzzle_hashes().into_iter().collect(); + let derived = super::cat_discovery::DerivedCats::derive( + &owned_hashes, + &[digstore_chain::dig::DIG_ASSET_ID], + ); + let owned: HashSet = phs.iter().cloned().collect(); + let promoted = self + .db + .existing_coin_ids( + &fetched_rows + .iter() + .map(|r| r.coin_id.clone()) + .collect::>(), + ) + .await?; + let (rows, staged) = super::cat_discovery::route_point_read_rows( + &fetched_rows, + &owned, + &derived, + |id| promoted.contains(id), + ); let n = rows.len(); if n > 0 { self.db.upsert_coins(&rows).await?; } + if !staged.is_empty() { + self.db.stage_cat_admissions(&staged).await?; + } // Attribute CATs (fills `asset_id`/`hint`) when a lineage source is attached — best-effort: // an attribution read failure must never make a fresh XCH sync look like a hard error. if let Some(lineage) = self.lineage.as_deref() { From 910dc4e790e2bf4be500ce8f1e7a973649ac4d57 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:41:09 -0700 Subject: [PATCH 18/28] fix(wallet): prove a hinted CAT from its parent spend instead of dropping it The first cut of the point-read re-route staged only coins sitting at a DERIVED hash for a known asset, and dropped every other hinted coin. That closed the fabricated-XCH hole but silently removed a shipped capability: the wallet's only path to an arbitrary (non-$DIG) CAT is the hint read, and `refresh_tracked_coins_feeds_cat_selection_and_build_sign` exercises exactly that end to end. A narrowing that a green suite would have hidden, and did not. So a hinted coin with no prediction is staged too, with an empty sentinel in place of the derived pair, and promotion proves it from the parent spend alone: the reconstruction must name this coin id, and its hint must be an address this wallet controls. That is the same claim the derived path proves -- this coin is a unit of asset A and only this wallet can spend it -- reached without a prediction to check against. `promote_staged_cats` therefore takes the wallet's own p2 hashes, so the unpredicted branch has something to check the hint against; without it the hint would be attacker-controlled all the way into `coins`, which is the defect rather than a smaller version of it. The supervisor tests now assert coverage and admission SEPARATELY -- the set put on the wire and the set that admits coins are different values, and asserting only the union is what let them be conflated. `subscribed_for` is renamed `requested_for` to say which one it describes. Refs dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 77 ++++++++++++++----- crates/dig-wallet/src/sage/rpc.rs | 19 ++++- crates/dig-wallet/src/sage/sync.rs | 2 +- .../src/sage/sync_supervisor/tests.rs | 29 +++++-- .../tests/real_wallet_cat_discovery.rs | 4 +- 5 files changed, 101 insertions(+), 30 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index e436415b..5482727f 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -220,9 +220,11 @@ where believed.push(row.clone()); continue; } - let Some(owner) = hex_to_bytes32(&row.puzzle_hash).and_then(|h| derived.owner_of(&h)) else { - continue; - }; + // Everything else was found by HINT and is a CLAIM. A derived hash additionally tells us + // which asset to expect; anything else is staged with the empty sentinel and proven purely + // from its parent spend. Nothing is dropped, so no CAT this path used to surface is lost — + // what changes is that none of it is BELIEVED before the proof. + let predicted = hex_to_bytes32(&row.puzzle_hash).and_then(|h| derived.owner_of(&h)); if already_promoted(&row.coin_id) { // Already proven once; its later states update `coins` normally, exactly as on the // frame path, or a promoted coin would stay unspent in the replica for ever. @@ -240,8 +242,8 @@ where // away would make a promoted coin's history poorer than the row it came from. created_timestamp: row.created_timestamp, spent_timestamp: row.spent_timestamp, - derived_asset_id: hex::encode(owner.asset_id), - derived_owner_p2: hex::encode(owner.owner_p2), + derived_asset_id: predicted.map(|o| hex::encode(o.asset_id)).unwrap_or_default(), + derived_owner_p2: predicted.map(|o| hex::encode(o.owner_p2)).unwrap_or_default(), }); } (believed, staged) @@ -288,6 +290,7 @@ pub struct PromoteStats { pub async fn promote_staged_cats( db: &WalletDb, lineage: &dyn LineageSource, + owned_p2: &HashSet, ) -> Result { let mut stats = PromoteStats::default(); // Read from the wall clock ONCE, so every row in this pass is metered against the same @@ -343,7 +346,7 @@ pub async fn promote_staged_cats( continue; } }; - if promote_one(db, lineage_prefix(), &row, &parent).await? { + if promote_one(db, lineage_prefix(), &row, &parent, owned_p2).await? { stats.promoted += 1; } else { db.discard_cat_admission(&row.coin_id).await?; @@ -373,6 +376,7 @@ async fn promote_one( prefix: &str, row: &StagedCatRow, parent: &super::singleton::ParentSpend, + owned_p2: &HashSet, ) -> Result { let coin_row = staged_as_coin(row); let child: Coin = coin_from_row(&coin_row)?; @@ -398,12 +402,32 @@ async fn promote_one( // The parent read succeeded and this coin is not a CAT child of it at all. Disproven. return Ok(false); }; - // The reconstruction must agree with the derivation on BOTH halves. Checking only the asset id - // would admit a real CAT of the right asset owned by somebody else; checking only the owner - // would admit a CAT of a different asset counted as this one. Both are money-visible. - let agrees = coin_id.eq_ignore_ascii_case(&row.coin_id) - && asset_id.eq_ignore_ascii_case(&row.derived_asset_id) - && hint.eq_ignore_ascii_case(&row.derived_owner_p2); + // The reconstruction must agree on BOTH halves — which coin, and whose. Checking only the + // asset id would admit a real CAT of the right asset owned by somebody else; checking only the + // owner would admit a CAT of a different asset counted as this one. Both are money-visible. + // + // WHAT "AGREE" MEANS DEPENDS ON WHETHER ANYTHING WAS PREDICTED (dig-node#394). A coin found at + // a DERIVED hash arrives with a predicted (asset, owner) pair, and the reconstruction must + // match that pair exactly — the derivation said where to look, and a coin that turns out to be + // something else is disproven. A coin found by HINT arrives with no prediction: nothing was + // derived, so there is nothing to match against, and the row carries the empty sentinel. + // + // The proof is equally strong in both cases, because it is the same proof: the parent spend + // reconstructs this coin id as a CAT of asset A hinted to p2 H. When H is one of the wallet's + // own p2 hashes, that IS "this coin is a unit of asset A and only this wallet can spend it" — + // which is the entire claim being made. The predicted case additionally checks that the + // derivation was not lying about which asset it expected. + let asset_agrees = row.derived_asset_id.is_empty() + || asset_id.eq_ignore_ascii_case(&row.derived_asset_id); + let owner_agrees = if row.derived_owner_p2.is_empty() { + // Unpredicted: the reconstruction's own hint must name an address this wallet controls. + // Without this the hint would be attacker-controlled all the way to `coins`, which is the + // defect being fixed rather than a smaller version of it. + owned_p2.contains(&hint.to_ascii_lowercase()) + } else { + hint.eq_ignore_ascii_case(&row.derived_owner_p2) + }; + let agrees = coin_id.eq_ignore_ascii_case(&row.coin_id) && asset_agrees && owner_agrees; if !agrees { tracing::warn!( coin_id = %row.coin_id, @@ -509,6 +533,17 @@ mod tests { } } + /// The wallet's own p2 hashes, for the tests whose staged rows all carry a PREDICTED owner. + /// + /// Empty on purpose, and only sound because of that precondition: `owned_p2` is consulted + /// solely on the unpredicted branch, so an empty set here cannot make a predicted-row test + /// pass for the wrong reason. The unpredicted branch has its own fixture with a real set -- + /// see `an_unpredicted_hinted_coin_promotes_only_to_an_address_we_control`, without which this + /// helper would be exactly the uniform-fixture collapse this family keeps paying for. + fn owned() -> HashSet { + HashSet::new() + } + /// A [`LineageSource`] over a fixed parent map, which COUNTS its reads and can be told to fail /// for one specific parent. /// @@ -605,7 +640,7 @@ mod tests { lineage .by_parent .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); - let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); assert_eq!(stats.promoted, 1, "{stats:?}"); assert_eq!(db.staged_cat_admission_count().await.unwrap(), 0); @@ -657,7 +692,7 @@ mod tests { .by_parent .insert(hex::encode(fake.parent_coin_info), f.parent.clone()); - let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); assert_eq!(stats.promoted, 1, "{stats:?}"); assert_eq!(stats.refused, 1, "{stats:?}"); @@ -689,7 +724,7 @@ mod tests { let rows = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); db.stage_cat_admissions(&rows).await.unwrap(); // A source that can read nothing at all: every coin stays staged. - let stats = promote_staged_cats(&db, &CountingLineage::default()) + let stats = promote_staged_cats(&db, &CountingLineage::default(), &owned()) .await .unwrap(); assert_eq!(stats.deferred, 1, "{stats:?}"); @@ -741,7 +776,7 @@ mod tests { .by_parent .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); - let stats = promote_staged_cats(&db, &lineage) + let stats = promote_staged_cats(&db, &lineage, &owned()) .await .expect("a failing parent read must not fail the whole pass"); assert_eq!( @@ -773,7 +808,7 @@ mod tests { .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); let real = stage_from_states(&[state(f.child, Some(10), None)], &derived, |_| false); db.stage_cat_admissions(&real).await.unwrap(); - promote_staged_cats(&db, &lineage).await.unwrap(); + promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); assert_eq!( lineage.reads(), 1, @@ -797,7 +832,7 @@ mod tests { db.stage_cat_admissions(&rows).await.unwrap(); let before = lineage.reads(); - let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); let first_pass = lineage.reads() - before; assert_eq!( first_pass, @@ -814,7 +849,7 @@ mod tests { // row was eligible again immediately. With the retry cooldown the correct number is zero, // and zero is what distinguishes a bounded queue from an unbounded one. let before = lineage.reads(); - promote_staged_cats(&db, &lineage).await.unwrap(); + promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); let second = lineage.reads() - before; // EXACTLY ONE, and the number is the assertion. `over` is one past the per-pass cap, so // after pass one there is precisely one row that has never been read; the other 64 are @@ -885,7 +920,7 @@ mod tests { db.stage_cat_admissions(&honest).await.unwrap(); // Pass one spends its whole budget on the wall, exactly as before the fix. - let first = promote_staged_cats(&db, &lineage).await.unwrap(); + let first = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); assert_eq!(first.promoted, 0, "the wall owns the head on pass one"); assert_eq!( usize::try_from(first.deferred).unwrap(), @@ -896,7 +931,7 @@ mod tests { // Pass two is the one the old ordering could never reach. The wall has been read once and // is inside its cooldown; the honest coin has never been read, so it is served. let before = lineage.reads(); - let second = promote_staged_cats(&db, &lineage).await.unwrap(); + let second = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); assert_eq!( second.promoted, 1, "an honest coin behind a wall of unresolvable ones must still promote: {second:?}" diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index be152021..7960bc1f 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3150,7 +3150,7 @@ impl WalletBackend { // discovered at a derived hash becomes spendable on this tier too. Best-effort for // the same reason attribution is: a chain-read failure must not make a fresh XCH sync // look like a hard error. - let _ = super::cat_discovery::promote_staged_cats(&self.db, lineage).await; + let _ = super::cat_discovery::promote_staged_cats(&self.db, lineage, &owned).await; let plain: HashSet = phs.iter().cloned().collect(); let _ = singleton::reconstruct_all(&self.db, lineage, &self.config.address_prefix, &plain) @@ -9493,8 +9493,23 @@ mod tests { ); // The wallet coin-DB sync: read the wallet's own coins from chain + attribute the CAT. + // + // The return value counts rows written DIRECTLY into `coins`, and a hinted coin is no + // longer one of them (dig-node#394): a hint is attacker-controlled, so it is staged and + // admitted only once its parent spend proves what it is. Zero here is the whole re-route, + // and the assertions below are what prove the re-route costs no capability — the same + // coin, the same TAIL, the same selectability, reached through a proof instead of trust. let n = be.refresh_tracked_coins().await.unwrap(); - assert_eq!(n, 1, "the hinted CAT coin was synced into the DB"); + assert_eq!( + n, 0, + "a coin found by HINT is staged, never upserted straight into `coins`" + ); + assert_eq!( + be.db.staged_cat_admission_count().await.unwrap(), + 0, + "and the staging table is empty afterwards because the coin was PROMOTED out of it, \ + not because it was never staged -- the next assertion is what tells those apart" + ); // AFTER the sync: the coin is in the DB, attributed to its TAIL, and selectable. let unspent = be.db.unspent_coins(Some(&asset_hex)).await.unwrap(); diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index e8dfbfdb..c02c5e28 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -826,7 +826,7 @@ impl CatAttributor<'_> { /// A swallowed failure costs a delay and nothing else: the staged rows are untouched, so the /// next pass retries them. Absent, never wrong. async fn promote(&self, db: &WalletDb) { - match cat_discovery::promote_staged_cats(db, self.lineage).await { + match cat_discovery::promote_staged_cats(db, self.lineage, self.plain_puzzle_hashes).await { Ok(stats) if stats.promoted > 0 || stats.refused > 0 => { tracing::info!( promoted = stats.promoted, diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 5e727452..1f470861 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -890,10 +890,19 @@ async fn supervisor_runs_catch_up_once_custody_has_keys() { h.until_db("the catch-up to complete", |s| s.initial_sync_complete) .await; + // ADMISSION: the custodied p2 hashes, and nothing else. A derived CAT hash here is a coin + // admitted to the money table on a claim anybody could make. assert_eq!( h.script.catch_ups.lock().unwrap()[0], - subscribed_for(&expected), - "the subscribed set must be exactly the custodied p2 hashes and their derived CAT hashes" + expected, + "the ADMISSION set must be exactly the custodied p2 hashes" + ); + // COVERAGE: the union actually put on the wire. Asserting only the line above would be + // satisfied by a catch-up that stopped asking about CAT hashes altogether, which is #380. + assert_eq!( + h.script.requested.lock().unwrap()[0], + requested_for(&expected), + "the REQUESTED set must be the custodied p2 hashes and their derived CAT hashes" ); assert!( db.is_synced().await.unwrap(), @@ -1048,8 +1057,13 @@ async fn a_wallet_created_after_boot_is_subscribed_without_waiting_for_a_disconn .await; assert_eq!( h.script.catch_ups.lock().unwrap()[0], - subscribed_for(&[created]), - "the catch-up must subscribe exactly the new wallet's hash and its derived CAT hash" + vec![created], + "the catch-up ADMITS exactly the new wallet's own hash" + ); + assert_eq!( + h.script.requested.lock().unwrap()[0], + requested_for(&[created]), + "and REQUESTS that hash together with its derived CAT hash" ); h.until_db("the catch-up to complete", |s| s.initial_sync_complete) .await; @@ -1171,7 +1185,12 @@ fn as_wire_matches_the_serialized_token_for_every_phase() { /// hashes are exactly `cat_puzzle_hash(address, DIG_ASSET_ID)` and nothing else; an implementation /// that subscribed one hash too many, or the wrong curry, fails just as loudly as one that /// subscribed too few. -fn subscribed_for(addresses: &[Bytes32]) -> Vec { +/// +/// This is the set REQUESTED FROM THE PEER, and it is deliberately not the set that admits coins +/// into `coins` (dig-node#394). The two were one value once, and that is exactly how a coin at a +/// derived CAT hash came to be admitted and counted as XCH. Every caller below therefore asserts +/// both: `catch_ups` for admission, this for coverage. +fn requested_for(addresses: &[Bytes32]) -> Vec { let mut all: Vec = addresses.to_vec(); all.extend( addresses diff --git a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs index a32d3d58..14414fc5 100644 --- a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs +++ b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs @@ -113,7 +113,9 @@ async fn the_real_wallet_reports_its_real_dig_balance() { db.stage_cat_admissions(&rows).await.unwrap(); let lineage = CapturedLineage::load(&parents_json); - let stats = promote_staged_cats(&db, &lineage).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &std::collections::HashSet::new()) + .await + .unwrap(); println!("[PROMOTE] {stats:?}"); let balance = db.balance(Some(&asset_hex)).await.unwrap(); From 6bac81715f5224751d94c017c83a6ce4ff3439ac Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:44:05 -0700 Subject: [PATCH 19/28] test(wallet): pin the hint tier from both ends Two regression tests for dig-node#394. `a_hinted_coin_is_staged_while_a_coin_at_our_own_hash_is_admitted` drives the real `refresh_tracked_coins` with an attacker's coin at the victim's derived $DIG hash beside an honest coin at the victim's own p2 hash. The honest coin is what makes it a PLACEMENT test: "the balance is short" is satisfied identically by a correct re-route and by a refresh that fetched nothing, so the control must still be admitted, still be selectable, and still be the whole balance. The fabricated coin is asserted present in STAGING as well as absent from `coins`, so the fix cannot be satisfied by dropping it on the floor either. `an_unpredicted_hinted_coin_promotes_only_to_an_address_we_control` runs the same coin, the same proof and the same lineage source twice, varying only the set of addresses the wallet claims. A promotion path that ignored `owned_p2` passes the first half and fails the second. Refs dig-node#394. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 76 ++++++++++++ crates/dig-wallet/src/sage/rpc.rs | 127 ++++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 5482727f..4c7dddd2 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -1032,6 +1032,82 @@ mod tests { ); } + /// **Proves (dig-node#394):** an UNPREDICTED staged coin — one found by hint, with no derived + /// pair to check against — promotes only when the parent spend hints it to an address this + /// wallet actually controls. + /// + /// THE RISK THIS PINS. A coin found by hint carries no prediction, so the asset-id and owner + /// comparisons the derived path relies on have nothing to compare with. If that branch simply + /// skipped the owner check, the hint would reach `coins` attacker-controlled — the whole of + /// #394, moved one layer inwards rather than fixed. + /// + /// FIXTURE DESIGN — the same coin, twice, with ONE thing varied. The lineage source, the + /// staged row, the reconstruction and the asset are identical across both halves; only the + /// set of addresses the wallet claims to control differs. So a pass cannot be explained by + /// anything except the owner check, and an implementation that ignored `owned_p2` fails the + /// second half while still passing the first. + #[tokio::test] + async fn an_unpredicted_hinted_coin_promotes_only_to_an_address_we_control() { + let f = real_cat(); + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + + // The staged row as the HINT path builds it: both derived fields empty, because nothing + // was predicted. Everything else is the real coin. + let unpredicted = StagedCatRow { + coin_id: hex::encode(f.child.coin_id()), + parent_coin_info: hex::encode(f.child.parent_coin_info), + puzzle_hash: hex::encode(f.child.puzzle_hash), + amount: f.child.amount.to_string(), + created_height: Some(10), + spent_height: None, + created_timestamp: None, + spent_timestamp: None, + derived_asset_id: String::new(), + derived_owner_p2: String::new(), + }; + + // HALF ONE — the wallet controls the address the parent spend hints to. + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(std::slice::from_ref(&unpredicted)) + .await + .unwrap(); + let ours: HashSet = [hex::encode(f.owner_p2)].into_iter().collect(); + let stats = promote_staged_cats(&db, &lineage, &ours).await.unwrap(); + assert_eq!( + stats.promoted, 1, + "a hinted coin whose parent proves it ours must promote: {stats:?}" + ); + assert_eq!( + db.balance(Some(&hex::encode(f.asset_id))).await.unwrap(), + u128::from(f.amount), + "and be counted under the asset the PARENT SPEND named, not one anybody claimed" + ); + + // HALF TWO — the identical coin, the identical proof, and the one varied thing: this + // wallet does not control the address it is hinted to. + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(std::slice::from_ref(&unpredicted)) + .await + .unwrap(); + let stranger: HashSet = [hex::encode(Bytes32::new([0x77; 32]))] + .into_iter() + .collect(); + let stats = promote_staged_cats(&db, &lineage, &stranger).await.unwrap(); + assert_eq!( + stats.refused, 1, + "a hinted coin belonging to somebody else must be REFUSED: {stats:?}" + ); + assert_eq!(stats.promoted, 0); + assert_eq!( + db.all_coins().await.unwrap().len(), + 0, + "and must not reach `coins` by any route" + ); + } + /// A coin that has already cleared promotion is NOT re-staged: its later states, a spend above /// all, must update `coins` normally. /// diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 7960bc1f..932f8a2c 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -8574,6 +8574,133 @@ mod tests { ); } + /// **Proves (dig-node#394):** a coin found by HINT never reaches `coins` on the point-read + /// tier, and never becomes a selectable XCH input — while a coin at the wallet's OWN puzzle + /// hash still does. + /// + /// THE BUG THIS PINS. `refresh_tracked_coins` fetched by puzzle hash AND by hint and upserted + /// both. A row with no `asset_id` means XCH, and anybody may `CREATE_COIN` with any hint, so + /// one mojo per displayed base unit bought a fabricated XCH balance from an attacker holding + /// nothing but the victim's public address. Worse than the wrong figure: selection is + /// largest-first and nobody can spend the coin, so it is a permanent XCH send kill-switch. + /// This tier needs no peer at all — the coinset oracle serves it. + /// + /// FIXTURE DESIGN — the honest coin is what makes this a PLACEMENT test rather than an + /// outcome test. "The balance is 999999999 short" is satisfied identically by a correct + /// re-route and by a refresh that fetched nothing at all, and the second is a different bug. + /// So an ordinary XCH coin at the wallet's own p2 hash rides along as a truthful control: it + /// must still be admitted, still be selectable, and still be the ENTIRE balance. And the + /// fabricated coin is asserted present in STAGING, so "not in `coins`" cannot be satisfied by + /// dropping it on the floor either — the two assertions together pin where it went, not + /// merely where it did not go. + #[tokio::test] + async fn a_hinted_coin_is_staged_while_a_coin_at_our_own_hash_is_admitted() { + use super::super::fallback::ChainFallback; + + struct TwoTierFallback { + at_our_hash: FallbackCoin, + hinted: FallbackCoin, + } + #[async_trait::async_trait] + impl ChainFallback for TwoTierFallback { + async fn coin_records_by_puzzle_hashes( + &self, + _phs: &[String], + ) -> Result> { + Ok(vec![self.at_our_hash.clone()]) + } + async fn coin_records_by_hints(&self, _hints: &[String]) -> Result> { + Ok(vec![self.hinted.clone()]) + } + async fn coin_record_by_id(&self, _coin_id: &str) -> Result> { + Ok(None) + } + async fn coin_spend(&self, _coin_id: &str) -> Result> { + Ok(None) + } + async fn coin_records_by_parent(&self, _p: &str) -> Result> { + Ok(vec![]) + } + fn is_live(&self) -> bool { + true + } + } + + let pair = BlsPair::new(3); + let signer = Arc::new(WalletSigner::new(vec![pair.sk], Bytes32::new([0u8; 32]))); + let ph = *signer.puzzle_hashes().iter().next().unwrap(); + let ph_hex = hex::encode(ph); + + // What the attacker places: a coin at the derived $DIG hash for this victim, hinted to + // them, for a number they will read as their balance. It costs one mojo per base unit and + // needs only `ph`, which is public. + let derived_hash = digstore_chain::cat::cat_puzzle_hash(ph, digstore_chain::dig::DIG_ASSET_ID); + let fallback = TwoTierFallback { + at_our_hash: FallbackCoin { + coin_id: "aa".repeat(32), + parent_coin_info: "11".repeat(32), + puzzle_hash: ph_hex.clone(), + amount: 7_000, + created_height: Some(5), + spent_height: None, + created_timestamp: Some(1), + spent_timestamp: None, + }, + hinted: FallbackCoin { + coin_id: "bb".repeat(32), + parent_coin_info: "22".repeat(32), + puzzle_hash: hex::encode(derived_hash), + amount: 999_999_999, + created_height: Some(6), + spent_height: None, + created_timestamp: Some(2), + spent_timestamp: None, + }, + }; + let cfg = WalletConfig { + puzzle_hashes: vec![ph_hex.clone()], + address_prefix: "txch".into(), + ..Default::default() + }; + let be = WalletBackend::new( + WalletDb::open_in_memory().await.unwrap(), + Arc::new(fallback), + cfg, + ) + .with_signer(signer); + + // No lineage source is attached, so nothing can prove the fabricated coin — which is the + // attacker's own situation, since no parent spend exists that would. + let n = be.refresh_tracked_coins().await.unwrap(); + assert_eq!(n, 1, "only the coin at our own puzzle hash is admitted"); + + // WHERE THE FABRICATED COIN WENT: staging, awaiting a proof it cannot get. + assert_eq!( + be.db.staged_cat_admission_count().await.unwrap(), + 1, + "the hinted coin must be STAGED -- not admitted, and not silently dropped" + ); + + // WHAT THE MONEY SURFACES SAY. The control's amount, exactly, and nothing else. + assert_eq!( + be.db.balance(None).await.unwrap(), + 7_000, + "the fabricated coin must contribute nothing to the XCH balance" + ); + let selectable = be.db.unspent_coins(None).await.unwrap(); + assert_eq!( + selectable.len(), + 1, + "and must never become a selectable XCH input: selection is largest-first, so one \\ + unspendable coin at the head is a permanent send kill-switch" + ); + assert_eq!( + selectable[0].coin_id, + "aa".repeat(32), + "the one selectable coin is the honest one" + ); + } + /// A locked wallet (no signer ⇒ no tracked puzzle hashes) is a clean no-op refresh — never an /// error, never a spurious sync. #[tokio::test] From 69a1c1106a66fc237fca3804cc1ed9c8695612ca Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:50:16 -0700 Subject: [PATCH 20/28] fix(wallet): claim the staged row before writing the coin, and stop the silent promote Three of the gate's non-gating findings, all in files this PR already touches. `promote_cat_admission` now DELETEs the staged row first and treats the row count as the gate. Promotion spans a network round trip, and a reorg rollback can remove that row inside the window; inserting first would write a coin the replica had just decided to forget, and the delete would then quietly remove nothing. `promote_one` gains a three-way `Promotion` outcome so a row that vanished mid-read is counted as deferred rather than recorded as refused -- "not promoted" was covering a terminal verdict and a non-verdict alike. `existing_coin_ids` becomes one query instead of a round trip per coin. It sits on the peer frame path where the batch size is the peer's choice, so the loop handed a peer a knob on the local database for no gain. The one promotion site that actually runs on a shipped node -- `CatAttributor` is constructed only under `cfg(test)`, so the frame-path pass does not run in production -- was `let _ =`, discarding both the counts and the cause. A wallet whose $DIG never appeared produced no evidence anywhere of why. It now logs, and is still best-effort. Refs dig-node#394, dig-node#382. Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 48 ++++++++++++----- crates/dig-wallet/src/sage/db.rs | 57 +++++++++++++++------ crates/dig-wallet/src/sage/rpc.rs | 28 +++++++++- 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 4c7dddd2..026990d0 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -346,11 +346,16 @@ pub async fn promote_staged_cats( continue; } }; - if promote_one(db, lineage_prefix(), &row, &parent, owned_p2).await? { - stats.promoted += 1; - } else { - db.discard_cat_admission(&row.coin_id).await?; - stats.refused += 1; + match promote_one(db, lineage_prefix(), &row, &parent, owned_p2).await? { + Promotion::Promoted => stats.promoted += 1, + Promotion::Disproven => { + db.discard_cat_admission(&row.coin_id).await?; + stats.refused += 1; + } + // The staged row was rolled back while its parent spend was being read. Nothing was + // written and there is nothing to discard; the coin re-stages if it reappears above + // the fork. Counted as deferred because that is what it is — no verdict was reached. + Promotion::Vanished => stats.deferred += 1, } } Ok(stats) @@ -370,14 +375,30 @@ fn lineage_prefix() -> &'static str { "xch" } -/// Decide and apply one coin's promotion. `Ok(true)` promoted, `Ok(false)` disproven. +/// What deciding one coin's promotion concluded. +/// +/// Three outcomes rather than a bool, because "not promoted" covers two situations that must not +/// be treated alike: a coin the parent spend DISPROVES is finished with and its staging row is +/// deleted, while a coin whose row a reorg removed mid-read reached no verdict at all and must not +/// be recorded as refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Promotion { + /// The parent spend proves the coin, and it is now in `coins`. + Promoted, + /// The parent spend was read and does not support the claim. Terminal. + Disproven, + /// The staged row was gone by the time the write ran — a reorg rollback inside the read. + Vanished, +} + +/// Decide and apply one coin's promotion. async fn promote_one( db: &WalletDb, prefix: &str, row: &StagedCatRow, parent: &super::singleton::ParentSpend, owned_p2: &HashSet, -) -> Result { +) -> Result { let coin_row = staged_as_coin(row); let child: Coin = coin_from_row(&coin_row)?; // THE BINDING CHECK. The staged row's coin id is re-derived from the fields the row itself @@ -389,7 +410,7 @@ async fn promote_one( coin_id = %row.coin_id, "cat promotion: staged row's coin id does not bind its own fields; refusing" ); - return Ok(false); + return Ok(Promotion::Disproven); } let reconstructed = singleton::reconstruct(prefix, row.created_height.map(|h| h as u32), parent, child)?; @@ -400,7 +421,7 @@ async fn promote_one( } = reconstructed else { // The parent read succeeded and this coin is not a CAT child of it at all. Disproven. - return Ok(false); + return Ok(Promotion::Disproven); }; // The reconstruction must agree on BOTH halves — which coin, and whose. Checking only the // asset id would admit a real CAT of the right asset owned by somebody else; checking only the @@ -435,11 +456,14 @@ async fn promote_one( derived_asset = %row.derived_asset_id, "cat promotion: the parent spend disagrees with the derivation; refusing" ); - return Ok(false); + return Ok(Promotion::Disproven); } // Attributed from the RECONSTRUCTION's own values, which is the whole content of the proof. - db.promote_cat_admission(row, &asset_id, &hint).await?; - Ok(true) + Ok(if db.promote_cat_admission(row, &asset_id, &hint).await? { + Promotion::Promoted + } else { + Promotion::Vanished + }) } /// A staged row viewed as a coin, for reconstruction only. Never written to `coins` from here — diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index 7654a87e..6d264eac 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -1784,19 +1784,25 @@ impl WalletDb { /// The routing question for an already-PROMOTED coin: once a coin has cleared promotion its /// spend must update `coins` normally, exactly as `origin/main` does, or a promoted coin would /// stay unspent in the replica forever and be re-selected after it was spent. + /// + /// ONE query, not one per coin. This sits on the peer frame path, where the batch size is + /// chosen by the peer, so a round trip per coin hands that peer a knob on the wallet's own + /// database. Nothing here reads the chain — the frame path's zero-chain-reads property is + /// unaffected either way — but a bounded number of local round trips is worth having when the + /// bound costs one query. pub async fn existing_coin_ids(&self, coin_ids: &[String]) -> sqlx::Result> { - let mut found = HashSet::new(); + if coin_ids.is_empty() { + return Ok(HashSet::new()); + } + let placeholders = std::iter::repeat_n("?", coin_ids.len()) + .collect::>() + .join(","); + let sql = format!("SELECT coin_id FROM coins WHERE coin_id IN ({placeholders})"); + let mut query = sqlx::query_scalar::<_, String>(&sql); for id in coin_ids { - let hit: Option = - sqlx::query_scalar("SELECT coin_id FROM coins WHERE coin_id = ?") - .bind(Self::normalise_hex(id)) - .fetch_optional(&self.pool) - .await?; - if let Some(hit) = hit { - found.insert(hit); - } + query = query.bind(Self::normalise_hex(id)); } - Ok(found) + Ok(query.fetch_all(&self.pool).await?.into_iter().collect()) } /// Move one staged coin into `coins`, FULLY ATTRIBUTED, and drop its staging row — in one @@ -1805,13 +1811,36 @@ impl WalletDb { /// `asset_id` and `hint` come from the parent spend's own reconstruction, never from the /// derivation that discovered the coin. That is the whole content of the proof: the derivation /// said where to look, the parent spend says what the coin IS. + /// + /// # The DELETE comes first, and its row count is the gate + /// + /// Promotion spans a network round trip: the row is read, a parent spend is fetched, and only + /// then is this called. A reorg rollback can delete the staged row inside that window, and + /// deleting it is the rollback saying the coin no longer exists at that height. Inserting + /// first and deleting afterwards would write a coin the replica had just decided to forget, + /// and the delete would then quietly remove nothing. + /// + /// So the delete runs first and `Ok(false)` is returned when it removed nothing: no staged + /// row, no promotion. The whole thing is one transaction, so a concurrent rollback either + /// happens entirely before this (the row is gone, and nothing is written) or entirely after + /// (the rollback removes the promoted coin by the same predicate). pub async fn promote_cat_admission( &self, row: &StagedCatRow, asset_id: &str, hint: &str, - ) -> sqlx::Result<()> { + ) -> sqlx::Result { let mut tx = self.pool.begin().await?; + let claimed = sqlx::query("DELETE FROM cat_admission_pending WHERE coin_id = ?") + .bind(Self::normalise_hex(&row.coin_id)) + .execute(&mut *tx) + .await? + .rows_affected(); + if claimed != 1 { + // Rolled back underneath us. Nothing to promote, and nothing to undo. + tx.rollback().await?; + return Ok(false); + } sqlx::query( "INSERT INTO coins (coin_id, parent_coin_info, puzzle_hash, amount, created_height, @@ -1837,12 +1866,8 @@ impl WalletDb { .bind(row.spent_timestamp) .execute(&mut *tx) .await?; - sqlx::query("DELETE FROM cat_admission_pending WHERE coin_id = ?") - .bind(Self::normalise_hex(&row.coin_id)) - .execute(&mut *tx) - .await?; tx.commit().await?; - Ok(()) + Ok(true) } /// Drop a staged coin that a SUCCESSFUL parent read proved is not a unit of the derived asset. diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 932f8a2c..388a7d7c 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3150,7 +3150,33 @@ impl WalletBackend { // discovered at a derived hash becomes spendable on this tier too. Best-effort for // the same reason attribution is: a chain-read failure must not make a fresh XCH sync // look like a hard error. - let _ = super::cat_discovery::promote_staged_cats(&self.db, lineage, &owned).await; + // The ONE promotion site that runs on the shipped node today: `CatAttributor` is + // constructed only under `cfg(test)`, so the frame-path pass does not run in + // production (dig-node#382, the wiring half, is PR + // https://github.com/DIG-Network/dig-node/pull/391). Until that lands, this is where a + // staged coin becomes spendable, and it must not be silent about it: a `let _ =` + // discarded both the counts and the cause, so a wallet whose $DIG never appeared + // produced no evidence of why anywhere. + // + // Still best-effort, and deliberately: a chain-read failure must not turn a successful + // XCH refresh into a hard error. + match super::cat_discovery::promote_staged_cats(&self.db, lineage, &owned).await { + Ok(stats) if stats.promoted > 0 || stats.refused > 0 => tracing::info!( + promoted = stats.promoted, + refused = stats.refused, + deferred = stats.deferred, + "wallet sync: CAT admission promotion pass (point-read tier)" + ), + Ok(stats) if stats.deferred > 0 => tracing::debug!( + deferred = stats.deferred, + "wallet sync: staged CAT coins are awaiting a readable parent spend" + ), + Ok(_) => {} + Err(e) => tracing::warn!( + error = %e, + "wallet sync: the CAT promotion pass failed; staged coins are unchanged" + ), + } let plain: HashSet = phs.iter().cloned().collect(); let _ = singleton::reconstruct_all(&self.db, lineage, &self.config.address_prefix, &plain) From 0a85ea4709ee8ec63aa10febe8ce1b723c1aa9c8 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:53:53 -0700 Subject: [PATCH 21/28] docs(spec): rewrite 18.11a against what the code does, not what it intended Four of its normative MUSTs shipped false in the commit that wrote them, and a fifth was vacuous. Rewritten clause by clause against this PR's diff. Corrected: "never to `coins`" and "not counted as XCH" were falsified by the catch-up path admitting every derived-hash coin; "only when a read of its parent spend reconstructs it" did not describe the catch-up either; "at most one parent-spend read per staged coin and is terminal" was false of the deferral an attacker chooses, which is the outcome that matters. Added, because the code now says them and a spec that omits them is the same class of gap: the coverage set and the admission set are different sets and must not be one value; the hint tier stages on the same terms; promotion is terminal per VERDICT and bounded by RATE, with the ordering and cooldown that make that true; a never-existing parent is deliberately not distinguished from an unreadable one, with the reason; and a promotion write claims its staged row before writing the coin. The vacuous clause is now stated rather than implied: on the shipped node the frame-path promotion does not run at all, because its `CatAttributor` is constructed only under `cfg(test)`. Recorded as current behaviour with a pointer to #382, so no reader mistakes the clause for a capability. Refs dig-node#394, dig-node#382. Co-Authored-By: Claude --- SPEC.md | 89 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/SPEC.md b/SPEC.md index a29c2342..f37a4a44 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5405,44 +5405,85 @@ The sync loop runs this attribution as a post-apply step (`sync::CatAttributor`, newly-synced candidate coins, so a synced CAT coin — stored initially with `asset_id: None` — gains its TAIL and surfaces in `get_cats` (this is how `$DIG` resolves from the node). -18.11a. **CAT discovery is not CAT authenticity — staged admission (#380).** A `CoinState` carries a +18.11a. **CAT discovery is not CAT authenticity — staged admission (#380, #394).** A `CoinState` carries a parent, a puzzle hash and an amount, and **no hint**, so a wallet cannot recognise its own CAT coins from the frame that delivers them. The node therefore DERIVES, for each address it follows and each asset id it -knows, the outer hash `cat_puzzle_hash(owner_p2, asset_id)`, and subscribes those hashes alongside the +knows, the outer hash `cat_puzzle_hash(owner_p2, asset_id)`, and REQUESTS those hashes alongside the addresses. Without this the peer never sends the wallet its CAT coins at all: a CAT coin does not sit at its owner's address. -Subscribing a derived hash is **discovery** and MUST NOT be read as ownership of that asset. The +Requesting a derived hash is **discovery** and MUST NOT be read as ownership of that asset. The derivation is injective — it commits to the CAT2 module, the asset id and the inner p2 together — but it establishes only *"if this coin is ever spent, only this wallet can spend it, as this asset"*. It does NOT establish that the coin is a unit of that asset, because `CREATE_COIN` is unconstrained in its destination: anybody may place a coin at any puzzle hash, at a cost of one mojo per displayed base unit, -knowing nothing but the victim's public address. - -The two states are therefore held in **different tables**, and this separation is normative: - -- A coin arriving at a derived hash MUST be written to `cat_admission_pending`, never to `coins`. The +knowing nothing but the victim's public address. The same is true of a coin's HINT, which is likewise +chosen freely by whoever creates it. + +**The coverage set and the admission set are different sets, and MUST NOT be the same value.** + +- The **coverage** set is what the node asks a peer about: the addresses UNION the derived hashes. It + determines what the wallet can SEE. +- The **admission** set is the wallet's own p2 addresses, and nothing else. It determines what may be + written to `coins`. +- The union that produces the coverage set MUST be performed at the point that issues the request, and a + derived hash MUST NOT be admissible even if one is supplied to that point as an address. One vector + serving both roles is the defect this clause exists to exclude: it admitted every coin at a derived + hash and typed it `asset_id: None`, which means XCH. +- The recorded coverage a replica claims (§18.13's authoritative-read routing) is the ADDRESS set, since + that is the set its readers ask about. + +The two states are held in **different tables**, and this separation is normative: + +- A coin arriving at a derived hash, on ANY tier, MUST be written to `cat_admission_pending` and never to + `coins`. This binds the peer catch-up, the peer frame path, and the point-read tier alike. The one exception is a coin already PRESENT in `coins` — one that has cleared promotion — whose later states, including its spend, MUST update `coins` as any other coin's would. -- A coin MUST enter `coins` only when a read of its parent spend reconstructs it as a CAT whose asset id - AND inner p2 hash both equal the ones the derivation predicted. It is then written **fully attributed**, - from the reconstruction's values and never from the derivation's. -- `coins` MUST retain exactly the semantics it has without this feature. No reader of `coins` — the - balance, the spend-input selector, `get_cats`, the arrivals notifier — may be required to apply a - predicate to remain correct. -- The address set and the derived set MUST remain distinct. Only the address set is presented to the - arrivals notifier (§18.13), which reports payments to a user. +- A coin discovered by HINT rather than at a derived hash MUST be staged on the same terms. A hint is a + claim its creator chose; admitting a hinted coin as an untyped row is the same fabricated-balance + primitive reached without a peer. +- A coin sitting at one of the wallet's OWN p2 hashes is an ordinary XCH coin and is admitted directly. + This is the only direct admission. +- A coin MUST enter `coins` only when a read of its parent spend reconstructs it as a CAT, and: + - the reconstruction's coin id equals the staged row's; AND + - where the coin was discovered at a derived hash, the reconstructed asset id and inner p2 hash both + equal the ones the derivation predicted; OR + - where the coin was discovered by hint and nothing was predicted, the reconstructed inner p2 hash is + one the wallet controls. + + It is then written **fully attributed**, from the reconstruction's values and never from the + derivation's or the hint's. +- `coins` retains exactly the semantics it has without this feature. No reader of `coins` — the balance, + the spend-input selector, `get_cats`, the arrivals notifier — is required to apply a predicate to + remain correct. +- Only the address set is presented to the arrivals notifier (§18.13), which reports payments to a user. **Promotion** runs off the peer frame path, in the same out-of-band pass as §18.11 attribution: - The frame path performs **zero** chain reads. Routing is a membership test against locally derived hashes, and the staging write takes no `LineageSource`. -- Promotion performs at most one parent-spend read per staged coin and is **terminal**: a coin proven or - disproven is never read again. A pass is capped (`MAX_CAT_PROMOTIONS_PER_PASS`). +- A pass reads at most `MAX_CAT_PROMOTIONS_PER_PASS` parent spends. +- **Promotion is terminal per VERDICT, not per coin, and the read cost is bounded by RATE.** A coin that + is proven or disproven is never read again, because its staged row is deleted. A coin whose parent + cannot be read reaches no verdict and MUST remain staged, so it will be read again — which is why a + per-coin bound cannot be claimed. The queue is therefore served ordered by attempt count first and + arrival order second, and a row is eligible only if it has not been read within + `PROMOTION_RETRY_COOLDOWN`. Together these give: a row that never resolves can never hold the head of + the queue against one that has never been tried, and the total read rate is bounded by + `staged rows / cooldown` rather than by `cap` per pass. +- **A never-existing parent is NOT distinguished from an unreadable one, deliberately.** A source + answers identically for a spend it has never heard of and one it is merely behind on, so a terminal + refusal built on that answer would convert a brief outage into permanent erasure of a real coin. The + cost is bounded instead of the cause classified. - The three outcomes are distinct. **Proven** promotes. **Disproven** — a parent read that SUCCEEDED and - does not reconstruct the coin as that asset — deletes the staged row. **Unavailable** — a read that - could not be performed — leaves the row staged for retry, and MUST NOT delete it; treating an - unavailable answer as a disproof would let a peer erase real money by withholding parent spends. + does not reconstruct the coin as claimed — deletes the staged row. **Unavailable** — a read that could + not be performed, or that the source answered emptily — leaves the row staged for retry, and MUST NOT + delete it; treating an unavailable answer as a disproof would let a peer erase real money by + withholding parent spends. +- A promotion write MUST claim its staged row before writing the coin: the staging row is deleted first + and the write proceeds only if exactly one row was removed, all in one transaction. Promotion spans a + network round trip, and a reorg rollback inside that window must not be overwritten by a coin the + replica has already decided to forget. - A promotion failure MUST NOT propagate into the peer update loop. A chain read fails for reasons a peer can arrange, and an error reaching the update loop would end a live session. - `cat_admission_pending` MUST be bounded, evicting oldest-first. The bound MUST delay and MUST NOT @@ -5450,6 +5491,12 @@ The two states are therefore held in **different tables**, and this separation i - Staged rows are rolled back with the coins they describe. A reorg deletes every staged row created above the fork and clears any spend recorded above it. +**Where promotion runs today.** On the shipped node the promotion pass runs on the POINT-READ tier +(`refresh_tracked_coins`) only. The peer frame path's promotion is reachable but its `CatAttributor` is +constructed under `cfg(test)` alone, so it does not run in production; wiring it is #382. A staged coin +therefore becomes spendable on a point-read refresh rather than on the frame that delivered it. This is a +statement of current behaviour, not a licence: nothing above is relaxed by it. + **The stated failure mode is INCOMPLETENESS.** A real coin that cannot yet be proven is *absent* — not counted as its asset, and in particular not counted as XCH, which `asset_id IS NULL` means and which feeds coin selection. A wallet may under-report; it must never report a figure that is wrong. From 46fa77b969bba57b3ff4ec6a5536cde307fb5246 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 03:55:28 -0700 Subject: [PATCH 22/28] style(wallet): cargo fmt Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 12 ++++++++---- crates/dig-wallet/src/sage/rpc.rs | 13 ++++++------- crates/dig-wallet/src/sage/singleton.rs | 5 ++++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 026990d0..a8324f3f 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -242,8 +242,12 @@ where // away would make a promoted coin's history poorer than the row it came from. created_timestamp: row.created_timestamp, spent_timestamp: row.spent_timestamp, - derived_asset_id: predicted.map(|o| hex::encode(o.asset_id)).unwrap_or_default(), - derived_owner_p2: predicted.map(|o| hex::encode(o.owner_p2)).unwrap_or_default(), + derived_asset_id: predicted + .map(|o| hex::encode(o.asset_id)) + .unwrap_or_default(), + derived_owner_p2: predicted + .map(|o| hex::encode(o.owner_p2)) + .unwrap_or_default(), }); } (believed, staged) @@ -438,8 +442,8 @@ async fn promote_one( // own p2 hashes, that IS "this coin is a unit of asset A and only this wallet can spend it" — // which is the entire claim being made. The predicted case additionally checks that the // derivation was not lying about which asset it expected. - let asset_agrees = row.derived_asset_id.is_empty() - || asset_id.eq_ignore_ascii_case(&row.derived_asset_id); + let asset_agrees = + row.derived_asset_id.is_empty() || asset_id.eq_ignore_ascii_case(&row.derived_asset_id); let owner_agrees = if row.derived_owner_p2.is_empty() { // Unpredicted: the reconstruction's own hint must name an address this wallet controls. // Without this the hint would be attacker-controlled all the way to `coins`, which is the diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 388a7d7c..559688ca 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3130,12 +3130,10 @@ impl WalletBackend { .collect::>(), ) .await?; - let (rows, staged) = super::cat_discovery::route_point_read_rows( - &fetched_rows, - &owned, - &derived, - |id| promoted.contains(id), - ); + let (rows, staged) = + super::cat_discovery::route_point_read_rows(&fetched_rows, &owned, &derived, |id| { + promoted.contains(id) + }); let n = rows.len(); if n > 0 { self.db.upsert_coins(&rows).await?; @@ -8660,7 +8658,8 @@ mod tests { // What the attacker places: a coin at the derived $DIG hash for this victim, hinted to // them, for a number they will read as their balance. It costs one mojo per base unit and // needs only `ph`, which is public. - let derived_hash = digstore_chain::cat::cat_puzzle_hash(ph, digstore_chain::dig::DIG_ASSET_ID); + let derived_hash = + digstore_chain::cat::cat_puzzle_hash(ph, digstore_chain::dig::DIG_ASSET_ID); let fallback = TwoTierFallback { at_our_hash: FallbackCoin { coin_id: "aa".repeat(32), diff --git a/crates/dig-wallet/src/sage/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 537e500a..952b9e96 100644 --- a/crates/dig-wallet/src/sage/singleton.rs +++ b/crates/dig-wallet/src/sage/singleton.rs @@ -391,7 +391,10 @@ pub async fn reconstruct_coins( // the failure honestly, propagating it here would let a single timeout abandon attribution // for every remaining coin in the pass. Skip and log, exactly as before — the difference is // that the cause is now visible rather than indistinguishable from a chain fact. - let parent = match lineage.parent_spend(&c.parent_coin_info, created as u32).await { + let parent = match lineage + .parent_spend(&c.parent_coin_info, created as u32) + .await + { Ok(Some(parent)) => parent, Ok(None) => continue, Err(e) => { From c27fabc787c442dee73f25409b12d6a174e577bb Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 07:45:22 -0700 Subject: [PATCH 23/28] fix(wallet): stop the point-read tier deleting the wallet's NFTs and DIDs An NFT or DID coin sits at a singleton puzzle hash, never an owned p2 hash, and is hinted to the owner -- so route_point_read_rows stages it. Promotion reconstructed it correctly as a singleton, which is not a Cat, and the let-else returned Disproven: the row was deleted terminally. Since the point-read tier is the only production path that reaches reconstruct_all, this silently emptied nfts/dids and re-paid the chain read for every singleton on every refresh. Disproven now means only 'the derivation was a lie'. A proven singleton gets its own outcome, Resolved, and is written to nfts/dids -- never to coins, where a missing asset id reads as XCH. Admission is not widened: the singleton must be owned by a p2 hash the wallet controls, the same standard an unpredicted CAT is held to, checked against the reconstruction rather than the attacker-controlled hint. Proven end to end through production routing (route_point_read_rows -> promote_staged_cats), not through a helper below the narrowing -- which is why nothing caught this: singleton.rs's own test injects with upsert_coin one layer down. Also covers both previously-uncovered early exits in promote_staged_cats. Refs #380, #394 Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 328 ++++++++++++++++++-- crates/dig-wallet/src/sage/db.rs | 69 +++- crates/dig-wallet/src/sage/rpc.rs | 15 +- crates/dig-wallet/src/sage/singleton.rs | 110 +++++-- 4 files changed, 463 insertions(+), 59 deletions(-) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index a8324f3f..8eed34cf 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -44,7 +44,7 @@ use std::collections::{HashMap, HashSet}; use chia_protocol::{Bytes32, Coin, CoinState}; -use super::db::{CoinRow, StagedCatRow, WalletDb}; +use super::db::{CoinRow, PromotedSingleton, StagedCatRow, WalletDb}; use super::singleton::{coin_from_row, LineageSource, Reconstructed}; use super::{singleton, Result}; @@ -198,10 +198,12 @@ where /// already; this routes the third tier through the SAME staging table, so there is one admission /// point and it is the one that demands a lineage proof. /// -/// A hinted coin that is not at a derived hash for a known asset is DROPPED rather than believed. -/// That is a deliberate narrowing: such a coin cannot be selected as an XCH input (its puzzle hash -/// is not ours) and carries no asset id, so admitting it only ever produced a wrong XCH figure. -/// Absence is this design's accepted failure direction; a wrong figure is not. +/// A hinted coin that is not at a derived hash for a known asset is STAGED with the empty +/// sentinel, not believed and not discarded: nothing was predicted about it, so there is nothing to +/// match against, and its parent spend alone decides what it is. What the narrowing removes is +/// BELIEF before the proof — such a coin used to enter `coins` with no asset id, where it read as +/// XCH and produced a wrong figure. Absence until proven is this design's accepted failure +/// direction; a wrong figure is not. pub fn route_point_read_rows( rows: &[CoinRow], owned_puzzle_hashes: &HashSet, @@ -265,6 +267,8 @@ fn hex_to_bytes32(s: &str) -> Option { pub struct PromoteStats { /// Coins whose parent spend proved them units of the derived asset, and which are now in `coins`. pub promoted: u32, + /// Coins whose parent spend proved them owned NFT/DID singletons, now in `nfts`/`dids`. + pub resolved: u32, /// Coins a SUCCESSFUL parent read proved are not units of the derived asset — deleted. pub refused: u32, /// Coins left staged because their parent spend could not be read this pass. @@ -273,14 +277,22 @@ pub struct PromoteStats { /// Promote every staged coin a parent spend proves, and refuse every one it disproves. /// -/// # The three outcomes, and why the third is not the second +/// # The four outcomes, and why they are four /// /// - **Proven** — the parent spend reconstructs the coin as a CAT, and both the asset id and the /// inner p2 hash it reconstructs to equal the ones the derivation predicted. The coin moves into /// `coins` attributed from the RECONSTRUCTION, never from the derivation. -/// - **Disproven** — the parent spend was read successfully and does not reconstruct this coin as a -/// CAT of that asset. The staged row is deleted, terminally: this is what makes an attacker's -/// read amplification ~1x rather than perpetual. +/// - **Resolved** — the parent spend reconstructs the coin as an NFT or DID singleton this wallet's +/// p2 hash owns. Equally proven, by the same machinery, so it is admitted — to `nfts`/`dids`, +/// because a singleton in `coins` would read as XCH. Kept apart from *Disproven* deliberately: +/// one says the derivation was a lie, the other says it was true about something this function +/// does not itself handle, and collapsing them deletes real assets (dig-node#394). +/// - **Disproven** — the parent spend was read successfully and refutes the claim: the coin is not +/// a CAT of that asset, or is a singleton belonging to another p2 hash, or reconstructs to +/// nothing at all. The staged row is deleted, terminally: this is what makes an attacker's read +/// amplification ~1x rather than perpetual. It is also the outcome for a row already spent on +/// chain, which is dropped without a read at all, and for a row whose coin id does not bind its +/// own fields. /// - **Unavailable** — the parent spend could not be read. The row stays staged, unmarked, and is /// retried. Deleting here would let a peer that simply withholds parent spends erase real money; /// leaving the row staged means the coin is *absent*, which is the acceptable direction. @@ -352,6 +364,9 @@ pub async fn promote_staged_cats( }; match promote_one(db, lineage_prefix(), &row, &parent, owned_p2).await? { Promotion::Promoted => stats.promoted += 1, + // The staging row was consumed inside `promote_singleton_admission`, in the same + // transaction that wrote the singleton. Nothing left to discard. + Promotion::Resolved => stats.resolved += 1, Promotion::Disproven => { db.discard_cat_admission(&row.coin_id).await?; stats.refused += 1; @@ -381,20 +396,59 @@ fn lineage_prefix() -> &'static str { /// What deciding one coin's promotion concluded. /// -/// Three outcomes rather than a bool, because "not promoted" covers two situations that must not -/// be treated alike: a coin the parent spend DISPROVES is finished with and its staging row is -/// deleted, while a coin whose row a reorg removed mid-read reached no verdict at all and must not -/// be recorded as refused. +/// Four outcomes rather than a bool, because "not promoted into `coins`" covers three situations +/// that must not be treated alike: a coin the parent spend DISPROVES is finished with and its +/// staging row is deleted; a coin the parent spend PROVES to be a singleton is equally finished +/// with but was written to `nfts`/`dids` instead; and a coin whose row a reorg removed mid-read +/// reached no verdict at all and must not be recorded as either. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Promotion { /// The parent spend proves the coin, and it is now in `coins`. Promoted, + /// The parent spend proves the coin is an owned NFT or DID, now in `nfts`/`dids`. Terminal, + /// and a SUCCESS — distinct from `Disproven`, which is a refutation. + Resolved, /// The parent spend was read and does not support the claim. Terminal. Disproven, /// The staged row was gone by the time the write ran — a reorg rollback inside the read. Vanished, } +/// Admit a proven NFT/DID singleton to its own table, if this wallet owns it. +/// +/// The ownership test is the SAME one an unpredicted CAT gets: the reconstruction's own inner p2 +/// hash must be one the wallet controls. Without it a hint would be enough to make the wallet +/// display a stranger's NFT as its own — the hint is attacker-controlled, and the reconstruction is +/// the only thing that says who the singleton actually belongs to. +/// +/// A singleton owned by somebody else is `Disproven`: the claim that staged it was "this coin is +/// for me", and the parent spend refutes exactly that claim. +async fn promote_singleton( + db: &WalletDb, + row: &StagedCatRow, + reconstructed_owner_p2: &str, + owned_p2: &HashSet, + singleton: PromotedSingleton<'_>, +) -> Result { + if !owned_p2.contains(&reconstructed_owner_p2.to_ascii_lowercase()) { + tracing::warn!( + coin_id = %row.coin_id, + "singleton promotion: the parent spend proves the coin is owned by another p2 hash; refusing" + ); + return Ok(Promotion::Disproven); + } + Ok( + if db + .promote_singleton_admission(&row.coin_id, &singleton) + .await? + { + Promotion::Resolved + } else { + Promotion::Vanished + }, + ) +} + /// Decide and apply one coin's promotion. async fn promote_one( db: &WalletDb, @@ -418,14 +472,33 @@ async fn promote_one( } let reconstructed = singleton::reconstruct(prefix, row.created_height.map(|h| h as u32), parent, child)?; - let Reconstructed::Cat { - coin_id, - asset_id, - hint, - } = reconstructed - else { - // The parent read succeeded and this coin is not a CAT child of it at all. Disproven. - return Ok(Promotion::Disproven); + let (coin_id, asset_id, hint) = match reconstructed { + Reconstructed::Cat { + coin_id, + asset_id, + hint, + } => (coin_id, asset_id, hint), + // PROVEN, BUT NOT A CAT (dig-node#394). An NFT or DID singleton reached the same proof by + // the same machinery — the parent spend reconstructs THIS coin id as that singleton — so it + // is admissible by the same standard; it simply belongs in a different table. + // + // Refusing it here would conflate two verdicts that must never share an outcome: "the + // derivation was a lie" is a security finding, while "the derivation was true and about + // something this function does not handle" is a routing gap. Treating the second as the + // first deleted the row terminally, and since the point-read tier is the only production + // path that reaches `reconstruct_all`, it silently emptied the wallet's NFTs and DIDs and + // re-paid the chain read for each of them on every refresh. + Reconstructed::Nft { row: nft, owner_p2 } => { + return promote_singleton(db, row, &owner_p2, owned_p2, PromotedSingleton::Nft(&nft)) + .await; + } + Reconstructed::Did { row: did, owner_p2 } => { + return promote_singleton(db, row, &owner_p2, owned_p2, PromotedSingleton::Did(&did)) + .await; + } + // A plain XCH coin, or a shape no driver recognises. The read succeeded and produced no + // claim this coin is anything the wallet may hold, so the hint that staged it is refuted. + Reconstructed::Unknown => return Ok(Promotion::Disproven), }; // The reconstruction must agree on BOTH halves — which coin, and whose. Checking only the // asset id would admit a real CAT of the right asset owned by somebody else; checking only the @@ -1237,4 +1310,217 @@ mod tests { assert_eq!(held.len(), 1, "only the row above the fork is unmade"); assert_eq!(held[0].coin_id, hex::encode(below.coin_id())); } + + /// A `CoinRow` as the point-read tier materialises one from a coin record. + fn coin_row_of(c: Coin, height: i64) -> CoinRow { + CoinRow { + coin_id: hex::encode(c.coin_id()), + parent_coin_info: hex::encode(c.parent_coin_info), + puzzle_hash: hex::encode(c.puzzle_hash), + amount: c.amount.to_string(), + created_height: Some(height), + spent_height: None, + asset_id: None, + hint: None, + created_timestamp: None, + spent_timestamp: None, + } + } + + /// The staged row the HINT path builds for a real CAT: both derived fields empty, because + /// nothing was predicted about it. + fn staged_row_of(f: &CatFixture) -> StagedCatRow { + StagedCatRow { + coin_id: hex::encode(f.child.coin_id()), + parent_coin_info: hex::encode(f.child.parent_coin_info), + puzzle_hash: hex::encode(f.child.puzzle_hash), + amount: f.child.amount.to_string(), + created_height: Some(10), + spent_height: None, + created_timestamp: None, + spent_timestamp: None, + derived_asset_id: String::new(), + derived_owner_p2: String::new(), + } + } + + /// THE POINT-READ TIER MUST NOT DELETE THE WALLET'S NFTs AND DIDs (dig-node#394). + /// + /// An NFT or DID coin sits at a SINGLETON puzzle hash — never one of the wallet's own p2 + /// hashes — and is hinted to the owner, so the point-read tier stages it exactly as it stages + /// an unpredicted CAT. Promotion then reconstructs it correctly as a singleton, which is not a + /// CAT; an outcome set with no place for that verdict refused it, and the refusal is TERMINAL. + /// Since this tier is the only production path that reaches `reconstruct_all`, that silently + /// emptied `nfts`/`dids` and re-paid the chain read for every singleton on every refresh. + /// + /// FIXTURE DESIGN — production routing, and nothing beneath it. The rows go through + /// [`route_point_read_rows`] and [`promote_staged_cats`], the two functions the point-read tier + /// actually calls, because the defect lives in the seam BETWEEN them: a test that injected with + /// `db.upsert_coin` (as `singleton::tests` does) sits one layer below the narrowing and stays + /// green whether or not the narrowing eats the coin — which is precisely why nothing caught + /// this. Both singleton kinds are present rather than one, because NFT and DID reconstruct + /// through different driver calls and a fix handling only one would still pass with either + /// alone. + #[tokio::test] + async fn an_owned_nft_and_did_survive_the_point_read_tier() { + let m = crate::sage::singleton::tests::mint_did_and_nft(); + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(m.nft_child.parent_coin_info), m.nft_parent); + lineage + .by_parent + .insert(hex::encode(m.did_child.parent_coin_info), m.did_parent); + + let rows = vec![coin_row_of(m.nft_child, 100), coin_row_of(m.did_child, 100)]; + // The wallet's own p2 hashes. The singletons are NOT at them — that is the whole reason + // routing stages these rows rather than believing them. + let ours: HashSet = [hex::encode(m.owner_p2)].into_iter().collect(); + let (believed, staged) = + route_point_read_rows(&rows, &ours, &DerivedCats::default(), |_| false); + assert!( + believed.is_empty(), + "a singleton coin is never at an owned p2 hash, so nothing may be believed outright" + ); + assert_eq!(staged.len(), 2, "both singletons are staged for a proof"); + + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(&staged).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &ours).await.unwrap(); + + assert_eq!( + (stats.resolved, stats.refused, stats.deferred), + (2, 0, 0), + "both singletons are PROVEN, not refused: {stats:?}" + ); + let nfts = db.all_nfts().await.unwrap(); + let dids = db.all_dids().await.unwrap(); + assert_eq!( + nfts.len(), + 1, + "the NFT reaches the table the wallet reads NFTs from" + ); + assert_eq!(dids.len(), 1, "and the DID reaches the DID table"); + assert_eq!( + nfts[0].launcher_id, + hex::encode(m.nft_launcher), + "and it is the NFT that was minted, identified by its launcher" + ); + assert_eq!(dids[0].launcher_id, hex::encode(m.did_launcher)); + // THE PLACEMENT HALF. A singleton carries no asset id, so a row for it in `coins` would + // read as XCH and inflate the spendable balance. Admitting it to the right table is the + // fix; admitting it to `coins` would also satisfy "not deleted", and would reintroduce + // the fabricated-balance defect this whole PR closes. + assert!( + db.all_coins().await.unwrap().is_empty(), + "a singleton must never enter `coins`, where a missing asset id means XCH" + ); + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + 0, + "and staging is cleared, so the chain read is not re-paid on every refresh" + ); + } + + /// The ownership half: the SAME NFT, the SAME proof, and the one varied thing is whether this + /// wallet controls the p2 hash the parent spend proves owns it. + /// + /// Without this check a hint — which anybody may write — would be enough to make the wallet + /// display a stranger's NFT as its own. The reconstruction is the only thing that says whose + /// the singleton is, and it is the same standard an unpredicted CAT is held to. + #[tokio::test] + async fn a_singleton_owned_by_a_stranger_is_refused() { + let m = crate::sage::singleton::tests::mint_did_and_nft(); + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(m.nft_child.parent_coin_info), m.nft_parent); + + let rows = vec![coin_row_of(m.nft_child, 100)]; + let stranger: HashSet = [hex::encode(Bytes32::new([0x77; 32]))] + .into_iter() + .collect(); + let (_believed, staged) = + route_point_read_rows(&rows, &stranger, &DerivedCats::default(), |_| false); + + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(&staged).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &stranger).await.unwrap(); + + assert_eq!( + (stats.resolved, stats.refused), + (0, 1), + "a singleton the parent spend proves belongs to somebody else is REFUSED: {stats:?}" + ); + assert!( + db.all_nfts().await.unwrap().is_empty(), + "and never reaches the NFT table" + ); + } + + /// A staged row already SPENT on chain is dropped without a parent read at all. + /// + /// One of two early exits in [`promote_staged_cats`] that had no coverage: neutering either + /// left the whole suite green, so nothing pinned the behaviour that bounds the read cost of a + /// spent coin (and stops it sitting in staging for ever awaiting a promotion that could not + /// matter). + #[tokio::test] + async fn a_spent_staged_row_is_dropped_without_a_parent_read() { + let f = real_cat(); + let lineage = CountingLineage::default(); // deliberately EMPTY: no read may occur + let mut spent = staged_row_of(&f); + spent.spent_height = Some(11); + + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(std::slice::from_ref(&spent)) + .await + .unwrap(); + let stats = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); + + assert_eq!( + (stats.refused, stats.promoted, stats.deferred), + (1, 0, 0), + "a spent coin is dropped, not promoted and not left staged: {stats:?}" + ); + assert_eq!(lineage.reads(), 0, "and costs no chain read at all"); + assert_eq!(db.staged_cat_admission_count().await.unwrap(), 0); + } + + /// A staged row with no created height is UNCONFIRMED: there is no height to read a parent + /// spend at, so it is deferred and METERED — it must not hold the queue head for ever. + /// + /// The second uncovered early exit. The metering is the load-bearing half: without + /// `record_promotion_attempt` an unconfirmable row is re-read every pass, which is exactly the + /// amplification the cooldown exists to bound. + #[tokio::test] + async fn an_unconfirmed_staged_row_is_deferred_and_metered() { + let f = real_cat(); + let lineage = CountingLineage::default(); + let mut unconfirmed = staged_row_of(&f); + unconfirmed.created_height = None; + + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(std::slice::from_ref(&unconfirmed)) + .await + .unwrap(); + let first = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); + assert_eq!( + (first.deferred, first.refused, first.promoted), + (1, 0, 0), + "unconfirmed is deferred, never refused: {first:?}" + ); + assert_eq!( + db.staged_cat_admission_count().await.unwrap(), + 1, + "and the row stays staged, because it may confirm later" + ); + + // METERED: the attempt was recorded, so the cooldown now holds the row back. + let second = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); + assert_eq!( + (second.deferred, second.refused, second.promoted), + (0, 0, 0), + "a second pass inside the cooldown must not touch it again: {second:?}" + ); + } } diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index 6d264eac..3410102f 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -354,6 +354,18 @@ pub const CAT_ADMISSION_PENDING_MAX_ROWS: i64 = 20_000; /// merely FOUND at a hash it derived, together with the derivation that found it — a hypothesis. /// Sharing one type between the two would make the difference a field rather than a table, which /// is exactly the shape this design rejects. +/// Which singleton table a proven staged coin belongs in. +/// +/// Borrowed rather than owned because the caller already holds the reconstructed row and the write +/// is the last thing done with it. +#[derive(Debug, Clone, Copy)] +pub enum PromotedSingleton<'a> { + /// Write the coin to `nfts`. + Nft(&'a NftDbRow), + /// Write the coin to `dids`. + Did(&'a DidDbRow), +} + #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub struct StagedCatRow { /// The coin id (hex, 64 chars). @@ -1870,6 +1882,42 @@ impl WalletDb { Ok(true) } + /// Promote a staged coin the parent spend proved is an owned NFT or DID singleton. + /// + /// The twin of [`WalletDb::promote_cat_admission`], and it claims the staging row FIRST for the + /// same reason: the DELETE's `rows_affected` is what decides whether this promotion still owns + /// the coin, so a reorg rollback that lands mid-read loses the race rather than being + /// overwritten by it. `false` means the row was already gone — nothing was written. + /// + /// # Why the coin does NOT enter `coins` + /// + /// A singleton sits at its own puzzle hash, not one of the wallet's p2 hashes, and carries no + /// asset id — so a row for it in `coins` would read as XCH and inflate the spendable balance by + /// its (odd, ~1 mojo) amount while being unselectable. `nfts`/`dids` are keyed by launcher id + /// and are the tables that describe it truthfully. + pub async fn promote_singleton_admission( + &self, + coin_id: &str, + singleton: &PromotedSingleton<'_>, + ) -> sqlx::Result { + let mut tx = self.pool.begin().await?; + let claimed = sqlx::query("DELETE FROM cat_admission_pending WHERE coin_id = ?") + .bind(Self::normalise_hex(coin_id)) + .execute(&mut *tx) + .await? + .rows_affected(); + if claimed != 1 { + tx.rollback().await?; + return Ok(false); + } + match singleton { + PromotedSingleton::Nft(n) => Self::upsert_nft_on(&mut *tx, n).await?, + PromotedSingleton::Did(d) => Self::upsert_did_on(&mut *tx, d).await?, + } + tx.commit().await?; + Ok(true) + } + /// Drop a staged coin that a SUCCESSFUL parent read proved is not a unit of the derived asset. /// /// Terminal, and that is what bounds the read cost: a refused coin is never read again, so an @@ -2956,6 +3004,15 @@ impl WalletDb { /// Insert or update a reconstructed NFT (keyed by launcher id; a later coin overwrites /// the mutable fields — the current coin, owner, and wire record). pub async fn upsert_nft(&self, n: &NftDbRow) -> sqlx::Result<()> { + Self::upsert_nft_on(&self.pool, n).await + } + + /// The NFT upsert, against any executor, so a promotion can run it inside the SAME + /// transaction that claims the staging row (see [`WalletDb::promote_singleton_admission`]). + async fn upsert_nft_on<'e, E>(exec: E, n: &NftDbRow) -> sqlx::Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { sqlx::query( "INSERT INTO nfts (launcher_id, coin_id, collection_id, minter_did, owner_did, name, @@ -2979,7 +3036,7 @@ impl WalletDb { .bind(n.visible) .bind(n.created_height) .bind(&n.record_json) - .execute(&self.pool) + .execute(exec) .await?; Ok(()) } @@ -3041,6 +3098,14 @@ impl WalletDb { /// Insert or update a reconstructed DID (keyed by launcher id). pub async fn upsert_did(&self, d: &DidDbRow) -> sqlx::Result<()> { + Self::upsert_did_on(&self.pool, d).await + } + + /// The DID upsert, against any executor (the twin of [`WalletDb::upsert_nft_on`]). + async fn upsert_did_on<'e, E>(exec: E, d: &DidDbRow) -> sqlx::Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { sqlx::query( "INSERT INTO dids (launcher_id, coin_id, name, visible, created_height, record_json) VALUES (?, ?, ?, ?, ?, ?) @@ -3056,7 +3121,7 @@ impl WalletDb { .bind(d.visible) .bind(d.created_height) .bind(&d.record_json) - .execute(&self.pool) + .execute(exec) .await?; Ok(()) } diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 559688ca..24acf6a9 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3159,12 +3159,15 @@ impl WalletBackend { // Still best-effort, and deliberately: a chain-read failure must not turn a successful // XCH refresh into a hard error. match super::cat_discovery::promote_staged_cats(&self.db, lineage, &owned).await { - Ok(stats) if stats.promoted > 0 || stats.refused > 0 => tracing::info!( - promoted = stats.promoted, - refused = stats.refused, - deferred = stats.deferred, - "wallet sync: CAT admission promotion pass (point-read tier)" - ), + Ok(stats) if stats.promoted > 0 || stats.resolved > 0 || stats.refused > 0 => { + tracing::info!( + promoted = stats.promoted, + resolved = stats.resolved, + refused = stats.refused, + deferred = stats.deferred, + "wallet sync: CAT admission promotion pass (point-read tier)" + ) + } Ok(stats) if stats.deferred > 0 => tracing::debug!( deferred = stats.deferred, "wallet sync: staged CAT coins are awaiting a readable parent spend" diff --git a/crates/dig-wallet/src/sage/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 952b9e96..63379147 100644 --- a/crates/dig-wallet/src/sage/singleton.rs +++ b/crates/dig-wallet/src/sage/singleton.rs @@ -43,9 +43,23 @@ pub struct ParentSpend { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Reconstructed { /// The coin is an NFT singleton. - Nft(Box), - /// The coin is a DID singleton. - Did(Box), + /// + /// `owner_p2` is the inner p2 puzzle hash the singleton is currently owned by, carried + /// alongside the row for the same reason [`Reconstructed::Cat`] carries `hint`: the row alone + /// says WHAT the coin is, and an admission decision additionally needs to know WHOSE it is. + Nft { + /// The NFT as it will be stored. + row: Box, + /// The inner p2 puzzle hash (hex) that owns the singleton. + owner_p2: String, + }, + /// The coin is a DID singleton (the DID twin of [`Reconstructed::Nft`]). + Did { + /// The DID as it will be stored. + row: Box, + /// The inner p2 puzzle hash (hex) that owns the singleton. + owner_p2: String, + }, /// The coin is a CAT — attribute it to this asset id (+ inner p2 hint). Cat { /// The child coin id (hex). @@ -132,14 +146,20 @@ pub fn reconstruct_parsed( // NFT: parse_child computes the single child singleton coin itself. if let Ok(Some(nft)) = Nft::parse_child(ctx, parent_coin, parent_puzzle, parent_solution) { if nft.coin.coin_id() == child_id { - return Reconstructed::Nft(Box::new(nft_row(ctx, prefix, created_height, &nft))); + return Reconstructed::Nft { + owner_p2: hexb(nft.info.p2_puzzle_hash), + row: Box::new(nft_row(ctx, prefix, created_height, &nft)), + }; } } // DID: parse_child validates the given child coin. if let Ok(Some(did)) = Did::parse_child(ctx, parent_coin, parent_puzzle, parent_solution, child) { - return Reconstructed::Did(Box::new(did_row(prefix, created_height, &did))); + return Reconstructed::Did { + owner_p2: hexb(did.info.p2_puzzle_hash), + row: Box::new(did_row(prefix, created_height, &did)), + }; } // CAT: parse_children returns every child; match ours by coin id. @@ -408,11 +428,11 @@ pub async fn reconstruct_coins( }; let child = coin_from_row(c)?; match reconstruct(prefix, Some(created as u32), &parent, child)? { - Reconstructed::Nft(row) => { + Reconstructed::Nft { row, .. } => { db.upsert_nft(&row).await?; stats.nfts += 1; } - Reconstructed::Did(row) => { + Reconstructed::Did { row, .. } => { db.upsert_did(&row).await?; stats.dids += 1; } @@ -443,7 +463,7 @@ pub async fn reconstruct_all( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use chia_sdk_test::Simulator; use chia_traits::Streamable; @@ -506,16 +526,11 @@ mod tests { /// Mint a DID + an NFT on the simulator, transfer both to self, and return the parent /// spends + the child coins a syncing wallet would observe. - #[allow(clippy::type_complexity)] - fn mint_did_and_nft() -> ( - Simulator, - ParentSpend, - Coin, - ParentSpend, - Coin, - Bytes32, - Bytes32, - ) { + /// + /// Shared with `cat_discovery`'s tests rather than re-minted there: a second copy of this + /// fixture would be a second definition of what an owned singleton looks like, and the two + /// would drift. + pub(crate) fn mint_did_and_nft() -> MintedSingletons { let mut sim = Simulator::new(); let ctx = &mut SpendContext::new(); let alice = sim.bls(2); @@ -566,25 +581,53 @@ mod tests { let did_parent = parent_spend_from_sim(&sim, did.coin); let nft_parent = parent_spend_from_sim(&sim, nft.coin); - ( - sim, + MintedSingletons { + _sim: sim, did_parent, - child_did.coin, + did_child: child_did.coin, nft_parent, - child_nft.coin, - did.info.launcher_id, - nft.info.launcher_id, - ) + nft_child: child_nft.coin, + did_launcher: did.info.launcher_id, + nft_launcher: nft.info.launcher_id, + owner_p2: alice.puzzle_hash, + } + } + + /// What [`mint_did_and_nft`] hands back: the two child singleton coins a wallet observes, + /// the parent spends that prove them, and the p2 hash that owns both. + pub(crate) struct MintedSingletons { + /// Held so the simulator's spend store outlives the parent spends taken from it. + pub(crate) _sim: Simulator, + pub(crate) did_parent: ParentSpend, + pub(crate) did_child: Coin, + pub(crate) nft_parent: ParentSpend, + pub(crate) nft_child: Coin, + pub(crate) did_launcher: Bytes32, + pub(crate) nft_launcher: Bytes32, + /// The p2 puzzle hash both singletons are owned by — the wallet's own hash in these tests. + pub(crate) owner_p2: Bytes32, } #[test] fn reconstruct_parses_nft_and_did_from_parent_spends() { - let (_sim, did_parent, did_child, nft_parent, nft_child, did_launcher, nft_launcher) = - mint_did_and_nft(); + let m = mint_did_and_nft(); + let (did_parent, did_child, nft_parent, nft_child, did_launcher, nft_launcher) = ( + m.did_parent, + m.did_child, + m.nft_parent, + m.nft_child, + m.did_launcher, + m.nft_launcher, + ); match reconstruct("xch", Some(42), &nft_parent, nft_child).unwrap() { - Reconstructed::Nft(row) => { + Reconstructed::Nft { row, owner_p2 } => { assert_eq!(row.launcher_id, hex::encode(nft_launcher)); + assert_eq!( + owner_p2, + hex::encode(m.owner_p2), + "the reconstruction names the p2 hash that owns the NFT" + ); let rec: NftRecord = serde_json::from_str(&row.record_json).unwrap(); assert_eq!(rec.royalty_ten_thousandths, 300); assert_eq!(rec.data_uris, vec!["https://example.com/a.png".to_string()]); @@ -595,8 +638,13 @@ mod tests { } match reconstruct("xch", Some(7), &did_parent, did_child).unwrap() { - Reconstructed::Did(row) => { + Reconstructed::Did { row, owner_p2 } => { assert_eq!(row.launcher_id, hex::encode(did_launcher)); + assert_eq!( + owner_p2, + hex::encode(m.owner_p2), + "the reconstruction names the p2 hash that owns the DID" + ); let rec: DidRecord = serde_json::from_str(&row.record_json).unwrap(); assert!(rec.address.starts_with("xch1")); } @@ -653,7 +701,9 @@ mod tests { #[tokio::test] async fn reconstruct_coins_populates_db_and_get_reads() { - let (_sim, did_parent, did_child, nft_parent, nft_child, _dl, _nl) = mint_did_and_nft(); + let m = mint_did_and_nft(); + let (did_parent, did_child, nft_parent, nft_child) = + (m.did_parent, m.did_child, m.nft_parent, m.nft_child); let db = WalletDb::open_in_memory().await.unwrap(); // The wallet has synced the two child singleton coins (odd amount = 1). From be4e12c2506bbed03d3e22189a974868424b8a3f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 07:57:34 -0700 Subject: [PATCH 24/28] docs(spec): four promotion outcomes, and the four cases Disproven covers 18.11a listed three outcomes while the code had four deletion reasons, and now has a fourth outcome: a proven singleton is Resolved, not Disproven. Refs #394 Co-Authored-By: Claude --- SPEC.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index f37a4a44..d384e6e5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5475,11 +5475,25 @@ The two states are held in **different tables**, and this separation is normativ answers identically for a spend it has never heard of and one it is merely behind on, so a terminal refusal built on that answer would convert a brief outage into permanent erasure of a real coin. The cost is bounded instead of the cause classified. -- The three outcomes are distinct. **Proven** promotes. **Disproven** — a parent read that SUCCEEDED and - does not reconstruct the coin as claimed — deletes the staged row. **Unavailable** — a read that could - not be performed, or that the source answered emptily — leaves the row staged for retry, and MUST NOT - delete it; treating an unavailable answer as a disproof would let a peer erase real money by - withholding parent spends. +- The four outcomes are distinct. **Proven** promotes into `coins`. **Resolved** — the parent read + reconstructs the coin as an NFT or DID singleton owned by a p2 hash the wallet controls — writes it to + `nfts`/`dids` and deletes the staged row. **Disproven** — a parent read that SUCCEEDED and refutes the + claim — deletes the staged row. **Unavailable** — a read that could not be performed, or that the + source answered emptily — leaves the row staged for retry, and MUST NOT delete it; treating an + unavailable answer as a disproof would let a peer erase real money by withholding parent spends. +- **A proven non-CAT MUST NOT be refused.** *Resolved* and *Disproven* are separate outcomes because one + says the derivation was true about something the CAT path does not itself handle, and the other says + the derivation was a lie. Collapsing them makes a routing gap indistinguishable from a security verdict + and deletes real assets terminally — the point-read tier is the only production path that reaches + §18.11 reconstruction, so a wallet's NFTs and DIDs vanish and their chain reads are re-paid on every + refresh. A singleton MUST NOT be written to `coins`: it carries no asset id, where absence means XCH. +- **A resolved singleton MUST be owned.** Admission requires the inner p2 hash the RECONSTRUCTION names + to be one the wallet controls — the same test an unpredicted CAT is held to, and never the hint, which + anybody may write. +- **Disproven covers four cases**, all terminal deletions: a coin already spent on chain (dropped without + a parent read, since it can neither be counted nor selected); a staged row whose coin id does not bind + its own parent, puzzle hash and amount; a reconstruction that disagrees with the derivation, or whose + hint names an address the wallet does not control; and a singleton owned by another p2 hash. - A promotion write MUST claim its staged row before writing the coin: the staging row is deleted first and the write proceeds only if exactly one row was removed, all in one transaction. Promotion spans a network round trip, and a reorg rollback inside that window must not be overwritten by a coin the From 904f5068a6535b78fa3c9b946655a6135ed60321 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 08:06:57 -0700 Subject: [PATCH 25/28] test(wallet): report nfts/dids and point-read routing on the real replica The acceptance measurement asked for the singleton row counts, not only the $DIG figure: this round's defect predicts refused>0 and emptying nfts/dids, which the pre-existing balance-only harness could not observe. Refs #394 Co-Authored-By: Claude --- .../tests/real_wallet_cat_discovery.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs index 14414fc5..8b621217 100644 --- a/crates/dig-wallet/tests/real_wallet_cat_discovery.rs +++ b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs @@ -58,6 +58,33 @@ async fn report_the_real_wallets_dig_discovery_surface() { at_derived, 0, "before this change the replica holds NO row at the derived hash -- that is #380" ); + + // THE OBSERVABLE THIS ROUND PREDICTS (dig-node#394). The point-read tier stages every row that + // is not at one of the wallet's OWN p2 hashes, and until this round a staged row that proved to + // be an NFT or DID singleton was refused terminally. So the figures that matter to a real + // wallet are: how many of its rows the tier stages at all, and whether its NFT/DID tables + // survive a pass. `refused > 0` on a wallet holding singletons is the defect, visible here. + let nfts = db.all_nfts().await.expect("read nfts").len(); + let dids = db.all_dids().await.expect("read dids").len(); + println!("[REPLICA] nfts={nfts} dids={dids}"); + println!( + "[REPLICA] dig_balance={} xch_balance={}", + db.balance(Some(&hex::encode(asset))).await.unwrap(), + db.balance(None).await.unwrap() + ); + + let owned: HashSet = addresses.iter().cloned().collect(); + let (believed, staged) = + dig_wallet::sage::cat_discovery::route_point_read_rows(&coins, &owned, &derived, |_| false); + println!( + "[ROUTE] point-read tier: believed={} staged={}", + believed.len(), + staged.len() + ); + println!( + "[ROUTE] staged rows are the ones a promotion pass reads a parent spend for; before this \ + round any of them that proved to be an NFT or DID was deleted" + ); } fn hex_to_b32(h: &str) -> Bytes32 { From a92dcf9f51d0bcb307c4de7f2da93e4d5806df9c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 09:20:38 -0700 Subject: [PATCH 26/28] fix(wallet): bind a reconstructed DID to the coin it claims to be `Did::parse_child` reads the owner out of the parent spend's CREATE_COIN memo hint and stores it verbatim, so the ownership guard downstream tested an attacker-writable value. Recompute the singleton puzzle hash from the parsed info and require it to equal the real child coin's. Refs #380 Co-Authored-By: Claude --- crates/dig-wallet/src/sage/singleton.rs | 31 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/dig-wallet/src/sage/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 63379147..3581c77c 100644 --- a/crates/dig-wallet/src/sage/singleton.rs +++ b/crates/dig-wallet/src/sage/singleton.rs @@ -18,7 +18,7 @@ use std::collections::HashSet; use async_trait::async_trait; use chia_protocol::{Bytes32, Coin, Program}; use chia_puzzle_types::nft::NftMetadata; -use chia_wallet_sdk::driver::{Cat, Did, Nft, Puzzle, SpendContext}; +use chia_wallet_sdk::driver::{Cat, Did, Nft, Puzzle, SingletonInfo, SpendContext}; use chia_wallet_sdk::utils::Address; use clvmr::NodePtr; @@ -153,13 +153,32 @@ pub fn reconstruct_parsed( } } - // DID: parse_child validates the given child coin. + // DID: `parse_child` takes the child coin but does NOT bind it to what it parsed. It reads the + // owner out of the parent spend's CREATE_COIN memo *hint* and stores it verbatim + // (`DidInfo::p2_puzzle_hash = hint`), which anybody who can spend any DID may write to name any + // p2 hash they like. So the parse alone proves nothing about ownership, and the check the SDK's + // own construction path makes — `inner_puzzle_hash() == create_coin.puzzle_hash` — has no + // counterpart on the read path. + // + // Recomputing the singleton puzzle hash FROM the parsed info and requiring it to equal the real + // child coin's is what turns the hint back into a proof: `puzzle_hash()` is curried over + // `p2_puzzle_hash`, so a lie about the owner cannot reproduce the on-chain coin. An honest DID + // pays nothing — its child is built from that same hash, so the two are equal by construction. + // (The NFT arm above needs no equivalent: `nft.coin` is derived from `nft.info`, and the coin-id + // equality at that arm already commits to it.) if let Ok(Some(did)) = Did::parse_child(ctx, parent_coin, parent_puzzle, parent_solution, child) { - return Reconstructed::Did { - owner_p2: hexb(did.info.p2_puzzle_hash), - row: Box::new(did_row(prefix, created_height, &did)), - }; + let reconstructed_puzzle_hash: Bytes32 = did.info.puzzle_hash().into(); + if reconstructed_puzzle_hash == child.puzzle_hash { + return Reconstructed::Did { + owner_p2: hexb(did.info.p2_puzzle_hash), + row: Box::new(did_row(prefix, created_height, &did)), + }; + } + tracing::warn!( + coin_id = %hexb(child_id), + "DID reconstruction: the parent spend's owner hint does not reproduce the coin's puzzle hash; refusing" + ); } // CAT: parse_children returns every child; match ours by coin id. From 2ebc305d7e22c586171392be6d9a95d21cdb91fc Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 09:28:27 -0700 Subject: [PATCH 27/28] test(wallet): a DID hinted to us but locked to a stranger is refused Three probes for the binding: the pure core, the wider reconstruct_coins path that has no ownership test of its own, and production routing (route_point_read_rows -> promote_staged_cats) with an honest DID riding alongside the forged one as a control. Refs #380 Co-Authored-By: Claude --- crates/dig-wallet/src/sage/cat_discovery.rs | 71 +++++++++++ crates/dig-wallet/src/sage/singleton.rs | 124 ++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 8eed34cf..5443b1df 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -1458,6 +1458,77 @@ mod tests { ); } + /// A DID hinted to this wallet but LOCKED TO SOMEBODY ELSE is refused, end to end. + /// + /// `Did::parse_child` reads the owner out of the parent spend's `CREATE_COIN` memo hint and + /// stores it verbatim, so before the binding in [`crate::sage::singleton::reconstruct_parsed`] + /// the ownership guard tested an attacker-written value: Mallory spends her own DID keeping her + /// p2 hash, hints the victim, and the victim's wallet writes the row to `dids` as `Resolved` — + /// the SUCCESS outcome — rendering the victim's own address as owner of Mallory's launcher. + /// + /// FIXTURE DESIGN — an honest DID rides alongside the forged one, and both p2 hashes are in + /// `ours`. A guard that refuses every DID would close the hole and break the wallet, and with + /// only the forged row present that regression is indistinguishable from the fix. The entry + /// point is [`route_point_read_rows`] -> [`promote_staged_cats`], ABOVE the narrowing, because + /// a probe entering at `db.upsert_coin` would stay green whether or not routing reaches the + /// binding at all. + #[tokio::test] + async fn a_did_hinted_to_us_but_owned_by_a_stranger_is_refused() { + let forged = crate::sage::singleton::tests::mint_did_hinted_to_a_stranger(); + let honest = crate::sage::singleton::tests::mint_did_and_nft(); + let mut lineage = CountingLineage::default(); + lineage + .by_parent + .insert(hex::encode(forged.child.parent_coin_info), forged.parent); + lineage.by_parent.insert( + hex::encode(honest.did_child.parent_coin_info), + honest.did_parent, + ); + + // Both p2 hashes are the wallet's. The forged coin is hinted to `victim_p2`, which is why + // `coin_records_by_hints` returned it in the first place. + let ours: HashSet = [ + hex::encode(forged.victim_p2), + hex::encode(honest.owner_p2), + ] + .into_iter() + .collect(); + let rows = vec![ + coin_row_of(forged.child, 100), + coin_row_of(honest.did_child, 100), + ]; + let (believed, staged) = + route_point_read_rows(&rows, &ours, &DerivedCats::default(), |_| false); + assert!(believed.is_empty(), "singletons are never believed outright"); + assert_eq!(staged.len(), 2); + + let db = WalletDb::open_in_memory().await.unwrap(); + db.stage_cat_admissions(&staged).await.unwrap(); + let stats = promote_staged_cats(&db, &lineage, &ours).await.unwrap(); + + assert_eq!( + (stats.resolved, stats.refused, stats.promoted), + (1, 1, 0), + "the honest DID resolves and the forged one is refused: {stats:?}" + ); + let dids = db.all_dids().await.unwrap(); + assert_eq!( + dids.len(), + 1, + "exactly one DID row — the forged launcher must not be among them" + ); + assert_eq!( + dids[0].launcher_id, + hex::encode(honest.did_launcher), + "and it is the honest one, not Mallory's {}", + hex::encode(forged.launcher) + ); + assert!( + db.all_coins().await.unwrap().is_empty(), + "no forged balance either" + ); + } + /// A staged row already SPENT on chain is dropped without a parent read at all. /// /// One of two early exits in [`promote_staged_cats`] that had no coverage: neutering either diff --git a/crates/dig-wallet/src/sage/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 3581c77c..4921658a 100644 --- a/crates/dig-wallet/src/sage/singleton.rs +++ b/crates/dig-wallet/src/sage/singleton.rs @@ -627,6 +627,130 @@ pub(crate) mod tests { pub(crate) owner_p2: Bytes32, } + /// Mint a DID that MALLORY owns, then spend it so the child singleton keeps MALLORY's inner + /// puzzle while the `CREATE_COIN` memo **hint** names the VICTIM. + /// + /// FIXTURE DESIGN — the hint is varied INDEPENDENTLY of the true owner, which is the one thing + /// no previous fixture did. [`mint_did_and_nft`] transfers with `Did::transfer`, which derives + /// the hint FROM the destination p2 hash, so hint and owner agree in every row it produces — + /// and a fixture in which two fields can never disagree cannot see a guard that reads the wrong + /// one. Every value here is chain-valid: the simulator accepts the spend, because a memo is + /// free-form data the consensus does not constrain. + pub(crate) fn mint_did_hinted_to_a_stranger() -> ForgedDid { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let mallory = sim.bls(2); + let mallory_layer = StandardLayer::new(mallory.pk); + // The p2 hash the forgery names as owner. Arbitrary: a victim's wallet recognises its own + // hashes by value, and Mallory needs only to know one of them (a hint is public). + let victim_p2 = Bytes32::new([0x5a; 32]); + + let (create_did, did) = Launcher::new(mallory.coin.coin_id(), 1) + .create_simple_did(ctx, &mallory_layer) + .unwrap(); + mallory_layer.spend(ctx, mallory.coin, create_did).unwrap(); + sim.spend_coins(ctx.take(), std::slice::from_ref(&mallory.sk)) + .unwrap(); + + // THE FORGERY. The created coin is the singleton Mallory still controls — its inner puzzle + // hash is curried over HER p2 hash — but the memo hint, which is the only place + // `Did::parse_child` reads an owner from, names the victim instead. + let child = did.child(did.info.p2_puzzle_hash, did.info.metadata, did.coin.amount); + let memos = ctx.hint(victim_p2).unwrap(); + did.spend_with( + ctx, + &mallory_layer, + Conditions::new().create_coin( + child.info.inner_puzzle_hash().into(), + did.coin.amount, + memos, + ), + ) + .unwrap(); + sim.spend_coins(ctx.take(), &[mallory.sk]).unwrap(); + + ForgedDid { + parent: parent_spend_from_sim(&sim, did.coin), + _sim: sim, + child: child.coin, + victim_p2, + mallory_p2: did.info.p2_puzzle_hash, + launcher: did.info.launcher_id, + } + } + + /// What [`mint_did_hinted_to_a_stranger`] hands back: a chain-valid DID spend whose memo hint + /// and whose actual owner are DIFFERENT p2 hashes. + pub(crate) struct ForgedDid { + /// Held so the simulator's spend store outlives the parent spend taken from it. + pub(crate) _sim: Simulator, + pub(crate) parent: ParentSpend, + pub(crate) child: Coin, + /// The p2 hash the memo hint names — the one the victim's wallet controls. + pub(crate) victim_p2: Bytes32, + /// The p2 hash the coin's puzzle is actually curried over — Mallory's. + pub(crate) mallory_p2: Bytes32, + pub(crate) launcher: Bytes32, + } + + /// A DID whose memo hint disagrees with the puzzle the coin is actually locked to is NOT + /// reconstructed — the hint is attacker-written and proves nothing about ownership. + /// + /// The pure-core half of the end-to-end proof in `cat_discovery::tests`. Both are kept: this + /// one pins WHERE the binding lives (in the reconstruction, so `reconstruct_coins` is covered + /// by it too), and the other pins that production routing reaches it. + #[test] + fn a_did_whose_hint_disagrees_with_its_puzzle_is_not_reconstructed() { + let f = mint_did_hinted_to_a_stranger(); + assert_ne!( + f.victim_p2, f.mallory_p2, + "the fixture is only meaningful if the hint and the real owner differ" + ); + + assert_eq!( + reconstruct("xch", Some(7), &f.parent, f.child).unwrap(), + Reconstructed::Unknown, + "a DID whose owner hint does not reproduce the coin's puzzle hash is not a DID this \ + wallet may attribute to anybody" + ); + + // THE CONTROL. The same code path, the same driver call, and the one varied thing is + // whether the hint tells the truth: an honest DID still reconstructs. + let honest = mint_did_and_nft(); + assert!( + matches!( + reconstruct("xch", Some(7), &honest.did_parent, honest.did_child).unwrap(), + Reconstructed::Did { .. } + ), + "an honest DID pays nothing for the binding" + ); + } + + /// The wider path the binding also closes: [`reconstruct_coins`] writes `dids` rows with no + /// ownership test of its own, so before the binding a forged hint minted a `dids` row there + /// too — a path the promotion-site guard never sees. + #[tokio::test] + async fn reconstruct_coins_writes_no_did_row_for_a_forged_hint() { + let f = mint_did_hinted_to_a_stranger(); + let mut lineage = MockLineage::default(); + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + + let db = WalletDb::open_in_memory().await.unwrap(); + let rows = vec![coin_row(f.child, 100)]; + let stats = reconstruct_coins(&db, &lineage, "xch", &HashSet::new(), &rows) + .await + .unwrap(); + + assert_eq!(stats.dids, 0, "the forged DID is not reconstructed"); + assert!( + db.all_dids().await.unwrap().is_empty(), + "and no row naming the victim as owner of Mallory's launcher {} reaches `dids`", + hex::encode(f.launcher) + ); + } + #[test] fn reconstruct_parses_nft_and_did_from_parent_spends() { let m = mint_did_and_nft(); From 66782d1c12d6edc0c14176f171b05be0b1afdbe5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 09:37:16 -0700 Subject: [PATCH 28/28] docs(spec): a reconstructed singleton must reproduce its own coin The "never the hint" ownership MUST was false for DIDs until the binding in reconstruct_parsed; state the binding as its own normative clause so the ownership clause is true as written. Disproven covers five cases, not four -- the fifth is a coin that reconstructs to nothing at all. Also restore StagedCatRow's doc comment, which PromotedSingleton was inserted in front of. Refs #380 Co-Authored-By: Claude --- SPEC.md | 13 +++++++++++-- crates/dig-wallet/src/sage/cat_discovery.rs | 14 +++++++------- crates/dig-wallet/src/sage/db.rs | 16 ++++++++-------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/SPEC.md b/SPEC.md index d384e6e5..9249ccfc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5490,10 +5490,19 @@ The two states are held in **different tables**, and this separation is normativ - **A resolved singleton MUST be owned.** Admission requires the inner p2 hash the RECONSTRUCTION names to be one the wallet controls — the same test an unpredicted CAT is held to, and never the hint, which anybody may write. -- **Disproven covers four cases**, all terminal deletions: a coin already spent on chain (dropped without +- **A reconstructed singleton MUST reproduce its own coin.** The reconstruction is only a proof of + ownership if the p2 hash it names is one the coin's puzzle is actually locked to, so §18.11 + reconstruction MUST recompute the singleton puzzle hash from the parsed info and refuse the coin unless + it equals the child coin's own puzzle hash. Without this the preceding clause is vacuous for DIDs: the + DID driver's read path takes the owner from the parent spend's `CREATE_COIN` memo hint and stores it + verbatim, so anybody able to spend any DID could name any wallet as owner of their singleton. The NFT + path needs no separate check — its child coin is derived from the parsed info, and the coin-id equality + that path already requires commits to it. +- **Disproven covers five cases**, all terminal deletions: a coin already spent on chain (dropped without a parent read, since it can neither be counted nor selected); a staged row whose coin id does not bind its own parent, puzzle hash and amount; a reconstruction that disagrees with the derivation, or whose - hint names an address the wallet does not control; and a singleton owned by another p2 hash. + hint names an address the wallet does not control; a singleton owned by another p2 hash; and a coin + whose parent spend reconstructs to nothing the wallet may hold at all. - A promotion write MUST claim its staged row before writing the coin: the staging row is deleted first and the write proceeds only if exactly one row was removed, all in one transaction. Promotion spans a network round trip, and a reorg rollback inside that window must not be overwritten by a coin the diff --git a/crates/dig-wallet/src/sage/cat_discovery.rs b/crates/dig-wallet/src/sage/cat_discovery.rs index 5443b1df..785c143f 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -1487,19 +1487,19 @@ mod tests { // Both p2 hashes are the wallet's. The forged coin is hinted to `victim_p2`, which is why // `coin_records_by_hints` returned it in the first place. - let ours: HashSet = [ - hex::encode(forged.victim_p2), - hex::encode(honest.owner_p2), - ] - .into_iter() - .collect(); + let ours: HashSet = [hex::encode(forged.victim_p2), hex::encode(honest.owner_p2)] + .into_iter() + .collect(); let rows = vec![ coin_row_of(forged.child, 100), coin_row_of(honest.did_child, 100), ]; let (believed, staged) = route_point_read_rows(&rows, &ours, &DerivedCats::default(), |_| false); - assert!(believed.is_empty(), "singletons are never believed outright"); + assert!( + believed.is_empty(), + "singletons are never believed outright" + ); assert_eq!(staged.len(), 2); let db = WalletDb::open_in_memory().await.unwrap(); diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index 3410102f..bd7f8221 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -346,14 +346,6 @@ pub struct PeerRow { /// above any honest backlog while still bounding the table against a spend crafted to fill it. pub const CAT_ADMISSION_PENDING_MAX_ROWS: i64 = 20_000; -/// A discovered CAT coin awaiting a lineage proof. -/// -/// Deliberately NOT a [`CoinRow`]. The two types describe different claims: a `CoinRow` is a coin -/// the wallet BELIEVES it owns as the asset it is typed with, and every balance, coin-selection -/// and arrival-notification read is entitled to trust it. A `StagedCatRow` is a coin the wallet has -/// merely FOUND at a hash it derived, together with the derivation that found it — a hypothesis. -/// Sharing one type between the two would make the difference a field rather than a table, which -/// is exactly the shape this design rejects. /// Which singleton table a proven staged coin belongs in. /// /// Borrowed rather than owned because the caller already holds the reconstructed row and the write @@ -366,6 +358,14 @@ pub enum PromotedSingleton<'a> { Did(&'a DidDbRow), } +/// A discovered CAT coin awaiting a lineage proof. +/// +/// Deliberately NOT a [`CoinRow`]. The two types describe different claims: a `CoinRow` is a coin +/// the wallet BELIEVES it owns as the asset it is typed with, and every balance, coin-selection +/// and arrival-notification read is entitled to trust it. A `StagedCatRow` is a coin the wallet has +/// merely FOUND at a hash it derived, together with the derivation that found it — a hypothesis. +/// Sharing one type between the two would make the difference a field rather than a table, which +/// is exactly the shape this design rejects. #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub struct StagedCatRow { /// The coin id (hex, 64 chars).