From f013e0cc33ee942317cef7496d09586aaf10923e Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:33:58 +0300 Subject: [PATCH 1/2] fix(platform-wallet-storage): durably apply swept transactions in the SQLite store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teaches the store the one subtractive part of a changeset. `apply_sweep` runs last in `apply`, batch by batch in emission order, so a later batch's decision to keep a coin spent survives an earlier batch's decision to free it — the order the wallet itself applied them in. Per swept transaction: the record row and every output it created go, its InstantSend lock row goes with it (nothing else ties that table to `core_transactions`), and a co-swept parent's outputs are removed even when the parent has no row of its own. Per released outpoint: the coin is freed unless a surviving stored record still claims it — asked as "does any unpruned row still claim this outpoint", upstream's own `retain_unclaimed` predicate, rather than the unanswerable "which transaction set this spent mark". The veto counts only network-final claimants: a bare mempool row can go stale forever, and letting one veto an authoritative release is the mirror image of the bug this fixes. Every input the release does NOT name keeps a durable claim, as a zero-value placeholder row when its funding output has never been seen, so a coin cannot come back unspent after a restart merely because the store never saw where it came from. `winner_mined_height` decides a placeholder's lifetime and never its existence. A block-context sweep stamps the winner's own height and the row is collectible once `min(chainlock, synced)` reaches it — upstream's `prune_finalized_observed_spends` boundary verbatim. An IS-locked winner that is not yet mined leaves the row UNSTAMPED and uncollectible: the lock alone settles the input under DIP-10, and no watermark can ever prove an unmined winner's funding delivered-or-never. V007 adds the stamp column; `spent_in_txid` needed no migration (V001 has it) and the new upsert valve is a no-op on every existing database, since `apply_sweep` is its only writer. The store declares `CORE_SWEEP_REMOVAL` and `DASHPAY_PAYMENTS`. Both are inert here — nothing emits a sweep until the producer lands, and the payments bit attests the overlay writer this crate already shipped. A sweep whose typed key disagrees with its stored record fails the round closed before anything is deleted: that row sits in the one gap where neither reader sees the other's evidence, and processing it would manufacture the double spend the veto exists to stop. Tests: 34 in `tests/sqlite_transaction_sweeps.rs`, all driving `core_state::apply` on hand-built changesets with no producer involved — release-versus-claim, co-swept twins, chained and repointed tombstones, collection boundaries, multi-wallet independence, corrupt-row refusals, and durability across a reopen. Known exposure, documented at the placeholder site and deferred by agreement: a swept loser's foreign inputs cannot be told from wallet-owned ones, so an unmined winner's placeholders are not collectible. rust-dashcore#968 tracks the upstream half. --- packages/rs-platform-wallet-storage/SCHEMA.md | 16 +- .../V007__utxo_sweep_winner_height.rs | 55 + .../src/sqlite/persister.rs | 16 + .../src/sqlite/schema/asset_locks.rs | 17 +- .../src/sqlite/schema/core_state.rs | 652 +++- .../tests/sqlite_transaction_sweeps.rs | 3233 +++++++++++++++++ 6 files changed, 3956 insertions(+), 33 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 8149fb16e23..fd28bacdce4 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -93,7 +93,7 @@ erDiagram INTEGER height "NULL if unconfirmed" INTEGER account_index INTEGER spent "0 | 1" - BLOB spent_in_txid "NULL until spend; cleared by trigger on tx delete" + BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" } CORE_INSTANT_LOCKS { @@ -381,10 +381,16 @@ is `1` once block context is present. ### `core_utxos` -One row per UTXO, spent or unspent. `spent_in_txid` is set to NULL -by a trigger when its referenced `core_transactions` row is deleted -(instead of a native `ON DELETE SET NULL`, which would also null the -NOT NULL `wallet_id` column). +One row per UTXO, spent or unspent. `spent_in_txid` is written only by +`apply_sweep`, naming the winner that took an input a swept loser claimed +but this store had no released record for. Its presence gates the funding +UTXO's own later upsert (`execute_upsert_utxo`): a coin held spent with a +`spent_in_txid` stays spent when the wallet redelivers it, unlike a coin +held spent with none (the ordinary "sweep couldn't resolve it" state, which +does clear on redelivery). It is set to NULL by a trigger when its +referenced `core_transactions` row is deleted (instead of a native +`ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` +column) — and by a later sweep that releases the same outpoint. - PK: `(wallet_id, outpoint)`. - FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. diff --git a/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs b/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs new file mode 100644 index 00000000000..a2de745804f --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs @@ -0,0 +1,55 @@ +//! Anchor sweep tombstones at their winner's mined height and pin the +//! chainlock finality boundary. +//! +//! `core_utxos.winner_mined_height` is the mined height of the +//! transaction that beat an unmaterialised sweep tombstone's outpoint — +//! the placeholder row `apply_sweep` writes for a held input whose +//! funding output has never classified (`height IS NULL AND spent = 1`; +//! no other writer leaves `height` NULL). The height is carried on the +//! sweep event itself (`TransactionsSwept::winner_mined_height`), and +//! `apply_sweep` writes the placeholder for EVERY non-released held +//! input in EVERY sweep context — only the stamp differs. A +//! block-context sweep (winner actually mined) stamps the winner's +//! height, and the collector in `core_state::apply` evicts the row +//! exactly when `min(chainlock_height, synced_height)` reaches that +//! height — `prune_finalized_observed_spends`' condition verbatim, with +//! no observation-age margin. An InstantSend-locked, unmined winner +//! writes the same row with the stamp NULL — under DIP-10 its lock +//! alone settles the input, but it carries no height to key a lifetime +//! on — and the collector never takes an unstamped row: it resolves +//! only through proof, when the funding upsert materialises it, a later +//! block-context sweep re-stamps it into the collectible set, or a +//! release deletes it. See `CORE_SWEEP_REMOVAL` and the `apply_sweep` +//! doc in `core_state.rs` for why an unstamped hold must survive (it is +//! the only durable carrier of upstream's in-memory `spent_outpoints` +//! hold across a restart) and what bounds the foreign-input residue. +//! +//! `core_sync_state.chainlock_height` is the monotonic-max height of +//! the last applied chainlock, mirrored from +//! `CoreChangeSet::last_applied_chain_lock` (previously dropped by +//! this store). It is one half of the collector's finality boundary; +//! rows are never collected before a chainlock has been persisted, +//! matching upstream's "no-op until a chainlock has been applied". +//! +//! The partial index covers exactly the unmaterialised rows — the +//! collector's scan set is the stamped subset of these — so the +//! per-round sweep touches tombstones only, not the wallet's full +//! spent history. +//! +//! Edited in place (formerly `V006__utxo_tombstone_stamp`, column +//! `held_since_height`) under the same pre-release policy V001's test +//! documents: nothing shipped has applied this migration, and a dev +//! database that did apply the old shape fails refinery's divergence +//! check and must be recreated. Renumbered `V006` → `V007` when the +//! mainline's `V006__tracked_masternodes` merged in ahead of this +//! unmerged branch: version numbers, like capability bits, are +//! append-only and the already-merged assignment keeps its slot. + +pub fn migration() -> String { + "ALTER TABLE core_utxos ADD COLUMN winner_mined_height INTEGER; + ALTER TABLE core_sync_state ADD COLUMN chainlock_height INTEGER; + CREATE INDEX idx_core_utxos_unmaterialized + ON core_utxos(wallet_id, winner_mined_height) + WHERE height IS NULL;" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d784f344ecf..4cc05bd05d2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -829,6 +829,20 @@ impl PlatformWalletPersistence for SqlitePersister { // Do NOT attest WALLET_RESTORE (and therefore not provider restore): // `load()` still reports `ClientStartState::wallets` in // `LOAD_UNIMPLEMENTED`. Shielded state lives in a separate store. + // `core_state::apply_sweep` deletes the loser row and resolves every + // input it claimed via `released` — including the held-but-unfunded + // case, where it leaves a `core_utxos` placeholder keyed by outpoint + // rather than by any relationship to the loser. That is what makes a + // later sweep of the winner that replaces it chain-safe with no + // extra bookkeeping: the next sweep matches the same outpoint + // directly — through the loser's decoded inputs when its row is on + // hand, and through the batch's own released set when it is not — + // so it repoints or releases the placeholder regardless of how many + // sweeps deep it is. A placeholder that never materialises is + // bounded, not permanent: `core_state::collect_finalized_tombstones` + // evicts it once the persisted chainlock finality boundary passes + // its creation stamp, so foreign-input junk from swept incoming + // payments cannot grow the store without limit. PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -837,6 +851,8 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) + .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) } fn persist_tracked_masternodes( diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index b0c21a58a47..216897725fd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -73,10 +73,19 @@ pub fn apply( if !cs.removed.is_empty() { // Same terminal rule as the upsert guard: a stored `consumed` // row is never deleted by a stale tombstone. Consumed rows are - // deliberately retained for historical lookup, and the only - // removal emitter (`untrack_asset_lock`) fires exclusively for - // Built rows whose broadcast was rejected — so a removal - // reaching a consumed row is by construction a stale write. + // deliberately retained for historical lookup, and neither + // removal producer can legitimately name one — a Built row + // rejected at broadcast (`untrack_asset_lock`) never got that + // far, and a sweep of the funding transaction + // (`remove_tracked_asset_locks_for_swept`) only tombstones + // entries still tracked, which a consumed lock no longer is — + // so a removal reaching a consumed row is by construction a + // stale write. `AssetLockChangeSet::merge` guarantees a stored + // changeset never carries an upsert and a tombstone for the + // same outpoint (a reinstating reconstruction cancels a folded + // sweep tombstone; a folding tombstone takes the dead upsert + // with it), so the upserts-then-removals order here is layout, + // not load-bearing sequencing. let mut stmt = tx.prepare_cached( "DELETE FROM asset_locks \ WHERE wallet_id = ?1 AND outpoint = ?2 AND status != 'consumed'", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 129819b0bce..020802f3a55 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -2,10 +2,12 @@ #[cfg(any(test, feature = "__test-helpers"))] use std::collections::BTreeMap; +use std::collections::HashSet; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -126,18 +128,541 @@ pub fn apply( ])?; } } - if cs.last_processed_height.is_some() || cs.synced_height.is_some() { - upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; + let chainlock_height = cs + .last_applied_chain_lock + .as_ref() + .map(|cl| cl.block_height); + let heights_advanced = cs.last_processed_height.is_some() + || cs.synced_height.is_some() + || chainlock_height.is_some(); + if heights_advanced { + upsert_sync_state( + tx, + wallet_id, + cs.last_processed_height, + cs.synced_height, + chainlock_height, + )?; + } + // Sweeps run last so a winner arriving in this very changeset has its + // own rows committed before the removal below touches the coins it took, + // and batch by batch in order: each sweep is only true of the wallet it + // saw, so a later one keeping a coin spent has to be able to correct an + // earlier one that freed it. + if cs.sweeps.is_empty() { + // The ordinary round. Everything below serves the sweep loop, and + // building the survivor set would hash every input of every record + // for a loop that never runs — with the write transaction open. + if heights_advanced { + collect_finalized_tombstones(tx, wallet_id)?; + } + return Ok(()); + } + + // The surviving claims are a property of the whole changeset, not of any + // one batch, so they are built once: the adapter folds up to a full drain + // into a single store, and rebuilding them per batch would re-hash every + // swept txid and every surviving record input once per sweep, with the + // write transaction open the whole time. + // + // `apply_sweep` below is what attributes a held input to `superseded_by` + // via `spent_in_txid`, and that only happens once it runs — so at this + // point in the round the table cannot yet tell a live claim in *this* + // round from the one a sweep is about to displace. The changeset carries + // the answer instead: any record in this round that is not swept by *any* + // batch and spends a released outpoint is that live claim, and the coin + // stays spent. + let swept_txids: HashSet = cs + .sweeps + .iter() + .flat_map(|b| b.txids.iter()) + .copied() + .collect(); + let claimed_by_survivors: HashSet = cs + .records + .iter() + .filter(|record| !swept_txids.contains(&record.txid)) + .flat_map(|record| record.transaction.input.iter()) + .map(|input| input.previous_output) + .collect(); + // The changeset is not the whole answer, though. Upstream computes + // `released_outpoints` from its *live* records, and under the default + // `keep-finalized-transactions = off` a chainlocked record is pruned to + // its bare txid — the pinned `TransactionsSwept::released_outpoints` + // doc records this exact limitation ("the inputs of a pruned record + // survive nowhere else, so this cannot be resolved at this layer"). + // It CAN be resolved at this layer: this store never prunes a + // `core_transactions` row on finalization, so the full input set of + // every settled spend the wallet has forgotten is still on disk. A + // release naming a coin such a record still claims is upstream + // reporting its own amnesia — honouring it flips the materialized UTXO + // to `spent = 0` and hands a provably consumed coin back as spendable + // after the next load, a guaranteed double spend. The same applies + // after a restart for every NETWORK-FINAL record (IS-locked, in-block, + // chainlocked): hydration rebuilds the in-memory wallet without its + // transaction history, so every settled claim the store holds is one + // upstream can no longer see. Bare mempool rows are deliberately not + // part of the veto — see `surviving_stored_input_claims` for why a + // stale one must not strand a legitimately released coin. + // + // `stored_input_claims` is therefore upstream's own `retain_unclaimed` + // predicate — "drop outpoints some surviving record still spends" — + // re-evaluated against the unpruned history. Built lazily and at most + // once per round: only a batch whose released set survives the + // in-round filter above pays for it, and the common sweep (a resend + // whose winner spends every input its loser did) releases nothing. + let mut stored_claims: Option> = None; + for batch in &cs.sweeps { + // Only this stays per batch: a release is true of the wallet its own + // sweep saw, which is what lets a later batch correct an earlier one. + let mut released: HashSet = batch + .released_outpoints + .iter() + .filter(|outpoint| !claimed_by_survivors.contains(outpoint)) + .copied() + .collect(); + if !released.is_empty() { + let claims = match stored_claims.as_ref() { + Some(claims) => claims, + None => { + stored_claims = + Some(surviving_stored_input_claims(tx, wallet_id, &swept_txids)?); + stored_claims.as_ref().expect("just assigned") + } + }; + released.retain(|outpoint| !claims.contains(outpoint)); + } + for loser_txid in &batch.txids { + apply_sweep( + tx, + wallet_id, + loser_txid, + &batch.superseded_by, + &released, + &swept_txids, + batch.winner_mined_height, + )?; + } + // Releases are outpoint-keyed facts, so they are applied by outpoint + // once the batch's losers are done — not only through each loser's + // decoded inputs above. A chained-sweep claim is a `core_utxos` + // placeholder that exists independently of any transaction row, and + // the loser now freeing it need not have one: a fatal flush error + // wipes a buffered round (the winner's record with it) while the + // faulted wallet keeps persisting later rounds, and `apply_sweep` + // above returns before its input loop when the swept txid has no + // row. Dropping the release set there would leave the `:340` valve + // holding the placeholder's `spent_in_txid` forever — the release + // is the one channel that clears it. Running after the loser loop + // rather than inside it changes nothing for inputs the loop already + // freed (same UPDATE, idempotent), and a coin a surviving record in + // this round re-claimed was already filtered out of `released` + // above. + if !released.is_empty() { + // A released claim that never materialised is deleted outright + // rather than flipped to `spent = 0`: the row is all placeholder + // (`value = 0`, `script = X''`, `height` NULL — no writer but the + // tombstone insert leaves `height` NULL), so releasing it in + // place would surface a zero-value phantom coin through + // `list_unspent_utxos`. No row is the correct end state — if the + // funding output ever classifies, its ordinary upsert creates + // the real row freshly unspent, exactly as if the dead claim had + // never existed. Materialised rows carry real funding data and + // are released in place as before. + let mut release_drop_stmt = tx.prepare_cached( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND height IS NULL", + )?; + let mut release_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \ + WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + for outpoint in &released { + let key = blob::encode_outpoint(outpoint)?; + let dropped = release_drop_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + if dropped == 0 { + release_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + } + } + } + } + if heights_advanced { + collect_finalized_tombstones(tx, wallet_id)?; } Ok(()) } +/// The union of every input outpoint claimed by a surviving +/// `core_transactions` row — every row except this round's swept losers, +/// whose deletion the round itself performs. +/// +/// This is the durable mirror of upstream's `retain_unclaimed` claimed-set, +/// with one decisive difference: it includes records the in-memory wallet +/// has pruned (chainlocked, under the default +/// `keep-finalized-transactions = off`) or lost across a restart. +/// +/// Only NETWORK-FINAL claimants count: InstantSend-locked (settled under +/// DIP-10 the moment the lock lands), in-block, or chainlocked. A bare +/// `Mempool` row is deliberately not settled-spend evidence, because it is +/// the one context that can go stale forever: an evicted or abandoned +/// mempool transaction has no removal path in this store other than a later +/// sweep (upstream's abandon path emits no events — +/// dashpay/rust-dashcore#976), and restoration deliberately does not +/// repopulate ordinary transaction history, so nothing ever re-asserts or +/// retracts the row. Letting it veto an authoritative release would leave +/// the coin attributed to an unrelated winner and durably spent — the +/// mirror image of the wrong-release bug this guard exists to stop. This is +/// also exactly the mobile stores' rule: their link guard protects a +/// network-final spender's link and lets a mempool link be replaced. A LIVE +/// mempool claim loses nothing here: in-session upstream holds the record +/// and never names its inputs released, and within the round +/// `claimed_by_survivors` carries the changeset's own mempool records. The +/// one accepted trade: after a restart a still-alive mempool claimant on +/// disk no longer vetoes, so the release wins and the coin may be +/// transiently re-offered while that pending spend races — self-resolving +/// when the pending spend confirms or dies, and strictly better than a +/// permanent strand. +/// +/// Fails CLOSED. This scan is the final guard against re-crediting a +/// consumed coin, so a malformed stored key must fail the round rather than +/// silently drop that row's veto: a `txid` column of the wrong length and a +/// record blob whose decoded `TransactionRecord::txid` disagrees with the +/// typed key (the key is what excludes a row as a swept loser) are both +/// `BlobDecode` errors, matching the other typed-column readers. +/// +/// One pass over the wallet's rows, decoding each blob once — the same +/// build-the-set-then-probe shape (and rationale) as upstream's +/// `retain_unclaimed`: released sets follow the input count of a +/// transaction a remote peer picks, so probing per candidate would be +/// `O(released × history)` instead. The pass itself is `O(history)` blob +/// decodes, paid only by a round whose sweep actually frees candidate +/// coins — rare organically, and an attacker can only force one per +/// on-chain final transaction they pay for. +fn surviving_stored_input_claims( + tx: &Transaction<'_>, + wallet_id: &WalletId, + swept_txids: &HashSet, +) -> Result, WalletStorageError> { + use dashcore::hashes::Hash; + + let mut stmt = + tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1")?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut claims: HashSet = HashSet::new(); + while let Some(row) = rows.next()? { + let txid_bytes: Vec = row.get(0)?; + let Ok(txid_array) = <[u8; 32]>::try_from(txid_bytes.as_slice()) else { + return Err(WalletStorageError::blob_decode( + "core_transactions.txid must be exactly 32 bytes", + )); + }; + let key_txid = dashcore::Txid::from_byte_array(txid_array); + if swept_txids.contains(&key_txid) { + continue; + } + let blob_bytes: Vec = row.get(1)?; + let record: TransactionRecord = blob::decode(&blob_bytes)?; + if record.txid != key_txid { + return Err(WalletStorageError::blob_decode( + "core_transactions.txid disagrees with the decoded record's txid", + )); + } + + if matches!(record.context, TransactionContext::Mempool) { + continue; + } + claims.extend( + record + .transaction + .input + .iter() + .map(|input| input.previous_output), + ); + } + Ok(claims) +} + +/// Delete a swept transaction's row and outputs, then resolve the coins it +/// claimed to spend. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm — the wallet +/// has already dropped it. Leaving the mirrored row in place would hand it +/// back at the next `load()` and replay a balance the wallet has already +/// corrected. It would also leave an InstantSend loser answerable through +/// `get_core_tx_record`, which sent-payment reconciliation reads as final and +/// would use to advance a dead DashPay payment to `Confirmed`. +/// +/// Deleting the row and the UTXOs it created is the easy half. The coins it +/// claimed to *spend* split in two, and `released` — computed upstream and +/// carried on the changeset — is the authority on which is which: an input +/// named there came free, because no surviving transaction spends it too; +/// every other input the loser claimed was taken by the transaction that beat +/// it and is gone for good. +/// +/// Recomputing that split here is not an option even though this schema +/// stores whole records. The transaction that took the rest need not be +/// wallet-relevant at all — it can spend our coin while paying only external +/// addresses, and then it is never recorded anywhere in this store — and even +/// a relevant one is not guaranteed to arrive in the same round as the sweep. +/// +/// A held input can also have no `core_utxos` row at all: this wallet can +/// persist the loser before its own funding output was ever classified as +/// ours, so the outpoint the loser claims to spend has nothing to update. +/// Losing that claim would matter — the funding transaction has not shown up +/// yet, and when it eventually does, the ordinary UTXO upsert would treat the +/// outpoint as freshly unspent — so a held-but-absent input gets a row of its +/// own here: `spent = 1`, `spent_in_txid = superseded_by`, everything else a +/// placeholder the real funding data overwrites on arrival. +/// `execute_upsert_utxo`'s conflict clause is what makes that placeholder +/// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the +/// claim survives the funding upsert instead of being upserted away by it. +/// +/// The placeholder is created for EVERY sweep context; only the stamp +/// differs. A BLOCK-CONTEXT sweep (`winner_mined_height` is `Some`) +/// stamps the winner's own mined height — the projection of key-wallet's +/// `observed_spent_outpoints`, which maps each outpoint observed spent in +/// a block to the height of the block that spent it — and +/// `collect_finalized_tombstones` evicts the row once the chainlock +/// finality boundary reaches that height, key-wallet's +/// `prune_finalized_observed_spends` condition verbatim. A +/// MEMPOOL-CONTEXT sweep (IS-locked winner, unmined) writes the same row +/// UNSTAMPED (`winner_mined_height` NULL), and the collector never takes +/// an unstamped row. The in-memory model an unstamped row mirrors is not +/// `observed_spent_outpoints` (which indeed records nothing for an +/// unconfirmed spend) but the account's `spent_outpoints`: +/// `drop_conflicted_transactions` deletes the loser and RETAINS the +/// winner's shared inputs there — a hold that carries no height, because +/// under DIP-10 the IS lock alone settles the input. That set is +/// `serde(skip_serializing)` upstream and rebuilt from live records on +/// load, so after the sweep no record can reconstruct it; this row is the +/// hold's only durable carrier, and dropping it lets a post-restart +/// funding delivery credit a coin the network has already consumed. +/// +/// Nothing may collect an unstamped row, ever: an IS-locked winner has no +/// mining deadline, and the funding transaction of an input it spends may +/// itself be IS-locked and unmined (DIP-10 eligibility allows chained +/// locks), so no height watermark can prove the funding output "delivered +/// or never will be". An unstamped row instead leaves the set only +/// through proof: the funding upsert materialises it (a wallet-owned +/// claim — DIP-10 eligibility means the funding tx is mined or will mine, +/// and BIP158 matches its block by our script, so delivery is guaranteed; +/// the row gains a real `height` and becomes an ordinary spent coin), a +/// later block-context sweep re-points it and stamps it into the +/// collectible set, or a release deletes it. +/// +/// The residue is foreign inputs — a swept INCOMING payment reaches this +/// loop too, and a sender-owned input's funding output never delivers, so +/// its unstamped row is permanent. It cannot be gated by ownership +/// because nothing anywhere can prove an input foreign (`input_details` +/// and `direction` are computed from the wallet's UTXO snapshot AT RECORD +/// TIME; dashpay/rust-dashcore#968 — the once-proposed "held outpoints +/// attested ours" set is empty by construction). What bounds the residue +/// is attack cost, not collection: masternodes lock first-seen, so for +/// the winner to earn the IS lock this sweep requires, the conflicting +/// loser must have been delivered straight to this wallet while withheld +/// from the network, and every batch of rows costs the attacker a +/// fee-paying, network-accepted double-spend. The unconditional-placeholder +/// shape this narrows (every context leaking rows with no collector at +/// all) does not return: block-context rows still collect at the finality +/// boundary, and only the IS-context shared-input residue is permanent. +/// +/// Idempotent: a txid this store never recorded is a successful no-op, not an +/// error. A sweep can legitimately name a transaction this wallet dropped, or +/// never derived an address for in the first place. Only the loser-scoped +/// work is skipped in that case — the batch's released outpoints are applied +/// by the caller, outside this function, precisely so a missing row cannot +/// swallow them. +fn apply_sweep( + tx: &Transaction<'_>, + wallet_id: &WalletId, + loser_txid: &dashcore::Txid, + superseded_by: &dashcore::Txid, + released: &HashSet, + swept_txids: &HashSet, + winner_mined_height: Option, +) -> Result<(), WalletStorageError> { + let loser_blob: Option> = tx + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + |row| row.get(0), + ) + .optional()?; + let Some(loser_blob) = loser_blob else { + return Ok(()); + }; + let loser: TransactionRecord = blob::decode(&loser_blob)?; + // Fails CLOSED on a key/record disagreement, before anything is deleted + // or any input is touched. The typed key is what named this row a swept + // loser — both here and in `surviving_stored_input_claims`, which skips + // the row on the key alone and so never contributes its blob's claims to + // the veto set. A row keyed `loser_txid` but holding some other record's + // blob would therefore have that record's inputs processed as this + // loser's, with its claimant veto already waived: a release naming a coin + // the stored record legitimately consumed would mark that coin unspent + // and delete the only stored evidence of its spender. Same `BlobDecode` + // verdict as the claim scan's own mismatch check, for the same reason. + if loser.txid != *loser_txid { + return Err(WalletStorageError::blob_decode( + "core_transactions.txid disagrees with the swept record's txid", + )); + } + + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; + // An InstantSend-locked loser is evictable by a chainlocked winner, so a + // swept transaction can own a row here. Nothing ties that table to + // `core_transactions` — no foreign key, no trigger — so the lock would + // outlive the transaction it describes forever. + tx.execute( + "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; + let mut delete_output_stmt = + tx.prepare_cached("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2")?; + for vout in 0..loser.transaction.output.len() as u32 { + let op = blob::encode_outpoint(&dashcore::OutPoint { + txid: *loser_txid, + vout, + })?; + delete_output_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; + } + drop(delete_output_stmt); + + // Each input is set outright rather than only touched when it changes: + // whichever way it went, the row must end this round agreeing with the + // wallet, and a coin the sweep did not free stays out of the unspent + // query even if nothing had marked it spent yet (upstream sweeps only + // unconfirmed records, whose spends this schema does not mark). + // `spent_in_txid` moves with `spent`: a released input clears back to + // NULL (nobody's claim), a held one is attributed to `superseded_by` so + // the claim outlives this row's own deletion below. + // A held, never-materialised claim (`height IS NULL`) is re-stamped + // with the NEW winner's mined height when this sweep has one — the + // claim now belongs to that winner, and its height is what the + // collector compares against the finality boundary. An IS-locked + // winner (`?5` NULL) re-points the claim but keeps the existing stamp: + // the earlier block-context observation stands, exactly as upstream's + // `observed_spent_outpoints` entry is never retracted by an + // unconfirmed conflict, and collection at the old height stays sound — + // the funding output of a spent outpoint is mined at or below the + // height of ANY block-context spender of it, so the boundary passing + // that height still proves the funding was delivered or never will be. + // Materialised rows (`height` set) keep their NULL stamp — they are + // outside the collector's reach either way. + let mut spend_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4, \ + winner_mined_height = CASE \ + WHEN ?3 AND height IS NULL THEN COALESCE(?5, winner_mined_height) \ + ELSE winner_mined_height END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + // Only reached for a held input with no existing row — see the doc + // comment above. `value`/`script`/`height`/`account_index` are + // placeholders; the funding UTXO's own upsert overwrites them (and, + // thanks to the `spent_in_txid` guard in `execute_upsert_utxo`, does + // not clear `spent` while doing it). `winner_mined_height` is the + // winner's own block height when the sweep has one — the row's whole + // lifetime rule for `collect_finalized_tombstones` — and NULL for an + // IS-locked, unmined winner, which the collector never touches: the + // hold then lasts until the funding upsert materialises it, a later + // block-context sweep stamps it, or a release deletes it. + let mut tombstone_stmt = tx.prepare_cached( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid, \ + winner_mined_height) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3, ?4)", + )?; + for input in &loser.transaction.input { + let outpoint = input.previous_output; + // An input funded by a transaction this same changeset also sweeps + // is a dead parent's output — nobody's coin, not something the + // winner took: upstream's descendant closure always sweeps parent + // and child together, and its release computation excludes exactly + // these outpoints (so `freed` below can never be true for one). The + // right end state is NO row, deleted here outright rather than + // assumed away or marked: + // + // - Assuming the parent's own pass deleted it fails when the + // parent's record was lost (the same record-loss threat the + // caller's by-outpoint release pass exists for) — that pass + // deletes nothing, and skipping the claim here would leave the + // dead output `spent = 0`, a phantom spendable coin `load()` + // hands back. + // - Holding it instead (`spent = 1`, `spent_in_txid = winner`, the + // ordinary path below) survives as a claim the funding upsert's + // valve then defends — against the chainlocked reinstatement + // that is the ONE event that can bring the coin back, whose + // re-emitted output must land freshly unspent. + // + // The delete is idempotent against the parent's own pass in either + // batch order, and a reinstatement re-creates the real row through + // the ordinary `utxos_added` upsert with nothing left standing in + // its way. + if swept_txids.contains(&outpoint.txid) { + let key = blob::encode_outpoint(&outpoint)?; + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..]], + )?; + continue; + } + let key = blob::encode_outpoint(&outpoint)?; + let freed = released.contains(&outpoint); + let spent_in_txid: Option<&[u8]> = if freed { + None + } else { + Some(AsRef::<[u8]>::as_ref(superseded_by)) + }; + let affected = spend_stmt.execute(params![ + wallet_id.as_slice(), + &key[..], + !freed, + spent_in_txid, + winner_mined_height.map(i64::from) + ])?; + if affected == 0 && !freed { + // A held input with no row gets a placeholder in EVERY sweep + // context — `CORE_SWEEP_REMOVAL`'s contract: each non-released + // input retains a durable spend claim even when its funding + // TXO has not materialised yet. An IS-locked, unmined winner + // just leaves the stamp NULL, which the collector never + // touches — see the doc comment above for what resolves (and + // what bounds) an unstamped row. + tombstone_stmt.execute(params![ + wallet_id.as_slice(), + &key[..], + AsRef::<[u8]>::as_ref(superseded_by), + winner_mined_height.map(i64::from) + ])?; + } + } + + Ok(()) +} + /// Resolve the owning account index for a UTXO by its rendered address, /// joining against the `core_derived_addresses` map written earlier in /// the same transaction. const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = "SELECT account_index FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2"; +// `spent` only takes the incoming value when the existing row has no +// `spent_in_txid`. A coin held spent with no spender on record is the +// documented recovery state — the wallet handing it back as a UTXO is +// what clears it. A coin held spent *with* `spent_in_txid` set is +// `apply_sweep`'s tombstone for an input the loser claimed but the funding +// row hadn't arrived for yet; the funding upsert (this statement) is +// exactly the arrival that tombstone exists to survive, so it must not +// double as the thing that erases it. `spent_in_txid` itself is left out of +// the SET list entirely — untouched, it carries the claim forward. +// `winner_mined_height` DOES clear: this statement always binds a real +// funding `height`, so the row it lands on is materialised from here on — +// permanently outside `collect_finalized_tombstones`'s reach — and a stale +// stamp would only mislead. const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -146,7 +671,9 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ script = excluded.script, \ height = excluded.height, \ account_index = excluded.account_index, \ - spent = excluded.spent"; + winner_mined_height = NULL, \ + spent = CASE WHEN core_utxos.spent_in_txid IS NOT NULL \ + THEN core_utxos.spent ELSE excluded.spent END"; fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, @@ -201,35 +728,112 @@ fn upsert_sync_state( wallet_id: &WalletId, last_processed: Option, synced: Option, + chainlock: Option, ) -> Result<(), WalletStorageError> { // Monotonic-max semantics — keep the larger of (current, new). - let current_raw: (Option, Option) = tx - .query_row( - "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", - params![wallet_id.as_slice()], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()? - .unwrap_or((None, None)); - let current = ( - sync_height_u32("core_sync_state.last_processed_height", current_raw.0)?, - sync_height_u32("core_sync_state.synced_height", current_raw.1)?, - ); - let lp = match (current.0, last_processed) { - (Some(a), Some(b)) => Some(a.max(b)), - (a, b) => a.or(b), - }; - let sy = match (current.1, synced) { + let current = read_sync_heights(tx, wallet_id)?; + let max_or = |a: Option, b: Option| match (a, b) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), }; + let lp = max_or(current.0, last_processed); + let sy = max_or(current.1, synced); + let cl = max_or(current.2, chainlock); tx.execute( - "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ - VALUES (?1, ?2, ?3) \ + "INSERT INTO core_sync_state \ + (wallet_id, last_processed_height, synced_height, chainlock_height) \ + VALUES (?1, ?2, ?3, ?4) \ ON CONFLICT(wallet_id) DO UPDATE SET \ last_processed_height = excluded.last_processed_height, \ - synced_height = excluded.synced_height", - params![wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from),], + synced_height = excluded.synced_height, \ + chainlock_height = excluded.chainlock_height", + params![ + wallet_id.as_slice(), + lp.map(i64::from), + sy.map(i64::from), + cl.map(i64::from), + ], + )?; + Ok(()) +} + +/// The wallet's `(last_processed_height, synced_height, chainlock_height)` +/// watermark triple as read back from `core_sync_state`. +type SyncHeights = (Option, Option, Option); + +/// Read the wallet's [`SyncHeights`] watermarks. All-`None` when the row +/// is absent. +fn read_sync_heights( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let raw: (Option, Option, Option) = tx + .query_row( + "SELECT last_processed_height, synced_height, chainlock_height \ + FROM core_sync_state WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()? + .unwrap_or((None, None, None)); + Ok(( + sync_height_u32("core_sync_state.last_processed_height", raw.0)?, + sync_height_u32("core_sync_state.synced_height", raw.1)?, + sync_height_u32("core_sync_state.chainlock_height", raw.2)?, + )) +} + +/// Evict never-materialised sweep tombstones once the chainlock finality +/// boundary reaches their winner's mined height — the storage-side mirror +/// of key-wallet's `prune_finalized_observed_spends`, same condition +/// verbatim: an entry whose spend height is at or below +/// `min(chainlock_height, synced_height)` is safe to forget, because the +/// spend at that height is chain-locked and every BIP158 filter below the +/// boundary has been matched with no false negatives, so the funding +/// transaction of the outpoint it guards — necessarily mined at or below +/// the spend's own height — has either been delivered (materialising the +/// row) or provably never will be. No observation-age margin: the stamp IS +/// the winner's height, carried on the sweep event itself, so nothing here +/// guesses when the winner mined. Rows with no stamp are never collected: +/// a mempool-context sweep (IS-locked winner, unmined) deliberately +/// writes its placeholder unstamped, because such a winner has no mining +/// deadline and no watermark can prove its inputs' funding "delivered or +/// never will be" — an unstamped row is a live hold, resolved only by the +/// funding upsert materialising it, a later block-context sweep stamping +/// it, or a release deleting it (see `apply_sweep`). +/// +/// Two passes, both narrowed to `height IS NULL` (only the tombstone +/// insert leaves `height` NULL, so the set is exactly the +/// never-materialised rows, served by the partial index): +/// +/// 1. Released leftovers (`spent = 0`) are deleted outright — a released, +/// never-materialised claim holds nothing and would read as a +/// zero-value phantom coin. The release path now deletes these +/// in-line; this pass self-heals rows written before it did. +/// 2. Held rows whose winner height is at or below the boundary are +/// collected. +/// +/// Like upstream, a no-op until a chainlock height has been persisted — +/// without a finality boundary nothing can be proven final. +fn collect_finalized_tombstones( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result<(), WalletStorageError> { + tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 0", + params![wallet_id.as_slice()], + )?; + let (_, sy, cl) = read_sync_heights(tx, wallet_id)?; + let (Some(sy), Some(cl)) = (sy, cl) else { + return Ok(()); + }; + let boundary = cl.min(sy); + tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ + AND winner_mined_height <= ?2", + params![wallet_id.as_slice(), i64::from(boundary)], )?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs new file mode 100644 index 00000000000..339cb3d5a95 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -0,0 +1,3233 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Coverage for `core_state::apply`'s handling of `CoreChangeSet::swept_transactions` +//! (the subtractive sweep-removal field — see `core_state.rs::apply_sweep`). +//! +//! Exercises the writer directly through `core_state::apply` on a hand-rolled +//! `rusqlite::Transaction`, same style as `sqlite_structural_hardening.rs`, so +//! each case can pre-seed exactly the rows a sweep needs to reason about +//! without going through the full changeset-merge/buffer machinery. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid, SqlitePersister, SqlitePersisterConfig}; + +use dashcore::hashes::Hash; +use dashcore::{Address, Network, OutPoint, Transaction, TxIn, TxOut, Txid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use key_wallet::Utxo; +use platform_wallet::changeset::changeset::SweepBatch; +use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::{blob, core_state}; +use rusqlite::params; + +/// Mined height carried by every block-context sweep in these tests +/// unless a test pins its own. High enough that the pre-seeded funding +/// heights (10) and default watermarks sit below it. +const WINNER_HEIGHT: u32 = 400; + +fn p2pkh(byte: u8) -> Address { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + let hash = PubkeyHash::from_byte_array([byte; 20]); + Address::new(Network::Testnet, Payload::PubkeyHash(hash)) +} + +fn make_utxo(addr: &Address, txid: Txid, vout: u32, value: u64) -> Utxo { + let outpoint = OutPoint::new(txid, vout); + let txout = TxOut { + value, + script_pubkey: addr.script_pubkey(), + }; + Utxo::new(outpoint, txout, addr.clone(), 10, false) +} + +fn derive_address(conn: &rusqlite::Connection, w: &WalletId, account_index: u32, addr: &Address) { + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, 'standard', ?2, ?3, '0/0', 0)", + params![w.as_slice(), account_index as i64, addr.to_string()], + ) + .unwrap(); +} + +/// Build a `TransactionRecord` whose `transaction.input`/`.output` are the +/// real, decodable fields `apply_sweep` reads back for its outpoint math — +/// as opposed to `input_details`/`output_details`, which only cover the +/// wallet-relevant subset and are left empty here on purpose. +fn tx_record(txid: Txid, inputs: Vec, outputs: Vec) -> TransactionRecord { + let inner = Transaction { + version: 3, + lock_time: 0, + input: inputs + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .collect(), + output: outputs, + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + inner, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record +} + +fn unspent(conn: &rusqlite::Connection, w: &WalletId) -> std::collections::BTreeSet { + core_state::list_unspent_utxos(conn, w) + .unwrap() + .into_values() + .flatten() + .map(|row| row.outpoint) + .collect() +} + +fn row_exists(conn: &rusqlite::Connection, w: &WalletId, op: &OutPoint) -> bool { + let bytes = platform_wallet_storage::sqlite::schema::blob::encode_outpoint(op).unwrap(); + conn.query_row( + "SELECT 1 FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |_| Ok(()), + ) + .optional() + .unwrap() + .is_some() +} + +use rusqlite::OptionalExtension; + +/// A changeset carrying nothing but a sweep still deletes: the loser's +/// `core_transactions` row and every `core_utxos` row it created go, even +/// though `records` / `new_utxos` / everything else on the changeset is +/// empty. This is the guard against the bug the review finding described — +/// `apply` skipping `swept_transactions` entirely because every other +/// `if !cs..is_empty()` block was false. +#[test] +fn sweep_only_changeset_deletes_loser_row_and_its_outputs() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE0); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x01); + let loser_txid = Txid::from_byte_array([0x10; 32]); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let loser_output = OutPoint::new(loser_txid, 0); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + new_utxos: vec![make_utxo(&addr, loser_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &loser_output), + "sanity: the loser's output must exist before the sweep" + ); + } + + // The sweep-only round: nothing else populated on the changeset. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: Txid::from_byte_array([0x11; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let record: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(record.is_none(), "swept transaction row must be gone"); + assert!( + !row_exists(&conn, &w, &loser_output), + "the swept transaction's own output must be gone" + ); +} + +/// A sweep naming a txid this store never recorded is a successful +/// no-op — sweeps are idempotent and can arrive for a transaction this +/// wallet dropped, or ran again after the first sweep already applied. +#[test] +fn sweeping_an_unknown_txid_is_a_no_op() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![Txid::from_byte_array([0x20; 32])], + superseded_by: Txid::from_byte_array([0x21; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).expect("unknown txid must not error"); + tx.commit().unwrap(); +} + +/// The released set is applied verbatim: an outpoint it names becomes +/// spendable again, and every other input the loser claimed stays out of +/// the unspent set because the transaction that beat the loser took it. +#[test] +fn the_released_set_frees_exactly_the_inputs_it_names() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE2); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x02); + let funding_txid = Txid::from_byte_array([0x30; 32]); + let shared_input = OutPoint::new(funding_txid, 0); + let exclusive_input = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x31; 32]); + let winner_txid = Txid::from_byte_array([0x32; 32]); + + let loser = tx_record( + loser_txid, + vec![shared_input, exclusive_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + // The winner only claimed the shared input. + let winner = tx_record( + winner_txid, + vec![shared_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + + // Fund both inputs as ordinary unspent UTXOs, then record the loser + // spending both (mirroring the ordinary flow before it was swept). + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // Record the winner, which re-claims only the shared input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![winner], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Sanity: before the sweep, neither input shows up as unspent. + assert!(!unspent(&conn, &w).contains(&shared_input)); + assert!(!unspent(&conn, &w).contains(&exclusive_input)); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![exclusive_input], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let after = unspent(&conn, &w); + assert!( + after.contains(&exclusive_input), + "an outpoint the sweep released must come back as spendable" + ); + assert!( + !after.contains(&shared_input), + "shared input stays spent — the winner took it" + ); +} + +/// The winner does not have to reach this store at all: it can spend our +/// coin while paying only external addresses, and then no record for it is +/// ever written here. The released set still resolves both inputs +/// correctly, which is the whole reason it is carried rather than +/// recomputed from the rows on hand. +#[test] +fn an_absent_winner_still_keeps_its_own_input_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE3); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x03); + let funding_txid = Txid::from_byte_array([0x40; 32]); + let taken_by_winner = OutPoint::new(funding_txid, 0); + let loser_exclusive = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x41; 32]); + let unrecorded_winner_txid = Txid::from_byte_array([0x42; 32]); + + let loser = tx_record( + loser_txid, + vec![taken_by_winner, loser_exclusive], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + // `superseded_by` never arrives in this store; upstream still + // knows which of the loser's inputs it did not take. + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: unrecorded_winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![loser_exclusive], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let after = unspent(&conn, &w); + assert!( + !after.contains(&taken_by_winner), + "a coin the chain has already spent must not return as spendable" + ); + assert!( + after.contains(&loser_exclusive), + "the loser's own input is free, winner record or not" + ); + // Both rows survive either way — held or freed, never deleted. + assert!(row_exists(&conn, &w, &taken_by_winner)); + assert!(row_exists(&conn, &w, &loser_exclusive)); +} + +/// A round can carry both a release and a later transaction that legitimately +/// spends the freed coin: merging folds several events together, and every +/// record is applied before sweeps. `core_utxos` never records who spent a +/// row, so the release has to defer to the surviving record in the changeset +/// itself — otherwise it hands a coin the later transaction consumed back to +/// the unspent set. +#[test] +fn a_released_coin_a_surviving_record_reclaims_stays_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE4); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x04); + let funding_txid = Txid::from_byte_array([0x50; 32]); + let freed_coin = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x51; 32]); + let winner_txid = Txid::from_byte_array([0x52; 32]); + let reclaimer_txid = Txid::from_byte_array([0x53; 32]); + + let loser = tx_record( + loser_txid, + vec![OutPoint::new(funding_txid, 0), freed_coin], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let reclaimer = tx_record( + reclaimer_txid, + vec![freed_coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // One round: the sweep frees the coin, and a surviving record in the very + // same round already spent it. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![reclaimer], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![freed_coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !unspent(&conn, &w).contains(&freed_coin), + "a coin a surviving record in the same round already spent must stay spent" + ); +} + +/// The reviewer scenario for the pruned-finalized-release defect: upstream +/// computes `released_outpoints` from its live records, and a chainlocked +/// spender F is pruned to a bare txid under the default +/// `keep-finalized-transactions = off` — so a loser L that arrived after the +/// pruning, reusing F's input alongside an attacker-owned one, reports F's +/// input as released when a final W beats L on the attacker input. The +/// pinned `TransactionsSwept` doc calls this unresolvable at its layer; this +/// store is the layer that CAN resolve it, because F's full record survives +/// in `core_transactions`. The release must be refused for F's coin — and +/// still honoured for a coin only the swept loser claimed, in the same +/// batch, or the guard would strand legitimately freed money. +#[test] +fn a_release_naming_a_coin_a_stored_finalized_record_claims_is_refused() { + use key_wallet::transaction_checking::transaction_context::BlockInfo; + + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x09); + let funding_txid = Txid::from_byte_array([0x90; 32]); + // F's coin — consumed on chain, must never come back. + let settled_coin = OutPoint::new(funding_txid, 0); + // Claimed only by the loser — must come free. + let losers_own_coin = OutPoint::new(funding_txid, 1); + // Attacker-owned input shared by L and W — never ours, no row. + let attacker_input = OutPoint::new(Txid::from_byte_array([0x9A; 32]), 0); + + let finalized_txid = Txid::from_byte_array([0x91; 32]); + let loser_txid = Txid::from_byte_array([0x92; 32]); + let winner_txid = Txid::from_byte_array([0x93; 32]); + + // F: chainlocked spender of `settled_coin`. Upstream keeps only its + // txid from here on; this row keeps everything. + let mut finalized = tx_record( + finalized_txid, + vec![settled_coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + finalized.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([0x9B; 32]), + 1_735_689_600, + )); + + // L: arrives after F's pruning, pays this wallet, reuses F's input and + // the attacker's. + let loser = tx_record( + loser_txid, + vec![settled_coin, attacker_input, losers_own_coin], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 400), + make_utxo(&addr, funding_txid, 1, 600), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![finalized], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 600)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // W (final) conflicts with L on the attacker input alone. Upstream's + // release set — computed from live records that no longer include F — + // wrongly names F's coin alongside the loser's own. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![settled_coin, losers_own_coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "a released coin a stored finalized record still claims must stay spent" + ); + assert!( + visible.contains(&losers_own_coin), + "a coin only the swept loser claimed must still come free" + ); + + drop(conn); + drop(persister); + + // Restart: the guard's verdict must be what a relaunch loads — this is + // exactly where the unguarded release manufactured the double spend. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "the refused release must hold across a restart" + ); + assert!( + visible.contains(&losers_own_coin), + "the honoured release must hold across a restart" + ); +} + +/// The stored-claims veto is settled-evidence only: a bare mempool row must +/// not outrank an authoritative release. A mempool record is the one context +/// that can go stale forever — an evicted or abandoned mempool transaction +/// has no removal path in this store other than a later sweep, and +/// restoration does not repopulate ordinary history — so a stale claimant +/// surviving a restart must not veto the release of a coin a later loser +/// claimed, or the coin is attributed to an unrelated winner and stranded +/// durably spent: the mirror image of the wrong-release bug the veto exists +/// to stop. +#[test] +fn a_stale_mempool_claimant_does_not_veto_an_authoritative_release() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0A); + let funding_txid = Txid::from_byte_array([0xA0; 32]); + let coin = OutPoint::new(funding_txid, 0); + + let stale_txid = Txid::from_byte_array([0xA1; 32]); + let loser_txid = Txid::from_byte_array([0xA2; 32]); + let winner_txid = Txid::from_byte_array([0xA3; 32]); + + // M: a mempool spend of the coin, marked spent when recorded. It is + // then evicted from the network's mempool without the wallet ever + // hearing — its row simply goes stale. + let stale = tx_record( + stale_txid, + vec![coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![stale], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Restart: upstream's memory of M is gone for good; only the stale row + // remains. + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let mut conn = persister.lock_conn_for_test(); + + // A fresh loser claims the same coin, and an authoritative sweep later + // frees it — upstream's word, computed from the wallet it actually + // holds. + let loser = tx_record( + loser_txid, + vec![coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + unspent(&conn, &w).contains(&coin), + "a stale mempool claimant must not veto an authoritative release" + ); +} + +/// The stored-claims scan is the final guard against re-crediting a +/// consumed coin, so corrupt claimant rows fail the round instead of +/// silently losing their veto: a wrong-length `txid` key and a record blob +/// whose decoded txid disagrees with its typed key are both `BlobDecode` +/// errors. +#[test] +fn a_corrupt_stored_claimant_fails_the_sweep_round_closed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xEB); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0B); + let funding_txid = Txid::from_byte_array([0xB0; 32]); + let coin = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0xB1; 32]); + let winner_txid = Txid::from_byte_array([0xB2; 32]); + + let loser = tx_record( + loser_txid, + vec![coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // A claimant row whose typed key is not 32 bytes. The blob is a real, + // decodable record so the failure is attributable to the key alone. + let honest = tx_record(Txid::from_byte_array([0xB3; 32]), vec![coin], Vec::new()); + let honest_blob = blob::encode(&honest).unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![w.as_slice(), &[0xB3u8; 31][..], &honest_blob[..]], + ) + .unwrap(); + + let sweep_cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + }], + ..Default::default() + }; + { + let tx = conn.transaction().unwrap(); + let err = core_state::apply(&tx, &w, &sweep_cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a wrong-length claimant key must fail the round closed, got {err:?}" + ); + } + + // Repair the key length but leave it disagreeing with the record's own + // txid — the typed key decides swept-loser exclusion, so the mismatch + // must fail too. + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND length(txid) = 31", + params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![w.as_slice(), &[0xB4u8; 32][..], &honest_blob[..]], + ) + .unwrap(); + { + let tx = conn.transaction().unwrap(); + let err = core_state::apply(&tx, &w, &sweep_cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a key/record txid mismatch must fail the round closed, got {err:?}" + ); + } +} + +/// The sweep's own lookup must validate what it decodes, not only the claim +/// scan's rows. The two readers key on the same column for opposite reasons: +/// `surviving_stored_input_claims` skips a row whose typed key is a swept +/// loser (its blob never joins the veto set), and `apply_sweep` selects a row +/// BY that key and then acts on the blob's inputs. A row keyed as loser L but +/// holding settled record F therefore lands in the one gap where neither +/// reader looks at the other's evidence: F's claim is waived by key, and F's +/// inputs are processed as L's. If the release set names a coin F consumed, +/// the unvalidated path marks that coin unspent and deletes F's row — the +/// only stored proof of its spender — manufacturing exactly the double spend +/// the veto exists to stop. +/// +/// The sibling test above pins the mismatch on a row that is NOT in the sweep +/// batch, which the claim scan rejects on its own; this one puts the +/// mismatched key inside the batch, where only `apply_sweep`'s check stands +/// between the corrupt row and the coin. +#[test] +fn a_swept_key_disagreeing_with_its_stored_record_fails_the_round_closed() { + use key_wallet::transaction_checking::transaction_context::BlockInfo; + + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEC); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0C); + let funding_txid = Txid::from_byte_array([0xC0; 32]); + // F's coin — consumed on chain by a settled record, must never come back. + let settled_coin = OutPoint::new(funding_txid, 0); + + let finalized_txid = Txid::from_byte_array([0xC5; 32]); + let loser_txid = Txid::from_byte_array([0xC1; 32]); + let winner_txid = Txid::from_byte_array([0xC2; 32]); + + // F: chainlocked spender of `settled_coin`. + let mut finalized = tx_record( + finalized_txid, + vec![settled_coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + finalized.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([0xCB; 32]), + 1_735_689_600, + )); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The corruption: F's blob stored under L's typed key. Written straight + // through SQL because no writer in this store can produce it — the point + // is what the reader does when the invariant is already broken on disk. + let finalized_blob = blob::encode(&finalized).unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![ + w.as_slice(), + AsRef::<[u8]>::as_ref(&loser_txid), + &finalized_blob[..] + ], + ) + .unwrap(); + + // The sweep names L and releases the coin F consumed. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![settled_coin], + }], + ..Default::default() + }; + let err = core_state::apply(&tx, &w, &cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a swept key disagreeing with its stored record must fail the round closed, got {err:?}" + ); + } + + // Failing closed is only worth anything if the round left nothing behind: + // the coin stays consumed and the row survives to be repaired. + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "the refused round must not release the coin the stored record consumed" + ); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + rows, 1, + "the refused round must not delete the record it could not validate" + ); + + drop(conn); + drop(persister); + + // And the verdict is what a relaunch loads — the unguarded path's damage + // was durable, so the guard's refusal has to be too. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&settled_coin), + "the refused release must hold across a restart" + ); +} + +/// A chainlocked winner may evict an InstantSend-locked loser, so a swept +/// transaction can own a row in `core_instant_locks`. Nothing ties that table +/// to `core_transactions`, so the lock has to be deleted explicitly or it +/// outlives the transaction it describes forever. +#[test] +fn sweeping_a_transaction_deletes_its_instant_lock() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE5); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x05); + let loser_txid = Txid::from_byte_array([0x60; 32]); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.execute( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, ?3)", + params![ + w.as_slice(), + AsRef::<[u8]>::as_ref(&loser_txid), + vec![0u8; 8] + ], + ) + .unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + instant_lock_count(&conn, &w, &loser_txid), + 1, + "sanity: the lock is there" + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: Txid::from_byte_array([0x61; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + instant_lock_count(&conn, &w, &loser_txid), + 0, + "the swept transaction's InstantLock must go with it" + ); +} + +fn instant_lock_count(conn: &rusqlite::Connection, w: &WalletId, txid: &Txid) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(txid)], + |row| row.get(0), + ) + .unwrap() +} + +/// Two sweeps in one round, and the later one disagrees with the earlier. +/// +/// The first frees a coin; a transaction then spends it; the second sweep +/// removes that spender but keeps the coin spent, because its own winner +/// took it. The later answer is the true one, and only replaying the batches +/// in order makes it stick — folding the release sets together leaves the +/// first "free" outliving the last "spent". +#[test] +fn a_later_sweep_keeping_a_coin_spent_overrides_an_earlier_release() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE6); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x06); + let funding_txid = Txid::from_byte_array([0x70; 32]); + let contested = OutPoint::new(funding_txid, 0); + + let first_loser = Txid::from_byte_array([0x71; 32]); + let second_loser = Txid::from_byte_array([0x72; 32]); + + let first = tx_record( + first_loser, + vec![contested], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + // The transaction that took the freed coin, and that the second sweep + // removes. It is a loser too, so it is not a surviving claim. + let second = tx_record( + second_loser, + vec![contested], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![first, second], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![ + SweepBatch { + txids: vec![first_loser], + superseded_by: Txid::from_byte_array([0x7a; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![contested], + }, + // The second winner consumed the coin, so this sweep frees + // nothing — and that has to override the release above. + SweepBatch { + txids: vec![second_loser], + superseded_by: Txid::from_byte_array([0x7b; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }, + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !unspent(&conn, &w).contains(&contested), + "the later sweep kept the coin spent, so it must not be spendable" + ); +} + +/// A loser can be persisted before its own funding output is: this store +/// only learns about a TXO through `new_utxos`/`spent_utxos`, so a spend can +/// name an outpoint `core_utxos` has never heard of. When such an input is +/// held (not released) by the sweep, `apply_sweep` has no row to update and +/// must leave a claim of its own — otherwise deleting the loser's +/// `core_transactions` row (the only place that input was ever recorded) +/// erases the claim entirely, and the funding output arriving later — even +/// after a full restart — would insert it back as a plain unspent UTXO. +#[test] +fn a_held_input_with_no_utxo_row_survives_restart_and_stays_spent_when_funded() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x07); + let funding_txid = Txid::from_byte_array([0x80; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + + let loser_txid = Txid::from_byte_array([0x81; 32]); + let winner_txid = Txid::from_byte_array([0x82; 32]); + + let loser = tx_record( + loser_txid, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + // The loser's spend arrives with no prior `new_utxos`/`spent_utxos` + // for `unfunded_input` — the funding side of that outpoint has not + // been observed yet. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + assert!( + !row_exists(&conn, &w, &unfunded_input), + "sanity: no core_utxos row exists for the unfunded input yet" + ); + } + + // The sweep holds the input (it is not in `released_outpoints`), with + // nothing on hand to update. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + + // Restart: a fresh persister loading the same on-disk store, exactly as + // a relaunch would see it. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + + // The funding transaction finally arrives and hands the outpoint back + // as a UTXO — the ordinary path a rescan or late block takes. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&unfunded_input), + "the winner's claim on this input must survive the loser's deletion, \ + a restart, and the funding UTXO's own arrival" + ); +} + +/// A held-but-unfunded input's placeholder (see the test above) can itself +/// need to move again: its first winner can go on to lose a later sweep +/// while the outpoint is still unfunded. Unlike the mobile backends' pending- +/// input table, this schema has no separate relationship the placeholder +/// detaches from — `apply_sweep` always looks up the loser's inputs fresh +/// from its own `core_transactions` blob and touches `core_utxos` by +/// outpoint alone, so the second sweep finds the same placeholder row the +/// first one wrote without any chain-specific bookkeeping. This is the +/// released half: L spends P; W spends P and Q and sweeps L holding P (P is +/// still unfunded); X spends Q and sweeps W, this time releasing P. +#[test] +fn a_chained_sweep_before_funding_still_frees_an_earlier_tombstone_on_release() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE8); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x08); + let funding_txid = Txid::from_byte_array([0x90; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + let funded_input = OutPoint::new(funding_txid, 1); + + let first_loser = Txid::from_byte_array([0x91; 32]); // L + let second_loser = Txid::from_byte_array([0x92; 32]); // W + let final_winner = Txid::from_byte_array([0x93; 32]); // X + + let l = tx_record( + first_loser, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let w_record = tx_record( + second_loser, + vec![unfunded_input, funded_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + + // `funded_input` is an ordinary UTXO from the start; `unfunded_input`'s + // funding side is never observed until the very end. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // L's spend of the unfunded input arrives with no core_utxos row for it. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![l], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // First sweep: W beats L, holding the still-unfunded input. This is what + // writes the placeholder row this test is about. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![first_loser], + superseded_by: second_loser, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &unfunded_input), + "sanity: the first sweep must have left a placeholder row" + ); + // W's own record has to be on hand for the second sweep to look its + // inputs up — the same requirement any ordinary (non-chained) sweep has. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![w_record], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // Second sweep: X beats W, and this time releases the input that has + // been sitting unfunded since the first sweep. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![unfunded_input], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !row_exists(&conn, &w, &unfunded_input), + "the chained sweep released this input while its funding TXO is \ + still unobserved — the placeholder must be deleted outright, not \ + flipped to a zero-value phantom that list_unspent would report" + ); + assert!( + !unspent(&conn, &w).contains(&funded_input), + "the second sweep's winner took the other input" + ); + + // The funding output finally classifies: with the dead claim's row gone, + // the ordinary upsert creates the coin freshly unspent with real data. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + unspent(&conn, &w).contains(&unfunded_input), + "the released coin arrives as an ordinary spendable UTXO once its \ + funding output classifies" + ); +} + +/// The held (not released) half of the chained-before-funding scenario +/// above: the second sweep keeps the still-unfunded input spent instead of +/// releasing it, and the placeholder must end up attributed to the NEW +/// winner rather than the one the second sweep just removed. Verified +/// across a full restart, then confirmed by finally funding the input — it +/// must still read as spent, and the persisted placeholder must name the +/// final winner rather than the intermediate one that no longer has a row. +#[test] +fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winner() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x09); + let funding_txid = Txid::from_byte_array([0xA0; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + + let first_loser = Txid::from_byte_array([0xA1; 32]); // L + let second_loser = Txid::from_byte_array([0xA2; 32]); // W + let final_winner = Txid::from_byte_array([0xA3; 32]); // X + + let l = tx_record( + first_loser, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let w_record = tx_record( + second_loser, + vec![unfunded_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![l], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // First sweep: W beats L, holding the unfunded input. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![first_loser], + superseded_by: second_loser, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // W's own record, needed by the second sweep below. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![w_record], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // Second sweep: X beats W, still holding the same unfunded input. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + + // The funding transaction finally arrives. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&unfunded_input), + "the final winner's claim must survive both sweeps, the restart, \ + and the funding UTXO's own arrival" + ); + let spent_in_txid: Vec = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + w.as_slice(), + &blob::encode_outpoint(&unfunded_input).unwrap()[..] + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + spent_in_txid, + AsRef::<[u8]>::as_ref(&final_winner).to_vec(), + "the placeholder must be attributed to the final winner, not the \ + intermediate one the second sweep already removed" + ); +} + +/// Confirmation, not a fix, of this round's BLOCKING finding on the mobile +/// backends' missing-row early return: there, one wallet's callback can +/// delete the shared winner row while a second wallet's detached tombstones +/// still name it, and the second wallet's own sweep of that winner then has +/// to reconcile them against a row that no longer exists. No such moment +/// exists here. `core_transactions` is keyed `(wallet_id, txid)`, so each +/// wallet sweeps its own copy of the winner and no other wallet's call can +/// have removed it first; and the tombstone is not a detached side-table row +/// but the wallet's own `core_utxos` placeholder, matched by `apply_sweep` +/// through the winner's own stored inputs — `(wallet_id, outpoint)`-scoped, +/// so the chain continues per wallet with nothing shared to lose. +/// +/// This is the reviewer's multi-wallet chained-sweep-before-funding shape +/// end to end: the same loser txid in two wallets, each claiming a +/// still-unfunded coin of its own; W beats L (both coins held as +/// placeholders); W's own record lands; X beats W, with wallet 1 releasing +/// its coin and wallet 2 holding — in that order, so wallet 1's whole chain +/// including its deletion of (its copy of) W commits before wallet 2's +/// callback runs. Each wallet's decision must land on its own coin only, and +/// each coin's eventual funding must respect it. +#[test] +fn a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombstones() { + let (persister, _tmp, _path) = fresh_persister(); + let w1: WalletId = wid(0xF1); + let w2: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x51); + let addr2 = p2pkh(0x52); + let funding_txid = Txid::from_byte_array([0x50; 32]); + // Wallet 1's coin and wallet 2's coin. Neither funding side has been + // observed in either wallet until the very end. + let p1 = OutPoint::new(funding_txid, 0); + let p2 = OutPoint::new(funding_txid, 1); + let shared_loser = Txid::from_byte_array([0x53; 32]); // L + let shared_winner = Txid::from_byte_array([0x54; 32]); // W + let final_winner = Txid::from_byte_array([0x55; 32]); // X + + // The raw transactions are the same for both wallets — a record is the + // whole on-chain transaction, inputs included — so each wallet's copy + // claims both outpoints even though only one is its own coin. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, if w == &w1 { &addr1 } else { &addr2 }); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(shared_loser, vec![p1, p2], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // First sweep in both wallets: W beats L, holding everything. Leaves + // each wallet a placeholder row per claimed outpoint, attributed to W. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_loser], + superseded_by: shared_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // W's own record lands in both wallets, as any wallet-relevant winner's + // eventually does. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(shared_winner, vec![p1, p2], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Second sweep, wallet 1 first: X beats W and wallet 1 releases its own + // coin. Its copy of W's row is deleted in the same call. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_winner], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![p1], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + let gone: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w1.as_slice(), AsRef::<[u8]>::as_ref(&shared_winner)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(gone.is_none(), "wallet 1's own copy of W is deleted"); + let w2_placeholder: Option> = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&p2).unwrap()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + w2_placeholder, + Some(AsRef::<[u8]>::as_ref(&shared_winner).to_vec()), + "wallet 1's whole chained sweep, deletion included, must leave wallet 2's \ + placeholder exactly where wallet 2's own first sweep put it" + ); + } + + // Wallet 2's callback runs only now, holding its coin. Its own copy of + // W is still on hand — nothing wallet 1 committed could have removed a + // `(wallet_id, txid)`-keyed row of wallet 2's. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_winner], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w2, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The funding transaction finally arrives, each coin through its own + // wallet's round. + for (w, addr, vout) in [(&w1, &addr1, 0u32), (&w2, &addr2, 1u32)] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(addr, funding_txid, vout, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w1).contains(&p1), + "wallet 1's released coin comes back spendable once funded" + ); + assert!( + !unspent(&conn, &w2).contains(&p2), + "wallet 2's held coin stays spent" + ); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&p2).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 1); + assert_eq!( + spent_in_txid, + Some(AsRef::<[u8]>::as_ref(&final_winner).to_vec()), + "wallet 2's placeholder followed its own chain to the final winner, \ + driven entirely by wallet 2's own calls" + ); +} + +/// Confirmation, not a fix: the review finding that motivated the Swift/ +/// Kotlin backend changes (a shared `PersistentTransaction` row updated with +/// one wallet's `released_outpoints` before another wallet's own callback +/// gets a turn) has no analog here. `core_transactions` and `core_utxos` are +/// keyed by `(wallet_id, txid)` / `(wallet_id, outpoint)` — there is no row +/// for a "loser shared across wallets" to BE, only two wallets each holding +/// their own copy of a transaction that happens to carry the same txid. +/// `apply_sweep` re-derives every input from the loser's own stored blob and +/// matches `core_utxos` strictly within the calling wallet's rows, so one +/// wallet's sweep call cannot see, let alone touch, another wallet's copy. +/// +/// This seeds the reviewer's exact shape — the same loser txid persisted +/// independently by two wallets, each holding a different coin of its own — +/// and sweeps them in opposite decisions (wallet 1 releases its coin, +/// wallet 2 holds its own) to show neither call perturbs the other wallet's +/// row at all, regardless of which runs first. +#[test] +fn sweep_of_a_shared_loser_txid_is_independent_per_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let w1: WalletId = wid(0xE8); + let w2: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x31); + let addr2 = p2pkh(0x32); + let funding_txid = Txid::from_byte_array([0x30; 32]); + // Same txid recorded independently in both wallets' storage — as two + // wallets sharing one on-chain transaction each would. + let loser_txid = Txid::from_byte_array([0x33; 32]); + let winner_txid = Txid::from_byte_array([0x34; 32]); + let coin = OutPoint::new(funding_txid, 0); + + for (w, addr) in [(&w1, &addr1), (&w2, &addr2)] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, addr); + let tx = conn.transaction().unwrap(); + let funding = tx_record( + funding_txid, + vec![], + vec![TxOut { + value: 100_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let loser = tx_record(loser_txid, vec![coin], vec![]); + let cs = CoreChangeSet { + records: vec![funding, loser], + new_utxos: vec![make_utxo(addr, funding_txid, 0, 100_000)], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Wallet 1 sweeps its copy of the loser and releases its own coin. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w1.as_slice(), &blob::encode_outpoint(&coin).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 0, "wallet 1's release frees its own coin"); + assert!(spent_in_txid.is_none()); + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_some(), + "wallet 2's own copy of the same-txid loser is a separate row, \ + untouched by wallet 1's sweep" + ); + assert!( + row_exists(&conn, &w2, &coin), + "wallet 2's coin is unaffected — it has not swept yet" + ); + } + + // Wallet 2 now sweeps its own copy of the same txid, releasing nothing. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w2, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_none(), + "wallet 2's own sweep removes its own row" + ); + + assert!( + row_exists(&conn, &w2, &coin), + "wallet 2 released nothing, so its coin stays held with a row of its own" + ); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&coin).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 1, "wallet 2's coin is held spent"); + assert_eq!( + spent_in_txid, + Some(AsRef::<[u8]>::as_ref(&winner_txid).to_vec()), + "held and attributed to wallet 2's own winner, per apply_sweep's hold contract — \ + wallet 1's earlier release of the SAME txid's other coin never touched this row" + ); +} + +/// Confirmation, not a fix, of this round's BLOCKING finding (a shared row +/// acknowledged as durably swept by one wallet's commit while a second +/// wallet's own callback is still outstanding — see the Swift/Kotlin +/// `PersistentTransaction.isGloballySwept` / `TransactionEntity. +/// isGloballySwept` flag those backends needed to add). The finding does not +/// apply here for the same structural reason as the independence test +/// above: there is no shared row for a second wallet's callback to hold +/// back in the first place, so wallet 1's own deletion has no cross-wallet +/// dependency to be durable *despite*. +/// +/// This confirms the corollary directly: wallet 1 sweeps and commits, wallet +/// 2's own callback for the same loser txid is never called again in this +/// test at all (a crash, a rejection, or it simply never coming), and the +/// persister is restarted from disk. Wallet 1's phantom output and row must +/// already be gone — nothing about their absence was waiting on wallet 2. +#[test] +fn sweep_deletion_is_durable_even_when_the_other_wallets_callback_never_arrives() { + let (persister, _tmp, path) = fresh_persister(); + let w1: WalletId = wid(0xEA); + let w2: WalletId = wid(0xEB); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x41); + let addr2 = p2pkh(0x42); + // Same loser txid recorded independently by both wallets, each with an + // output of its own — the "phantom money" the blocking finding is about. + let loser_txid = Txid::from_byte_array([0x43; 32]); + let winner_txid = Txid::from_byte_array([0x44; 32]); + + for (w, addr) in [(&w1, &addr1), (&w2, &addr2)] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, addr); + let tx = conn.transaction().unwrap(); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 60_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Only wallet 1 ever sweeps. Wallet 2's own callback for this sweep + // never arrives — this test never calls `apply` for w2 again. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + + let w1_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w1.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w1_loser.is_none(), + "wallet 1's own sweep commit is durable across a restart on its own — \ + nothing about it was waiting on wallet 2's callback" + ); + assert!( + !row_exists(&conn, &w1, &OutPoint::new(loser_txid, 0)), + "wallet 1's phantom output must not survive — its deletion never depended \ + on wallet 2's callback, which never arrives in this test" + ); + + // Wallet 2 never swept, so its own independent copy legitimately still + // stands — that is correct per-wallet state, not the bug under test. + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_some(), + "wallet 2's own row is untouched — it never ran its own sweep" + ); +} + +/// Confirmation, not a fix, of this round's BLOCKING finding on the mobile +/// backends (Swift `PersistentTransaction.isGloballySwept` / Kotlin +/// `TransactionEntity.isGloballySwept`): once a sweep is reversed by a +/// chainlocked return, a later-arriving record for the same txid must be +/// accepted as reinstatement rather than permanently rejected. +/// +/// That guard exists on the mobile backends only because their +/// `PersistentTransaction` / `TransactionEntity` rows are shared across +/// wallets and durably flagged the moment *any* wallet's callback observes +/// the sweep, before every wallet's own claim is known to be gone — a +/// second wallet's still-outstanding claim can keep the row physically +/// present after the first wallet's commit, which is exactly what forces a +/// flag instead of relying on row-absence. `apply_sweep` here has no such +/// row to hold onto: it is keyed `(wallet_id, txid)`, so the delete is +/// unconditional and wallet-local (`sweep_of_a_shared_loser_txid_is_ +/// independent_per_wallet` above), and a second wallet's own claim on the +/// same on-chain txid lives in an entirely separate row this wallet's sweep +/// never touches. There is therefore nothing left standing after `apply` +/// runs a sweep for the row's txid — no tombstone to clear, because there +/// is no row to protect from resurrection in the first place. A later round +/// carrying a plain record for the same `(wallet_id, txid)` is just an +/// ordinary `INSERT … ON CONFLICT DO UPDATE` into empty space, so this test +/// exercises that "reinstatement" is unconditionally already correct here, +/// across a separate `apply` call *and* a restart — the same cross-round +/// shape the mobile fix had to add tombstone-clearing for. +#[test] +fn a_record_reinstating_a_swept_txid_in_a_later_round_is_accepted_and_durable() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEC); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x51); + let txid = Txid::from_byte_array([0x53; 32]); + let winner_txid = Txid::from_byte_array([0x54; 32]); + let output = OutPoint::new(txid, 0); + + // Round 1: the transaction is recorded normally, with its own output. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let record = tx_record( + txid, + vec![], + vec![TxOut { + value: 45_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![record], + new_utxos: vec![make_utxo(&addr, txid, 0, 45_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Round 2, a separate `apply` call: an IS-locked conflict sweeps it — + // the row and its output are gone, same as `sweep_only_changeset_ + // deletes_loser_row_and_its_outputs` above. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let swept: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(swept.is_none(), "sanity: the sweep removed the row"); + assert!( + !row_exists(&conn, &w, &output), + "sanity: its output is gone too" + ); + } + + // Round 3, yet another separate `apply` call: the wallet returns + // chainlocked and sweeps the conflict in turn — upstream's newer word, + // carried here as a plain record the same way any fresh transaction + // would arrive. Nothing on this backend needs to know it is a + // "reinstatement" rather than a first sighting. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let record = tx_record( + txid, + vec![], + vec![TxOut { + value: 45_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![record], + new_utxos: vec![make_utxo(&addr, txid, 0, 45_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Durable across a restart — not merely visible within the open + // connection that just wrote it. + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + + let reinstated: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + reinstated.is_some(), + "the reinstating record must be live and durable — a later round is \ + upstream's newer word, and this backend has no tombstone standing \ + in its way" + ); + assert!( + row_exists(&conn, &w, &output), + "the reinstated transaction's own output must be live and durable too" + ); + assert!( + unspent(&conn, &w).contains(&output), + "and spendable — not left behind in some half-restored state" + ); +} + +/// A release must land even when the swept txid has no `core_transactions` +/// row of its own. A chained-sweep claim is a `core_utxos` placeholder that +/// exists independently of any transaction row, and the loser now freeing +/// it need not have one — a fatal flush error wipes a buffered round (the +/// winner's record with it) while the faulted wallet keeps persisting later +/// rounds. `apply_sweep` returns before its input loop for a missing row, +/// so if that loop were the only place releases were applied the set would +/// be silently dropped and the upsert valve would hold the placeholder's +/// `spent_in_txid` forever. +#[test] +fn a_release_applies_even_when_the_swept_txid_has_no_row() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x31); + let funding_txid = Txid::from_byte_array([0x30; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0x31; 32]); // L + let winner_txid = Txid::from_byte_array([0x32; 32]); // W — never recorded + let final_winner = Txid::from_byte_array([0x33; 32]); // X + + // Round 1: L, spending the still-unfunded P, is recorded and then swept + // by W with nothing released — leaving the held-but-absent placeholder. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(loser_txid, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &p), + "sanity: the held claim left its placeholder" + ); + assert!(unspent(&conn, &w).is_empty()); + } + + // Round 2: W is swept in turn, releasing P — but W's own record never + // reached this store, so there is no row and no input loop to walk. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![winner_txid], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![p], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &p), + "the release must reach the placeholder with no loser row to walk \ + — and delete it outright, since it never materialised" + ); + } + + // The funding output finally arrives: the shed hold must let the + // upsert's valve accept the coin as unspent, with its real value. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&p), + "the funded coin stays spendable — the valve has no stale claim to defend" + ); +} + +/// One batch can sweep a parent and the child that spends its output — +/// upstream's descendant closure always removes them together, and its +/// release computation filters out outpoints whose txid is itself a loser. +/// With the parent ordered first, its pass deletes the output row; the +/// child's pass must not re-create it as a held placeholder. The +/// placeholder's `spent_in_txid` is exactly what the funding upsert's +/// valve defends, so a chainlocked reinstatement of the parent — the one +/// event that can bring the coin back — would find its genuinely unspent +/// output locked out of the restore set forever. +#[test] +fn a_batch_sweeping_parent_and_child_leaves_no_placeholder_for_the_parents_output() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE8); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x41); + let parent_txid = Txid::from_byte_array([0x40; 32]); // L + let child_txid = Txid::from_byte_array([0x41; 32]); // C + let winner_txid = Txid::from_byte_array([0x42; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // L pays us and is funded; C spends L's output. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![ + tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + ), + tx_record(child_txid, vec![parent_output], vec![]), + ], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The batch removes both, parent first — the ordering that deletes the + // output row before the child's pass walks its inputs. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &parent_output), + "a dead parent's output is nobody's coin — no placeholder may re-create it" + ); + } + + // The chainlocked return: L is reinstated with its output re-emitted. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's genuinely unspent output must restore — no stale \ + spent_in_txid claim may stand in its way" + ); +} + +/// The record-loss half of the co-swept rule. A parent whose record this +/// store lost (the same threat the by-outpoint release pass exists for) +/// deletes nothing in its own pass, so the child's pass must take the +/// surviving output row out of the restore set itself — leaving it +/// `spent = 0` would hand back a phantom spendable coin. And it must do +/// so by DELETING the row, not by holding it: a `spent_in_txid` claim is +/// exactly what the funding upsert's valve defends, which would lock out +/// the chainlocked reinstatement that is the one event able to bring the +/// coin back for real. +#[test] +fn a_co_swept_parent_with_no_row_still_has_its_output_removed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x51); + let parent_txid = Txid::from_byte_array([0x50; 32]); // P — record lost + let child_txid = Txid::from_byte_array([0x51; 32]); // C + let winner_txid = Txid::from_byte_array([0x52; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // P's record round was wiped, but its funded output row and C's record + // both persisted. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(child_txid, vec![parent_output], vec![])], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "sanity: the parent's output starts live" + ); + } + + // The batch sweeps both. P's pass finds no row and deletes nothing; the + // child's claim on P:0 is the only thing that can take the dead coin + // out of the unspent set. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&parent_output), + "a dead parent's output must not survive as a phantom spendable coin \ + just because the parent's own record was lost" + ); + assert!( + !row_exists(&conn, &w, &parent_output), + "and it must be deleted, not held — a spent_in_txid claim would lock \ + out the reinstatement below" + ); + } + + // The chainlocked return: P is reinstated with its output re-emitted, + // and nothing this sweep left behind may stand in its way. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's genuinely unspent output must restore even when \ + its record was lost at sweep time" + ); +} + +/// The second route into the co-swept-parent corner: P:0's row exists only +/// as the synthetic spent-only row `derive_spent_utxos` wrote when C's +/// record arrived IN ORDER (P's own record and funding never persisted — +/// weaker preconditions than the record-loss shape, no lost round needed). +/// The co-swept rule must treat it exactly like any other row for a dead +/// parent's output: DELETE it, never attribute it to the winner — a +/// `spent_in_txid` hold on it would survive into the upsert valve and lock +/// out P's chainlocked reinstatement forever, since no release ever names +/// a loser-funded outpoint. +#[test] +fn a_co_swept_parent_known_only_through_the_childs_spend_is_still_removed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xEA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x61); + let parent_txid = Txid::from_byte_array([0x60; 32]); // P — never recorded + let child_txid = Txid::from_byte_array([0x61; 32]); // C + let winner_txid = Txid::from_byte_array([0x62; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // C arrives in order, spending P:0 — the spent-utxos apply writes the + // synthetic spent-only row because no funded row exists. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(child_txid, vec![parent_output], vec![])], + spent_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &parent_output), + "sanity: the synthetic spent-only row exists" + ); + assert!(unspent(&conn, &w).is_empty()); + } + + // The batch sweeps both; P's pass has no record to walk, so only the + // co-swept rule in C's pass can decide the synthetic row's fate. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &parent_output), + "the dead parent's output must be deleted, not attributed to the winner" + ); + } + + // The chainlocked return: P reinstated with its output re-emitted must + // land spendable — nothing this sweep left behind may block the valve. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's output must restore even when its pre-sweep row \ + was only ever the synthetic spent-only one" + ); +} + +// ───────────────────────── tombstone collection ───────────────────────── +// +// A held-but-absent placeholder exists only for a block-context sweep and +// stores the winner's own mined height; `collect_finalized_tombstones` +// deletes it exactly when `min(chainlock_height, synced_height)` reaches +// that height — upstream's `prune_finalized_observed_spends` condition +// verbatim, no observation-age margin. These tests drive both the creation +// gate and the collector through ordinary `core_state::apply` rounds. + +fn chain_lock_at(height: u32) -> dashcore::ephemerealdata::chain_lock::ChainLock { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::BlockHash; + dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0xCC; 32]), + signature: BLSSignature::from([0u8; 96]), + } +} + +/// Apply a round carrying only chain progress: processed/synced watermarks +/// and a chainlock at `height`. +fn apply_heights(conn: &mut rusqlite::Connection, w: &WalletId, height: u32) { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(height), + synced_height: Some(height), + last_applied_chain_lock: Some(chain_lock_at(height)), + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); +} + +/// `(spent, height, winner_mined_height)` of a `core_utxos` row, or `None` +/// when absent. +fn utxo_row_state( + conn: &rusqlite::Connection, + w: &WalletId, + op: &OutPoint, +) -> Option<(bool, Option, Option)> { + let bytes = blob::encode_outpoint(op).unwrap(); + conn.query_row( + "SELECT spent, height, winner_mined_height FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .unwrap() +} + +/// Record a loser spending `input` (no funding row exists), then sweep it +/// in the given winner context — `Some(height)` leaves the held-but-absent +/// placeholder stamped with the winner's mined height, `None` (an +/// IS-locked, unmined winner) leaves the same placeholder unstamped, which +/// the collector never touches. +fn seed_tombstone( + conn: &mut rusqlite::Connection, + w: &WalletId, + input: OutPoint, + loser: Txid, + winner: Txid, + winner_mined_height: Option, +) { + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(loser, vec![input], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser], + superseded_by: winner, + winner_mined_height, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); +} + +/// A block-context placeholder stores the WINNER'S mined height and is +/// collected exactly when `min(chainlock_height, synced_height)` reaches +/// it — upstream's `prune_finalized_observed_spends` condition verbatim, +/// no observation-age margin. At that boundary the funding transaction of +/// the outpoint (necessarily mined at or below the winner's height) has +/// been filter-scanned with no false negatives, so an unmaterialised row +/// is provably not the wallet's coin. +#[test] +fn a_never_materialised_tombstone_is_collected_at_finality_and_not_before() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF1); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x50; 32]), 0); + let loser = Txid::from_byte_array([0x51; 32]); + let winner = Txid::from_byte_array([0x52; 32]); + + let mut conn = persister.lock_conn_for_test(); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "sanity: the sweep left a held, never-materialised row stamped with \ + the winner's own mined height — not any observation watermark" + ); + + // Boundary one below the winner's height: the winner's block is not + // yet inside the finality boundary, so the hold must survive. + apply_heights(&mut conn, &w, WINNER_HEIGHT - 1); + assert!( + row_exists(&conn, &w, &p), + "boundary {} has not reached the winner's height {} — the hold stays", + WINNER_HEIGHT - 1, + WINNER_HEIGHT + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "the boundary reaching the winner's height collects the row" + ); +} + +/// The reviewer's unrelated-advancement scenario, block-context half: the +/// chainlock can run arbitrarily far ahead, but while `synced_height` sits +/// below the winner's mined height the boundary has not reached the spend +/// and the hold must survive — the funding output could still be delivered +/// by the unscanned range. It collects the moment the synced height +/// catches up. +#[test] +fn a_block_context_tombstone_outlives_unrelated_advancement_below_its_winners_height() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x62; 32]), 0); + let loser = Txid::from_byte_array([0x63; 32]); + let winner = Txid::from_byte_array([0x64; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + + // Chainlocks race ahead by thousands of blocks; the filter scan has + // only reached one block short of the winner. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(WINNER_HEIGHT - 1), + last_applied_chain_lock: Some(chain_lock_at(WINNER_HEIGHT + 10_000)), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &p), + "min(chainlock, synced) = {} is below the winner's height {} — any \ + amount of unrelated chainlock progress must not collect the hold", + WINNER_HEIGHT - 1, + WINNER_HEIGHT + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(WINNER_HEIGHT), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !row_exists(&conn, &w, &p), + "the scan reaching the winner's height completes the boundary and collects" + ); +} + +/// A mempool-context sweep — an InstantSend-locked winner that has not +/// mined — preserves an UNSTAMPED tombstone for every held-but-unfunded +/// input. Under DIP-10 the IS lock alone settles those inputs: upstream's +/// `drop_conflicted_transactions` deletes the loser and retains them in +/// the account's `spent_outpoints`, a hold that carries no height and +/// that nothing can reconstruct from records once the loser is gone (the +/// winner need not be wallet-relevant). The row is that hold's only +/// durable carrier — `CORE_SWEEP_REMOVAL` requires every non-released +/// input to keep a durable spend claim before its funding TXO +/// materialises — and it is unstamped because an IS-locked winner has no +/// mining deadline, so no boundary may ever collect it; resolution is the +/// funding upsert, a later block-context re-stamp, or a release. +#[test] +fn a_mempool_context_sweep_preserves_an_unstamped_tombstone() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF8); + ensure_wallet_meta(&persister, &w); + + let mut conn = persister.lock_conn_for_test(); + // Several IS-context sweeps in a row, each with a distinct + // held-but-unfunded input. + for i in 0u8..3 { + let p = OutPoint::new(Txid::from_byte_array([0x70 + i; 32]), 0); + let loser = Txid::from_byte_array([0x80 + i; 32]); + let winner = Txid::from_byte_array([0x90 + i; 32]); + seed_tombstone(&mut conn, &w, p, loser, winner, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "an unmined IS-locked winner must leave a held, unstamped \ + placeholder for input #{i}" + ); + } + // Arbitrary chainlock/height advancement never collects an unstamped + // hold — two rounds, so a back-filling collector would be caught too. + apply_heights(&mut conn, &w, 1_000_000); + apply_heights(&mut conn, &w, 1_000_010); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1 \ + AND spent = 1 AND winner_mined_height IS NULL", + params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + rows, 3, + "every unstamped hold outlasts any boundary — only funding \ + materialisation, a block-context re-stamp, or a release resolves one" + ); +} + +/// The mempool-context sweep still spend-marks a coin that HAS +/// materialised: the row carries real funding data, so holding it costs +/// nothing an attacker controls, and the winner's own record (or its +/// eventual block delivery) is the durable evidence. Its stamp stays NULL +/// — a materialised row is outside the collector's reach anyway. +#[test] +fn a_mempool_context_sweep_still_spend_marks_a_materialised_coin() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x65); + let funding_txid = Txid::from_byte_array([0x66; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x67; 32]); + let winner = Txid::from_byte_array([0x68; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + seed_tombstone(&mut conn, &w, p, loser, winner, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "a materialised coin is spend-marked by the IS-locked winner, with \ + no stamp — its funding data is real and the collector never sees it" + ); +} + +/// The reviewer's named regression: an IS-locked winner sweeps on the +/// mempool path and never mines, the app restarts, chainlocks and heights +/// advance arbitrarily, and only then is the funding output delivered. +/// Under DIP-10 the IS lock already settled that input — upstream deleted +/// the loser and retained the hold in the account's `spent_outpoints`, a +/// set rebuilt from records on load that no surviving record can +/// reconstruct (the winner need not be wallet-relevant). The unstamped +/// tombstone is therefore the claim's only durable carrier, and the +/// funding upsert must land ON it and stay spent: crediting the coin +/// would hand coin selection an outpoint the network has provably +/// consumed. This is `CORE_SWEEP_REMOVAL`'s contract verbatim — every +/// non-released input retains a durable spend claim even before its +/// funding TXO materialises. +#[test] +fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_spent() { + let (persister, tmp, path) = fresh_persister(); + let w: WalletId = wid(0xFA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x69); + let funding_txid = Txid::from_byte_array([0x6A; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x6B; 32]); + let winner = Txid::from_byte_array([0x6C; 32]); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + seed_tombstone(&mut conn, &w, p, loser, winner, None); + } + // Restart. + drop(persister); + let cfg = SqlitePersisterConfig::new(&path); + let persister = SqlitePersister::open(cfg).expect("reopen"); + + let mut conn = persister.lock_conn_for_test(); + // Arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold. + apply_heights(&mut conn, &w, 25_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "the unstamped hold survives the restart and every boundary" + ); + + // The funding output is finally delivered and classified: the upsert + // materialises the row (real height, stamp stays clear) and the + // `spent_in_txid` valve keeps the coin spent. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !unspent(&conn, &w).contains(&p), + "an input the IS-locked winner consumed must never come back \ + spendable — the sweep's claim outlives the restart" + ); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "materialised on the tombstone: real funding height, still spent, \ + permanently outside the collector's reach" + ); + drop(conn); + drop(tmp); +} + +/// Synced height alone is not finality: with no chainlock ever persisted +/// the collector must not run, mirroring upstream's "no-op until a +/// chainlock has been applied". +#[test] +fn a_tombstone_is_never_collected_without_a_persisted_chainlock() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x53; 32]), 0); + let loser = Txid::from_byte_array([0x54; 32]); + let winner = Txid::from_byte_array([0x55; 32]); + + let mut conn = persister.lock_conn_for_test(); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(100), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(500), + synced_height: Some(500), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &p), + "without a chainlock there is no finality boundary — the hold must \ + outlast any amount of synced-height progress" + ); + + // The moment a chainlock does land, the boundary exists and the + // winner's height sits inside it — the row collects immediately. + apply_heights(&mut conn, &w, 500); + assert!( + !row_exists(&conn, &w, &p), + "the first persisted chainlock supplies the boundary and the \ + winner-height stamp collects" + ); +} + +/// The genuine claim the tombstone exists for: its funding output +/// classifies, the upsert's valve keeps it spent, and materialising +/// (gaining a real `height`) takes it out of the collector's reach forever. +#[test] +fn a_materialised_claim_is_never_collected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF3); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x61); + let funding_txid = Txid::from_byte_array([0x56; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x57; 32]); + let winner = Txid::from_byte_array([0x58; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "sanity: held, unmaterialised, stamped with the winner's height" + ); + + // The funding output classifies: the valve keeps the coin spent, the + // row gains real funding data, and the stale stamp clears. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "sanity: materialised — real height, stamp cleared, still spent" + ); + + apply_heights(&mut conn, &w, 10_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "a materialised claim is the wallet's own coin held spent — no \ + boundary may ever collect it" + ); +} + +/// A held, unmaterialised row with a NULL winner height is never +/// collected. The mempool-context sweep path writes exactly this shape +/// (an IS-locked, unmined winner has no finality horizon to stamp), and +/// legacy rows read identically — either way the safe reading is to hold +/// it forever rather than guess it collectible. +#[test] +fn a_tombstone_without_a_winner_height_is_never_collected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF4); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x59; 32]), 0); + let loser = Txid::from_byte_array([0x5A; 32]); + let winner = Txid::from_byte_array([0x5B; 32]); + + let mut conn = persister.lock_conn_for_test(); + // The real writer: an IS-context sweep of a loser whose funding row + // never arrived. + seed_tombstone(&mut conn, &w, p, loser, winner, None); + + // Two rounds, not one: a back-filling collector (the rejected design) + // would stamp the row on the first round and collect it on the second. + apply_heights(&mut conn, &w, 1_000_000); + apply_heights(&mut conn, &w, 1_000_010); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "no winner height, no proof of finality — the hold outlasts any boundary" + ); +} + +/// A chained sweep that re-points a still-unfunded claim to a new +/// block-context winner also re-stamps it with THAT winner's mined +/// height: the claim now belongs to a spend anchored at a later block, +/// and its collection horizon moves with it. +#[test] +fn a_repointed_tombstone_is_restamped_to_the_later_winners_height() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF5); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x5C; 32]), 0); + let first_loser = Txid::from_byte_array([0x5D; 32]); + let second_loser = Txid::from_byte_array([0x5E; 32]); + let final_winner = Txid::from_byte_array([0x5F; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone( + &mut conn, + &w, + p, + first_loser, + second_loser, + Some(WINNER_HEIGHT), + ); + assert_eq!( + utxo_row_state(&conn, &w, &p).and_then(|(_, _, s)| s), + Some(i64::from(WINNER_HEIGHT)), + "sanity: stamped with the first winner's mined height" + ); + + // The first winner is itself swept — by a winner mined 50 blocks + // later — still holding the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT + 50), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT + 50)))), + "the re-pointed claim is re-stamped to the later winner's mined height" + ); +} + +/// The IS-locked half of the chained case: an unmined winner re-points +/// the claim but must NOT disturb the earlier block-context stamp — +/// upstream's observed-spend entry is never retracted by an unconfirmed +/// conflict. Collection at the retained height stays sound (the funding +/// output is mined at or below the FIRST spender's height regardless of +/// who claims the coin now), so the row still collects at that boundary. +#[test] +fn a_mempool_repointed_tombstone_keeps_its_block_context_stamp() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFB); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x6D; 32]), 0); + let first_loser = Txid::from_byte_array([0x6E; 32]); + let second_loser = Txid::from_byte_array([0x6F; 32]); + let final_winner = Txid::from_byte_array([0x71; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone( + &mut conn, + &w, + p, + first_loser, + second_loser, + Some(WINNER_HEIGHT), + ); + + // The first winner is evicted by an IS-locked, unmined conflict that + // also claims the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: None, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "an unmined winner re-points the claim without touching the earlier \ + block-context stamp" + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "the retained stamp still bounds the row: the funding output sits at \ + or below the first spender's height, so the boundary reaching it \ + proves delivery-or-never" + ); +} + +/// The other direction of the chained case: an UNSTAMPED hold (IS-context +/// sweep) re-pointed by a later BLOCK-context sweep gains that winner's +/// stamp — the claim now belongs to a spend anchored in a real block, so +/// it enters the collectible set and the boundary reaching the new +/// winner's height collects it. This is one of the three resolution +/// channels that bound the unstamped population. +#[test] +fn an_unstamped_tombstone_restamped_by_a_block_context_sweep_becomes_collectible() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFC); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x72; 32]), 0); + let first_loser = Txid::from_byte_array([0x73; 32]); + let second_loser = Txid::from_byte_array([0x74; 32]); + let final_winner = Txid::from_byte_array([0x75; 32]); + + let mut conn = persister.lock_conn_for_test(); + // IS-context sweep: the hold lands unstamped. + seed_tombstone(&mut conn, &w, p, first_loser, second_loser, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "sanity: held and unstamped" + ); + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "the block-context re-point stamps the previously unstamped hold" + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "once stamped, the ordinary finality boundary collects the row" + ); +} + +/// Legacy shape self-heal: a zero-value released placeholder written +/// before the release path deleted them (`height` NULL, `spent = 0`) holds +/// no claim and is swept up by the collector's first pass — chainlock or +/// not — instead of reading as a phantom spendable coin forever. +#[test] +fn a_legacy_released_placeholder_is_swept_up_by_the_collector() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF6); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x60; 32]), 0); + let mut conn = persister.lock_conn_for_test(); + // Plant the pre-fix shape directly — the current release path can no + // longer produce it. + { + let bytes = blob::encode_outpoint(&p).unwrap(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 0, NULL)", + params![w.as_slice(), &bytes[..]], + ) + .unwrap(); + } + assert!( + unspent(&conn, &w).contains(&p), + "sanity: the legacy phantom" + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(100), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !row_exists(&conn, &w, &p), + "the first height-carrying round deletes the claimless leftover" + ); +} From b95a50d0c2004daa6d98e0dc993dc912dee0d3c6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:27:23 +0300 Subject: [PATCH 2/2] docs(platform-wallet-storage): document V007 and pin the materialised release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups. `SCHEMA.md` described `spent_in_txid` but not the three objects V007 creates, so the reference no longer matched the database: `core_utxos.winner_mined_height`, `core_sync_state.chainlock_height`, and the partial `idx_core_utxos_unmaterialized` covering exactly the unmaterialised rows. All three are now in the diagrams and the prose, including what the stamp decides (a placeholder's lifetime, never its existence) and why the funding upsert clears it. The second was raised as a missing release path for a materialised claim. The path exists — `apply` splits on `height IS NULL`, deleting an unmaterialised placeholder outright and freeing a materialised row in place — but nothing pinned that half: every other release test exercises the placeholder, so a release that silently skipped materialised rows would have left a live coin spent forever with nothing else able to free it, the collector being deliberately unable to take such a row. `a_release_frees_a_materialised_claim_in_place` closes that: seed a stamped tombstone, materialise it through the funding upsert, then have the winner itself swept with the coin released, and assert the row comes back unspent in place — keeping its funding data — and stays so across a restart. --- packages/rs-platform-wallet-storage/SCHEMA.md | 26 ++++++ .../tests/sqlite_transaction_sweeps.rs | 90 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index fd28bacdce4..da7f51156db 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -94,6 +94,7 @@ erDiagram INTEGER account_index INTEGER spent "0 | 1" BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" + INTEGER winner_mined_height "V007: sweep winner's mined height; NULL when unstamped or materialised" } CORE_INSTANT_LOCKS { @@ -115,6 +116,7 @@ erDiagram BLOB wallet_id PK "one row per wallet" INTEGER last_processed_height "NULL until first block processed" INTEGER synced_height "NULL until first sync" + INTEGER chainlock_height "V007: monotonic-max applied chainlock height; NULL until one is applied" } ``` @@ -392,9 +394,26 @@ referenced `core_transactions` row is deleted (instead of a native `ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` column) — and by a later sweep that releases the same outpoint. +`winner_mined_height` (V007) stamps that claim with the mined height of the +winner named in `spent_in_txid`, and decides the placeholder's lifetime +rather than its existence. A block-context sweep stamps the winner's own +height and `collect_finalized_tombstones` evicts the row once +`min(chainlock_height, synced_height)` reaches it — upstream's +`prune_finalized_observed_spends` boundary verbatim. An InstantSend-locked +winner that is not yet mined leaves it NULL: the lock alone settles the +input, but it carries no height to key a lifetime on, so the row resolves +only through proof (the funding upsert materialising it, a later +block-context sweep re-stamping it, or a release). The funding upsert +clears the stamp, because a materialised row is the wallet's own coin held +spent and is permanently outside the collector's reach. + - PK: `(wallet_id, outpoint)`. - FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. - Index: `idx_core_utxos_spent(wallet_id, spent)`. +- Index: `idx_core_utxos_unmaterialized(wallet_id, winner_mined_height) + WHERE height IS NULL` (V007) — covers exactly the unmaterialised rows, so + the collector's per-round scan touches tombstones rather than the + wallet's full spent history. ### `core_instant_locks` @@ -419,6 +438,13 @@ One row per wallet, holding monotonically-advancing SPV sync watermarks. `last_processed_height` and `synced_height` are NULL until the first block is processed. +`chainlock_height` (V007) mirrors `CoreChangeSet::last_applied_chain_lock` +as a monotonic max — the height alone, which this store previously dropped. +It is one half of the finality boundary +`collect_finalized_tombstones` collects sweep tombstones against, so a +tombstone is never collected before a chainlock has been persisted, +matching upstream's "no-op until a chainlock has been applied". + - PK: `wallet_id` (single-row-per-wallet). - FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 339cb3d5a95..6f4371c04fb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -2962,6 +2962,96 @@ fn a_materialised_claim_is_never_collected() { ); } +/// A MATERIALISED claim is releasable, and release is the only thing that +/// frees it. +/// +/// The collector deliberately never takes such a row +/// (`a_materialised_claim_is_never_collected`): once the funding output has +/// classified, the row carries real funding data and is the wallet's own coin +/// held spent, so no finality boundary may reclaim it. That leaves exactly one +/// way back — a later sweep naming the outpoint in `released_outpoints`, which +/// the unmaterialised path handles by DELETE and this one by an in-place +/// `spent = 0, spent_in_txid = NULL`. +/// +/// Pinned because the two paths diverge on `height IS NULL` and every other +/// release test exercises the placeholder half; without this one, a release +/// that silently skipped materialised rows would leave a live coin spent +/// forever with nothing else able to free it. +#[test] +fn a_release_frees_a_materialised_claim_in_place() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x62); + let funding_txid = Txid::from_byte_array([0x71; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x72; 32]); + let winner = Txid::from_byte_array([0x73; 32]); + let final_winner = Txid::from_byte_array([0x74; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + + // The funding output classifies: real data, stamp cleared, still spent. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "sanity: materialised — real height, stamp cleared, still spent" + ); + assert!( + !unspent(&conn, &w).contains(&p), + "sanity: a held coin is not spendable" + ); + + // The winner is itself swept, and this time the coin comes back free. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![winner], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![p], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((false, Some(10), None)), + "a released materialised claim is freed in place, keeping its funding data" + ); + assert!( + unspent(&conn, &w).contains(&p), + "and the coin is spendable again" + ); + + // Durable: the in-place release is not a memory-only flip. + drop(conn); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&p), + "the release must hold across a restart" + ); +} + /// A held, unmaterialised row with a NULL winner height is never /// collected. The mempool-context sweep path writes exactly this shape /// (an IS-locked, unmined winner has no finality horizon to stamp), and