diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 8149fb16e23..da7f51156db 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -93,7 +93,8 @@ 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" + 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" } ``` @@ -381,14 +383,37 @@ 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. + +`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` @@ -413,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/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..6f4371c04fb --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -0,0 +1,3323 @@ +#![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 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 +/// 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" + ); +}