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", 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..9249ccfc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5405,6 +5405,126 @@ 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, #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 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. + +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 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 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`. +- 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 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. +- **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; 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 + 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 + 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. + +**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. + + 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 new file mode 100644 index 00000000..785c143f --- /dev/null +++ b/crates/dig-wallet/src/sage/cat_discovery.rs @@ -0,0 +1,1597 @@ +//! 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, 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, Result}; + +/// How many staged coins one promotion pass will read parent spends for. +/// +/// 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. +/// +/// 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 +/// 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( + states: &[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 +} + +/// 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 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, + 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; + } + // 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. + 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: 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) +} + +/// 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 { + /// 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. + pub deferred: u32, +} + +/// Promote every staged coin a parent spend proves, and refuse every one it disproves. +/// +/// # 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. +/// - **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. +/// +/// # 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, + 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 + // 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, retry_cutoff) + .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, 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; + }; + let parent = match lineage + .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) => { + 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; + } + }; + 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; + } + // 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) +} + +/// 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 { + "xch" +} + +/// What deciding one coin's promotion concluded. +/// +/// 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, + 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)?; + // 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(Promotion::Disproven); + } + let reconstructed = + singleton::reconstruct(prefix, row.created_height.map(|h| h as u32), parent, child)?; + 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 + // 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, + reconstructed_asset = %asset_id, + derived_asset = %row.derived_asset_id, + "cat promotion: the parent spend disagrees with the derivation; refusing" + ); + return Ok(Promotion::Disproven); + } + // Attributed from the RECONSTRUCTION's own values, which is the whole content of the proof. + 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 — +/// [`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, + } +} + +#[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, + } + } + + /// 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. + /// + /// 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, &owned()).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, &owned()).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(), &owned()) + .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, &owned()) + .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, &owned()).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, &owned()).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" + ); + // 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:?}"); + + // 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, &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 + // 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() + .await + .unwrap() + .iter() + .any(|c| c.coin_id != hex::encode(f.child.coin_id())), + "no unproven coin may have entered `coins` at any point" + ); + } + + /// **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, &owned()).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, &owned()).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" + ); + } + + /// **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. + /// + /// 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, i64::MAX) + .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, 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())); + } + + /// 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 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 + /// 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 ff4d166a..bd7f8221 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,63 @@ 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; + +/// 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), +} + +/// 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 +418,28 @@ 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, + 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, idx INTEGER NOT NULL, @@ -563,6 +643,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 --------------------------------------- @@ -1578,6 +1662,283 @@ 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 promotion pass's work queue: up to `limit` staged rows, FEWEST ATTEMPTS FIRST, and + /// only rows not tried since `retry_cutoff`. + /// + /// # 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 + 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 + /// 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> { + 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 { + query = query.bind(Self::normalise_hex(id)); + } + 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 + /// 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. + /// + /// # 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 { + 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, + 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?; + tx.commit().await?; + 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 + /// 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; @@ -1610,6 +1971,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 > ?", @@ -2611,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, @@ -2634,7 +3036,7 @@ impl WalletDb { .bind(n.visible) .bind(n.created_height) .bind(&n.record_json) - .execute(&self.pool) + .execute(exec) .await?; Ok(()) } @@ -2696,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 (?, ?, ?, ?, ?, ?) @@ -2711,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/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/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/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index cdff70d4..24acf6a9 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -3103,14 +3103,81 @@ 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() { + // 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. + // 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.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" + ), + 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) @@ -8534,6 +8601,134 @@ 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] @@ -9453,8 +9648,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/singleton.rs b/crates/dig-wallet/src/sage/singleton.rs index 68e25a05..4921658a 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; @@ -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,39 @@ 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. + // 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(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. @@ -386,19 +425,33 @@ pub async fn reconstruct_coins( if !is_candidate(c, plain_puzzle_hashes) { continue; } - let Some(parent) = lineage + // 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? - else { - continue; + .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)? { - 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; } @@ -429,7 +482,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; @@ -492,16 +545,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); @@ -552,25 +600,177 @@ 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, + } + + /// 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 (_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()]); @@ -581,8 +781,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")); } @@ -639,7 +844,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). diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index b2dc7031..c02c5e28 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(()) } @@ -735,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, self.plain_puzzle_hashes).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) @@ -823,7 +927,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 @@ -898,14 +1002,29 @@ 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 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, 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 @@ -928,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 { @@ -957,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(|_| { @@ -987,7 +1129,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 { @@ -999,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(()); @@ -1191,6 +1337,320 @@ 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 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 + /// 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(); @@ -1198,7 +1658,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); @@ -1209,14 +1669,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()) - .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()) - .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); } @@ -1238,6 +1708,7 @@ mod tests { state(coin(2, OWNED, 7_000), Some(20), None), ], &subscribed, + &DerivedCats::default(), ) .await .unwrap(); @@ -1253,6 +1724,7 @@ mod tests { state(coin(2, OWNED, 7_000), Some(20), None), ], &subscribed, + &DerivedCats::default(), ) .await .unwrap(); @@ -1357,6 +1829,7 @@ mod tests { &db, &[state(coin(1, 9, 5), Some(10), Some(30))], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); @@ -1388,6 +1861,7 @@ mod tests { &db, &[state(coin(1, 9, 5), Some(10), None)], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); @@ -1429,6 +1903,7 @@ mod tests { None, )], &subscribed_owned(), + &DerivedCats::default(), ) .await .unwrap(); @@ -1717,6 +2192,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Operator, + &DerivedCats::default(), ), ) .await @@ -1757,6 +2233,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 +2304,7 @@ mod tests { "127.0.0.1", &events, WriteAuthority::Operator, + &DerivedCats::default(), ) .await .expect("a non-empty subscription set catches up normally"); @@ -1933,6 +2411,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 +2482,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 +2788,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 +2918,7 @@ mod tests { "1.2.3.4", &events, authority, + &DerivedCats::default(), ) .await } @@ -2885,6 +3367,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.rs b/crates/dig-wallet/src/sage/sync_supervisor.rs index c394e438..fbf90ed5 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor.rs @@ -53,6 +53,7 @@ use chia_bls::PublicKey; use chia_protocol::Bytes32; use chia_puzzle_types::standard::StandardArgs; +use super::cat_discovery::DerivedCats; use super::custody::WalletCustody; use super::db::WalletDb; use super::events::EventBus; @@ -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 @@ -792,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 @@ -808,10 +830,11 @@ 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, + derived: &DerivedCats, ) -> Result<(), SyncError>; /// Consume peer pushes until the peer disconnects. Consumes the session. @@ -1320,6 +1343,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 +1383,19 @@ 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. + // 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, - puzzle_hashes, + puzzle_hashes.clone(), 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 +1450,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 { @@ -2282,21 +2322,23 @@ impl SyncSession for ChiaPeerSession { async fn catch_up( &self, db: &WalletDb, - puzzle_hashes: Vec, + addresses: Vec, 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. sync::initial_sync_with_authority( &self.peer, db, - puzzle_hashes, + addresses, genesis_challenge, &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..1f470861 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; @@ -42,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![], @@ -65,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 @@ -258,16 +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, ) -> 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. @@ -276,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, @@ -286,6 +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, + // 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 } @@ -872,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], expected, - "the subscribed set must be exactly the custodied p2 puzzle hashes" + "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(), @@ -1031,7 +1058,12 @@ async fn a_wallet_created_after_boot_is_subscribed_without_waiting_for_a_disconn assert_eq!( h.script.catch_ups.lock().unwrap()[0], vec![created], - "the catch-up must subscribe exactly the newly-created wallet's hash" + "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; @@ -1144,6 +1176,32 @@ 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. +/// +/// 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 + .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`. /// 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..8b621217 --- /dev/null +++ b/crates/dig-wallet/tests/real_wallet_cat_discovery.rs @@ -0,0 +1,222 @@ +//! 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" + ); + + // 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 { + 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, &std::collections::HashSet::new()) + .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()) + } +}