diff --git a/Cargo.lock b/Cargo.lock index 63fb50ed..0d6dfe4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3067,7 +3067,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.162.0" +version = "0.163.0" dependencies = [ "async-trait", "axum", @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.40.0" +version = "0.41.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2c6262b8..2c965534 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.162.0" +version = "0.163.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 0b6afc9d..d98d487a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5398,12 +5398,18 @@ self-custody is again the only model, §18.20.) 18.11. **NFT/DID/CAT reconstruction.** A raw `CoinState` does not reveal a coin's asset kind — that lives in the coin's puzzle, revealed only when its parent is spent. Reconstruction uncurries the parent spend (via the `Nft`/`Did`/`Cat` driver parsers) to populate the `nfts`/`dids`/`nft_collections` tables and to -attribute CAT coins to their asset id (TAIL hash) in the `coins` table (so `get_cats`/`get_token` become -complete). Parent spends are fetched through a `LineageSource` (out-of-DB lineage reads, B.5). Reads only. -The sync loop runs this attribution as a post-apply step (`sync::CatAttributor`, threaded into -`run_update_loop`): every `coin_state_update` is followed by an attribution pass that uncurries the -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). +attribute CAT coins to their asset id (TAIL hash) in the `coins` table, which is what makes such a coin +visible to `get_cats`/`get_token` at all. Parent spends are fetched through a `LineageSource` (out-of-DB +lineage reads, B.5). Reads only. Neither reader becomes COMPLETE by this: a coin the replica never +ingested cannot be attributed by a pass over rows it does not hold, and a row whose parent could not be +read is left for a later pass. + +The attributor is owned by the SUPERVISOR, which builds it from the subscription set it resolved for the +current attempt and threads it into the update loop; a supervisor with no lineage source attaches none, +and that absence MUST be honest rather than silent. The pass also runs ONCE after a completed catch-up, +so a replica that syncs and then receives no further pushes still attributes what it holds. The pass MUST +NOT run after a frame that was refused before any database write — otherwise an empty, already-refused +frame buys a whole-replica scan and a chain read per candidate row. 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 @@ -5514,17 +5520,52 @@ The two states are held in **different tables**, and this separation is normativ - 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. +**Where promotion runs.** The promotion pass runs on BOTH tiers. On the point-read tier it is driven by +`refresh_tracked_coins`. On the peer path it is driven by the supervisor's `CatAttributor`, which runs it +once after a completed catch-up and again after every frame that actually WROTE something — so a staged +coin becomes spendable on the sync that delivers it rather than waiting for a point-read refresh. Until +#382 the peer path's attributor was constructed under `cfg(test)` alone and the production call site +passed a hard-coded `None`, so this tier existed and never ran; that is fixed, and the wording here is +the 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. +18.11b. **A parent spend binds to the coin it was asked for.** A coin id is self-certifying — +`SHA256(parent ‖ puzzle_hash ‖ amount)` — so a `LineageSource` MUST check that the coin a spend answer +carries hashes to the coin that was requested, and MUST NOT return one that does not. Where it does not, +the coin is repaired from the coin record; where it still does not bind, the answer is NO LINEAGE rather +than a placeholder. This is a correctness requirement and not defence-in-depth: every CAT/singleton +driver derives its children's coin ids FROM that coin, so a placeholder makes `Cat::parse_children` +compute children matching nothing and the caller conclude the coin is not a CAT. + +18.11c. **The attribution pass remembers its OUTCOMES, and distinguishes an absence from an outage.** +A coin's parent spend is settled chain history, so a row a pass RESOLVED and could not attribute answers +identically for ever. Those rows are ordinary: an NFT or DID coin row keeps `asset_id` NULL because the +reconstruction is written to its own table, and an odd-amount plain coin at the wallet's own p2 hash +reconstructs to nothing. A memory of failed LOOKUPS cannot cover them, because their lookups succeed — so +without an outcome mark each costs one outbound chain read per push frame for the life of the replica. A +row whose parent could NOT be read MUST NOT be marked: nothing was learned about it. The pass MUST +therefore cost work proportional to newly-arrived rows. + +**A lineage answer distinguishes ABSENT from UNAVAILABLE, and the SOURCE must be able to tell them +apart.** "A source answered and there is no such spend" and "no source could be reached" MUST NOT be the +same value. Only an absence may be remembered or treated as a settled judgement; an unavailability is a +statement about this node's reachability and MUST be treated as *unknown*, so that a later pass asks +again. + +The distinction MUST be carried by the chain READ, not merely by the enum. A source that reads spends +through an API which collapses "no such spend" into the same error as "the read failed" cannot produce an +absence at all, whatever its mapping says. The production source MUST therefore use an absence-aware, +corroborated read (`chia-query`'s `get_coin_spend_opt`), whose `Ok(None)` requires agreement across +independent sources and whose every transport failure, rejection and disagreement remains an error. + +**A failed lineage read is NO LINEAGE, never an error.** An error propagates out of the attribution pass +and ends the peer session, which hands a denial of service to whoever made the read fail. The same +reasoning binds §18.11b's repair read, which fails to NO LINEAGE rather than propagating. + 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 547b847d..6b35675b 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.40.0" +version = "0.41.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 785c143f..dd668c0a 100644 --- a/crates/dig-wallet/src/sage/cat_discovery.rs +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -45,7 +45,7 @@ use std::collections::{HashMap, HashSet}; use chia_protocol::{Bytes32, Coin, CoinState}; use super::db::{CoinRow, PromotedSingleton, StagedCatRow, WalletDb}; -use super::singleton::{coin_from_row, LineageSource, Reconstructed}; +use super::singleton::{coin_from_row, LineageAnswer, LineageSource, Reconstructed}; use super::{singleton, Result}; /// How many staged coins one promotion pass will read parent spends for. @@ -339,18 +339,34 @@ pub async fn promote_staged_cats( .parent_spend(&row.parent_coin_info, created as u32) .await { - Ok(Some(parent)) => parent, - // `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) => { + Ok(LineageAnswer::Found(parent)) => *parent, + // A source ANSWERED, and has no spend for this parent. + // + // #393 reasoned about this as `Ok(None)` and DEFERRED it, and that stays exactly + // right even though the answer is strictly stronger now: with `LineageAnswer` this + // arm is a CORROBORATED absence (chia-query settles an uncorroborated one as an + // `Err`), so one hostile peer can no longer produce it. Corroboration is agreement, + // NOT currency -- every source can agree and every source can be behind the chain, + // which is the ordinary case for a coin created seconds ago. So a corroborated + // absence still does not disprove ancestry, and promoting it to `Disproven` would + // delete real coins whenever the sources lag. Deferred, with the cooldown bounding + // the cost of retrying for ever. + Ok(LineageAnswer::Absent) => { 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. + // No source answered at all -- transport, timeout, an uncorroborated claim, or two + // sources that contradict each other. Strictly less informative than an absence, and + // handled the same way. + Ok(LineageAnswer::Unavailable) => { + db.record_promotion_attempt(&row.coin_id, now).await?; + stats.deferred += 1; + continue; + } + // A source answered with something unusable (a malformed reveal, undecodable hex). + // Narrower than #393's `Err` arm, which also caught outages; those are `Unavailable` + // above. Handled identically, so no promotion decision changes. Err(e) => { tracing::debug!( coin_id = %row.coin_id, @@ -645,6 +661,24 @@ mod tests { HashSet::new() } + /// What a MISS from [`CountingLineage`] means. Named rather than left implicit because the + /// two are different chain facts that `Option` could not tell apart: `Absent` is + /// "the sources agree there is no such spend", `Unavailable` is "nothing could be learned". + /// + /// The field exists so a double is not forced to pick one and pretend it is both. A fixture + /// map cannot know which a missing key represents, so it has to say -- and a double that can + /// only express one of production's two miss modes makes every test over it a partial green + /// (see [`LineageAnswer::from_lookup`]). + #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] + enum Miss { + /// Nothing could be learned. The default, because it is the WEAKER claim: a double that + /// silently asserted a settled absence would let a test read a deferral as a disproof. + #[default] + Unavailable, + /// The sources agree there is no such spend. + Absent, + } + /// A [`LineageSource`] over a fixed parent map, which COUNTS its reads and can be told to fail /// for one specific parent. /// @@ -657,6 +691,8 @@ mod tests { by_parent: HashMap, reads: AtomicUsize, fail_for: Option, + /// What a parent absent from `by_parent` reports. See [`Miss`]. + miss: Miss, } impl CountingLineage { @@ -671,12 +707,18 @@ mod tests { &self, parent_coin_id: &str, _spent_height: u32, - ) -> Result> { + ) -> 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()) + Ok(LineageAnswer::from_lookup( + self.by_parent.get(parent_coin_id).cloned(), + match self.miss { + Miss::Unavailable => LineageAnswer::Unavailable, + Miss::Absent => LineageAnswer::Absent, + }, + )) } } @@ -1594,4 +1636,108 @@ mod tests { "a second pass inside the cooldown must not touch it again: {second:?}" ); } + + /// **Proves:** a CORROBORATED absence defers exactly like an unreadable parent -- it never + /// disproves the coin, and never discards the staged row. + /// + /// # Why this test exists + /// + /// `LineageAnswer` split what used to be one `Ok(None)` into `Absent` ("the sources AGREE + /// there is no such spend") and `Unavailable` ("nothing could be learned"). `Absent` is the + /// stronger claim, and the tempting next step is to treat it as a disproof and discard the + /// row. That would be wrong, and wrong in the money-losing direction: corroboration is + /// agreement, NOT currency. Every source can agree and every source can be behind the chain, + /// which is the ordinary case for a coin created seconds ago -- so discarding on `Absent` + /// deletes real coins whenever the sources lag. This test goes red on that change. + /// + /// # The fixture varies ONE actor + /// + /// The honest CAT is present in both runs with its parent resolvable, so it must promote in + /// both. Only the SECOND coin's miss answer varies. A fixture where every parent missed would + /// be the blindest possible one here: with no promotion left to observe, a pass that deferred + /// correctly and a pass that abandoned the whole table on the first miss look identical. + #[tokio::test] + async fn a_corroborated_absence_defers_the_row_exactly_as_an_unreadable_parent_does() { + /// Run one promotion pass in which the honest CAT resolves and a second staged coin + /// MISSES, with the miss reported as `miss`. Returns + /// `(stats, rows still staged, rows believed)`. + async fn pass_with(miss: Miss) -> (PromoteStats, i64, usize) { + let f = real_cat(); + let db = WalletDb::open_in_memory().await.unwrap(); + let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]); + // A second coin at the SAME derived hash, so it is staged for the same reason the + // honest one is; only its parent differs, and that parent is deliberately absent from + // the lineage map. + let missing = fabricated_at(f.child.puzzle_hash, 1_234, 0xCD); + + let rows = stage_from_states( + &[ + state(f.child, Some(10), None), + state(missing, Some(11), None), + ], + &derived, + |_| false, + ); + assert_eq!(rows.len(), 2, "both coins are staged, neither believed"); + db.stage_cat_admissions(&rows).await.unwrap(); + + let mut lineage = CountingLineage { + miss, + ..Default::default() + }; + // ONLY the honest coin's parent is resolvable. The other falls through to `miss`. + lineage + .by_parent + .insert(hex::encode(f.child.parent_coin_info), f.parent.clone()); + + let stats = promote_staged_cats(&db, &lineage, &owned()).await.unwrap(); + let staged_left = db.staged_cat_admission_count().await.unwrap(); + let believed = db.all_coins().await.unwrap().len(); + (stats, staged_left, believed) + } + + let (absent, absent_staged, absent_believed) = pass_with(Miss::Absent).await; + let (unavail, unavail_staged, unavail_believed) = pass_with(Miss::Unavailable).await; + + // The truthful control: the honest CAT promotes under BOTH miss answers. Without this, a + // pass that abandoned the table on the first miss would satisfy every assertion below. + assert_eq!( + (absent.promoted, unavail.promoted), + (1, 1), + "the honest CAT must promote regardless of what the OTHER coin's parent reported -- \ + one unresolvable row must never abandon the pass: absent={absent:?} \ + unavailable={unavail:?}" + ); + + // The missing coin is DEFERRED under both, never refused. + assert_eq!( + (absent.deferred, absent.refused), + (1, 0), + "a corroborated absence must DEFER the row, not disprove it: sources can agree and \ + still be behind the chain, so refusing here deletes real coins whenever they lag. \ + {absent:?}" + ); + assert_eq!( + (absent.deferred, absent.refused), + (unavail.deferred, unavail.refused), + "and it must be handled identically to an unreadable parent -- the promotion path \ + deliberately does not act on the Absent/Unavailable distinction. absent={absent:?} \ + unavailable={unavail:?}" + ); + + // The row SURVIVES, so a later pass can promote it once the sources catch up. This is the + // assertion a discard-on-Absent implementation fails. + assert_eq!( + (absent_staged, unavail_staged), + (1, 1), + "the unresolved row must stay STAGED under both answers, or a source that is merely \ + behind permanently deletes a real coin" + ); + assert_eq!( + (absent_believed, unavail_believed), + (1, 1), + "and exactly one coin is believed -- the proven one. An unresolved coin must never \ + enter `coins`, where a NULL asset id would read as XCH" + ); + } } diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index bd7f8221..474fbca5 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -460,7 +460,8 @@ CREATE TABLE IF NOT EXISTS coins ( asset_id TEXT, hint TEXT, created_timestamp INTEGER, - spent_timestamp INTEGER + spent_timestamp INTEGER, + attribution_examined INTEGER ); CREATE INDEX IF NOT EXISTS idx_coins_ph ON coins (puzzle_hash); CREATE INDEX IF NOT EXISTS idx_coins_asset ON coins (asset_id); @@ -647,6 +648,10 @@ const ADD_COLUMN_MIGRATIONS: &[&str] = &[ // 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", + // Set once a CAT/singleton attribution pass has RESOLVED this coin's parent spend and acted + // on what it said (dig-node#383). NULL means "not examined yet", which is the right reading + // for every row written before this column existed: they are re-examined once and then settle. + "ALTER TABLE coins ADD COLUMN attribution_examined INTEGER", ]; // ---- one-shot data-migration ladder --------------------------------------- @@ -709,6 +714,10 @@ const POST_MIGRATION_INDEXES: &[&str] = &[ // The eviction order is `ORDER BY last_used_at`, run on every cache write (dig_ecosystem#3035). "CREATE INDEX IF NOT EXISTS idx_chain_read_cache_last_used ON chain_read_cache (last_used_at)", "CREATE INDEX IF NOT EXISTS idx_chain_spend_cache_last_used ON chain_spend_cache (last_used_at)", + // `unexamined_attribution_candidates` runs after every applied push frame. Without this it is + // a full table scan per frame; with it, a quiet wallet's pass touches no rows at all. + "CREATE INDEX IF NOT EXISTS idx_coins_attribution_pending ON coins (attribution_examined) \ + WHERE spent_height IS NULL AND asset_id IS NULL AND attribution_examined IS NULL", ]; // ---- chain-read cache budget (dig_ecosystem#3035) ------------------------- @@ -2999,6 +3008,53 @@ impl WalletDb { Ok(()) } + /// Record that an attribution pass RESOLVED this coin's parent spend and acted on the answer, + /// so no later pass reads it again (dig-node#383). + /// + /// # Why the outcome is remembered rather than the lookup + /// + /// A CAT gains an `asset_id` and so is self-marking, but the other resolved outcomes are not: + /// [`Self::upsert_nft`] and [`Self::upsert_did`] write their own tables and leave + /// `coins.asset_id` NULL, and an odd-amount plain XCH coin at the wallet's own p2 hash + /// reconstructs to *nothing at all*. Every one of those rows is a candidate again on the next + /// pass, resolves again, and costs another outbound chain read — forever, at whatever cadence + /// a peer chooses to send frames at. + /// + /// A cache over the *lookup* cannot fix that, because these lookups succeed. Only the + /// attribution OUTCOME is stable enough to remember, and it is stable for the strongest + /// possible reason: a coin's parent spend is settled chain history and cannot change. + /// + /// The mark is set ONLY on a resolved read. A parent that could not be reached leaves the row + /// unmarked, because remembering "we could not ask" as "we asked" is how an outage turns into + /// a permanent wrong balance. + pub async fn mark_attribution_examined(&self, coin_id: &str) -> sqlx::Result<()> { + sqlx::query("UPDATE coins SET attribution_examined = 1 WHERE coin_id = ?") + .bind(Self::normalise_hex(coin_id)) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The coins an attribution pass could still learn something from: unspent, unattributed, + /// confirmed, and not yet examined. + /// + /// Narrowed in SQL rather than by filtering [`Self::all_coins`] in Rust. The pass runs after + /// every applied push frame, so a whole-table read there is a standing per-frame cost + /// proportional to everything the replica has ever synced; here it is proportional to what + /// has newly arrived, and on a quiet wallet it returns nothing. + pub async fn unexamined_attribution_candidates(&self) -> sqlx::Result> { + let rows = sqlx::query( + "SELECT * FROM coins + WHERE spent_height IS NULL + AND asset_id IS NULL + AND created_height IS NOT NULL + AND attribution_examined IS NULL", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.iter().map(Self::coin_from_row).collect()) + } + // ---- NFTs ------------------------------------------------------------- /// Insert or update a reconstructed NFT (keyed by launcher id; a later coin overwrites diff --git a/crates/dig-wallet/src/sage/fallback.rs b/crates/dig-wallet/src/sage/fallback.rs index 4c110632..0eb10222 100644 --- a/crates/dig-wallet/src/sage/fallback.rs +++ b/crates/dig-wallet/src/sage/fallback.rs @@ -372,10 +372,17 @@ impl ChainFallback for CoinsetFallback { /// `Ok(None)` ONLY when a chain source ANSWERED and reported no such coin; every failure to /// read is an `Err` (dig_ecosystem#2392). /// - /// `Ok(None)` is NOT yet proof of absence: `chia-query` 0.6 mints it from ONE peer's empty - /// coin-state list without consulting coinset, so a peer that is a block behind, mid-reorg, - /// pruning or hostile produces it. Requiring corroboration is dig_ecosystem#2456, one crate - /// down. Callers polling a mint read `None` as "not seen yet", never as "never happened". + /// `Ok(None)` IS proof of absence, and the mapping at [`ChiaQueryLineage::parent_spend`] now + /// depends on that. The graph resolves `chia-query` **0.19.0**, where this read goes through + /// `peer_then_coinset_opt` into `read_opt_corroborated`: `Ok(None)` is produced only for a + /// `CorroboratedAbsent` — the answering peer plus `CORROBORATION_FLOOR` independent peers at + /// different addresses all reporting absent — or a peer-uncorroborated absence that coinset + /// agrees with. ONE peer's empty coin-state list yields `UncorroboratedAbsent`, and any + /// contradiction is `SourcesDisagree`, which stays an `Err`. dig_ecosystem#2456, which this + /// comment used to cite as pending against `chia-query` 0.6, has landed. + /// + /// Callers polling a MINT should still read `None` as "not seen yet" rather than "never + /// happened" — but that is a statement about mempool timing, not about corroboration. /// /// The absence-aware `_opt` variant carries that distinction (a `success: true` envelope with a /// null record is absence; a transport/API failure is not), so this method must not re-decide @@ -506,51 +513,122 @@ impl super::singleton::LineageSource for ChiaQueryLineage { async fn parent_spend( &self, parent_coin_id: &str, - spent_height: u32, - ) -> Result> { + _spent_height: u32, + ) -> Result { + use super::singleton::LineageAnswer; let coin_id = format!("0x{}", CoinsetFallback::norm_hex(parent_coin_id)); - // A FAILED READ IS REPORTED AS A FAILED READ (dig-node#394). + // The ABSENCE-AWARE read, and the choice is load-bearing rather than stylistic. // - // 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. + // dig-node#394 widened this arm so that EVERY unsuccessful read became an `Err` and + // `Ok(None)` was never manufactured from a failure. That direction is right and is kept + // below: an outage must never arrive at the promotion path wearing the costume of a chain + // fact. What #394 could not do was report a real absence, because it read through the + // non-`_opt` `get_puzzle_and_solution`, where a parent that does not exist is an `Err` + // indistinguishable from an outage. Its comment gave the reason as "only the inner coinset + // client exposes an absence-aware read, and lifting it onto the facade is a chia-query + // release this PR will not take". That premise no longer holds, and it is the one thing + // changed here: `chia_query::ChiaQuery::get_coin_spend_opt` is ON the facade (0.19.0, + // `lib.rs:347`) and carries exactly the distinction, so no release is needed. // - // `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, - Err(e) => { - return Err(Error::internal(format!( - "lineage: parent spend {coin_id} could not be read: {e}" - ))) - } + // The distinction matters because widening in the safe direction made a settled absence + // unreachable in production for the exact case attribution was written for: a parent that + // simply does not exist produced "this node could not read the chain", and the pass + // retried it forever instead of concluding anything (dig-node#383). + // + // The absence it reports is CORROBORATED, not one peer's say-so. `get_coin_spend_opt` + // routes through `Router::peer_then_coinset_opt` -> `settle_peer_answer`, where only + // `OptAnswer::CorroboratedAbsent` becomes `Ok(None)`; an uncorroborated absence is + // `ChiaQueryError::UncorroboratedAbsence`, an `Err`. Corroboration means + // `chia_query::peer::plurality::CORROBORATION_FLOOR` (= 2) independent peers BESIDES the + // one that answered, or coinset agreeing with a peer-uncorroborated absence. One hostile + // peer's empty coin-state list therefore cannot mint an absence here, and a contradiction + // between sources is an `Err` rather than either answer. + // + // It also needs no height: it resolves the spend height itself from a coin-state read, + // which is why `_spent_height` is unused. An unknown OR unspent coin is `Ok(None)` -- both + // are honestly "there is no such parent spend", which is the question being asked. + let cs = match self.query.get_coin_spend_opt(&coin_id).await { + Ok(Some(cs)) => cs, + // A corroborated absence. SETTLED, not unknown: the coin the peer offered descends + // from nothing, so refusing it is a judgement about the peer's claim and leaves the + // replica no less complete. + Ok(None) => return Ok(LineageAnswer::Absent), + // A read that could not be completed -- an outage, a rejection, an uncorroborated + // claim, or two sources that disagree. Nothing was learned, so the weaker claim is the + // only safe one: a caller that hears "unknown" retries and refuses to declare itself + // complete, whereas one that hears "absent" writes the coin off and answers a + // confident balance without it. This is #394's rule, unchanged. + Err(_) => return Ok(LineageAnswer::Unavailable), }; let decode = |field: &str, s: &str| -> Result> { hex::decode(s.strip_prefix("0x").unwrap_or(s)) .map_err(|e| Error::internal(format!("lineage {field} hex: {e}"))) }; - let parent = super::singleton::bytes32_from_hex(&cs.coin.parent_coin_info)?; - let puzzle_hash = super::singleton::bytes32_from_hex(&cs.coin.puzzle_hash)?; - Ok(Some(super::singleton::ParentSpend { - coin: chia_protocol::Coin { - parent_coin_info: parent, - puzzle_hash, - amount: cs.coin.amount, + let coin = chia_protocol::Coin { + parent_coin_info: super::singleton::bytes32_from_hex(&cs.coin.parent_coin_info)?, + puzzle_hash: super::singleton::bytes32_from_hex(&cs.coin.puzzle_hash)?, + amount: cs.coin.amount, + }; + // The coin a spend answer CARRIES is checked against the coin that was ASKED for, and + // repaired from the coin record when it does not bind. + // + // This is not defence-in-depth. `chia-query`'s PEER tier returns the puzzle and solution + // faithfully and leaves the coin a placeholder — measured on mainnet 2026-08-27: for + // parent `567d481d…` the coinset tier answered the real coin while the peer tier answered + // `puzzle_hash: 0x00…00, amount: 0` beside byte-identical reveal and solution. Every CAT + // driver derives its children's coin ids FROM that coin, so a zeroed one makes + // `Cat::parse_children` compute children that match nothing and report "not a CAT". That + // is the whole of dig-node#382's last mile: eight real $DIG coins, refused one by one, on + // a wallet holding 3,856.455 $DIG. + // + // A coin id is self-certifying — `SHA256(parent ‖ puzzle_hash ‖ amount)` — so the binding + // is checkable locally and costs nothing when the answer is already right. + let expected = super::singleton::bytes32_from_hex(parent_coin_id)?; + let coin = if coin.coin_id() == expected { + coin + } else { + // `Ok(None)` on a failed read, matching the spend read above (`Err(_) => Ok(None)`) + // rather than propagating. On the peer tier the answer is a placeholder every time, + // so THIS is the common path — and an `Err` here escapes `reconstruct_coins`, then + // `attribute()`, then `run_update_loop`, killing the peer session over a transient + // coinset blip. The supervisor makes the same call one function away, deliberately: + // "a read failure must never turn a completed catch-up into a failed session". + // A missing lineage is refused-and-retried; a dead session is not. + let record = match CoinsetFallback::new(self.query.clone()) + .coin_record_by_id(parent_coin_id) + .await + { + Ok(Some(record)) => record, + // A source answered and has no such coin: settled, and cheap to remember. + Ok(None) => return Ok(LineageAnswer::Absent), + // No source answered. Never an `Err`, for the reason above the spend read; and + // never `Absent`, because nothing was learned. + Err(_) => return Ok(LineageAnswer::Unavailable), + }; + let repaired = chia_protocol::Coin { + parent_coin_info: super::singleton::bytes32_from_hex(&record.parent_coin_info)?, + puzzle_hash: super::singleton::bytes32_from_hex(&record.puzzle_hash)?, + amount: record.amount, + }; + // `coin_record_by_id` already refuses a record for a different coin, so reaching here + // with a mismatch would mean two independent reads disagree about a self-certifying + // id. There is no honest lineage to return in that case. + if repaired.coin_id() != expected { + // Both reads answered and they disagree about a self-certifying id. Not an + // outage — the sources are reachable and one of them is wrong — so this is + // `Absent`: there is no honest lineage here and re-asking would return the same + // contradiction. + return Ok(LineageAnswer::Absent); + } + repaired + }; + Ok(LineageAnswer::Found(Box::new( + super::singleton::ParentSpend { + coin, + puzzle_reveal: decode("puzzle_reveal", &cs.puzzle_reveal)?, + solution: decode("solution", &cs.solution)?, }, - puzzle_reveal: decode("puzzle_reveal", &cs.puzzle_reveal)?, - solution: decode("solution", &cs.solution)?, - })) + ))) } } @@ -645,6 +723,7 @@ mod chain_failure_tests { //! performs no DNS), so every read falls through to the local coinset stand-in below. use super::*; + use crate::sage::singleton::{LineageAnswer, LineageSource}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -692,6 +771,221 @@ mod chain_failure_tests { format!("http://127.0.0.1:{port}") } + /// Serve a body chosen by the request PATH, so one fixture can answer two different coinset + /// endpoints differently. [`serve_json`] answers every path identically, which cannot express + /// a tier that is right about one read and wrong about another. + async fn serve_routed(routes: &'static [(&'static str, &'static str)]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0u8; 4096]; + let n = socket.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + let body = routes + .iter() + .find(|(path, _)| request.contains(path)) + .map(|(_, body)| *body) + .unwrap_or(r#"{"success":false}"#); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + format!("http://127.0.0.1:{port}") + } + + /// A `get_puzzle_and_solution` answer carrying a PLACEHOLDER coin — a zeroed puzzle hash and a + /// zero amount beside a faithful reveal and solution. This is the shape `chia-query`'s PEER + /// tier really returns (measured on mainnet, dig-node#382). + const SPEND_WITH_PLACEHOLDER_COIN: &str = r#"{"success":true,"coin_solution":{ + "coin":{"parent_coin_info":"0x1111111111111111111111111111111111111111111111111111111111111111", + "puzzle_hash":"0x0000000000000000000000000000000000000000000000000000000000000000", + "amount":0}, + "puzzle_reveal":"0x01","solution":"0x80"}}"#; + + /// The coin record for [`KNOWN_COIN_ID`], which is what the repair read must recover. + const REPAIR_RECORD: &str = KNOWN_COIN_RECORD; + + async fn lineage_against(base_url: String) -> ChiaQueryLineage { + let query = chia_query::ChiaQuery::new(chia_query::ChiaQueryConfig { + coinset_base_url: base_url, + max_peers: 0, + coinset_fallback_enabled: true, + coinset_request_timeout: std::time::Duration::from_secs(5), + ..Default::default() + }) + .await + .expect("a zero-peer client with the coinset fallback enabled always constructs"); + ChiaQueryLineage::new(Arc::new(query)) + } + + /// **Proves (dig-node#382, the last mile):** a parent spend whose CARRIED coin does not bind to + /// the coin that was asked for is repaired from the coin record, never returned as-is. + /// + /// # Why this is a correctness bug and not hardening + /// + /// Every CAT/singleton driver derives its children's coin ids FROM `ParentSpend::coin`. A + /// placeholder coin therefore makes `Cat::parse_children` compute children that match nothing, + /// and the caller concludes the coin is not a CAT. Measured on mainnet: all eight of a real + /// wallet's unspent $DIG coins were refused this way, on a wallet holding 3,856.455 $DIG, while + /// the coinset tier answered the same question correctly. + /// + /// The fixture serves a placeholder coin on the SPEND read and the truth on the RECORD read, + /// because that is exactly the split observed — a tier right about one read and wrong about + /// another. A fixture that got both wrong could not tell repair from luck. + #[tokio::test] + async fn a_parent_spend_that_does_not_bind_is_repaired_from_the_coin_record() { + let base = serve_routed(&[ + ("get_puzzle_and_solution", SPEND_WITH_PLACEHOLDER_COIN), + ("get_coin_record_by_name", REPAIR_RECORD), + ]) + .await; + let lineage = lineage_against(base).await; + + let spend = lineage + .parent_spend(KNOWN_COIN_ID, 140) + .await + .expect("the read succeeds") + .found() + .expect("a spend the chain reported must not be dropped"); + + assert_eq!( + hex::encode(spend.coin.coin_id()), + KNOWN_COIN_ID, + "the returned parent coin must bind to the coin that was asked for" + ); + assert_eq!(spend.puzzle_reveal, vec![0x01]); + assert_eq!(spend.solution, vec![0x80]); + } + + /// **The control:** when the repair read cannot recover a binding coin either, the answer is + /// a reported ABSENCE — never the placeholder. + /// + /// Without this half, the test above is satisfied by an implementation that returns whatever + /// the record read produced without checking it, which is the same unchecked trust one hop + /// further along. + #[tokio::test] + async fn an_unrepairable_parent_spend_is_no_lineage_rather_than_a_placeholder() { + let base = serve_routed(&[ + ("get_puzzle_and_solution", SPEND_WITH_PLACEHOLDER_COIN), + ( + "get_coin_record_by_name", + r#"{"success":true,"coin_record":null}"#, + ), + ]) + .await; + let lineage = lineage_against(base).await; + + let spend = lineage + .parent_spend(KNOWN_COIN_ID, 140) + .await + .expect("a reported absence is not a read failure"); + + assert!( + matches!(spend, LineageAnswer::Absent), + "a chain that ANSWERED 'no such coin' is an absence, not an outage: an absence may be remembered and written off, an outage may not. Got {spend:?}" + ); + } + + /// A `get_puzzle_and_solution` answer reporting that the chain HAS no such spend: a + /// `success: true` envelope carrying a null `coin_solution`. This is the shape coinset returns + /// for a coin that is unknown or simply unspent. + const NO_SUCH_SPEND: &str = r#"{"success":true,"coin_solution":null}"#; + + /// **Proves (dig-node#383, F1):** the PRODUCTION source reports a parent the chain does not + /// have as [`LineageAnswer::Absent`], not as an outage. + /// + /// # Why this test is about a call, not a branch + /// + /// The attribution pass distinguishes "the chain says there is no such parent" from "this node + /// could not read the chain", and only the first is a settled judgement it can act on. That + /// distinction was unreachable in production: the spend read went through the non-`_opt` + /// `get_puzzle_and_solution`, which returns `Result`, so a nonexistent parent + /// arrived as an `Err` indistinguishable from a dead network and was mapped, correctly for what + /// it knew, to [`LineageAnswer::Unavailable`]. A row whose parent genuinely does not exist was + /// therefore re-read on every pass, forever, and never concluded. + /// + /// So the defect was not a branch that decided wrongly; it was a CALL that could not carry the + /// answer. This asserts on the answer the production impl gives for the honest wire shape, + /// which is the only thing that can distinguish the two reads. + /// + /// The sibling below is the control that stops this being satisfied by a source that simply + /// calls everything absent: an outage on the SAME read must still be `Unavailable`. + #[tokio::test] + async fn a_parent_the_chain_reports_no_spend_for_is_absent_not_unavailable() { + let base = serve_routed(&[("get_puzzle_and_solution", NO_SUCH_SPEND)]).await; + let lineage = lineage_against(base).await; + + let answer = lineage + .parent_spend(KNOWN_COIN_ID, 140) + .await + .expect("a reported absence is not a read failure"); + + assert!( + matches!(answer, LineageAnswer::Absent), + "a corroborated 'there is no such spend' is a settled judgement about the PEER'S \ + CLAIM, so the coin is refused and the batch stays complete. Reported as an outage it \ + becomes a statement about this node, the batch is incomplete, the session is torn \ + down, and the peer re-sends the same 32 random bytes on every redial. Got {answer:?}" + ); + } + + /// **The control for the test above.** A read that genuinely FAILS on the very same endpoint + /// must still be [`LineageAnswer::Unavailable`]. + /// + /// Without it, "absence maps to `Absent`" would be satisfied by a source that had simply + /// stopped distinguishing the two in the other direction — which is the money-lie this family + /// exists to close, since a wallet that reads "we could not reach anyone" as "it does not + /// exist" writes off coins it owns and answers a confident balance without them. + #[tokio::test] + async fn a_spend_read_that_fails_is_still_unavailable_not_absent() { + // No route matches, so the fixture answers `{"success":false}` — a rejection, not an + // absence. + let base = serve_routed(&[("some_other_endpoint", NO_SUCH_SPEND)]).await; + let lineage = lineage_against(base).await; + + let answer = lineage + .parent_spend(KNOWN_COIN_ID, 140) + .await + .expect("a failed read refuses the coin, never the session"); + + assert!( + matches!(answer, LineageAnswer::Unavailable), + "nothing was learned about the chain, so the weaker claim is the only honest one. \ + Got {answer:?}" + ); + } + + /// **A failed REPAIR read is no lineage, not a dead peer session (#383).** + /// + /// The spend read beside it maps a failed read to `Ok(None)` deliberately; the repair read + /// added with the binding check did not, and on the peer tier the repair branch is the + /// COMMON path — every answer there is a placeholder. So a transient coinset failure + /// propagated out of `parent_spend`, through `reconstruct_coins`, through `attribute()`, and + /// ended the peer session, over a read the wallet is entitled to simply retry. + /// + /// The fixture routes ONLY the spend read, so the record read hits the fallback route and + /// FAILS. That is the distinction that matters and the one the sibling test above cannot + /// make: it serves `coin_record: null`, a chain that answered "no such coin", which reaches + /// `Ok(None)` by a different branch and would stay green with the propagation intact. + #[tokio::test] + async fn a_failed_repair_read_is_no_lineage_rather_than_a_failed_session() { + let base = serve_routed(&[("get_puzzle_and_solution", SPEND_WITH_PLACEHOLDER_COIN)]).await; + let lineage = lineage_against(base).await; + + let spend = lineage.parent_spend(KNOWN_COIN_ID, 140).await; + + assert!( + matches!(spend, Ok(LineageAnswer::Unavailable)), + "a failed repair read must refuse the coin, not the session — and must say UNAVAILABLE rather than ABSENT, because nothing was learned about the chain. Reporting it as an absence would let one failed read write a real coin off for the cache's whole TTL. Got {spend:?}" + ); + } + /// A coin id the fixtures ask for. Its value is irrelevant — what varies is the SOURCE. const SOME_COIN_ID: &str = "1111111111111111111111111111111111111111111111111111111111111111"; diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 24acf6a9..3c1cc55d 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3148,13 +3148,14 @@ 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. - // 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. + // ONE OF TWO promotion sites. The other is the supervisor's `CatAttributor` on the + // peer path, which #391 wired into production (dig-node#382) -- until then + // `CatAttributor` was constructed under `cfg(test)` alone and this was the only site + // that ran on a shipped node. Both are still needed: this tier promotes on a + // point-read refresh, the peer tier on the sync that delivers the coin. + // + // It must not be silent either way: 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. @@ -3395,6 +3396,7 @@ impl WalletBackend { let parent = lineage .parent_spend(&row.parent_coin_info, created) .await? + .found() .ok_or_else(|| Error::internal("CAT parent spend unavailable"))?; let child = singleton::coin_from_row(row)?; let cat = singleton::resolve_cat(&parent, child)? @@ -3535,6 +3537,7 @@ impl WalletBackend { let parent = lineage .parent_spend(&row.parent_coin_info, created) .await? + .found() .ok_or_else(|| Error::internal("parent spend unavailable"))?; let child = singleton::coin_from_row(&row)?; Ok((parent, child)) @@ -9374,8 +9377,13 @@ mod tests { &self, parent_coin_id: &str, _spent_height: u32, - ) -> Result> { - Ok((parent_coin_id == self.parent_id).then(|| self.spend.clone())) + ) -> Result { + // A parent this double does not hold is one the node could not READ, which is + // what the production source reports for an unresolvable parent. + Ok(singleton::LineageAnswer::from_lookup( + (parent_coin_id == self.parent_id).then(|| self.spend.clone()), + singleton::LineageAnswer::Unavailable, + )) } } @@ -9552,8 +9560,13 @@ mod tests { &self, parent_coin_id: &str, _spent_height: u32, - ) -> Result> { - Ok((parent_coin_id == self.parent_id).then(|| self.spend.clone())) + ) -> Result { + // A parent this double does not hold is one the node could not READ, which is + // what the production source reports for an unresolvable parent. + Ok(singleton::LineageAnswer::from_lookup( + (parent_coin_id == self.parent_id).then(|| self.spend.clone()), + singleton::LineageAnswer::Unavailable, + )) } } diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs index 334ca371..d6298bc8 100644 --- a/crates/dig-wallet/src/sage/service.rs +++ b/crates/dig-wallet/src/sage/service.rs @@ -32,8 +32,9 @@ use super::spend::{ Broadcaster, ChiaQueryBroadcaster, ChiaQueryConfirmer, Confirmer, ConfirmingBroadcaster, }; use super::sync_supervisor::{ - spawn_supervisor, ChiaPeerSessionFactory, ChiaQuorumCorroborator, FallbackChainTip, Supervisor, - SyncHandle, TokioTime, UnionPuzzleHashSource, SESSION_MAX_LIFETIME, + spawn_supervisor, Attribution, ChiaPeerSessionFactory, ChiaQuorumCorroborator, + FallbackChainTip, Supervisor, SyncHandle, TokioTime, UnionPuzzleHashSource, + SESSION_MAX_LIFETIME, }; use super::tipping::{ChainOwnerResolver, NodeTipSpender, SystemClock, TipEventBus, TippingEngine}; use super::transport::SharedCert; @@ -230,8 +231,28 @@ impl WalletService { // writes", never a silent downgrade. // The task handle is dropped deliberately: the supervisor lives for the process, and // its stop signal is `SyncHandle::shutdown`, not a dropped join handle. + // Attribution rides the SAME shared client as every other chain read, and is built here + // rather than inside `build_live_wallet` on purpose: uncurrying a parent spend to recover + // a CAT's asset id is a READ. Gating it on `enable_live_broadcast` — the flag that means + // "this node may spend" — would leave every read-only install unable to name the $DIG it + // holds, which is exactly the shape of dig-node#382. + let attribution: Option> = if cfg.enable_chain_sync { + match chain.shared_client().await { + Ok(query) => Some(Arc::new(Attribution::new( + Arc::new(ChiaQueryLineage::new(query)), + WalletConfig::default().address_prefix, + ))), + Err(e) => { + warn_chain_source_unavailable(&e); + None + } + } + } else { + None + }; let sync = if cfg.enable_chain_sync { Some(spawn_supervisor(Supervisor { + attribution, db: db.clone(), puzzle_hashes: Arc::new(UnionPuzzleHashSource::new( custody.clone(), diff --git a/crates/dig-wallet/src/sage/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 4921658a..e3a4abd1 100644 --- a/crates/dig-wallet/src/sage/singleton.rs +++ b/crates/dig-wallet/src/sage/singleton.rs @@ -82,6 +82,64 @@ pub struct ReconstructStats { pub dids: u32, /// CAT coins attributed. pub cats: u32, + /// Rows this pass RESOLVED and will never read again, whatever they turned out to be. + pub settled: u32, + /// Rows whose parent could not be read at all, so nothing was learned and nothing marked. + pub unresolved: u32, +} + +/// What a lineage source has to say about a parent coin's spend. +/// +/// The two negative answers are deliberately NOT one variant. They differ in the only way that +/// matters to a caller deciding what to do next: +/// +/// * [`LineageAnswer::Absent`] is a FACT about the chain — it was asked and the spend is not +/// there. Remembering it is sound, because asking again immediately gets the same answer. +/// * [`LineageAnswer::Unavailable`] is a fact about THIS NODE — no source could be reached. +/// Nothing was learned about the chain at all. +/// +/// Collapsing them is how an outage becomes a money-lie: a wallet that caches "we could not reach +/// anyone" as "it does not exist" spends the cache's lifetime confidently refusing to name coins +/// it owns. Every consumer here therefore treats `Unavailable` as *unknown* — never as a negative +/// result, and never as grounds to declare a replica complete. +#[derive(Debug, Clone)] +pub enum LineageAnswer { + /// The parent spend, as read. + Found(Box), + /// A source answered, and there is no such spend at that height. + Absent, + /// No source could answer. Nothing is known either way. + Unavailable, +} + +impl LineageAnswer { + /// The spend if one was found. Discards the [`Absent`](LineageAnswer::Absent) / + /// [`Unavailable`](LineageAnswer::Unavailable) distinction, so it belongs only in callers + /// that genuinely treat the two alike. + pub fn found(self) -> Option { + match self { + Self::Found(spend) => Some(*spend), + _ => None, + } + } + + /// The answer for a spend that was found, or `on_miss` when it was not. + /// + /// # Why the miss answer must be passed in + /// + /// This replaces a `from_answered(Option)` helper that folded every miss to + /// [`Self::Absent`]. Nothing in production ever called it — but it was the constructor every + /// test double reached for, so **every** double in this crate modelled an unresolvable parent + /// as a settled absence while the production source modelled it as unreadable. The suite was + /// therefore structurally unable to reach production's unreadable-parent path by the ordinary + /// route, which is how dig-node#383 survived a review round hunting exactly that class. + /// + /// A fixture map cannot know which of the two a miss represents, so it has to say. Making the + /// caller name it is the whole point: a double that cannot express production's failure mode + /// makes every test over it a partial green. + pub fn from_lookup(spend: Option, on_miss: Self) -> Self { + spend.map_or(on_miss, |s| Self::Found(Box::new(s))) + } } /// Fetches the parent coin's spend for a coin being reconstructed. The production path reads @@ -90,12 +148,13 @@ pub struct ReconstructStats { #[async_trait] pub trait LineageSource: Send + Sync { /// The spend of `parent_coin_id`, which was spent at `spent_height` (= the child's - /// created height). `None` if the parent spend is not available. - async fn parent_spend( - &self, - parent_coin_id: &str, - spent_height: u32, - ) -> Result>; + /// created height). + /// + /// `Err` is reserved for a caller-fatal fault. An unreadable parent is + /// [`LineageAnswer::Unavailable`], not an error: an error here escapes + /// [`reconstruct_coins`] and ends the peer session, handing a denial of service to whoever + /// made the read fail. + async fn parent_spend(&self, parent_coin_id: &str, spent_height: u32) -> Result; } fn hexb(b: Bytes32) -> String { @@ -405,8 +464,31 @@ fn is_candidate(c: &CoinRow, plain_puzzle_hashes: &HashSet) -> bool { /// /// For each **unspent** candidate coin, fetch its parent spend through `lineage`, reconstruct /// it, and write the result: NFT/DID rows are upserted; a CAT coin is attributed to its asset -/// id in the `coins` table (so `get_cats`/`get_token` become complete). Coins whose parent -/// spend is unavailable, or that are not NFT/DID/CAT, are skipped. +/// id in the `coins` table, which is what makes it visible to `get_cats`/`get_token` at all. +/// +/// Neither reader becomes COMPLETE by this: a CAT coin the replica never ingested cannot be +/// attributed by a pass over rows it does not hold, and a row whose parent could not be read is +/// deliberately left for a later pass. The honest claim is that an attributed row is nameable, +/// never that every coin the wallet owns has been attributed. +/// +/// # A row is examined ONCE, and the outcome is what gets remembered +/// +/// A coin's parent spend is immutable chain history, and so is what that spend says the coin is. +/// So a row this pass RESOLVED and could not attribute — an NFT, a DID, an odd-amount plain XCH +/// coin that uncurries to [`Reconstructed::Unknown`] — will answer identically on every future +/// pass, forever, at the cost of one outbound chain read each time. +/// +/// That is not hypothetical: `upsert_nft`/`upsert_did` write the `nfts`/`dids` tables and never +/// touch `coins.asset_id`, so **every NFT and DID the wallet holds** stays a candidate for the +/// life of the replica. A negative cache over the *lookup* cannot help, because these lookups +/// SUCCEED. What has to be remembered is the *attribution outcome*. +/// +/// [`WalletDb::mark_attribution_examined`] records it, so the pass costs one indexed read plus +/// work proportional to **newly-arrived** rows rather than to every row ever synced. +/// +/// A row whose parent could not be READ ([`LineageAnswer::Unavailable`]) is deliberately NOT +/// marked: nothing was learned about it, and marking it would convert a chain-source outage into +/// a permanent refusal to name the wallet's own money. pub async fn reconstruct_coins( db: &WalletDb, lineage: &dyn LineageSource, @@ -425,23 +507,41 @@ pub async fn reconstruct_coins( if !is_candidate(c, plain_puzzle_hashes) { 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, + Ok(LineageAnswer::Found(parent)) => *parent, + // The chain says there is no such spend, and says it with corroboration. That answer + // is stable, so the row is settled and never costs another read. + Ok(LineageAnswer::Absent) => { + db.mark_attribution_examined(&c.coin_id).await?; + stats.settled += 1; + continue; + } + // No source answered, so nothing was learned. The row is left UNMARKED so a later + // pass retries it -- marking it would turn a momentary outage into a permanently + // wrong balance. + Ok(LineageAnswer::Unavailable) => { + stats.unresolved += 1; + continue; + } + // PER-COIN RESILIENCE, kept from dig-node#394 and load-bearing here. + // + // With `LineageAnswer` in place a read failure is `Unavailable` rather than `Err`, so + // this arm is narrower than #394's was -- it now catches a source that answered with + // something unusable (a malformed puzzle reveal, an undecodable hex field) rather than + // an outage. The handling must still not be `?`. Propagating would let ONE malformed + // reply from ONE peer abandon attribution for every remaining coin in the pass, which + // is the same failure #394 removed, re-entered through a narrower door. The row is + // left unmarked, so it is retried like any other thing not yet learned. Err(e) => { tracing::debug!( coin_id = %c.coin_id, error = %e, - "attribution: parent spend unreadable; leaving the coin unattributed" + "attribution: parent spend unusable; leaving the coin unattributed" ); + stats.unresolved += 1; continue; } }; @@ -466,18 +566,28 @@ pub async fn reconstruct_coins( } Reconstructed::Unknown => {} } + // Marked for EVERY resolved outcome, including the CAT one. `asset_id` alone would do + // for a CAT, but making the mark unconditional on "we read the spend and acted on it" + // means no future reconstruction kind can be added that silently re-reads forever. + db.mark_attribution_examined(&c.coin_id).await?; + stats.settled += 1; } Ok(stats) } -/// Convenience: reconstruct every coin currently in the wallet DB. +/// Reconstruct every coin in the wallet DB that a pass could still learn something from. +/// +/// The candidate set is narrowed in SQL — unspent, unattributed, confirmed, and not already +/// examined — rather than by scanning the whole `coins` table in Rust. The scan this replaces +/// was the standing per-frame cost that made a seeded replica an amplifier: one `SELECT *` plus +/// one outbound chain read per stable row, on every push frame, for the life of the process. pub async fn reconstruct_all( db: &WalletDb, lineage: &dyn LineageSource, prefix: &str, plain_puzzle_hashes: &HashSet, ) -> Result { - let coins = db.all_coins().await?; + let coins = db.unexamined_attribution_candidates().await?; reconstruct_coins(db, lineage, prefix, plain_puzzle_hashes, &coins).await } @@ -507,8 +617,13 @@ pub(crate) mod tests { &self, parent_coin_id: &str, _spent_height: u32, - ) -> Result> { - Ok(self.by_parent.get(parent_coin_id).cloned()) + ) -> Result { + // A parent this map does not hold is one the node could not READ — production's + // answer for an unresolvable parent, not a settled absence. + Ok(LineageAnswer::from_lookup( + self.by_parent.get(parent_coin_id).cloned(), + LineageAnswer::Unavailable, + )) } } @@ -898,4 +1013,182 @@ pub(crate) mod tests { odd.amount = "1".into(); assert!(is_candidate(&odd, &phs)); } + + /// A [`LineageSource`] that counts its reads and answers however it was told to. + struct CountingLineage { + answer: LineageAnswerKind, + hits: std::sync::atomic::AtomicUsize, + } + + /// Which answer [`CountingLineage`] gives. Named rather than boolean because the three cases + /// have three different consequences for whether a row may be written off. + #[derive(Clone, Copy)] + enum LineageAnswerKind { + /// A real spend that reconstructs to nothing — the shape of an odd-amount plain XCH coin + /// at the wallet's own p2 hash, and of every NFT/DID coin row after its own table is + /// written. These RESOLVE, which is precisely why a cache over the lookup cannot help. + ResolvesToNothing, + /// The chain answered: no such spend. + Absent, + /// Nothing could be reached. + Unavailable, + } + + #[async_trait] + impl LineageSource for CountingLineage { + async fn parent_spend(&self, _parent: &str, _height: u32) -> Result { + self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(match self.answer { + LineageAnswerKind::ResolvesToNothing => { + LineageAnswer::Found(Box::new(ParentSpend { + // A spend of a coin that creates nothing the drivers recognise, so + // `reconstruct_parsed` falls through to `Unknown`. + coin: Coin { + parent_coin_info: Bytes32::from([1u8; 32]), + puzzle_hash: Bytes32::from([2u8; 32]), + amount: 1, + }, + puzzle_reveal: vec![0x01], + solution: vec![0x80], + })) + } + LineageAnswerKind::Absent => LineageAnswer::Absent, + LineageAnswerKind::Unavailable => LineageAnswer::Unavailable, + }) + } + } + + fn counting(answer: LineageAnswerKind) -> CountingLineage { + CountingLineage { + answer, + hits: std::sync::atomic::AtomicUsize::new(0), + } + } + + /// An unattributed, unspent, confirmed coin at an UNSUBSCRIBED puzzle hash — a candidate on + /// every pass until something settles it. + fn candidate_row(n: u8) -> CoinRow { + CoinRow { + coin_id: hex::encode([n; 32]), + parent_coin_info: hex::encode([n.wrapping_add(100); 32]), + puzzle_hash: hex::encode([n.wrapping_add(200); 32]), + amount: "1".into(), + created_height: Some(5), + spent_height: None, + asset_id: None, + hint: None, + created_timestamp: None, + spent_timestamp: None, + } + } + + /// **Proves (dig-node#383):** a row whose parent RESOLVES but which cannot be attributed is + /// read exactly once, however many passes run. + /// + /// # Why this fixture and not an unresolvable parent + /// + /// The nearest wrong implementation is the one that shipped: a negative cache over the LOOKUP. + /// It makes an *unresolvable* parent free and is structurally unable to help here, because + /// this lookup succeeds. Every NFT and DID the wallet holds has this shape — the reconstructed + /// row is written to its own table and `coins.asset_id` stays NULL — so choosing a resolving + /// parent is what distinguishes remembering the OUTCOME from remembering the lookup. + /// + /// Ten passes rather than two, so a fix that merely halves the work fails as loudly as one + /// that does nothing. + #[tokio::test] + async fn a_resolving_but_unattributable_row_is_read_once_not_once_per_pass() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_coins(&[candidate_row(1)]).await.unwrap(); + let lineage = counting(LineageAnswerKind::ResolvesToNothing); + let plain = HashSet::new(); + + for _ in 0..10 { + reconstruct_all(&db, &lineage, "xch", &plain).await.unwrap(); + } + + assert_eq!( + lineage.hits.load(std::sync::atomic::Ordering::SeqCst), + 1, + "ten passes over one settled row must cost one read; anything more is a per-frame \ + outbound chain read for the life of the replica" + ); + } + + /// **Proves (dig-node#383):** a reported ABSENCE settles the row too — asking again gets the + /// same answer, so paying for it again buys nothing. + #[tokio::test] + async fn a_reported_absence_settles_the_row() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_coins(&[candidate_row(2)]).await.unwrap(); + let lineage = counting(LineageAnswerKind::Absent); + let plain = HashSet::new(); + + for _ in 0..10 { + reconstruct_all(&db, &lineage, "xch", &plain).await.unwrap(); + } + + assert_eq!( + lineage.hits.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a spend the chain says does not exist is settled chain history, not a retry" + ); + } + + /// **Proves (dig-node#383, D3):** an UNREADABLE parent is NOT settled, so a chain-source + /// outage cannot write a coin off. + /// + /// The control that makes this test load-bearing is its sibling above: identical traffic, + /// identical row, and the only difference is whether the source answered. If the mark were + /// applied on every pass rather than on a resolved one, this would report 1 like the others — + /// and the wallet would spend the rest of the replica's life refusing to look at a coin it + /// failed to read once. + #[tokio::test] + async fn an_unreadable_parent_is_retried_rather_than_written_off() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_coins(&[candidate_row(3)]).await.unwrap(); + let lineage = counting(LineageAnswerKind::Unavailable); + let plain = HashSet::new(); + + for _ in 0..10 { + reconstruct_all(&db, &lineage, "xch", &plain).await.unwrap(); + } + + assert_eq!( + lineage.hits.load(std::sync::atomic::Ordering::SeqCst), + 10, + "nothing was learned about this parent, so every pass must ask again; marking it \ + would turn a transient outage into a permanent wrong balance" + ); + } + + /// **Proves (dig-node#383):** the pass's cost tracks NEWLY-ARRIVED rows, not the size of the + /// replica. + /// + /// This is the property the deferral of the per-frame scan was wrongly assumed to already + /// have. Twenty settled rows plus one new one costs one read, not twenty-one — the difference + /// between a scan a peer can re-trigger for the price of an empty frame and one it cannot. + #[tokio::test] + async fn a_later_pass_pays_only_for_what_newly_arrived() { + let db = WalletDb::open_in_memory().await.unwrap(); + let seeded: Vec = (10..30).map(candidate_row).collect(); + db.upsert_coins(&seeded).await.unwrap(); + let lineage = counting(LineageAnswerKind::ResolvesToNothing); + let plain = HashSet::new(); + + reconstruct_all(&db, &lineage, "xch", &plain).await.unwrap(); + assert_eq!( + lineage.hits.load(std::sync::atomic::Ordering::SeqCst), + 20, + "the first pass must genuinely examine all twenty, or the second pass proves nothing" + ); + + db.upsert_coins(&[candidate_row(40)]).await.unwrap(); + reconstruct_all(&db, &lineage, "xch", &plain).await.unwrap(); + + assert_eq!( + lineage.hits.load(std::sync::atomic::Ordering::SeqCst), + 21, + "the second pass must pay for the one new row only" + ); + } } diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index c02c5e28..99eaf71f 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -793,7 +793,11 @@ pub async fn apply_coin_states( /// with an even amount is an ordinary XCH coin and is skipped (never fetches a parent /// spend). Attribution reads only; it never signs or broadcasts. pub struct CatAttributor<'a> { - /// The parent-spend source (coinset/peer point-read) uncurrying reads through. + /// The parent-spend source (coinset/peer point-read) the whole-replica pass reads through. + /// + /// ONE source, unmetered, and deliberately so: this pass runs on the node's own schedule over + /// rows the replica already holds, so its volume is set by what has newly arrived rather than + /// by anything a remote peer chooses to send. pub lineage: &'a dyn LineageSource, /// The address bech32m prefix for any reconstructed NFT/DID addresses. pub prefix: &'a str, @@ -883,13 +887,13 @@ pub async fn handle_coin_state_update( update: &CoinStateUpdate, events: &EventBus, session: &mut SessionState<'_>, -) -> Result<(), SyncError> { +) -> Result { if !session.authority.trust().is_authoritative() { tracing::debug!( claimed_height = update.height, "wallet sync: dropping a coin_state_update from a discovered peer" ); - return Ok(()); + return Ok(FrameApplied::Dropped); } // Judged BEFORE the frame acts, not at the write. A guard sitting on the `set_peak` call would // satisfy "the peak is unchanged" identically while the rollback below had already deleted @@ -898,7 +902,7 @@ pub async fn handle_coin_state_update( // coins included, which is also the conservative reading of coins offered alongside one. let admitted = match session.admit_peak(update.height)? { PeakClaim::Admitted(peak) => peak, - PeakClaim::Refused => return Ok(()), + PeakClaim::Refused => return Ok(FrameApplied::Dropped), }; let current_peak = db.sync_state().await?.peak_height; let mut moved_backwards = false; @@ -952,7 +956,23 @@ pub async fn handle_coin_state_update( ); } events.publish(SyncEvent::CoinState); - Ok(()) + Ok(FrameApplied::Applied) +} + +/// Whether a `coin_state_update` frame reached the database at all. +/// +/// Returned so the caller can decide whether the follow-up attribution pass is worth running. A +/// frame dropped for coming from a discovered peer, or for claiming a peak this session will not +/// admit, changed nothing — so a pass after it can only re-examine rows an earlier pass already +/// settled. Without the distinction, an *empty, refused* frame from an untrusted peer still buys a +/// whole-replica scan and a chain read per candidate row, which is roughly forty bytes on the wire +/// for an unbounded amount of this node's work. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameApplied { + /// The frame's coins and peak were written. + Applied, + /// The frame was refused before any database write. + Dropped, } /// The one peer call [`initial_sync_with_authority`] makes, behind a trait. @@ -1182,9 +1202,12 @@ pub async fn run_update_loop( match message.msg_type { ProtocolMessageTypes::CoinStateUpdate => { if let Ok(update) = decode::(&message) { - handle_coin_state_update(db, &update, events, session).await?; - if let Some(a) = attributor { - a.attribute(db).await?; + let applied = handle_coin_state_update(db, &update, events, session).await?; + // Only after a frame that actually WROTE something. See [`FrameApplied`]. + if applied == FrameApplied::Applied { + if let Some(a) = attributor { + a.attribute(db).await?; + } } } } @@ -2813,7 +2836,7 @@ mod tests { /// CAT can be attributed. (The full uncurry→`get_cats` path is proven in `sage::rpc`.) #[tokio::test] async fn run_update_loop_runs_attribution_when_attributor_present() { - use crate::sage::singleton::{LineageSource, ParentSpend}; + use crate::sage::singleton::{LineageAnswer, LineageSource}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -2826,9 +2849,9 @@ mod tests { &self, _parent_coin_id: &str, _spent_height: u32, - ) -> crate::sage::Result> { + ) -> crate::sage::Result { self.hits.fetch_add(1, Ordering::SeqCst); - Ok(None) + Ok(LineageAnswer::Absent) } } @@ -2877,6 +2900,110 @@ mod tests { ); } + /// **Proves (dig-node#383):** a frame the node REFUSED schedules no attribution pass. + /// + /// # The fixture varies one actor and keeps an honest control + /// + /// Two runs over the same replica, the same seeded candidate row and the same frame. Only the + /// session's trust differs. An operator session MUST run the pass — that is the control, and + /// without it "zero reads" would be satisfied by an attributor that was simply never wired, or + /// by a fixture that presented no candidate row to read. A discovered session must not, because + /// its frame is dropped before any database write. + /// + /// The frame is EMPTY on purpose. An empty, refused frame is the cheapest thing on the wire, so + /// a peer that gets a whole-replica pass for it has an amplifier however the pass is bounded. + #[tokio::test] + async fn a_refused_frame_schedules_no_attribution_pass() { + use crate::sage::singleton::{LineageAnswer, LineageSource}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + struct CountingLineage { + hits: Arc, + } + #[async_trait::async_trait] + impl LineageSource for CountingLineage { + async fn parent_spend( + &self, + _parent_coin_id: &str, + _spent_height: u32, + ) -> crate::sage::Result { + self.hits.fetch_add(1, Ordering::SeqCst); + Ok(LineageAnswer::Absent) + } + } + + /// Run one empty `coin_state_update` through the production loop and report how many + /// parent-spend reads the seeded candidate row cost. + async fn reads_for_one_empty_frame(authoritative: bool) -> usize { + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_peak(10, "aa").await.unwrap(); + // One unattributed, unspent, confirmed coin at an unsubscribed hash: a candidate the + // pass will want to read the moment it runs. + db.upsert_coins(&[CoinRow { + coin_id: hex::encode([9u8; 32]), + parent_coin_info: hex::encode([8u8; 32]), + puzzle_hash: hex::encode([7u8; 32]), + amount: "1".into(), + created_height: Some(5), + spent_height: None, + asset_id: None, + hint: None, + created_timestamp: None, + spent_timestamp: None, + }]) + .await + .unwrap(); + + let events = EventBus::with_capacity(8); + let (tx, receiver) = tokio::sync::mpsc::channel::(4); + let update = CoinStateUpdate { + height: 11, + fork_height: 10, + peak_hash: Bytes32::new([2; 32]), + items: vec![], + }; + tx.send(Message { + msg_type: ProtocolMessageTypes::CoinStateUpdate, + id: None, + data: chia_traits::Streamable::to_bytes(&update).unwrap().into(), + }) + .await + .unwrap(); + drop(tx); + + let hits = Arc::new(AtomicUsize::new(0)); + let lineage = CountingLineage { hits: hits.clone() }; + let plain = HashSet::new(); + let attributor = CatAttributor { + lineage: &lineage, + prefix: "xch", + plain_puzzle_hashes: &plain, + }; + let subscribed = subscribed_owned(); + let mut session = if authoritative { + operator(&subscribed) + } else { + discovered(&subscribed) + }; + run_update_loop(&db, receiver, &events, Some(&attributor), &mut session) + .await + .unwrap(); + hits.load(Ordering::SeqCst) + } + + assert_eq!( + reads_for_one_empty_frame(true).await, + 1, + "control: an APPLIED frame runs the pass, so the fixture really does present a candidate row that costs a read" + ); + assert_eq!( + reads_for_one_empty_frame(false).await, + 0, + "a frame dropped before any database write must schedule no work at all; running the pass after it lets a peer buy a whole-replica scan for an empty frame it already knows will be refused" + ); + } + // --------------------------------------------------------------------------------------- // The corroborated peak ceiling (dig_ecosystem#2851). // --------------------------------------------------------------------------------------- @@ -3231,7 +3358,7 @@ mod tests { db: &WalletDb, session: &mut SessionState<'_>, update: CoinStateUpdate, - ) -> Result<(), SyncError> { + ) -> Result { handle_coin_state_update(db, &update, &EventBus::default(), session).await } diff --git a/crates/dig-wallet/src/sage/sync_supervisor.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index fbf90ed5..3743818d 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -44,7 +44,7 @@ //! from custody's persisted PUBLIC keys, which are readable while every wallet is locked. No //! seed is touched and nothing here can sign. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; use std::net::SocketAddr; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; @@ -58,6 +58,7 @@ use super::custody::WalletCustody; use super::db::WalletDb; use super::events::EventBus; use super::quorum::{self, Verdict}; +use super::singleton; use super::sync::{self, PeerTrust, SyncError}; use super::watchlist::WatchRegistry; @@ -842,11 +843,18 @@ pub trait SyncSession: Send + Sync { /// `session` carries the set this session subscribed in [`SyncSession::catch_up`] (pushed /// coins outside it are dropped, because a peer answers a subscription rather than defining /// one), its peer's trust level, and its rollback allowance. + /// + /// `attributor` is the CAT/singleton attribution pass the supervisor built for this session + /// (`None` when no lineage source is attached). It is threaded through the trait rather than + /// constructed by the session because only the supervisor knows the wallet's plain p2 puzzle + /// hashes, and an implementation that drops it silently reintroduces dig-node#382 — every CAT + /// row keeping `asset_id = NULL`, so a funded wallet reports a $DIG balance of zero. async fn run( self: Box, db: &WalletDb, events: &EventBus, session: &mut sync::SessionState<'_>, + attributor: Option<&sync::CatAttributor<'_>>, ) -> Result<(), SyncError>; } @@ -1231,6 +1239,34 @@ impl StallWatch { } } +/// The read-only inputs a CAT/singleton attribution pass needs, held for the life of the +/// supervisor so every session it opens attributes the coins that session syncs. +/// +/// A `CoinState` frame does not carry a CAT's TAIL, so a coin a peer pushes is stored with +/// `asset_id = NULL` and the id is recovered afterwards by uncurrying the parent spend +/// ([`sync::CatAttributor`], design B.6). Without this the recovery never runs and +/// `unspent_coins(Some(asset))` matches nothing on a funded wallet (dig-node#382). +/// +/// The wallet's plain p2 puzzle hashes are deliberately NOT a field: they change as wallets are +/// created, unlocked and watched, so the supervisor takes them from the subscription set it +/// resolved for the current attempt rather than from a snapshot taken at boot. +pub struct Attribution { + /// Resolves a parent coin's spend for the background whole-replica pass. Production passes + /// the shared `chia_query` lineage source, so attribution reads through the SAME peer pool as + /// every other chain read. + lineage: Arc, + /// The address bech32m prefix used for any reconstructed NFT/DID addresses. + prefix: String, +} + +impl Attribution { + /// Take `lineage` as the background pass's parent-spend source and `prefix` for any + /// reconstructed NFT/DID addresses. + pub fn new(lineage: Arc, prefix: String) -> Self { + Self { lineage, prefix } + } +} + /// Everything the supervisor loop needs. Assembled by [`spawn_supervisor`]. pub struct Supervisor { /// The local replica the session writes into. @@ -1262,6 +1298,13 @@ pub struct Supervisor { /// "the check ran and found nothing" must not look the same. A supervisor built without one /// behaves exactly as it did before this field existed. pub chain_tip: Option>, + /// The CAT/singleton attribution inputs, or `None` for no attribution at all. + /// + /// `None` is honest and load-bearing rather than a silent default: a supervisor with no + /// lineage source cannot uncurry a parent spend, and "attribution is switched off" must not + /// look like "attribution ran and found nothing" — the same distinction [`Self::corroborator`] + /// draws. Production attaches one whenever a chain source exists. + pub attribution: Option>, /// How long one session runs before it is retired. Production passes /// [`SESSION_MAX_LIFETIME`]; see that constant for the costs the number trades. /// @@ -1355,6 +1398,17 @@ impl Supervisor { 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(); + // Built per ATTEMPT, from the set this attempt actually resolved: these are the hashes + // an ordinary XCH holding sits at, and an even-amount coin at one of them is skipped + // rather than costing a parent-spend read. A snapshot taken at boot would be empty on + // every install whose wallet is created after start-up. + let plain_puzzle_hashes: HashSet = + puzzle_hashes.iter().map(hex::encode).collect(); + let attributor = self.attribution.as_ref().map(|a| sync::CatAttributor { + lineage: a.lineage.as_ref(), + prefix: &a.prefix, + plain_puzzle_hashes: &plain_puzzle_hashes, + }); // The MEASUREMENT of the subscription set. Paired with the trust recorded above, the // phase can now tell "custody holds nothing" from "this writer was refused" — the two // produce an identical empty set here, and only the first is the benign @@ -1368,7 +1422,11 @@ impl Supervisor { // empty puzzle-hash set, `new_peak_wallet` needs no subscription and the // replica's peak keeps advancing. A DISCOVERED peer subscribes nothing AND // writes nothing — its frames are dropped by `handle_coin_state_update` and - // `run_update_loop` before any DB write, including the peak. In both cases + // `run_update_loop` before any DB write, including the peak, AND before the + // attribution pass — which is load-bearing rather than incidental: with the pass + // sitting unconditionally after the drop, a discovered peer's empty, already-refused + // frames still bought a whole-replica scan and a chain read per candidate row, so + // "writes nothing" was true while "costs nothing" was not. In both cases // `is_synced()` stays false — the truth, and what keeps wallet-scoped reads on // the fallback tier. The session is also re-polled below, so a wallet created // after boot is subscribed within seconds rather than at the next disconnect. @@ -1442,6 +1500,23 @@ impl Supervisor { catch_up_ms = self.time.now().duration_since(began).as_millis(), "wallet sync: catch-up complete" ); + // The catch-up replays the wallet's whole coin history, and every CAT row it + // wrote is unattributed. Running the pass HERE and not only inside the update + // loop is what makes a quiet wallet correct: a replica that syncs from genesis + // and then receives no further pushes would otherwise hold its $DIG for ever + // without ever being able to name it (dig-node#382). + // + // Best-effort by design. Attribution reads the chain, and a read failure must + // never turn a completed catch-up into a failed session — the pass is idempotent + // and the update loop retries it on the next applied frame. + if let Some(a) = &attributor { + if let Err(e) = a.attribute(&self.db).await { + tracing::warn!( + error = %e, + "wallet sync: post-catch-up CAT attribution failed; retrying on the next update" + ); + } + } } // Taken before the session is moved into `run` below, so the stall and recovery lines @@ -1453,7 +1528,7 @@ impl Supervisor { 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) => { + result = session.run(&self.db, &self.events, &mut state, attributor.as_ref()) => { if let Err(e) = result { tracing::warn!(error = %e, "wallet sync: update loop ended in error"); } @@ -2354,6 +2429,7 @@ impl SyncSession for ChiaPeerSession { db: &WalletDb, events: &EventBus, session: &mut sync::SessionState<'_>, + attributor: Option<&sync::CatAttributor<'_>>, ) -> Result<(), SyncError> { let receiver = self .receiver @@ -2361,7 +2437,12 @@ impl SyncSession for ChiaPeerSession { .await .take() .ok_or_else(|| SyncError::Peer("sync session already consumed".into()))?; - sync::run_update_loop(db, receiver, events, None, session).await + // Forwarded, never re-decided here. This argument was a hard-coded `None` for the whole + // life of the production sync path (dig-node#382): the attribution pass existed, was + // correct and was unit-tested, and was simply never reached by the only call site that + // ships. Naming the parameter is what makes dropping it a compile error rather than an + // invisible default. + sync::run_update_loop(db, receiver, events, attributor, session).await } } diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 1f470861..364892e8 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -313,9 +313,13 @@ impl SyncSession for ScriptedSession { db: &WalletDb, events: &EventBus, session: &mut sync::SessionState<'_>, + attributor: Option<&sync::CatAttributor<'_>>, ) -> Result<(), SyncError> { let receiver = self.receiver.lock().await.take().expect("run called once"); - let result = sync::run_update_loop(db, receiver, events, None, session).await; + // Forwarded exactly as `ChiaPeerSession::run` forwards it. Substituting `None` here would + // make this double structurally incapable of observing dig-node#382, which is the defect + // the double is used to prove absent. + let result = sync::run_update_loop(db, receiver, events, attributor, session).await; let lifetime = *self.script.session_lifetime.lock().unwrap(); self.script.advance(lifetime); result @@ -588,6 +592,34 @@ impl Harness { corroborator: Option>, chain_tip: Option>, session_lifetime: Duration, + ) -> Self { + Self::start_with_attribution( + db, + hashes, + script, + addrs, + trust, + corroborator, + chain_tip, + session_lifetime, + None, + ) + .await + } + + /// Start a supervisor with a CAT/singleton attribution source attached — the production + /// shape, and the only one that can observe dig-node#382. + #[allow(clippy::too_many_arguments)] + async fn start_with_attribution( + db: WalletDb, + hashes: Arc, + script: Arc