From 600707d399c9e6b67578afe674d609b815de0273 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:40:02 +0300 Subject: [PATCH 1/9] fix(platform-wallet): act on swept transactions at the persistence seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the rust-dashcore pin to dev and projects the `TransactionsSwept` event the bump brings with it. The two halves are one commit by construction: `WalletEvent` is not `#[non_exhaustive]` and platform has four exhaustive matches over it, so new-pin code cannot compile without the arms — and arms that did nothing would be worse than none, because upstream's removal is unconditional. The wallet drops the losing rows in memory; a store that keeps them replays them at the next load and re-creates the phantom balance the upstream fix exists to kill. The projection is one `SweepBatch` per event, and a sweep-only round is counted in `is_empty_no_records` so a round carrying nothing but a sweep still reaches the persister. The gate is what makes every intermediate host state safe. A backend that has not attested `CORE_SWEEP_REMOVAL` is not known to have applied the round's subtractive half, so its watermark is stripped BEFORE the store and the wallet faults exactly as it would on a rejection — reporting the height durable first and faulting after cannot retract a height a legacy backend already committed. Such a host freezes its sync watermark on the first sweep it meets instead of diverging: fail-closed, funds-safe, and unfrozen the moment its persister ships. A record arriving after a sweep of the same txid retracts that txid from the folded sweep, since persisters write records before replaying sweeps and would otherwise delete a row the wallet has brought back. The asset-lock half mirrors it: a sweep removes the tracked entry its funding transaction created, and `AssetLockChangeSet::merge` now cancels a folded tombstone against a reinstating upsert (and vice versa), so no store ever sees an upsert/tombstone pair for one outpoint whose outcome depends on which it applies first. The pin also carries rust-dashcore#981, which collapses BIP-39 parsing onto one auto-detecting path. Platform's four hand-rolled "try every wordlist" helpers are now that function, and the call sites drop their `Language` argument. It is unrelated to sweeps and rides here only because the sweep chain and the payload-finalization seam this branch's base already depends on both sit above it on dev. `spend_observer`'s two projections gain sweep arms that report no observed spend: a sweep's released outpoints are coins that came back free, and the inputs it kept spent are precisely the ones it does not name, so the held set cannot be derived from the event at all. --- Cargo.lock | 24 +- Cargo.toml | 17 +- .../rs-platform-wallet-ffi/src/derivation.rs | 25 +- .../src/identity_keys_from_mnemonic.rs | 25 +- .../rs-platform-wallet-ffi/src/persistence.rs | 6 +- .../src/changeset/changeset.rs | 100 +- .../src/changeset/core_bridge.rs | 1035 ++++++++++++++++- .../src/manager/accessors.rs | 5 +- .../src/manager/dashpay_sync.rs | 5 +- .../rs-platform-wallet/src/manager/startup.rs | 4 +- .../src/manager/wallet_lifecycle.rs | 48 +- .../rs-platform-wallet/src/test_support.rs | 9 +- .../wallet/asset_lock/sync/reconstruction.rs | 67 ++ .../src/wallet/core/balance_handler.rs | 21 +- .../src/wallet/core/spend_observer.rs | 14 +- .../identity/network/contact_requests.rs | 11 +- .../src/wallet/identity/network/discovery.rs | 4 +- .../identity/network/identity_handle.rs | 8 +- .../src/wallet/identity/network/invitation.rs | 5 +- .../src/wallet/identity/network/loading.rs | 8 +- .../identity/network/payment_handler.rs | 48 +- .../src/wallet/identity/network/payments.rs | 65 +- .../wallet/identity/network/seed_binding.rs | 4 +- .../src/wallet/provider_key_at_index.rs | 8 +- .../src/mnemonic_resolver_core_signer.rs | 16 +- packages/rs-sdk-ffi/src/signer_simple.rs | 27 +- 26 files changed, 1354 insertions(+), 255 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5558a5e9169..392b352192e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "dashcore-private", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "glob" @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 74e08d20530..5fc7a2a957c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,15 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } - +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/rs-platform-wallet-ffi/src/derivation.rs b/packages/rs-platform-wallet-ffi/src/derivation.rs index bf34953c3ec..81b1020276d 100644 --- a/packages/rs-platform-wallet-ffi/src/derivation.rs +++ b/packages/rs-platform-wallet-ffi/src/derivation.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use dashcore::secp256k1::Secp256k1; use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -14,24 +14,11 @@ use crate::types::{FFINetwork, Network}; use crate::{check_ptr, unwrap_result_or_return}; fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs index ef4ad2bf93d..2afc08840fc 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -8,7 +8,7 @@ use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPu use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -55,24 +55,11 @@ pub(crate) unsafe fn zeroize_and_free_row(row: &mut IdentityKeyPreviewFFI) { /// Parse a BIP-39 mnemonic against every supported wordlist. pub(crate) fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Resolve a wallet's BIP-39 mnemonic via a Swift-owned diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2ba409b0eb8..2b0b23dbd49 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -7774,7 +7774,7 @@ mod tests { use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::Wallet; /// Regression: restored pool addresses must be tagged with the @@ -7923,7 +7923,6 @@ mod tests { // `account_collection_test.rs` uses. let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -7957,7 +7956,6 @@ mod tests { fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8073,7 +8071,6 @@ mod tests { fn test_managed_wallet_info_with_provider_owner() -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8516,7 +8513,6 @@ mod tests { fn account_xpub_survives_persist_restore_round_trip() { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 8227dcec755..22bd563d144 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -579,6 +579,29 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext) impl Merge for CoreChangeSet { fn merge(&mut self, other: Self) { + // A record arriving after a sweep that removed the same transaction + // reinstates it, and every persister writes records before replaying + // sweeps — so without this the sweep would delete a row the wallet + // has since brought back. Reachable through IS-lock precedence: an + // unconfirmed transaction is swept when an IS-locked conflict lands, + // then returns chainlocked and sweeps that conflict in turn. + // + // The release set stays as it is. It is the aggregate for every loser + // in the batch, so dropping it when one of them is reinstated would + // discard coins freed by the losers that are still going. Entries + // belonging to the reinstated transaction are inert on every backend: + // each scopes its release to the remaining losers' own inputs, or + // withholds any outpoint a surviving record claims — and the + // reinstating record is exactly such a claim. + if !other.records.is_empty() && !self.sweeps.is_empty() { + let reinstated: std::collections::HashSet = + other.records.iter().map(|record| record.txid).collect(); + for batch in &mut self.sweeps { + batch.txids.retain(|txid| !reinstated.contains(txid)); + } + self.sweeps.retain(|batch| !batch.txids.is_empty()); + } + // Records: coalesce by txid, NEWEST-WINS (dashpay/platform#4387). // // The event bridge already folded each event's per-account @@ -1338,32 +1361,47 @@ impl Merge for AssetLockChangeSet { // swift-sdk `persistAssetLocks`), making the store order of // racing snapshots immaterial. for (out_point, entry) in other.asset_locks { - if entry.status == AssetLockStatus::Consumed { - // A Consumed write supersedes any earlier-folded - // tombstone for the outpoint — Consumed rows are - // deliberately retained for historical lookup (see the - // variant doc), so the terminal write wins over a stale - // removal exactly as it wins over a stale status. - self.removed.remove(&out_point); - } else if let Some(existing) = self.asset_locks.get(&out_point) { - if existing.status == AssetLockStatus::Consumed { - continue; + if entry.status != AssetLockStatus::Consumed { + if let Some(existing) = self.asset_locks.get(&out_point) { + if existing.status == AssetLockStatus::Consumed { + continue; + } } } + // Every ACCEPTED upsert supersedes an earlier-folded tombstone + // for its outpoint, not just a Consumed one. Sweeps are a + // removal producer now (`remove_tracked_asset_locks_for_swept`), + // and a swept funding transaction can return chainlocked in the + // same folded drain — the reinstating record re-inserts the + // entry through reconstruction at a non-Consumed status, and + // letting the sweep's tombstone ride along would have the store + // delete the row it just reinstated (SQLite applies upserts + // before removals) while the in-memory wallet keeps it. This is + // the asset-lock mirror of `CoreChangeSet::merge`'s + // reinstated-txid retraction. For Consumed the same line also + // covers the historical rule: the terminal write wins over a + // stale removal exactly as it wins over a stale status. + self.removed.remove(&out_point); self.asset_locks.insert(out_point, entry); } - // Tombstones folded after a Consumed upsert are dropped for the - // same reason. The only removal emitter (`untrack_asset_lock`) - // fires exclusively for Built rows whose broadcast was - // definitively rejected, so a Consumed/removed pair for one - // outpoint has no legitimate producer — this is defense in - // depth matching the upsert guard. + // Tombstones folded after a Consumed upsert are dropped — Consumed + // rows are deliberately retained for historical lookup (see the + // variant doc). Any other pending upsert is dropped WITH the + // tombstone landing: a removal is upstream's newer word for the + // outpoint (a lock tracked and then swept, or a Built row rejected + // at broadcast, inside one fold), and carrying the dead upsert + // alongside the tombstone would make every store's correctness + // depend on applying upserts before removals. Together with the + // retraction above this keeps the invariant every backend relies + // on: a merged changeset never carries both an upsert and a + // tombstone for the same outpoint. for out_point in other.removed { let consumed = self .asset_locks .get(&out_point) .is_some_and(|entry| entry.status == AssetLockStatus::Consumed); if !consumed { + self.asset_locks.remove(&out_point); self.removed.insert(out_point); } } @@ -2374,10 +2412,38 @@ mod tests { folded.asset_locks[&outpoint].status, AssetLockStatus::Consumed ); - // …and a legitimate removal (rejected Built row) still folds. + // …and a legitimate removal (rejected Built row, or a sweep of the + // funding tx) still folds — taking the now-dead upsert with it, so + // no store ever sees an upsert/tombstone pair whose outcome would + // hinge on which it applies first. let mut folded = cs_with(AssetLockStatus::Built); folded.merge(removal()); assert!(folded.removed.contains(&outpoint)); + assert!( + !folded.asset_locks.contains_key(&outpoint), + "a tombstone folding in must not leave the dead upsert beside it" + ); + + // The coalesced sweep-then-chainlocked-reinstatement fold: the + // sweep removes the tracked entry and contributes a tombstone, then + // the reinstating record re-inserts through reconstruction at a + // non-Consumed status — in the SAME drain. The accepted upsert must + // cancel the earlier tombstone (the asset-lock mirror of + // `CoreChangeSet::merge`'s reinstated-txid retraction); otherwise + // SQLite — upserts before removals — deletes the row it just + // reinstated while the in-memory wallet keeps it, and the durable + // tracked lock is gone after restart even though its funding + // transaction survived. + let mut folded = removal(); + folded.merge(cs_with(AssetLockStatus::RecoveredFromChain)); + assert!( + folded.removed.is_empty(), + "a reinstating reconstruction must cancel the folded sweep tombstone" + ); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::RecoveredFromChain + ); } #[test] diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index f9b7f491977..0768867c4dc 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -51,9 +51,10 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ - AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, + AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, SweepBatch, }; use crate::changeset::merge::Merge; +use crate::changeset::persistence_capabilities::PersistenceCapabilities; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::asset_lock::sync::reconstruction; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -77,10 +78,24 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; /// Folding every event *already buffered* in the channel into one changeset /// per wallet collapses a burst of N events into a single store, so the /// drain keeps pace with the producer at projection speed. This is -/// exactly the fold [`Merge`] was specified for — `CoreChangeSet` merging -/// is commutative and associative, and its doc comment already anticipates -/// "a flush can fold multiple events together (TransactionDetected + -/// BlockProcessed for the same wallet over a sync round)". +/// exactly the fold [`Merge`] was specified for — an ORDERED left fold in +/// channel-arrival order. `CoreChangeSet` merging is associative but NOT +/// commutative, so regrouping the fold is safe but reordering or +/// parallelizing it is not: sweep-aware merging deliberately depends on +/// operand order in two ways. A record arriving after a sweep of the same +/// txid retracts that sweep (reinstatement), while a sweep arriving after +/// the record survives the merge and deletes the row at apply time — +/// swapping the operands swaps which of those happens. And sweep batches +/// append in emission order because each release set is only true of the +/// wallet as that sweep saw it, so a later batch keeping a coin spent must +/// replay after the earlier batch that freed it. (The IS-lock map's +/// last-write-wins and the chain-lock equal-height tie-break also take the +/// later operand.) A reordered fold can therefore persist a different +/// spend decision, not just a differently-arranged changeset. The doc +/// comment on [`Merge`] states the same contract and already anticipates +/// this fold: "a flush can fold multiple events together +/// (TransactionDetected + BlockProcessed for the same wallet over a sync +/// round)". /// /// The cap bounds the worst-case size of a single merged changeset (and /// hence one Room transaction), and keeps a saturated producer from @@ -587,13 +602,39 @@ where P: PlatformWalletPersistence + ?Sized, { let mut diag = BatchDiagnostics::new(folded, batch.len()); - for ( - wallet_id, - WalletBatch { - mut core, - asset_locks, - }, - ) in batch + for (wallet_id, wallet_batch) in batch { + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + settled, + ); + } + diag +} + +/// Commit one wallet's folded changeset — the per-wallet unit of +/// [`commit_batch`]. +fn commit_wallet

( + persister: &P, + wallet_id: WalletId, + wallet_batch: WalletBatch, + diag: &mut BatchDiagnostics, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + freeze_logged: &AtomicBool, + settled: &mut Vec, +) where + P: PlatformWalletPersistence + ?Sized, +{ + let WalletBatch { + mut core, + asset_locks, + } = wallet_batch; { // Hold this wallet's durable watermark at the last fully persisted // height once it has faulted. Records/UTXOs still persist — only the @@ -616,11 +657,40 @@ where // SyncHeightAdvanced for an unknown wallet, empty BlockProcessed, a // watermark-only batch stripped by the fault guard above, etc. — // nothing to persist. Skip the round-trip. - continue; + return; } // The height this changeset OFFERS to the store. It is counted as // persisted only in the `Ok` arm below. let offered_height = core.synced_height; + + // Sweeps reach an FFI host only through the persistence extension's + // size-negotiated sweep callback, and Rust never calls a slot the + // host's declared `struct_size` did not prove — so a persister + // predating that slot (an old C host, or a Kotlin subclass that + // never overrode `onWalletChangesetTransactionsSwept`) processes the + // rest of the round normally and returns success without ever + // seeing `core.sweeps` at all. `store()` coming back `Ok` in that + // case proves nothing about whether the removal actually happened, + // so it is checked separately from the result below rather than + // folded into it. + let sweep_removal_unsupported = !core.sweeps.is_empty() + && !persister + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + if sweep_removal_unsupported { + // Strip the watermark from THIS round, not just later ones. The + // adapter folds whatever is buffered, so a `TransactionsSwept` + // and a following `SyncHeightAdvanced` land in one changeset — + // and `synced_height` lives in the unchanged prefix such a + // persister does read. Letting it through would commit a height + // that claims blocks are scanned while the removal those blocks + // implied never landed, and the fault below cannot retract a + // watermark the backend has already made durable. `offered_height` + // keeps the original so the rejection is still diagnosed as a + // withheld advance rather than as a round that carried none. + core.synced_height = None; + } + let cs = PlatformWalletChangeSet { core: Some(core), // Tracked-asset-lock rows reconstructed from this drain's @@ -639,6 +709,38 @@ where // `run_wallet_event_adapter`. settled.push(wallet_id); match store_result { + Ok(()) if sweep_removal_unsupported => { + // The write nominally succeeded, but a backend that never + // attested `CORE_SWEEP_REMOVAL` is not known to have applied + // the one subtractive part of this round — reporting it + // durable would let the swept loser return at the next + // `load()`. Fault exactly like a rejection: the next scan + // re-emits the sweep and the idempotent removal is retried + // against (hopefully, by then) a capable backend. + if fault_and_freeze( + diag, + offered_height, + fault, + sync_fault, + wallet_id, + is_faulted, + freeze_logged, + ) { + log::error!( + "SYNC WATERMARK FROZEN: persister for wallet {} does not advertise \ + CORE_SWEEP_REMOVAL but this round swept one or more transactions; a \ + removal must never be reported durable to a backend that cannot apply \ + it, so the sync watermark is held back (dashpay/platform#4406).", + hex::encode(wallet_id) + ); + } + tracing::error!( + wallet_id = %hex::encode(wallet_id), + "Persister lacks CORE_SWEEP_REMOVAL for a changeset carrying sweeps; \ + freezing this wallet's sync watermark rather than trusting an unversioned \ + store() success" + ); + } Ok(()) => { if let Some(h) = offered_height { diag.record_persisted(h); @@ -648,19 +750,15 @@ where // A rejected changeset means these rows are not on disk. Fault // THIS wallet's watermark so it can't outrun them; the next // scan re-emits and the idempotent upserts recover the state. - if let Some(h) = offered_height { - diag.record_rejected(h); - } - fault.fault_wallet(wallet_id, sync_fault); - // Count each faulted wallet once per drain: a wallet that - // entered already faulted was counted at the top of the loop, - // and a repeat rejection must not count it again. - if !is_faulted { - diag.faulted += 1; - } - // One-shot, unambiguous logcat marker via the `log` facade - // (android_logger forwards `log` to logcat; `tracing` may not). - if !freeze_logged.swap(true, Ordering::Relaxed) { + if fault_and_freeze( + diag, + offered_height, + fault, + sync_fault, + wallet_id, + is_faulted, + freeze_logged, + ) { log::error!( "SYNC WATERMARK FROZEN: persister rejected a changeset for wallet {} ({}); \ its durable sync height is now held so the next scan re-persists the \ @@ -677,7 +775,35 @@ where } } } - diag +} + +/// The bookkeeping shared by the two ways a round fails to be durably +/// applied — a rejected `store()`, and a nominal success from a backend +/// that cannot have applied the round's sweeps. Records the withheld +/// advance, faults the wallet (counting it once per drain: a wallet that +/// entered already faulted was counted at the top of the loop, and a +/// repeat failure must not count it again), and returns whether this is +/// the drain's first freeze — the caller owns the one-shot `log`-facade +/// line, whose wording differs per cause (android_logger forwards `log` +/// to logcat; `tracing` may not). +fn fault_and_freeze( + diag: &mut BatchDiagnostics, + offered_height: Option, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + wallet_id: WalletId, + entered_faulted: bool, + freeze_logged: &AtomicBool, +) -> bool { + if let Some(h) = offered_height { + diag.record_rejected(h); + } + fault.fault_wallet(wallet_id, sync_fault); + if !entered_faulted { + diag.faulted += 1; + } + // One-shot: only the first freeze of the session logs. + !freeze_logged.swap(true, Ordering::Relaxed) } /// Durable-watermark guard for dashpay/platform#4069. @@ -769,6 +895,23 @@ async fn reconstruct_asset_locks_for_event( ) .await; } + // The subtractive arm: a swept funding tx can never confirm, so + // every tracked lock it funds is dead. Nothing else cascades the + // sweep into this table — without this arm the entry is a zombie + // `resume_asset_lock` re-broadcasts and waits on without bound, + // mirrored forever by every store. A chainlocked return re-emits + // the funding record through the arms above, which re-insert the + // entry, so removal here is not a one-way door. + WalletEvent::TransactionsSwept { + wallet_id, txids, .. + } => { + return reconstruction::remove_tracked_asset_locks_for_swept( + wallet_manager, + wallet_id, + txids, + ) + .await; + } _ => return AssetLockChangeSet::default(), }; if candidates.is_empty() { @@ -938,6 +1081,54 @@ async fn build_core_changeset( cs.account_highest_used = account_highest_used; cs } + WalletEvent::TransactionsSwept { + txids, + superseded_by, + winner_mined_height, + released_outpoints, + .. + } => { + // The only subtractive event upstream emits. Each txid was a + // recorded spend that `superseded_by` beat to an input, so it can + // never confirm and the wallet has already dropped it. Mirroring + // the removal is not optional: every other arm here appends, so a + // persister that skipped this would keep the dead rows, hand them + // back on the next load, and re-create the balance the wallet + // just corrected — the exact bug the upstream sweep fixes. + // + // No `spent_utxos` entry for the inputs: a wallet-relevant winner + // claims them through its own record. This arm names the dead and + // the coins their removal freed — the persister holds every input + // of what it deletes, so `released_outpoints` is the only thing + // that tells it which of those to hand back. It cannot work that + // out from the txids: the transaction that took the rest may + // never appear in this wallet's stream at all. + tracing::debug!( + swept = txids.len(), + released = released_outpoints.len(), + superseded_by = %superseded_by, + winner_mined_height = ?winner_mined_height, + "Mirroring swept transactions to the persister" + ); + CoreChangeSet { + sweeps: vec![SweepBatch { + txids: txids.clone(), + superseded_by: *superseded_by, + // The winner's finality context rides with the batch: + // only the event has it (the winner may never appear in + // this wallet's records), and every persister keys the + // lifetime of a held-but-unfunded placeholder on it — + // `Some` anchors the hold at a height that chainlocks, + // `None` (IS-locked, unmined) leaves the hold unstamped + // and uncollectible, the durable stand-in for the + // `spent_outpoints` retention upstream cannot rebuild + // once the loser's record is gone. + winner_mined_height: *winner_mined_height, + released_outpoints: released_outpoints.clone(), + }], + ..CoreChangeSet::default() + } + } WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { synced_height: Some(*height), ..CoreChangeSet::default() @@ -1407,6 +1598,7 @@ impl CoreChangeSet { fn is_empty_no_records(&self) -> bool { self.records.is_empty() && self.account_records.is_empty() + && self.sweeps.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -1419,6 +1611,289 @@ impl CoreChangeSet { } } +#[cfg(test)] +mod swept_transaction_projection_tests { + //! Coverage for the one subtractive arm of [`build_core_changeset`]. + //! + //! A sweep carries txids and no records, so it has to survive the + //! `is_empty_no_records` filter on the strength of the txids alone — + //! that filter is what decides whether the persister is called at all, + //! and a sweep that never reaches it leaves the dead rows on disk. + + use super::*; + use dashcore::hashes::Hash; + use dashcore::Txid; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletManager; + + const WALLET_ID: WalletId = [7u8; 32]; + + fn test_manager() -> Arc>> { + Arc::new(RwLock::new(WalletManager::::new( + dashcore::Network::Testnet, + ))) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: txid(byte), + vout, + } + } + + /// A minimal record for `txid` — only its identity matters here, since + /// the merge keys reinstatement on the txid alone. + fn record_for(txid: Txid) -> TransactionRecord { + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record + } + + /// Mined height every block-context sweep event in these tests carries. + const WINNER_HEIGHT: u32 = 700; + + fn swept(txids: Vec) -> WalletEvent { + swept_releasing(txids, vec![]) + } + + fn swept_releasing(txids: Vec, released_outpoints: Vec) -> WalletEvent { + WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids, + superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints, + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + #[tokio::test] + async fn sweep_names_the_dead_transactions_and_nothing_else() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; + + assert_eq!( + cs.sweeps, + vec![SweepBatch { + txids: vec![txid(1), txid(2)], + superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }] + ); + // A wallet-relevant winner claims the inputs through its own + // record; this arm must not invent UTXO deltas of its own. + assert!(cs.records.is_empty(), "a sweep carries no records"); + assert!(cs.spent_utxos.is_empty(), "a sweep spends nothing"); + assert!(cs.new_utxos.is_empty(), "a sweep creates nothing"); + } + + /// An IS-locked winner's sweep carries `winner_mined_height: None` + /// through to the batch untouched. Every persister keys the lifetime of + /// a held-but-unfunded placeholder on this field — a bridge that + /// fabricated a height here would hand the placeholder a finality + /// horizon the winner does not have, and one that dropped the `Some` + /// leg would make block-context holds uncollectible. + #[tokio::test] + async fn sweep_carries_the_winners_finality_context_verbatim() { + let event = WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids: vec![txid(1)], + superseded_by: txid(0xff), + winner_mined_height: None, + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + let cs = build_core_changeset(&test_manager(), &event).await; + assert_eq!( + cs.sweeps[0].winner_mined_height, None, + "an unmined IS-locked winner must cross the bridge with no mined height" + ); + } + + #[tokio::test] + async fn sweep_reaches_the_persister() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; + + assert!( + !cs.is_empty_no_records(), + "a sweep-only round must not be filtered out as empty — that \ + filter decides whether the persister is called at all" + ); + assert!(!Merge::is_empty(&cs)); + } + + /// The released set is what a persister acts on, so it has to survive + /// the projection intact — it cannot be recovered from the txids, since + /// the transaction that took the remaining inputs may never appear here. + #[tokio::test] + async fn sweep_carries_the_outpoints_it_released() { + let cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![outpoint(9, 1)]), + ) + .await; + + assert_eq!(cs.sweeps[0].released_outpoints, vec![outpoint(9, 1)]); + } + + /// An ordinary resend frees nothing: the winner took every input the + /// removed transaction named. + #[tokio::test] + async fn a_sweep_that_freed_nothing_releases_nothing() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; + + assert!(cs.sweeps[0].released_outpoints.is_empty()); + } + + /// Merging keeps every sweep as its own batch, in arrival order. + /// + /// Folding them would lose the only thing that makes a later sweep able + /// to correct an earlier one — see the ordering test below, which is the + /// case that actually breaks. + #[tokio::test] + async fn merged_sweeps_stay_separate_and_ordered() { + let mut cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; + let second = build_core_changeset(&test_manager(), &swept(vec![txid(3)])).await; + + cs.merge(second); + + assert_eq!(cs.sweeps.len(), 2); + assert_eq!(cs.sweeps[0].txids, vec![txid(1), txid(2)]); + assert_eq!(cs.sweeps[1].txids, vec![txid(3)]); + } + + /// A record arriving after a sweep of the same transaction reinstates + /// it. Every persister writes records before replaying sweeps, so a + /// buffered sweep would otherwise delete a row the wallet has since + /// brought back. + /// + /// Reachable through IS-lock precedence: an unconfirmed transaction is + /// swept when an IS-locked conflict arrives, then returns chainlocked + /// and sweeps that conflict in turn — leaving one round holding both + /// removals plus the reinstating record. + #[tokio::test] + async fn a_record_arriving_after_its_sweep_survives_the_round() { + let reinstated = txid(1); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![reinstated], vec![outpoint(9, 1)]), + ) + .await; + assert_eq!( + cs.sweeps.len(), + 1, + "sanity: the sweep is there to begin with" + ); + + // The wallet records it again, which is the newer fact. + let mut later = CoreChangeSet::default(); + later.records.push(record_for(reinstated)); + cs.merge(later); + + assert!( + cs.sweeps.is_empty(), + "the sweep must not delete a transaction the wallet brought back" + ); + assert_eq!(cs.records.len(), 1); + } + + /// Only the reinstated transaction leaves the batch; anything else it + /// removed still goes — and so does everything that batch freed. + /// + /// `released_outpoints` is the aggregate for every loser in the batch, so + /// dropping it would discard coins freed by the losers still going. The + /// entries belonging to the reinstated transaction do no harm: every + /// backend either scopes its release to the remaining losers' own inputs + /// or withholds an outpoint a surviving record claims, and the + /// reinstating record is exactly such a claim. + #[tokio::test] + async fn a_reinstated_record_only_rescues_its_own_transaction() { + let reinstated = txid(1); + let still_dead = txid(2); + let freed_by_the_survivor = outpoint(9, 2); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![reinstated, still_dead], vec![freed_by_the_survivor]), + ) + .await; + let mut later = CoreChangeSet::default(); + later.records.push(record_for(reinstated)); + cs.merge(later); + + assert_eq!(cs.sweeps.len(), 1); + assert_eq!(cs.sweeps[0].txids, vec![still_dead]); + assert_eq!( + cs.sweeps[0].released_outpoints, + vec![freed_by_the_survivor], + "a coin the still-swept loser freed must survive the reinstatement" + ); + } + + /// A release is only true of the wallet the sweep that made it saw. A + /// later sweep can remove the transaction that re-spent the freed coin + /// while keeping the coin spent, because its own winner took it — and + /// that answer has to win, since it is the later one. + /// + /// Unioning the release sets loses exactly this: the earlier "B is free" + /// outlives the later "B is spent", and every backend then persists a + /// coin the chain consumed as spendable. + #[tokio::test] + async fn a_later_sweep_that_keeps_a_coin_spent_outlives_an_earlier_release() { + let freed = outpoint(9, 1); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![freed]), + ) + .await; + // The second sweep removes the transaction that took `freed` and + // releases nothing: its own winner consumed that coin. + let second = + build_core_changeset(&test_manager(), &swept_releasing(vec![txid(2)], vec![])).await; + + cs.merge(second); + + assert_eq!( + cs.sweeps.len(), + 2, + "the two answers must stay distinguishable" + ); + assert_eq!(cs.sweeps[0].released_outpoints, vec![freed]); + assert!( + cs.sweeps[1].released_outpoints.is_empty(), + "the later sweep kept the coin spent, and applying it after the \ + first is what makes that stick" + ); + } +} + #[cfg(test)] mod contact_watch_only_projection_tests { //! Regression coverage for the persist-time projection of records @@ -2859,6 +3334,7 @@ mod tests { last_processed_height: Option, n_records: usize, n_asset_locks: usize, + n_asset_locks_removed: usize, rejected: bool, } @@ -2880,6 +3356,7 @@ mod tests { /// Raised as soon as a blocked `store()` is entered, so a test can wait /// for the block to be in effect rather than sleeping and hoping. blocked: Arc, + capabilities: crate::changeset::PersistenceCapabilities, } impl ProbePersister { @@ -2890,6 +3367,19 @@ mod tests { panic_once: Mutex::new(HashSet::new()), block_until: Mutex::new(None), blocked: Arc::new(AtomicBool::new(false)), + capabilities: crate::changeset::PersistenceCapabilities::NONE, + } + } + /// A probe that additionally attests `capabilities` — used by the + /// `CORE_SWEEP_REMOVAL` gate tests, which need a persister on record + /// as (not) supporting the sweep contract. + fn with_capabilities( + obs: UnboundedSender, + capabilities: crate::changeset::PersistenceCapabilities, + ) -> Self { + Self { + capabilities, + ..Self::new(obs) } } /// Park the next `store()` until the returned sender is dropped or @@ -2908,6 +3398,10 @@ mod tests { } impl PlatformWalletPersistence for ProbePersister { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + self.capabilities + } + fn store( &self, wallet_id: WalletId, @@ -2936,6 +3430,11 @@ mod tests { .as_ref() .map(|a| a.asset_locks.len()) .unwrap_or(0), + n_asset_locks_removed: changeset + .asset_locks + .as_ref() + .map(|a| a.removed.len()) + .unwrap_or(0), rejected, }); if rejected { @@ -3075,7 +3574,10 @@ mod tests { // 3) Sentinel proving the loop moved past the watermark. tx.send(block_processed_event(wallet_id, 20)).unwrap(); - let sentinel = obs_rx.recv().await.expect("sentinel store must arrive"); + let sentinel = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the sentinel store must arrive rather than hanging the suite") + .expect("sentinel store must arrive"); assert_eq!( sentinel.last_processed_height, Some(20), @@ -3618,6 +4120,212 @@ mod tests { } } + /// Mined height every block-context sweep event in this module carries. + const WINNER_HEIGHT: u32 = 700; + + /// A `TransactionsSwept` event for a helper below. + fn swept_event(wallet_id: WalletId, txid_byte: u8, superseded_by_byte: u8) -> WalletEvent { + use dashcore::hashes::Hash as _; + WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![dashcore::Txid::from_byte_array([txid_byte; 32])], + superseded_by: dashcore::Txid::from_byte_array([superseded_by_byte; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + /// dashpay/platform#4406 (finding 2): sweeps reach an FFI host only + /// through the persistence extension's size-negotiated sweep slot, so a + /// persister predating it processes the rest of the round and returns + /// success without ever seeing `core.sweeps`. A `store()` that comes + /// back `Ok` therefore proves nothing about whether a swept loser's + /// row was actually removed unless the persister has separately + /// attested `CORE_SWEEP_REMOVAL`. A persister that never declares it + /// (the probe's default) must be treated exactly like a rejection when + /// a round carries a sweep — even though, unlike the rejection tests + /// above, the probe's own `store()` call reports success. + #[tokio::test] + async fn sweep_without_declared_capability_freezes_the_wallet_despite_a_successful_store() { + let wallet_id = [21u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared — the pre-`CORE_SWEEP_REMOVAL` shape. + let persister = Arc::new(ProbePersister::new(obs_tx)); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + tx.send(swept_event(wallet_id, 0x51, 0x52)).unwrap(); + let first = obs_rx + .recv() + .await + .expect("the round is still handed to store()"); + assert!( + !first.rejected, + "the probe's own store() must succeed — the gate lives in the \ + adapter, not in a persister that has no idea sweeps exist" + ); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !sync_fault.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + }) + .await + .expect( + "the fail-closed guard must trip for an undeclared sweep even \ + though store() itself reported success", + ); + + // A later watermark-only event must be stripped just like it would + // be after a real store() rejection. + tx.send(sync_height_event(wallet_id, 500)).unwrap(); + tx.send(block_processed_event(wallet_id, 40)).unwrap(); + let sentinel = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the sentinel store must arrive rather than hanging the suite") + .expect("sentinel store must arrive"); + assert_eq!(sentinel.last_processed_height, Some(40)); + assert_eq!( + sentinel.synced_height, None, + "the watermark must stay frozen: a removal must never be \ + reported durable to a backend that never attested it can apply it" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// The coalesced shape of the same gap, which is the one that actually + /// loses data. The adapter folds whatever is buffered, so a sweep and a + /// following watermark advance arrive in ONE changeset — and + /// `synced_height` sits in the unchanged prefix a pre-sweep persister + /// does read and commit. + /// + /// Faulting after `store()` returns cannot retract a watermark the + /// backend has already made durable: on the next launch the wallet + /// believes those blocks are scanned, never re-matches them, and the + /// removal that round carried is lost for good. So the height has to be + /// stripped before the changeset is handed over, not after. + #[tokio::test] + async fn a_coalesced_sweep_and_watermark_never_commits_the_height() { + let wallet_id = [23u8; 32]; + let (tx, rx) = unbounded_channel::(); + // Buffered before the adapter starts, so both events are guaranteed + // to land in the same drain rather than racing it. + tx.send(swept_event(wallet_id, 0x61, 0x62)).unwrap(); + tx.send(sync_height_event(wallet_id, 900)).unwrap(); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared — the pre-`CORE_SWEEP_REMOVAL` shape. + let persister = Arc::new(ProbePersister::new(obs_tx)); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + // Bounded like the neighbouring capability tests below: both the + // adapter and `ProbePersister` hold their own sender, so a + // regression that stops the folded round from reaching `store()` + // would otherwise hang this test instead of failing its assertion. + let observed = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the folded round reaches store() within the timeout") + .expect("the folded round reaches store()"); + assert_eq!( + observed.synced_height, None, + "an unattested persister must never be handed the watermark of a \ + round whose removal it cannot apply" + ); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !sync_fault.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + }) + .await + .expect("the fail-closed guard must still trip for the folded round"); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// The positive case for the same gate: a persister that attests + /// `CORE_SWEEP_REMOVAL` is trusted normally, and the watermark keeps + /// advancing through a sweep-bearing round exactly as it would through + /// any other. + #[tokio::test] + async fn sweep_with_declared_capability_does_not_freeze() { + let wallet_id = [22u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + tx.send(swept_event(wallet_id, 0x61, 0x62)).unwrap(); + // A watermark-bearing event right behind it, folded or not — either + // way it must reach the store untouched while the capability holds. + tx.send(sync_height_event(wallet_id, 700)).unwrap(); + + let mut last_synced = None; + // Drain until a store carries the watermark. Each receive is bounded: + // the adapter and the probe both hold the sender alive, so a plain + // `recv()` would never report the channel quiet — a regression that + // stops the watermark would hang here until the suite's own timeout + // instead of failing on the assertion below. + for _ in 0..10 { + match tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()).await { + Ok(Some(observed)) => { + assert!(!observed.rejected); + if let Some(h) = observed.synced_height { + last_synced = Some(h); + break; + } + } + Ok(None) | Err(_) => break, + } + } + assert_eq!( + last_synced, + Some(700), + "the watermark must advance normally once the backend attests \ + CORE_SWEEP_REMOVAL" + ); + assert!( + !sync_fault.load(Ordering::Relaxed), + "an attested backend must never trip the fail-closed guard" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + /// End-to-end restore-scan shape through the real adapter loop: a /// `BlockProcessed` event whose inserted record is an asset-lock tx /// filed under a funding account must (a) repopulate the wallet's @@ -3747,6 +4455,277 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The `TransactionsSwept` arm end to end: a sweep naming a tracked + /// lock's funding tx must drop the in-memory entry and carry the + /// tombstone to the persister through the same `removed` channel a + /// rejected-at-broadcast `Built` row uses. A swept funding tx can + /// never confirm, so without this the entry is a zombie + /// `resume_asset_lock` re-broadcasts and waits on without bound, and + /// every store mirrors it forever. + #[tokio::test] + async fn transactions_swept_removes_the_tracked_asset_lock_it_funded() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // Attested for sweeps AND payments: the removal must ride an + // ordinary round, and the flip's overlay is only staged for a + // payment-durable backend. + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), + )); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let sync_fault = Arc::new(AtomicBool::new(false)); + let handle = spawn_wallet_event_adapter( + Arc::clone(&wallet_manager), + Arc::clone(&persister), + event_rx, + Arc::clone(&sync_fault), + cancel.clone(), + ); + + // Track the lock the same way a restore scan would. + event_tx + .send(WalletEvent::BlockProcessed { + wallet_id, + height: 4321, + chain_lock: None, + inserted: vec![record], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }) + .expect("send reconstruction event"); + let observed = obs_rx.recv().await.expect("reconstruction store"); + assert_eq!(observed.n_asset_locks, 1, "sanity: the entry is tracked"); + + // The funding tx is swept. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![tx.txid()], + superseded_by: dashcore::Txid::from_byte_array([0x77; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep event"); + + let observed = obs_rx.recv().await.expect("sweep store"); + assert_eq!( + observed.n_asset_locks_removed, 1, + "the dead lock's tombstone must ride the sweep's own store()" + ); + + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + { + let wm = wallet_manager.read().await; + assert!( + !wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .contains_key(&out_point), + "the in-memory entry must not outlive its swept funding tx" + ); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// The coalesced sweep-then-chainlocked-reinstatement fold, driven + /// through the REAL producers rather than hand-built changesets: the + /// sweep arm removes the tracked entry and emits its tombstone, the + /// reinstating chainlocked record re-inserts through reconstruction at + /// a non-Consumed status, and folding the two — exactly what the + /// adapter's batched drain does — must cancel the tombstone. Before + /// `AssetLockChangeSet::merge` learned that, the merged changeset + /// carried both, and SQLite (upserts before removals) deleted the row + /// it had just reinstated while the in-memory wallet kept it: the + /// durable tracked lock vanished across a restart even though its + /// funding transaction survived. + #[tokio::test] + async fn a_reinstating_reconstruction_folded_after_a_sweep_cancels_its_tombstone() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use crate::changeset::merge::Merge as _; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::sync::reconstruction; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + + // Track the lock the way a restore scan would. + let tracked = reconstruction::reconstruct_tracked_asset_locks( + &wallet_manager, + &wallet_id, + &[&record], + ) + .await; + assert_eq!(tracked.asset_locks.len(), 1, "sanity: the entry is tracked"); + + // The sweep's own changeset, then the reinstating record's — the + // two events a single folded drain can carry back to back. + let mut folded = reconstruction::remove_tracked_asset_locks_for_swept( + &wallet_manager, + &wallet_id, + &[tx.txid()], + ) + .await; + assert!( + folded.removed.contains(&out_point), + "sanity: the sweep produced the tombstone" + ); + let reinstated = reconstruction::reconstruct_tracked_asset_locks( + &wallet_manager, + &wallet_id, + &[&record], + ) + .await; + let reinstated_entry = reinstated + .asset_locks + .get(&out_point) + .expect("reconstruction must re-insert the entry the sweep removed"); + assert_ne!( + reinstated_entry.status, + AssetLockStatus::Consumed, + "sanity: the load-bearing premise — a reinstating reconstruction is non-Consumed" + ); + folded.merge(reinstated); + + assert!( + folded.removed.is_empty(), + "the reinstating upsert must cancel the folded sweep tombstone" + ); + assert!( + folded.asset_locks.contains_key(&out_point), + "and the reinstated entry rides the store round" + ); + } + /// The `ChainLockProcessed` arm end to end: a lock the scan /// reconstructed at a pre-finality status (its block wasn't /// chain-locked yet — the restore-scan norm) upgrades to diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 0f77a3a4b70..880675fffb0 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1198,7 +1198,7 @@ fn tx_record_snapshot(rec: &TransactionRecord) -> AccountTransactionSnapshot { mod spv_rescan_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1245,8 +1245,7 @@ mod spv_rescan_tests { Arc::new(NoopPersister), event_handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 7c1b45e1d7e..ca35adb4dbc 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -525,7 +525,7 @@ impl std::fmt::Debug for DashPaySyncManager { mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -585,8 +585,7 @@ mod tests { /// registry, which is exactly the case that registry-driven DashPay /// sync would skip. async fn register_test_wallet(manager: &Arc>) -> WalletId { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index 168512f4d40..8d62dbcfae1 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -1378,8 +1378,8 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - use key_wallet::mnemonic::{Language, Mnemonic}; - Mnemonic::from_phrase(phrase, Language::English) + use key_wallet::mnemonic::Mnemonic; + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 270d9ff6aa2..da69f8f777d 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dash_spv::chain::CheckpointManager; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -31,24 +31,11 @@ use super::PlatformWalletManager; /// "invalid English". BIP-39 wordlists are mutually exclusive per /// phrase, so the first match is unambiguous. fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], @@ -926,7 +913,7 @@ impl PlatformWalletManager

{ #[cfg(test)] mod scoped_wallet_id_tests { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -937,8 +924,7 @@ mod scoped_wallet_id_tests { abandon abandon abandon abandon abandon about"; fn wallet_id_for(network: Network) -> [u8; 32] { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -953,8 +939,7 @@ mod scoped_wallet_id_tests { /// "Networks" section can group a seed's sibling-network wallets. /// Mirrors the `register_wallet` derivation exactly. fn wallet_group_id_for(network: Network) -> [u8; 32] { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -1037,7 +1022,7 @@ mod scoped_wallet_id_tests { mod register_wallet_duplicate_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1103,8 +1088,7 @@ mod register_wallet_duplicate_tests { let manager = make_manager(); let network = Network::Testnet; - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // First registration succeeds. `Some(0)` skips the SPV-tip @@ -1164,7 +1148,7 @@ mod register_wallet_duplicate_tests { let manager = make_manager(); let network = Network::Testnet; - let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic") .to_seed(""); @@ -1242,7 +1226,7 @@ mod register_wallet_duplicate_tests { use dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, Txid, Witness}; let manager = make_manager(); - let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic") .to_seed(""); @@ -1309,7 +1293,7 @@ mod remove_versus_recreate_tests { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1428,8 +1412,8 @@ mod remove_versus_recreate_tests { if already_fired { return; } - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid test mnemonic"); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // The real registration path: inner `WalletManager` first, // then `self.wallets`. `Some(0)` skips the SPV-tip lookup. diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index a9dbddba98c..854d6c59d81 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -667,7 +667,7 @@ pub async fn test_platform_wallet_manager() -> ( Arc>, WalletId, ) { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; // Canonical all-`abandon` BIP-39 test vector. @@ -684,8 +684,7 @@ pub async fn test_platform_wallet_manager() -> ( event_handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // `Some(0)` skips the SPV birth-height lookup so the create never hits the // network. @@ -733,9 +732,9 @@ pub(crate) async fn mnemonic_wallet_manager( ) { use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::ManagedWalletInfo; - use key_wallet::{Language, Mnemonic}; + use key_wallet::Mnemonic; - let mnemonic = Mnemonic::from_phrase(phrase, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(phrase).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic( mnemonic, Network::Testnet, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 64b6414c07b..a14417dba53 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -358,6 +358,73 @@ pub(crate) async fn reconstruct_tracked_asset_locks( cs } +/// `TransactionsSwept` sibling of the hooks above: drop every tracked +/// entry whose funding transaction the sweep just removed. +/// +/// A swept funding tx was provably beaten to one of its inputs, so it can +/// never confirm and its credit outputs will never be usable — but nothing +/// else ever cascades the removal into this table. Left alone, the entry +/// is a zombie the resume path re-broadcasts and then waits on without +/// bound, and the persisted mirror carries it forever. The changeset's +/// `removed` set is the same deletion channel a rejected-at-broadcast +/// `Built` row uses, and every store already applies it. +/// +/// Removal is safe against the one way the verdict can reverse: a +/// chainlocked return re-emits the funding record through +/// `TransactionDetected` / `BlockProcessed`, and reconstruction re-inserts +/// the entry from it — the same path a restore scan uses. +/// +/// The tracked map is inspected under the write lock (sweeps are rare and +/// carry few txids, so there is no hot path to protect), and untouched +/// wallets return an empty changeset without allocating. +/// +/// Deliberately NO rejection undo, unlike the sweep's payment flips: if +/// the round this changeset rides is rejected, the in-memory entry is +/// gone while the mirror row survives — a session-local divergence only. +/// The rejection faults the wallet, the frozen watermark already forces +/// the restart, and `load()` there re-syncs from the mirror while the +/// re-scan re-emits the sweep (rejected rounds keep the loser's record) +/// and re-drops the entry — or re-inserts it through reconstruction if +/// the funding tx turned out to live. An undo ledger would buy nothing +/// that restart does not already guarantee. +pub(crate) async fn remove_tracked_asset_locks_for_swept( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + swept: &[dashcore::Txid], +) -> AssetLockChangeSet { + let mut cs = AssetLockChangeSet::default(); + if swept.is_empty() { + return cs; + } + // Hashed once, before the write lock: the loser slice is sized by the + // network (the mempool alone tracks up to a thousand conflicts), and a + // linear `contains` per tracked entry would put O(entries × losers) + // work under the wallet-manager write lock. + let swept: std::collections::HashSet = swept.iter().copied().collect(); + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return cs; + }; + if info.tracked_asset_locks.is_empty() { + return cs; + } + let dead: Vec = info + .tracked_asset_locks + .keys() + .filter(|out_point| swept.contains(&out_point.txid)) + .copied() + .collect(); + for out_point in dead { + info.tracked_asset_locks.remove(&out_point); + cs.removed.insert(out_point); + tracing::info!( + outpoint = %out_point, + "dropped tracked asset lock — its funding transaction was swept" + ); + } + cs +} + /// One record's full reconstruction step: insert-if-absent, then let a /// finalized record upgrade what's already tracked but still unproven /// (the inserts carry their own proof already, so enrichment only ever diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index abc626d55b5..146826c2e3f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -33,11 +33,13 @@ use crate::wallet::PlatformWallet; /// manager lifecycle write (wallet insert / remove / load) is publishing /// a new one. That infallibility is load-bearing, not a convenience. /// `on_wallet_event` is synchronous and the bus neither retries nor -/// coalesces, so a snapshot missed here is gone for good: nothing -/// guarantees a later event carries the same correction, and until one -/// does the wallet displays superseded totals. A fallible lookup (the -/// previous `RwLock::try_read`) dropped exactly that snapshot whenever -/// it raced a lifecycle write. +/// coalesces, so a snapshot missed here is gone for good — and +/// `TransactionsSwept` can be the *only* event carrying a corrected +/// (lower) balance, since the winner that settled the inputs need not be +/// wallet-relevant and so may never produce a later balance-bearing +/// event. A fallible lookup (the previous `RwLock::try_read`) dropped +/// exactly that snapshot when it raced a lifecycle write, leaving +/// removed funds on display indefinitely. pub struct BalanceUpdateHandler { wallets: Arc>>>, } @@ -59,6 +61,15 @@ impl EventHandler for BalanceUpdateHandler { } | WalletEvent::BlockProcessed { wallet_id, balance, .. + } + // A sweep is the one event that can lower the balance: the + // removed transactions' outputs are gone from the UTXO set. + // The snapshot it carries is post-removal, like every other + // variant's, so it routes identically — dropping it would + // leave the corrected-away amount on screen until the next + // balance-bearing event happened to arrive. + | WalletEvent::TransactionsSwept { + wallet_id, balance, .. } => (wallet_id, balance), // No balance on SyncHeightAdvanced — checkpoint advance only. WalletEvent::SyncHeightAdvanced { .. } => return, diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs index ba9151db56a..271b6590fbc 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -138,7 +138,8 @@ fn observing_wallet(event: &WalletEvent) -> Option<&WalletId> { | WalletEvent::BlockProcessed { wallet_id, .. } => Some(wallet_id), WalletEvent::TransactionInstantLocked { .. } | WalletEvent::ChainLockProcessed { .. } - | WalletEvent::SyncHeightAdvanced { .. } => None, + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::TransactionsSwept { .. } => None, } } @@ -173,6 +174,17 @@ pub(crate) fn observed_spends(event: &WalletEvent) -> Vec { WalletEvent::TransactionInstantLocked { .. } | WalletEvent::ChainLockProcessed { .. } | WalletEvent::SyncHeightAdvanced { .. } => Vec::new(), + // A sweep names dead transactions, and the coins it DOES report — + // `released_outpoints` — are the ones that came back free, the + // opposite of a spend. The inputs it kept spent are exactly the ones + // it does not name: the event carries txids, not records, so the held + // set cannot be derived here at all. When the winner that settled + // them is wallet-relevant, its own `TransactionDetected` / + // `BlockProcessed` reports those spends and retires the fence + // through the arms above; when it is not, this wallet never observes + // the spend from any event, which is a gap this handler cannot close + // without the loser's inputs travelling on the event. + WalletEvent::TransactionsSwept { .. } => Vec::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index a9a6fa64e6c..afcc152ea3a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -5462,7 +5462,7 @@ mod contact_info_provider_tests { use crate::wallet::identity::crypto::contact_info::derive_contact_info_keys; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use key_wallet::bip32::KeyDerivationType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; // Canonical BIP-39 test mnemonic. @@ -5478,7 +5478,7 @@ mod contact_info_provider_tests { /// open round-trips. #[tokio::test] async fn contact_info_seal_open_matches_resident_derivation_at_real_auth_path() { - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -5561,7 +5561,7 @@ mod contact_info_provider_tests { async fn ecdh_shared_secret_returns_zeroizing_matching_resident_derivation() { use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -5663,7 +5663,7 @@ mod stamp_race_tests { use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use std::collections::BTreeMap; @@ -5699,8 +5699,7 @@ mod stamp_race_tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 93dbfe1ff5b..3e3d7be8a01 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -850,7 +850,7 @@ mod tests { use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; use std::collections::BTreeMap; @@ -858,7 +858,7 @@ mod tests { abandon abandon abandon abandon abandon about"; fn test_master() -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master xpriv") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 4f0a3a51c1e..402b6930737 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -479,7 +479,7 @@ impl IdentityWallet { mod tests { use super::*; use dpp::util::hash::ripemd160_sha256; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -497,8 +497,7 @@ mod tests { /// touches — the identity-auth derivation walks the master xpriv, /// not the per-account collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") } @@ -508,8 +507,7 @@ mod tests { /// (`RootExtendedPrivKey::new_master(seed).to_extended_priv_key(network)` /// is byte-for-byte `ExtendedPrivKey::new_master(network, seed)`). fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index c44eecb6685..0df02566704 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -1490,7 +1490,7 @@ mod tests { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWalletError; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1561,8 +1561,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index bdbde9b0850..ecc7897ae4b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -527,7 +527,7 @@ mod tests { }; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -545,8 +545,7 @@ mod tests { /// never touches — it walks the master xpriv, not the per-account /// collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") } @@ -554,8 +553,7 @@ mod tests { /// The BIP-32 master node for [`TEST_MNEMONIC`] on `network` — the /// same node `derive_extended_private_key` reconstructs internally. fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs index 62d174bb650..0c3cc79d3d1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs @@ -251,7 +251,16 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { WalletEvent::BlockProcessed { inserted, updated, .. } => inserted.iter().chain(updated.iter()).collect(), + // `TransactionsSwept` carries txids, not records: the wallet has + // already dropped the records these name. Its payment consequence + // — failing the matching `Pending` sent payments, since a swept + // transaction can never confirm — is NOT this handler's to apply: + // a sweep never re-emits once its round is durable, so the flip + // must ride the sweep's own atomic store round, which belongs to + // the wallet-event adapter. Routing it here would persist the + // flip on a separate round with no replay if that round fails. WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => Vec::new(), } @@ -274,16 +283,25 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { WalletEvent::BlockProcessed { inserted, updated, .. } => !inserted.is_empty() || !updated.is_empty(), - WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => false, + // No records to route (see `dashpay_payment_records`), so a task + // here would take and release the wallet-manager write lock for + // nothing. The sweep's payment consequence belongs on the + // wallet-event adapter's own store round — see `dashpay_payment_records`. + WalletEvent::TransactionsSwept { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => false, } } /// Run the DashPay payment hooks for `event`: record any incoming DashPay /// payment, then advance a matching sent payment from `Pending` to /// `Confirmed` once its transaction reaches finality (mined or -/// InstantSend-locked). All paths are idempotent per txid, so re-detections -/// and repeated block-processing rounds converge without duplicating -/// entries. +/// InstantSend-locked). The opposite terminal — `Failed`, when a sweep +/// proves the transaction never can confirm — is deliberately not applied +/// here: it belongs on the sweep's own atomic store round in the +/// wallet-event adapter (see `dashpay_payment_records`). All paths are +/// idempotent per txid, so re-detections and repeated block-processing +/// rounds converge without duplicating entries. pub(crate) async fn run_dashpay_payment_hooks( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -458,6 +476,28 @@ mod tests { assert!(drives_payment_hooks(&event)); } + /// `TransactionsSwept` must NOT drive the payment hooks: its payment + /// consequence — failing the losers' `Pending` sent payments — belongs + /// on the wallet-event adapter's own atomic store round, because a + /// sweep never re-emits once its round is durable and a separately + /// persisted flip that failed its store would be lost for good. + /// Spawning a hook task here would race a second write against that + /// round. + #[test] + fn transactions_swept_does_not_drive_payment_hooks() { + let event = WalletEvent::TransactionsSwept { + wallet_id: [0u8; 32], + txids: vec![dashcore::Txid::from([0x21; 32])], + superseded_by: dashcore::Txid::from([0x22; 32]), + winner_mined_height: None, + released_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + assert!(dashpay_payment_records(&event).is_empty()); + assert!(!drives_payment_hooks(&event)); + } + /// A `BlockProcessed` that changed no records (syncing past an empty /// block) has no payment work, so it must not spawn a hook task. Pins /// the spawn-skip that keeps initial sync from taking the wallet-manager diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index d59c3250390..4abcec29e97 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1643,7 +1643,7 @@ mod tests { use dpp::prelude::Identifier; use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1880,8 +1880,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1913,8 +1912,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1949,8 +1947,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1976,7 +1973,7 @@ mod tests { owner: &Identifier, contact: &Identifier, ) -> key_wallet::bip32::ExtendedPubKey { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet = key_wallet::wallet::Wallet::from_seed_bytes( @@ -2596,8 +2593,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -4772,7 +4768,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -5078,8 +5074,7 @@ mod tests { // The signer's seed (the faithful test stand-in derives from it). let seed = { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); mnemonic.to_seed("") }; @@ -5223,7 +5218,7 @@ mod tests { let watched = Identifier::from([0x42; 32]); let contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -5350,7 +5345,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5495,7 +5490,7 @@ mod tests { } let provider = SeedCryptoProvider::from_seed( - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""), Network::Testnet, @@ -5571,7 +5566,7 @@ mod tests { ) .expect("auth path at the legacy key id"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -5718,7 +5713,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5949,7 +5944,7 @@ mod tests { let (manager, _persister, wallet_id) = make_watch_only_wallet().await; let iw = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = iw.identity(); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6057,7 +6052,7 @@ mod tests { // so the send fails AFTER the drain has run. let pay_contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -6162,7 +6157,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6195,7 +6190,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6273,7 +6268,7 @@ mod tests { // The sending side, so the external-account lookup passes. let shared_key = [0x55u8; 32]; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let compact = { @@ -6358,7 +6353,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6391,7 +6386,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6472,7 +6467,7 @@ mod tests { .expect("register receiving account"); plant_receival_utxo(&manager, wallet_id, owner_id, contact_id, 0xC2, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6520,7 +6515,7 @@ mod tests { // broadcast (and its preceding used-flip persist). fund_bip44_account_0(&manager, wallet_id, 0xB7, 120_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6695,7 +6690,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6738,7 +6733,7 @@ mod tests { // broadcast (a funding-build failure returns before it). fund_bip44_account_0(&manager, wallet_id, 0xA1, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6824,7 +6819,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6923,7 +6918,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7034,7 +7029,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7331,7 +7326,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -7391,7 +7386,7 @@ mod tests { let funded = amount + 526; fund_bip44_account_0(&manager, wallet_id, 0xA1, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7446,7 +7441,7 @@ mod tests { let funded = amount + 1226; fund_bip44_account_0(&manager, wallet_id, 0xB2, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs index 6634264f848..3dd327a702b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -613,7 +613,7 @@ mod tests { use std::sync::Arc; use std::time::{Duration, Instant}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -659,7 +659,7 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - Mnemonic::from_phrase(phrase, Language::English) + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 629a7c574cc..d1487dc7254 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -710,7 +710,7 @@ impl PlatformWallet { #[cfg(test)] mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -727,15 +727,13 @@ mod tests { "legal winner thank year wave sausage worth useful legal winner thank yellow"; fn seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction") } fn second_seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B, Language::English) - .expect("valid test mnemonic B"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B).expect("valid test mnemonic B"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet B construction") } diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index b0c1a3c4d3c..6ae682c2bca 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -943,7 +943,7 @@ mod tests { #[tokio::test] async fn extended_public_key_matches_wallet_derivation_for_dashpay_path() { use key_wallet::account::AccountType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -961,7 +961,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic. let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -999,7 +999,7 @@ mod tests { /// this pins them equal. #[tokio::test] async fn ecdh_shared_secret_matches_wallet_derivation() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1013,7 +1013,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → ECDH through the single crypto source. let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1053,7 +1053,7 @@ mod tests { /// this pins the signer route equal to `Wallet`'s and confirms the inverse. #[tokio::test] async fn account_reference_matches_wallet_derivation_and_round_trips() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1066,7 +1066,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → mask through the single accountReference source. let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1117,7 +1117,7 @@ mod tests { /// so contactInfo the signer seals is readable by the reference clients. #[tokio::test] async fn contact_info_seal_open_round_trips_and_matches_wallet_derivation() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1154,7 +1154,7 @@ mod tests { // Parity: encToUserId equals a resident wallet's derive+encrypt. let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 1eb97522e5a..e3b3d34e2ba 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -28,26 +28,13 @@ use dash_async::block_on; pub(crate) fn parse_mnemonic_any_language( phrase: &str, ) -> Result { - use key_wallet::mnemonic::{Language, Mnemonic}; - - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + use key_wallet::mnemonic::Mnemonic; + + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Create a signer from a private key. From 9afbb8904d42f29ccc4e73d0e0f82db8d38737d9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:42:41 +0300 Subject: [PATCH 2/9] style(sdk-ffi): rustfmt the mnemonic call sites the #981 adaptation touched `cargo fmt --check --all` is a CI gate and the collapsed `Mnemonic::from_phrase` calls left two of them wrapped. --- .../rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 6ae682c2bca..e6a9367c4ab 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -960,8 +960,7 @@ mod tests { .expect("DashPay receiving path"); // Old route: resident-seed wallet from the same mnemonic. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1012,8 +1011,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → ECDH through the single crypto source. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1065,8 +1063,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → mask through the single accountReference source. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1153,8 +1150,7 @@ mod tests { ); // Parity: encToUserId equals a resident wallet's derive+encrypt. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) From 1cb7db2502072f0259e661d21b0e27655b6797c2 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:24:28 +0300 Subject: [PATCH 3/9] docs(platform-wallet): correct four statements the #981 bump left stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nits, all documentation. `parse_mnemonic_any_language`'s doc still said `key_wallet::Mnemonic` "only exposes language-tagged constructors" and that callers "must walk the language list themselves" — precisely what rust-dashcore#981 removed, and it contradicted the inline comment three lines below. The wrapper is kept: 20 call sites narrow upstream's error to the `&'static str` they report, and that narrowing is now what the doc says it does. The sweep gate's recovery note read as if a capable backend might appear mid-session. It cannot: the persister does not change under a running adapter, so a host without the slot stays frozen until it ships one and relaunches. Freezing is the point. `last_processed_height` is now documented as deliberately NOT stripped beside `synced_height`, matching the #4069 guard: `synced_height` is the durable "scanned AND persisted" claim that must not outrun an unapplied removal, while `last_processed_height` is the adapter's own progress marker whose retention makes nothing safer. And the asset-lock test's `DASHPAY_PAYMENTS` attestation no longer describes an overlay this PR writes — nothing here stages `dashpay_payments_overlay`; the bit is declared so the fixture still describes a fully capable backend once #4442 lands. Not taken: de-indenting the vestigial block in `commit_wallet`. It spans 152 lines, so removing it would bury the reviewable diff under a whitespace-only change and force another rebase of the four PRs stacked above this one. --- .../src/changeset/core_bridge.rs | 29 +++++++++++++++---- .../src/manager/wallet_lifecycle.rs | 17 ++++------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 0768867c4dc..9c4ca74c0ce 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -688,6 +688,15 @@ fn commit_wallet

( // watermark the backend has already made durable. `offered_height` // keeps the original so the rejection is still diagnosed as a // withheld advance rather than as a round that carried none. + // + // `last_processed_height` is deliberately NOT stripped, matching + // the existing fault guard (`freeze_synced_height_if_faulted`, + // dashpay/platform#4069). The two watermarks answer different + // questions: `synced_height` is the durable claim "everything up + // to here is scanned AND persisted", which is what must not + // outrun an unapplied removal, while `last_processed_height` is + // the adapter's own progress marker and holding it back would + // re-drive work without making anything safer. core.synced_height = None; } @@ -714,9 +723,15 @@ fn commit_wallet

( // attested `CORE_SWEEP_REMOVAL` is not known to have applied // the one subtractive part of this round — reporting it // durable would let the swept loser return at the next - // `load()`. Fault exactly like a rejection: the next scan - // re-emits the sweep and the idempotent removal is retried - // against (hopefully, by then) a capable backend. + // `load()`. Fault exactly like a rejection: the watermark is + // held, so the next scan re-emits the sweep and the + // idempotent removal is retried. + // + // Recovery is not in-session: the persister does not change + // under a running adapter, so a host that lacks the slot + // stays frozen until it ships one and relaunches. Freezing + // is the point — it is what keeps a height that outran an + // unapplied removal from becoming durable. if fault_and_freeze( diag, offered_height, @@ -4528,9 +4543,11 @@ mod tests { ); let (obs_tx, mut obs_rx) = unbounded_channel(); - // Attested for sweeps AND payments: the removal must ride an - // ordinary round, and the flip's overlay is only staged for a - // payment-durable backend. + // Attested for sweeps AND payments. Only the sweep half matters + // here: nothing in this PR writes `dashpay_payments_overlay`, so + // the payments bit is inert — it is declared so this fixture keeps + // describing a fully capable backend once the payment-flip coupling + // lands (dashpay/platform#4442) and starts staging that overlay. let persister = Arc::new(ProbePersister::with_capabilities( obs_tx, crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index da69f8f777d..ede53f51d79 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -22,19 +22,14 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; -/// Parse a BIP-39 mnemonic against every supported wordlist in turn, -/// returning the first language that yields a valid mnemonic. +/// Parse a BIP-39 mnemonic in any supported language. /// -/// `key_wallet::Mnemonic` only exposes language-tagged constructors, -/// so callers that take a user-supplied mnemonic must walk the -/// language list themselves to avoid rejecting non-English phrases as -/// "invalid English". BIP-39 wordlists are mutually exclusive per -/// phrase, so the first match is unambiguous. +/// Since rust-dashcore#981 `Mnemonic::from_phrase` IS the auto-detecting +/// parse — one path, with English diagnostics kept when nothing matches — +/// so there is no language list left for a caller to walk. What remains is +/// the error narrowing: callers report a `&'static str`, and this is where +/// upstream's richer error is reduced to one. fn parse_mnemonic_any_language(phrase: &str) -> Result { - // Upstream's `from_phrase` IS the auto-detecting parse since - // rust-dashcore#981 — one path, English diagnostics preserved when - // nothing matches. This wrapper survives only to narrow the error to - // the `&'static str` its callers report. Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } From 66a7c74b7c73c49c5bbeec9de28a1102c8913be5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:44:17 +0300 Subject: [PATCH 4/9] fix(platform-wallet): allow commit_wallet's argument count, with the reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI lints these crates with `-D warnings`, so clippy's seven-argument threshold is an error, and the #4370 merge gave `commit_wallet` an eighth: the `settled` set the panic arm in `run_wallet_event_adapter` reads back to decide which wallets have an unknown outcome. Every parameter is a distinct piece of drain state this function reads and writes, and the borrow split is what keeps them separately mutable — bundling them would rename the same eight. --- packages/rs-platform-wallet/src/changeset/core_bridge.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 9c4ca74c0ce..df7a5d1b386 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -619,6 +619,14 @@ where /// Commit one wallet's folded changeset — the per-wallet unit of /// [`commit_batch`]. +/// +/// Eight parameters, one over clippy's threshold: every one is a distinct +/// piece of the drain's state that this function must both read and write — +/// the fault map, the sync flag, the one-shot freeze log, the diagnostics +/// and the settled set that the panic arm in `run_wallet_event_adapter` +/// reads back. Bundling them into a struct would only rename the same +/// eight, and the borrow split is what keeps them separately mutable here. +#[allow(clippy::too_many_arguments)] fn commit_wallet

( persister: &P, wallet_id: WalletId, From 27c7c0810c28df8a31e13183ba597e2f564ed580 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:50:51 +0300 Subject: [PATCH 5/9] fix(swift-sdk): act on swept transactions in the SwiftData store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftData mirror of the storage contract, complicated by two things SQLite does not have: rows shared across wallets, and a round that now spans two callbacks. Shared rows are why a sweep marks rather than deletes. A transaction row can belong to several wallets, so the first wallet's callback cannot remove it — it sets `isGloballySwept`, which excludes the row and its outputs from every restore and enumeration path, and the physical delete is left to housekeeping once every wallet's scoped cleanup has landed. A tombstone must likewise outlive its loser: detach it and the consumed coin reads unspent again. Held inputs become pending-input tombstones carrying the winner and, when it was mined, its height; a chained sweep repoints an earlier tombstone at the new winner rather than stacking a second hold. The release pass is outpoint-keyed, the drain gives tombstones precedence over ordinary observations, and `isSpent` stays monotonic against them: a hold the sweep proved consumed is never downgraded by a later record — not even the winner's own, which can arrive IS-locked, a context below in-block. `autosaveEnabled` goes off on the round context. Sweeps travel in their own callback, so the round spans two calls, and an autosave landing between them would make the watermark and the additive rows durable while the removal is still unstaged — with `rollback()` unable to take back a save that already happened. The handler attests `ATOMIC_CHANGESETS`, and Rust now relies on that to trust the split transport, so the guarantee has to be real. The handler declares `CORE_SWEEP_REMOVAL` and `DASHPAY_PAYMENTS`; before this commit it published the legacy `struct_size`, the negotiated slot read `None`, and Rust fail-closed. The four models that gain a column — `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput`, `PersistentWallet` — were still referenced live by `DashSchemaV1/V2/V3`. Adding a property to a live model mutates those released versions' checksums in place, so a store written by a shipped binary would match no registered schema and fail to open with Cocoa 134504 instead of migrating. That is exactly the defect `DashSchemaFrozenModels.swift` was introduced to prevent, and its instruction is to freeze the model you change. Freezing those four alone is not possible: a frozen model declares its relationships against frozen counterparts (an `inverse:` key path is typed on the destination model), and following relationships in both directions closes over 24 of the 35 models — one type per entity name is all a schema can hold, so the component travels together. All 24 are frozen here at their V3 shape, shared by V1, V2 and V3, none of which changed any of them. The eleven models outside the component are still live-referenced and still carry the latent defect, unchanged by this. `DashSchemaV4` then registers the live models with a lightweight V3→V4 stage: every new column is additive with a default or optional, so existing rows migrate as not-swept, unsuperseded, ordinary unstamped claims, and a wallet with no chainlock boundary yet. `reconcileSpendObservation` stays the single spend verdict, extended with one sweep term — a stamped hold outranks any observation — and its oldest-first pending-row reconciliation stays, under a tombstone-precedence branch. One correction the merge forced: the "never displace confirmed evidence" rule refused the link when `isSpent` was true with NO spender linked, which is precisely the sweep-hold shape, so the winner's own record could never supply the attribution the hold lacked. With no link there is nothing to displace, so it is adopted. One gap neither PR covered is closed here: `buildUnresolvedAssetLockTxRecordBuffer` now skips globally-swept rows, so the double-spend screen can never be handed a swept loser as the settled spender of a lock's input. Also carries the `ChangesetRoundIndex` per-round fetch cache — the reviewed-but-untested fix for the quadratic SwiftData fetch that put ~99% of CPU on the serial queue. Sweep paths deliberately opt out of it, since they key on mutable columns the index cannot answer stale. Tests: `SweptTransactionPersistTests` (38) — shared losers, detached tombstones with a missing winner row, chained tombstones, cross-round reinstatement, released-pending deadlock, co-swept twins, the throwing-lookup round failure, and the winner's late record against a stamped hold. `DashModelMigrationTests` gains the V3→V4 stage and reads V1/V2 rows through the frozen types. Full suite: 437 tests, the only failures being two `KeychainSignerAdditionalSigningKeysTests` cases that fail identically on an unmodified checkout (the test host cannot write to the keychain). --- .../Persistence/DashModelContainer.swift | 113 +- .../Persistence/DashSchemaFrozenModels.swift | 3272 +++++++++++++++++ .../Models/PersistentPendingInput.swift | 44 + .../Models/PersistentTransaction.swift | 17 + .../Persistence/Models/PersistentTxo.swift | 21 + .../Persistence/Models/PersistentWallet.swift | 13 + .../PlatformWalletManager.swift | 20 + .../PlatformWalletPersistenceHandler.swift | 1491 +++++++- .../DashModelMigrationTests.swift | 63 +- .../InvitationPersistenceTests.swift | 21 +- .../SweptTransactionPersistTests.swift | 2815 ++++++++++++++ 11 files changed, 7742 insertions(+), 148 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0126c3d65be..a9ebbba890f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -51,12 +51,61 @@ public enum DashModelContainer { ] } + + /// The V1/V2/V3 model set: frozen copies for every model in the + /// relationship component (see `DashSchemaFrozenModels.swift`), live + /// types for the eleven models outside it, and `assetLock` for the one + /// model whose shape differs between V2 and V3. + /// + /// Positionally identical to `allModelTypes` — a released version's + /// list must describe exactly the entities that version shipped. + private static func componentFrozenModelTypes( + assetLock: any PersistentModel.Type + ) -> [any PersistentModel.Type] { + [ + DashSchemaV1.PersistentIdentity.self, + DashSchemaV1.PersistentDPNSName.self, + DashSchemaV1.PersistentDashpayProfile.self, + DashSchemaV1.PersistentDashpayContactProfile.self, + DashSchemaV1.PersistentDashpayContactRequest.self, + DashSchemaV1.PersistentDashpayPayment.self, + DashSchemaV1.PersistentDashpayIgnoredSender.self, + DashSchemaV1.PersistentDocument.self, + DashSchemaV1.PersistentDataContract.self, + DashSchemaV1.PersistentPublicKey.self, + DashSchemaV1.PersistentTokenBalance.self, + DashSchemaV1.PersistentKeyword.self, + DashSchemaV1.PersistentToken.self, + DashSchemaV1.PersistentDocumentType.self, + DashSchemaV1.PersistentIndex.self, + DashSchemaV1.PersistentProperty.self, + DashSchemaV1.PersistentTokenHistoryEvent.self, + DashSchemaV1.PersistentPlatformAddress.self, + PersistentPlatformAddressesSyncState.self, + DashSchemaV1.PersistentWallet.self, + DashSchemaV1.PersistentAccount.self, + DashSchemaV1.PersistentCoreAddress.self, + DashSchemaV1.PersistentTransaction.self, + DashSchemaV1.PersistentTxo.self, + DashSchemaV1.PersistentPendingInput.self, + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedOutgoingNote.self, + PersistentShieldedSyncState.self, + PersistentShieldedActivity.self, + PersistentShieldedViewingKey.self, + assetLock, + PersistentInvitation.self, + PersistentMasternode.self + ] + } + /// The exact model set registered as schema V1. Keep frozen: staged /// migration identifies an existing store by this schema's checksum, so /// this list may only reference models whose shape is frozen (see /// `DashSchemaFrozenModels.swift`). fileprivate static var v1ModelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) + componentFrozenModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) } /// The exact model set registered as schema V2 — V1 plus @@ -66,17 +115,25 @@ public enum DashModelContainer { v1ModelTypes + [PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V3). - /// Unlike `v1ModelTypes` / `v2ModelTypes` this list tracks the LIVE - /// models, so it moves whenever a model gains a property — which is - /// exactly why the released versions above must not. + /// The exact model set registered as schema V3 — V2's frozen component + /// with the LIVE `PersistentAssetLock`, which is the only model V3 + /// changed. Frozen for the same reason as `v1ModelTypes`. + fileprivate static var v3ModelTypes: [any PersistentModel.Type] { + componentFrozenModelTypes(assetLock: PersistentAssetLock.self) + + [PersistentTrackedMasternode.self] + } + + /// All persistent model types in the current Dash SDK schema (V4). + /// Unlike the lists above this one tracks the LIVE models, so it moves + /// whenever a model gains a property — which is exactly why the + /// released versions must not. public static var modelTypes: [any PersistentModel.Type] { allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data @@ -124,13 +181,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -251,6 +309,24 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// migrate with a nil `documentIdBase58`, which is the documented /// "no marketplace state tracked" signal — the next marketplace /// sync pass fills them in. +/// - `PersistentTxo` gained the optional `supersededByTxid`, and +/// `PersistentPendingInput` gained `isSweptTombstone` (defaulted +/// `false`). Together they let a sweep's claim on an input whose +/// funding TXO hasn't arrived yet survive the loser transaction's +/// deletion — previously that claim lived only on the doomed row's +/// `PersistentPendingInput`, which cascades away with it. Both +/// additive with defaults ⇒ lightweight migration; existing rows +/// migrate as ordinary (non-tombstone, non-superseded) entries. +/// - `PersistentPendingInput` gained the optional `winnerMinedHeight` +/// (a block-context sweep tombstone's finality stamp — the winner's +/// own mined height) and `PersistentWallet` gained the optional +/// `lastAppliedChainLockHeight` (the numeric chainlock watermark +/// delivered by `on_persist_wallet_changeset_chain_lock_height_fn`, +/// stored monotonic-max). Together they drive the bounded tombstone +/// lifetime: a tombstone is collected exactly when +/// `min(chainlockHeight, syncedHeight)` reaches its stamp. Both +/// optional ⇒ lightweight migration; pre-existing rows read as +/// unstamped (held forever) over a wallet with no boundary yet. /// Each of those is a destructive change to a unique-attribute /// column or to relationship topology, so any pre-existing dev /// store will fail to open and get rebuilt from scratch on next @@ -296,6 +372,27 @@ public enum DashSchemaV3: VersionedSchema { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v3ModelTypes + } +} + +/// Version 4 adds the sweep columns: `isGloballySwept` on +/// `PersistentTransaction`, `supersededByTxid` on `PersistentTxo`, +/// `isSweptTombstone` / `winnerMinedHeight` on `PersistentPendingInput`, +/// and `lastAppliedChainLockHeight` on `PersistentWallet`. Every one is +/// additive with a default or optional, so a lightweight migration +/// preserves each existing row: transactions read as not swept, TXOs as +/// unsuperseded, pending inputs as ordinary unstamped claims, and a wallet +/// as having no chainlock boundary yet. +/// +/// Registering it required freezing the whole relationship component those +/// four models sit in — see `DashSchemaFrozenModels.swift`. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { + Schema.Version(4, 0, 0) + } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index 72df796e36c..b1417c2fa90 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -100,3 +100,3275 @@ extension DashSchemaV1 { } } } + +// MARK: - The rest of the relationship component, frozen at the V3 shape +// +// The four models the sweep persistence changes — `PersistentTransaction`, +// `PersistentTxo`, `PersistentPendingInput` and `PersistentWallet` — each +// gain a property, so each needs a frozen copy for the same reason +// `PersistentAssetLock` did. Freezing them alone is not possible: a frozen +// model must declare its relationships against frozen counterparts (an +// `inverse:` key path is typed on the destination model), and following +// those relationships in both directions closes over 24 of the 35 models. +// Registering a frozen copy beside a live one for the SAME entity name is +// what the schema cannot express, so the whole component travels together. +// +// These copies are the shape as of V3 — i.e. everything the live models had +// before the sweep columns — and are shared by V1, V2 and V3, none of which +// changed any model in this component. The eleven models outside the +// component (shielded storage, invitations, masternodes, the asset-lock +// pair above, wallet-manager metadata) are still referenced live and still +// carry the latent defect this file exists to fix; freezing them is the +// same mechanical exercise, for whichever change next touches one. +// +// Do not edit these copies to match the live models. Every attribute, its +// optionality, its default, each `@Attribute` marker, each `#Index` and +// each relationship is an input to the V1/V2/V3 checksums, and changing one +// re-breaks the stores these types exist to keep openable. + +extension DashSchemaV1 { + @Model + final class PersistentAccount { + /// Compound uniqueness on the full account-identity tuple: + /// `(wallet, accountType, accountIndex, standardTag, + /// registrationIndex, keyClass, userIdentityId, + /// friendIdentityId)`. Mirrors the persister's match logic + /// exactly — the variant disambiguators (`standardTag` for + /// BIP44 vs BIP32, `registrationIndex` for top-ups, `keyClass` + /// for PlatformPayment) are part of the key so legitimate + /// sibling accounts can coexist (e.g. BIP44 #0 and BIP32 #0, + /// or multiple top-up accounts on the same identity). + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + /// Account type identifier — matches the `AccountTypeTagFFI` + /// discriminant from the Rust side (0 = Standard, 1 = CoinJoin, + /// … 14 = PlatformPayment, 15 = IdentityAuthenticationEcdsa, + /// 16 = IdentityAuthenticationBls). Stable across releases. + var accountType: UInt32 + /// Account index within the type (for indexed account types). For + /// `PlatformPayment` this is the `account` field; for + /// `DashpayReceivingFunds` / `DashpayExternalAccount` it's the + /// account-level selector; for + /// `IdentityAuthentication{Ecdsa,Bls}` it's the identity index. + var accountIndex: UInt32 + /// Human-readable account type name. + var accountTypeName: String + /// Per-account confirmed balance in duffs. + var balanceConfirmed: UInt64 + /// Per-account unconfirmed balance in duffs. + var balanceUnconfirmed: UInt64 + /// External address pool: highest used index (-1 = none). + var externalHighestUsed: Int32 + /// Internal (change) address pool: highest used index. + var internalHighestUsed: Int32 + /// `StandardAccountTypeTagFFI` value. Meaningful only when + /// `accountType == 0` (Standard): 0 = BIP44, 1 = BIP32. + var standardTag: UInt8 + /// `IdentityTopUp.registration_index`. Zero for other variants. + var registrationIndex: UInt32 + /// `PlatformPayment.key_class`. Zero for other variants. + var keyClass: UInt32 + /// `Dashpay*`.user_identity_id (32 bytes). Empty `Data` for other + /// variants. + var userIdentityId: Data + /// `Dashpay*`.friend_identity_id (32 bytes). Empty `Data` for + /// other variants. + var friendIdentityId: Data + /// Bincode-encoded extended public key for this account. For ECDSA + /// accounts it's an `ExtendedPubKey`; for the two provider + /// key-material accounts (`accountType == 10` operator = BLS, + /// `accountType == 11` platform node = Ed25519) it's the extended + /// BLS / Ed25519 public key instead. Populated by + /// `on_persist_account_registrations_fn`, consumed by + /// `on_load_wallet_list_fn` to reconstruct a watch-only account + /// (`Account::from_xpub` for ECDSA, `BLSAccount`/`EdDSAAccount` for + /// the provider accounts). `nil` means "not yet persisted" — + /// account cannot be restored silently. Unique because two + /// accounts can't legitimately share an xpub (would imply a key + /// reuse / derivation collision); SQL UNIQUE allows multiple + /// `nil` values, so freshly-inserted unhydrated rows don't + /// conflict. + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent wallet. Every account currently belongs to a wallet. If + /// standalone non-wallet accounts are introduced later, this + /// becomes optional again. + /// + /// Kept non-optional. SwiftData would otherwise fatal during + /// the `save()` phase of a wallet delete + /// (`Cannot remove PersistentWallet from relationship wallet on + /// PersistentAccount because an appropriate default value is + /// not configured`); the workaround is in + /// `PlatformWalletPersistenceHandler.deleteWalletData`, which + /// deletes all of the wallet's accounts in a separate + /// `save()` BEFORE deleting the wallet itself. By the time the + /// wallet row is deleted, its `accounts` collection is empty + /// and SwiftData has no inverse to null out. This costs + /// atomicity (two saves instead of one) — acceptable for a + /// user-initiated wipe. + var wallet: PersistentWallet + + /// Addresses from this account's address pools (external + + /// internal, or a single Absent pool for degenerate types). Holds + /// Core-chain (base58check) addresses only — PlatformPayment + /// accounts keep their addresses in `platformAddresses`. + /// Per-account TXOs flow through this collection + /// (`coreAddresses.flatMap(\.txos)`). + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + /// DIP-17 Platform Payment addresses for this account, keyed on + /// DIP-0018 bech32m encoding. Populated only when + /// `accountType == 14` (PlatformPayment). + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + /// Transactions this account participates in that the TXO graph + /// cannot recover — the payload-only involvement described in the + /// type doc above. Populated by the persistence handler, which + /// appends this account whenever it upserts a tx record the + /// changeset bucketed under this account, even when the record + /// produced no TXO here (special-tx payloads matching provider + /// owner / voting key addresses). + /// + /// A superset that overlaps the TXO-derived set for ordinary funded + /// txs (the handler appends there too), so consumers computing a + /// per-account transaction list must **union** this with the + /// TXO-derived txids and de-dup — see `AccountDetailView`. + /// + /// The `inverse:` for this many-to-many lives on + /// `PersistentTransaction.involvedAccounts`; this side carries the + /// plain declaration. Default `.nullify` delete rule — deleting + /// this account detaches it from each tx without removing the + /// (shared) tx rows. That matters for the wallet-wipe path + /// (`deleteWalletData`), which deletes accounts before the wallet: + /// `.nullify` on a to-many inverse has no "default value" fatal + /// (unlike the non-optional `wallet` back-reference), so no extra + /// pre-delete pass is needed. + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } + + @Model + final class PersistentCoreAddress { + /// Base58check-encoded address. Unique across the SwiftData store + /// because the same address can't validly exist under two accounts + /// (collision would imply a wallet-id hash collision). + @Attribute(.unique) var address: String + /// Typed public key bytes, or empty Data when the Rust side couldn't + /// produce one (e.g. a pool entry that stored only a script). The + /// curve is given by `keyType`: 33-byte compressed secp256k1 (ECDSA), + /// 48-byte BLS operator key, or 32-byte Ed25519 platform-node key. + var publicKey: Data + /// `KeyTypeTagFFI` raw value identifying the curve of `publicKey`: + /// 0 ECDSA / 1 BLS / 2 EdDSA. Meaningful only when `publicKey` is + /// non-empty. The stored default (NOT just the init-parameter + /// default, which SwiftData migration never consults) keeps + /// pre-column stores openable: without it, lightweight migration + /// fails with "missing attribute values on mandatory destination + /// attribute" and the container refuses to load — a launch crash on + /// every device that has existing rows. Defaulted legacy rows read + /// as ECDSA with an empty `publicKey` until the next Rust + /// address-pool persist pulse (pool extension / address-used / + /// registration — NOT plain load, which only reads the snapshot) + /// re-emits them with typed keys. On load, Rust's + /// `restore_address_pool` keeps the pre-derived typed key when a + /// legacy row arrives key-less, so in-memory BLS operator matching + /// is unaffected; legacy Ed25519 platform-node keys are hardened-only + /// and re-derivable only via delete+re-import (pre-release + /// convention). + var keyType: UInt8 = 0 + /// `AddressPoolTypeTagFFI` raw value — 0 External, 1 Internal, + /// 2 Absent, 3 AbsentHardened. + var poolTypeTag: UInt8 + /// Derivation index within this pool. + var addressIndex: UInt32 + /// BIP32 derivation path (e.g. `"m/44'/1'/0'/0/3"`). + var derivationPath: String + /// Marked used by the Rust address pool (first-seen tx or explicit + /// `mark_used`). + var isUsed: Bool + /// SPV height where this address first appeared in a transaction. + /// Zero until the address is seen on-chain. + var firstSeenHeight: UInt32 + /// SPV height of the most recent transaction touching this address. + var lastSeenHeight: UInt32 + /// Cached balance in duffs from `AddressInfo.balance`. Updated by + /// subsequent `on_persist_account_address_pools_fn` pulses. + var balance: UInt64 + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent account. + var account: PersistentAccount? + + /// TXOs paid to this address. Cascade-delete: dropping the + /// address row takes its TXOs with it. The address is the + /// canonical owning record — no meaningful render path for an + /// address-less TXO. Pool rebuilds therefore need to reuse + /// existing rows (the persister upserts by Base58Check string, + /// which it already does) rather than wholesale-replace, or + /// the historical TXO chain gets wiped. + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDPNSName { + /// Compound uniqueness on `(networkRaw, normalizedParentDomainName, + /// normalizedLabel)`. Mirrors the DPNS contract's `domain` + /// document index `parentNameAndLabel` + /// (`normalizedParentDomainName + normalizedLabel`, `unique: true`) + /// and adds the network scope so two networks don't collide in a + /// shared local store. A label is only unique within a domain + /// on a given chain. + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Stays in sync with `identity.networkRaw` + /// via the init; identities don't migrate between networks. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts — matches + /// `PersistentIdentity.network`. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Display label — the original case-and-letters form the user + /// registered, e.g. "Alice". Maps to the DPNS document's + /// `label` property. + var label: String + + /// Homograph-safe lowercase form of `label` used for lookups + /// (e.g. "Alice" → "a11ce"; `o`/`O`→`0`, `i`/`I`→`1`, + /// `l`/`L`→`1`, everything else lowercased). Maps to the DPNS + /// document's `normalizedLabel` property and participates in the + /// per-domain uniqueness above. Computed once on insert from + /// `label` via `Self.normalize(_:)`. + var normalizedLabel: String + + /// Display parent domain — e.g. "dash". Maps to the DPNS + /// document's `parentDomainName` property. DPNS today only + /// supports the single top-level domain "dash", so the persister + /// stamps that as the default; the field exists so subdomain + /// support (when/if DPNS gains it) lands without a schema bump. + var parentDomainName: String + + /// Homograph-safe form of `parentDomainName` used for lookups. + /// Maps to the DPNS document's `normalizedParentDomainName` + /// property and participates in the per-domain uniqueness above. + var normalizedParentDomainName: String + + /// Unix-millis timestamp when the wallet first observed this + /// label belonging to the identity. Mirrors + /// `DpnsNameInfo.acquired_at`. `0` when unknown. + var acquiredAt: UInt64 + + /// Whether the latest canonical identity snapshot still includes this + /// name. Marketplace callbacks never overwrite this value. A name that + /// leaves the wallet keeps its row on the departed identity with `false`; + /// a same-wallet transfer rebinds the unique row to the current identity + /// with `true`. + var isOwned: Bool = true + + // MARK: - Username marketplace + // + // Fed by the `on_persist_dpns_name_states_fn` persister callback + // (`DpnsNameStateFFI`), NOT by the identity label snapshot that + // populates the fields above. All of them are optional or defaulted + // so an existing store migrates in place (SwiftData lightweight + // migration). + // + // READ CONTRACT: every field in this section is meaningful only + // while `documentIdBase58` is non-nil. A nil document id means the + // wallet is not tracking this name's marketplace state — it does NOT + // mean the name is owned and unlisted. Gate any marketplace UI on + // `documentIdBase58 != nil` before reading `saleStatus` or + // `priceCredits`. + + /// Base58 id of the DPNS `domain` document behind this label — the + /// handle every trade transition needs, stable across transfers and + /// purchases. `nil` while no marketplace state has been mirrored (or + /// after the row was dropped from marketplace tracking). + var documentIdBase58: String? + + /// Listed sale price in **credits** (1 duff = 1000 credits), stored + /// as `Int64(bitPattern:)` like `PersistentIdentity.balance` because + /// SwiftData has no unsigned 64-bit column. `nil` = the name is not + /// listed for sale, which is distinct from a 0-credit listing. + var priceCredits: Int64? + + /// Raw ``DpnsNameSaleStatus`` discriminant: 0 = owned, 1 = sold, + /// 2 = transferred. Defaults to 0 so existing rows migrate, so read + /// it through ``saleStatus`` rather than directly. + var saleStatusRaw: Int16 = 0 + + /// Base58 id of the counterparty a departed name went to — the buyer + /// when `saleStatusRaw == 1`, the recipient when it is 2. `nil` while + /// the name is still owned (or the counterparty is unknown). + var counterpartyIdBase58: String? + + /// Domain document `$createdAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentCreatedAtMs: UInt64? + + /// Domain document `$updatedAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentUpdatedAtMs: UInt64? + + /// Domain document `$transferredAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentTransferredAtMs: UInt64? + + /// Unix-millis timestamp of the sync pass / confirmed transition + /// that last wrote the marketplace fields. `0` = never written. + var marketplaceUpdatedAt: UInt64 = 0 + + // MARK: - Relationships + + /// Owning identity. Cascade-deleted from the parent — losing the + /// identity row should drop its label cache too. The `inverse` + /// declaration on `PersistentIdentity.dpnsNames` is the source of + /// truth for this association. + /// + /// Non-optional: every DPNS-label row exists *because* of an + /// identity. The persister wires it at construction time + /// (before insert) so SwiftData's non-optional relationship + /// contract is honored. + var identity: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = label.lowercased() + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = parentDomainName.lowercased() + self.acquiredAt = acquiredAt + self.isOwned = isOwned + // A freshly inserted row carries no marketplace state until the + // marketplace persister callback writes it — hence a nil document + // id, which is the "not tracked" signal the read contract above + // documents. + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactProfile { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId)`. Mirrors the per-owner, per-contact keying of + /// the Rust `contact_profiles` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the persister. + var ownerIdentityId: Data + + /// The contact's 32-byte identity id — the `contact_profiles` map + /// key. Part of the compound unique key above. + var contactIdentityId: Data + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is optional + // in the contract schema except the implicit `$ownerId`. We mirror + // that so partial profiles (only an `avatarUrl` set, only a + // `displayName` set, etc.) round-trip without forcing placeholders. + + /// `displayName` field on the contact's DashPay `profile` document. + var displayName: String? + + /// `publicMessage` field on the contact's `profile` document. + var publicMessage: String? + + /// `bio` field. Carried for forwards-compat with future contract + /// revisions; reserved here so adding it later doesn't trigger a + /// destructive schema change. + var bio: String? + + /// `avatarUrl` field — URL the consumer fetches + caches locally. + /// The binary asset itself is never persisted. Treated as untrusted + /// (attacker-controlled public data): the Rust side caches and + /// restores it only when it is a bounded `https://` URL. + var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, so + /// consumers can verify a fetched asset matches what the contact + /// published. `nil` when the underlying `avatar_hash` was absent. + var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for quick + /// equality checks on cached avatars. `nil` when absent. + var avatarFingerprint: Data? + + /// Wall-clock ms of the last fetch attempt on the Rust side + /// (`ContactProfileEntry.checked_at_ms`) — drives the self-heal + /// backoff. Round-tripped verbatim so the restored cache keeps the + /// same re-query schedule it had before relaunch. Stored as the + /// scalar so the predicate engine compares it directly. + var checkedAtMs: UInt64 + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose cached + /// contact profiles this row belongs to. Non-optional: every contact + /// profile exists *because of* an owner identity. Cascade-deleted + /// from `PersistentIdentity.contactProfiles`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactRequest { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId, isOutgoing)`. Mirrors the per-direction + /// keying the Rust changeset uses on + /// `ContactChangeSet::sent_requests` / + /// `incoming_requests`, scoped by network so two networks don't + /// collide in a shared local store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the optional `owner` join. Always equal to + /// `owner.identityId` — kept in sync by the persister. + var ownerIdentityId: Data + + /// Other party's 32-byte identity id. For outgoing rows this is + /// the recipient (`ContactRequest::recipient_id`); for incoming + /// rows this is the sender (`ContactRequest::sender_id`). The + /// `isOutgoing` bit disambiguates which direction this row + /// represents. + var contactIdentityId: Data + + /// Direction bit. `true` ⇒ owner sent this request to contact; + /// `false` ⇒ contact sent this request to owner. Same shape as + /// the Rust `ContactRequestFFI::is_outgoing` field. + var isOutgoing: Bool + + // MARK: - Payload — round-trips `ContactRequest` verbatim + + /// `ContactRequest::sender_key_index` — index of the sender's + /// identity public key used for the ECDH that encrypted the + /// payload. + var senderKeyIndex: UInt32 + + /// `ContactRequest::recipient_key_index`. + var recipientKeyIndex: UInt32 + + /// `ContactRequest::account_reference` — DashPay account derivation + /// hint the sender encoded in the request. + var accountReference: UInt32 + + /// `ContactRequest::encrypted_public_key` bytes. Always non-empty + /// — every contact-request document carries an encrypted key. + var encryptedPublicKey: Data + + /// `ContactRequest::encrypted_account_label` bytes, when present. + /// `nil` mirrors the source `Option` being `None`. + var encryptedAccountLabel: Data? + + /// `ContactRequest::auto_accept_proof` bytes, when present. `nil` + /// mirrors the source `Option` being `None`. + var autoAcceptProof: Data? + + /// `ContactRequest::core_height_created_at` — the Core block + /// height at which the request landed on Platform. + var coreHeightCreatedAt: UInt32 + + /// `ContactRequest::created_at` — Unix-millis timestamp the + /// request document was created. + var createdAtMillis: UInt64 + + /// Whether the established relationship this row belongs to has a + /// **permanently broken** payment channel. Mirrors + /// `ContactRequestFFI::payment_channel_broken`: only meaningful + /// for rows projected from the `established` map — both + /// directions of an established pair carry the same flag (it's a + /// property of the relationship, not of one direction). Always + /// `false` for pending rows. The UI reads it to disable "Send + /// Dash" and surface "payment channel broken — ask the contact to + /// send a new request". + /// + /// Defaulted so existing rows ride SwiftData's lightweight + /// migration (additive column, non-destructive). + var paymentChannelBroken: Bool = false + + /// Owner-private alias for the contact — `contactInfo`-backed, + /// synced across devices via Platform. Mirrors + /// `ContactRequestFFI::alias`; established rows only, replicated + /// onto both directions like `paymentChannelBroken`. Optional so + /// existing rows ride the lightweight migration. + var contactAlias: String? + + /// Owner-private note — same conventions as `contactAlias`. + var contactNote: String? + + /// `contactInfo.displayHidden` — whether the owner hid this + /// contact from the list. Defaulted for lightweight migration. + var contactHidden: Bool = false + + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the label + /// the contact chose for the account they shared (a payment-routing + /// hint, e.g. "Main wallet"). **System-derived and read-only**, unlike + /// the owner-private `contactAlias`/`contactNote`: it is decrypted in + /// Rust from the contact's incoming request, so it is populated only on + /// the incoming-direction row (the outgoing row carries a label *we* + /// sent, which is not surfaced). Optional so existing rows ride the + /// lightweight migration. + var contactAccountLabel: String? + + /// `EstablishedContact::accepted_accounts` — the DIP-15 + /// rotated-account acceptances for this relationship. Mirrors + /// `ContactRequestFFI::accepted_accounts`: a property of the + /// relationship (not one direction), so it is replicated onto + /// both directions like `paymentChannelBroken`; always empty for + /// pending rows. Defaulted to an empty array so existing rows + /// ride SwiftData's lightweight migration. + var contactAcceptedAccounts: [UInt32] = [] + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity this row's + /// `ownerIdentityId` denormalizes. Non-optional: every + /// contact-request row exists *because of* an owner identity. + /// Cascade-deleted from `PersistentIdentity.contactRequests`. + var owner: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayIgnoredSender { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// ignoredSenderId)` — the Rust per-sender suppression key, scoped by + /// network so two networks don't collide in a shared store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue`, kept + /// in sync with `owner.networkRaw` by the init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` if + /// the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id — the recipient that + /// ignored the sender. Denormalized so `#Predicate` filters match + /// without a relationship traversal. Always equal to + /// `owner.identityId`. + var ownerIdentityId: Data + + /// The 32-byte id of the ignored sender. The per-sender suppression + /// key — no `accountReference`, so ALL of this sender's requests are + /// suppressed. + var ignoredSenderId: Data + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity that ignored the + /// sender. Non-optional: an ignore exists *because of* an owner + /// identity. Cascade-deleted from + /// `PersistentIdentity.dashpayIgnoredSenders`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + var ignoredAt: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } + + @Model + final class PersistentDashpayPayment { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, txid)`. + /// Mirrors the per-identity txid keying of the Rust + /// `dashpay_payments` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the refresh path. + var ownerIdentityId: Data + + /// The other identity in this payment + /// (`DashpayPaymentFFI::counterparty_id`). Whether they are the + /// sender or the receiver is encoded in `directionRaw`. + var counterpartyIdentityId: Data + + /// Amount in duffs. Always positive; `directionRaw` carries the + /// sign. + var amountDuffs: UInt64 + + /// Raw `DashPayPaymentDirection` value. Stored as the scalar so + /// the predicate engine compares it directly. + var directionRaw: UInt8 + + /// Type-safe accessor over `directionRaw`. Falls back to `.sent` + /// if the stored raw value drifts. + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + /// Raw `DashPayPaymentStatus` value. + var statusRaw: UInt8 + + /// Type-safe accessor over `statusRaw`. Falls back to `.pending` + /// if the stored raw value drifts. + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + /// Transaction id (hex), the Rust `dashpay_payments` map key. + /// Part of the compound unique key above. + var txid: String + + /// Sender memo, when present. `nil` mirrors the source `Option` + /// being `None`. + var memo: String? + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose payment + /// history this row belongs to. Non-optional: every payment row + /// exists *because of* an owner identity. Cascade-deleted from + /// `PersistentIdentity.dashpayPayments`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping, not payment dates) + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayProfile { + /// Compound uniqueness on `(networkRaw, identity)`. Mirrors the + /// DashPay contract's per-`ownerId` uniqueness on the `profile` + /// document, scoped by network so two networks don't collide in a + /// shared local store. + #Unique([\.networkRaw, \.identity]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Stays in sync with `identity.networkRaw` + /// (set by the init); identities don't migrate between networks. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts — matches + /// `PersistentIdentity.network`. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is + // optional in the contract schema except the implicit + // `$ownerId`. We mirror that on the row so partial profiles + // (only an `avatarUrl` set, only a `displayName` set, etc.) + // round-trip without forcing placeholder values. + + /// `displayName` field on the DashPay `profile` document. Up to + /// 25 chars per the contract schema. + var displayName: String? + + /// `publicMessage` field on the DashPay `profile` document. Up to + /// 140 chars per the contract schema. + var publicMessage: String? + + /// `bio` field. Not part of the v3 DashPay contract today; the + /// FFI carries the slot for forwards-compat with future contract + /// revisions and the column is reserved here so adding it doesn't + /// trigger a destructive schema change. + var bio: String? + + /// `avatarUrl` field. URL string the consumer is expected to + /// fetch + cache locally; the binary asset itself is never + /// persisted on this row. + var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, + /// stored alongside the URL so consumers can verify the fetched + /// asset matches what the profile author published. `nil` when + /// the underlying `avatar_hash` was `None`. + var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for + /// quick equality checks on cached avatars without rehashing the + /// full asset. `nil` when the underlying `avatar_fingerprint` + /// was `None`. + var avatarFingerprint: Data? + + // MARK: - Relationships + + /// Owning identity. Non-optional — a profile only exists in the + /// context of an identity. Cascade-deleted from the parent's + /// `dashpayProfile` relationship; the persister wires this up at + /// construction time. + var identity: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDataContract { + /// Index `networkRaw` so the static `predicate(networkRaw:)` and + /// `tokensPredicate(networkRaw:)` helpers — plus every per-network + /// list view — can index-scan instead of table-scan. + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + // Binary serialization (CBOR format) + var binarySerialization: Data? + + // Version info + var version: Int? + var ownerId: Data? + + // Keywords and description + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + // Schema and document types storage + var schemaData: Data + var documentTypesData: Data + + // Groups + var groupsData: Data? + + // Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // Timestamps + var lastUpdated: Date + var lastSyncedAt: Date? + + // Contract configuration + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + // Document defaults + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + // Relationships with cascade delete + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + // Owner identity — populated when the owner happens to also live in + // the local store. May be nil even when `ownerId` is set, because + // most contracts in the local cache will be owned by identities the + // user doesn't hold. Back-filled lazily by + // `ContractIdentityLinker.linkContractToOwner` when either side is + // inserted. + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + // Token support tracking + var hasTokens: Bool + var tokensData: Data? + + // Computed properties + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + // Schema and document types + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + // Keywords + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + // Tokens + self.hasTokens = hasTokens + self.tokensData = nil + + // Groups + self.groupsData = nil + + // Documents + self.documents = [] + + // Owner identity link is back-filled later by + // `ContractIdentityLinker`. Initialise explicitly because + // SwiftData's auto-init of optional relationships has + // historically been flaky enough in this codebase to be + // worth the line. + self.ownerIdentity = nil + + // Network and timestamps + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + // Default values for contract configuration + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } + + @Model + final class PersistentDocument { + /// Index `networkRaw` to keep per-network document scans + /// index-served. The static `predicate(contractId:network:)` helper + /// and every UI list view filter by the active network. + #Index([\.networkRaw]) + + // Primary key + @Attribute(.unique) var documentId: String + + // Core document properties + var documentType: String + var revision: Int32 + var data: Data + + // References (stored as strings for queries) + var contractId: String + var ownerId: String + + // Binary data for efficient operations + var contractIdData: Data + var ownerIdData: Data + + // Timestamps + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + // Block heights + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + // Core block heights + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + // Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // Deletion flag + var isDeleted: Bool = false + + // Local tracking + var localCreatedAt: Date + var localUpdatedAt: Date + + // Relationships + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + // Optional reference to local identity (if owner is local) + var ownerIdentity: PersistentIdentity? + + // Computed properties + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + // Pin to Gregorian so the `createdAt` year stays CE even + // when the device is configured for a non-Gregorian + // calendar (e.g. Thai region → Buddhist era). The SDK + // doesn't depend on the app's `AppDate` helper, so we + // configure the formatter inline. + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + // MARK: - Methods + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + // MARK: - Static Methods + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + // See `PersistentIdentity.predicate(network:)` — Foundation's + // predicate engine can't capture `Network`, so we filter on + // the UInt32-backed `networkRaw` shadow field. + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + // MARK: - Identity Linking + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } + + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + // Schema stored as JSON + var schemaJSON: Data + var propertiesJSON: Data + + // Document behavior settings + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + // indexOnly storage mode (meta-schema v3, protocol version 14): no + // stored rows — the index entries ARE the documents + var indexOnly: Bool = false + + // Required fields + var requiredFieldsJSON: Data? + + // Security + var securityLevel: Int + + // Trade and creation restrictions + var tradeMode: Int + var creationRestrictionMode: Int + + // Identity encryption keys + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + // Timestamps + var createdAt: Date + var lastAccessedAt: Date + + // Relationship to data contract + var dataContract: PersistentDataContract? + + // Relationship to documents + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + // Relationship to indices + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + // Relationship to properties + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + // Create unique ID by combining contract ID and name + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } + + @Model + final class PersistentIdentity { + /// Index `networkRaw` so per-network scans (`#Predicate { $0.networkRaw == raw }`) + /// don't degrade to a table scan. Every UI surface that lists + /// identities filters by the active network. + #Index([\.networkRaw]) + + // MARK: - Core Properties + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + /// `true` iff this identity is YOURS or deliberately tracked on + /// this device, two ways in: + /// - wallet-derived: identities of a wallet on this device are + /// ALWAYS local — the persister promotes the flag when it + /// attaches the `wallet` relationship, and the startup heal + /// repairs rows persisted before that rule existed; + /// - manually added: the user loaded/watched the identity via a + /// UI flow (LoadIdentityView by id/name), which marks its own + /// row (the initializer default `true` matches — a directly + /// constructed row is a manual add). + /// + /// `false` only for incidental rows — observed foreign + /// identities materialized by sync that nobody asked to track. + /// The flag is PROMOTE-ONLY: no sync path ever writes `false` + /// over a `true` (a manual mark must survive Platform data + /// flowing over the row, and losing a wallet link doesn't + /// un-track an identity). + /// + /// It makes no claim about signing capability — compute that + /// live where needed; wallet-owned filtering has + /// `walletOwnedIdentitiesPredicate`. + var isLocal: Bool + var alias: String? + /// User's chosen primary display label (the one rendered on + /// list rows and avatars). Populated only when the user selects a + /// main name from `mainDpnsName` selection or as the fallback set + /// during initial registration. The full label collection lives on + /// the `dpnsNames` relationship below; this scalar is just the + /// "show this one in the cell" hint. + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + // MARK: - Special Key Storage (stored in keychain) + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + // MARK: - Public Keys + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + // MARK: - Timestamps + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + // MARK: - Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. Foundation's + /// predicate engine rejects captured non-primitive types — even + /// Codable raw-value enums crash at evaluation with + /// "Unsupported Predicate: Captured/constant values of type + /// 'Network' are not supported". The `network` computed + /// accessor below keeps the public API type-safe; only predicates + /// that need to filter by network reach for `networkRaw`. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Reads fall back to + /// `.testnet` if the stored raw value ever drifts out of the + /// `Network` range (shouldn't happen — writers only go through + /// this setter which uses `Network.rawValue`). + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Wallet Association + // + // Cardinality: an identity belongs to 0 or 1 wallet. A wallet + // holds N identities (see `PersistentWallet.identities`). When + // the wallet is deleted, `wallet` nulls out (deleteRule: + // `.nullify`) and the identity row survives orphaned. + // + // The `wallet` reference is the single source of truth — there + // is no denormalized scalar `walletId`. Callers that want the + // 32-byte wallet id read `identity.wallet?.walletId`; + // predicates filter with `$0.wallet?.walletId == target`. + // `@Relationship` is declared on the `PersistentWallet` side + // (`identities`, with `inverse: \PersistentIdentity.wallet`), + // so this is a plain stored property. + var wallet: PersistentWallet? + /// DIP-9 identity index within the owning wallet. Mirrors the + /// `identity_index` carried on `IdentityEntryFFI` from Rust. + /// Only meaningful when `wallet != nil`; defaults to 0 + /// otherwise. Used to stable-sort identities within a wallet + /// (e.g. when grouping public keys by identity). + var identityIndex: UInt32 = 0 + + // MARK: - Relationships + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + /// Confirmed DPNS labels observed for this identity. Cascade-deleted from + /// the parent — losing the identity row drops the label cache and retained + /// marketplace history too. A name that leaves this wallet remains related + /// to its departed identity for history with + /// `PersistentDPNSName.isOwned == false`. A transfer to another identity in + /// the same wallet instead rebinds the schema's single unique-name row to + /// the current owner. Owned-name surfaces use + /// `PersistentDPNSName.predicate(identityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + /// DashPay profile cache for this identity — at most one row per + /// (network, identity) per the contract's per-`ownerId` + /// uniqueness on the `profile` document. Cascade-deleted from the + /// parent. Optional because not every identity has published a + /// profile (and the FFI changeset's `dashpay_profile: None` + /// semantics mean "no update", not "delete" — the persister never + /// nils this out from a flush). Inserted / refreshed by + /// `PlatformWalletPersistenceHandler.upsertDashpayProfile(...)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + /// DashPay contact-request rows owned by this identity (both + /// outgoing and incoming). Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `dpnsNames`: filters use + /// `PersistentDashpayContactRequest.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Append / overwrite / delete on the write path: the persister + /// callback applies upserts (per `(owner, contact, isOutgoing)`) + /// and tombstones (`removed_sent` / `removed_incoming`) directly. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + /// DashPay payment-history rows owned by this identity. + /// Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `contactRequests`: filters + /// use `PersistentDashpayPayment.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Populated by `PlatformWalletManager.refreshDashPayPayments` + /// (FFI getter → upsert), not by the persister callback. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + /// DashPay ignored senders (per-sender mute, = block, reversible, + /// local-only) owned by this identity. Cascade-deleted from the parent. + /// Persisted from the `ignored` changeset array by `persistContacts` + /// and read back at load to rebuild the Rust `ignored_senders` set — + /// without them an ignored sender resurfaces on relaunch. Filters use + /// `PersistentDashpayIgnoredSender.predicate(ownerIdentityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + /// Cached DashPay **contact** profiles owned by this identity (one + /// per contact whose public profile has been fetched). Cascade-deleted + /// from the parent. Same query-by-denormalized-id pattern as + /// `contactRequests`: filters use + /// `PersistentDashpayContactProfile.predicate(ownerIdentityId:)` rather + /// than walking this collection from a SwiftUI view. Populated by the + /// persister callback (`IdentityEntryFFI.contact_profiles` rows) and + /// read back at load to rebuild the Rust `contact_profiles` map. + /// Distinct from the owner's own `dashpayProfile`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + // Contracts in the local store that name this identity as their + // owner. `.nullify` so deleting the identity leaves the contract + // rows alive (with `ownerIdentity` nulled) — matches the user's + // intent that contracts persist independently of whether the owner + // identity happens to be loaded. + // The `@Relationship` macro is declared on the contract side + // (`PersistentDataContract.ownerIdentity`) so this is a plain + // stored property — see `wallet` above for the same pattern. + var ownedDataContracts: [PersistentDataContract] + + // MARK: - Initialization + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + // MARK: - Computed Properties + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + /// User-facing short name. Priority: `alias` → `mainDpnsName` + /// → `dpnsName` → truncated hex id. Mirrors the old + /// `IdentityModel.displayName` extension so views that read + /// this don't change behavior post-migration. + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + // MARK: - Methods + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } + + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + // Index configuration + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + // Count / sum axes (meta-schema v3, protocol version 14). Every + // keyword is persisted VERBATIM as authored in the contract JSON — + // `countable` keeps its boolean-or-string spelling ("true" / + // "countable" / "countableAllowingOffset"), and the `averageable` / + // `rangeAverageable` sugar is stored as-is rather than desugared. + // Interpreting the spellings (DPP's normalization rules) is protocol + // logic and stays out of the SDK; display layers map them for + // presentation. + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + // Ranking axes (each adds one ordered secondary tree) + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + // indexOnly member key (the property whose value keys each entry). + // Persisted only when declared; an omitted terminal on an indexOnly + // type means $ownerId per DPP, a default display layers apply. + var terminal: String? + + // Preallocation: creating the refersTo-referenced document also + // creates this index's trees, and deleting the last entry keeps them + var preallocated: Bool = false + + // Time-range bucketing transform ({on, range, step, phase}), if any + var timeRangeJSON: Data? + + // Properties in the index with sorting + var propertiesJSON: Data + + // Contested details (if contested) + var contestedDetailsJSON: Data? + + // Timestamps + var createdAt: Date + + // Relationship to document type + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + // Create unique ID by combining contract ID, document type name, and index name + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + // Store properties as JSON array + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } + + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + // Relationship + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } + + @Model + final class PersistentPendingInput { + /// Two single-column indexes: + /// * `outpoint` — the per-outpoint reconciliation lookup that + /// runs on every `upsertUtxo`. + /// * `walletId` — per-wallet pending-input scans (cleanup when + /// a wallet is removed, the storage explorer's network + /// scope, "long-lived non-zero pending count" diagnostics). + /// + /// SwiftData allows only a single `#Index` macro per model; + /// passing multiple key-path arrays declares multiple separate + /// indexes from one macro call. + #Index([\.outpoint], [\.walletId]) + var outpoint: Data + + /// Position of this input in the spending transaction's input + /// list. Carried so a future UI surface can render the input + /// index correctly without re-deriving from the raw tx bytes; + /// the resolution flow itself only uses `outpoint`. + var inputIndex: UInt32 + + /// 32-byte canonical txid of the spending transaction. Stored + /// in addition to the relationship below so the entry remains + /// usable if the parent `PersistentTransaction` isn't yet in the + /// background context (re-upsert ordering, fault-in lag, …). + var spendingTxid: Data + + /// The transaction this input belongs to. Cascade-deleted from + /// the parent side via `PersistentTransaction.pendingInputs` so + /// removing a tx doesn't leave dangling pending rows. + var spendingTransaction: PersistentTransaction? + + /// Wallet id (`PersistentTxo.walletId` denorm) so cleanup / + /// per-wallet diagnostics can scope without joining through the + /// transaction relationship. + var walletId: Data + + /// Insertion timestamp — useful for spotting stale entries that + /// never resolved (orphans whose previous output isn't ours). + var createdAt: Date + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } + + @Model + final class PersistentPlatformAddress { + /// Index `walletId` so per-wallet platform-address scans — + /// `predicate(walletId:)`, the storage explorer's network scope + /// fallback, BLAST-sync re-upsert paths — hit an index instead + /// of scanning the whole table. + #Index([\.walletId]) + + /// DIP-0018 bech32m-encoded address (`dash1…` / `tdash1…`). Unique + /// across the SwiftData store — a collision would imply a wallet- + /// id / derivation path collision. + @Attribute(.unique) var address: String + /// `PlatformAddress` type byte: 0 = P2PKH, 1 = P2SH. Matches the + /// discriminant emitted by the Rust-side FFI. + var addressType: UInt8 + /// 20-byte address hash. Kept denormalized so the BLAST balance + /// callback (which gets hashes, not full addresses) can upsert in + /// one fetch. + @Attribute(.unique) var addressHash: Data + /// 33-byte compressed secp256k1 public key, or empty Data if the + /// Rust side couldn't produce one (pool entries that stored only + /// a script, etc.). + var publicKey: Data + /// DIP-17 account index (field `account` in `PlatformPayment`). + var accountIndex: UInt32 + /// DIP-17 derivation index within the account. + var addressIndex: UInt32 + /// BIP32 derivation path (e.g. `"m/9'/5'/17'/0'/0'/0"`). + var derivationPath: String + /// Marked used by the Rust address pool (first-seen tx or explicit + /// `mark_used`), or auto-flipped by BLAST when a non-zero + /// balance / nonce first arrives. + var isUsed: Bool + /// Credit balance in credits (1e11 credits per DASH). + var balance: UInt64 + /// Current anti-replay nonce. + var nonce: UInt32 + /// Platform block height where this address first appeared in a + /// balance changeset. Zero until the address is seen on-chain. + var firstSeenHeight: UInt32 + /// Platform block height this row's `balance` is current **as of** + /// — the balance height pin (`AddressFunds::as_of_height` in Rust). + /// Round-tripped verbatim through the persistence callbacks so the + /// sync's delta-replay gate survives restarts. Zero means "unknown + /// provenance" (rows persisted before the pin existed). + var lastSeenHeight: UInt64 + /// 32-byte wallet ID that owns this address. Denormalized from + /// `account.wallet.walletId` so per-wallet `@Query` filters don't + /// have to traverse two optional relationships. + var walletId: Data + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent account (PlatformPayment, type tag 14). + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + // Property type and constraints + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + // Property attributes + var transient: Bool + var isRequired: Bool + + // Timestamps + var createdAt: Date + + // Relationship to document type + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + // Create unique ID by combining contract ID, document type name, and property name + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } + + @Model + final class PersistentPublicKey { + // MARK: - Core Properties + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + // MARK: - Key Data + var publicKeyData: Data + + // MARK: - Contract Bounds + /// JSON-encoded `[base64(contractId)]` — legacy storage shape + /// that only retains the contract id, never the document-type + /// name. New code paths still write here for the id portion; + /// `contractBoundsDocumentTypeName` carries the doc-type so + /// the `SingleContractDocumentType` variant round-trips + /// faithfully. Keeping the field shape lets old SwiftData + /// stores that predate the doc-type column continue to load + /// without migration (the doc-type column is just `nil`). + var contractBoundsData: Data? + + /// When set, the key's bounds are + /// `.singleContractDocumentType(id: contractBoundsData[0], + /// documentTypeName: contractBoundsDocumentTypeName)`. When + /// `nil`, the key is either unbounded (when `contractBoundsData` + /// is also nil) or bounded to a whole contract via + /// `.singleContract(id:)`. Optional so old stores load cleanly. + var contractBoundsDocumentTypeName: String? + + // MARK: - Private Key Reference (optional) + var privateKeyKeychainIdentifier: String? + + // MARK: - Derivation breadcrumb (derive-sign-destroy) + /// 32-byte wallet id that owns this identity key, denormalized from the + /// discovery breadcrumb. Paired with `identityDerivationPath`, it lets the + /// signer derive this key on demand from the Keychain-held seed instead of + /// reading a stored scalar. `nil` for rows persisted before this column + /// existed and for keys with no wallet association; such rows fall back to + /// the stored scalar until the backfill populates them. Additive optional + /// column => SwiftData lightweight migration. + var walletId: Data? + + /// Full DIP-9 identity-authentication path + /// `m/9'/coin'/5'/0'/ECDSA'/identityIndex'/keyIndex'` the signer feeds to + /// the mnemonic resolver to derive this key's private scalar at sign time. + /// The authoritative breadcrumb; `nil` until written on persist or + /// backfilled from the key's Keychain metadata. + var identityDerivationPath: String? + + // MARK: - Metadata + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + // MARK: - Relationships + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + // MARK: - Initialization + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + + // MARK: - Computed Properties + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + // Always clear the doc-type column when the contract- + // bounds ids change through this setter. The + // `documentTypeName` is paired with a SPECIFIC id, so + // mutating ids without explicitly carrying the doc- + // type would leave the columns inconsistent and make + // `toIdentityPublicKey()` reconstruct a stale variant. + // Callers that want the full `.singleContractDocumentType` + // round-trip should write `contractBoundsDocumentTypeName` + // explicitly after this setter, or go through + // `PersistentPublicKey.from(IdentityPublicKey, identityId:)` + // which sets both columns atomically. + contractBoundsDocumentTypeName = nil + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + /// Check if this public key has an associated private key identifier + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } + + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + // Basic token supply info + var baseSupply: String + var maxSupply: String? + var decimals: Int + + // Token conventions + var localizations: [String: TokenLocalization]? + + // Status flags + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + // History keeping rules + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + // Control rules + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + // Distribution rules + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + // Marketplace rules + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + // Main control group + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + // Description + var tokenDescription: String? + + // Timestamps + var createdAt: Date + var lastUpdatedAt: Date + + // Relationships + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + // Create unique ID by combining contract ID and position + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + // Default values + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } + + @Model + final class PersistentTokenBalance { + /// Index `networkRaw` for per-network balance scans. Token-balance + /// rows are aggregated per-identity per-token; UI surfaces always + /// scope to the active network. + #Index([\.networkRaw]) + + // MARK: - Core Properties + var tokenId: String + var identityId: Data + /// Schema-stable signed carrier for the protocol's unsigned balance. + /// SwiftData/SQLite keep the original `balance` Int64 column unchanged; + /// interpret its bits through `unsignedBalance` at every API boundary. + var balance: Int64 + var frozen: Bool + + // MARK: - Timestamps + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + // MARK: - Token Info (Cached) + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + // MARK: - Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Relationships + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + // MARK: - Initialization + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + /// Full-domain unsigned initializer. The distinct argument label preserves + /// the original public `balance: Int64` source API without making integer + /// literals ambiguous between signed and unsigned overloads. + public convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + // MARK: - Computed Properties + /// Lossless full-domain view over the schema-stable signed carrier. + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + // Place the decimal point in the exact integer string. A Double + // conversion loses low digits well before UInt64.max. + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } + + @Model + final class PersistentTransaction { + /// Index on `firstSeen` so per-wallet queries — which fetch + /// `PersistentTxo` rows by `walletId` then sort their parent + /// transactions by `firstSeen` — get a sorted scan instead of + /// an in-memory O(N log N) pass. The unique `txid` index covers + /// point-lookups; this one covers the timeline. + #Index([\.firstSeen]) + + /// Transaction ID (32-byte hash, raw little-endian wire bytes — + /// the same orientation Rust hands us via the FFI `[u8; 32]`). + /// Stored as raw `Data` so the unique index covers 32 bytes + /// instead of a 64-char hex string, and the persistence + /// handler avoids a hex round-trip on every write. + @Attribute(.unique) var txid: Data + /// Raw transaction bytes (consensus-encoded — the same wire + /// format `dashcore::consensus::encode::serialize` produces and + /// `Transaction::consensus_decode` round-trips). The FFI write + /// path always populates this; the persister-fallback read path + /// (`PlatformWalletPersistence::get_core_tx_record`) hands it + /// back over FFI so Rust can decode a real `Transaction` + /// without a placeholder body. + var transactionData: Data + /// Context: 0=mempool, 1=instantSend, 2=inBlock, 3=inChainLockedBlock. + var context: UInt32 + /// Block height (0 for mempool). + var blockHeight: UInt32 + /// Block hash (nil for mempool). + var blockHash: Data? + /// Block timestamp. + var blockTimestamp: UInt32 + /// The transaction's index within its block (`block.vtx` order), + /// meaningful only when [`hasBlockPosition`]. Pure storage of the + /// Rust-stamped value (rust-dashcore#891): restored provider special + /// transactions hand it back so the masternode aggregation keeps + /// Core's same-block apply order across restarts. `false` on rows + /// persisted before the field existed and on unconfirmed contexts. + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + /// Direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin. + var direction: UInt32 + /// Transaction type name (Standard, CoinJoin, etc.). Sourced + /// from Rust's `Debug` repr of `TransactionType` for human + /// display only — DO NOT use this string as a discriminant; + /// match on [`transactionTypeKind`] instead. The string is + /// not a stable wire contract (a `#[derive(Debug)]` rename on + /// the Rust side would silently change it). + var transactionType: String + /// Typed discriminant of Rust's + /// `key_wallet::transaction_checking::transaction_router::TransactionType`, + /// kept in lockstep with [`TransactionTypeKind`]. Use this byte + /// (via [`typedKind`] / [`isAssetLock`] / [`isAssetUnlock`]) to + /// branch on transaction kind in UI code; the parallel + /// [`transactionType`] string is human-readable only and not + /// stable. + /// + /// Sentinel `0xFF` means "pre-feature row whose discriminant + /// hasn't been populated yet" — SPV's next upsert round + /// replaces it with the real discriminant on touch. Accessors + /// treat the sentinel as unknown (no branch fires). + var transactionTypeKind: UInt8 = 0xFF + /// Net amount in duffs (signed: positive=received, negative=sent). + var netAmount: Int64 + /// Fee in duffs (nil if unknown). + var fee: UInt64? + /// User-assigned label. + var label: String + /// Timestamp when first observed (Unix seconds). + var firstSeen: UInt64 + + // MARK: - Provider (masternode) special-transaction payload + + /// Fields lifted by the Rust FFI from a ProRegTx / ProUpServTx + /// DIP-3 payload (see `provider_payload_fields` in + /// `rs-platform-wallet-ffi`). All optional — populated only when + /// [`typedKind`] is `.providerRegistration` / `.providerUpdateService`. + /// The Swift side never decodes the payload; these are pure storage. + /// + /// Masternode service endpoint as `"ip:port"`. + var providerServiceAddress: String? = nil + /// ProUpServTx `proTxHash` (32 raw wire bytes) linking the update to + /// its registration. `nil` for ProRegTx (whose own txid is the + /// proTxHash). + var providerProTxHash: Data? = nil + /// ProRegTx collateral outpoint txid (32 raw wire bytes); pair with + /// [`providerCollateralVout`]. `nil` when not a ProRegTx. + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + /// ProRegTx owner / voting key hashes (hash160, 20 bytes each). + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Transaction outputs created by this transaction. + /// + /// Cascade-deletes the matching `PersistentTxo` rows when the + /// transaction is removed — outputs cannot meaningfully exist + /// without their containing transaction (the outpoint, script, + /// amount, and address are all derived from it). + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + /// Transaction outputs spent *by* this transaction. + /// + /// Inverse of `PersistentTxo.spendingTransaction`. Default + /// `.nullify` delete rule (do not pass `.cascade`!) — those TXOs + /// are owned by their *creating* transaction, not this one. + /// Cascading from the spending side would let a recent tx wipe + /// outputs of an older tx on delete: a data-loss bug. Removing + /// this transaction merely detaches the spend-link and the TXOs + /// flip back to "unspent" until something else claims them. + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + /// Pending input outpoints — entries this transaction's input + /// list references but for which no `PersistentTxo` has been + /// upserted yet. Filled by `PlatformWalletPersistenceHandler. + /// upsertTransaction` via the FFI's `input_outpoints` slice; + /// each entry is consumed (deleted) by `upsertUtxo` when the + /// matching previous-output finally arrives. See + /// `PersistentPendingInput` for the full reconciliation flow. + /// Cascade-delete: removing the spending tx drops every pending + /// row that hasn't resolved yet. + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + /// Every account whose changeset bucket carried this tx record. + /// + /// This is a **superset** of the TXO-derived membership: it + /// includes payload-only involvement (special-tx payloads whose + /// Provider Owner / Voting key addresses matched an account) where + /// no `PersistentTxo` exists in the account, so the TXO join can + /// never surface it. The persistence handler appends the matched + /// account here for every record it upserts, mirroring how + /// `WalletChangeSetFFI::from_changeset` buckets `cs.records` by + /// `record.account_type` on the Rust side. + /// + /// The TXO join (`outputs` / `inputs` → `PersistentTxo.account`) + /// remains the canonical path for **funds** — balances, spend + /// tracking, per-address history all flow through it. This join + /// exists only so payload-only involvement is representable at + /// all; treat it as "account participation," not "account owns + /// value in this tx." + /// + /// Inverse of `PersistentAccount.involvedTransactions`, declared + /// on this side only (SwiftData needs the `inverse:` on exactly + /// one end of a many-to-many pair). Default `.nullify` delete rule + /// on both sides — deleting an account merely detaches it from the + /// tx (and vice versa); neither end cascades, since the tx row is + /// shared across accounts / wallets and the account outlives any + /// single tx. + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + // MARK: - Display Helpers + + /// Hex-encoded txid for UI / log sites. The on-disk row stores + /// the raw 32 bytes in wire/internal order (matches what + /// `dashcore::Txid::as_ref()` hands the FFI). The canonical + /// Bitcoin/Dash display convention is the *reverse* of those + /// bytes (the `Txid: Display` impl in dashcore-rust does the + /// same flip), so block-explorer hex matches what users see + /// here. Storage stays unflipped — predicate fetches compare + /// wire-order `Data` directly without re-encoding. + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + /// Typed view onto [`transactionTypeKind`]. `nil` only for the + /// `0xFF` sentinel (pre-feature row not yet re-persisted by SPV) + /// or for a future Rust-side variant addition Swift hasn't + /// learned about yet — both treated as "unknown" by the + /// `isAssetLock` / `isAssetUnlock` accessors so an unexpected + /// byte never silently fires the wrong branch. + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + /// `true` when this transaction is a Dash Platform asset-lock + /// funding tx — a Layer-1 burn that mints Layer-2 credits. The + /// wallet's `direction` classifier reports `Internal` because the + /// credit output is derived from this wallet's identity-funding + /// account, but the *intent* is conversion to L2 credits, not + /// "transaction to myself." + var isAssetLock: Bool { + typedKind == .assetLock + } + + /// Companion to [`isAssetLock`] — withdrawal back to L1. + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + /// `true` for a masternode provider-registration (ProRegTx). + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + /// `true` for a masternode provider-update-service (ProUpServTx). + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + /// ProUpServTx proTxHash in block-explorer (reversed) hex, or `nil`. + /// Matches [`txidHex`]'s display-order convention. + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + /// ProRegTx collateral outpoint as `"txidHex:vout"` in display order, + /// or `nil` when there's no collateral field. + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + /// ProRegTx owner key hash (hash160) in hex — key hashes are shown + /// in their natural forward byte order, unlike txids. + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// ProRegTx voting key hash (hash160) in forward-order hex. + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// `true` for masternode provider special transactions (ProRegTx + /// and the three ProUp*Tx kinds). Like asset locks, these get + /// classified `Internal` by the wallet's direction logic (the + /// wallet only sees its own owner/voting/payout keys referenced + /// in the payload), so direction-derived labels like + /// "Self-Transfer" are misleading for them. + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + /// Human-readable name for provider special transactions, `nil` + /// for every other kind. + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + /// Direction text for UI surfaces, overridden for asset-lock / + /// asset-unlock txs (the L1 DASH isn't going "to myself" — it's + /// being converted to / from L2 platform credits) and for + /// provider special txs (the payload references our keys but no + /// value moves "to myself"). + /// + /// Use this anywhere a human-readable "what happened" label is + /// needed; fall back to [`directionName`] only when the consumer + /// genuinely needs the raw direction (e.g. the filter dropdown). + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } + + @Model + final class PersistentTxo { + /// Index `walletId` so per-wallet TXO scans — the canonical + /// "show every TXO (and, by union of `transaction` + + /// `spendingTransaction`, every transaction) that touches wallet + /// W" path — hit an index instead of scanning the entire TXO + /// table. The denorm is what makes the predicate translatable + /// to SQL in the first place; this just makes the resulting + /// query fast at scale. + #Index([\.walletId]) + + /// Outpoint: 36 raw bytes (32-byte txid in wire orientation + + /// 4-byte vout little-endian) — the standard Bitcoin outpoint + /// serialization. Unique identifier stored explicitly so + /// SwiftData predicate fetches can hit a single column without + /// traversing the `transaction` relationship. Always equals + /// `PersistentTxo.makeOutpoint(txid: transaction.txid, vout: vout)`. + @Attribute(.unique) var outpoint: Data + /// Output index within the transaction. + var vout: UInt32 + /// Value in duffs. + var amount: UInt64 + /// Owning address (Base58Check). + var address: String + /// Script pubkey bytes. + var scriptPubKey: Data + /// Block height where created. + var height: UInt32 + /// Whether this is a coinbase output. + var isCoinbase: Bool + /// Whether confirmed in a block. + var isConfirmed: Bool + /// Whether locked by InstantSend. + var isInstantLocked: Bool + /// Whether reserved/locked for a specific purpose. + var isLocked: Bool + /// Whether this TXO has been spent. + /// + /// Denormalized: should track `spendingTransaction != nil`. Kept + /// as an explicit column because per-row spent/unspent filters + /// are a hot query path, and chasing the optional relationship + /// in a predicate drops SwiftData onto the same nested-optional + /// codepath that crashes elsewhere. The persistence handler is + /// responsible for keeping the two in sync; do not enforce + /// invariants here. + var isSpent: Bool + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// 32-byte wallet ID this TXO belongs to. Denormalized from + /// `account?.wallet.walletId` so per-wallet `@Query` predicates + /// can filter with a single equality check instead of chaining + /// through the optional `account` relationship — SwiftData's + /// predicate compiler can't translate that chain into SQLite and + /// crashes with `Unsupported function expression TERNARY(...).walletId`. + /// This is the single column callers filter on for "show every + /// TXO (and, by union of `transaction` + `spendingTransaction`, + /// every transaction) that touches wallet W". Empty `Data()` for + /// rows migrated from older schema; the next sync pass will + /// populate it. + var walletId: Data = Data() + + /// Containing transaction (the one that *created* this output). + /// Cascade-deleted from the parent side (see + /// `PersistentTransaction.outputs`). Optional only because the + /// underlying SwiftData inverse must allow nil during the brief + /// window between row insert and relationship attachment; in + /// steady state every TXO has a non-nil `transaction`. + var transaction: PersistentTransaction? + + /// The transaction that *spent* this output, or nil if the TXO + /// is unspent. Inverse of `PersistentTransaction.inputs`. Uses + /// the default `.nullify` delete rule from that side — deleting + /// the spending tx must not cascade-delete this row. + var spendingTransaction: PersistentTransaction? + + /// Position of this output within `spendingTransaction.input` + /// (i.e. the canonical "vin index"). Captured at the moment the + /// spend is reconciled — sourced from + /// `TransactionRecordFFI.input_outpoints` index, which itself + /// comes from `tx.input.iter()` on the Rust side, so the value + /// matches the serialized transaction's input ordering exactly. + /// `nil` when the TXO is unspent (no spending tx, no vin index) + /// or when migrated from an older row that predates the column. + /// Surfaced by `TransactionStorageDetailView` so input rows + /// render in serialized vin order with their real positions + /// rather than being re-sorted by outpoint hex (which loses + /// the relationship between row and serialized index). + var spendingInputIndex: UInt32? = nil + + /// Parent account. No longer paired with an inverse on the + /// account side — the canonical account path is + /// `coreAddress?.account`. This field is the fallback when the + /// address row isn't yet linked (out-of-order flush, address + /// pool rebuild, etc.). + var account: PersistentAccount? + + /// Owning `PersistentCoreAddress` row, if it exists in the + /// account's address pool. Linked alongside `address` (the + /// Base58Check string) — the string is the authoritative + /// identifier and survives even when the address pool is rebuilt + /// or the TXO was paid to an address never in our pool (e.g. an + /// outgoing recipient). The relationship is the convenient + /// pointer for navigating to derivation metadata, balance, and + /// pool tag without a separate fetch. Inverse of + /// `PersistentCoreAddress.txos`; `.cascade` on that side so + /// account / wallet teardown drops TXOs cleanly. + var coreAddress: PersistentCoreAddress? + + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + /// Build the 36-byte outpoint key (32-byte txid raw bytes + + /// 4-byte vout little-endian). Exposed so the persistence + /// handler can compose predicates / lookups directly from the + /// FFI's `[u8; 32]` + `u32` without going through string + /// formatting. + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + /// Convenience accessor for the containing transaction's txid + /// as raw 32-byte `Data`. Prefers the `transaction` relationship; + /// falls back to the first 32 bytes of `outpoint` when the + /// inverse is briefly nil during insert (so storage-explorer + /// rows still render a stable identifier rather than collapsing + /// to empty). + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + /// Hex-encoded txid for UI / log sites. Reverses bytes to match + /// the canonical block-explorer display (same flip as + /// `dashcore::Txid: Display`). Mirrors + /// `PersistentTransaction.txidHex` directly so the two stay in + /// sync; can't simply forward to it because we want the same + /// hex even when `transaction` is briefly unattached. + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + /// Human-readable outpoint (`:`) for UI / log + /// sites. Reconstructs from `txidHex` so the byte-flip stays + /// consistent across all display surfaces. + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } + + @Model + final class PersistentWallet { + /// Index `networkRaw` so per-network wallet scans (used everywhere + /// from the network-scoped storage explorer to the per-network + /// "is there a wallet on this chain yet" lookups) don't degrade + /// to a table scan. Also index `walletGroupId` so the Wallet Info + /// "Networks" lookup — which fetches every sibling-network row for + /// a seed by its group id — stays a keyed scan. + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + /// 32-byte NETWORK-SCOPED wallet ID, and the row's primary + /// uniqueness key. Since the network-scoping change the same seed + /// yields a DISTINCT `walletId` per network (a domain-tagged network + /// byte is folded into the digest), so a wallet that exists on + /// multiple chains has one row per network, each with its own id — + /// the network is already baked into the id, so `walletId` alone is + /// globally unique (an earlier `(walletId, networkRaw)` composite + /// was a leftover from the pre-scoping model, where one seed shared + /// a single id across networks and `networkRaw` was the only + /// distinguishing column). To gather a seed's sibling-network rows, + /// group by `walletGroupId` (which is the same across networks), + /// not by this id. + var walletId: Data + /// 32-byte NETWORK-INDEPENDENT group id shared by every network's + /// wallet derived from the same seed (Rust computes it as the + /// no-network digest of the root key). Distinct from `walletId`, + /// which is network-scoped. Used to group a seed's sibling-network + /// rows in the Wallet Info "Networks" section. Defaults to empty + /// for rows written before this column existed (pre-release, no + /// migration); consumers treat empty as "legacy — this single row + /// only". + var walletGroupId: Data = Data() + /// Network this wallet belongs to. `nil` means "not yet known" — + /// the row was created by a changeset before `persistWalletMetadata` + /// filled the network in. Views treat `nil` as unknown. + /// + /// Stored as the `Network.rawValue` `UInt32?` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32? + + /// Type-safe accessor over `networkRaw`. `nil` round-trips as + /// `nil`; non-nil reads fall back to `.testnet` if the stored + /// raw value ever drifts out of the `Network` range. + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + /// Optional wallet name. + var name: String? + /// Optional free-form user-supplied description. Mirrored into + /// the keychain metadata blob (see `WalletKeychainMetadata`) so + /// it survives a SwiftData wipe / reinstall via the + /// orphan-mnemonic recovery flow. No UI surfaces this yet, but + /// the column is wired so existing rows roll forward without a + /// schema migration when it lands. + var walletDescription: String? + /// Birth height — block height when the wallet was created. + var birthHeight: UInt32 + /// Last synced core block height. + var syncedHeight: UInt32 + /// Timestamp of last sync (Unix seconds). + var lastSynced: UInt64 + /// Bincode-serialised + /// `dashcore::ephemerealdata::chain_lock::ChainLock` carrying the + /// wallet's `WalletMetadata::last_applied_chain_lock` from the + /// previous session. Roundtripped across app launches so the + /// asset-lock-resume CL-from-metadata fallback in Rust's + /// `proof.rs` can fire on catch-up at launch without waiting + /// for SPV to re-apply a fresh ChainLock. `nil` when no + /// ChainLock has ever been observed for this wallet (fresh + /// wallet, or pre-feature row). + var lastAppliedChainLockBytes: Data? + /// User imported this wallet from an existing mnemonic (as + /// opposed to generating a fresh one). Cosmetic flag that + /// drives the "📥 Imported" badge; defaulted to `false` for + /// rows that predate the column. + var isImported: Bool = false + /// Verified seed-binding marker: the BIP44 account-0 xpub that the + /// Keychain-resolved seed was proven to derive, bound to the mnemonic + /// Keychain item's identity stamp, written after one successful + /// `platform_wallet_verify_seed_binds_to_wallet_cached` run. On later + /// launches the unlock path hands this back to Rust (with the item's + /// current stamp), which skips the mnemonic-resolving derivation when + /// it still matches — and re-verifies when the xpub OR the Keychain + /// item changed. Opaque to Swift — Rust decides match-vs-verify; this + /// column only stores and returns it. `nil` (rows predating the + /// column, or never verified) means the full check runs at the next + /// unlock. + var seedBindingVerifiedMarker: String? + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Accounts belonging to this wallet. + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + /// Identities registered against this wallet. Cardinality is + /// 0..N — a wallet may have zero identities (freshly created) + /// or many. Deletion semantics: `.nullify` so an identity + /// survives a wallet delete as an orphaned row (useful for + /// post-mortem inspection and possible re-association if the + /// wallet is re-imported from the same seed). + /// + /// Paired with `PersistentIdentity.wallet` (plain stored + /// property; the inverse key lives on this side). + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index a3e5f5626de..f340cf34b79 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -78,6 +78,50 @@ public final class PersistentPendingInput { /// never resolved (orphans whose previous output isn't ours). public var createdAt: Date + /// Set when `applySweptTransaction` repurposes this row as a durable + /// claim rather than an ordinary in-flight spend: the original + /// spending transaction turned out to be a loser, this input wasn't in + /// `released`, and the funding `PersistentTxo` still hasn't arrived to + /// hold the claim itself. `spendingTxid` is overwritten to the winner + /// (`superseded_by`) and `spendingTransaction` is detached so the row + /// survives the loser's cascade-delete. `upsertUtxo` checks this flag + /// on resolve: a tombstone forces `PersistentTxo.isSpent = true` + /// unconditionally (a sweep's winner is already final, unlike an + /// ordinary pending spend whose confirmation is still pending) and + /// stamps `PersistentTxo.supersededByTxid` so the mark survives even + /// when the winner's own row never materializes. Defaulted `false` so + /// existing rows migrate as ordinary pending entries. + public var isSweptTombstone: Bool = false + + /// The WINNER'S own mined block height, stamped when a block-context + /// sweep (`SweepBatchFFI.has_winner_mined_height`) repurposes this row + /// into a tombstone — the projection of upstream 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 deliberately + /// records nothing for a mempool/IS-lock spend ("an unconfirmed spend + /// must not invalidate a coin"). Not an observation watermark: the + /// height rides the sweep event itself, so nothing here guesses when + /// the winner mined. It is the row's whole lifetime rule — + /// `collectFinalizedSweptTombstones` deletes the tombstone exactly when + /// the finality boundary `min(chainlockHeight, syncedHeight)` reaches + /// this stamp (upstream's `prune_finalized_observed_spends` condition + /// verbatim, no margin): every BIP158 filter at or below the boundary + /// has been matched with no false negatives, so the funding transaction + /// of the guarded outpoint — necessarily mined at or below the spend's + /// own height — has either been delivered (draining the row) or + /// provably never will be. A mempool-context sweep (IS-locked winner, + /// unmined) writes its tombstone with this NIL on purpose: under + /// DIP-10 the lock alone settles the input, but the winner has no + /// mining deadline, so no boundary can ever prove its funding output + /// delivered-or-never — the collector never touches an unstamped row, + /// and the hold lasts until the funding TXO drains it, a later + /// block-context sweep stamps it, or a release deletes it. + /// Re-pointing an existing tombstone on a mempool-context sweep keeps + /// the earlier block-context stamp untouched (upstream never retracts + /// an observed-spend entry for an unconfirmed conflict). + /// Optional, so existing stores lightweight-migrate. + public var winnerMinedHeight: UInt32? + public init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..654ec7e5c9d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -119,6 +119,23 @@ public final class PersistentTransaction { public var createdAt: Date public var lastUpdated: Date + /// Durable global exclusion for a swept loser. + /// + /// Set by `applySweptTransaction` in EVERY wallet's callback that + /// observes this row's sweep — not only the one whose deletion happens + /// to remove it. `store()` commits once per wallet, independently, so a + /// row `commit_batch` holds back for a second wallet's still-outstanding + /// claim cannot let that hold-back also postpone the parts of the sweep + /// that are true regardless of who else has weighed in: this flag is + /// what stays true the moment the first wallet's callback runs, so a + /// crash or rejection before any other wallet's callback arrives still + /// leaves the row excluded from every restore/enumeration path. `true` + /// means Rust has already proven the transaction can never confirm; + /// callers must treat the row as gone regardless of whether it still + /// physically exists (see `applySweptTransaction`'s doc for why the + /// physical delete is demoted to housekeeping once this is set). + public var isGloballySwept: Bool = false + /// Transaction outputs created by this transaction. /// /// Cascade-deletes the matching `PersistentTxo` rows when the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 1775eda311e..0dae02f814b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -86,6 +86,27 @@ public final class PersistentTxo { /// the spending tx must not cascade-delete this row. public var spendingTransaction: PersistentTransaction? + /// 32-byte txid of the transaction a sweep's winner is known to have + /// beaten this coin to — the durable carrier of a sweep hold, + /// mirroring the SQLite store's `spent_in_txid`. Two writers set it: + /// `applySweptTransaction` holding an already-materialized input, and + /// `upsertUtxo` resolving a `PersistentPendingInput` tombstone + /// (`isSweptTombstone`) — the funding output arrived only after its + /// loser was already swept and deleted. The winner named here need not + /// have a row of its own (it can pay only outside addresses), which is + /// why the stamp is a bare txid rather than a relationship. + /// + /// `upsertUtxo`'s recovery clear keys on it: a coin the wallet + /// re-delivers as unspent lifts `isSpent` only when both + /// `spendingTransaction` and this are nil — a rescan re-finds the + /// funding output precisely because it is blind to an unconfirmed + /// winner no block carries yet, so re-delivery cannot outrank the + /// sweep's verdict. Cleared only by the sweep release pass, when a + /// later sweep proves the coin came free after all; a pre-stamp row + /// (written before holds named their winner) still frees on + /// re-delivery. + public var supersededByTxid: Data? + /// Position of this output within `spendingTransaction.input` /// (i.e. the canonical "vin index"). Captured at the moment the /// spend is reconciled — sourced from diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 6d6e80644a4..52365db3006 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -88,6 +88,19 @@ public final class PersistentWallet { /// ChainLock has ever been observed for this wallet (fresh /// wallet, or pre-feature row). public var lastAppliedChainLockBytes: Data? + /// NUMERIC block height of the wallet's last applied ChainLock — + /// the same watermark whose bincode blob sits in + /// `lastAppliedChainLockBytes`, which is opaque on this side of the + /// FFI. Delivered separately through the persistence extension's + /// `on_persist_wallet_changeset_chain_lock_height_fn` and stored + /// with monotonic-max semantics (chain locks only move forward). + /// This is one half of the swept-tombstone collection boundary + /// `min(chainlockHeight, syncedHeight)` — see + /// `PersistentPendingInput.winnerMinedHeight`. `nil` (fresh wallet, + /// pre-feature row, or a native library too old to fill the slot) + /// means no finality boundary is known and no tombstone may be + /// collected. Optional, so existing stores lightweight-migrate. + public var lastAppliedChainLockHeight: UInt32? /// User imported this wallet from an existing mnemonic (as /// opposed to generating a fresh one). Cosmetic flag that /// drives the "📥 Imported" badge; defaulted to `false` for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8b7025e72b3..838eb567950 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -69,6 +69,26 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// across restarts. Mirrors /// `PersistenceCapabilities::TRACKED_MASTERNODES`. public static let trackedMasternodes: UInt64 = 1 << 10 + /// A round's sweep batches — delivered through the persistence + /// extension's size-negotiated sweep callback — are durably applied + /// batch by batch and in order: swept transactions and their outputs + /// are excluded from every restore and enumeration path (physical + /// deletion or a durable marker alike), released outpoints are freed + /// unless a surviving claim supersedes, and non-released spend claims + /// are retained durably. Mirrors + /// `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; Rust only honours + /// the declaration when the extension actually carries the callback. + public static let coreSweepRemoval: UInt64 = 1 << 11 + /// DashPay payment rows delivered on a store round + /// (`dashpay_payments_overlay`) are durably applied. This is what the + /// wallet-event adapter keys on before coupling a sweep's + /// `Pending → Failed` payment flip to the sweep's own atomic round — + /// a non-attesting host (Android keeps payment recording + /// in-memory-only) gets the in-memory flip with nothing + /// round-coupled. Mirrors `PersistenceCapabilities::DASHPAY_PAYMENTS`; + /// Rust only honours the declaration when the payments callback is + /// actually wired. + public static let dashpayPayments: UInt64 = 1 << 12 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..c3c0fea5c7c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -79,6 +79,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data, transaction: PersistentTransaction ) -> Bool { + // A globally-swept row is never "owned" for restore purposes, even + // though `involvedAccounts` below can still name this wallet — that + // membership was recorded before the transaction lost the sweep and + // `applySweptTransaction` does not (and should not) rewrite history + // by removing it. Excluding here, at the single call site every + // restore-to-Rust enumeration goes through (`walletCoreTxids`), is + // what keeps a row `isGloballySwept` has already proven dead from + // being handed back as this wallet's transaction after a restart. + guard !transaction.isGloballySwept else { return false } if transaction.involvedAccounts.contains(where: { let wallet: PersistentWallet? = $0.wallet return wallet?.walletId == walletId @@ -146,6 +155,72 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// atomically. private var inChangeset = false + /// In-memory index over the rows the open changeset round has + /// inserted into `backgroundContext` but not yet saved, keyed by the + /// same columns the hot-path fetches filter on. + /// + /// Why it exists: a `FetchDescriptor` with the default + /// `includePendingChanges == true` evaluates its predicate IN MEMORY + /// against every unsaved insert of the target entity — + /// `Predicate.evaluate` walks the key path per row, with a dynamic + /// cast per step. The `#Index`/`.unique` declarations on the models + /// only accelerate the SQL half of the fetch; the pending-changes + /// half is always a linear scan. Because the whole round defers its + /// `save()` to `endChangeset` (the `inChangeset` contract above), a + /// large wallet's initial scan accumulates thousands of unsaved + /// inserts in one round, and every subsequent fetch paid O(inserts + /// so far) — quadratic over the round, and measured as ~99% of CPU + /// on `serialQueue` minutes after the SPV scan itself finished. + /// + /// How it is used: while the index is non-nil, the lookup helpers + /// (`fetchTransactionRow`, `fetchTxoRow`, `pendingInputRows`, + /// `coreAddressRow`) consult it first and run their store fetch with + /// `includePendingChanges = false`, so SQLite answers from its + /// indexes and never triggers the in-memory scan. The single-object + /// maps are READ-THROUGH: they hold both this round's unsaved + /// inserts (registered at the insert site) and every row a store + /// fetch has already resolved this round (registered by the helper). + /// Caching store hits is not an optimization — it is load-bearing + /// for correctness: a store-only fetch that matches an + /// already-registered object REFRESHES that object to its store + /// values, silently discarding the round's unsaved attribute + /// mutations (unlike the default pending-changes fetch, which + /// returns the object with its in-memory state; staged deletions do + /// survive the refresh). Registering every resolution means each + /// key touches the store at most once per round — at first touch, + /// before the round can have mutated the object — so the refresh + /// never has anything to discard. Both sources stay disjoint + /// because `beginChangeset` builds the index only over a clean + /// context. Rows deleted mid-round are filtered by `isDeleted` on + /// both sources (index entries are deliberately never + /// unregistered — `isDeleted` already answers the question, and it + /// also covers deletes on paths that don't know about the index, + /// e.g. wallet removal). + /// + /// Lifecycle: built by `beginChangeset`, discarded in + /// `endChangeset`'s `defer` on both the commit and rollback paths — + /// after a commit the cached rows are ordinary saved rows the store + /// fetch finds on its own, and on rollback the context un-inserts / + /// reverts every one of them, so the index dies with the round + /// either way and never leaks state across rounds. `nil` outside a + /// round (and inside a round that began on a dirty context — see + /// `beginChangeset`), in which case the lookup helpers run the + /// exact pre-index fetch, pending changes included. + private struct ChangesetRoundIndex { + var transactionsByTxid: [Data: PersistentTransaction] = [:] + var txosByOutpoint: [Data: PersistentTxo] = [:] + /// `PersistentPendingInput.outpoint` is deliberately not unique + /// (re-org / double-spend can stack rows on one outpoint — see + /// the model), so this holds only the round's staged inserts + /// per key; saved rows come from the store fetch each time. + /// Pending rows need no read-through registration because + /// nothing mutates their attributes before the sweep pass, and + /// sweeps run last in the round (see `pendingInputRows`). + var pendingInputsByOutpoint: [Data: [PersistentPendingInput]] = [:] + var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] + } + private var roundIndex: ChangesetRoundIndex? + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -192,10 +267,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.network = network self.modelFetcher = modelFetcher self.backgroundContext = ModelContext(modelContainer) - self.backgroundContext.autosaveEnabled = true + // Autosave off: this context is the transaction buffer for the + // begin → changeset → sweeps → end sequence, and autosave can commit + // its pending mutations between those callbacks. Since sweeps moved + // to their own callback the round spans two calls, so an autosave + // landing in between would make the watermark and the additive rows + // durable while the removal is still unstaged — and `rollback()` + // cannot take back a save that already happened. The handler + // attests `ATOMIC_CHANGESETS`, which is what Rust now relies on to + // trust the split transport, so that guarantee has to be real. + // + // Nothing depends on the implicit commits: every path either runs + // inside a round, which `endChangeset` commits with its single + // `save()`, or saves itself when `inChangeset` is clear. + self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) - self.trackedMasternodeContext.autosaveEnabled = false - } + self.trackedMasternodeContext.autosaveEnabled = false } /// Synchronously run `body` on `serialQueue`. /// @@ -458,10 +545,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let existing = try? backgroundContext.fetch(descriptor).first { // Same terminal rule as the upsert guard above: a // Consumed (4) row is deliberately retained for - // historical lookup and the only removal emitter - // (`untrack_asset_lock`) targets rejected Built - // rows — a removal reaching a consumed row is by - // construction a stale write. + // 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 only + // tombstones entries still tracked, which a consumed + // lock no longer is. A removal reaching a consumed row + // is by construction a stale write. + // `AssetLockChangeSet::merge` guarantees one call never + // carries an upsert and a removal for the same + // outpoint, so the upserts-then-removals order above is + // layout, not load-bearing sequencing. if existing.statusRaw == 4 { continue } @@ -992,9 +1086,35 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Called from the Rust persister when an SPV round produces core- /// wallet state changes. Upserts PersistentAccount / Transaction / /// Utxo records so views observing via `@Query` update automatically. - func persistWalletChangeset(walletId: Data, changeset: UnsafePointer) { + /// + /// Returns `false` when the round could not be applied, which the C shim + /// forwards to Rust so `store()` rolls the round back instead of treating + /// it as durable. Everything this method itself applies is additive, so + /// only a failed wallet lookup reports it here; the round's subtractive + /// part arrives through `persistWalletChangesetSweeps` below, with its + /// own failure path. + @discardableResult + func persistWalletChangeset( + walletId: Data, + changeset: UnsafePointer + ) -> Bool { onQueue { - guard let wallet = findWalletRecord(walletId: walletId) else { return } + // A stale post-deletion callback is not a failure — there is + // simply nothing left to write to. A fetch that *throws* is a + // different matter: reporting success would let Rust discard the + // round's sweep, and a later callback could then persist a height + // beyond a removal that never landed. + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangeset: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard let wallet else { return true } let cs = changeset.pointee // Chain update. @@ -1024,6 +1144,31 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { wallet.lastUpdated = Date() } + // Bounded tombstone lifetime (the SwiftData mirror of the SQLite + // store's `collect_finalized_tombstones`): once the finality + // boundary reaches a swept tombstone's winner-height stamp, the + // row has provably never drained — a genuine claim's rows are + // deleted by the drain in `upsertUtxo` when its funding TXO + // lands — so what remains is junk from foreign inputs of swept + // incoming payments, previously permanent and attacker-growable. + // The boundary is upstream's verbatim: + // `min(chainlockHeight, syncedHeight)` — the chainlock half + // proves the winner's spend final, the synced half certifies + // BIP158 filter coverage of every block that could have carried + // the funding output. The chainlock height arrives NUMERICALLY + // through the extension's chain-lock-height slot (the bincode + // bytes above are opaque here); until one has been stored no + // finality boundary exists and nothing may be collected — + // present chainlock BYTES prove nothing about how far finality + // reaches, and synced-height progress alone is not finality. + if cs.has_chain, cs.chain.has_synced_height, cs.chain.synced_height > 0, + let clHeight = wallet.lastAppliedChainLockHeight { + collectFinalizedSweptTombstones( + walletId: walletId, + boundary: min(clHeight, cs.chain.synced_height) + ) + } + // Balance delta — Rust still emits per-round deltas, but the // PersistentWallet `balance*` fields they used to update were // removed (canonical source is now the in-memory account @@ -1042,10 +1187,703 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Swept transactions no longer ride this struct: they arrive + // through `persistWalletChangesetSweeps(walletId:sweeps:count:)` + // below, fired by Rust immediately after this callback in the + // same round. The struct crosses the C ABI by bare pointer, so a + // field appended to it cannot be proven present to a consumer + // built after a producer — the extension callback's negotiated + // `struct_size` is what carries that proof instead. + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete this wallet's swept tombstones whose winner-height stamp the + /// finality boundary has reached: `winnerMinedHeight <= boundary`, + /// where the caller computes `boundary = min(chainlockHeight, + /// syncedHeight)` — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, and the + /// SQLite store's `collect_finalized_tombstones`. No observation-age + /// margin: the stamp IS the winner's own mined height, carried on the + /// sweep event, 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 tombstone + /// unstamped, because such a winner has no mining deadline and no + /// watermark can prove its inputs' funding delivered-or-never — an + /// unstamped row is a live hold, resolved only by the funding TXO + /// draining it, a later block-context sweep stamping it, or a release + /// deleting it. See the property doc on + /// `PersistentPendingInput.winnerMinedHeight`. + /// + /// Housekeeping, not correctness: a pass that cannot run self-heals on + /// the next boundary-carrying round, so a fetch failure logs and + /// returns instead of failing the round the way the sweep path must. + private func collectFinalizedSweptTombstones(walletId: Data, boundary: UInt32) { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + // Same pending-changes + in-memory-filter pattern as the sweep + // path's tombstone scan: rows tombstoned earlier in this round + // exist only as staged state, and `isSweptTombstone` is mutable, so + // a store-side predicate on it would test stale saved values. + descriptor.includePendingChanges = true + let rows: [PersistentPendingInput] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + print( + "⚠️ collectFinalizedSweptTombstones: scan failed: " + + "\(error.localizedDescription); skipping this pass" + ) + return + } + for pending in rows where pending.isSweptTombstone && !pending.isDeleted { + // A nil stamp is deliberately NOT back-filled. The unmined + // InstantSend sweep path produces one on purpose (the writer + // below maps a missing winner height to nil), so these rows + // are live holds, not stragglers: they must stay outside this + // height collector until the funding materialises, a later + // block-context sweep stamps them, or an authoritative release + // deletes them. Stamping one here would convert "no proof of + // finality" into a fabricated horizon. + guard let stamp = pending.winnerMinedHeight else { continue } + if stamp <= boundary { + backgroundContext.delete(pending) + } + } + } + + /// Extension entry for the round's NUMERIC chainlock height — the + /// same watermark whose bincode blob rides + /// `WalletChangeSetFFI.last_applied_chain_lock_bytes` (still stored, + /// for the Rust-side metadata roundtrip), delivered separately because + /// that blob is opaque here and the tombstone collection boundary + /// needs the number. Fired inside the round's begin/end bracket, after + /// the changeset callback, only when the round advanced the chainlock + /// watermark. + /// + /// Stores monotonic-max (chain locks only move forward; a late or + /// re-emitted lower height must not walk the boundary backwards), + /// then runs the tombstone collector with the completed boundary + /// `min(chainlockHeight, syncedHeight)` — the freshly known chainlock + /// half is what can newly prove a stamp final, so waiting for the next + /// height-carrying changeset would hold collectible junk for no + /// reason. Same fail-the-round contract as every per-kind callback: a + /// throwing wallet lookup returns `false` so Rust does not treat the + /// round as durable. + @discardableResult + func persistWalletChangesetChainLockHeight( + walletId: Data, + height: UInt32 + ) -> Bool { + onQueue { + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangesetChainLockHeight: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard let wallet else { return true } + + let effective = max(wallet.lastAppliedChainLockHeight ?? 0, height) + if wallet.lastAppliedChainLockHeight != effective { + wallet.lastAppliedChainLockHeight = effective + wallet.lastUpdated = Date() + } + + // `syncedHeight == 0` means no filter coverage is certified at + // all — the boundary's synced half is missing, so nothing can + // be proven final yet. + if wallet.syncedHeight > 0 { + collectFinalizedSweptTombstones( + walletId: walletId, + boundary: min(effective, wallet.syncedHeight) + ) + } + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Apply a round's sweep batches — the one subtractive part of the + /// changeset path, delivered through the size-negotiated + /// `PersistenceCallbacksExtension` slot rather than as a field on + /// `WalletChangeSetFFI` (see `persistWalletChangeset` for why). Rust + /// fires this right after that callback within the same + /// begin/end round, so a wallet-relevant winner riding in the round has + /// its claim on the shared inputs already recorded when the removal here + /// decides which links are left pointing at a dead transaction. + /// + /// Returns `false` to fail the round, same contract as + /// `persistWalletChangeset`: a deletion that silently didn't happen + /// would have Rust clear the sweep while the dead row survives to be + /// replayed at the next load. + @discardableResult + func persistWalletChangesetSweeps( + walletId: Data, + sweeps: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + // Same wallet gate as `persistWalletChangeset`: a stale + // post-deletion callback has nothing left to write to, but a + // lookup that throws must fail the round rather than let Rust + // discard a sweep that never landed. + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangesetSweeps: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard wallet != nil else { return true } + guard count > 0, let sweepsPtr = sweeps else { return true } + + // The funding txids this round removes, across every batch — + // the same changeset-wide set the SQLite co-swept rule keys + // on. A pending claim whose outpoint is funded by a co-swept + // loser is a claim on 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 the claim + // is neither released nor legitimate to hold. Tombstoning it + // would wedge the parent's chainlocked reinstatement forever + // (the re-delivered funding output drains into the + // tombstone-outranks pick, `supersededByTxid` pins the hold, + // and the recovery clear refuses stamped rows). + var coSwept = Set() + for batchIndex in 0.. 0, let txidsPtr = batch.txids else { continue } + for i in 0..() + if batch.released_outpoints_count > 0, + let releasedPtr = batch.released_outpoints { + for i in 0.. 0, let txidsPtr = batch.txids { + // This wallet's detached tombstones, fetched ONCE per + // batch and grouped by the live `spendingTxid` each + // loser is looked up under. The per-loser form of this + // fetch paid the pending-changes tax — an in-memory + // predicate pass over every unsaved insert of the + // entity — once per swept txid, and a single + // network-derived sweep can carry many losers into the + // same round as thousands of freshly staged records. + // Pending changes stay ON (rows tombstoned earlier in + // this round exist only as staged state), the predicate + // names only the immutable `walletId`, and the mutable + // halves (`isSweptTombstone`, `spendingTxid`) are read + // off the live objects — a store-side predicate on a + // mutable column would test stale saved values. + // Rebuilt per batch, not per round: an earlier batch's + // retargets must be visible to a later batch sweeping + // that batch's winner. Within one batch no rebuild is + // needed — rows retarget to the batch's own winner, and + // upstream never lists a batch's winner among its own + // losers. + var tombstonesBySpender: [Data: [PersistentPendingInput]] = [:] + do { + var pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + pendingDescriptor.includePendingChanges = true + for pending in try backgroundContext.fetch(pendingDescriptor) + where pending.isSweptTombstone && !pending.isDeleted { + tombstonesBySpender[pending.spendingTxid, default: []] + .append(pending) + } + } catch { + print( + "⚠️ persistWalletChangesetSweeps: tombstone scan failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + + for i in 0..( + predicate: #Predicate { released.contains($0.outpoint) } + ) + rows = try backgroundContext.fetch(releasedDescriptor) + } catch { + // Same contract as the loser loop: a release + // silently skipped would report a removal durable + // that never fully happened. + print( + "⚠️ persistWalletChangesetSweeps: release lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + for txo in rows where !txo.isDeleted { + guard Self.resolvedWalletId(of: txo) == walletId, + txo.spendingTransaction == nil else { continue } + txo.isSpent = false + txo.supersededByTxid = nil + txo.spendingInputIndex = nil + txo.lastUpdated = Date() + } + } + } + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete the mirror of a transaction the wallet swept. + /// + /// A swept transaction was a recorded spend that `supersededBy` provably + /// beat to one of its inputs, so it can never confirm; Rust has already + /// dropped it. Keeping the row would hand it back at the next load and + /// re-create a balance the wallet has already corrected — this is the + /// only removal the changeset path performs. + /// + /// `isGloballySwept` is upstream's word as of this callback, not a + /// permanent verdict — the wallet's sweep state can itself be swept in + /// turn (IS-lock precedence: a chainlocked return beats the IS-locked + /// conflict that swept it originally), and `upsertTransaction` clears + /// this flag when a later record reinstates the txid. See that + /// method's doc comment for what reinstatement can and cannot undo. + /// + /// `commit_batch` calls `store()` once per wallet, and each of those + /// commits independently — there is no single transaction spanning every + /// wallet this sweep touches. That splits what has to be durable in + /// *this* callback from what can wait for a later one: the outputs this + /// row created are phantom money for every wallet, not just the one + /// running right now, and once Rust has proven the row dead no + /// restore/enumeration path may serve it to anyone — waiting for the + /// last wallet's callback to confirm that would leave it acknowledged-but- + /// resurrectable for however long the other wallets take to run, or + /// forever if one of them crashes first or never arrives. So the outputs + /// are deleted and `isGloballySwept` is set in EVERY callback that + /// reaches this function, idempotently, before anything wallet-scoped is + /// touched below. Physically removing `row` itself is different: that is + /// safe to defer, because `isGloballySwept` already makes the row inert + /// the moment the first callback sets it — see the ownership check near + /// the bottom for why the row is still worth reclaiming once nothing + /// points at it, now purely as housekeeping. + /// + /// The coins it claimed to *spend* split in two, and + /// `released` is the authority on which is which: + /// + /// - an input named there came free — no surviving transaction spends it; + /// - every other input it claimed was taken by the transaction that beat + /// it, and is gone. + /// + /// That distinction cannot be made here. Upstream only ever sweeps + /// *unconfirmed* records, and this store flips `isSpent` only for a + /// spender that reached a block, so a swept loser holds its inputs by + /// link alone with `isSpent == false`; deleting the row nils the link and + /// every one of those coins would fall back into the restore set, + /// including the consumed one. Nor can the winner's own row be consulted: + /// it need not be wallet-relevant at all, and even when it is, the sweep + /// can be committed in a round that arrives before the winner's record. + /// So upstream computes the split and names the freed coins, and this + /// applies it verbatim — the rest are held spent with no spender + /// linked, attributed to the winner via `supersededByTxid`, which keeps + /// them out of the restore set durably. + /// + /// A held input can also have no `PersistentTxo` at all yet — the loser + /// was persisted before its own funding TXO was, so + /// `resolveInputOutpoint` parked the claim as a `PersistentPendingInput` + /// instead. `PersistentTransaction.pendingInputs` cascades on delete just + /// like `outputs`, so left alone that claim would vanish with `row` + /// below, and the funding TXO's own later `upsertUtxo` — even after a + /// restart — would have nothing to tell it the coin isn't really free. + /// A held pending input is therefore detached from `row` (so the cascade + /// no longer reaches it) and repointed at `supersededBy` before the + /// delete, flagged `isSweptTombstone` so `upsertUtxo` knows to keep the + /// coin spent — durably, via `PersistentTxo.supersededByTxid` — once the + /// funding TXO materializes rather than treating it as an ordinary + /// in-flight spend. A released pending input needs none of this: it is + /// left for the cascade, the same as a released materialized input needs + /// no special handling beyond the loop above. + /// + /// The tombstone is written for EVERY sweep context; only the stamp + /// differs. A BLOCK-CONTEXT sweep (`winnerMinedHeight` non-nil) stamps + /// the winner's own mined height — the projection of key-wallet's + /// `observed_spent_outpoints` — and `collectFinalizedSweptTombstones` + /// evicts the row once the finality boundary reaches it. A + /// mempool-context sweep (`winnerMinedHeight` nil — the winner is + /// IS-locked and not yet mined) writes the SAME tombstone UNSTAMPED, + /// which the collector never touches. The in-memory model an unstamped + /// tombstone mirrors is the account's `spent_outpoints`: upstream's + /// `drop_conflicted_transactions` deletes the loser and RETAINS the + /// winner's shared inputs there — under DIP-10 the IS lock alone + /// settles them — but that set is rebuilt from live records on load, + /// and after the sweep neither the deleted loser nor a (possibly + /// wallet-irrelevant) winner leaves a record to rebuild it from. The + /// tombstone is the hold's only durable carrier; dropping it lets a + /// post-restart funding delivery credit a coin the network has + /// provably consumed. + /// + /// Nothing may collect an unstamped tombstone: an IS-locked winner has + /// no mining deadline (and the funding tx of an input it spends may + /// itself be IS-locked and unmined), so no watermark proves the + /// funding delivered-or-never. It resolves only through proof — the + /// funding TXO drains it (a wallet-owned claim always eventually + /// delivers via BIP158), a later block-context sweep re-stamps it into + /// the collectible set, or a release deletes it. The permanent residue + /// is foreign inputs of IS-context sweeps (a swept INCOMING payment + /// reaches this loop too, and ownership cannot gate it — nothing + /// anywhere can prove an input foreign, dashpay/rust-dashcore#968), + /// bounded by attack cost rather than collection: masternodes lock + /// first-seen, so every such row needs a conflicting payment delivered + /// straight to this wallet while withheld from the network, plus a + /// fee-paying IS-locked double-spend. + /// + /// A tombstoned row can itself need to move again: `supersededBy` is + /// only this round's winner, and nothing stops it from losing a later + /// round to a further winner while its own funding TXO is still + /// unresolved. `row.pendingInputs` above cannot see that earlier + /// tombstone — it already detached from `spendingTransaction` (and + /// therefore from `row`) the moment it was first written — so it is + /// looked up the only other way it is still findable, by the scalar + /// `spendingTxid` it was repointed to, and carried the rest of the + /// chain below: deleted if this round finally frees its outpoint, + /// repointed at the new winner if not. + /// + /// `PersistentTransaction` is shared across wallets by design, but + /// `released` is not: upstream computes it per wallet + /// (`per_wallet_released_outpoints`), so this wallet's set says nothing + /// about an input a *different* wallet's coin claims on the same row. + /// The input decisions below are scoped to the inputs this wallet + /// actually owns; the physical row delete at the bottom is housekeeping + /// only now (see above) and runs once no other wallet's claim is still + /// attached to it. See the ownership check below for how "no other + /// wallet" is decided without an explicit cross-wallet coordination + /// point. + /// + /// Fetch-free by design: the caller resolves `row` (through the + /// round-index-aware sweep lookup, failing the round if SwiftData + /// cannot answer) and hands over this loser's `priorTombstones` from + /// its once-per-batch scan. A `nil` row skips only the row-scoped work, + /// NOT the whole function. Sweeps are idempotent and can name a + /// transaction this store never had — but they can also name one this + /// store DID have and another wallet's callback already deleted. The + /// row is shared; the detached tombstones this wallet wrote against it + /// are not, and they are exactly the state that is still findable — by + /// scalar `spendingTxid` — after the row is gone. Skipping them would + /// strand them: this wallet's release decision would never reach a + /// tombstone that then marks its coin spent by a transaction that no + /// longer exists, and a held one could never follow the chain to a + /// further winner. So the wallet-scoped tombstone reconciliation at the + /// bottom runs either way. + private func applySweptTransaction( + walletId: Data, + supersededBy: Data, + released: Set, + coSwept: Set, + row: PersistentTransaction?, + priorTombstones: [PersistentPendingInput], + winnerMinedHeight: UInt32? + ) { + if let row { + // The global half, done every time this function runs regardless + // of which wallet's callback it is or whether this row has been + // seen by a sweep before: delete the outputs this row created + // (they are nobody's coin, ever — a swept transaction cannot have + // funded anything) and mark the row excluded from restoration. + // Both are idempotent, so re-processing an already-flagged row (a + // second wallet's callback, or a re-emitted sweep) is a harmless + // no-op. + for output in row.outputs { + backgroundContext.delete(output) + } + row.isGloballySwept = true + + // `released` is only ever true of the wallet that computed it, so + // an input this wallet does not own must be left exactly as it is + // — that wallet's own callback (delivered earlier, arriving + // later, or never coming at all) is the only thing allowed to + // decide it. Resolved through `resolvedWalletId(of:)` rather than + // a raw `walletId` compare, same reasoning as `loadWalletList`: + // the denormalized column reads empty on a row migrated before it + // existed, and comparing it raw would make every such coin look + // unowned and leave it untouched forever. + for txo in row.inputs where Self.resolvedWalletId(of: txo) == walletId { + let held = !released.contains(txo.outpoint) + txo.isSpent = held + // A held coin is attributed to the winner — the same stamp + // the pending-input drain writes, and the one SQLite + // records as `spent_in_txid`. Without it the hold has no + // durable carrier: `upsertUtxo`'s recovery clear frees a + // spent row with neither a spender nor a marker, and a + // restore-rescan re-delivers the funding output precisely + // because it is blind to an unconfirmed winner no block + // carries yet — resurrecting a provably consumed coin. + // Only an explicit release frees a stamped hold; a + // released coin's stale marker is likewise the release + // pass's business (the outpoint loop in the caller), not + // this one's. + if held { txo.supersededByTxid = supersededBy } + txo.spendingTransaction = nil + txo.lastUpdated = Date() + } + for pending in row.pendingInputs where pending.walletId == walletId { + if coSwept.contains(pending.outpoint.prefix(32)) { + // A claim on a co-swept loser's own output: nobody's + // coin, never in `released`, and a tombstone here + // would outlive the parent's reinstatement — see the + // `coSwept` doc in the caller. Deleted with the batch, + // the mobile mirror of the SQLite co-swept DELETE. + backgroundContext.delete(pending) + continue + } + guard !released.contains(pending.outpoint) else { + // Deleted now rather than left for the row's cascade. + // Still attached it reads as this wallet's claim in the + // ownership check below, so a shared loser holding one + // released input per wallet deadlocks: each callback + // sees the other's row and declines the delete, and + // replaying either reaches the same stalemate. The + // global marker keeps the dead transaction from + // contributing funds regardless, but the row and both + // pending entries would otherwise be stored forever. + backgroundContext.delete(pending) + continue + } + // Held in every winner context — `CORE_SWEEP_REMOVAL` + // requires each non-released input to keep a durable + // spend claim before its funding TXO materializes. A + // block-context winner stamps its mined height; an + // IS-locked, unmined winner leaves the stamp nil and the + // collector never touches the row — see the doc comment + // above for what resolves an unstamped hold. + pending.spendingTransaction = nil + pending.spendingTxid = supersededBy + pending.isSweptTombstone = true + pending.winnerMinedHeight = winnerMinedHeight + } + + // Whatever is still attached to `row` after the scoping above + // belongs to a different wallet that has not weighed in yet — + // this wallet's own rows are all resolved by now, held ones + // detached and released ones deleted. Whichever callback finds nothing + // left over is the last one to run and performs the delete, so + // order stops mattering. A wallet whose callback never arrives at + // all just leaves the row behind with every other wallet's inputs + // already correctly decided — a leaked dead row, not a + // wrongly-spent coin, and a re-emitted sweep cleans it up. + // + // Nothing below is load-bearing for correctness anymore: `row` + // has no outputs and reads as `isGloballySwept` as of the block + // above, in every callback that reaches this point, regardless of + // whether this delete ever fires. This is reclaiming the + // now-inert row's storage, not finishing the sweep. Detached + // tombstones deliberately do not count as claims here — they no + // longer need the row (the scalar reconciliation below never + // touches it), so holding the delete for them would leak the row + // for nothing. Nor do this wallet's released pending inputs: + // they were deleted outright above precisely so they cannot + // stalemate another wallet's callback. + let otherWalletStillClaims = row.inputs.contains { txo in + txo.spendingTransaction != nil && Self.resolvedWalletId(of: txo) != walletId + } || row.pendingInputs.contains { pending in + pending.spendingTransaction != nil && pending.walletId != walletId + } + if !otherWalletStillClaims { + backgroundContext.delete(row) + } + } + + // Chained-sweep continuation: a pending row an EARLIER sweep already + // tombstoned to this loser (itself a sweep's winner until now) is no + // longer reachable through `row.pendingInputs` — see the doc comment + // above. The caller found it by the scalar `spendingTxid` it carries + // instead (its once-per-batch scan), scoped to this wallet for the + // same reason the live pending inputs above were: the tombstone + // names one specific wallet's coin, and only that wallet's own + // released set is the right authority to re-decide it. + // + // Deliberately runs even with `row` nil. A tombstone's very + // existence means `resolveInputOutpoint` declined to re-attach a + // pending row when the winner's own record arrived (the duplicate + // guard matches on `(outpoint, spendingTxid)` and a tombstone + // occupies that key), so a wallet-relevant winner can carry no + // attached claim of this wallet's at all — and another wallet's + // callback, seeing nothing attached, legitimately deletes the shared + // row before this wallet's callback ever runs. The tombstones are + // this wallet's private state; the row's fate says nothing about + // whether they still need their release applied or their chain + // continued. + for pending in priorTombstones where !pending.isDeleted { + if released.contains(pending.outpoint) || coSwept.contains(pending.outpoint.prefix(32)) + { + backgroundContext.delete(pending) + } else { + // Re-pointed to the new winner; the stamp moves ONLY when + // this sweep has a block context. A block-context re-point + // re-stamps to the NEW winner's mined height — the claim + // now belongs to a spend anchored at that block, and its + // collection horizon moves with it. A mempool-context + // re-point (`winnerMinedHeight` nil) keeps the existing + // stamp untouched: upstream never retracts a block-context + // observed-spend entry for an unconfirmed conflict, and + // collection at the retained 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. + pending.spendingTxid = supersededBy + if let winnerMinedHeight { + pending.winnerMinedHeight = winnerMinedHeight + } + } } } + /// Sweep-phase transaction lookup: round-index first, store-only on a + /// miss, and the store hit is REGISTERED so the next lookup of the same + /// txid — a later batch of this round sweeping or chaining onto it — + /// returns the same object instead of re-fetching. That registration is + /// what makes the store-only miss path safe here: every transaction row + /// carrying staged state is already in the index (record upserts + /// register inserts and store hits, the drain registers + /// relationship-resolved winners, and this helper registers what it + /// fetches — covering `isGloballySwept` staged by an earlier batch), so + /// the refresh a store-only fetch performs can only land on a clean + /// row. The plain-fetch fallback with no active round keeps the old + /// behavior for unbracketed callers. + /// + /// This replaces a plain pending-changes fetch that paid an in-memory + /// predicate pass over every unsaved `PersistentTransaction` insert + /// once per swept txid — O(records × losers) in the folded rounds that + /// carry an initial scan's records and a large conflict sweep together, + /// all of it synchronous on the persistence queue before + /// `endChangeset`. + private func fetchSweepTransactionRow(txid: Data) throws -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = try backgroundContext.fetch(descriptor).first, !row.isDeleted else { + return nil + } + roundIndex?.transactionsByTxid[txid] = row + return row + } + /// Find or create the `PersistentWallet` row for `walletId`. /// Used only by `persistWalletMetadata`; every other write path /// fetches via `findWalletRecord` and drops on missing so that @@ -1065,10 +1903,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Find the `PersistentWallet` row for `walletId`. Returns `nil` /// when no row exists. private func findWalletRecord(walletId: Data) -> PersistentWallet? { + try? fetchWalletRecord(walletId: walletId) + } + + /// Throwing form of `findWalletRecord`, for callers that must tell a + /// successful "no such wallet" apart from a failed lookup — anything + /// carrying a subtractive change, where swallowing the failure would + /// report a removal durable that never happened. + private func fetchWalletRecord(walletId: Data) throws -> PersistentWallet? { let descriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) - return try? backgroundContext.fetch(descriptor).first + return try backgroundContext.fetch(descriptor).first } /// Predicate matching the `PersistentWallet` row owned by THIS @@ -1213,6 +2059,118 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Round-indexed lookups + // + // The helpers below are the only way the changeset hot path + // (`upsertTransaction`, `upsertUtxo`, `resolveInputOutpoint`, + // `markUtxoSpent`, `markUtxoInstantLocked`, `removePendingInputs`, + // `persistAccountAddresses`) resolves rows by key. Each one reads + // `roundIndex` first, and on a miss — only while the index is + // active — fetches with `includePendingChanges = false` so the store + // lookup stays on SQLite's indexes instead of scanning the round's + // pending inserts in memory (see `roundIndex`); a store hit is + // registered in the index so the same key never fetches twice in one + // round (the store-only refetch would refresh the object and discard + // the round's unsaved mutations — see `roundIndex`). A miss on both + // sources may re-fetch on a later call, which is safe: there is no + // registered object for the refresh to clobber. With no active index + // the helpers degrade to the plain default fetch. Predicates only + // name immutable key columns (`txid`, `outpoint`, `address` are + // fixed at insert), so matching on store values instead of in-memory + // values cannot miss an in-round mutation; mutable-column filters + // (`spendingTxid` on pending rows) stay in Swift at the call sites, + // on live values. `isDeleted` is filtered on both sources because a + // store-only fetch still returns rows whose delete is staged but + // unsaved. + // + // The sweep phase has its own fetch discipline. Loser rows resolve + // through `fetchSweepTransactionRow` — index-first, store-only on a + // miss, registering its hits so later batches reuse the object (see + // its doc for why the miss path cannot refresh staged state away). + // The per-batch tombstone scan and the by-outpoint release fetch stay + // on plain pending-changes fetches, ONCE per batch: they key on + // columns that MUTATE mid-round (`spendingTxid`, `isSweptTombstone`) + // or must see rows staged earlier in the round, which neither the + // index nor a store-only fetch can answer. The sweep pass also + // mutates TXO / pending rows through `row.inputs` / + // `row.pendingInputs` without any keyed lookup the index could + // observe — which is safe only because sweeps are applied LAST in + // `persistWalletChangeset`, so no store-only first-touch fetch can + // follow those mutations within the round and refresh them away. + + /// Resolve a `PersistentTransaction` by its unique `txid`. + private func fetchTransactionRow(txid: Data) -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.transactionsByTxid[txid] = row + return row + } + + /// Resolve a `PersistentTxo` by its unique 36-byte `outpoint`. + private func fetchTxoRow(outpoint: Data) -> PersistentTxo? { + if let known = roundIndex?.txosByOutpoint[outpoint] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.txosByOutpoint[outpoint] = row + return row + } + + /// Every live `PersistentPendingInput` row keyed on `outpoint` — + /// saved rows plus this round's staged inserts. Non-unique key, so + /// this returns the full set; callers filter further (by + /// `spendingTxid`, `createdAt`) on the live objects. Saved rows are + /// re-fetched store-only on every call rather than registered: no + /// path mutates a pending row's attributes before the sweep pass, + /// and sweeps run last (see the MARK comment), so the refetch + /// refresh never has unsaved changes to discard — deletions, the + /// one staged state these rows do accumulate mid-round, survive it. + /// De-duped by object identity as insurance against a save landing + /// mid-round (which would make a staged row visible to the store + /// fetch too). + private func pendingInputRows(outpoint: Data) -> [PersistentPendingInput] { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + if roundIndex != nil { descriptor.includePendingChanges = false } + var rows = (try? backgroundContext.fetch(descriptor)) ?? [] + if let staged = roundIndex?.pendingInputsByOutpoint[outpoint] { + let seen = Set(rows.map { ObjectIdentifier($0) }) + rows.append(contentsOf: staged.filter { !seen.contains(ObjectIdentifier($0)) }) + } + return rows.filter { !$0.isDeleted } + } + + /// Resolve a `PersistentCoreAddress` by its unique `address`. + private func coreAddressRow(address: String) -> PersistentCoreAddress? { + if let known = roundIndex?.coreAddressesByAddress[address] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.coreAddressesByAddress[address] = row + return row + } + private func upsertTransaction(account: PersistentAccount, tx: TransactionRecordFFI) { // The `account` parameter scopes the wallet-id used for the // input-reconciliation pass at the bottom of this method, and @@ -1234,9 +2192,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // let resolvedWalletId: Data = account.wallet.walletId let txidData = hashData(tx.txid) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) // The FFI projection always serializes the transaction body // (`dashcore::consensus::encode::serialize` upstream), so @@ -1258,8 +2213,43 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) + let existing = fetchTransactionRow(txid: txidData) + // A sweep is upstream's word at the moment it fired, but the + // wallet's sweep state is not monotonic: `CoreChangeSet::merge` + // documents the exact reachable sequence — an unconfirmed + // transaction swept by an IS-locked conflict can return + // chainlocked and sweep that conflict in turn, per key-wallet's + // own IS-lock precedence rules. When both events land in the same + // changeset the merge already strips the sweep before it gets + // here. Across separate rounds it can't: the earlier sweep is + // already durable (row tombstoned, possibly still physically + // present because another wallet's claim held the delete back — + // see `applySweptTransaction`), and this later record is the only + // signal this callback ever sees that the wallet reversed itself. + // Upstream never re-emits a live record for a txid it still + // considers dead, so a record naming an `isGloballySwept` txid is + // authoritative reinstatement, not a stale replay — treat it as + // upstream's newer word and let it win: clear the tombstone and + // fall through to the ordinary upsert below. + // + // What this does and does not restore: `context`/`blockHeight`, + // `involvedAccounts` membership, and this record's own input + // reconciliation all rebuild normally from here since they're + // driven straight off `tx` and `account`. The outputs + // `applySweptTransaction` physically deleted are a different + // story — they come back only if this round (or the one + // `upsertUtxo` processes moments later, before any other sweep + // callback can re-tombstone this row) also carries fresh + // `utxos_added` entries for them, the same way any transaction's + // outputs ordinarily arrive alongside its record. That is not + // this method's call to make: if Rust doesn't re-emit them, they + // cannot be reconstructed here from nothing. + if let existing, existing.isGloballySwept { + existing.isGloballySwept = false + } + let record: PersistentTransaction - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing { record = existing } else { record = PersistentTransaction( @@ -1273,6 +2263,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + roundIndex?.transactionsByTxid[txidData] = record } record.context = tx.context @@ -1376,6 +2367,44 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { tx.context >= TransactionContextType.inBlock.rawValue } + /// Whether a TXO's existing spender link must survive an arriving + /// record that also claims the outpoint. The link is this store's spend + /// attribution, and the sweep release pass trusts it: the loser walk + /// detaches rows by their spender and the by-outpoint release frees + /// only detached rows (`spendingTransaction == nil`). A network-final + /// spender's link must therefore never be stolen by a later conflicting + /// record — upstream prunes a chainlocked spender to a bare txid (and + /// after a restart holds no history at all), so a loser reusing that + /// coin arrives with upstream unable to see the settled claim, and its + /// own eventual sweep names the coin released. With the link intact the + /// release is refused; with it stolen, the provably consumed coin reads + /// unspent after the next restart — a guaranteed double spend. + /// + /// Kept when the existing spender has not been globally swept (a swept + /// spender's claims were resolved by its own sweep) and is + /// network-final: IS-locked, in-block, or chainlocked. Two mempool + /// spenders keep last-writer-wins, as before. The single sanctioned + /// takeover mirrors DIP-10 precedence: a chainlocked arrival may take + /// the coin from a spender that was only IS-locked — a plain in-block + /// arrival may not, exactly as upstream's sweep gate refuses a plain + /// block against a signed lock. A re-emit of the same spender is never + /// a takeover. + private static func settledSpenderLinkIsKept( + existing: PersistentTransaction?, + newTxid: Data, + newContext: UInt32 + ) -> Bool { + guard let existing, existing.txid != newTxid else { return false } + guard !existing.isGloballySwept else { return false } + guard existing.context >= TransactionContextType.instantSend.rawValue else { + return false + } + let chainlockOverIsLock = + newContext >= TransactionContextType.inChainLockedBlock.rawValue + && existing.context == TransactionContextType.instantSend.rawValue + return !chainlockOverIsLock + } + /// Mark the `PersistentTxo` whose 36-byte `outpoint` matches the /// given input as spent and link it to `spendingTransaction`. /// If no matching TXO exists yet (in-Swift out-of-order, or @@ -1389,26 +2418,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spendingTxid: Data, walletId: Data ) { - let txoDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(txoDescriptor).first { - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. + if let txo = fetchTxoRow(outpoint: outpoint) { + // `reconcileSpendObservation` is the single spend verdict — + // flag and link move together under its finality rule. One + // sweep-specific term rides on top of it: a TXO the sweep is + // holding (`supersededByTxid` set) was proved consumed by a + // winner this record knows nothing about, so the verdict may + // never downgrade it back into the restore set. The sharp case + // is the winner's own record arriving IS-locked — a context + // below in-block — for a coin the sweep already settled. let verdict = Self.reconcileSpendObservation( currentSpenderTxid: txo.spendingTransaction?.txid, currentIsSpent: txo.isSpent, incoming: spendingTransaction, incomingTxid: spendingTxid ) + let resolvedIsSpent = verdict.isSpent || txo.supersededByTxid != nil let linkageChanged = - txo.isSpent != verdict.isSpent + txo.isSpent != resolvedIsSpent || (verdict.adoptLink && txo.spendingTransaction?.txid != spendingTxid) || (verdict.adoptLink && txo.spendingInputIndex != inputIndex) if linkageChanged { - txo.isSpent = verdict.isSpent - if verdict.adoptLink { - if txo.spendingTransaction?.txid != spendingTxid { + txo.isSpent = resolvedIsSpent + if verdict.adoptLink { if txo.spendingTransaction?.txid != spendingTxid { txo.spendingTransaction = spendingTransaction } // Capture the canonical vin index so the detail @@ -1431,11 +2463,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // (outpoint, spending-tx) pair already exists — re-upserts // of the same transaction would otherwise produce // duplicate pending rows that all resolve to the same - // TXO, wasting fetch work on the resolve side. - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint && $0.spendingTxid == spendingTxid } - ) - if (try? backgroundContext.fetch(pendingDescriptor).first) == nil { + // TXO, wasting fetch work on the resolve side. The + // `spendingTxid` half of the pair is compared in Swift on + // the live rows (it is mutable — `applySweptTransaction` + // rewrites it on tombstones — so it can't be a store-side + // predicate under the round index's store-only fetch). + let alreadyPending = pendingInputRows(outpoint: outpoint) + .contains { $0.spendingTxid == spendingTxid } + if !alreadyPending { let pending = PersistentPendingInput( outpoint: outpoint, inputIndex: inputIndex, @@ -1444,6 +2479,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(pending) } } } @@ -1454,13 +2490,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `upsertUtxo`'s resolve path so a freshly-arrived TXO doesn't /// keep its corresponding pending row alive. private func removePendingInputs(for outpoint: Data) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { - return - } - for row in rows { + // Deletes are not unregistered from `roundIndex` — the stale + // entry answers `isDeleted == true` and every lookup filters on + // that (see the index's doc). + for row in pendingInputRows(outpoint: outpoint) { backgroundContext.delete(row) } } @@ -1473,11 +2506,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let txidData = hashData(utxo.outpoint.txid) let outpoint = PersistentTxo.makeOutpoint(txid: txidData, vout: utxo.outpoint.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) let record: PersistentTxo - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = fetchTxoRow(outpoint: outpoint) { record = existing // Backfill if the account or wallet linkage is missing — // the per-wallet query path filters on TXO.walletId, so @@ -1496,11 +2526,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // arrives. Note we no longer set `parentTx.account` — // transactions don't carry account linkage anymore (they // can span multiple accounts). - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) let parentTx: PersistentTransaction - if let existingTx = try? backgroundContext.fetch(txDescriptor).first { + if let existingTx = fetchTransactionRow(txid: txidData) { + // A globally-swept parent is a transaction Rust has already + // proven can never confirm — a fresh UTXO entry naming its + // txid would (re-)create exactly the phantom output + // `applySweptTransaction` deletes on every callback that + // observes the sweep. Bail rather than attach a new + // `PersistentTxo` to a row still excluded from restoration. + // + // This does not fight `upsertTransaction`'s reinstatement + // path — it relies on it running first. `applyAccountChangeset` + // processes an account's `tx.transactions` before its + // `utxos_added`, so a reinstating record for this same txid + // in this same round has already cleared the tombstone by + // the time this guard reads it here; only a UTXO entry with + // no accompanying record this round (or in a stray one that + // arrives out of order relative to it) still finds the flag + // set. That is genuinely a stale/out-of-order signal — Rust + // does not otherwise re-emit a swept loser's own outputs — + // and staying defensive here is correct: there is no record + // in flight to attribute a resurrected output to. + guard !existingTx.isGloballySwept else { return } parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -1512,6 +2559,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // treats as miss. parentTx = PersistentTransaction(txid: txidData, transactionData: Data()) backgroundContext.insert(parentTx) + roundIndex?.transactionsByTxid[txidData] = parentTx } let script: Data = { @@ -1530,6 +2578,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record } record.amount = utxo.amount @@ -1540,6 +2589,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.isLocked = utxo.is_locked record.lastUpdated = Date() + // The wallet is handing this outpoint over as a UTXO, so it holds it + // unspent — authoritative, and the only thing that can lift a mark + // with neither a spender nor a winner behind it (a pre-stamp row + // from before `applySweptTransaction` named its winner; every hold + // written today is stamped). A row whose spend is still on record + // is left alone: the pending-input resolve below owns that + // transition. So is a `supersededByTxid` hold: the winner that + // consumed this coin is known even though its row never + // materialized here, and a re-delivery cannot outrank that verdict + // — a restore-rescan re-finds the funding output precisely because + // it is blind to an unconfirmed winner no block carries yet. Only + // an explicit release frees a stamped coin. + if record.isSpent, record.spendingTransaction == nil, record.supersededByTxid == nil { + record.isSpent = false + } + // Attach the `PersistentCoreAddress` row, if we have one. The // address-emit pass typically runs ahead of the SPV-utxo pass // within a flush, so the row should exist; if it doesn't (TXO @@ -1547,11 +2612,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // leave the relationship nil — `record.address` stays as the // authoritative identifier. if record.coreAddress == nil, !record.address.isEmpty { - let addressLookup = record.address - let coreAddressDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == addressLookup } - ) - if let coreAddr = try? backgroundContext.fetch(coreAddressDescriptor).first { + if let coreAddr = coreAddressRow(address: record.address) { record.coreAddress = coreAddr } } @@ -1565,62 +2626,82 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `upsertTransaction`, so the spend signal is order- // independent at this layer regardless of which side arrives // first. - let outpointKey = record.outpoint - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpointKey } - ) - if let pendingRows = try? backgroundContext.fetch(pendingDescriptor), - !pendingRows.isEmpty { - // Reconcile EVERY deferred observation, not just the newest — - // the rows are about to be deleted, and picking one would let - // a mempool competitor recorded after a confirmed spender - // erase that confirmed evidence with the rows. Applying the - // finality-aware rule per row makes the order irrelevant by - // construction: confirmed evidence wins and is never - // displaced by a mempool observation, so the oldest-first - // pass below converges to the same state any order would. - var adoptedAny = false - for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { - // Resolve the spending tx (prefer the relationship; fall - // back to a txid lookup if the row wasn't faulted in). - let resolvedSpending: PersistentTransaction? - if let spending = pending.spendingTransaction { - resolvedSpending = spending - } else { - let spendingTxid = pending.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first + let pendingRows = pendingInputRows(outpoint: record.outpoint) + if !pendingRows.isEmpty { + // A tombstone outranks every ordinary row regardless of age. + // The per-row reconciliation below arbitrates between competing + // *observations*; a tombstone is not an observation — it is the + // sweep's settled verdict that its winner consumed this coin. + // The two coexist in exactly one way: records precede sweeps + // within a round, so the winner's own record can stage an + // ordinary pending row moments before the sweep repoints the + // loser's row, which keeps its original, older `createdAt`. + // Letting an observation win there would leave `isSpent` gated + // on the winner confirming, never stamp `supersededByTxid`, and + // then delete every row including the tombstone — the durable + // hold evaporates and the consumed coin re-enters the restore + // set. + if let tombstone = pendingRows.filter(\.isSweptTombstone) + .max(by: { $0.createdAt < $1.createdAt }) + { + record.spendingInputIndex = tombstone.inputIndex + if let spending = resolvePendingSpender(tombstone), + record.spendingTransaction?.txid != spending.txid + { + record.spendingTransaction = spending } - guard let spending = resolvedSpending else { continue } - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: record.spendingTransaction?.txid, - currentIsSpent: record.isSpent, - incoming: spending, - incomingTxid: spending.txid - ) - record.isSpent = verdict.isSpent - if verdict.adoptLink { - if record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending + // A sweep's winner is already final — there is no mempool + // state to wait out — so `isSpent` does not gate on + // resolving the spender the way an ordinary pending spend + // does; that lookup only succeeds when the winner happens to + // have its own materialized row, which is not guaranteed. + // `supersededByTxid` is what makes the mark durable either + // way, and it is what the recovery clear above checks so + // this coin is not handed back as spendable on a later sync. + record.isSpent = true + record.supersededByTxid = tombstone.spendingTxid + } else { + // Reconcile EVERY deferred observation, not just the newest — + // the rows are about to be deleted, and picking one would let + // a mempool competitor recorded after a confirmed spender + // erase that confirmed evidence with the rows. Applying the + // finality-aware rule per row makes the order irrelevant by + // construction: confirmed evidence wins and is never + // displaced by a mempool observation, so the oldest-first + // pass converges to the same state any order would. + var adoptedAny = false + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { + guard let spending = resolvePendingSpender(pending) else { continue } + // Flag and link move together — see + // `reconcileSpendObservation` for the finality rule. + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: record.spendingTransaction?.txid, + currentIsSpent: record.isSpent, + incoming: spending, + incomingTxid: spending.txid + ) + // A stamped hold is the sweep's settled verdict and + // outranks any observation, exactly as in + // `resolveInputOutpoint`. + record.isSpent = verdict.isSpent || record.supersededByTxid != nil + if verdict.adoptLink { + if record.spendingTransaction?.txid != spending.txid { + record.spendingTransaction = spending + } + // The vin index rides with the adopted claim so the + // spending tx's detail view renders inputs in the + // canonical serialized order. + record.spendingInputIndex = pending.inputIndex + adoptedAny = true } - // The vin index rides with the adopted claim so the - // spending tx's detail view renders inputs in the - // canonical serialized order. - record.spendingInputIndex = pending.inputIndex - adoptedAny = true } - } - if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { - // No row resolved a spending tx this flush: carry the - // newest claim's vin index forward the way the old - // single-row path did; the linkage itself catches up on - // the next flush that carries the spending tx. - record.spendingInputIndex = newest.inputIndex - } + if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + // No row resolved a spending tx this flush: carry the + // newest claim's vin index forward the way the old + // single-row path did; the linkage itself catches up on + // the next flush that carries the spending tx. + record.spendingInputIndex = newest.inputIndex + } } record.lastUpdated = Date() for row in pendingRows { backgroundContext.delete(row) @@ -1628,6 +2709,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Resolve a pending row's spending transaction — the relationship when + /// it is faulted in, otherwise a txid lookup through the round index. + private func resolvePendingSpender(_ pending: PersistentPendingInput) -> PersistentTransaction? { + if let spending = pending.spendingTransaction { + // Resolved through the relationship, not the index — register it + // so a later `fetchTransactionRow` for this txid returns this + // same object instead of running a first-touch store fetch that + // would refresh away any staged writes it carries (see + // `roundIndex`). + roundIndex?.transactionsByTxid[spending.txid] = spending + return spending + } + return fetchTransactionRow(txid: pending.spendingTxid) + } + /// The one rule every spend-linkage writer follows, so `isSpent` and /// `spendingTransaction` move as a single finality-aware state instead /// of a monotonic flag beside a last-writer-wins link (which could @@ -1658,6 +2754,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (adoptLink: true, isSpent: true) } if currentIsSpent { + // Refusing the link protects EXISTING confirmed evidence. With + // no spender linked there is none to protect: the flag is true + // because a sweep hold says the coin was consumed + // (`supersededByTxid`), and the arriving record is typically the + // very winner that hold names — the one transaction that can + // supply the attribution the hold could not. Adopt the link and + // keep the flag; a linked settled spender is still never + // displaced by a mempool competitor, which is the case the rule + // was written for. + if currentSpenderTxid == nil { + return (adoptLink: true, isSpent: true) + } return (adoptLink: false, isSpent: true) } return (adoptLink: true, isSpent: false) @@ -1668,10 +2776,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txid: hashData(entry.outpoint.txid), vout: entry.outpoint.vout ) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let txo = try? backgroundContext.fetch(descriptor).first else { + guard let txo = fetchTxoRow(outpoint: outpoint) else { return } // Link the spending transaction. The FFI now carries @@ -1689,10 +2794,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if txo.spendingTransaction?.txid == spendingTxid { spendingTx = txo.spendingTransaction } else { - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - spendingTx = try? backgroundContext.fetch(txDescriptor).first + spendingTx = fetchTransactionRow(txid: spendingTxid) } } // When the spending tx isn't resolved this flush, leave the row @@ -1702,18 +2804,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `isSpent` on every reordered emit. if let spending = spendingTx { // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. + // `reconcileSpendObservation` for the finality rule. A stamped + // hold outranks the verdict: this emit can carry the sweep + // winner's own IS-locked spend of a coin the sweep already + // proved consumed, and answering from the verdict alone would + // flip the durable hold back into the restore set until the + // winner reaches a block. let verdict = Self.reconcileSpendObservation( currentSpenderTxid: txo.spendingTransaction?.txid, currentIsSpent: txo.isSpent, incoming: spending, incomingTxid: spendingTxid ) - txo.isSpent = verdict.isSpent + txo.isSpent = verdict.isSpent || txo.supersededByTxid != nil if verdict.adoptLink, txo.spendingTransaction?.txid != spendingTxid { txo.spendingTransaction = spending - } - } + } } txo.lastUpdated = Date() // The spend signal landed both via the legacy // `utxos_spent` slice (this path) and — assuming the @@ -1728,10 +2834,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func markUtxoInstantLocked(_ op: OutPointFFI) { let outpoint = PersistentTxo.makeOutpoint(txid: hashData(op.txid), vout: op.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(descriptor).first { + if let txo = fetchTxoRow(outpoint: outpoint) { txo.isInstantLocked = true txo.lastUpdated = Date() } @@ -1763,6 +2866,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + | PlatformWalletPersistenceCapabilities.dashpayPayments ) } @@ -1778,6 +2883,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_tracked_masternodes_fn = persistTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_fn = loadTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_free_fn = loadTrackedMasternodesFreeCallback + // Sweeps negotiate through this size-tagged structure rather than + // riding `WalletChangeSetFFI` because that struct crosses by bare + // pointer: `struct_size` above is what proves to an older native + // library that this slot exists, and proves to this build that an + // older library will simply never call it — rather than either side + // reading memory the other never allocated. + extensionCallbacks.on_persist_wallet_changeset_sweeps_fn = + persistWalletChangesetSweepsCallback + // The numeric chainlock height rides its own slot for the same + // reason: the bincode chainlock bytes on `WalletChangeSetFFI` are + // opaque to this side, and the tombstone-collection finality + // boundary `min(chainlockHeight, syncedHeight)` needs the number. + extensionCallbacks.on_persist_wallet_changeset_chain_lock_height_fn = + persistWalletChangesetChainLockHeightCallback return extensionCallbacks } @@ -1853,10 +2972,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `persistAccountChangeset`, …) fires between begin and end and /// only mutates `backgroundContext`; `save()` happens at the end. /// - /// Currently a no-op beyond the tag — `ModelContext`'s pending- - /// change buffer already gives us the batching we need. Kept as - /// a named hook so future work (explicit transaction scoping, - /// instrumented timing, etc.) has an obvious seam. + /// Beyond the tag, this builds the round's insert index (see + /// `roundIndex`) — `ModelContext`'s pending-change buffer already + /// gives us the batching we need. func beginChangeset(walletId: Data) { onQueue { self.inChangeset = true @@ -1865,7 +2983,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { category: .persistence, fields: ["wallet_reference": .reference(walletId)] ) - } + // The index's O(1) lookups are only equivalent to the plain + // pending-changes fetch when the index and the store + // partition the rows between them: index = this round's + // inserts, store = everything saved. A context that is + // already dirty here (an out-of-round writer whose `save()` + // threw and left its staged rows behind) breaks that + // partition — such a row is in neither source — so the + // round runs unindexed and the lookup helpers fall back to + // the exact pre-index fetch, pending changes included. + self.roundIndex = backgroundContext.hasChanges ? nil : ChangesetRoundIndex() } } /// Closes a persistence round. Commits all per-kind writes @@ -1890,8 +3017,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Clear the flag before draining deferred backfills so each one's // save() lands cleanly outside the round; `drainDeferredBackfills` // is guarded on `!inChangeset`, so the ordering inside this `defer` - // (clear, then drain) is load-bearing. + // (clear, then drain) is load-bearing. The round index dies here + // on both paths — after the commit its entries are ordinary saved + // rows the store fetch finds on its own, and after a rollback the + // context has un-inserted every one of them. defer { + self.roundIndex = nil self.inChangeset = false self.drainDeferredBackfills() } @@ -3138,7 +4269,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // No save here even outside a round: the Rust store() round // that invoked this callback brackets it with begin/end, so // `inChangeset` is set in practice; if a host ever fires it - // without a bracket, autosave/next round flushes the stage. + // without a bracket, the next round's own save flushes the + // stage (autosave is disabled on this context — see init). } } @@ -3575,12 +4707,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { for entry in entries { let address = entry.address - let existingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == address } - ) - let existing = try? backgroundContext.fetch(existingDescriptor).first let row: PersistentCoreAddress - if let existing = existing { + if let existing = coreAddressRow(address: address) { row = existing } else { row = PersistentCoreAddress( @@ -3594,6 +4722,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { balance: entry.balance ) backgroundContext.insert(row) + roundIndex?.coreAddressesByAddress[address] = row } // Mutation path for both insert + update. row.publicKey = entry.publicKey @@ -3616,12 +4745,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // address row now exists. Avoid the SwiftData // optional-relationship-in-predicate gotcha by // filtering nil-coreAddress in Swift after the fetch. + // + // Deliberately NOT a round-indexed store-only lookup: this + // joins TXOs by `address`, and the rows it returns are the + // same objects the outpoint-keyed hot path mutates — a + // store-only fetch here would refresh those objects and + // discard the round's unsaved writes (see `roundIndex`). + // The pending-changes scan this keeps is bounded by the + // round's TXO inserts per emitted address entry; the + // outpoint-keyed quadratic hot path stays indexed. let txoBackfillDescriptor = FetchDescriptor( predicate: #Predicate { $0.address == address } ) if let txosAtAddress = try? backgroundContext.fetch(txoBackfillDescriptor) { for txo in txosAtAddress where txo.coreAddress == nil { txo.coreAddress = row + // This write happened outside any keyed lookup, so + // register the row: a later first-touch + // `fetchTxoRow` for this outpoint would otherwise + // run a store-only fetch and refresh the link away + // (see `roundIndex`). + roundIndex?.txosByOutpoint[txo.outpoint] = txo } } } @@ -6167,6 +7311,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func recordEntry( for txRow: PersistentTransaction, accountIndex: UInt32 ) -> UnresolvedAssetLockTxRecordFFI? { + // A globally-swept transaction lost a double-spend on one of + // its own inputs and can never confirm. Restoring it would put + // a dead funding tx back in the account's live history — or, + // through the spender pass below, hand the double-spend screen + // a swept loser as the settled spender of a lock's input, which + // is the one verdict that must never come from a transaction + // the wallet has already removed. + guard !txRow.isGloballySwept else { return nil } let txBytes = txRow.transactionData guard !txBytes.isEmpty else { return nil } let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) @@ -6272,9 +7424,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) -> (UnsafeMutablePointer?, Int) { // Provider special-tx kinds are the contiguous discriminant range // 2...5 (ProviderRegistration=2 … ProviderUpdateRevocation=5). + // `!isGloballySwept` excludes a provider tx that itself lost a + // double-spend on one of its inputs — an edge case (most losers are + // ordinary spends), but a swept row is never restorable regardless + // of kind. let descriptor = FetchDescriptor( predicate: #Predicate { tx in tx.transactionTypeKind >= 2 && tx.transactionTypeKind <= 5 + && tx.isGloballySwept == false } ) guard let providerTxs = try? backgroundContext.fetch(descriptor), @@ -6804,8 +7961,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func persistTrackedMasternodes(networkRaw: UInt32, rows: [TrackedMasternodeRow]) -> Bool { onQueue { do { - let existing = try trackedMasternodeContext.fetch( - FetchDescriptor( + let existing = try trackedMasternodeContext.fetch( FetchDescriptor( predicate: #Predicate { $0.networkRaw == networkRaw } ) ) @@ -6819,8 +7975,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { found.addedAt = row.addedAt found.snapshotJSON = row.snapshotJSON } else { - trackedMasternodeContext.insert(PersistentTrackedMasternode( - networkRaw: networkRaw, + trackedMasternodeContext.insert(PersistentTrackedMasternode( networkRaw: networkRaw, proTxHash: row.proTxHash, label: row.label, addedAt: row.addedAt, @@ -7038,6 +8193,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard let row = try? backgroundContext.fetch(descriptor).first else { return nil } + // A globally-swept row can still physically exist (another + // wallet's claim may not have cleared yet), but Rust has already + // proven it dead — treat it the same as "no such transaction" + // rather than handing back a body sent-payment reconciliation or + // the asset-lock proof flow would read as live. + guard !row.isGloballySwept else { + return nil + } // The Rust side decodes `transactionData` into a // `dashcore::Transaction`; an empty buffer (left over // from an orphaned stub row in the UTXO upsert path @@ -7555,8 +8718,64 @@ private func persistWalletChangesetCallback( .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) - handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) - return 0 + // Non-zero fails the round: `endChangeset(success: false)` rolls the + // staged writes back and Rust keeps its in-memory state instead of + // treating a partly-applied changeset as durable. + return handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) ? 0 : 1 +} + +/// C shim for the extension's `on_persist_wallet_changeset_sweeps_fn` — +/// the round's sweep batches, fired right after the changeset callback +/// above within the same begin/end bracket. Same non-zero-fails-the-round +/// contract: a removal Rust believes durable but that never landed would +/// replay the dead row at the next load. +private func persistWalletChangesetSweepsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + sweepsPtr: UnsafePointer?, + sweepsCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: sweepsPtr, + count: sweepsCount + ) ? 0 : 1 +} + +/// C shim for the extension's +/// `on_persist_wallet_changeset_chain_lock_height_fn` — the round's +/// NUMERIC chainlock height, fired inside the same begin/end bracket +/// after the changeset callback whenever the round advanced the chainlock +/// watermark. Same non-zero-fails-the-round contract as its siblings. +private func persistWalletChangesetChainLockHeightCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + chainLockHeight: UInt32 +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) ? 0 : 1 } /// C shim for `on_changeset_begin_fn`. Forwards to diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1e28edf34d8..793da6fa30e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -24,7 +24,11 @@ final class DashModelMigrationTests: XCTestCase { var v1Container: ModelContainer? = try ModelContainer( for: v1Schema, configurations: [v1Configuration]) - v1Container?.mainContext.insert(PersistentKeyword( + // V1 registers the FROZEN component (see `DashSchemaFrozenModels`), + // so a row written into a V1 container is that type — inserting the + // live one would materialise as the frozen entity and then fail its + // cast on read. + v1Container?.mainContext.insert(DashSchemaV1.PersistentKeyword( keyword: "preserved", contractId: "contract")) try v1Container?.mainContext.save() @@ -42,7 +46,9 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v2Configuration]) - let keywords = try migrated.mainContext.fetch(FetchDescriptor()) + // V2 registers the same frozen copy, so the read side is frozen too. + let keywords = try migrated.mainContext.fetch( + FetchDescriptor()) XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) migrated.mainContext.insert(PersistentTrackedMasternode( @@ -58,6 +64,59 @@ final class DashModelMigrationTests: XCTestCase { 1) } + /// The stage this change adds: a V3 store must migrate to V4 and read + /// back with the sweep columns backfilled to their "nothing swept yet" + /// values. V3 registers the frozen component, so the row goes in as the + /// frozen type and comes out as the live one — which is the whole point + /// of the freeze: the same entity, one property wider. + @MainActor + func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let walletId = Data(repeating: 0x5A, count: 32) + + let v3Schema = Schema(versionedSchema: DashSchemaV3.self) + let v3Configuration = ModelConfiguration( + "DashSweepMigrationTest", + schema: v3Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v3Container: ModelContainer? = try ModelContainer( + for: v3Schema, + configurations: [v3Configuration]) + v3Container?.mainContext.insert(DashSchemaV1.PersistentWallet( + walletId: walletId, + network: .testnet)) + try v3Container?.mainContext.save() + v3Container = nil + + let v4Schema = Schema(versionedSchema: DashSchemaV4.self) + let v4Configuration = ModelConfiguration( + "DashSweepMigrationTest", + schema: v4Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v4Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v4Configuration]) + + let wallets = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") + XCTAssertNil( + wallets.first?.lastAppliedChainLockHeight, + "a wallet migrated from V3 has no chainlock boundary yet, so no " + + "tombstone it later takes can be collected on a fabricated one") + } + /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only /// keeps V1/V2 stores openable if SwiftData names its entity /// "PersistentAssetLock" — i.e. from the UNQUALIFIED type name. If a diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 478c114d0ca..4fd3c52c85c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -61,6 +61,12 @@ final class InvitationPersistenceTests: XCTestCase { // the persist/load/free trio onto `PersistentTrackedMasternode`, // so restart survival is genuinely attested. | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + // DashPay payment rows: the handler wires + // `on_persist_dashpay_payments_fn` and lands the overlay on + // `PersistentDashpayPayment` rows, so the sweep's Failed flip + // may ride this store's rounds — genuinely attested. + | PlatformWalletPersistenceCapabilities.dashpayPayments XCTAssertEqual( capabilities.version, @@ -83,6 +89,9 @@ final class InvitationPersistenceTests: XCTestCase { XCTAssertFalse(diagnostic.contains( PlatformWalletPersistenceCapabilities.pendingContactCrypto )) + XCTAssertTrue(diagnostic.contains( + PlatformWalletPersistenceCapabilities.coreSweepRemoval + )) } /// Create inserts one row (fields mapped, `walletId` set), a re-upsert of the @@ -95,7 +104,11 @@ final class InvitationPersistenceTests: XCTestCase { // 1. Create. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) var rows = try fetchRows(container) @@ -110,7 +123,11 @@ final class InvitationPersistenceTests: XCTestCase { // 2. Status change → upsert in place, no duplicate row. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) rows = try fetchRows(container) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift new file mode 100644 index 00000000000..8993144e0bc --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -0,0 +1,2815 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the one subtractive part of the changeset path: the sweep +/// batches delivered through the persistence extension's +/// `on_persist_wallet_changeset_sweeps_fn` alongside each round's +/// `WalletChangeSetFFI`. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm and Rust has +/// already dropped it. Everything else the round carries is additive, so a +/// mirror that ignores the sweeps keeps the dead row, hands it back at the +/// next load, and re-creates a balance the wallet has already corrected — +/// the bug the upstream sweep exists to fix, one layer up. +/// +/// The fixtures model the shape that makes the coins tricky: an unconfirmed +/// loser — upstream sweeps nothing else — spends A and B, and the winner +/// takes only A. Because the loser never reached a block, this store never +/// flipped `isSpent` on either coin, so both are one deleted row away from +/// re-entering the restore set, and only the released set upstream carries +/// says which of them belongs there. +@MainActor +final class SweptTransactionPersistTests: XCTestCase { + + private let walletId = Data(repeating: 0x01, count: 32) + private let fundingTxid = Data(repeating: 0x41, count: 32) + private let sweptTxid = Data(repeating: 0x42, count: 32) + private let winnerTxid = Data(repeating: 0x44, count: 32) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// File-backed variant of `makeHandler()` — an in-memory store can't + /// outlive its own `ModelContainer`, so simulating a restart (a fresh + /// load/persister over the same on-disk store) needs a real file two + /// separate containers can both point at. + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// Seed the shape a confirmed spend leaves behind: a funding transaction + /// with two outputs, a spending transaction that claimed both (linked + /// and flagged spent), and the change that spend created. + /// + /// `winnerTakesA` models a wallet-relevant winner that already + /// re-pointed A at itself, which is what the additive half of the round + /// does before the sweep runs. + private func seedSpend(in container: ModelContainer, winnerTakesA: Bool) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + // Mempool context: the only kind of record upstream sweeps. + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(funding) + context.insert(swept) + + let winner: PersistentTransaction? + if winnerTakesA { + let row = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 2, + blockHeight: 102, + netAmount: -100_000 + ) + context.insert(row) + winner = row + } else { + winner = nil + } + + // A — the coin the winner also takes. When the winner is + // wallet-relevant its confirmed record owns the link and the flag; + // otherwise A is left where the unconfirmed loser put it, linked and + // unspent, which is what makes it indistinguishable from B. + let coinA = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + coinA.walletId = walletId + coinA.isSpent = winner != nil + coinA.spendingTransaction = winner ?? swept + context.insert(coinA) + + // B — named only by the loser, and so still unspent. + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.spendingTransaction = swept + context.insert(coinB) + + let change = PersistentTxo( + transaction: swept, + vout: 0, + amount: 60_000, + address: "yChangeAddr", + height: 0 + ) + change.walletId = walletId + context.insert(change) + + try context.save() + } + + /// Drive one changeset round of sweeps through the same entry point the + /// Rust persister calls. + /// One sweep batch: the transactions it removed, the winner it is + /// attributed to, the winner's finality context, and the coins it + /// freed. + private struct Batch { + var losers: [Data] + var winner: Data + /// The winner's own mined block height — `SweepBatchFFI`'s + /// `has_winner_mined_height`/`winner_mined_height` pair. Non-nil + /// models a block-context sweep (the winner is mined, tombstones + /// are written and stamped with this height); `nil` models a + /// mempool-context sweep (the winner is IS-locked and not yet + /// mined, and no tombstone may be created). Deliberately + /// undefaulted so every test states which world it is in. + var winnerMinedHeight: UInt32? + var released: [(txid: Data, vout: UInt32)] = [] + } + + /// Drive a changeset of sweep batches through the same entry point the + /// Rust persister calls, preserving their order. + /// + /// The nested buffers are allocated explicitly and freed after the call. + /// `withUnsafeMutableBufferPointer` only guarantees its pointer for the + /// duration of its own closure, so storing `baseAddress` in a struct the + /// FFI reads later would hand the consumer a dangling pointer. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch] + ) -> Bool { + sweep(handler, batches, walletId: walletId) + } + + /// `walletId`-parameterized form for the multi-wallet tests below, + /// where the same shared loser row needs a separate callback per wallet + /// — each carrying that wallet's own `released` set, the way two real + /// `persistWalletChangeset` calls would. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch], + walletId: Data + ) -> Bool { + typealias RawTxid = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + + var txidBuffers: [UnsafeMutablePointer] = [] + var releasedBuffers: [UnsafeMutablePointer] = [] + var ffiBatches: [SweepBatchFFI] = [] + defer { + for (i, buf) in txidBuffers.enumerated() { + buf.deinitialize(count: batches[i].losers.count) + buf.deallocate() + } + for (i, buf) in releasedBuffers.enumerated() { + buf.deinitialize(count: batches[i].released.count) + buf.deallocate() + } + } + + for batch in batches { + let txids = UnsafeMutablePointer.allocate(capacity: max(batch.losers.count, 1)) + for (i, loser) in batch.losers.enumerated() { + var tuple: RawTxid = (0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0) + Swift.withUnsafeMutableBytes(of: &tuple) { dst in + loser.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + txids.advanced(by: i).initialize(to: tuple) + } + txidBuffers.append(txids) + + let freed = UnsafeMutablePointer.allocate( + capacity: max(batch.released.count, 1) + ) + for (i, outpoint) in batch.released.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + freed.advanced(by: i).initialize(to: entry) + } + releasedBuffers.append(freed) + + var entry = SweepBatchFFI() + entry.txids = UnsafePointer(txids) + entry.txids_count = UInt(batch.losers.count) + entry.released_outpoints = UnsafePointer(freed) + entry.released_outpoints_count = UInt(batch.released.count) + Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in + batch.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + // The winner's finality context: `has_winner_mined_height` + // false is the mempool path (IS-locked, unmined winner — + // no tombstone may be created), true carries the winner's + // own mined block. + entry.has_winner_mined_height = batch.winnerMinedHeight != nil + entry.winner_mined_height = batch.winnerMinedHeight ?? 0 + ffiBatches.append(entry) + } + + let sweeps = UnsafeMutablePointer.allocate( + capacity: max(ffiBatches.count, 1) + ) + sweeps.initialize(from: ffiBatches, count: ffiBatches.count) + defer { + sweeps.deinitialize(count: ffiBatches.count) + sweeps.deallocate() + } + + // The extension entry point, not a `WalletChangeSetFFI` field: the + // Rust persister delivers sweeps through the size-negotiated + // `on_persist_wallet_changeset_sweeps_fn` in the same round as the + // changeset callback, and this drives the Swift side of exactly + // that call. + handler.beginChangeset(walletId: walletId) + let applied = handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: UnsafePointer(sweeps), + count: UInt(ffiBatches.count) + ) + _ = handler.endChangeset(walletId: walletId, success: applied) + return applied + } + + private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + return try? context.fetch(descriptor).first + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try? context.fetch(descriptor).first + } + + /// The row and everything it created go; the funding transaction and its + /// coins stay. + func testSweptTransactionAndItsOutputsAreDeleted() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row is gone") + XCTAssertNil(txo(container, txid: sweptTxid, vout: 0), "the change it created is gone with it") + XCTAssertNotNil(transaction(container, txid: fundingTxid), "the funding transaction is untouched") + } + + /// The released set is applied verbatim: the coin it names comes back, + /// and the one it does not stays out — the winner took that one. + func testSweepFreesOnlyTheInputsTheWinnerDidNotTake() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue(takenByWinner!.isSpent, "the coin the winner took stays spent") + XCTAssertEqual(takenByWinner!.spendingTransaction?.txid, winnerTxid) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse(losersOwn!.isSpent, "the loser's own input is free again") + XCTAssertNil(losersOwn!.spendingTransaction) + } + + /// The winner does not have to reach this store at all: it can spend our + /// coin while paying only to outside addresses, and then no record for it + /// is ever written here. Nothing on hand could separate the coin it took + /// from the loser's own — upstream can, and says so through the released + /// set, which is the entire reason that set is carried. + func testAnAbsentWinnerStillKeepsItsOwnInputSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue( + takenByWinner!.isSpent, + "a coin the chain has already spent must not come back" + ) + XCTAssertNil(takenByWinner!.spendingTransaction, "and no spender is invented for it") + XCTAssertEqual( + takenByWinner!.supersededByTxid, + winnerTxid, + "the hold is attributed to the winner — SQLite's spent_in_txid, mirrored" + ) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse( + losersOwn!.isSpent, + "the loser's own input is free, winner record or not" + ) + } + + /// A re-delivery of the funding output — what a restore-rescan does, + /// blind to the unconfirmed winner no block carries yet — must NOT + /// outrank the sweep's verdict: the coin was provably consumed, and + /// handing it back would resurrect it into the restore set on every + /// restore-from-seed until the winner confirms. Only an explicit + /// release frees a stamped hold — the same answer the SQLite store's + /// upsert valve gives to the identical event stream. + func testWalletReDeliveringAStampedHeldCoinKeepsItSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) + + redeliverCoinB(handler) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(held.isSpent, "the stamped hold survives re-delivery") + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertNil(held.spendingTransaction) + } + + /// The winner's own record can reach this store only after the sweep + /// and the funding TXO already did — IS-locked, not yet in a block. + /// Both writers it flows through resolved the in-block gate to false + /// and wrote it outright: `resolveInputOutpoint` on the record pass, + /// then `markUtxoSpent` on the `utxos_spent` emit riding the same + /// round. Either flipped the durable stamped hold back into the + /// restore set until the winner confirmed — contradicting the verdict + /// the sweep already recorded (and the handler's own "winner is + /// already final" reasoning). + func testAWinnersLateRecordDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + // The winner's own record finally arrives, IS-locked (context 1 < + // in-block), with the spent emit riding along the way a real round + // delivers both. + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0) + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the winner's own unconfirmed arrival must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual( + held.spendingTransaction?.txid, + winnerTxid, + "the spender is linked all the same" + ) + } + + /// The record-only half of the scenario above: a flush can deliver the + /// winner's record without a `utxos_spent` emit (the wallet had no live + /// UTXO to classify — the coin sits as a stamped hold), so + /// `resolveInputOutpoint`'s own monotonic guard must carry the hold by + /// itself. Pinned separately because the combined test's spent emit + /// re-applies the hold through `markUtxoSpent`'s guard, masking a + /// regression in the record pass alone. + func testAWinnersLateRecordAloneDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0), + includeSpentEmit: false + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the record pass alone must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual(held.spendingTransaction?.txid, winnerTxid) + } + + /// One changeset round carrying a transaction record and — unless the + /// caller opts out to pin the record pass alone — the `utxos_spent` + /// emit for the input it consumed, the shape a real round takes when + /// the wallet classifies the spend in the same flush as the record. + private func deliverRecordWithSpentEmit( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoint: (txid: Data, vout: UInt32), + includeSpentEmit: Bool = true + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + inputOutpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = inputOutpoint.vout + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + var spent = SpentOutPointFFI() + spent.outpoint = input + Swift.withUnsafeMutableBytes(of: &spent.spending_txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &input) { inputPtr in + record.input_outpoints = inputPtr + record.input_outpoints_count = 1 + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &spent) { spentPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + if includeSpentEmit { + account.utxos_spent = spentPtr + account.utxos_spent_count = 1 + } + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Multi-input record delivery with no spent emit — the shape a + /// wallet-relevant loser takes when its inputs were never classified + /// against live UTXOs (`input_outpoints` carries every raw input either + /// way). + private func deliverRecord( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)] + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var inputs: [OutPointFFI] = inputOutpoints.map { outpoint in + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = outpoint.vout + return input + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + handler.beginChangeset(walletId: walletId) + inputs.withUnsafeMutableBufferPointer { inputsPtr in + record.input_outpoints = inputsPtr.baseAddress + record.input_outpoints_count = UInt(inputsPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// The pruned-finalized-release defect, on this store's terms: a + /// chainlocked spender F is pruned upstream to a bare txid, so a later + /// loser L that pays this wallet while reusing F's input (plus an + /// attacker-owned one) sweeps with F's coin wrongly named in the + /// released set. F's row and its `spendingTransaction` link survive + /// HERE, and `settledSpenderLinkIsKept` keeps L's record pass from + /// stealing the attribution — so the loser walk never detaches F's coin + /// and the by-outpoint release refuses it (`spendingTransaction == nil` + /// gate), while the coin only L claimed still comes free in the same + /// batch. + func testAReleaseNamingACoinASettledSpenderStillClaimsIsRefused() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let finalizedTxid = Data(repeating: 0x46, count: 32) + let attackerTxid = Data(repeating: 0x47, count: 32) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 200_000 + ) + // F: the chainlocked spender of the settled coin — upstream keeps + // only its txid from here on; this store keeps the row and the link. + let finalized = PersistentTransaction( + txid: finalizedTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 3, + blockHeight: 120, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(finalized) + + let settledCoin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + settledCoin.walletId = walletId + settledCoin.isSpent = true + settledCoin.spendingTransaction = finalized + context.insert(settledCoin) + + let losersOwnCoin = PersistentTxo( + transaction: funding, + vout: 1, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + losersOwnCoin.walletId = walletId + context.insert(losersOwnCoin) + try context.save() + + // L: arrives after F's pruning — pays this wallet, reuses F's input + // alongside the attacker's and one coin of its own. Its record pass + // must NOT steal F's link. + deliverRecord( + handler, + txid: sweptTxid, + context: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: attackerTxid, vout: 0), + (txid: fundingTxid, vout: 1), + ] + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).spendingTransaction?.txid, + finalizedTxid, + "a settled spender's link is not stolen by a conflicting record" + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)).spendingTransaction?.txid, + sweptTxid, + "the loser's own coin links normally" + ) + + // W (final) beats 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. + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)] + )]) + + let settled = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + settled.isSpent, + "a released coin a settled stored spender still claims must stay spent" + ) + XCTAssertEqual(settled.spendingTransaction?.txid, finalizedTxid) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a coin only the swept loser claimed must come free") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil(freed.supersededByTxid) + } + + /// The backstop for rows written before holds named their winner: a + /// coin held spent with neither a spender nor a `supersededByTxid` + /// stamp has nothing durable behind it, so the wallet re-delivering it + /// as a UTXO — the authority on what it holds — still lifts the mark. + /// Every hold written today is stamped; this pins the migration path + /// for the ones already on disk. + func testAPreStampHoldStillFreesOnRedelivery() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(funding) + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.isSpent = true + context.insert(coinB) + try context.save() + + redeliverCoinB(handler) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a hold with nothing durable behind it frees on re-delivery") + XCTAssertNil(freed.spendingTransaction) + } + + /// Hand coin B back through the ordinary account changeset, the way a + /// rescan that re-finds the funding transaction does. + private func redeliverCoinB(_ handler: PlatformWalletPersistenceHandler) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = 1 + utxo.amount = 40_000 + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Two sweeps in one round, the later disagreeing with the earlier. + /// + /// The first frees coin B; a second transaction spends it; the second + /// sweep removes that spender and frees nothing, because its own winner + /// took B. The later answer is the true one — and it only sticks because + /// the batches are applied in sequence. Folding their release sets would + /// leave the first "B is free" outliving the last "B is spent". + func testALaterSweepKeepingACoinSpentOverridesAnEarlierRelease() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // A second transaction takes coin B after the first sweep freed it. + let secondLoser = Data(repeating: 0x55, count: 32) + let context = ModelContext(container) + let reclaimer = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x07, count: 10), + context: 0, + blockHeight: 0, + netAmount: -40_000 + ) + context.insert(reclaimer) + let coinB = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinB } + ) + let row = try XCTUnwrap(try context.fetch(descriptor).first) + row.spendingTransaction = reclaimer + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]), + // Its winner consumed B, so this batch frees nothing. + Batch(losers: [secondLoser], winner: Data(repeating: 0x56, count: 32), winnerMinedHeight: 400), + ]) + + let contested = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(contested) + XCTAssertTrue( + contested!.isSpent, + "the later sweep kept the coin spent, so it must not come back" + ) + } + + /// Seed the review finding's exact shape: one loser transaction shared + /// by two wallets, spending a coin from each. `walletA` owns P, `walletB` + /// owns Q; neither wallet's `PersistentTransaction` row for the winner is + /// ever created here, matching the "winner can pay only outside + /// addresses" case the released set exists to handle. The two coins live + /// in the same funding transaction only for setup convenience — nothing + /// about the fix depends on that; what makes `loser` shared is that its + /// `row.inputs` spans two different owning wallets. + private func seedSharedLoserAcrossTwoWallets( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletA, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + context.insert(funding) + + let loser = PersistentTransaction( + txid: loserTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(loser) + + // P — wallet A's coin, claimed only by the shared loser. + let coinP = PersistentTxo( + transaction: funding, vout: 0, amount: 100_000, address: "yWalletA", height: 100 + ) + coinP.walletId = walletA + coinP.spendingTransaction = loser + context.insert(coinP) + + // Q — wallet B's coin, also claimed only by the shared loser. + let coinQ = PersistentTxo( + transaction: funding, vout: 1, amount: 40_000, address: "yWalletB", height: 100 + ) + coinQ.walletId = walletB + coinQ.spendingTransaction = loser + context.insert(coinQ) + + try context.save() + } + + /// The BLOCKING finding's exact shape, built on top of + /// `seedSharedLoserAcrossTwoWallets`: the shared loser also created an + /// output of its own — phantom money, since a transaction that never + /// confirms funded nothing — and was `involvedAccounts`-linked to an + /// account under `walletA` from back when it was still a live candidate + /// (the ordinary `upsertTransaction` path does this before a later round + /// ever learns the tx lost a double-spend). That link is what makes this + /// fixture actually exercise the fix: without the `isGloballySwept` + /// guard, `walletOwnsTransaction` finds `walletA` through + /// `involvedAccounts` alone, regardless of what happens to P. + private func seedSharedLoserWithOutputAndInvolvedAccount( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletA, walletB: walletB, loserTxid: loserTxid + ) + let context = ModelContext(container) + let walletRecord = try XCTUnwrap( + try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletA }) + ).first + ) + let account = PersistentAccount( + wallet: walletRecord, accountType: 0, accountIndex: 0, accountTypeName: "Standard" + ) + context.insert(account) + + let loserDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + let loser = try XCTUnwrap(try context.fetch(loserDescriptor).first) + loser.involvedAccounts.append(account) + + let phantomChange = PersistentTxo( + transaction: loser, vout: 2, amount: 60_000, address: "yLoserChange", height: 0 + ) + phantomChange.walletId = walletA + context.insert(phantomChange) + + try context.save() + } + + /// The review finding, order 1: wallet B's callback — the one that + /// releases nothing — runs first. Before the fix this alone deleted the + /// shared loser row (nothing in the old code held it back), so wallet + /// A's later release of P landed on the missing-row no-op and P stayed + /// wrongly spent forever. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_BThenA() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x81, count: 32) + let winner = Data(repeating: 0x82, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet B first: its own released set names nothing, so its coin + // (Q) is held rather than freed. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet B alone must not delete a row wallet A still has a claim on" + ) + let untouchedP = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(untouchedP.isSpent, "wallet B's callback must not touch wallet A's coin") + XCTAssertNotNil(untouchedP.spendingTransaction, "P is still linked to the loser, untouched") + + // Wallet A second: its own released set names P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the last wallet to run performs the delete" + ) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's own release must free its own coin") + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's earlier decision to hold Q must survive wallet A's callback") + XCTAssertNil(q.spendingTransaction) + } + + /// The review finding, order 2: wallet A — the one that releases P — + /// runs first. The fix is meant to be order-independent, so this must + /// land on the exact same end state as the B-then-A ordering above. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_AThenB() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x91, count: 32) + let winner = Data(repeating: 0x92, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet A first: releases P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet A alone must not delete a row wallet B still has a claim on" + ) + let untouchedQ = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(untouchedQ.isSpent, "wallet A's callback must not touch wallet B's coin") + XCTAssertNotNil(untouchedQ.spendingTransaction, "Q is still linked to the loser, untouched") + + // Wallet B second: releases nothing. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the last wallet to run performs the delete" + ) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's earlier release must survive wallet B's callback") + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's own decision to hold its coin must stick") + XCTAssertNil(q.spendingTransaction) + } + + /// The BLOCKING review finding: a shared loser's own output, and its + /// reachability through `walletCoreTxids`, must not survive across a + /// restart when only ONE wallet's callback ever commits and the other's + /// never arrives at all — a crash, a rejection, or simply never coming. + /// + /// `commit_batch` calls `store()` once per wallet and each commits + /// independently, so before the fix wallet B alone could not delete a + /// row wallet A still had an outstanding claim on (see the + /// `_BThenA`/`_AThenB` tests above) — and the OUTPUT went with the row, + /// because deletion was the only thing that excluded either. If wallet + /// A's own callback then never runs, that hold is permanent: the row, + /// its phantom output, and its `involvedAccounts` link to wallet A all + /// stay fully live forever, so `walletCoreTxids` hands the dead + /// transaction back to wallet A as its own after every future restart. + /// + /// Only wallet B's callback ever runs here, and it releases nothing — + /// the worst case, since it gives the row no reason to be physically + /// deleted at all. The fix's global half must still make the output and + /// the enumeration exclusion durable from that single callback alone. + func testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-shared-durability-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xA1, count: 32) + let winner = Data(repeating: 0xA2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Only wallet B's callback ever runs, and it releases nothing — + // wallet A's own callback (which would release P) never arrives + // in this test at all. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet A's own claim on P is still outstanding, so the row itself survives" + ) + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the loser's own output must not survive even a single committed callback, " + + "regardless of which wallet's callback that was" + ) + let row = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertTrue( + row.isGloballySwept, + "any callback that reaches the sweep must flag the row, not just wallet A's own" + ) + } + + // Restart: a fresh handler/container over the same file. Wallet A's + // callback never happens in this test, simulating a crash or a + // rejection that stops it from ever arriving — the exact scenario + // the finding describes. + let (handler, container) = try makeHandler(url: storeURL) + + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the phantom output must not resurrect across a restart" + ) + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertFalse( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must not be able to enumerate the swept loser as its own transaction " + + "after a restart, even though it is still linked via involvedAccounts and " + + "its own callback never ran" + ) + } + + /// Cross-round reinstatement — the BLOCKING finding this round fixes. + /// The sweep and its reinstating record land in two SEPARATE + /// `persistWalletChangeset` rounds, with wallet B's still-outstanding + /// claim keeping the shared row physically present in between, exactly + /// as `testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits` + /// establishes on its own. Before the fix, `upsertTransaction` bailed + /// unconditionally on `isGloballySwept == true`, so round 2's record — + /// upstream's newer word, per `CoreChangeSet::merge`'s documented + /// IS-lock-precedence sequence (swept by an IS-locked conflict, then + /// returns chainlocked and sweeps that conflict in turn) — would be + /// silently discarded forever, and `upsertUtxo` would keep rejecting + /// its output on the strength of a tombstone nothing could ever clear. + /// Verified across a restart: the reinstatement has to be durable, not + /// merely visible in the context that just applied it. + func testAReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-reinstatement-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xB1, count: 32) + let winner = Data(repeating: 0xB2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing. Wallet A's own claim on P (its funding coin) is still + // outstanding, so the shared row survives physically even + // though the global half of the sweep already tombstoned it and + // deleted its phantom output. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + let tombstoned = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertTrue(tombstoned.isGloballySwept, "sanity: the row is tombstoned after round 1") + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "sanity: the loser's own output is gone after round 1" + ) + + // Round 2, a SEPARATE callback (not coalesced with round 1's + // sweep — the cross-round shape the merge-level fix in + // `CoreChangeSet::merge` cannot reach): the wallet returns + // chainlocked and sweeps the erstwhile winner in turn. Arrives + // here exactly like any freshly-detected transaction would — + // nothing marks it as "the reinstating one" — with its own + // output riding along in the same round the way a transaction's + // outputs ordinarily do. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: loserTxid, + context: 3, // inChainLockedBlock + blockHeight: 200, + inputOutpoints: [(txid: fundingTxid, vout: 0)], + outputVout: 2, + outputAmount: 60_000, + outputAddress: "yLoserChange" + ) + + let reinstated = try XCTUnwrap( + transaction(container, txid: loserTxid), + "the reinstating record must not be discarded" + ) + XCTAssertFalse( + reinstated.isGloballySwept, + "a later record naming a tombstoned txid must clear the tombstone" + ) + XCTAssertEqual(reinstated.blockHeight, 200) + + let revivedOutput = try XCTUnwrap( + txo(container, txid: loserTxid, vout: 2), + "the reinstated transaction's own output must come back" + ) + XCTAssertEqual(revivedOutput.amount, 60_000) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "wallet A reclaims its input once its own record is live again") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must be able to enumerate the reinstated transaction as its own again" + ) + } + + // Restart: a fresh handler/container over the same file. The + // reinstatement has to be durable, not just visible to the context + // that applied it. + let (handler, container) = try makeHandler(url: storeURL) + + let survived = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertFalse(survived.isGloballySwept, "the reinstatement must survive a restart") + XCTAssertNotNil( + txo(container, txid: loserTxid, vout: 2), + "the revived output must survive a restart" + ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "the reclaimed input must survive a restart") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "the reinstated transaction must still enumerate as wallet A's own after a restart" + ) + } + + /// A failed wallet lookup must fail the round, not read as "no such + /// wallet". + /// + /// `try?` collapsed the two: a thrown SwiftData fetch returned success + /// without applying the sweep, Rust discarded the subtractive event, and + /// a later round could then persist a height beyond a removal that never + /// landed. Driving the real failure is awkward, so this pins the + /// distinction that makes it impossible — a wallet that genuinely is not + /// there is still a successful no-op. + func testAMissingWalletIsASuccessfulNoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // Delete the wallet row, leaving the fetch to succeed and find + // nothing — the branch that must stay a success. + let context = ModelContext(container) + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try context.fetch(descriptor) { + context.delete(row) + } + try context.save() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertTrue(applied, "a stale post-deletion callback is not a failure") + XCTAssertNotNil( + transaction(container, txid: sweptTxid), + "and it must not have applied anything either" + ) + } + + /// Companion to `testAMissingWalletIsASuccessfulNoOp` above, which its + /// own doc admits does not distinguish the fix from the old `try?` + /// behavior — a successful empty fetch reads identically either way. + /// This drives a genuinely THROWING fetch instead, using a real seam + /// rather than a mock: a file-backed store (so the container's SQLite + /// connection is live and long-lived, unlike the in-memory variant) is + /// truncated on disk, out from under that open connection, between + /// seeding and the sweep. `fetchWalletRecord`'s `context.fetch` then has + /// to perform real I/O against a file that is no longer a valid SQLite + /// database, which is the only way found to make it throw without + /// adding a test-only injection point to production code. + func testAThrowingWalletLookupFailsTheRound() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-throwing-lookup-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + let (handler, _) = try makeHandler(url: storeURL) + + // Corrupt the on-disk store out from under the still-open container + // BEFORE any context — including a seed helper's — reads or writes + // through it: SwiftData's row cache is scoped to the persistent + // store coordinator, not to any one `ModelContext`, so a row + // touched by a throwaway seeding context would still be served from + // that shared cache here and never reach disk at all. With nothing + // cached yet, `fetchWalletRecord`'s fetch is the first real read + // this store ever performs, and it hits the truncated file — well + // short of a valid SQLite header — directly. + let handle = try FileHandle(forWritingTo: storeURL) + handle.truncateFile(atOffset: 16) + try handle.close() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") + } + + /// Two wallets, each holding an unresolved *released* input on the same + /// shared loser — the case where the row would otherwise never be + /// reclaimed. + /// + /// Left attached, a released pending input reads as its wallet's claim + /// in the ownership check, so A declines the delete because B's row is + /// there and B declines because A's is: a stalemate no replay breaks. + /// The dead transaction contributes no funds either way thanks to the + /// global marker, so this is storage rather than balance — but the row + /// and both pending entries would be kept forever. + func testTwoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: sweptTxid + ) + + // Each wallet has one pending input on the loser, and each will be + // released by its own wallet's sweep. + let context = ModelContext(container) + let loserTxid = sweptTxid + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + descriptor.fetchLimit = 1 + let loser = try XCTUnwrap(try context.fetch(descriptor).first) + let pendingA = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 8), + inputIndex: 0, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletId + ) + let pendingB = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 9), + inputIndex: 1, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletB + ) + context.insert(pendingA) + context.insert(pendingB) + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 8)]) + ]) + sweep( + handler, + [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 9)])], + walletId: walletB + ) + + XCTAssertNil( + transaction(container, txid: sweptTxid), + "a released pending input is not a claim once its own wallet has resolved it" + ) + } + + /// A txid the store has never seen is not an error: sweeps are + /// idempotent, and a round can name a transaction this mirror never + /// recorded in the first place. + func testSweepingAnUnknownTransactionIsANoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + let applied = sweep(handler, [ + Batch(losers: [Data(repeating: 0x99, count: 32)], winner: winnerTxid, winnerMinedHeight: 400) + ]) + + XCTAssertTrue(applied, "an absent row is a successful no-op, not a failed round") + XCTAssertNotNil(transaction(container, txid: sweptTxid)) + XCTAssertNotNil(transaction(container, txid: fundingTxid)) + } + + /// The loser can be persisted before its own funding output ever is — + /// `upsertTransaction` parks a spend like that as a `PersistentPendingInput` + /// rather than a `PersistentTxo` update (see `resolveInputOutpoint`). + /// When the sweep holds that input (it's not in `released`), there is no + /// `PersistentTxo` row to mark — the only record of the claim is the + /// pending row, which cascades away with the loser it names unless + /// `applySweptTransaction` rescues it first. This is the regression the + /// review finding described: seed the pending spend, sweep it, restart + /// the store, and only then let the funding UTXO arrive. The coin must + /// come back spent, attributed to the winner, not as a fresh unspent row. + func testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-pending-input-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + // What `resolveInputOutpoint` would have written: the funding + // TXO for (fundingTxid, 0) has never been seen here. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + XCTAssertNil( + txo(container, txid: fundingTxid, vout: 0), + "sanity: the funding TXO has not arrived yet" + ) + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") + } + + // Restart: a fresh persister loading the same on-disk store. + let (handler, container) = try makeHandler(url: storeURL) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Records precede sweeps within a round, so a wallet-relevant winner + /// whose own funding side is ALSO unobserved stages an ordinary pending + /// row for the same outpoint moments before the sweep repoints the + /// loser's row into a tombstone — and the tombstone keeps the loser's + /// original, older `createdAt`. The drain's newest-wins pick then + /// selected the winner's ordinary row, took the gated branch (`isSpent` + /// stays false until the winner confirms — never, for an IS-locked + /// unconfirmed winner), skipped the `supersededByTxid` stamp, and + /// deleted every pending row including the tombstone: the durable hold + /// evaporated and the consumed coin re-entered the restore set. + func testAWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + // The doomed spend arrived before its funding output — parked as a + // pending row, exactly what `resolveInputOutpoint` writes. Backdated + // so the winner's row below is strictly newer, as it always is in + // reality (the loser's record preceded the winner's by definition). + let loser = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(loser) + let losersClaim = PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: loser, + walletId: walletId + ) + losersClaim.createdAt = Date(timeIntervalSinceNow: -10) + context.insert(losersClaim) + + // The winner's own record — IS-locked, still unconfirmed — lands in + // the same round as the sweep, records first, and stages its own + // ordinary pending row for the same still-unfunded outpoint. + let winner = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(winner) + context.insert(PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: winner, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + let rows = try context.fetch(pendingDescriptor) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows.filter(\.isSweptTombstone).count, 1) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the sweep's hold must survive the winner's own coexisting pending row" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Chained-sweep continuation of `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent` + /// above: L spends P; W spends P and Q and sweeps L, holding P (still + /// unfunded); X spends Q and sweeps W, this time releasing P. The + /// tombstone `applySweptTransaction` wrote for P when L was swept + /// already detached from `spendingTransaction`, so the second sweep of + /// W cannot find it through `row.pendingInputs` the way the first sweep + /// did — it can only be found by the scalar `spendingTxid` it now + /// carries. This is the review finding: without that second lookup, the + /// second sweep's release of P is silently dropped, and P's funding TXO + /// resurrects the coin attributed to the wrong (already deleted) + /// transaction instead of coming back spendable. + func testChainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x61, count: 32) // L + let secondLoser = Data(repeating: 0x62, count: 32) // W + let finalWinner = Data(repeating: 0x63, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + // P (fundingTxid:0) has never been observed as a TXO — parked as a + // pending input, the same as `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent`. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P (still unfunded). + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone, "the first sweep must tombstone the pending row") + XCTAssertEqual(tombstone.spendingTxid, secondLoser) + XCTAssertNil(tombstone.spendingTransaction, "must have detached from the doomed loser's FK") + + // W's own row, plus a materialized claim on Q, needed for the + // second sweep to find W at all — the same requirement any sweep of + // a wallet-relevant loser has. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -90_000 + ) + context.insert(w) + let qFunding = PersistentTransaction( + txid: Data(repeating: 0x65, count: 32), + transactionData: Data(repeating: 0x09, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(qFunding) + let coinQ = PersistentTxo( + transaction: qFunding, + vout: 0, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinQ.walletId = walletId + coinQ.spendingTransaction = w + context.insert(coinQ) + try context.save() + + // Second sweep: X beats W, this time releasing P. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let survivingTombstones = try context.fetch(tombstoneDescriptor) + XCTAssertTrue( + survivingTombstones.isEmpty, + "a released outpoint's tombstone must not survive a chained sweep" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertFalse( + coin.isSpent, + "the final sweep released this coin, so it must come back spendable even " + + "though an earlier sweep in the chain had tombstoned it" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The held (not released) half of the chained scenario above: the + /// second sweep keeps P spent instead of releasing it, and the + /// tombstone must end up attributed to the NEW winner rather than the + /// intermediate one that no longer has a row. + func testChainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x71, count: 32) // L + let secondLoser = Data(repeating: 0x72, count: 32) // W + let finalWinner = Data(repeating: 0x73, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own row — this time claiming ONLY P, so the second sweep has + // no other input to reason about. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(w) + try context.save() + + // Second sweep: X beats W, still holding the same input. + sweep(handler, [Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertEqual( + tombstone.spendingTxid, + finalWinner, + "the tombstone must be repointed at the FINAL winner, not the intermediate " + + "one the second sweep already removed" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the final winner's claim must survive both sweeps and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, finalWinner) + } + + /// The multi-loser batch shape upstream's descendant closure always + /// produces — parent P and child C removed together — which no fixture + /// here ever exercised: C spends P:0, still unfunded, so the claim + /// lives as a pending row. Upstream never releases a loser-funded + /// outpoint, so without a co-swept check the sweep tombstones the + /// claim to the winner — and P's chainlocked reinstatement then + /// re-delivers P:0 straight into the tombstone-outranks drain: + /// `isSpent = true`, `supersededByTxid = winner`, recovery clear + /// refusing stamped holds. Permanently unspendable. A dead parent's + /// output is nobody's coin; the claim must be deleted with the batch. + func testABatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + // P is `fundingTxid` (so the redelivery helper reaches it) and its + // record was never persisted — the weaker-preconditions shape. C's + // claim on P:0 is parked as a pending row, exactly what + // `resolveInputOutpoint` writes. + let childTxid = Data(repeating: 0xB5, count: 32) // C + let winner = Data(repeating: 0xB6, count: 32) // W + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + let c = PersistentTransaction( + txid: childTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(c) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, + inputIndex: 0, + spendingTxid: childTxid, + spendingTransaction: c, + walletId: walletId + )) + try context.save() + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + sweep(handler, [Batch(losers: [fundingTxid, childTxid], winner: winner, winnerMinedHeight: 400)]) + + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "a claim on a co-swept parent's output must be deleted, not tombstoned" + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + coin.isSpent, + "the reinstated parent's output must not be wedged by its dead child's claim" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The whole chain inside ONE round: a single sweeps callback can carry + /// two batches where the second sweeps the first's winner, so the + /// tombstone the first batch just wrote — staged, unsaved, retargeted by + /// nothing but in-memory mutation — must be visible to the second + /// batch's scalar reconciliation. Pins the per-batch tombstone scan + /// reading the mutable columns off live objects; a store-side predicate + /// would test the stale saved values and miss the row entirely. + func testChainedSweepAcrossTwoBatchesInOneRoundReleasesTheFreshTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0xA1, count: 32) // L + let secondLoser = Data(repeating: 0xA2, count: 32) // W — batch 1's winner + let finalWinner = Data(repeating: 0xA3, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // One callback, two batches: W beats L holding the unfunded coin, + // then X beats W and frees it. + sweep(handler, [ + Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400), + Batch( + losers: [secondLoser], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ), + ]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "the second batch must find and release the tombstone the first batch just wrote" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coin.isSpent, "the released coin funds as spendable") + XCTAssertNil(coin.supersededByTxid) + } + + /// The funding-BEFORE-release ordering of the chained scenario above: + /// the funding TXO arrives between the sweep that held the coin and the + /// sweep that frees it, so the tombstone drains into + /// `PersistentTxo.supersededByTxid` and the pending row is gone by the + /// time the release runs. With the intermediate winner's own record on + /// hand the drain links `spendingTransaction` too, so the release DOES + /// reach the row through `row.inputs` — but nothing cleared the marker, + /// and a released coin keeping its dead winner's marker turns the next + /// hold on this outpoint permanent (`upsertUtxo`'s recovery clear reads + /// a present marker as a durable claim). + func testAReleasedCoinDropsItsDeadWinnersMarker() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x91, count: 32) // L + let secondLoser = Data(repeating: 0x92, count: 32) // W + let finalWinner = Data(repeating: 0x93, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own record lands before the funding TXO does, so the drain + // below links `spendingTransaction` as well as stamping the marker. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(w) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, secondLoser) + + // Second sweep: X beats W, and this time upstream frees the coin. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(freed.isSpent, "the released coin is spendable again") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil( + freed.supersededByTxid, + "the dead winner's marker goes with the hold it carried" + ) + } + + /// The unreachable-claim variant of the same ordering: the claim + /// drained into `PersistentTxo.supersededByTxid`, its pending row is + /// gone, and the winner it names was NEVER recorded here — so when that + /// winner is swept in turn there is no `row` to fetch, no `row.inputs` + /// to walk, and no tombstone left for the scalar reconciliation to + /// find. Only an outpoint-keyed release — the form Kotlin's + /// `releaseByOutpoint` and SQLite's outpoint-matched UPDATE both + /// implement — can reach the coin; without it the release is silently + /// dropped and the coin stays spent forever. + func testAReleaseReachesAClaimDrainedToTheTxoWhenTheWinnerWasNeverRecorded() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x94, count: 32) // L + let unrecordedWinner = Data(repeating: 0x95, count: 32) // W — never a row here + let finalWinner = Data(repeating: 0x96, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: unrecordedWinner, winnerMinedHeight: 400)]) + + // The funding TXO arrives with W still unrecorded: the drain stamps + // the marker but has no row to link. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, unrecordedWinner) + XCTAssertNil(stamped.spendingTransaction, "sanity: no relationship to reach it by") + + // Second sweep: X beats the never-recorded W, freeing the coin. + sweep(handler, [ + Batch( + losers: [unrecordedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + freed.isSpent, + "the release must reach a drained claim even with no row and no tombstone left" + ) + XCTAssertNil(freed.supersededByTxid) + } + + /// The multi-wallet continuation of the chained scenarios above — the + /// review finding on the missing-row early return. A shared loser L + /// spends one still-unfunded coin of wallet A's and two of wallet B's, + /// so the first sweep leaves each wallet's claims as detached tombstones + /// pointing at winner W. When W's own record then arrives, + /// `resolveInputOutpoint`'s duplicate guard sees each `(outpoint, W)` + /// tombstone and attaches nothing to W's row — so when W is swept in + /// turn, wallet A's callback finds no other wallet's claim on the row + /// and deletes it. Wallet B's independently committed callback then runs + /// against a row that no longer exists, and before the fix returned + /// without ever applying B's release decision: B's released coin would + /// later come back spent by the obsolete W, and B's held coin stayed + /// attributed to W, unable to follow any further sweep. + func testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + let walletB = Data(repeating: 0x02, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let sharedLoser = Data(repeating: 0xC1, count: 32) // L + let sharedWinner = Data(repeating: 0xC2, count: 32) // W + let finalWinner = Data(repeating: 0xC3, count: 32) // X + + let l = PersistentTransaction( + txid: sharedLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(l) + // None of the three coins L claims has been funded here yet: one of + // wallet A's (vout 0) and two of wallet B's (vouts 1 and 2), all + // parked as pending inputs the way `resolveInputOutpoint` does. + for (vout, owner) in [(UInt32(0), walletId), (1, walletB), (2, walletB)] { + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: vout), + inputIndex: vout, + spendingTxid: sharedLoser, + spendingTransaction: l, + walletId: owner + )) + } + try context.save() + + // First sweep, one independently committed callback per wallet: W + // beats L, holding everything (nothing funded, nothing released). + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletId) + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletB) + XCTAssertNil(transaction(container, txid: sharedLoser), "L is gone once both wallets ran") + + // W's own record arrives, claiming all three outpoints. The + // `(outpoint, W)` tombstones occupy the duplicate-guard key, so no + // new pending relationship attaches to W's row — the premise that + // lets wallet A's callback below delete it. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: sharedWinner, + context: 0, + blockHeight: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: fundingTxid, vout: 1), + (txid: fundingTxid, vout: 2), + ], + outputVout: 0, + outputAmount: 120_000, + outputAddress: "yWinnerChange" + ) + + // Second sweep: X beats W. Wallet A's callback runs first, releases + // its own coin, and — finding no attached claim of any other + // wallet's — deletes the shared row. + sweep(handler, [ + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + XCTAssertNil( + transaction(container, txid: sharedWinner), + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive" + ) + + // Wallet B's callback arrives after the row is gone, releasing one + // of its two coins and holding the other. + sweep(handler, [ + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 2)]) + ], walletId: walletB) + + let heldOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let heldDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == heldOutpoint } + ) + let heldTombstone = try XCTUnwrap( + try context.fetch(heldDescriptor).first, + "wallet B's held tombstone must survive the row's absence" + ) + XCTAssertEqual( + heldTombstone.spendingTxid, + finalWinner, + "the held tombstone must follow the chain to X even though W's row was " + + "already deleted by wallet A's callback" + ) + let releasedOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 2) + let releasedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == releasedOutpoint } + ) + XCTAssertTrue( + try context.fetch(releasedDescriptor).isEmpty, + "wallet B's release decision must reach its tombstone even though W's row " + + "was already deleted by wallet A's callback" + ) + + // The funding TXOs finally arrive, one per owning wallet. + deliverFundingUtxo(handler, walletId: walletId, vout: 0, amount: 100_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 1, amount: 40_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 2, amount: 20_000) + + let coinA = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coinA.isSpent, "wallet A's released coin comes back spendable") + let heldB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(heldB.isSpent, "wallet B's held coin stays spent") + XCTAssertEqual( + heldB.supersededByTxid, + finalWinner, + "the held coin must be attributed to the final winner, not the deleted W" + ) + let releasedB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 2)) + XCTAssertFalse( + releasedB.isSpent, + "wallet B's released coin must not resurrect spent under the obsolete winner" + ) + XCTAssertNil(releasedB.supersededByTxid) + } + + /// Hand a UTXO for `(fundingTxid, vout)` back through the ordinary + /// account changeset — the same entry point `redeliverCoinB` drives, but + /// generalized so a fresh outpoint can be delivered rather than the one + /// baked into `seedSpend`. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + vout: UInt32, + amount: UInt64 + ) { + deliverFundingUtxo(handler, walletId: walletId, vout: vout, amount: amount) + } + + /// `walletId`-parameterized form for the multi-wallet tests, where each + /// wallet's own funding UTXO has to arrive through that wallet's own + /// changeset — the drain in `upsertUtxo` resolves the tombstone by + /// outpoint, but the round itself is wallet-scoped like every real one. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + vout: UInt32, + amount: UInt64 + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = vout + utxo.amount = amount + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Deliver a plain transaction record — with a fresh output of its own + /// riding along in the same round — through the ordinary account + /// changeset entry point. Models the reinstating event the BLOCKING + /// finding describes: upstream reports a previously-swept txid to + /// `records` exactly the way it reports any freshly-detected + /// transaction, with nothing on the wire flagging it as "the one that + /// used to be swept" — `upsertTransaction` has to infer that entirely + /// from the row it finds already sitting in the store. + private func deliverReinstatingRecord( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + txid: Data, + context: UInt32, + blockHeight: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)], + outputVout: UInt32, + outputAmount: UInt64, + outputAddress: String + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup(outputAddress) + defer { + free(name) + free(address) + } + + let inputs = UnsafeMutablePointer.allocate( + capacity: max(inputOutpoints.count, 1) + ) + for (i, input) in inputOutpoints.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + input.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = input.vout + inputs.advanced(by: i).initialize(to: entry) + } + defer { + inputs.deinitialize(count: inputOutpoints.count) + inputs.deallocate() + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = blockHeight + record.input_outpoints = inputs + record.input_outpoints_count = UInt(inputOutpoints.count) + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = outputVout + utxo.amount = outputAmount + utxo.address = address + utxo.height = blockHeight + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + // MARK: - Bounded tombstone lifetime + + /// The block-context winner's mined height used across the bounded- + /// lifetime tests — the stamp every tombstone carries, and the exact + /// boundary value at which it collects. + private static let winnerHeight: UInt32 = 400 + + /// One committed round carrying chain progress: the synced height, + /// (unless the caller opts out) opaque chainlock bytes, and — when + /// `chainLockHeight` is supplied — the NUMERIC chainlock height + /// through the extension's dedicated slot, fired inside the same + /// begin/end bracket after the changeset callback exactly the way the + /// Rust persister fires it. The bytes and the number are deliberately + /// independent knobs: the reviewer's point is precisely that bytes + /// alone must not enable collection. + private func heightsRound( + _ handler: PlatformWalletPersistenceHandler, + synced: UInt32, + chainLock: Bool = true, + chainLockHeight: UInt32? = nil + ) { + handler.beginChangeset(walletId: walletId) + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = synced + var clBytes = [UInt8](repeating: 9, count: 84) + clBytes.withUnsafeMutableBufferPointer { buf in + if chainLock { + cs.last_applied_chain_lock_bytes = buf.baseAddress + cs.last_applied_chain_lock_bytes_len = UInt(buf.count) + } + withUnsafePointer(to: &cs) { csPtr in + _ = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + if let chainLockHeight { + _ = handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Record a loser spending `(spentTxid, 0)` with the funding side + /// unobserved, then sweep it in the given winner context — + /// `winnerMinedHeight` non-nil leaves the stamped tombstone the + /// collection tests reason about; `nil` (an IS-locked, unmined winner) + /// must leave nothing. + private func seedSweptTombstone( + _ handler: PlatformWalletPersistenceHandler, + _ container: ModelContainer, + winnerMinedHeight: UInt32?, + spentTxid: Data? = nil, + loser: Data? = nil, + winner: Data? = nil + ) throws { + let loser = loser ?? sweptTxid + let context = ModelContext(container) + let swept = PersistentTransaction( + txid: loser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: loser, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + sweep(handler, [Batch( + losers: [loser], + winner: winner ?? winnerTxid, + winnerMinedHeight: winnerMinedHeight + )]) + } + + private func pendingRows( + _ container: ModelContainer, + spentTxid: Data? = nil + ) throws -> [PersistentPendingInput] { + let outpoint = PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// Every pending-input row this wallet holds, regardless of outpoint — + /// the attacker-growth metric the mempool-context tests measure. + private func walletPendingRows( + _ container: ModelContainer + ) throws -> [PersistentPendingInput] { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// This wallet's persisted row, for asserting on the stored numeric + /// chainlock height. + private func walletRow(_ container: ModelContainer) throws -> PersistentWallet? { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor).first + } + + /// The attacker-shaped row's lawful cousin: a block-context sweep's + /// tombstone stores the WINNER'S own mined height and is collected + /// exactly when the finality boundary `min(chainlockHeight, + /// syncedHeight)` reaches it — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, no + /// observation-age margin. At that boundary the funding transaction of + /// the guarded outpoint (necessarily mined at or below the winner's + /// height) has been filter-scanned with no false negatives, so an + /// undrained tombstone is provably not guarding the wallet's coin. + func testASweptTombstoneIsCollectedAtFinalityAndNotBefore() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + let tombstone = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(tombstone.isSweptTombstone, "sanity: the sweep flagged the row") + XCTAssertEqual( + tombstone.winnerMinedHeight, Self.winnerHeight, + "the tombstone is stamped with the WINNER'S own mined height — " + + "not any observation watermark" + ) + + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight - 1 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "boundary \(Self.winnerHeight - 1) has not reached the winner's " + + "height \(Self.winnerHeight) — the hold stays" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the boundary reaching the winner's height collects the row — no margin" + ) + } + + /// The reviewer's "weaker still" point, named: synced-height progress + /// plus even PRESENT chainlock BYTES must not collect — the bincode + /// blob proves a chainlock was once applied, but says nothing about + /// how far finality reaches. Only the NUMERIC chainlock height + /// delivered through the extension slot supplies the boundary's + /// chainlock half, mirroring upstream's (and the SQLite store's) + /// "no-op until a chainlock height has been persisted". + func testASweptTombstoneOutlivesSyncProgressWithoutANumericChainLockHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + heightsRound(handler, synced: 10_000, chainLock: true) + XCTAssertEqual( + try pendingRows(container).count, 1, + "chainlock BYTES exist and the synced height is far past the " + + "stamp — but no numeric chainlock height has ever been " + + "stored, so no finality boundary exists and the hold stays" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the first NUMERIC chainlock height supplies the boundary and " + + "the long-aged stamp collects" + ) + } + + /// The genuine claim the tombstone exists for: its funding TXO arrives, + /// the drain moves the hold onto the TXO row (`supersededByTxid`) and + /// deletes the pending rows — so no amount of later boundary progress + /// may touch the materialised hold. + func testADrainedClaimIsImmuneToTheCollector() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: held, undrained, stamped with the winner's height" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "sanity: the drain consumed the pending rows" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the materialised claim's row survives collection" + ) + XCTAssertTrue(coin.isSpent, "still held spent by the winner's claim") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// A held tombstone with a nil winner-height stamp 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. With no proof of finality the safe + /// reading is to hold it forever rather than guess. + /// Replaces the rejected back-fill design, which stamped such a row + /// with the current height and thereby fabricated a finality horizon. + func testATombstoneWithoutAWinnerHeightIsNeverCollected() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + + // 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. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + + let row = try XCTUnwrap( + try pendingRows(container).first, + "no winner height, no proof of finality — the hold outlasts any boundary" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil( + row.winnerMinedHeight, + "and the stamp is never back-filled — that would fabricate the horizon" + ) + } + + /// 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. + func testARepointedTombstoneIsRestampedToTheLaterSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: stamped with the first winner's mined height" + ) + + // The first winner is itself swept — by a winner mined 50 blocks + // later — the chained-sweep continuation that re-points the + // earlier tombstone (no row needed: the tombstone is found by the + // scalar `spendingTxid` it carries). + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "re-pointed to a later block-context winner ⇒ re-stamped to " + + "THAT winner's mined height" + ) + + // And the horizon moved with it: the old height no longer collects, + // the new one does. + heightsRound( + handler, + synced: Self.winnerHeight + 49, + chainLockHeight: Self.winnerHeight + 49 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "the boundary reaching only the FIRST winner's height must no " + + "longer collect the re-stamped claim" + ) + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue(try pendingRows(container).isEmpty) + } + + /// 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 + /// deletes the loser and retains them in the account's + /// `spent_outpoints`, a hold with no height that no record survives to + /// rebuild (the winner need not be wallet-relevant). The tombstone 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 materializes — and it is unstamped because an IS-locked + /// winner has no mining deadline, so no boundary may ever collect it. + func testAMempoolContextSweepPreservesAnUnstampedTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + for i in 0..<3 { + let spent = Data(repeating: UInt8(0x70 + i), count: 32) + try seedSweptTombstone( + handler, + container, + winnerMinedHeight: nil, + spentTxid: spent, + loser: Data(repeating: UInt8(0x80 + i), count: 32), + winner: Data(repeating: UInt8(0x90 + i), count: 32) + ) + let row = try XCTUnwrap( + try pendingRows(container, spentTxid: spent).first, + "an unmined IS-locked winner must leave a held tombstone for input #\(i)" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil(row.winnerMinedHeight, "and it carries no finality stamp") + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + XCTAssertEqual( + try walletPendingRows(container).count, 3, + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release " + + "resolves one" + ) + } + + /// The mempool-context sweep still spend-marks a coin that HAS + /// materialised — that path is unchanged: the row carries real funding + /// data and `supersededByTxid` is its durable hold. The + /// never-materialised claim the same loser carries survives too, as an + /// unstamped tombstone — the pending row is the only durable carrier + /// of a hold upstream keeps in `spent_outpoints` and cannot rebuild + /// after the loser's record is gone. + func testAMempoolContextSweepStillSpendMarksAMaterialisedCoin() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + // The same loser also claims an input whose funding side was never + // observed — the shape that would have become a tombstone. + let unfundedTxid = Data(repeating: 0x77, count: 32) + let context = ModelContext(container) + let loserRow = try XCTUnwrap(transaction(container, txid: sweptTxid)) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: unfundedTxid, vout: 0), + inputIndex: 2, + spendingTxid: sweptTxid, + spendingTransaction: loserRow, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: nil + )]) + + let coinB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue( + coinB.isSpent, + "a materialised coin is spend-marked by the IS-locked winner exactly as before" + ) + XCTAssertEqual(coinB.supersededByTxid, winnerTxid) + let claim = try XCTUnwrap( + try pendingRows(container, spentTxid: unfundedTxid).first, + "while the never-materialised claim survives as a tombstone" + ) + XCTAssertTrue(claim.isSweptTombstone) + XCTAssertEqual(claim.spendingTxid, winnerTxid, "re-pointed at the winner") + XCTAssertNil(claim.winnerMinedHeight, "unstamped — the winner is unmined") + } + + /// 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 unstamped tombstone is the + /// claim's only durable carrier, so the funding delivery must drain + /// INTO it and land spent: crediting the coin would hand coin + /// selection an outpoint the network has provably consumed. + func testAFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mempool-sweep-restart-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil(transaction(container, txid: sweptTxid), "sanity: the loser is gone") + let tombstone = try XCTUnwrap( + try walletPendingRows(container).first, + "sanity: the mempool sweep left the hold behind" + ) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertNil(tombstone.winnerMinedHeight, "unstamped — no finality horizon exists") + } + + // Restart: a fresh persister loading the same on-disk store, then + // arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold — and only + // then the funding delivery. + let (handler, container) = try makeHandler(url: storeURL) + heightsRound(handler, synced: 25_000, chainLockHeight: 25_000) + XCTAssertEqual( + try walletPendingRows(container).count, 1, + "the unstamped hold survives the restart and every boundary" + ) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "held by the winner the sweep named") + XCTAssertTrue( + try walletPendingRows(container).isEmpty, + "the claim drained into the TXO row" + ) + } + + /// The unrelated-advancement scenario, block-context half: the + /// chainlock can run arbitrarily far ahead, but while `syncedHeight` + /// 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. + func testABlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight + 10_000 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "min(chainlock, synced) = \(Self.winnerHeight - 1) is below the " + + "winner's height — any amount of unrelated chainlock " + + "progress must not collect the hold" + ) + + // No fresh chainlock this round: the changeset-path collector runs + // off the STORED numeric height. + heightsRound(handler, synced: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the scan reaching the winner's height completes the boundary and collects" + ) + } + + /// 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. One of the three + /// resolution channels that bound the unstamped population. + func testAnUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // IS-context sweep: the hold lands unstamped. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + "sanity: held and unstamped" + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input — the chained-sweep + // continuation finds the tombstone by its scalar `spendingTxid`. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "the block-context re-point stamps the previously unstamped hold" + ) + + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "once stamped, the ordinary finality boundary collects the row" + ) + } + + /// 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. + func testAMempoolRepointedTombstoneKeepsItsBlockContextStamp() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // The first winner is evicted by an IS-locked, unmined conflict. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: nil + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight, + "an unmined winner re-points the claim without touching the " + + "earlier block-context stamp" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "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 chainlock-height extension callback stores monotonic-max on the + /// wallet row: chain locks only move forward, and a late or re-emitted + /// lower height must not walk the finality boundary backwards. + func testTheChainLockHeightCallbackStoresMonotonicMaxOnTheWalletRow() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + XCTAssertNil( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, + "sanity: fresh row, no numeric chainlock height yet" + ) + + heightsRound(handler, synced: 10, chainLockHeight: 500) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "the first height lands as stored" + ) + + heightsRound(handler, synced: 11, chainLockHeight: 300) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "a lower height must not walk the watermark backwards" + ) + + heightsRound(handler, synced: 12, chainLockHeight: 700) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 700, + "a higher height advances it" + ) + } +} From f0fdcc1da7df8fd89b6661ab40aa4e30e0fbfe9c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:39:34 +0300 Subject: [PATCH 6/9] fix(swift-sdk): key the sweep hold on the coin, hold globally, release per wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the SwiftData sweep writer, brought onto the same doctrine as the SQLite store (#4559). Every rule below is a property of the coin, not of the row's relationships. The hold is keyed by outpoint. `applySweptTransaction` decodes the loser's inputs from its stored bytes and settles each one by key; a spender link is detached only if it points at the loser. Before, the sweep walked the loser's `inputs` relationship, which a store-only fetch had refreshed to its saved state — so a winner recorded in the same round as the sweep of its loser had its freshly written link nil-ed, and `walletFundedTransaction` never saw the winner again. The one link writer, `adoptSpendObservation`, now registers the displaced spender in the round index, so no keyed store-only lookup can refresh an object carrying staged state. The hold is global, the release is per wallet. The first callback that sees a sweep holds every wallet's rows for the loser's inputs, then deletes the loser's row; each wallet's own callback applies its release set to its own rows. The loser's outputs are dead for everyone and its inputs' holds are a txid fact, not a per-wallet one — only the release set depends on which records a wallet holds. That removes `isGloballySwept`, the deferred delete and every reader guard built to hide a surviving swept row; a swept row no longer enumerates through `involvedTransactions`, and a wallet whose round is rejected finds its coin held rather than restorable. Pending rows are per (outpoint, spendingTxid, walletId), so a second wallet recording the same spend keeps its own claim row; the drain prefers the tombstone tagged with the delivering wallet. A drained tombstone stamps — `isSpent`, `supersededByTxid` — and never mints a spender link or a vin index; the winner's own claim row beside it supplies both. A release is vetoed by a stored network-final claim whose bytes actually spend the outpoint (a stamp alone does not veto: under the global hold it lands on every non-released input). The settled-link guard is wired for real: `reconcileSpendObservation` takes the existing spender's context, `isSpent` is monotonic on both channels, and a stamped, unlinked coin the wallet re-delivers unspent follows the wallet — refusing would lock a real coin out of every future restore after a reorg of its winner. The collector runs once per round, from `endChangeset`, after every account slice and every sweep, on the boundary the round's own writes left on the wallet row. Before, it ran in the header — before this round's `utxos_added` — so a funding output arriving in the round that completed the boundary found its tombstone already deleted and landed unspent. The store query now selects tombstones only, with an index on `[walletId, isSweptTombstone]` in the V4 stage. Also: out-of-round save failures roll the context back and log when the round index has to fall back; the seven `print` sites go through `SDKLogger`; `hashData(_:)`, one tombstone-scan helper and one wallet-lookup preamble replace the inline copies; the seven collapsed newlines are restored; the V4 columns are documented under V4 and the frozen-models header describes what is actually frozen; the migration test asserts V3 and V4 name the same entity set. Tests: `SweptTransactionPersistTests` 38 → 50, eighteen of them red on the pre-fix handler; `swift test` 461 passed. --- .../Persistence/DashModelContainer.swift | 51 +- .../Persistence/DashSchemaFrozenModels.swift | 60 +- .../Models/PersistentPendingInput.swift | 31 +- .../Models/PersistentTransaction.swift | 17 - .../Persistence/Models/PersistentTxo.swift | 27 +- .../PlatformWalletPersistenceHandler.swift | 1884 +++++++++-------- .../DashModelMigrationTests.swift | 49 +- .../SweptTransactionPersistTests.swift | 1090 +++++++--- 8 files changed, 1976 insertions(+), 1233 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index a9ebbba890f..eaa0ff44317 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -309,24 +309,6 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// migrate with a nil `documentIdBase58`, which is the documented /// "no marketplace state tracked" signal — the next marketplace /// sync pass fills them in. -/// - `PersistentTxo` gained the optional `supersededByTxid`, and -/// `PersistentPendingInput` gained `isSweptTombstone` (defaulted -/// `false`). Together they let a sweep's claim on an input whose -/// funding TXO hasn't arrived yet survive the loser transaction's -/// deletion — previously that claim lived only on the doomed row's -/// `PersistentPendingInput`, which cascades away with it. Both -/// additive with defaults ⇒ lightweight migration; existing rows -/// migrate as ordinary (non-tombstone, non-superseded) entries. -/// - `PersistentPendingInput` gained the optional `winnerMinedHeight` -/// (a block-context sweep tombstone's finality stamp — the winner's -/// own mined height) and `PersistentWallet` gained the optional -/// `lastAppliedChainLockHeight` (the numeric chainlock watermark -/// delivered by `on_persist_wallet_changeset_chain_lock_height_fn`, -/// stored monotonic-max). Together they drive the bounded tombstone -/// lifetime: a tombstone is collected exactly when -/// `min(chainlockHeight, syncedHeight)` reaches its stamp. Both -/// optional ⇒ lightweight migration; pre-existing rows read as -/// unstamped (held forever) over a wallet with no boundary yet. /// Each of those is a destructive change to a unique-attribute /// column or to relationship topology, so any pre-existing dev /// store will fail to open and get rebuilt from scratch on next @@ -377,17 +359,32 @@ public enum DashSchemaV3: VersionedSchema { } } -/// Version 4 adds the sweep columns: `isGloballySwept` on -/// `PersistentTransaction`, `supersededByTxid` on `PersistentTxo`, -/// `isSweptTombstone` / `winnerMinedHeight` on `PersistentPendingInput`, -/// and `lastAppliedChainLockHeight` on `PersistentWallet`. Every one is -/// additive with a default or optional, so a lightweight migration -/// preserves each existing row: transactions read as not swept, TXOs as -/// unsuperseded, pending inputs as ordinary unstamped claims, and a wallet -/// as having no chainlock boundary yet. +/// Version 4 adds the sweep columns, on the same entity set as V3: +/// - `PersistentTxo.supersededByTxid` (optional) and +/// `PersistentPendingInput.isSweptTombstone` (defaulted `false`). +/// Together they let a sweep's claim on an input whose funding TXO +/// hasn't arrived yet survive the loser transaction's deletion — +/// previously that claim lived only on the doomed row's +/// `PersistentPendingInput`, which cascades away with it. Existing +/// rows migrate as ordinary (non-tombstone, non-superseded) entries. +/// - `PersistentPendingInput.winnerMinedHeight` (optional — a +/// block-context sweep tombstone's finality stamp, the winner's own +/// mined height) and `PersistentWallet.lastAppliedChainLockHeight` +/// (optional — the numeric chainlock watermark delivered by +/// `on_persist_wallet_changeset_chain_lock_height_fn`, stored +/// monotonic-max). Together they drive the bounded tombstone lifetime: +/// a tombstone is collected exactly when +/// `min(chainlockHeight, syncedHeight)` reaches its stamp. +/// Pre-existing rows read as unstamped (held forever) over a wallet +/// with no boundary yet. +/// - The `(walletId, isSweptTombstone)` index on +/// `PersistentPendingInput`, serving the collector's tombstone-only +/// scan. +/// Every column is additive with a default or optional and the index is +/// additive, so a lightweight migration preserves each existing row. /// /// Registering it required freezing the whole relationship component those -/// four models sit in — see `DashSchemaFrozenModels.swift`. +/// three models sit in — see `DashSchemaFrozenModels.swift`. public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index b1417c2fa90..1b535ec001d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -25,21 +25,38 @@ import SwiftData // // ## Scope of the freeze // -// Only `PersistentAssetLock` is frozen today, because it is the only model -// this file's callers have changed since V2 shipped. Every other model in -// `DashSchemaV1` / `DashSchemaV2` is still referenced live and therefore -// still carries the same latent defect. Freezing them is a mechanical but -// wide change (34 models) and is deliberately left out of the change that -// introduced this file; when the next model gains a property, freeze that -// one here too and add the matching stage. +// Twenty-five of the 35 models are frozen here, in two groups: +// +// - `PersistentAssetLock`, frozen at its V2 shape (everything the live +// model has EXCEPT `recipientIsExternal`, which V3 added). Referenced by +// `DashSchemaV1.models` and `DashSchemaV2.models`; V3 and V4 reference +// the live type. +// - The 24 models of the relationship component that contains +// `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput` and +// `PersistentWallet`, frozen at their V3 shape (everything the live +// models had before V4's sweep columns). Referenced by V1, V2 and V3; +// V4 references the live types. The component travels as a whole +// because a frozen model must declare its relationships against frozen +// counterparts (an `inverse:` key path is typed on the destination +// model), and following those relationships in both directions closes +// over all 24 — while registering a frozen copy beside a live one for +// the SAME entity name is what a schema cannot express. +// +// The ten models outside the component (shielded storage, invitations, +// masternodes, the tracked-masternode registry, wallet-manager metadata, +// the platform-addresses sync state) are still referenced live by every +// version and still carry the latent defect described above. When the +// next change touches one of them, freeze it here too — and if it sits in +// a relationship component, freeze that component with it — then add a +// version and a stage. // // V1's own checksum has already drifted from what actually shipped as V1 // (see the `DashSchemaV1` doc comment: several models were changed in place // while V1 was the only registered version, and dev stores at V1 are -// knowingly expected to fail open and be rebuilt). The frozen copy below is -// therefore the shape as of the V2 release, shared by V1 and V2 — which is -// what makes V1 -> V2 continue to be "add `PersistentTrackedMasternode`" -// and nothing else, exactly as before. +// knowingly expected to fail open and be rebuilt). The asset-lock copy +// below is therefore the shape as of the V2 release, shared by V1 and V2 — +// which is what makes V1 -> V2 continue to be "add +// `PersistentTrackedMasternode`" and nothing else, exactly as before. extension DashSchemaV1 { /// `PersistentAssetLock` frozen at the shape it had when schema V2 @@ -103,23 +120,16 @@ extension DashSchemaV1 { // MARK: - The rest of the relationship component, frozen at the V3 shape // -// The four models the sweep persistence changes — `PersistentTransaction`, -// `PersistentTxo`, `PersistentPendingInput` and `PersistentWallet` — each -// gain a property, so each needs a frozen copy for the same reason -// `PersistentAssetLock` did. Freezing them alone is not possible: a frozen -// model must declare its relationships against frozen counterparts (an -// `inverse:` key path is typed on the destination model), and following -// those relationships in both directions closes over 24 of the 35 models. -// Registering a frozen copy beside a live one for the SAME entity name is -// what the schema cannot express, so the whole component travels together. +// The three models the sweep persistence changes — `PersistentTxo`, +// `PersistentPendingInput` and `PersistentWallet` — each gain a property +// (and `PersistentPendingInput` an index), so each needs a frozen copy for +// the same reason `PersistentAssetLock` did; `PersistentTransaction` is +// unchanged but sits in the same component. See the scope note at the top +// of this file for why the whole component travels together. // // These copies are the shape as of V3 — i.e. everything the live models had // before the sweep columns — and are shared by V1, V2 and V3, none of which -// changed any model in this component. The eleven models outside the -// component (shielded storage, invitations, masternodes, the asset-lock -// pair above, wallet-manager metadata) are still referenced live and still -// carry the latent defect this file exists to fix; freezing them is the -// same mechanical exercise, for whichever change next touches one. +// changed any model in this component. // // Do not edit these copies to match the live models. Every attribute, its // optionality, its default, each `@Attribute` marker, each `#Index` and diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index f340cf34b79..ff4261be818 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -39,17 +39,23 @@ import SwiftData /// upsert outright. @Model public final class PersistentPendingInput { - /// Two single-column indexes: + /// Three indexes: /// * `outpoint` — the per-outpoint reconciliation lookup that /// runs on every `upsertUtxo`. /// * `walletId` — per-wallet pending-input scans (cleanup when /// a wallet is removed, the storage explorer's network /// scope, "long-lived non-zero pending count" diagnostics). + /// * `(walletId, isSweptTombstone)` — the tombstone collector's + /// once-per-round scan (`collectFinalizedSweptTombstones`), + /// which must select tombstones only: the ordinary rows on this + /// table — one per foreign input of every incoming payment — + /// are never pruned, so a scan that materialised them would grow + /// with the wallet's history. /// /// SwiftData allows only a single `#Index` macro per model; /// passing multiple key-path arrays declares multiple separate /// indexes from one macro call. - #Index([\.outpoint], [\.walletId]) + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) public var outpoint: Data /// Position of this input in the spending transaction's input @@ -79,18 +85,23 @@ public final class PersistentPendingInput { public var createdAt: Date /// Set when `applySweptTransaction` repurposes this row as a durable - /// claim rather than an ordinary in-flight spend: the original - /// spending transaction turned out to be a loser, this input wasn't in - /// `released`, and the funding `PersistentTxo` still hasn't arrived to - /// hold the claim itself. `spendingTxid` is overwritten to the winner + /// claim rather than an ordinary in-flight spend — or writes it fresh + /// for a held input that had no row: the original spending transaction + /// turned out to be a loser, this input wasn't released, and the + /// funding `PersistentTxo` still hasn't arrived to hold the claim + /// itself. `spendingTxid` is overwritten to the winner /// (`superseded_by`) and `spendingTransaction` is detached so the row - /// survives the loser's cascade-delete. `upsertUtxo` checks this flag - /// on resolve: a tombstone forces `PersistentTxo.isSpent = true` + /// survives the loser's deletion. `upsertUtxo` checks this flag on + /// resolve: a tombstone forces `PersistentTxo.isSpent = true` /// unconditionally (a sweep's winner is already final, unlike an /// ordinary pending spend whose confirmation is still pending) and /// stamps `PersistentTxo.supersededByTxid` so the mark survives even - /// when the winner's own row never materializes. Defaulted `false` so - /// existing rows migrate as ordinary pending entries. + /// when the winner's own row never materializes — and nothing else: + /// the spender link and the vin index come only from an ordinary row, + /// never from a tombstone (its `inputIndex` is the loser's). Rows are + /// per (outpoint, spending txid, wallet); the drain prefers the + /// tombstone tagged with the wallet delivering the coin. Defaulted + /// `false` so existing rows migrate as ordinary pending entries. public var isSweptTombstone: Bool = false /// The WINNER'S own mined block height, stamped when a block-context diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 654ec7e5c9d..f0ecd0fce34 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -119,23 +119,6 @@ public final class PersistentTransaction { public var createdAt: Date public var lastUpdated: Date - /// Durable global exclusion for a swept loser. - /// - /// Set by `applySweptTransaction` in EVERY wallet's callback that - /// observes this row's sweep — not only the one whose deletion happens - /// to remove it. `store()` commits once per wallet, independently, so a - /// row `commit_batch` holds back for a second wallet's still-outstanding - /// claim cannot let that hold-back also postpone the parts of the sweep - /// that are true regardless of who else has weighed in: this flag is - /// what stays true the moment the first wallet's callback runs, so a - /// crash or rejection before any other wallet's callback arrives still - /// leaves the row excluded from every restore/enumeration path. `true` - /// means Rust has already proven the transaction can never confirm; - /// callers must treat the row as gone regardless of whether it still - /// physically exists (see `applySweptTransaction`'s doc for why the - /// physical delete is demoted to housekeeping once this is set). - public var isGloballySwept: Bool = false - /// Transaction outputs created by this transaction. /// /// Cascade-deletes the matching `PersistentTxo` rows when the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 0dae02f814b..f459cc30d09 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -96,15 +96,24 @@ public final class PersistentTxo { /// have a row of its own (it can pay only outside addresses), which is /// why the stamp is a bare txid rather than a relationship. /// - /// `upsertUtxo`'s recovery clear keys on it: a coin the wallet - /// re-delivers as unspent lifts `isSpent` only when both - /// `spendingTransaction` and this are nil — a rescan re-finds the - /// funding output precisely because it is blind to an unconfirmed - /// winner no block carries yet, so re-delivery cannot outrank the - /// sweep's verdict. Cleared only by the sweep release pass, when a - /// later sweep proves the coin came free after all; a pre-stamp row - /// (written before holds named their winner) still frees on - /// re-delivery. + /// What the stamp does on a materialised row: it keeps `isSpent` up on + /// the record and spend-emit channels (`reconcileSpendObservation` + /// treats a stamped row as spent whatever the arriving spender's + /// context — the sharp case is the winner's own record arriving + /// IS-locked, below in-block), and it names a claimant the sweep + /// release veto (`releaseIsVetoed`) checks against the named + /// transaction's stored bytes. It does NOT refuse a re-delivery: a + /// coin the wallet hands back as unspent through `utxos_added` follows + /// the wallet — `isSpent` and this stamp clear together (see + /// `upsertUtxo`), because the wallet knows this coin, so any + /// network-final spender of it is wallet-relevant by BIP158 prevout + /// matching and the wallet's own scan re-discovers the spend; refusing + /// would lock a real coin out forever after a reorg of the winner. The + /// hold that must survive a funding delivery is the never-materialised + /// one, carried by a `PersistentPendingInput` tombstone until the + /// delivery drains it into this stamp. Also cleared by the sweep + /// release pass, when a later sweep proves the coin came free after + /// all. public var supersededByTxid: Data? /// Position of this output within `spendingTransaction.input` diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c3c0fea5c7c..49e97964602 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -79,15 +79,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data, transaction: PersistentTransaction ) -> Bool { - // A globally-swept row is never "owned" for restore purposes, even - // though `involvedAccounts` below can still name this wallet — that - // membership was recorded before the transaction lost the sweep and - // `applySweptTransaction` does not (and should not) rewrite history - // by removing it. Excluding here, at the single call site every - // restore-to-Rust enumeration goes through (`walletCoreTxids`), is - // what keeps a row `isGloballySwept` has already proven dead from - // being handed back as this wallet's transaction after a restart. - guard !transaction.isGloballySwept else { return false } if transaction.involvedAccounts.contains(where: { let wallet: PersistentWallet? = $0.wallet return wallet?.walletId == walletId @@ -211,16 +202,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var txosByOutpoint: [Data: PersistentTxo] = [:] /// `PersistentPendingInput.outpoint` is deliberately not unique /// (re-org / double-spend can stack rows on one outpoint — see - /// the model), so this holds only the round's staged inserts - /// per key; saved rows come from the store fetch each time. - /// Pending rows need no read-through registration because - /// nothing mutates their attributes before the sweep pass, and - /// sweeps run last in the round (see `pendingInputRows`). + /// the model), so a key maps to the full set: this round's + /// staged inserts, plus — once the key is in + /// `pendingInputsFetched` — the saved rows `pendingInputRows` + /// resolved on first touch. Read-through for the same reason as + /// the single-object maps: the sweep phase re-points these rows + /// by scalar, and a later store-only fetch of the key would + /// refresh that away. var pendingInputsByOutpoint: [Data: [PersistentPendingInput]] = [:] + /// Keys whose saved rows have been fetched this round — a key + /// present here answers from `pendingInputsByOutpoint` alone. + var pendingInputsFetched: Set = [] var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] } private var roundIndex: ChangesetRoundIndex? + /// Set when the open round advanced either half of the tombstone + /// finality boundary — `syncedHeight` through the changeset callback or + /// the numeric chainlock height through its extension slot — and read + /// by `endChangeset`, which then runs `collectFinalizedSweptTombstones` + /// once, after every slice and every sweep of the round, before the + /// round's single save. Cleared by `beginChangeset` and `endChangeset`. + /// Confined to `serialQueue` like all other mutable handler state. + private var roundAdvancedFinalityBoundary = false + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -282,7 +287,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `save()`, or saves itself when `inChangeset` is clear. self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) - self.trackedMasternodeContext.autosaveEnabled = false } + self.trackedMasternodeContext.autosaveEnabled = false + } /// Synchronously run `body` on `serialQueue`. /// @@ -322,7 +328,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Best-effort save used by callback helpers that may also be invoked /// outside a Rust changeset. The legacy behavior remains non-throwing, - /// but failures are no longer invisible in exported diagnostics. + /// but failures are no longer invisible in exported diagnostics, and a + /// failed save rolls the context back: with autosave off, staged rows a + /// failed save left behind would otherwise ride the next round's single + /// `save()` — failing that round for a reason unrelated to its content + /// — and make `beginChangeset` run the round unindexed. private func saveBackgroundContextIfNeeded( operation: String, walletId: Data? = nil @@ -344,6 +354,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { fields: fields, error: error ) + backgroundContext.rollback() } } @@ -1081,6 +1092,42 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // MARK: - Wallet Changeset (transactions, utxos, accounts, balance, chain) + /// Outcome of the wallet gate every round callback carrying a watermark + /// or subtractive write starts with (`persistWalletChangeset`, + /// `persistWalletChangesetChainLockHeight`, `persistWalletChangesetSweeps`). + private enum RoundWalletLookup { + case found(PersistentWallet) + /// A stale post-deletion callback: there is nothing left to write + /// to, and that is not a failure. + case absent + /// The fetch threw. Reporting success would let Rust discard a sweep + /// — or advance a watermark past one — that never landed, so the + /// caller fails the round. + case failed + } + + /// The shared wallet-lookup preamble: one place decides how a thrown + /// fetch and a missing row differ, and logs the former with the + /// callback that hit it. + private func roundWalletLookup(walletId: Data, callback: String) -> RoundWalletLookup { + do { + guard let wallet = try fetchWalletRecord(walletId: walletId) else { return .absent } + return .found(wallet) + } catch { + SDKLogger.event( + "persistence_round_wallet_lookup_failed", + category: .persistence, + severity: .error, + fields: [ + "callback": .publicText(callback), + "wallet_reference": .reference(walletId), + ], + error: error + ) + return .failed + } + } + /// Apply a full `WalletChangeSetFFI` to SwiftData. /// /// Called from the Rust persister when an SPV round produces core- @@ -1099,28 +1146,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { changeset: UnsafePointer ) -> Bool { onQueue { - // A stale post-deletion callback is not a failure — there is - // simply nothing left to write to. A fetch that *throws* is a - // different matter: reporting success would let Rust discard the - // round's sweep, and a later callback could then persist a height - // beyond a removal that never landed. - let wallet: PersistentWallet? - do { - wallet = try fetchWalletRecord(walletId: walletId) - } catch { - print( - "⚠️ persistWalletChangeset: wallet lookup failed: " - + "\(error.localizedDescription); failing the round" - ) - return false + let wallet: PersistentWallet + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset") { + case .failed: return false + case .absent: return true + case .found(let row): wallet = row } - guard let wallet else { return true } let cs = changeset.pointee - // Chain update. + // Chain update. A synced-height write advances one half of the + // tombstone finality boundary, so the round's collector (see + // `endChangeset`) is armed. if cs.has_chain { if cs.chain.has_synced_height { wallet.syncedHeight = cs.chain.synced_height + roundAdvancedFinalityBoundary = true } wallet.lastUpdated = Date() } @@ -1134,6 +1174,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // wallet-side mirror, not a duplicate of SPV state. // Pre-feature rows / wallets that have never observed a // ChainLock carry `null` from Rust and stay `nil` here. + // The NUMERIC height arrives separately, through + // `persistWalletChangesetChainLockHeight` — these bytes are + // opaque here and prove nothing about how far finality + // reaches. if cs.last_applied_chain_lock_bytes_len > 0, let clPtr = cs.last_applied_chain_lock_bytes { let bytes = Data( @@ -1144,31 +1188,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { wallet.lastUpdated = Date() } - // Bounded tombstone lifetime (the SwiftData mirror of the SQLite - // store's `collect_finalized_tombstones`): once the finality - // boundary reaches a swept tombstone's winner-height stamp, the - // row has provably never drained — a genuine claim's rows are - // deleted by the drain in `upsertUtxo` when its funding TXO - // lands — so what remains is junk from foreign inputs of swept - // incoming payments, previously permanent and attacker-growable. - // The boundary is upstream's verbatim: - // `min(chainlockHeight, syncedHeight)` — the chainlock half - // proves the winner's spend final, the synced half certifies - // BIP158 filter coverage of every block that could have carried - // the funding output. The chainlock height arrives NUMERICALLY - // through the extension's chain-lock-height slot (the bincode - // bytes above are opaque here); until one has been stored no - // finality boundary exists and nothing may be collected — - // present chainlock BYTES prove nothing about how far finality - // reaches, and synced-height progress alone is not finality. - if cs.has_chain, cs.chain.has_synced_height, cs.chain.synced_height > 0, - let clHeight = wallet.lastAppliedChainLockHeight { - collectFinalizedSweptTombstones( - walletId: walletId, - boundary: min(clHeight, cs.chain.synced_height) - ) - } - // Balance delta — Rust still emits per-round deltas, but the // PersistentWallet `balance*` fields they used to update were // removed (canonical source is now the in-memory account @@ -1189,11 +1208,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Swept transactions no longer ride this struct: they arrive // through `persistWalletChangesetSweeps(walletId:sweeps:count:)` - // below, fired by Rust immediately after this callback in the - // same round. The struct crosses the C ABI by bare pointer, so a - // field appended to it cannot be proven present to a consumer - // built after a producer — the extension callback's negotiated - // `struct_size` is what carries that proof instead. + // below, fired by Rust after this callback (and after the + // chainlock-height slot) in the same round. The struct crosses + // the C ABI by bare pointer, so a field appended to it cannot be + // proven present to a consumer built after a producer — the + // extension callback's negotiated `struct_size` is what carries + // that proof instead. // No save() — bracketed by changesetBegin/End. return true @@ -1201,110 +1221,130 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } /// Delete this wallet's swept tombstones whose winner-height stamp the - /// finality boundary has reached: `winnerMinedHeight <= boundary`, - /// where the caller computes `boundary = min(chainlockHeight, - /// syncedHeight)` — upstream key-wallet's - /// `prune_finalized_observed_spends` condition verbatim, and the - /// SQLite store's `collect_finalized_tombstones`. No observation-age - /// margin: the stamp IS the winner's own mined height, carried on the - /// sweep event, 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 tombstone - /// unstamped, because such a winner has no mining deadline and no - /// watermark can prove its inputs' funding delivered-or-never — an - /// unstamped row is a live hold, resolved only by the funding TXO - /// draining it, a later block-context sweep stamping it, or a release - /// deleting it. See the property doc on + /// finality boundary has reached: `winnerMinedHeight <= boundary`, with + /// `boundary = min(chainlockHeight, syncedHeight)` read off the STORED + /// wallet row — upstream key-wallet's `prune_finalized_observed_spends` + /// condition verbatim, and the SQLite store's + /// `collect_finalized_tombstones`. No observation-age margin: the stamp + /// IS the winner's own mined height, carried on the sweep event, 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 tombstone unstamped, because such a winner + /// has no mining deadline and no watermark can prove its inputs' + /// funding delivered-or-never — an unstamped row is a live hold, + /// resolved only by the funding TXO draining it, a later block-context + /// sweep stamping it, or a release deleting it. See the property doc on /// `PersistentPendingInput.winnerMinedHeight`. /// + /// Runs ONCE per round, from `endChangeset`, after every account slice + /// and after the sweeps — the same position as the reference store's + /// collector. Running it earlier in the round was a funds bug: the + /// changeset callback wrote `syncedHeight` and collected BEFORE the + /// same round's `utxos_added`, so a funding output that should have + /// drained its tombstone into a durable `supersededByTxid` hold found + /// the tombstone already gone and landed unspent. `syncedHeight == 0` + /// or a missing numeric chainlock height means one half of the + /// boundary is unknown — nothing can be proven final, and nothing is + /// collected. + /// /// Housekeeping, not correctness: a pass that cannot run self-heals on /// the next boundary-carrying round, so a fetch failure logs and /// returns instead of failing the round the way the sweep path must. - private func collectFinalizedSweptTombstones(walletId: Data, boundary: UInt32) { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - // Same pending-changes + in-memory-filter pattern as the sweep - // path's tombstone scan: rows tombstoned earlier in this round - // exist only as staged state, and `isSweptTombstone` is mutable, so - // a store-side predicate on it would test stale saved values. - descriptor.includePendingChanges = true - let rows: [PersistentPendingInput] + private func collectFinalizedSweptTombstones(walletId: Data) { + guard let wallet = findWalletRecord(walletId: walletId), + wallet.syncedHeight > 0, + let chainLockHeight = wallet.lastAppliedChainLockHeight + else { return } + let boundary = min(chainLockHeight, wallet.syncedHeight) + let tombstones: [PersistentPendingInput] do { - rows = try backgroundContext.fetch(descriptor) + tombstones = try fetchSweptTombstones(walletId: walletId) } catch { - print( - "⚠️ collectFinalizedSweptTombstones: scan failed: " - + "\(error.localizedDescription); skipping this pass" + SDKLogger.event( + "persistence_tombstone_collection_failed", + category: .persistence, + severity: .warning, + fields: ["wallet_reference": .reference(walletId)], + error: error ) return } - for pending in rows where pending.isSweptTombstone && !pending.isDeleted { + for tombstone in tombstones { // A nil stamp is deliberately NOT back-filled. The unmined // InstantSend sweep path produces one on purpose (the writer - // below maps a missing winner height to nil), so these rows - // are live holds, not stragglers: they must stay outside this - // height collector until the funding materialises, a later + // maps a missing winner height to nil), so these rows are live + // holds, not stragglers: they must stay outside this height + // collector until the funding materialises, a later // block-context sweep stamps them, or an authoritative release // deletes them. Stamping one here would convert "no proof of // finality" into a fabricated horizon. - guard let stamp = pending.winnerMinedHeight else { continue } - if stamp <= boundary { - backgroundContext.delete(pending) - } + guard let stamp = tombstone.winnerMinedHeight, stamp <= boundary else { continue } + backgroundContext.delete(tombstone) } } + /// The live swept tombstones — `isSweptTombstone == true` — of one + /// wallet, or of every wallet when `walletId` is nil. The one + /// tombstone-scan helper, shared by the collector (wallet-scoped) and + /// the sweep phase (global: the hold is global, see + /// `applySweptTransaction`). + /// + /// The predicate selects tombstones only, so ordinary pending rows — + /// one per foreign input of every incoming payment, never pruned — are + /// never materialised; the `[walletId, isSweptTombstone]` index on the + /// model serves the store half. Pending changes stay ON so a row + /// tombstoned earlier in this round (staged, unsaved) is found with its + /// live values: the in-memory half of the fetch evaluates the predicate + /// over this round's pending `PersistentPendingInput` objects only, and + /// a pending-changes fetch never refreshes an object away from its + /// staged state (see `roundIndex`). + private func fetchSweptTombstones(walletId: Data?) throws -> [PersistentPendingInput] { + var descriptor: FetchDescriptor + if let walletId { + descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.isSweptTombstone == true } + ) + } else { + descriptor = FetchDescriptor(predicate: #Predicate { $0.isSweptTombstone == true }) + } + descriptor.includePendingChanges = true + return try backgroundContext.fetch(descriptor).filter { !$0.isDeleted } + } + /// Extension entry for the round's NUMERIC chainlock height — the /// same watermark whose bincode blob rides /// `WalletChangeSetFFI.last_applied_chain_lock_bytes` (still stored, /// for the Rust-side metadata roundtrip), delivered separately because /// that blob is opaque here and the tombstone collection boundary /// needs the number. Fired inside the round's begin/end bracket, after - /// the changeset callback, only when the round advanced the chainlock - /// watermark. + /// the changeset callback and before the sweeps, on every round whose + /// changeset carries a chainlock. /// /// Stores monotonic-max (chain locks only move forward; a late or - /// re-emitted lower height must not walk the boundary backwards), - /// then runs the tombstone collector with the completed boundary - /// `min(chainlockHeight, syncedHeight)` — the freshly known chainlock - /// half is what can newly prove a stamp final, so waiting for the next - /// height-carrying changeset would hold collectible junk for no - /// reason. Same fail-the-round contract as every per-kind callback: a - /// throwing wallet lookup returns `false` so Rust does not treat the - /// round as durable. + /// re-emitted lower height must not walk the boundary backwards). A + /// height that actually advanced arms the round's collector — see + /// `endChangeset` — which then runs with the completed boundary + /// `min(chainlockHeight, syncedHeight)`. Same fail-the-round contract + /// as every per-kind callback: a throwing wallet lookup returns `false` + /// so Rust does not treat the round as durable. @discardableResult func persistWalletChangesetChainLockHeight( walletId: Data, height: UInt32 ) -> Bool { onQueue { - let wallet: PersistentWallet? - do { - wallet = try fetchWalletRecord(walletId: walletId) - } catch { - print( - "⚠️ persistWalletChangesetChainLockHeight: wallet lookup failed: " - + "\(error.localizedDescription); failing the round" - ) - return false + let wallet: PersistentWallet + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_chain_lock_height") { + case .failed: return false + case .absent: return true + case .found(let row): wallet = row } - guard let wallet else { return true } let effective = max(wallet.lastAppliedChainLockHeight ?? 0, height) if wallet.lastAppliedChainLockHeight != effective { wallet.lastAppliedChainLockHeight = effective wallet.lastUpdated = Date() - } - - // `syncedHeight == 0` means no filter coverage is certified at - // all — the boundary's synced half is missing, so nothing can - // be proven final yet. - if wallet.syncedHeight > 0 { - collectFinalizedSweptTombstones( - walletId: walletId, - boundary: min(effective, wallet.syncedHeight) - ) + roundAdvancedFinalityBoundary = true } // No save() — bracketed by changesetBegin/End. @@ -1312,14 +1352,32 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Per-round state the sweep phase threads through its helpers: the + /// round-wide swept set and a memo of decoded input sets, so a stored + /// transaction's bytes are decoded at most once per round. + private struct SweepRound { + /// Every txid swept by ANY batch of this round — the co-swept + /// predicate (an input funded by a transaction that is itself + /// swept this round is a dead parent's output: deleted, never + /// tombstoned or released) and the release veto's "not swept this + /// round" clause are both evaluated against the whole round, not + /// the batch at hand: upstream's descendant closure sweeps parent + /// and child together, but not necessarily in one batch. + let sweptTxids: Set + /// `txid → input outpoints`, `nil` for a row whose bytes do not + /// decode. Filled lazily by `decodedInputOutpoints(of:)`. + var decodedInputs: [Data: [Data]?] = [:] + } + /// Apply a round's sweep batches — the one subtractive part of the /// changeset path, delivered through the size-negotiated /// `PersistenceCallbacksExtension` slot rather than as a field on /// `WalletChangeSetFFI` (see `persistWalletChangeset` for why). Rust - /// fires this right after that callback within the same - /// begin/end round, so a wallet-relevant winner riding in the round has - /// its claim on the shared inputs already recorded when the removal here - /// decides which links are left pointing at a dead transaction. + /// fires this after that callback (and after the chainlock-height + /// slot) within the same begin/end round, so a wallet-relevant winner + /// riding in the round has its claim on the shared inputs already + /// recorded when the removal here decides which links point at a dead + /// transaction. /// /// Returns `false` to fail the round, same contract as /// `persistWalletChangeset`: a deletion that silently didn't happen @@ -1332,43 +1390,53 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { count: UInt ) -> Bool { onQueue { - // Same wallet gate as `persistWalletChangeset`: a stale - // post-deletion callback has nothing left to write to, but a - // lookup that throws must fail the round rather than let Rust - // discard a sweep that never landed. - let wallet: PersistentWallet? - do { - wallet = try fetchWalletRecord(walletId: walletId) - } catch { - print( - "⚠️ persistWalletChangesetSweeps: wallet lookup failed: " - + "\(error.localizedDescription); failing the round" - ) - return false + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_sweeps") { + case .failed: return false + case .absent: return true + case .found: break } - guard wallet != nil else { return true } guard count > 0, let sweepsPtr = sweeps else { return true } - // The funding txids this round removes, across every batch — - // the same changeset-wide set the SQLite co-swept rule keys - // on. A pending claim whose outpoint is funded by a co-swept - // loser is a claim on 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 the claim - // is neither released nor legitimate to hold. Tombstoning it - // would wedge the parent's chainlocked reinstatement forever - // (the re-delivered funding output drains into the - // tombstone-outranks pick, `supersededByTxid` pins the hold, - // and the recovery clear refuses stamped rows). - var coSwept = Set() + // The txids this round removes, across every batch — see + // `SweepRound.sweptTxids`. + var sweptTxids = Set() for batchIndex in 0.. 0, let txidsPtr = batch.txids else { continue } for i in 0.. 0, let txidsPtr = batch.txids { - // This wallet's detached tombstones, fetched ONCE per - // batch and grouped by the live `spendingTxid` each - // loser is looked up under. The per-loser form of this - // fetch paid the pending-changes tax — an in-memory - // predicate pass over every unsaved insert of the - // entity — once per swept txid, and a single - // network-derived sweep can carry many losers into the - // same round as thousands of freshly staged records. - // Pending changes stay ON (rows tombstoned earlier in - // this round exist only as staged state), the predicate - // names only the immutable `walletId`, and the mutable - // halves (`isSweptTombstone`, `spendingTxid`) are read - // off the live objects — a store-side predicate on a - // mutable column would test stale saved values. - // Rebuilt per batch, not per round: an earlier batch's - // retargets must be visible to a later batch sweeping - // that batch's winner. Within one batch no rebuild is - // needed — rows retarget to the batch's own winner, and - // upstream never lists a batch's winner among its own - // losers. - var tombstonesBySpender: [Data: [PersistentPendingInput]] = [:] - do { - var pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId } - ) - pendingDescriptor.includePendingChanges = true - for pending in try backgroundContext.fetch(pendingDescriptor) - where pending.isSweptTombstone && !pending.isDeleted { - tombstonesBySpender[pending.spendingTxid, default: []] - .append(pending) - } - } catch { - print( - "⚠️ persistWalletChangesetSweeps: tombstone scan failed: " - + "\(error.localizedDescription); failing the round" - ) - return false - } - for i in 0..( - predicate: #Predicate { released.contains($0.outpoint) } - ) - rows = try backgroundContext.fetch(releasedDescriptor) - } catch { - // Same contract as the loser loop: a release - // silently skipped would report a removal durable - // that never fully happened. - print( - "⚠️ persistWalletChangesetSweeps: release lookup failed: " - + "\(error.localizedDescription); failing the round" - ) - return false - } - for txo in rows where !txo.isDeleted { - guard Self.resolvedWalletId(of: txo) == walletId, - txo.spendingTransaction == nil else { continue } - txo.isSpent = false - txo.supersededByTxid = nil - txo.spendingInputIndex = nil - txo.lastUpdated = Date() - } - } + releaseByOutpoint( + walletId: walletId, + released: released, + round: &round + ) } // No save() — bracketed by changesetBegin/End. @@ -1545,7 +1527,34 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// Delete the mirror of a transaction the wallet swept. + /// The one structured event a failed sweep round leaves behind, so a + /// round Rust rolls back because a sweep fetch threw is correlatable — + /// `endChangeset`'s `persistence_changeset_rolled_back` carries the + /// wallet, this carries the cause. + private func logSweepFailure( + walletId: Data, + reason: String, + txid: Data? = nil, + error: Error + ) { + var fields: [String: SDKLogValue] = [ + "reason": .publicText(reason), + "wallet_reference": .reference(walletId), + ] + if let txid { + fields["txid"] = .reference(txid) + } + SDKLogger.event( + "persistence_sweep_failed", + category: .persistence, + severity: .error, + fields: fields, + error: error + ) + } + + /// Remove the mirror of a transaction the wallet swept and settle the + /// coins it claimed to spend. /// /// A swept transaction was a recorded spend that `supersededBy` provably /// beat to one of its inputs, so it can never confirm; Rust has already @@ -1553,66 +1562,59 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// re-create a balance the wallet has already corrected — this is the /// only removal the changeset path performs. /// - /// `isGloballySwept` is upstream's word as of this callback, not a - /// permanent verdict — the wallet's sweep state can itself be swept in - /// turn (IS-lock precedence: a chainlocked return beats the IS-locked - /// conflict that swept it originally), and `upsertTransaction` clears - /// this flag when a later record reinstates the txid. See that - /// method's doc comment for what reinstatement can and cannot undo. - /// - /// `commit_batch` calls `store()` once per wallet, and each of those - /// commits independently — there is no single transaction spanning every - /// wallet this sweep touches. That splits what has to be durable in - /// *this* callback from what can wait for a later one: the outputs this - /// row created are phantom money for every wallet, not just the one - /// running right now, and once Rust has proven the row dead no - /// restore/enumeration path may serve it to anyone — waiting for the - /// last wallet's callback to confirm that would leave it acknowledged-but- - /// resurrectable for however long the other wallets take to run, or - /// forever if one of them crashes first or never arrives. So the outputs - /// are deleted and `isGloballySwept` is set in EVERY callback that - /// reaches this function, idempotently, before anything wallet-scoped is - /// touched below. Physically removing `row` itself is different: that is - /// safe to defer, because `isGloballySwept` already makes the row inert - /// the moment the first callback sets it — see the ownership check near - /// the bottom for why the row is still worth reclaiming once nothing - /// points at it, now purely as housekeeping. - /// - /// The coins it claimed to *spend* split in two, and - /// `released` is the authority on which is which: - /// - /// - an input named there came free — no surviving transaction spends it; - /// - every other input it claimed was taken by the transaction that beat - /// it, and is gone. + /// **The hold is keyed by outpoint, not by link.** The loser's input + /// outpoints are decoded from its stored transaction bytes, and each + /// one is settled by key — `fetchTxoRow` / `pendingInputRows` — rather + /// than by walking `row.inputs`: a link the loser once held can already + /// have moved to the winner (recorded earlier in this very round) or to + /// any other surviving spender, and a link that is not the loser's is + /// never detached. Only a row whose bytes do not decode (a stub whose + /// record never arrived) falls back to the relationship-reachable + /// inputs, which is all such a row can name. /// - /// That distinction cannot be made here. Upstream only ever sweeps - /// *unconfirmed* records, and this store flips `isSpent` only for a - /// spender that reached a block, so a swept loser holds its inputs by - /// link alone with `isSpent == false`; deleting the row nils the link and - /// every one of those coins would fall back into the restore set, - /// including the consumed one. Nor can the winner's own row be consulted: - /// it need not be wallet-relevant at all, and even when it is, the sweep - /// can be committed in a round that arrives before the winner's record. - /// So upstream computes the split and names the freed coins, and this - /// applies it verbatim — the rest are held spent with no spender - /// linked, attributed to the winner via `supersededByTxid`, which keeps - /// them out of the restore set durably. + /// **The hold is global, the release is per wallet.** `store()` commits + /// once per wallet, independently, and a `PersistentTransaction` row is + /// shared across wallets — so the FIRST callback that sees the sweep + /// settles EVERY wallet's rows for the loser's inputs, then deletes the + /// row unconditionally (hold before delete, so the cascade on + /// `pendingInputs` and the nullify on `inputs` only ever clear links, + /// never a hold). What `released` — upstream's per-wallet verdict on + /// which coins came free — is allowed to touch is exactly this wallet's + /// own rows: a TXO resolved to this wallet, a pending row tagged with + /// it. Everything else the loser claimed is held for the winner, and the + /// owning wallet's own callback (earlier, later, or never) applies its + /// release by outpoint through `releaseByOutpoint`, against rows that no + /// longer need the loser's row to be findable. A later callback for the + /// same loser from another wallet therefore finds no row and still + /// applies its releases. /// - /// A held input can also have no `PersistentTxo` at all yet — the loser - /// was persisted before its own funding TXO was, so - /// `resolveInputOutpoint` parked the claim as a `PersistentPendingInput` - /// instead. `PersistentTransaction.pendingInputs` cascades on delete just - /// like `outputs`, so left alone that claim would vanish with `row` - /// below, and the funding TXO's own later `upsertUtxo` — even after a - /// restart — would have nothing to tell it the coin isn't really free. - /// A held pending input is therefore detached from `row` (so the cascade - /// no longer reaches it) and repointed at `supersededBy` before the - /// delete, flagged `isSweptTombstone` so `upsertUtxo` knows to keep the - /// coin spent — durably, via `PersistentTxo.supersededByTxid` — once the - /// funding TXO materializes rather than treating it as an ordinary - /// in-flight spend. A released pending input needs none of this: it is - /// left for the cascade, the same as a released materialized input needs - /// no special handling beyond the loop above. + /// Per input, not funded by a transaction this round also sweeps: + /// - TXO row present → `isSpent = true`, `supersededByTxid = winner` + /// (SQLite's `spent_in_txid`, mirrored: the attribution of a hold + /// whose winner may have no row here, what keeps `isSpent` up on the + /// record and spend-emit channels, and a claimant the release veto + /// checks). The spender link is detached ONLY if it points at the + /// loser. A released input of this wallet's + /// is instead freed — `isSpent`, the stamp and (once unlinked) the + /// vin index cleared — unless the release is vetoed (see + /// `releaseIsVetoed`), in which case the coin stays spent under the + /// claim that vetoed it. + /// - No TXO row → every pending row on the outpoint that names the + /// loser becomes a tombstone (link dropped, `spendingTxid = winner`, + /// `isSweptTombstone`, stamped with the batch's winner height when it + /// has one, otherwise keeping the stamp it had). One tombstone per + /// wallet per outpoint; duplicates are deleted. A released pending + /// row of this wallet's is deleted outright — never a released + /// tombstone. If the loser's claim on a held input has no pending row + /// for this wallet at all, the tombstone is created. + /// An input funded by a co-swept transaction is a dead parent's output + /// — nobody's coin, never in `released`: its TXO row and the loser's + /// pending rows on it are deleted, the mobile mirror of the SQLite + /// co-swept DELETE (a tombstone there would wedge the parent's + /// chainlocked reinstatement, and assuming the parent's own pass + /// deleted the row fails when the parent's record was lost). + /// The loser's own outputs are deleted for every wallet: a transaction + /// that never confirms funded nothing. /// /// The tombstone is written for EVERY sweep context; only the stamp /// differs. A BLOCK-CONTEXT sweep (`winnerMinedHeight` non-nil) stamps @@ -1630,258 +1632,397 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// wallet-irrelevant) winner leaves a record to rebuild it from. The /// tombstone is the hold's only durable carrier; dropping it lets a /// post-restart funding delivery credit a coin the network has - /// provably consumed. - /// - /// Nothing may collect an unstamped tombstone: an IS-locked winner has - /// no mining deadline (and the funding tx of an input it spends may - /// itself be IS-locked and unmined), so no watermark proves the - /// funding delivered-or-never. It resolves only through proof — the - /// funding TXO drains it (a wallet-owned claim always eventually - /// delivers via BIP158), a later block-context sweep re-stamps it into - /// the collectible set, or a release deletes it. The permanent residue - /// is foreign inputs of IS-context sweeps (a swept INCOMING payment - /// reaches this loop too, and ownership cannot gate it — nothing - /// anywhere can prove an input foreign, dashpay/rust-dashcore#968), - /// bounded by attack cost rather than collection: masternodes lock - /// first-seen, so every such row needs a conflicting payment delivered - /// straight to this wallet while withheld from the network, plus a - /// fee-paying IS-locked double-spend. + /// provably consumed. Nothing may collect an unstamped tombstone: an + /// IS-locked winner has no mining deadline (and the funding tx of an + /// input it spends may itself be IS-locked and unmined), so no + /// watermark proves the funding delivered-or-never. It resolves only + /// through proof — the funding TXO drains it, a later block-context + /// sweep re-stamps it into the collectible set, or a release deletes + /// it. The permanent residue is foreign inputs of IS-context sweeps (a + /// swept INCOMING payment reaches this loop too, and ownership cannot + /// gate it — nothing anywhere can prove an input foreign, + /// dashpay/rust-dashcore#968), bounded by attack cost rather than + /// collection: masternodes lock first-seen, so every such row needs a + /// conflicting payment delivered straight to this wallet while + /// withheld from the network, plus a fee-paying IS-locked double-spend. /// /// A tombstoned row can itself need to move again: `supersededBy` is /// only this round's winner, and nothing stops it from losing a later /// round to a further winner while its own funding TXO is still - /// unresolved. `row.pendingInputs` above cannot see that earlier - /// tombstone — it already detached from `spendingTransaction` (and - /// therefore from `row`) the moment it was first written — so it is - /// looked up the only other way it is still findable, by the scalar - /// `spendingTxid` it was repointed to, and carried the rest of the - /// chain below: deleted if this round finally frees its outpoint, - /// repointed at the new winner if not. - /// - /// `PersistentTransaction` is shared across wallets by design, but - /// `released` is not: upstream computes it per wallet - /// (`per_wallet_released_outpoints`), so this wallet's set says nothing - /// about an input a *different* wallet's coin claims on the same row. - /// The input decisions below are scoped to the inputs this wallet - /// actually owns; the physical row delete at the bottom is housekeeping - /// only now (see above) and runs once no other wallet's claim is still - /// attached to it. See the ownership check below for how "no other - /// wallet" is decided without an explicit cross-wallet coordination - /// point. + /// unresolved. Such a row is no longer linked to anything, so it is + /// found by the scalar `spendingTxid` it was re-pointed to — the + /// caller's once-per-round scan hands them over as `priorTombstones` — + /// and carried the rest of the chain: deleted if this round finally + /// frees its outpoint, re-pointed at the new winner if not. The stamp + /// moves ONLY when this sweep has a block context; a mempool-context + /// re-point keeps the existing stamp untouched (upstream never retracts + /// a block-context observed-spend entry for an unconfirmed conflict, + /// and collection at the retained height stays sound — the funding + /// output of a spent outpoint is mined at or below the height of ANY + /// block-context spender of it). Runs even with `row` nil: sweeps are + /// idempotent and can name a transaction this store never had, or one + /// another wallet's callback already deleted, and the tombstones are + /// still findable either way. /// - /// Fetch-free by design: the caller resolves `row` (through the - /// round-index-aware sweep lookup, failing the round if SwiftData - /// cannot answer) and hands over this loser's `priorTombstones` from - /// its once-per-batch scan. A `nil` row skips only the row-scoped work, - /// NOT the whole function. Sweeps are idempotent and can name a - /// transaction this store never had — but they can also name one this - /// store DID have and another wallet's callback already deleted. The - /// row is shared; the detached tombstones this wallet wrote against it - /// are not, and they are exactly the state that is still findable — by - /// scalar `spendingTxid` — after the row is gone. Skipping them would - /// strand them: this wallet's release decision would never reach a - /// tombstone that then marks its coin spent by a transaction that no - /// longer exists, and a held one could never follow the chain to a - /// further winner. So the wallet-scoped tombstone reconciliation at the - /// bottom runs either way. + /// Returns the tombstones now held under `supersededBy`, so the caller + /// can re-key its per-round map without another fetch. private func applySweptTransaction( walletId: Data, + loserTxid: Data, supersededBy: Data, released: Set, - coSwept: Set, row: PersistentTransaction?, priorTombstones: [PersistentPendingInput], - winnerMinedHeight: UInt32? - ) { + winnerMinedHeight: UInt32?, + round: inout SweepRound + ) -> [PersistentPendingInput] { + var retargeted: [PersistentPendingInput] = [] + if let row { - // The global half, done every time this function runs regardless - // of which wallet's callback it is or whether this row has been - // seen by a sweep before: delete the outputs this row created - // (they are nobody's coin, ever — a swept transaction cannot have - // funded anything) and mark the row excluded from restoration. - // Both are idempotent, so re-processing an already-flagged row (a - // second wallet's callback, or a re-emitted sweep) is a harmless - // no-op. for output in row.outputs { backgroundContext.delete(output) } - row.isGloballySwept = true - - // `released` is only ever true of the wallet that computed it, so - // an input this wallet does not own must be left exactly as it is - // — that wallet's own callback (delivered earlier, arriving - // later, or never coming at all) is the only thing allowed to - // decide it. Resolved through `resolvedWalletId(of:)` rather than - // a raw `walletId` compare, same reasoning as `loadWalletList`: - // the denormalized column reads empty on a row migrated before it - // existed, and comparing it raw would make every such coin look - // unowned and leave it untouched forever. - for txo in row.inputs where Self.resolvedWalletId(of: txo) == walletId { - let held = !released.contains(txo.outpoint) - txo.isSpent = held - // A held coin is attributed to the winner — the same stamp - // the pending-input drain writes, and the one SQLite - // records as `spent_in_txid`. Without it the hold has no - // durable carrier: `upsertUtxo`'s recovery clear frees a - // spent row with neither a spender nor a marker, and a - // restore-rescan re-delivers the funding output precisely - // because it is blind to an unconfirmed winner no block - // carries yet — resurrecting a provably consumed coin. - // Only an explicit release frees a stamped hold; a - // released coin's stale marker is likewise the release - // pass's business (the outpoint loop in the caller), not - // this one's. - if held { txo.supersededByTxid = supersededBy } + + let inputs: [(outpoint: Data, inputIndex: UInt32)] + if let decoded = decodedInputOutpoints(of: row, round: &round) { + inputs = decoded.enumerated().map { (outpoint: $1, inputIndex: UInt32($0)) } + } else { + // No decodable body: the relationship-reachable inputs are + // all this row can name. Logged, because a hold keyed by + // link can miss a coin the loser claimed whose link had + // already moved on. + SDKLogger.event( + "persistence_sweep_loser_undecodable", + category: .persistence, + severity: .warning, + fields: [ + "txid": .reference(loserTxid), + "wallet_reference": .reference(walletId), + ] + ) + var seen = Set() + var fallback: [(outpoint: Data, inputIndex: UInt32)] = [] + for txo in row.inputs where seen.insert(txo.outpoint).inserted { + fallback.append((txo.outpoint, txo.spendingInputIndex ?? 0)) + } + for pending in row.pendingInputs where seen.insert(pending.outpoint).inserted { + fallback.append((pending.outpoint, pending.inputIndex)) + } + inputs = fallback + } + + var settled = Set() + for input in inputs where settled.insert(input.outpoint).inserted { + retargeted.append(contentsOf: settleSweptInput( + outpoint: input.outpoint, + inputIndex: input.inputIndex, + walletId: walletId, + loserTxid: loserTxid, + supersededBy: supersededBy, + released: released, + winnerMinedHeight: winnerMinedHeight, + createTombstone: true, + round: &round + )) + } + + // Hold before delete: every claim on the loser's inputs is by now + // carried by a stamp or a detached tombstone, so the cascade on + // `pendingInputs` and the nullify on `inputs` only clear links. + backgroundContext.delete(row) + } + + // Chained-sweep continuation. A prior tombstone whose outpoint the + // loop above already settled has been re-pointed (its `spendingTxid` + // is no longer the loser's) and is skipped here; the rest are + // tombstones on outpoints the loser's stored inputs do not name — + // a claim an earlier sweep held for a survivor other than the + // winner — or every tombstone when `row` is nil. + for tombstone in priorTombstones + where !tombstone.isDeleted && tombstone.spendingTxid == loserTxid { + retargeted.append(contentsOf: settleSweptInput( + outpoint: tombstone.outpoint, + inputIndex: tombstone.inputIndex, + walletId: walletId, + loserTxid: loserTxid, + supersededBy: supersededBy, + released: released, + winnerMinedHeight: winnerMinedHeight, + createTombstone: false, + round: &round + )) + } + return retargeted + } + + /// Settle one input outpoint of a swept loser — the per-input rule + /// `applySweptTransaction` documents. `createTombstone` is true when + /// the outpoint comes from the loser's stored inputs (a held input with + /// no row of this wallet's gets one), false for the chained + /// continuation, where the existing tombstone is the claim. + private func settleSweptInput( + outpoint: Data, + inputIndex: UInt32, + walletId: Data, + loserTxid: Data, + supersededBy: Data, + released: Set, + winnerMinedHeight: UInt32?, + createTombstone: Bool, + round: inout SweepRound + ) -> [PersistentPendingInput] { + if round.sweptTxids.contains(outpoint.prefix(32)) { + // A co-swept parent's output: nobody's coin. + if let txo = fetchTxoRow(outpoint: outpoint) { + backgroundContext.delete(txo) + } + for pending in pendingInputRows(outpoint: outpoint) + where pending.spendingTxid == loserTxid { + backgroundContext.delete(pending) + } + return [] + } + + let txo = fetchTxoRow(outpoint: outpoint) + let pendingRows = pendingInputRows(outpoint: outpoint) + let releasedHere = released.contains(outpoint) + let vetoed = releasedHere && releaseIsVetoed( + outpoint: outpoint, + txo: txo, + pendingRows: pendingRows, + round: &round + ) + // This wallet's verdict on the coin. Another wallet's rows are held + // regardless — its own callback releases them. + let freedForThisWallet = releasedHere && !vetoed + + if let txo { + if txo.spendingTransaction?.txid == loserTxid { txo.spendingTransaction = nil - txo.lastUpdated = Date() + txo.spendingInputIndex = nil + } + let owned = Self.resolvedWalletId(of: txo) == walletId + if owned && freedForThisWallet { + txo.isSpent = false + txo.supersededByTxid = nil + } else if owned && releasedHere { + // Vetoed: the surviving claim that refused the release is + // the attribution — its link or stamp stays as it is. + txo.isSpent = true + } else { + txo.isSpent = true + txo.supersededByTxid = supersededBy } - for pending in row.pendingInputs where pending.walletId == walletId { - if coSwept.contains(pending.outpoint.prefix(32)) { - // A claim on a co-swept loser's own output: nobody's - // coin, never in `released`, and a tombstone here - // would outlive the parent's reinstatement — see the - // `coSwept` doc in the caller. Deleted with the batch, - // the mobile mirror of the SQLite co-swept DELETE. + txo.lastUpdated = Date() + } + + var retargeted: [PersistentPendingInput] = [] + var walletsHoldingTombstones = Set() + // Existing tombstones first: where a wallet holds both an earlier + // tombstone and an ordinary claim row on the coin, the tombstone — + // which may carry a block-context stamp a mempool re-point must + // keep — is the one that survives as the hold. + let orderedRows = pendingRows.filter(\.isSweptTombstone) + pendingRows.filter { !$0.isSweptTombstone } + for pending in orderedRows { + if pending.spendingTxid == loserTxid { + if pending.walletId == walletId && freedForThisWallet { backgroundContext.delete(pending) continue } - guard !released.contains(pending.outpoint) else { - // Deleted now rather than left for the row's cascade. - // Still attached it reads as this wallet's claim in the - // ownership check below, so a shared loser holding one - // released input per wallet deadlocks: each callback - // sees the other's row and declines the delete, and - // replaying either reaches the same stalemate. The - // global marker keeps the dead transaction from - // contributing funds regardless, but the row and both - // pending entries would otherwise be stored forever. + if !walletsHoldingTombstones.insert(pending.walletId).inserted { + // A second claim of the same wallet on the same coin — + // one tombstone carries it. backgroundContext.delete(pending) continue } - // Held in every winner context — `CORE_SWEEP_REMOVAL` - // requires each non-released input to keep a durable - // spend claim before its funding TXO materializes. A - // block-context winner stamps its mined height; an - // IS-locked, unmined winner leaves the stamp nil and the - // collector never touches the row — see the doc comment - // above for what resolves an unstamped hold. pending.spendingTransaction = nil pending.spendingTxid = supersededBy pending.isSweptTombstone = true - pending.winnerMinedHeight = winnerMinedHeight - } - - // Whatever is still attached to `row` after the scoping above - // belongs to a different wallet that has not weighed in yet — - // this wallet's own rows are all resolved by now, held ones - // detached and released ones deleted. Whichever callback finds nothing - // left over is the last one to run and performs the delete, so - // order stops mattering. A wallet whose callback never arrives at - // all just leaves the row behind with every other wallet's inputs - // already correctly decided — a leaked dead row, not a - // wrongly-spent coin, and a re-emitted sweep cleans it up. - // - // Nothing below is load-bearing for correctness anymore: `row` - // has no outputs and reads as `isGloballySwept` as of the block - // above, in every callback that reaches this point, regardless of - // whether this delete ever fires. This is reclaiming the - // now-inert row's storage, not finishing the sweep. Detached - // tombstones deliberately do not count as claims here — they no - // longer need the row (the scalar reconciliation below never - // touches it), so holding the delete for them would leak the row - // for nothing. Nor do this wallet's released pending inputs: - // they were deleted outright above precisely so they cannot - // stalemate another wallet's callback. - let otherWalletStillClaims = row.inputs.contains { txo in - txo.spendingTransaction != nil && Self.resolvedWalletId(of: txo) != walletId - } || row.pendingInputs.contains { pending in - pending.spendingTransaction != nil && pending.walletId != walletId - } - if !otherWalletStillClaims { - backgroundContext.delete(row) - } - } - - // Chained-sweep continuation: a pending row an EARLIER sweep already - // tombstoned to this loser (itself a sweep's winner until now) is no - // longer reachable through `row.pendingInputs` — see the doc comment - // above. The caller found it by the scalar `spendingTxid` it carries - // instead (its once-per-batch scan), scoped to this wallet for the - // same reason the live pending inputs above were: the tombstone - // names one specific wallet's coin, and only that wallet's own - // released set is the right authority to re-decide it. - // - // Deliberately runs even with `row` nil. A tombstone's very - // existence means `resolveInputOutpoint` declined to re-attach a - // pending row when the winner's own record arrived (the duplicate - // guard matches on `(outpoint, spendingTxid)` and a tombstone - // occupies that key), so a wallet-relevant winner can carry no - // attached claim of this wallet's at all — and another wallet's - // callback, seeing nothing attached, legitimately deletes the shared - // row before this wallet's callback ever runs. The tombstones are - // this wallet's private state; the row's fate says nothing about - // whether they still need their release applied or their chain - // continued. - for pending in priorTombstones where !pending.isDeleted { - if released.contains(pending.outpoint) || coSwept.contains(pending.outpoint.prefix(32)) - { - backgroundContext.delete(pending) - } else { - // Re-pointed to the new winner; the stamp moves ONLY when - // this sweep has a block context. A block-context re-point - // re-stamps to the NEW winner's mined height — the claim - // now belongs to a spend anchored at that block, and its - // collection horizon moves with it. A mempool-context - // re-point (`winnerMinedHeight` nil) keeps the existing - // stamp untouched: upstream never retracts a block-context - // observed-spend entry for an unconfirmed conflict, and - // collection at the retained 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. - pending.spendingTxid = supersededBy if let winnerMinedHeight { pending.winnerMinedHeight = winnerMinedHeight } + retargeted.append(pending) + } else if pending.isSweptTombstone && pending.spendingTxid == supersededBy { + walletsHoldingTombstones.insert(pending.walletId) + } + } + + if createTombstone, txo == nil, !freedForThisWallet, + !walletsHoldingTombstones.contains(walletId) { + // Held, unfunded, and no claim row of this wallet's to carry the + // hold: create it. `inputIndex` is the loser's vin, kept for + // display only — the drain never copies it onto the winner. + let tombstone = PersistentPendingInput( + outpoint: outpoint, + inputIndex: inputIndex, + spendingTxid: supersededBy, + spendingTransaction: nil, + walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.winnerMinedHeight = winnerMinedHeight + backgroundContext.insert(tombstone) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(tombstone) + retargeted.append(tombstone) + } + return retargeted + } + + /// Apply a batch's released set by OUTPOINT, after every loser in the + /// batch has been walked. `applySweptTransaction` reaches a claim only + /// through the loser's stored inputs, and a claim need not be + /// reachable that way: the loser's row can be gone (deleted by another + /// wallet's callback — each wallet's `store()` commits independently — + /// or lost to a fatal flush), leaving this wallet's hold on the coin + /// carried by a stamp or a tombstone with nothing to walk. Kotlin's + /// `releaseByOutpoint` and SQLite's outpoint-matched release pass both + /// cover exactly this; without it the release is silently dropped and + /// the coin stays spent forever. Idempotent against the loser loop for + /// the coins it already freed. + /// + /// Only this wallet's rows are touched — a released set is only ever + /// true of the wallet that computed it — and only when the release is + /// not vetoed (`releaseIsVetoed`). A released outpoint whose funding + /// transaction is swept this round is deleted whatever its shape, never + /// freed: a coin created by a dead transaction cannot be unspent, only + /// gone, and this pass runs regardless of whether the parent's own + /// record survived to delete it. + private func releaseByOutpoint( + walletId: Data, + released: Set, + round: inout SweepRound + ) { + for outpoint in released { + if round.sweptTxids.contains(outpoint.prefix(32)) { + if let txo = fetchTxoRow(outpoint: outpoint) { + backgroundContext.delete(txo) + } + for pending in pendingInputRows(outpoint: outpoint) + where pending.walletId == walletId { + backgroundContext.delete(pending) + } + continue + } + + let txo = fetchTxoRow(outpoint: outpoint) + let pendingRows = pendingInputRows(outpoint: outpoint) + if releaseIsVetoed( + outpoint: outpoint, + txo: txo, + pendingRows: pendingRows, + round: &round + ) { + if let txo, Self.resolvedWalletId(of: txo) == walletId, !txo.isSpent { + txo.isSpent = true + txo.lastUpdated = Date() + } + continue + } + + if let txo, Self.resolvedWalletId(of: txo) == walletId { + if let link = txo.spendingTransaction?.txid, round.sweptTxids.contains(link) { + txo.spendingTransaction = nil + txo.spendingInputIndex = nil + } + txo.isSpent = false + txo.supersededByTxid = nil + txo.lastUpdated = Date() + } + for pending in pendingRows + where pending.walletId == walletId + && (pending.isSweptTombstone || round.sweptTxids.contains(pending.spendingTxid)) { + backgroundContext.delete(pending) } } } - /// Sweep-phase transaction lookup: round-index first, store-only on a - /// miss, and the store hit is REGISTERED so the next lookup of the same - /// txid — a later batch of this round sweeping or chaining onto it — - /// returns the same object instead of re-fetching. That registration is - /// what makes the store-only miss path safe here: every transaction row - /// carrying staged state is already in the index (record upserts - /// register inserts and store hits, the drain registers - /// relationship-resolved winners, and this helper registers what it - /// fetches — covering `isGloballySwept` staged by an earlier batch), so - /// the refresh a store-only fetch performs can only land on a clean - /// row. The plain-fetch fallback with no active round keeps the old - /// behavior for unbracketed callers. + /// Whether a release of `outpoint` must be refused — the mirror of the + /// reference store's `surviving_stored_input_claims`. /// - /// This replaces a plain pending-changes fetch that paid an in-memory - /// predicate pass over every unsaved `PersistentTransaction` insert - /// once per swept txid — O(records × losers) in the folded rounds that - /// carry an initial scan's records and a large conflict sweep together, - /// all of it synchronous on the persistence queue before - /// `endChangeset`. - private func fetchSweepTransactionRow(txid: Data) throws -> PersistentTransaction? { - if let known = roundIndex?.transactionsByTxid[txid] { - return known.isDeleted ? nil : known + /// Upstream computes `released_outpoints` from its LIVE records, and a + /// network-final spender it has pruned (a chainlocked record kept as a + /// bare txid) or lost across a restart is one it can no longer see. A + /// release naming a coin such a record still claims is upstream + /// reporting its own amnesia — honouring it hands a provably consumed + /// coin back as spendable. This store keeps those rows, so the claim is + /// re-evaluated here: the release is vetoed when a stored transaction + /// with context at or above InstantSend-locked, not swept in this + /// round, still claims the coin — through the TXO's spender link or an + /// ordinary pending row (both written from that transaction's own input + /// list, so the claim holds by construction), or through the TXO's + /// `supersededByTxid` stamp or a tombstone's `spendingTxid`, where the + /// named transaction's stored bytes must actually name the outpoint + /// among its inputs (a stamp is the winner of a sweep whose LOSER spent + /// the coin; the winner need not have). A stamp whose transaction has + /// no stored row (a chained sweep already deleted it) does not veto. + /// Bare mempool claimants never veto: a mempool row is the one context + /// that can go stale forever, and letting it refuse an authoritative + /// release would strand the coin. + /// + /// Fails closed on a network-final claimant whose bytes do not decode: + /// this is the last guard against re-crediting a consumed coin. + private func releaseIsVetoed( + outpoint: Data, + txo: PersistentTxo?, + pendingRows: [PersistentPendingInput], + round: inout SweepRound + ) -> Bool { + var linkClaimants: [Data] = [] + var stampClaimants: [Data] = [] + if let txo { + if let link = txo.spendingTransaction?.txid { linkClaimants.append(link) } + if let stamp = txo.supersededByTxid { stampClaimants.append(stamp) } + } + for pending in pendingRows { + if pending.isSweptTombstone { + stampClaimants.append(pending.spendingTxid) + } else { + linkClaimants.append(pending.spendingTxid) + } } - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txid } - ) - descriptor.fetchLimit = 1 - descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] - if roundIndex != nil { descriptor.includePendingChanges = false } - guard let row = try backgroundContext.fetch(descriptor).first, !row.isDeleted else { - return nil + func survives(_ txid: Data) -> PersistentTransaction? { + guard !round.sweptTxids.contains(txid), + let claimant = fetchTransactionRow(txid: txid), + claimant.context >= TransactionContextType.instantSend.rawValue + else { return nil } + return claimant } - roundIndex?.transactionsByTxid[txid] = row - return row + for txid in linkClaimants where survives(txid) != nil { + return true + } + for txid in stampClaimants { + guard let claimant = survives(txid) else { continue } + guard let inputs = decodedInputOutpoints(of: claimant, round: &round) else { + return true + } + if inputs.contains(outpoint) { return true } + } + return false + } + + /// The input outpoints of a stored transaction, decoded from its bytes + /// through the key-wallet FFI decoder and memoised per round. `nil` + /// when the row carries no decodable body (a stub whose record never + /// arrived, or corrupt bytes). + private func decodedInputOutpoints( + of row: PersistentTransaction, + round: inout SweepRound + ) -> [Data]? { + if let memo = round.decodedInputs[row.txid] { + return memo + } + var result: [Data]? = nil + if !row.transactionData.isEmpty, + let decoded = try? TransactionDecoder.decode( + row.transactionData, + network: network ?? .testnet + ) { + result = decoded.inputs + .filter { $0.prevTxid.count == 32 } + .map { PersistentTxo.makeOutpoint(txid: $0.prevTxid, vout: $0.prevVout) } + } + round.decodedInputs[row.txid] = result + return result } /// Find or create the `PersistentWallet` row for `walletId`. @@ -2083,23 +2224,46 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // store-only fetch still returns rows whose delete is staged but // unsaved. // - // The sweep phase has its own fetch discipline. Loser rows resolve - // through `fetchSweepTransactionRow` — index-first, store-only on a - // miss, registering its hits so later batches reuse the object (see - // its doc for why the miss path cannot refresh staged state away). - // The per-batch tombstone scan and the by-outpoint release fetch stay - // on plain pending-changes fetches, ONCE per batch: they key on - // columns that MUTATE mid-round (`spendingTxid`, `isSweptTombstone`) - // or must see rows staged earlier in the round, which neither the - // index nor a store-only fetch can answer. The sweep pass also - // mutates TXO / pending rows through `row.inputs` / - // `row.pendingInputs` without any keyed lookup the index could - // observe — which is safe only because sweeps are applied LAST in - // `persistWalletChangeset`, so no store-only first-touch fetch can - // follow those mutations within the round and refresh them away. - - /// Resolve a `PersistentTransaction` by its unique `txid`. + // The sweep phase (`persistWalletChangesetSweeps`) resolves every row it + // mutates through these same helpers — loser rows through the throwing + // form of `fetchTransactionRow`, TXOs through `fetchTxoRow`, pending + // rows through `pendingInputRows` — so each object it touches is + // registered before it is written. Its two non-keyed reads are + // pending-changes fetches, which never refresh: the once-per-round + // tombstone scan (`fetchSweptTombstones`, whose outpoints are primed + // into the pending-row cache before any re-point — see + // `primePendingInputIndex`) and `endChangeset`'s collector. The + // invariant that keeps the whole round sound is therefore simply that + // no keyed store-only lookup ever refreshes an object carrying staged + // state: every first touch of a key goes through a helper that + // registers it, and a registered key never touches the store again in + // the round. Rows a relationship hands over without a keyed lookup — + // a displaced spender faulted in through `spendingTransaction`, a + // winner resolved through a pending row's link — are registered at + // that site for the same reason. The identities and dashpay-payments + // callbacks that Rust fires after the sweeps in the same bracket do + // not use these helpers and touch none of these entities. + + /// Resolve a `PersistentTransaction` by its unique `txid`. A failed + /// fetch reads as a miss — the additive hot path's contract. private func fetchTransactionRow(txid: Data) -> PersistentTransaction? { + try? fetchTransactionRow(txid: txid, prefetching: []) + } + + /// Throwing form of `fetchTransactionRow`, for the sweep phase: a + /// subtractive caller must tell "no such row" apart from a failed + /// fetch (reporting a deletion that never happened would let Rust + /// clear the sweep while the dead row survives), and it prefetches + /// the relationships it is about to walk. Index-first, store-only on + /// a miss, and the store hit is registered so the next lookup of the + /// same txid — a later batch of this round sweeping or chaining onto + /// it — returns the same object instead of re-fetching. The plain + /// pending-changes fetch with no active round keeps the old behaviour + /// for unbracketed callers. + private func fetchTransactionRow( + txid: Data, + prefetching: [PartialKeyPath] + ) throws -> PersistentTransaction? { if let known = roundIndex?.transactionsByTxid[txid] { return known.isDeleted ? nil : known } @@ -2107,8 +2271,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.txid == txid } ) descriptor.fetchLimit = 1 + if !prefetching.isEmpty { + descriptor.relationshipKeyPathsForPrefetching = prefetching + } if roundIndex != nil { descriptor.includePendingChanges = false } - guard let row = (try? backgroundContext.fetch(descriptor))?.first, + guard let row = try backgroundContext.fetch(descriptor).first, !row.isDeleted else { return nil } roundIndex?.transactionsByTxid[txid] = row return row @@ -2133,26 +2300,59 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Every live `PersistentPendingInput` row keyed on `outpoint` — /// saved rows plus this round's staged inserts. Non-unique key, so /// this returns the full set; callers filter further (by - /// `spendingTxid`, `createdAt`) on the live objects. Saved rows are - /// re-fetched store-only on every call rather than registered: no - /// path mutates a pending row's attributes before the sweep pass, - /// and sweeps run last (see the MARK comment), so the refetch - /// refresh never has unsaved changes to discard — deletions, the - /// one staged state these rows do accumulate mid-round, survive it. - /// De-duped by object identity as insurance against a save landing - /// mid-round (which would make a staged row visible to the store - /// fetch too). + /// `spendingTxid`, `walletId`, `createdAt`) on the live objects. + /// Read-through like the single-object maps: the first call for a key + /// in a round fetches the saved rows store-only, merges the staged + /// inserts (de-duped by object identity as insurance against a save + /// landing mid-round) and caches the set; every later call answers + /// from the cache. That registration is load-bearing, not an + /// optimisation — the sweep phase re-points and tombstones these rows + /// through this helper, and a second store-only fetch of the same key + /// would refresh those mutations away (see `roundIndex`). private func pendingInputRows(outpoint: Data) -> [PersistentPendingInput] { + guard roundIndex != nil else { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return ((try? backgroundContext.fetch(descriptor)) ?? []).filter { !$0.isDeleted } + } + if roundIndex!.pendingInputsFetched.contains(outpoint) { + return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } + } var descriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == outpoint } ) - if roundIndex != nil { descriptor.includePendingChanges = false } - var rows = (try? backgroundContext.fetch(descriptor)) ?? [] - if let staged = roundIndex?.pendingInputsByOutpoint[outpoint] { + descriptor.includePendingChanges = false + let saved = (try? backgroundContext.fetch(descriptor)) ?? [] + registerPendingInputRows(saved, outpoint: outpoint) + return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } + } + + /// Cache the saved pending rows of `outpoint` for the rest of the + /// round, merged with whatever the round already staged under the key. + private func registerPendingInputRows(_ saved: [PersistentPendingInput], outpoint: Data) { + guard roundIndex != nil else { return } + var rows = saved + if let staged = roundIndex!.pendingInputsByOutpoint[outpoint] { let seen = Set(rows.map { ObjectIdentifier($0) }) rows.append(contentsOf: staged.filter { !seen.contains(ObjectIdentifier($0)) }) } - return rows.filter { !$0.isDeleted } + roundIndex!.pendingInputsByOutpoint[outpoint] = rows + roundIndex!.pendingInputsFetched.insert(outpoint) + } + + /// Prime the pending-row cache for the outpoints of every tombstone + /// the sweep phase's once-per-round scan found: those rows are about to + /// be re-pointed by scalar, and the re-point must never be followed by + /// a first-touch store-only fetch of the same key (see `roundIndex`). + /// One keyed fetch per outpoint not yet touched this round — the + /// tombstone population is the bounded residue the collector keeps + /// small, and this runs only on a round that carries sweeps. + private func primePendingInputIndex(outpoints: Set) { + guard roundIndex != nil else { return } + for outpoint in outpoints where !roundIndex!.pendingInputsFetched.contains(outpoint) { + _ = pendingInputRows(outpoint: outpoint) + } } /// Resolve a `PersistentCoreAddress` by its unique `address`. @@ -2213,40 +2413,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) + // A record naming a txid a sweep already removed is upstream's + // newer word — `CoreChangeSet::merge` documents the reachable + // sequence: an unconfirmed transaction swept by an IS-locked + // conflict can return chainlocked and sweep that conflict in turn. + // The sweep deleted the row outright, so the reinstatement is an + // ordinary insert below; its outputs come back only through the + // `utxos_added` entries riding alongside the record, the same way + // any transaction's outputs ordinarily arrive. let existing = fetchTransactionRow(txid: txidData) - // A sweep is upstream's word at the moment it fired, but the - // wallet's sweep state is not monotonic: `CoreChangeSet::merge` - // documents the exact reachable sequence — an unconfirmed - // transaction swept by an IS-locked conflict can return - // chainlocked and sweep that conflict in turn, per key-wallet's - // own IS-lock precedence rules. When both events land in the same - // changeset the merge already strips the sweep before it gets - // here. Across separate rounds it can't: the earlier sweep is - // already durable (row tombstoned, possibly still physically - // present because another wallet's claim held the delete back — - // see `applySweptTransaction`), and this later record is the only - // signal this callback ever sees that the wallet reversed itself. - // Upstream never re-emits a live record for a txid it still - // considers dead, so a record naming an `isGloballySwept` txid is - // authoritative reinstatement, not a stale replay — treat it as - // upstream's newer word and let it win: clear the tombstone and - // fall through to the ordinary upsert below. - // - // What this does and does not restore: `context`/`blockHeight`, - // `involvedAccounts` membership, and this record's own input - // reconciliation all rebuild normally from here since they're - // driven straight off `tx` and `account`. The outputs - // `applySweptTransaction` physically deleted are a different - // story — they come back only if this round (or the one - // `upsertUtxo` processes moments later, before any other sweep - // callback can re-tombstone this row) also carries fresh - // `utxos_added` entries for them, the same way any transaction's - // outputs ordinarily arrive alongside its record. That is not - // this method's call to make: if Rust doesn't re-emit them, they - // cannot be reconstructed here from nothing. - if let existing, existing.isGloballySwept { - existing.isGloballySwept = false - } let record: PersistentTransaction if let existing { @@ -2369,39 +2544,38 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Whether a TXO's existing spender link must survive an arriving /// record that also claims the outpoint. The link is this store's spend - /// attribution, and the sweep release pass trusts it: the loser walk - /// detaches rows by their spender and the by-outpoint release frees - /// only detached rows (`spendingTransaction == nil`). A network-final - /// spender's link must therefore never be stolen by a later conflicting - /// record — upstream prunes a chainlocked spender to a bare txid (and - /// after a restart holds no history at all), so a loser reusing that - /// coin arrives with upstream unable to see the settled claim, and its - /// own eventual sweep names the coin released. With the link intact the - /// release is refused; with it stolen, the provably consumed coin reads - /// unspent after the next restart — a guaranteed double spend. + /// attribution, and the sweep release veto trusts it + /// (`releaseIsVetoed`): a network-final spender's link must never be + /// stolen by a later conflicting record — upstream prunes a chainlocked + /// spender to a bare txid (and after a restart holds no history at + /// all), so a loser reusing that coin arrives with upstream unable to + /// see the settled claim, and its own eventual sweep names the coin + /// released. With the link intact the release is refused; with it + /// stolen, the provably consumed coin reads unspent after the next + /// restart — a guaranteed double spend. /// - /// Kept when the existing spender has not been globally swept (a swept - /// spender's claims were resolved by its own sweep) and is - /// network-final: IS-locked, in-block, or chainlocked. Two mempool - /// spenders keep last-writer-wins, as before. The single sanctioned - /// takeover mirrors DIP-10 precedence: a chainlocked arrival may take - /// the coin from a spender that was only IS-locked — a plain in-block - /// arrival may not, exactly as upstream's sweep gate refuses a plain - /// block against a signed lock. A re-emit of the same spender is never - /// a takeover. + /// Kept when the existing spender is network-final: IS-locked, + /// in-block, or chainlocked. Two mempool spenders keep + /// last-writer-wins. The single sanctioned takeover mirrors DIP-10 + /// precedence: a chainlocked arrival may take the coin from a spender + /// that was only IS-locked — a plain in-block arrival may not, exactly + /// as upstream's sweep gate refuses a plain block against a signed + /// lock. A re-emit of the same spender is never a takeover. A swept + /// spender needs no exclusion here: its row is deleted by its sweep, + /// and a deleted row links nothing. private static func settledSpenderLinkIsKept( - existing: PersistentTransaction?, + existingTxid: Data, + existingContext: UInt32, newTxid: Data, newContext: UInt32 ) -> Bool { - guard let existing, existing.txid != newTxid else { return false } - guard !existing.isGloballySwept else { return false } - guard existing.context >= TransactionContextType.instantSend.rawValue else { + guard existingTxid != newTxid else { return false } + guard existingContext >= TransactionContextType.instantSend.rawValue else { return false } let chainlockOverIsLock = newContext >= TransactionContextType.inChainLockedBlock.rawValue - && existing.context == TransactionContextType.instantSend.rawValue + && existingContext == TransactionContextType.instantSend.rawValue return !chainlockOverIsLock } @@ -2419,36 +2593,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data ) { if let txo = fetchTxoRow(outpoint: outpoint) { - // `reconcileSpendObservation` is the single spend verdict — - // flag and link move together under its finality rule. One - // sweep-specific term rides on top of it: a TXO the sweep is - // holding (`supersededByTxid` set) was proved consumed by a - // winner this record knows nothing about, so the verdict may - // never downgrade it back into the restore set. The sharp case - // is the winner's own record arriving IS-locked — a context - // below in-block — for a coin the sweep already settled. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: txo.spendingTransaction?.txid, - currentIsSpent: txo.isSpent, - incoming: spendingTransaction, - incomingTxid: spendingTxid - ) - let resolvedIsSpent = verdict.isSpent || txo.supersededByTxid != nil - let linkageChanged = - txo.isSpent != resolvedIsSpent - || (verdict.adoptLink && txo.spendingTransaction?.txid != spendingTxid) - || (verdict.adoptLink && txo.spendingInputIndex != inputIndex) - if linkageChanged { - txo.isSpent = resolvedIsSpent - if verdict.adoptLink { if txo.spendingTransaction?.txid != spendingTxid { - txo.spendingTransaction = spendingTransaction - } - // Capture the canonical vin index so the detail - // view can render inputs in serialized order. - txo.spendingInputIndex = inputIndex - } - txo.lastUpdated = Date() - } + // Flag and link move together under `reconcileSpendObservation`'s + // rule — including the sweep term: a TXO a sweep is holding + // (`supersededByTxid` set) was proved consumed by a winner this + // record may know nothing about, and the verdict never lowers + // it. The sharp case is the winner's own record arriving + // IS-locked — a context below in-block — for a coin the sweep + // already settled: the link is adopted, the hold stays. + adoptSpendObservation(txo: txo, spender: spendingTransaction, inputIndex: inputIndex) // A pending entry from an earlier write is now stale — // resolved by this fetch. Drop it. removePendingInputs(for: outpoint) @@ -2459,17 +2611,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // path in `upsertUtxo` keep the table from growing // unbounded. // - // Skip the write if a pending row for this exact - // (outpoint, spending-tx) pair already exists — re-upserts - // of the same transaction would otherwise produce - // duplicate pending rows that all resolve to the same - // TXO, wasting fetch work on the resolve side. The - // `spendingTxid` half of the pair is compared in Swift on - // the live rows (it is mutable — `applySweptTransaction` - // rewrites it on tombstones — so it can't be a store-side - // predicate under the round index's store-only fetch). - let alreadyPending = pendingInputRows(outpoint: outpoint) - .contains { $0.spendingTxid == spendingTxid } + // Skip the write if an ordinary pending row for this exact + // (outpoint, spending-tx, wallet) triple already exists — + // re-upserts of the same transaction would otherwise produce + // duplicate pending rows that all resolve to the same TXO, + // wasting fetch work on the resolve side. The key includes + // the recording wallet: a second wallet recording the same + // transaction gets its own claim row, because every sweep + // decision on a pending row is scoped by that tag and a + // release computed by one wallet must never decide another + // wallet's claim. A tombstone does not occupy the key: it is + // the sweep's hold, not this record's claim, and the drain + // reads the vin index and the spender link off the ordinary + // row while the tombstone supplies only the stamp. The + // mutable halves (`spendingTxid`, `isSweptTombstone`) are + // compared in Swift on the live rows. + let alreadyPending = pendingInputRows(outpoint: outpoint).contains { + !$0.isSweptTombstone && $0.spendingTxid == spendingTxid && $0.walletId == walletId + } if !alreadyPending { let pending = PersistentPendingInput( outpoint: outpoint, @@ -2507,8 +2666,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let txidData = hashData(utxo.outpoint.txid) let outpoint = PersistentTxo.makeOutpoint(txid: txidData, vout: utxo.outpoint.vout) let record: PersistentTxo + let redelivered: Bool if let existing = fetchTxoRow(outpoint: outpoint) { record = existing + redelivered = true // Backfill if the account or wallet linkage is missing — // the per-wallet query path filters on TXO.walletId, so // an empty value would silently hide the row. @@ -2528,26 +2689,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // can span multiple accounts). let parentTx: PersistentTransaction if let existingTx = fetchTransactionRow(txid: txidData) { - // A globally-swept parent is a transaction Rust has already - // proven can never confirm — a fresh UTXO entry naming its - // txid would (re-)create exactly the phantom output - // `applySweptTransaction` deletes on every callback that - // observes the sweep. Bail rather than attach a new - // `PersistentTxo` to a row still excluded from restoration. - // - // This does not fight `upsertTransaction`'s reinstatement - // path — it relies on it running first. `applyAccountChangeset` - // processes an account's `tx.transactions` before its - // `utxos_added`, so a reinstating record for this same txid - // in this same round has already cleared the tombstone by - // the time this guard reads it here; only a UTXO entry with - // no accompanying record this round (or in a stray one that - // arrives out of order relative to it) still finds the flag - // set. That is genuinely a stale/out-of-order signal — Rust - // does not otherwise re-emit a swept loser's own outputs — - // and staying defensive here is correct: there is no record - // in flight to attribute a resurrected output to. - guard !existingTx.isGloballySwept else { return } parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -2579,6 +2720,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.walletId = resolvedWalletId backgroundContext.insert(record) roundIndex?.txosByOutpoint[outpoint] = record + redelivered = false } record.amount = utxo.amount @@ -2589,20 +2731,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.isLocked = utxo.is_locked record.lastUpdated = Date() - // The wallet is handing this outpoint over as a UTXO, so it holds it - // unspent — authoritative, and the only thing that can lift a mark - // with neither a spender nor a winner behind it (a pre-stamp row - // from before `applySweptTransaction` named its winner; every hold - // written today is stamped). A row whose spend is still on record - // is left alone: the pending-input resolve below owns that - // transition. So is a `supersededByTxid` hold: the winner that - // consumed this coin is known even though its row never - // materialized here, and a re-delivery cannot outrank that verdict - // — a restore-rescan re-finds the funding output precisely because - // it is blind to an unconfirmed winner no block carries yet. Only - // an explicit release frees a stamped coin. - if record.isSpent, record.spendingTransaction == nil, record.supersededByTxid == nil { - record.isSpent = false + // The wallet is handing a coin it already materialised back as + // UNSPENT, and it follows the wallet: `isSpent` and the sweep stamp + // clear together. The wallet knows this coin, so any network-final + // spender of it is wallet-relevant by BIP158 prevout matching and + // its own scan re-discovers the spend; refusing the re-delivery + // would instead lock a real coin out forever after a reorg of the + // winner — and a row with `isSpent == true` is never restored to + // Rust again. The same rule as the reference store's upsert valve, + // which holds only never-materialised placeholders; here those are + // the tombstones the drain below resolves. The one exception is a + // linked spender with context at or above InstantSend-locked: + // confirmed evidence on record is never displaced by a re-delivery + // (the pending-input resolve and the spend emit own that link). + if redelivered, record.isSpent { + let settledSpender = record.spendingTransaction.map { + $0.context >= TransactionContextType.instantSend.rawValue + } ?? false + if !settledSpender { + record.isSpent = false + record.supersededByTxid = nil + if record.spendingTransaction == nil { + record.spendingInputIndex = nil + } + } } // Attach the `PersistentCoreAddress` row, if we have one. The @@ -2628,80 +2780,57 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // first. let pendingRows = pendingInputRows(outpoint: record.outpoint) if !pendingRows.isEmpty { - // A tombstone outranks every ordinary row regardless of age. - // The per-row reconciliation below arbitrates between competing - // *observations*; a tombstone is not an observation — it is the - // sweep's settled verdict that its winner consumed this coin. - // The two coexist in exactly one way: records precede sweeps - // within a round, so the winner's own record can stage an - // ordinary pending row moments before the sweep repoints the - // loser's row, which keeps its original, older `createdAt`. - // Letting an observation win there would leave `isSpent` gated - // on the winner confirming, never stamp `supersededByTxid`, and - // then delete every row including the tombstone — the durable - // hold evaporates and the consumed coin re-enters the restore - // set. - if let tombstone = pendingRows.filter(\.isSweptTombstone) - .max(by: { $0.createdAt < $1.createdAt }) + // A tombstone is not an observation — it is a sweep's settled + // verdict that its winner consumed this coin — so it outranks + // every ordinary row regardless of age, and a drained tombstone + // STAMPS: `isSpent = true`, `supersededByTxid = winner`, and + // nothing else. It never mints a spender link or a vin index — + // its `inputIndex` is the LOSER'S vin, and the winner it names + // need not spend this coin at that position (or at all: the + // hold means "some survivor took it"). A sweep's winner is + // already final, so `isSpent` does not gate on resolving a + // spender the way an ordinary pending spend does; + // `supersededByTxid` is what makes the mark durable. Rows are + // per (outpoint, winner, wallet): the tombstone tagged with the + // delivering wallet is preferred, and failing that any + // tombstone on the outpoint still holds — the stamp is a txid + // fact, not a per-wallet fact. + let tombstones = pendingRows.filter(\.isSweptTombstone) + if let tombstone = tombstones.first(where: { $0.walletId == resolvedWalletId }) + ?? tombstones.max(by: { $0.createdAt < $1.createdAt }) { - record.spendingInputIndex = tombstone.inputIndex - if let spending = resolvePendingSpender(tombstone), - record.spendingTransaction?.txid != spending.txid - { - record.spendingTransaction = spending - } - // A sweep's winner is already final — there is no mempool - // state to wait out — so `isSpent` does not gate on - // resolving the spender the way an ordinary pending spend - // does; that lookup only succeeds when the winner happens to - // have its own materialized row, which is not guaranteed. - // `supersededByTxid` is what makes the mark durable either - // way, and it is what the recovery clear above checks so - // this coin is not handed back as spendable on a later sync. record.isSpent = true record.supersededByTxid = tombstone.spendingTxid - } else { - // Reconcile EVERY deferred observation, not just the newest — - // the rows are about to be deleted, and picking one would let - // a mempool competitor recorded after a confirmed spender - // erase that confirmed evidence with the rows. Applying the - // finality-aware rule per row makes the order irrelevant by - // construction: confirmed evidence wins and is never - // displaced by a mempool observation, so the oldest-first - // pass converges to the same state any order would. - var adoptedAny = false - for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { - guard let spending = resolvePendingSpender(pending) else { continue } - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: record.spendingTransaction?.txid, - currentIsSpent: record.isSpent, - incoming: spending, - incomingTxid: spending.txid - ) - // A stamped hold is the sweep's settled verdict and - // outranks any observation, exactly as in - // `resolveInputOutpoint`. - record.isSpent = verdict.isSpent || record.supersededByTxid != nil - if verdict.adoptLink { - if record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending - } - // The vin index rides with the adopted claim so the - // spending tx's detail view renders inputs in the - // canonical serialized order. - record.spendingInputIndex = pending.inputIndex - adoptedAny = true - } + } + // Reconcile EVERY deferred ordinary observation, not just the + // newest — the rows are about to be deleted, and picking one + // would let a mempool competitor recorded after a confirmed + // spender erase that confirmed evidence with the rows. The + // per-row rule is order-independent by construction (a settled + // spender is never displaced by a lower-context one, `isSpent` + // is monotonic), so the oldest-first pass converges to the + // same state any order would. Attribution survives a coexisting + // tombstone this way: the winner's own record staged its + // ordinary row moments before the sweep, and that row — not + // the tombstone — carries the link and the right vin index. + var adoptedAny = false + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) + where !pending.isSweptTombstone { + guard let spending = resolvePendingSpender(pending) else { continue } + adoptSpendObservation(txo: record, spender: spending, inputIndex: pending.inputIndex) + if record.spendingTransaction?.txid == spending.txid { + adoptedAny = true } - if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { - // No row resolved a spending tx this flush: carry the - // newest claim's vin index forward the way the old - // single-row path did; the linkage itself catches up on - // the next flush that carries the spending tx. - record.spendingInputIndex = newest.inputIndex - } } + } + if !adoptedAny, tombstones.isEmpty, + let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + // No row resolved a spending tx this flush: carry the + // newest claim's vin index forward the way the old + // single-row path did; the linkage itself catches up on + // the next flush that carries the spending tx. Never off + // a tombstone — its index is the loser's. + record.spendingInputIndex = newest.inputIndex + } record.lastUpdated = Date() for row in pendingRows { backgroundContext.delete(row) @@ -2724,51 +2853,96 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return fetchTransactionRow(txid: pending.spendingTxid) } - /// The one rule every spend-linkage writer follows, so `isSpent` and - /// `spendingTransaction` move as a single finality-aware state instead - /// of a monotonic flag beside a last-writer-wins link (which could - /// diverge: a mempool competitor replacing a confirmed link under a - /// stuck-true flag, or a reorg demotion never lowering it). + /// The one rule every spend-linkage writer follows — the record pass + /// (`resolveInputOutpoint`), the `utxos_spent` emit (`markUtxoSpent`) + /// and the pending-row drain (`upsertUtxo`) all route through + /// `adoptSpendObservation`, which applies this verdict. /// - /// - Re-observation of the LINKED spender follows its context in both - /// directions: a demotion is chain truth — key-wallet emits - /// `InBlock` → `Mempool` context updates on a reorg — and keeping a - /// stale flag would wedge the coin out of the restore set. - /// - A DIFFERENT in-block spender takes the link and the flag: its - /// claim is chain-attested and mutually exclusive with the old one. - /// - A mempool competitor never displaces confirmed evidence: link and - /// flag both stay. - /// - When nothing confirmed is at stake, the newest observation wins - /// the link and the flag stays down. + /// - `isSpent` is MONOTONIC: `existing || incoming in block || stamped`. + /// A stamped hold (`supersededByTxid`) is a sweep's settled verdict + /// and outranks any observation; an in-block spender sets it; nothing + /// on these channels lowers it. A coin the wallet holds unspent again + /// comes back through `utxos_added`, whose recovery clear is the one + /// path down (see `upsertUtxo`). + /// - The link follows `settledSpenderLinkIsKept`: a network-final + /// spender keeps its link against a conflicting arrival (DIP-10 + /// precedence decides the one takeover), a mempool spender yields to + /// the newest observation, and a re-observation of the linked spender + /// keeps it. A stamped, unlinked row ADOPTS the arriving spender's + /// link — the attribution `walletFundedTransaction` reads — while the + /// hold stays: the hold is the stamp, not the link. private static func reconcileSpendObservation( currentSpenderTxid: Data?, + currentSpenderContext: UInt32?, currentIsSpent: Bool, + currentIsStamped: Bool, incoming: PersistentTransaction, incomingTxid: Data ) -> (adoptLink: Bool, isSpent: Bool) { - let incomingInBlock = spendIsInBlock(incoming) - if currentSpenderTxid == incomingTxid { - return (adoptLink: true, isSpent: incomingInBlock) - } - if incomingInBlock { - return (adoptLink: true, isSpent: true) - } - if currentIsSpent { - // Refusing the link protects EXISTING confirmed evidence. With - // no spender linked there is none to protect: the flag is true - // because a sweep hold says the coin was consumed - // (`supersededByTxid`), and the arriving record is typically the - // very winner that hold names — the one transaction that can - // supply the attribution the hold could not. Adopt the link and - // keep the flag; a linked settled spender is still never - // displaced by a mempool competitor, which is the case the rule - // was written for. - if currentSpenderTxid == nil { - return (adoptLink: true, isSpent: true) - } - return (adoptLink: false, isSpent: true) + let isSpent = currentIsSpent || spendIsInBlock(incoming) || currentIsStamped + if let currentSpenderTxid, let currentSpenderContext, + settledSpenderLinkIsKept( + existingTxid: currentSpenderTxid, + existingContext: currentSpenderContext, + newTxid: incomingTxid, + newContext: incoming.context + ) { + return (adoptLink: false, isSpent: isSpent) + } + return (adoptLink: true, isSpent: isSpent) + } + + /// Apply `reconcileSpendObservation`'s verdict for `spender` to `txo` — + /// the single link writer for every channel. `inputIndex` is the + /// spender's vin for this coin when the channel carries it (the record + /// pass and a drained ordinary pending row do; the `utxos_spent` emit + /// does not). + /// + /// A spender the link moves away from is registered in the round index + /// before it is displaced: the relationship faulted it into the context + /// without any keyed lookup, and if a later store-only first-touch + /// fetch of its txid (the sweep phase looking up a loser this record + /// just beat) refreshed it, the refresh would reset its `inputs` + /// inverse and with it this very link — durably, since a chainlock + /// promotion never re-emits the record (see `roundIndex`). + private func adoptSpendObservation( + txo: PersistentTxo, + spender: PersistentTransaction, + inputIndex: UInt32? + ) { + let currentSpender = txo.spendingTransaction + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: currentSpender?.txid, + currentSpenderContext: currentSpender?.context, + currentIsSpent: txo.isSpent, + currentIsStamped: txo.supersededByTxid != nil, + incoming: spender, + incomingTxid: spender.txid + ) + var changed = false + if txo.isSpent != verdict.isSpent { + txo.isSpent = verdict.isSpent + changed = true + } + if verdict.adoptLink { + if let currentSpender, currentSpender.txid != spender.txid { + roundIndex?.transactionsByTxid[currentSpender.txid] = currentSpender + txo.spendingTransaction = spender + changed = true + } else if currentSpender == nil { + txo.spendingTransaction = spender + changed = true + } + // The canonical vin index, so the detail view can render inputs + // in serialized order. + if let inputIndex, txo.spendingInputIndex != inputIndex { + txo.spendingInputIndex = inputIndex + changed = true + } + } + if changed { + txo.lastUpdated = Date() } - return (adoptLink: true, isSpent: false) } private func markUtxoSpent(_ entry: SpentOutPointFFI) { @@ -2804,22 +2978,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `isSpent` on every reordered emit. if let spending = spendingTx { // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. A stamped - // hold outranks the verdict: this emit can carry the sweep - // winner's own IS-locked spend of a coin the sweep already - // proved consumed, and answering from the verdict alone would - // flip the durable hold back into the restore set until the - // winner reaches a block. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: txo.spendingTransaction?.txid, - currentIsSpent: txo.isSpent, - incoming: spending, - incomingTxid: spendingTxid - ) - txo.isSpent = verdict.isSpent || txo.supersededByTxid != nil - if verdict.adoptLink, txo.spendingTransaction?.txid != spendingTxid { - txo.spendingTransaction = spending - } } + // `reconcileSpendObservation` for the finality rule, stamped + // hold included: this emit can carry the sweep winner's own + // IS-locked spend of a coin the sweep already proved consumed, + // and the monotonic flag keeps the durable hold out of the + // restore set until the winner reaches a block. + adoptSpendObservation(txo: txo, spender: spending, inputIndex: nil) + } txo.lastUpdated = Date() // The spend signal landed both via the legacy // `utxos_spent` slice (this path) and — assuming the @@ -2992,7 +3157,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // partition — such a row is in neither source — so the // round runs unindexed and the lookup helpers fall back to // the exact pre-index fetch, pending changes included. - self.roundIndex = backgroundContext.hasChanges ? nil : ChangesetRoundIndex() } + if backgroundContext.hasChanges { + SDKLogger.event( + "persistence_round_index_disabled", + category: .persistence, + severity: .warning, + fields: [ + "reason": .publicText("dirty_context_at_round_start"), + "wallet_reference": .reference(walletId), + ] + ) + self.roundIndex = nil + } else { + self.roundIndex = ChangesetRoundIndex() + } + self.roundAdvancedFinalityBoundary = false + } } /// Closes a persistence round. Commits all per-kind writes @@ -3023,6 +3203,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // context has un-inserted every one of them. defer { self.roundIndex = nil + self.roundAdvancedFinalityBoundary = false self.inChangeset = false self.drainDeferredBackfills() } @@ -3057,6 +3238,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.rollback() return false } + // The round's one collector pass: after every account slice + // and every sweep, on the boundary the round's own writes + // left on the wallet row — see + // `collectFinalizedSweptTombstones` for why not earlier. + if roundAdvancedFinalityBoundary { + collectFinalizedSweptTombstones(walletId: walletId) + } do { try backgroundContext.save() SDKLogger.event( @@ -4233,6 +4421,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { fields: ["identity_reference": .reference(ownerIdentityId)], error: error ) + // Same reason as `saveBackgroundContextIfNeeded`: the + // staged rows must not ride the next round's save. + backgroundContext.rollback() } } } @@ -7311,14 +7502,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func recordEntry( for txRow: PersistentTransaction, accountIndex: UInt32 ) -> UnresolvedAssetLockTxRecordFFI? { - // A globally-swept transaction lost a double-spend on one of - // its own inputs and can never confirm. Restoring it would put - // a dead funding tx back in the account's live history — or, - // through the spender pass below, hand the double-spend screen - // a swept loser as the settled spender of a lock's input, which - // is the one verdict that must never come from a transaction - // the wallet has already removed. - guard !txRow.isGloballySwept else { return nil } let txBytes = txRow.transactionData guard !txBytes.isEmpty else { return nil } let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) @@ -7424,14 +7607,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) -> (UnsafeMutablePointer?, Int) { // Provider special-tx kinds are the contiguous discriminant range // 2...5 (ProviderRegistration=2 … ProviderUpdateRevocation=5). - // `!isGloballySwept` excludes a provider tx that itself lost a - // double-spend on one of its inputs — an edge case (most losers are - // ordinary spends), but a swept row is never restorable regardless - // of kind. let descriptor = FetchDescriptor( predicate: #Predicate { tx in tx.transactionTypeKind >= 2 && tx.transactionTypeKind <= 5 - && tx.isGloballySwept == false } ) guard let providerTxs = try? backgroundContext.fetch(descriptor), @@ -7961,7 +8139,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func persistTrackedMasternodes(networkRaw: UInt32, rows: [TrackedMasternodeRow]) -> Bool { onQueue { do { - let existing = try trackedMasternodeContext.fetch( FetchDescriptor( + let existing = try trackedMasternodeContext.fetch( + FetchDescriptor( predicate: #Predicate { $0.networkRaw == networkRaw } ) ) @@ -7975,7 +8154,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { found.addedAt = row.addedAt found.snapshotJSON = row.snapshotJSON } else { - trackedMasternodeContext.insert(PersistentTrackedMasternode( networkRaw: networkRaw, + trackedMasternodeContext.insert(PersistentTrackedMasternode( + networkRaw: networkRaw, proTxHash: row.proTxHash, label: row.label, addedAt: row.addedAt, @@ -8193,14 +8373,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard let row = try? backgroundContext.fetch(descriptor).first else { return nil } - // A globally-swept row can still physically exist (another - // wallet's claim may not have cleared yet), but Rust has already - // proven it dead — treat it the same as "no such transaction" - // rather than handing back a body sent-payment reconciliation or - // the asset-lock proof flow would read as live. - guard !row.isGloballySwept else { - return nil - } // The Rust side decodes `transactionData` into a // `dashcore::Transaction`; an empty buffer (left over // from an orphaned stub row in the UTXO upsert path diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 793da6fa30e..6844e502294 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -68,7 +68,9 @@ final class DashModelMigrationTests: XCTestCase { /// back with the sweep columns backfilled to their "nothing swept yet" /// values. V3 registers the frozen component, so the row goes in as the /// frozen type and comes out as the live one — which is the whole point - /// of the freeze: the same entity, one property wider. + /// of the freeze: the same entity, one property wider. A pending-input + /// row rides along so the tombstone index V4 adds is exercised by the + /// migration too. @MainActor func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { let directory = FileManager.default.temporaryDirectory @@ -93,6 +95,12 @@ final class DashModelMigrationTests: XCTestCase { v3Container?.mainContext.insert(DashSchemaV1.PersistentWallet( walletId: walletId, network: .testnet)) + v3Container?.mainContext.insert(DashSchemaV1.PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), + inputIndex: 0, + spendingTxid: Data(repeating: 0x22, count: 32), + spendingTransaction: nil, + walletId: walletId)) try v3Container?.mainContext.save() v3Container = nil @@ -115,6 +123,42 @@ final class DashModelMigrationTests: XCTestCase { wallets.first?.lastAppliedChainLockHeight, "a wallet migrated from V3 has no chainlock boundary yet, so no " + "tombstone it later takes can be collected on a fabricated one") + let pending = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") + XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") + XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") + } + + /// What makes the V3 -> V4 stage lightweight: the two versions name the + /// same entity set, and V4 only widens three of them. Also pins that + /// `PersistentTransaction` is NOT one of the three — a swept row is + /// deleted outright, so the transaction entity carries no sweep marker, + /// and one that came back would silently change V4's checksum. + func testV3AndV4NameTheSameEntitySet() throws { + let v3 = Schema(versionedSchema: DashSchemaV3.self) + let v4 = Schema(versionedSchema: DashSchemaV4.self) + XCTAssertEqual( + v3.entities.map(\.name).sorted(), + v4.entities.map(\.name).sorted()) + + let transaction = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTransaction" }) + let frozenTransaction = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTransaction" }) + XCTAssertEqual( + transaction.attributesByName.keys.sorted(), + frozenTransaction.attributesByName.keys.sorted(), + "V4 adds no column to PersistentTransaction") + let txo = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTxo" }) + XCTAssertNotNil(txo.attributesByName["supersededByTxid"]) + let pendingInput = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPendingInput" }) + XCTAssertNotNil(pendingInput.attributesByName["isSweptTombstone"]) + XCTAssertNotNil(pendingInput.attributesByName["winnerMinedHeight"]) + let wallet = try XCTUnwrap(v4.entities.first { $0.name == "PersistentWallet" }) + XCTAssertNotNil(wallet.attributesByName["lastAppliedChainLockHeight"]) + + // And V3's frozen copies do not carry them. + let frozenTxo = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTxo" }) + XCTAssertNil(frozenTxo.attributesByName["supersededByTxid"]) } /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only @@ -128,7 +172,8 @@ final class DashModelMigrationTests: XCTestCase { for schema in [ Schema(versionedSchema: DashSchemaV1.self), Schema(versionedSchema: DashSchemaV2.self), - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV3.self), + Schema(versionedSchema: DashSchemaV4.self) ] { let names = schema.entities.map(\.name) XCTAssertTrue( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 8993144e0bc..f0d254ec2df 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -21,6 +21,11 @@ import DashSDKFFI /// flipped `isSpent` on either coin, so both are one deleted row away from /// re-entering the restore set, and only the released set upstream carries /// says which of them belongs there. +/// +/// Loser rows carry REAL consensus bytes (`serializedTransaction`): the +/// sweep keys its hold on the loser's decoded inputs, not on the links +/// the row happens to hold, so a fixture with undecodable bytes would only +/// exercise the link-keyed fallback. @MainActor final class SweptTransactionPersistTests: XCTestCase { @@ -50,6 +55,49 @@ final class SweptTransactionPersistTests: XCTestCase { return (handler, container) } + /// Serialize a plain version-2 transaction spending `inputs` (empty + /// scriptSigs) with `outputs` empty-script outputs, in the form + /// `TransactionDecoder` parses — which is what `applySweptTransaction` + /// decodes a loser's inputs from. The txid of these bytes is NOT the + /// fixture's row key; nothing in the store compares the two. + private func serializedTransaction( + inputs: [(txid: Data, vout: UInt32)], + outputs: Int = 1 + ) -> Data { + var bytes = Data() + bytes.append(contentsOf: withUnsafeBytes(of: UInt32(2).littleEndian) { Data($0) }) + bytes.append(UInt8(inputs.count)) + for input in inputs { + bytes.append(input.txid) + bytes.append(contentsOf: withUnsafeBytes(of: input.vout.littleEndian) { Data($0) }) + bytes.append(0x00) // empty scriptSig + bytes.append(contentsOf: [0xff, 0xff, 0xff, 0xff]) // sequence + } + bytes.append(UInt8(outputs)) + for _ in 0.. PersistentTransaction { + PersistentTransaction( + txid: txid, + transactionData: serializedTransaction(inputs: inputs), + context: 0, + blockHeight: 0, + netAmount: netAmount + ) + } + /// Seed the shape a confirmed spend leaves behind: a funding transaction /// with two outputs, a spending transaction that claimed both (linked /// and flagged spent), and the change that spend created. @@ -69,11 +117,9 @@ final class SweptTransactionPersistTests: XCTestCase { netAmount: 140_000 ) // Mempool context: the only kind of record upstream sweeps. - let swept = PersistentTransaction( + let swept = loserRow( txid: sweptTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, + spending: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)], netAmount: -140_000 ) context.insert(funding) @@ -83,7 +129,7 @@ final class SweptTransactionPersistTests: XCTestCase { if winnerTakesA { let row = PersistentTransaction( txid: winnerTxid, - transactionData: Data(repeating: 0x06, count: 10), + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), context: 2, blockHeight: 102, netAmount: -100_000 @@ -178,6 +224,38 @@ final class SweptTransactionPersistTests: XCTestCase { _ handler: PlatformWalletPersistenceHandler, _ batches: [Batch], walletId: Data + ) -> Bool { + var applied = false + round(handler, walletId: walletId) { + applied = stageSweeps(handler, batches, walletId: walletId) + return applied + } + return applied + } + + /// One begin/end bracket, the way every Rust `store()` round is + /// delivered. `body` returns the round's success, which `endChangeset` + /// commits or rolls back on. + private func round( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data? = nil, + _ body: () -> Bool + ) { + let walletId = walletId ?? self.walletId + handler.beginChangeset(walletId: walletId) + let success = body() + _ = handler.endChangeset(walletId: walletId, success: success) + } + + /// The sweeps callback alone, inside whatever bracket the caller + /// opened — so a test can stage a record and a sweep between ONE + /// `beginChangeset`/`endChangeset` pair, the shape Rust produces when + /// it folds a winner's detection and the loser's sweep into one round. + @discardableResult + private func stageSweeps( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch], + walletId: Data ) -> Bool { typealias RawTxid = ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -258,14 +336,11 @@ final class SweptTransactionPersistTests: XCTestCase { // `on_persist_wallet_changeset_sweeps_fn` in the same round as the // changeset callback, and this drives the Swift side of exactly // that call. - handler.beginChangeset(walletId: walletId) - let applied = handler.persistWalletChangesetSweeps( + return handler.persistWalletChangesetSweeps( walletId: walletId, sweeps: UnsafePointer(sweeps), count: UInt(ffiBatches.count) ) - _ = handler.endChangeset(walletId: walletId, success: applied) - return applied } private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { @@ -357,25 +432,64 @@ final class SweptTransactionPersistTests: XCTestCase { ) } - /// A re-delivery of the funding output — what a restore-rescan does, - /// blind to the unconfirmed winner no block carries yet — must NOT - /// outrank the sweep's verdict: the coin was provably consumed, and - /// handing it back would resurrect it into the restore set on every - /// restore-from-seed until the winner confirms. Only an explicit - /// release frees a stamped hold — the same answer the SQLite store's - /// upsert valve gives to the identical event stream. - func testWalletReDeliveringAStampedHeldCoinKeepsItSpent() throws { + /// A MATERIALISED coin the wallet hands back as unspent follows the + /// wallet, stamped hold or not. This test used to pin the opposite — + /// "the recovery clear refuses stamped rows" — on the reasoning that a + /// restore-rescan re-finds the funding output blind to an unconfirmed + /// winner. That reasoning only holds for a coin the wallet has never + /// materialised (the tombstone's job, see the drain tests below): a + /// coin the wallet knows is one whose every network-final spender is + /// wallet-relevant by BIP158 prevout matching, so the wallet's own scan + /// re-discovers the spend and its view is authoritative — and refusing + /// the re-delivery locks a real coin out forever after a reorg of the + /// winner, since a row with `isSpent == true` is never restored to Rust + /// again. The reference store's upsert valve was narrowed to + /// never-materialised placeholders for exactly this reason; this is the + /// same rule. + func testWalletReDeliveringAMaterialisedHeldCoinFreesIt() throws { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: false) sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) - XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(held.isSpent, "sanity: the sweep held the coin") + XCTAssertEqual(held.supersededByTxid, winnerTxid) redeliverCoinB(handler) - let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) - XCTAssertTrue(held.isSpent, "the stamped hold survives re-delivery") - XCTAssertEqual(held.supersededByTxid, winnerTxid) - XCTAssertNil(held.spendingTransaction) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "the wallet re-delivering a coin it knows frees it") + XCTAssertNil(freed.supersededByTxid, "the stamp clears with the hold") + XCTAssertNil(freed.spendingTransaction) + } + + /// The one shape the re-delivery does not free: a coin linked to a + /// spender with context at or above InstantSend-locked. Confirmed + /// evidence on record is never displaced by a re-delivery — the spend + /// emit and the record pass own that link. + func testWalletReDeliveringACoinLinkedToASettledSpenderKeepsItSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + let context = ModelContext(container) + let coinB = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let row = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinB } + )).first + ) + let winnerTxid = self.winnerTxid + row.isSpent = true + row.spendingTransaction = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == winnerTxid } + )).first + ) + try context.save() + + redeliverCoinB(handler) + + let kept = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(kept.isSpent, "a coin an in-block spender holds stays spent") + XCTAssertEqual(kept.spendingTransaction?.txid, winnerTxid) } /// The winner's own record can reach this store only after the sweep @@ -392,13 +506,7 @@ final class SweptTransactionPersistTests: XCTestCase { let context = ModelContext(container) context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let l = PersistentTransaction( - txid: sweptTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let l = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) context.insert(l) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), @@ -450,13 +558,7 @@ final class SweptTransactionPersistTests: XCTestCase { let context = ModelContext(container) context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let l = PersistentTransaction( - txid: sweptTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let l = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) context.insert(l) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), @@ -555,10 +657,12 @@ final class SweptTransactionPersistTests: XCTestCase { /// way). private func deliverRecord( _ handler: PlatformWalletPersistenceHandler, + walletId: Data? = nil, txid: Data, context: UInt32, inputOutpoints: [(txid: Data, vout: UInt32)] ) { + let walletId = walletId ?? self.walletId let name = strdup("Standard { index: 0 }") defer { free(name) } @@ -600,16 +704,22 @@ final class SweptTransactionPersistTests: XCTestCase { _ = handler.endChangeset(walletId: walletId, success: true) } - /// The pruned-finalized-release defect, on this store's terms: a - /// chainlocked spender F is pruned upstream to a bare txid, so a later - /// loser L that pays this wallet while reusing F's input (plus an - /// attacker-owned one) sweeps with F's coin wrongly named in the - /// released set. F's row and its `spendingTransaction` link survive - /// HERE, and `settledSpenderLinkIsKept` keeps L's record pass from - /// stealing the attribution — so the loser walk never detaches F's coin - /// and the by-outpoint release refuses it (`spendingTransaction == nil` - /// gate), while the coin only L claimed still comes free in the same - /// batch. + /// The pruned-finalized-release defect, on this store's terms: an + /// InstantSend-locked spender F — settled under DIP-10, and one upstream + /// no longer sees after a restart — is linked to a coin a later loser L + /// reuses (alongside an attacker-owned input) while paying this wallet, + /// so L's sweep names F's coin wrongly in the released set. F's row and + /// its `spendingTransaction` link survive HERE: the settled-link guard + /// keeps L's record pass from stealing the attribution, and the release + /// veto (`releaseIsVetoed`) refuses the release the link contradicts, + /// while the coin only L claimed still comes free in the same batch. + /// + /// F is seeded IS-locked (context 1) with `isSpent == false` — an + /// unmined spender leaves the flag down — so the first assertion is + /// carried by the guard alone: a mempool arrival against a linked + /// spender that is merely flagged spent was already refused by the + /// pre-existing `isSpent` branch, which this fixture deliberately does + /// not exercise. func testAReleaseNamingACoinASettledSpenderStillClaimsIsRefused() throws { let (handler, container) = try makeHandler() let context = ModelContext(container) @@ -625,13 +735,14 @@ final class SweptTransactionPersistTests: XCTestCase { blockHeight: 100, netAmount: 200_000 ) - // F: the chainlocked spender of the settled coin — upstream keeps - // only its txid from here on; this store keeps the row and the link. + // F: the IS-locked spender of the settled coin — upstream holds no + // history for it after a restart; this store keeps the row and the + // link. let finalized = PersistentTransaction( txid: finalizedTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 3, - blockHeight: 120, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 1, + blockHeight: 0, netAmount: -100_000 ) context.insert(funding) @@ -645,7 +756,7 @@ final class SweptTransactionPersistTests: XCTestCase { height: 100 ) settledCoin.walletId = walletId - settledCoin.isSpent = true + settledCoin.isSpent = false settledCoin.spendingTransaction = finalized context.insert(settledCoin) @@ -697,7 +808,7 @@ final class SweptTransactionPersistTests: XCTestCase { let settled = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) XCTAssertTrue( settled.isSpent, - "a released coin a settled stored spender still claims must stay spent" + "a released coin a settled stored spender still claims is held spent" ) XCTAssertEqual(settled.spendingTransaction?.txid, finalizedTxid) let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) @@ -795,11 +906,9 @@ final class SweptTransactionPersistTests: XCTestCase { // A second transaction takes coin B after the first sweep freed it. let secondLoser = Data(repeating: 0x55, count: 32) let context = ModelContext(container) - let reclaimer = PersistentTransaction( + let reclaimer = loserRow( txid: secondLoser, - transactionData: Data(repeating: 0x07, count: 10), - context: 0, - blockHeight: 0, + spending: [(txid: fundingTxid, vout: 1)], netAmount: -40_000 ) context.insert(reclaimer) @@ -852,11 +961,9 @@ final class SweptTransactionPersistTests: XCTestCase { ) context.insert(funding) - let loser = PersistentTransaction( + let loser = loserRow( txid: loserTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, + spending: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)], netAmount: -140_000 ) context.insert(loser) @@ -887,8 +994,8 @@ final class SweptTransactionPersistTests: XCTestCase { /// account under `walletA` from back when it was still a live candidate /// (the ordinary `upsertTransaction` path does this before a later round /// ever learns the tx lost a double-spend). That link is what makes this - /// fixture actually exercise the fix: without the `isGloballySwept` - /// guard, `walletOwnsTransaction` finds `walletA` through + /// fixture actually exercise the fix: were the row to survive, + /// `walletOwnsTransaction` would find `walletA` through /// `involvedAccounts` alone, regardless of what happens to P. private func seedSharedLoserWithOutputAndInvolvedAccount( in container: ModelContainer, @@ -925,11 +1032,14 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() } - /// The review finding, order 1: wallet B's callback — the one that - /// releases nothing — runs first. Before the fix this alone deleted the - /// shared loser row (nothing in the old code held it back), so wallet - /// A's later release of P landed on the missing-row no-op and P stayed - /// wrongly spent forever. + /// The hold is global, the release is per wallet — order 1: wallet B's + /// callback, the one that releases nothing, runs first. It is the first + /// callback to see the sweep, so it holds EVERY wallet's coins the + /// loser claimed (P is wallet A's, and is held all the same: a released + /// set is only ever true of the wallet that computed it, and B's says + /// nothing about P) and deletes the shared row outright. Wallet A's + /// later callback finds no row and still applies its release by + /// outpoint, freeing P; B's hold on Q is untouched by it. func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_BThenA() throws { let (handler, container) = try makeHandler() let loserTxid = Data(repeating: 0x81, count: 32) @@ -939,40 +1049,39 @@ final class SweptTransactionPersistTests: XCTestCase { in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid ) - // Wallet B first: its own released set names nothing, so its coin - // (Q) is held rather than freed. + // Wallet B first: its own released set names nothing. sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) - XCTAssertNotNil( + XCTAssertNil( transaction(container, txid: loserTxid), - "wallet B alone must not delete a row wallet A still has a claim on" + "the first callback to see the sweep deletes the row — hold before delete" ) - let untouchedP = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) - XCTAssertFalse(untouchedP.isSpent, "wallet B's callback must not touch wallet A's coin") - XCTAssertNotNil(untouchedP.spendingTransaction, "P is still linked to the loser, untouched") + let heldP = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(heldP.isSpent, "wallet A's coin is held by wallet B's callback — the hold is global") + XCTAssertEqual(heldP.supersededByTxid, winner) + XCTAssertNil(heldP.spendingTransaction, "the link to the dead loser is gone") // Wallet A second: its own released set names P. sweep(handler, [ Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ], walletId: walletId) - XCTAssertNil( - transaction(container, txid: loserTxid), - "the last wallet to run performs the delete" - ) - let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) - XCTAssertFalse(p.isSpent, "wallet A's own release must free its own coin") + XCTAssertFalse(p.isSpent, "wallet A's own release frees its own coin, row or no row") + XCTAssertNil(p.supersededByTxid) XCTAssertNil(p.spendingTransaction) let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) - XCTAssertTrue(q.isSpent, "wallet B's earlier decision to hold Q must survive wallet A's callback") + XCTAssertTrue(q.isSpent, "wallet B's hold on Q must survive wallet A's callback") + XCTAssertEqual(q.supersededByTxid, winner) XCTAssertNil(q.spendingTransaction) } - /// The review finding, order 2: wallet A — the one that releases P — - /// runs first. The fix is meant to be order-independent, so this must - /// land on the exact same end state as the B-then-A ordering above. + /// Order 2: wallet A — the one that releases P — runs first. It frees + /// its own P, holds wallet B's Q (global hold) and deletes the row; + /// wallet B's callback then finds no row and, releasing nothing, leaves + /// its hold on Q as it is. Order-independent: the exact same end state + /// as the B-then-A ordering above. func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_AThenB() throws { let (handler, container) = try makeHandler() let loserTxid = Data(repeating: 0x91, count: 32) @@ -987,28 +1096,26 @@ final class SweptTransactionPersistTests: XCTestCase { Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ], walletId: walletId) - XCTAssertNotNil( + XCTAssertNil( transaction(container, txid: loserTxid), - "wallet A alone must not delete a row wallet B still has a claim on" + "the first callback to see the sweep deletes the row — hold before delete" ) - let untouchedQ = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) - XCTAssertFalse(untouchedQ.isSpent, "wallet A's callback must not touch wallet B's coin") - XCTAssertNotNil(untouchedQ.spendingTransaction, "Q is still linked to the loser, untouched") + let heldQ = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(heldQ.isSpent, "wallet B's coin is held by wallet A's callback — the hold is global") + XCTAssertEqual(heldQ.supersededByTxid, winner) + XCTAssertNil(heldQ.spendingTransaction) // Wallet B second: releases nothing. sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) - XCTAssertNil( - transaction(container, txid: loserTxid), - "the last wallet to run performs the delete" - ) - let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) XCTAssertFalse(p.isSpent, "wallet A's earlier release must survive wallet B's callback") + XCTAssertNil(p.supersededByTxid) XCTAssertNil(p.spendingTransaction) let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) - XCTAssertTrue(q.isSpent, "wallet B's own decision to hold its coin must stick") + XCTAssertTrue(q.isSpent, "wallet B's coin stays held — its own callback released nothing") + XCTAssertEqual(q.supersededByTxid, winner) XCTAssertNil(q.spendingTransaction) } @@ -1018,19 +1125,18 @@ final class SweptTransactionPersistTests: XCTestCase { /// never arrives at all — a crash, a rejection, or simply never coming. /// /// `commit_batch` calls `store()` once per wallet and each commits - /// independently, so before the fix wallet B alone could not delete a - /// row wallet A still had an outstanding claim on (see the - /// `_BThenA`/`_AThenB` tests above) — and the OUTPUT went with the row, - /// because deletion was the only thing that excluded either. If wallet - /// A's own callback then never runs, that hold is permanent: the row, - /// its phantom output, and its `involvedAccounts` link to wallet A all - /// stay fully live forever, so `walletCoreTxids` hands the dead + /// independently. A row held back for another wallet's still-pending + /// callback is a row that survives forever when that callback never + /// comes — with its phantom output and its `involvedAccounts` link to + /// wallet A fully live, so `walletCoreTxids` would hand the dead /// transaction back to wallet A as its own after every future restart. + /// So the first callback to see the sweep deletes the row and its + /// outputs for every wallet, after holding every wallet's inputs; a + /// surviving swept row is a shape that no longer exists, and no reader + /// needs a guard against it. /// /// Only wallet B's callback ever runs here, and it releases nothing — - /// the worst case, since it gives the row no reason to be physically - /// deleted at all. The fix's global half must still make the output and - /// the enumeration exclusion durable from that single callback alone. + /// the worst case for the old deferred delete. func testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits() throws { let storeURL = FileManager.default.temporaryDirectory .appendingPathComponent("swept-shared-durability-\(UUID().uuidString).store") @@ -1050,20 +1156,18 @@ final class SweptTransactionPersistTests: XCTestCase { // in this test at all. sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) - XCTAssertNotNil( + XCTAssertNil( transaction(container, txid: loserTxid), - "wallet A's own claim on P is still outstanding, so the row itself survives" + "the row is deleted by whichever wallet's callback sees the sweep first" ) XCTAssertNil( txo(container, txid: loserTxid, vout: 2), "the loser's own output must not survive even a single committed callback, " + "regardless of which wallet's callback that was" ) - let row = try XCTUnwrap(transaction(container, txid: loserTxid)) - XCTAssertTrue( - row.isGloballySwept, - "any callback that reaches the sweep must flag the row, not just wallet A's own" - ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "wallet A's coin is held for the winner until A's own release") + XCTAssertEqual(p.supersededByTxid, winner) } // Restart: a fresh handler/container over the same file. Wallet A's @@ -1076,30 +1180,30 @@ final class SweptTransactionPersistTests: XCTestCase { txo(container, txid: loserTxid, vout: 2), "the phantom output must not resurrect across a restart" ) + XCTAssertNil(transaction(container, txid: loserTxid), "nor the row") let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) XCTAssertFalse(erroredA) XCTAssertFalse( txidsA.contains { $0.txid == loserTxid }, "wallet A must not be able to enumerate the swept loser as its own transaction " - + "after a restart, even though it is still linked via involvedAccounts and " - + "its own callback never ran" + + "after a restart, even though its own callback never ran" ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "and the hold on its coin is durable") + XCTAssertEqual(p.supersededByTxid, winner) } - /// Cross-round reinstatement — the BLOCKING finding this round fixes. - /// The sweep and its reinstating record land in two SEPARATE - /// `persistWalletChangeset` rounds, with wallet B's still-outstanding - /// claim keeping the shared row physically present in between, exactly - /// as `testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits` - /// establishes on its own. Before the fix, `upsertTransaction` bailed - /// unconditionally on `isGloballySwept == true`, so round 2's record — - /// upstream's newer word, per `CoreChangeSet::merge`'s documented + /// Cross-round reinstatement. The sweep and its reinstating record land + /// in two SEPARATE `persistWalletChangeset` rounds: round 1 deletes the + /// shared row and holds wallet A's coin for the winner; round 2's record + /// — upstream's newer word, per `CoreChangeSet::merge`'s documented /// IS-lock-precedence sequence (swept by an IS-locked conflict, then - /// returns chainlocked and sweeps that conflict in turn) — would be - /// silently discarded forever, and `upsertUtxo` would keep rejecting - /// its output on the strength of a tombstone nothing could ever clear. - /// Verified across a restart: the reinstatement has to be durable, not - /// merely visible in the context that just applied it. + /// returns chainlocked and sweeps that conflict in turn) — arrives like + /// any freshly detected transaction, inserts a fresh row, re-adopts the + /// held coin's link and brings its output back through the + /// `utxos_added` riding alongside. Verified across a restart: the + /// reinstatement has to be durable, not merely visible in the context + /// that just applied it. func testAReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() throws { let storeURL = FileManager.default.temporaryDirectory .appendingPathComponent("swept-reinstatement-\(UUID().uuidString).store") @@ -1115,18 +1219,16 @@ final class SweptTransactionPersistTests: XCTestCase { ) // Round 1: only wallet B's own sweep callback runs, releasing - // nothing. Wallet A's own claim on P (its funding coin) is still - // outstanding, so the shared row survives physically even - // though the global half of the sweep already tombstoned it and - // deleted its phantom output. + // nothing. The row and its phantom output go, and wallet A's + // coin P is held for the winner. sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) - let tombstoned = try XCTUnwrap(transaction(container, txid: loserTxid)) - XCTAssertTrue(tombstoned.isGloballySwept, "sanity: the row is tombstoned after round 1") + XCTAssertNil(transaction(container, txid: loserTxid), "sanity: the row is gone after round 1") XCTAssertNil( txo(container, txid: loserTxid, vout: 2), "sanity: the loser's own output is gone after round 1" ) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) // Round 2, a SEPARATE callback (not coalesced with round 1's // sweep — the cross-round shape the merge-level fix in @@ -1152,10 +1254,6 @@ final class SweptTransactionPersistTests: XCTestCase { transaction(container, txid: loserTxid), "the reinstating record must not be discarded" ) - XCTAssertFalse( - reinstated.isGloballySwept, - "a later record naming a tombstoned txid must clear the tombstone" - ) XCTAssertEqual(reinstated.blockHeight, 200) let revivedOutput = try XCTUnwrap( @@ -1181,8 +1279,7 @@ final class SweptTransactionPersistTests: XCTestCase { // that applied it. let (handler, container) = try makeHandler(url: storeURL) - let survived = try XCTUnwrap(transaction(container, txid: loserTxid)) - XCTAssertFalse(survived.isGloballySwept, "the reinstatement must survive a restart") + XCTAssertNotNil(transaction(container, txid: loserTxid), "the reinstatement must survive a restart") XCTAssertNotNil( txo(container, txid: loserTxid, vout: 2), "the revived output must survive a restart" @@ -1269,17 +1366,17 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") } - /// Two wallets, each holding an unresolved *released* input on the same - /// shared loser — the case where the row would otherwise never be - /// reclaimed. - /// - /// Left attached, a released pending input reads as its wallet's claim - /// in the ownership check, so A declines the delete because B's row is - /// there and B declines because A's is: a stalemate no replay breaks. - /// The dead transaction contributes no funds either way thanks to the - /// global marker, so this is storage rather than balance — but the row - /// and both pending entries would be kept forever. - func testTwoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete() throws { + /// Two wallets, each holding an unresolved pending input on the same + /// shared loser, and a winner that took neither — so upstream names + /// both coins released in BOTH wallets' views (a released set is + /// computed from the loser's and the winner's inputs, the same for + /// every wallet). A release only ever touches the releasing wallet's + /// own rows: the first callback (A's) deletes its own released row, + /// holds B's — A's released set is not the authority on B's claim — + /// and deletes the loser; B's callback then applies its own release by + /// outpoint against the tombstone. Nothing is left behind: no row, no + /// pending entry of either wallet's. + func testEachWalletsReleaseReachesItsOwnPendingRowOnASharedLoser() throws { let (handler, container) = try makeHandler() let walletB = Data(repeating: 0x02, count: 32) try seedSharedLoserAcrossTwoWallets( @@ -1287,7 +1384,7 @@ final class SweptTransactionPersistTests: XCTestCase { ) // Each wallet has one pending input on the loser, and each will be - // released by its own wallet's sweep. + // released by its own wallet's sweep. The loser's bytes name both. let context = ModelContext(container) let loserTxid = sweptTxid var descriptor = FetchDescriptor( @@ -1295,6 +1392,10 @@ final class SweptTransactionPersistTests: XCTestCase { ) descriptor.fetchLimit = 1 let loser = try XCTUnwrap(try context.fetch(descriptor).first) + loser.transactionData = serializedTransaction(inputs: [ + (txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1), + (txid: fundingTxid, vout: 8), (txid: fundingTxid, vout: 9), + ]) let pendingA = PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 8), inputIndex: 0, @@ -1313,18 +1414,31 @@ final class SweptTransactionPersistTests: XCTestCase { context.insert(pendingB) try context.save() + let releasedInBothViews = [(txid: fundingTxid, vout: UInt32(8)), (txid: fundingTxid, vout: UInt32(9))] sweep(handler, [ - Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 8)]) + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: releasedInBothViews) ]) + XCTAssertNil(transaction(container, txid: sweptTxid), "the first callback deletes the row") + XCTAssertTrue( + try pendingRows(container, spentTxid: fundingTxid, vout: 8).isEmpty, + "wallet A's released row is deleted outright — never a released tombstone" + ) + let heldB = try XCTUnwrap( + try pendingRows(container, spentTxid: fundingTxid, vout: 9).first, + "wallet B's row is held for the winner by wallet A's callback" + ) + XCTAssertTrue(heldB.isSweptTombstone) + XCTAssertEqual(heldB.spendingTxid, winnerTxid) + XCTAssertEqual(heldB.walletId, walletB) + sweep( handler, - [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 9)])], + [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: releasedInBothViews)], walletId: walletB ) - - XCTAssertNil( - transaction(container, txid: sweptTxid), - "a released pending input is not a claim once its own wallet has resolved it" + XCTAssertTrue( + try pendingRows(container, spentTxid: fundingTxid, vout: 9).isEmpty, + "wallet B's own release reaches its tombstone with the row already gone" ) } @@ -1363,13 +1477,7 @@ final class SweptTransactionPersistTests: XCTestCase { let (handler, container) = try makeHandler(url: storeURL) let context = ModelContext(container) context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let swept = PersistentTransaction( - txid: sweptTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let swept = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) context.insert(swept) // What `resolveInputOutpoint` would have written: the funding // TXO for (fundingTxid, 0) has never been seen here. @@ -1411,12 +1519,14 @@ final class SweptTransactionPersistTests: XCTestCase { /// whose own funding side is ALSO unobserved stages an ordinary pending /// row for the same outpoint moments before the sweep repoints the /// loser's row into a tombstone — and the tombstone keeps the loser's - /// original, older `createdAt`. The drain's newest-wins pick then + /// original, older `createdAt`. A newest-wins pick over all rows once /// selected the winner's ordinary row, took the gated branch (`isSpent` /// stays false until the winner confirms — never, for an IS-locked /// unconfirmed winner), skipped the `supersededByTxid` stamp, and /// deleted every pending row including the tombstone: the durable hold - /// evaporated and the consumed coin re-entered the restore set. + /// evaporated and the consumed coin re-entered the restore set. The + /// tombstone supplies the stamp regardless of age; the winner's own row + /// supplies the link beside it. func testAWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() throws { let (handler, container) = try makeHandler() let context = ModelContext(container) @@ -1428,13 +1538,7 @@ final class SweptTransactionPersistTests: XCTestCase { // pending row, exactly what `resolveInputOutpoint` writes. Backdated // so the winner's row below is strictly newer, as it always is in // reality (the loser's record preceded the winner's by definition). - let loser = PersistentTransaction( - txid: sweptTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let loser = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) context.insert(loser) let losersClaim = PersistentPendingInput( outpoint: outpoint, @@ -1451,7 +1555,7 @@ final class SweptTransactionPersistTests: XCTestCase { // ordinary pending row for the same still-unfunded outpoint. let winner = PersistentTransaction( txid: winnerTxid, - transactionData: Data(repeating: 0x06, count: 10), + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), context: 1, blockHeight: 0, netAmount: -100_000 @@ -1485,6 +1589,10 @@ final class SweptTransactionPersistTests: XCTestCase { "the sweep's hold must survive the winner's own coexisting pending row" ) XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertEqual( + coin.spendingTransaction?.txid, winnerTxid, + "and the winner's own row supplies the attribution the tombstone cannot" + ) } /// Chained-sweep continuation of `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent` @@ -1507,13 +1615,7 @@ final class SweptTransactionPersistTests: XCTestCase { let secondLoser = Data(repeating: 0x62, count: 32) // W let finalWinner = Data(repeating: 0x63, count: 32) // X - let l = PersistentTransaction( - txid: firstLoser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)]) context.insert(l) // P (fundingTxid:0) has never been observed as a TXO — parked as a // pending input, the same as `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent`. @@ -1541,11 +1643,9 @@ final class SweptTransactionPersistTests: XCTestCase { // W's own row, plus a materialized claim on Q, needed for the // second sweep to find W at all — the same requirement any sweep of // a wallet-relevant loser has. - let w = PersistentTransaction( + let w = loserRow( txid: secondLoser, - transactionData: Data(repeating: 0x06, count: 10), - context: 0, - blockHeight: 0, + spending: [(txid: fundingTxid, vout: 0), (txid: Data(repeating: 0x65, count: 32), vout: 0)], netAmount: -90_000 ) context.insert(w) @@ -1607,13 +1707,7 @@ final class SweptTransactionPersistTests: XCTestCase { let secondLoser = Data(repeating: 0x72, count: 32) // W let finalWinner = Data(repeating: 0x73, count: 32) // X - let l = PersistentTransaction( - txid: firstLoser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)]) context.insert(l) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), @@ -1629,13 +1723,7 @@ final class SweptTransactionPersistTests: XCTestCase { // W's own row — this time claiming ONLY P, so the second sweep has // no other input to reason about. - let w = PersistentTransaction( - txid: secondLoser, - transactionData: Data(repeating: 0x06, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let w = loserRow(txid: secondLoser, spending: [(txid: fundingTxid, vout: 0)]) context.insert(w) try context.save() @@ -1688,13 +1776,7 @@ final class SweptTransactionPersistTests: XCTestCase { let winner = Data(repeating: 0xB6, count: 32) // W let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) - let c = PersistentTransaction( - txid: childTxid, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -50_000 - ) + let c = loserRow(txid: childTxid, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) context.insert(c) context.insert(PersistentPendingInput( outpoint: pOutpoint, @@ -1732,10 +1814,11 @@ final class SweptTransactionPersistTests: XCTestCase { /// The whole chain inside ONE round: a single sweeps callback can carry /// two batches where the second sweeps the first's winner, so the /// tombstone the first batch just wrote — staged, unsaved, retargeted by - /// nothing but in-memory mutation — must be visible to the second - /// batch's scalar reconciliation. Pins the per-batch tombstone scan - /// reading the mutable columns off live objects; a store-side predicate - /// would test the stale saved values and miss the row entirely. + /// nothing but in-memory mutation — must reach the second batch. Pins + /// the once-per-round tombstone map being re-keyed in memory as batches + /// run: a second store fetch would not see the re-point, and a + /// store-side predicate on the mutable column would test the stale + /// saved value and miss the row entirely. func testChainedSweepAcrossTwoBatchesInOneRoundReleasesTheFreshTombstone() throws { let (handler, container) = try makeHandler() let context = ModelContext(container) @@ -1745,13 +1828,7 @@ final class SweptTransactionPersistTests: XCTestCase { let secondLoser = Data(repeating: 0xA2, count: 32) // W — batch 1's winner let finalWinner = Data(repeating: 0xA3, count: 32) // X - let l = PersistentTransaction( - txid: firstLoser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -50_000 - ) + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) context.insert(l) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), @@ -1794,11 +1871,10 @@ final class SweptTransactionPersistTests: XCTestCase { /// sweep that frees it, so the tombstone drains into /// `PersistentTxo.supersededByTxid` and the pending row is gone by the /// time the release runs. With the intermediate winner's own record on - /// hand the drain links `spendingTransaction` too, so the release DOES - /// reach the row through `row.inputs` — but nothing cleared the marker, - /// and a released coin keeping its dead winner's marker turns the next - /// hold on this outpoint permanent (`upsertUtxo`'s recovery clear reads - /// a present marker as a durable claim). + /// hand the drain links `spendingTransaction` too, so the release + /// reaches the row through the winner's decoded inputs — and must clear + /// the marker with the hold: a released coin keeping its dead winner's + /// marker would read as a durable claim on every later channel. func testAReleasedCoinDropsItsDeadWinnersMarker() throws { let (handler, container) = try makeHandler() let context = ModelContext(container) @@ -1808,13 +1884,7 @@ final class SweptTransactionPersistTests: XCTestCase { let secondLoser = Data(repeating: 0x92, count: 32) // W let finalWinner = Data(repeating: 0x93, count: 32) // X - let l = PersistentTransaction( - txid: firstLoser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -50_000 - ) + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) context.insert(l) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), @@ -1828,23 +1898,18 @@ final class SweptTransactionPersistTests: XCTestCase { // First sweep: W beats L, holding the still-unfunded coin. sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) - // W's own record lands before the funding TXO does, so the drain - // below links `spendingTransaction` as well as stamping the marker. - let w = PersistentTransaction( - txid: secondLoser, - transactionData: Data(repeating: 0x06, count: 10), - context: 0, - blockHeight: 0, - netAmount: -50_000 - ) - context.insert(w) - try context.save() + // W's own record lands before the funding TXO does — through the + // record pass, which stages W's own ordinary claim row beside the + // tombstone — so the drain below links `spendingTransaction` as + // well as stamping the marker. + deliverRecord(handler, txid: secondLoser, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) deliverFundingUtxo(handler, vout: 0, amount: 50_000) let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") XCTAssertEqual(stamped.supersededByTxid, secondLoser) + XCTAssertEqual(stamped.spendingTransaction?.txid, secondLoser, "sanity: linked through W's own row") // Second sweep: X beats W, and this time upstream frees the coin. sweep(handler, [ @@ -1878,13 +1943,7 @@ final class SweptTransactionPersistTests: XCTestCase { let unrecordedWinner = Data(repeating: 0x95, count: 32) // W — never a row here let finalWinner = Data(repeating: 0x96, count: 32) // X - let l = PersistentTransaction( - txid: firstLoser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -50_000 - ) + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) context.insert(l) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), @@ -1925,19 +1984,19 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertNil(freed.supersededByTxid) } - /// The multi-wallet continuation of the chained scenarios above — the - /// review finding on the missing-row early return. A shared loser L - /// spends one still-unfunded coin of wallet A's and two of wallet B's, - /// so the first sweep leaves each wallet's claims as detached tombstones - /// pointing at winner W. When W's own record then arrives, - /// `resolveInputOutpoint`'s duplicate guard sees each `(outpoint, W)` - /// tombstone and attaches nothing to W's row — so when W is swept in - /// turn, wallet A's callback finds no other wallet's claim on the row - /// and deletes it. Wallet B's independently committed callback then runs - /// against a row that no longer exists, and before the fix returned - /// without ever applying B's release decision: B's released coin would - /// later come back spent by the obsolete W, and B's held coin stayed - /// attributed to W, unable to follow any further sweep. + /// The multi-wallet continuation of the chained scenarios above. A + /// shared loser L spends one still-unfunded coin of wallet A's and two + /// of wallet B's, so the first sweep leaves each wallet's claims as + /// detached tombstones pointing at winner W. W's own record then + /// arrives through wallet A, staging A's own ordinary claim rows beside + /// B's tombstones (rows are per wallet). When X — spending only B's + /// second coin — sweeps W, both wallets release the other two coins; + /// wallet A's callback runs first, frees its own coin, holds both of + /// B's for X and deletes W's row. Wallet B's independently committed + /// callback then runs against a row that no longer exists and must + /// still apply its own release by outpoint: without it B's released + /// coin would later come back spent under X, and B's held coin's + /// tombstone could not follow any further sweep. func testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() throws { let (handler, container) = try makeHandler() let context = ModelContext(container) @@ -1949,11 +2008,9 @@ final class SweptTransactionPersistTests: XCTestCase { let sharedWinner = Data(repeating: 0xC2, count: 32) // W let finalWinner = Data(repeating: 0xC3, count: 32) // X - let l = PersistentTransaction( + let l = loserRow( txid: sharedLoser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, + spending: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1), (txid: fundingTxid, vout: 2)], netAmount: -140_000 ) context.insert(l) @@ -1977,10 +2034,10 @@ final class SweptTransactionPersistTests: XCTestCase { sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletB) XCTAssertNil(transaction(container, txid: sharedLoser), "L is gone once both wallets ran") - // W's own record arrives, claiming all three outpoints. The - // `(outpoint, W)` tombstones occupy the duplicate-guard key, so no - // new pending relationship attaches to W's row — the premise that - // lets wallet A's callback below delete it. + // W's own record arrives through wallet A, claiming all three + // outpoints: wallet A's ordinary claim rows are staged beside B's + // tombstones (the tombstone does not occupy A's key on vout 0, and + // B's tombstones are not A's rows on vouts 1 and 2). deliverReinstatingRecord( handler, walletId: walletId, @@ -1997,11 +2054,17 @@ final class SweptTransactionPersistTests: XCTestCase { outputAddress: "yWinnerChange" ) - // Second sweep: X beats W. Wallet A's callback runs first, releases - // its own coin, and — finding no attached claim of any other - // wallet's — deletes the shared row. + // Second sweep: X beats W on vout 1 alone, so upstream releases + // vouts 0 and 2 in both wallets' views. Wallet A's callback runs + // first, frees its own coin, holds B's for X and deletes the shared + // row. sweep(handler, [ - Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + Batch( + losers: [sharedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 2)] + ) ], walletId: walletId) XCTAssertNil( transaction(container, txid: sharedWinner), @@ -2012,7 +2075,12 @@ final class SweptTransactionPersistTests: XCTestCase { // Wallet B's callback arrives after the row is gone, releasing one // of its two coins and holding the other. sweep(handler, [ - Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 2)]) + Batch( + losers: [sharedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 2)] + ) ], walletId: walletB) let heldOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) @@ -2020,7 +2088,7 @@ final class SweptTransactionPersistTests: XCTestCase { predicate: #Predicate { $0.outpoint == heldOutpoint } ) let heldTombstone = try XCTUnwrap( - try context.fetch(heldDescriptor).first, + try context.fetch(heldDescriptor).first { $0.walletId == walletB }, "wallet B's held tombstone must survive the row's absence" ) XCTAssertEqual( @@ -2261,13 +2329,7 @@ final class SweptTransactionPersistTests: XCTestCase { ) throws { let loser = loser ?? sweptTxid let context = ModelContext(container) - let swept = PersistentTransaction( - txid: loser, - transactionData: Data(repeating: 0x05, count: 10), - context: 0, - blockHeight: 0, - netAmount: -100_000 - ) + let swept = loserRow(txid: loser, spending: [(txid: spentTxid ?? fundingTxid, vout: 0)]) context.insert(swept) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0), @@ -2286,9 +2348,10 @@ final class SweptTransactionPersistTests: XCTestCase { private func pendingRows( _ container: ModelContainer, - spentTxid: Data? = nil + spentTxid: Data? = nil, + vout: UInt32 = 0 ) throws -> [PersistentPendingInput] { - let outpoint = PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0) + let outpoint = PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: vout) let descriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == outpoint } ) @@ -2567,15 +2630,24 @@ final class SweptTransactionPersistTests: XCTestCase { try seedSpend(in: container, winnerTakesA: false) // The same loser also claims an input whose funding side was never - // observed — the shape that would have become a tombstone. + // observed — the shape that would have become a tombstone. Its + // bytes name that input too, the way a real record's would. let unfundedTxid = Data(repeating: 0x77, count: 32) let context = ModelContext(container) - let loserRow = try XCTUnwrap(transaction(container, txid: sweptTxid)) + let loserTxid = sweptTxid + let loser = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + )).first + ) + loser.transactionData = serializedTransaction(inputs: [ + (txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1), (txid: unfundedTxid, vout: 0), + ]) context.insert(PersistentPendingInput( outpoint: PersistentTxo.makeOutpoint(txid: unfundedTxid, vout: 0), inputIndex: 2, spendingTxid: sweptTxid, - spendingTransaction: loserRow, + spendingTransaction: loser, walletId: walletId )) try context.save() @@ -2812,4 +2884,448 @@ final class SweptTransactionPersistTests: XCTestCase { "a higher height advances it" ) } + + // MARK: - Review round: hold by outpoint, per-wallet rows, one-round shapes + + /// The hold is keyed by the loser's decoded inputs, not by the links + /// its row happens to hold. Coin A's link already moved to a surviving + /// mempool spender M when L is swept: A is not released, so it is held + /// for the winner, and M's link — not the loser's — is kept; coin B, + /// linked to the loser, is detached. A link-keyed walk never saw A. + func testASweepHoldsEveryDecodedInputAndDetachesOnlyTheLosersOwnLinks() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + let survivorTxid = Data(repeating: 0x4A, count: 32) + let context = ModelContext(container) + let survivor = loserRow(txid: survivorTxid, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(survivor) + let coinA = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let a = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinA } + )).first + ) + a.spendingTransaction = survivor + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(held.isSpent, "an input the loser's bytes name is held even with its link elsewhere") + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual(held.spendingTransaction?.txid, survivorTxid, "a link that is not the loser's is kept") + + let detached = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(detached.isSpent) + XCTAssertNil(detached.spendingTransaction, "the loser's own link is detached") + } + + /// A held input with no `PersistentTxo` and no pending row of this + /// wallet's gets its tombstone created: the loser's stored bytes name + /// the coin, and the claim must not depend on a row `resolveInputOutpoint` + /// happened to leave behind. + func testASweepCreatesTheTombstoneForAHeldInputWithNoClaimRow() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)])) + try context.save() + XCTAssertTrue(try pendingRows(container).isEmpty, "sanity: no claim row at all") + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + let tombstone = try XCTUnwrap(try pendingRows(container).first, "the hold is created from the bytes") + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertEqual(tombstone.spendingTxid, winnerTxid) + XCTAssertEqual(tombstone.walletId, walletId) + XCTAssertEqual(tombstone.winnerMinedHeight, 400) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Pending rows are per (outpoint, spending txid, wallet): a second + /// wallet recording the same transaction gets its own claim row. Every + /// sweep decision on a pending row is scoped by that tag, so a claim + /// tagged with the first recorder alone would let one wallet's released + /// set decide the other wallet's coin. + func testASecondWalletRecordingTheSameSpendGetsItsOwnPendingRow() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + try context.save() + + deliverRecord(handler, walletId: walletId, txid: sweptTxid, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + deliverRecord(handler, walletId: walletB, txid: sweptTxid, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + + let rows = try pendingRows(container) + XCTAssertEqual(Set(rows.map(\.walletId)), [walletId, walletB], "one claim row per recording wallet") + XCTAssertEqual(rows.count, 2) + + // And a re-upsert by the same wallet still does not duplicate. + deliverRecord(handler, walletId: walletB, txid: sweptTxid, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + XCTAssertEqual(try pendingRows(container).count, 2) + } + + /// At drain time the tombstone tagged with the delivering wallet wins + /// over another wallet's, whatever their ages: the stamp is that + /// wallet's own sweep verdict on its own coin. + func testTheDrainPrefersTheTombstoneTaggedWithTheDeliveringWallet() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + let winnerForA = Data(repeating: 0x5A, count: 32) + let winnerForB = Data(repeating: 0x5B, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + // B's tombstone is the OLDER one, so a newest-wins pick would take A's. + let forB = PersistentPendingInput( + outpoint: outpoint, inputIndex: 0, spendingTxid: winnerForB, spendingTransaction: nil, walletId: walletB + ) + forB.isSweptTombstone = true + forB.createdAt = Date(timeIntervalSinceNow: -10) + let forA = PersistentPendingInput( + outpoint: outpoint, inputIndex: 0, spendingTxid: winnerForA, spendingTransaction: nil, walletId: walletId + ) + forA.isSweptTombstone = true + context.insert(forB) + context.insert(forA) + try context.save() + + deliverFundingUtxo(handler, walletId: walletB, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerForB, "the delivering wallet's own tombstone supplies the stamp") + XCTAssertTrue(try pendingRows(container).isEmpty, "every pending row on the outpoint is consumed by the drain") + } + + /// A drained tombstone stamps and nothing more. Its `inputIndex` is the + /// LOSER'S vin (L spent F at vin 0), and the winner it names spends F — + /// if at all — somewhere else; copying the index onto the winner's link + /// mislabelled the winner's own inputs, and minting the link from a + /// tombstone attributed a coin to a transaction that need not spend it. + /// W's row exists here precisely so an old drain WOULD have linked it. + func testADrainedTombstoneStampsWithoutMintingALinkOrAVinIndex() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(loserRow(txid: winnerTxid, spending: [(txid: Data(repeating: 0x58, count: 32), vout: 0)])) + let tombstone = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: nil, + walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.winnerMinedHeight = 400 + context.insert(tombstone) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent, "the stamp holds the coin") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertNil(coin.spendingTransaction, "no link is minted from a tombstone") + XCTAssertNil(coin.spendingInputIndex, "and no vin index — the tombstone's is the loser's") + } + + /// The winner's own ordinary claim row beside the tombstone is what + /// carries the link and the RIGHT vin index: W spends X at vin 0 and F + /// at vin 1, while the loser had spent F at vin 0. + func testTheWinnersOwnPendingRowSuppliesTheLinkAndVinIndexBesideATombstone() throws { + let (handler, container) = try makeHandler() + let otherCoinTxid = Data(repeating: 0x58, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let winner = loserRow( + txid: winnerTxid, + spending: [(txid: otherCoinTxid, vout: 0), (txid: fundingTxid, vout: 0)] + ) + context.insert(winner) + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstone = PersistentPendingInput( + outpoint: outpoint, inputIndex: 0, spendingTxid: winnerTxid, spendingTransaction: nil, walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.createdAt = Date(timeIntervalSinceNow: -10) + context.insert(tombstone) + context.insert(PersistentPendingInput( + outpoint: outpoint, inputIndex: 1, spendingTxid: winnerTxid, spendingTransaction: winner, walletId: walletId + )) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "the tombstone supplies the stamp") + XCTAssertEqual(coin.spendingTransaction?.txid, winnerTxid, "the ordinary row supplies the link") + XCTAssertEqual(coin.spendingInputIndex, 1, "and the winner's own vin index, not the loser's") + } + + /// One bracket carrying the winner's record and the loser's sweep — the + /// shape Rust produces when it folds `TransactionDetected(W)` and + /// `TransactionsSwept{[L]}` into one `store()`. The record pass moves + /// coin A's link from L to W; the sweep must then find L through the + /// round index rather than a store-only fetch, because a store-only + /// refetch of L resets its `inputs` inverse to the saved `[A, B]` and + /// with it A's freshly written link. After the commit W must still own + /// A: `walletFundedTransaction(W)` reads exactly that link, and a + /// chainlock promotion never re-emits the record. + func testAWinnerRecordedAndItsLoserSweptInOneRoundKeepsTheWinnersInputLink() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + round(handler) { + stageRecord(handler, txid: winnerTxid, context: 1, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + return stageSweeps(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: nil, released: [(txid: fundingTxid, vout: 1)]) + ], walletId: walletId) + } + + XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") + let taken = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual(taken.spendingTransaction?.txid, winnerTxid, "the winner keeps the link it recorded this round") + XCTAssertTrue(taken.isSpent) + let winner = try XCTUnwrap(transaction(container, txid: winnerTxid)) + XCTAssertEqual(winner.inputs.map(\.outpoint), [PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0)]) + XCTAssertTrue( + PlatformWalletPersistenceHandler.walletFundedTransaction(walletId: walletId, transaction: winner), + "the winner reads as wallet-funded after the commit" + ) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent) + } + + /// Stage a transaction record inside whatever bracket the caller + /// opened — the record half of `deliverRecord`, without the round. + private func stageRecord( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)] + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + var inputs: [OutPointFFI] = inputOutpoints.map { outpoint in + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = outpoint.vout + return input + } + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + inputs.withUnsafeMutableBufferPointer { inputsPtr in + record.input_outpoints = inputsPtr.baseAddress + record.input_outpoints_count = UInt(inputsPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + + /// The collector runs once per round, at the END — after the round's + /// own `utxos_added`. Rust folds `BlockProcessed` and + /// `SyncHeightAdvanced` into one `store()`, so the round that advances + /// `syncedHeight` to the winner's height can be the very round that + /// delivers the funding output the tombstone guards. Collecting first + /// deleted the tombstone, and the funding output then landed unspent — + /// a coin the chainlocked winner consumed, handed back as spendable. + func testAFundingOutputDeliveredInTheRoundThatCompletesTheBoundaryStillDrainsItsTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + // The chainlock half is already past the stamp; the synced half is + // one block short. + heightsRound(handler, synced: Self.winnerHeight - 1, chainLockHeight: Self.winnerHeight + 100) + XCTAssertEqual(try pendingRows(container).count, 1, "sanity: boundary not reached yet") + + // ONE round: the synced height reaches the stamp AND the funding + // output arrives. + round(handler) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = 0 + utxo.amount = 100_000 + utxo.address = address + utxo.height = Self.winnerHeight - 5 + utxo.is_confirmed = true + var applied = false + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = Self.winnerHeight + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + applied = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + return applied + } + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent, "the funding output drained the tombstone before anything could collect it") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertTrue(try pendingRows(container).isEmpty, "the drain consumed the tombstone") + } + + /// The release veto's stamp half: a coin held by a stamp naming a + /// stored, network-final winner F whose bytes DO spend it stays spent + /// when a later sweep of an unrelated loser names it released — + /// upstream reporting its own amnesia about F. + func testAReleaseNamingACoinAStoredFinalWinnerStampedIsRefused() throws { + let (handler, container) = try makeHandler() + let finalWinner = Data(repeating: 0x46, count: 32) + let unrelatedLoser = Data(repeating: 0x48, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, transactionData: Data(repeating: 0x04, count: 10), + context: 2, blockHeight: 100, netAmount: 100_000 + ) + context.insert(funding) + context.insert(PersistentTransaction( + txid: finalWinner, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 3, blockHeight: 120, netAmount: -100_000 + )) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100) + coin.walletId = walletId + coin.isSpent = true + coin.supersededByTxid = finalWinner + context.insert(coin) + context.insert(loserRow(txid: unrelatedLoser, spending: [(txid: fundingTxid, vout: 0)])) + try context.save() + + sweep(handler, [Batch( + losers: [unrelatedLoser], winner: winnerTxid, winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + )]) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(held.isSpent, "a stored chainlocked spender of the coin refuses the release") + XCTAssertEqual(held.supersededByTxid, finalWinner, "and keeps its attribution") + } + + /// The veto's other half: a stamp naming a stored, network-final + /// transaction whose bytes do NOT spend the coin does not refuse the + /// release — the stamp was a global hold written by another loser's + /// sweep, not a claim of that transaction's. + func testAReleaseNamingACoinStampedWithAFinalTransactionThatDoesNotSpendItIsHonoured() throws { + let (handler, container) = try makeHandler() + let stampedWinner = Data(repeating: 0x46, count: 32) + let unrelatedLoser = Data(repeating: 0x48, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, transactionData: Data(repeating: 0x04, count: 10), + context: 2, blockHeight: 100, netAmount: 100_000 + ) + context.insert(funding) + context.insert(PersistentTransaction( + txid: stampedWinner, + transactionData: serializedTransaction(inputs: [(txid: Data(repeating: 0x49, count: 32), vout: 0)]), + context: 3, blockHeight: 120, netAmount: -100_000 + )) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100) + coin.walletId = walletId + coin.isSpent = true + coin.supersededByTxid = stampedWinner + context.insert(coin) + context.insert(loserRow(txid: unrelatedLoser, spending: [(txid: fundingTxid, vout: 0)])) + try context.save() + + sweep(handler, [Batch( + losers: [unrelatedLoser], winner: winnerTxid, winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + )]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(freed.isSpent, "a stamp whose transaction does not spend the coin is no claim") + XCTAssertNil(freed.supersededByTxid) + } + + /// A released outpoint whose funding transaction is swept in the same + /// round is deleted, never freed. Here the parent's record was lost + /// (a `PersistentTxo` cannot outlive its transaction row, so the only + /// claim left on the dead output is a pending row of this wallet's), + /// and the release names that output: the claim is deleted with the + /// batch rather than left behind as a claim on a coin that can never + /// exist. + func testAReleaseNamingAnOutputOfACoSweptParentDeletesItsClaimRatherThanFreeingIt() throws { + let (handler, container) = try makeHandler() + let parentTxid = Data(repeating: 0xD1, count: 32) + let childTxid = Data(repeating: 0xD2, count: 32) + let claimantTxid = Data(repeating: 0xD3, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + // The child's own claim on the parent's output, and a third + // transaction's claim on the same dead output; the parent has no + // row at all. + let child = loserRow(txid: childTxid, spending: [(txid: parentTxid, vout: 0)], netAmount: -50_000) + context.insert(child) + let pOutpoint = PersistentTxo.makeOutpoint(txid: parentTxid, vout: 0) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, inputIndex: 0, spendingTxid: childTxid, spendingTransaction: child, walletId: walletId + )) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, inputIndex: 0, spendingTxid: claimantTxid, spendingTransaction: nil, walletId: walletId + )) + try context.save() + + sweep(handler, [Batch( + losers: [parentTxid, childTxid], winner: winnerTxid, winnerMinedHeight: 400, + released: [(txid: parentTxid, vout: 0)] + )]) + + XCTAssertNil(transaction(container, txid: childTxid)) + XCTAssertTrue( + try pendingRows(container, spentTxid: parentTxid).isEmpty, + "no claim on a dead parent's output survives the batch, released or not" + ) + } } From a93c4190a03fdfbb55ddb7b8c27662625369d17f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:45:03 +0300 Subject: [PATCH 7/9] test(swift-sdk): pin in-block-vs-IS-lock and migrate every widened model from V1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from review. The settled-link guard's own case — a plain in-block arrival against a spender that is only IS-locked — was covered only through the mempool variant, which the pre-existing `isSpent` branch would have refused anyway. The new case delivers the conflicting record at context 2 against an unmined IS-locked spender, checks the link stays and the coin stays spent through the following sweep's release, and pins chainlock-over-IS-lock as the one takeover. The V3 → V4 migration test wrote only a wallet and a pending row, so two of the four widened models never crossed the stage; it now carries a transaction and a spent coin through and asserts the stamp backfills to nil. A new V1 → V4 case migrates a wallet, a transaction and a coin from the oldest registered version, including the coin's relationship to its funding transaction — the freeze pinned where it matters. --- .../DashModelMigrationTests.swift | 108 ++++++++++++++++++ .../SweptTransactionPersistTests.swift | 86 ++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 6844e502294..08d1f84c383 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -101,6 +101,23 @@ final class DashModelMigrationTests: XCTestCase { spendingTxid: Data(repeating: 0x22, count: 32), spendingTransaction: nil, walletId: walletId)) + // A transaction with one spent output: the two other widened + // models, so the migration is exercised on every column V4 adds. + let v3Funding = DashSchemaV1.PersistentTransaction( + txid: Data(repeating: 0x33, count: 32), + transactionData: Data([0x03, 0x00]), + context: 2, + blockHeight: 100) + v3Container?.mainContext.insert(v3Funding) + let v3Coin = DashSchemaV1.PersistentTxo( + transaction: v3Funding, + vout: 0, + amount: 1_000, + address: "yV3Coin", + height: 100) + v3Coin.walletId = walletId + v3Coin.isSpent = true + v3Container?.mainContext.insert(v3Coin) try v3Container?.mainContext.save() v3Container = nil @@ -128,6 +145,97 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") + let coins = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") + XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") + XCTAssertNil( + coins.first?.supersededByTxid, + "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " + + "so the release and re-delivery rules see an ordinary spent coin") + let transactions = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") + } + + /// The whole chain from the oldest registered version, on the models this + /// change actually widens: a V1 store carrying a wallet, a transaction + /// and a coin must arrive at V4 with every row intact and the V4 columns + /// at their backfill values. V1 and V2 register the frozen component, + /// so the rows go in as frozen types and come out live — the property + /// the freeze exists to guarantee, pinned here where it matters most. + @MainActor + func testV1StoreWithWalletTransactionAndCoinMigratesToV4() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let walletId = Data(repeating: 0x1A, count: 32) + let txid = Data(repeating: 0x1B, count: 32) + + let v1Schema = Schema(versionedSchema: DashSchemaV1.self) + let v1Configuration = ModelConfiguration( + "DashChainMigrationTest", + schema: v1Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v1Container: ModelContainer? = try ModelContainer( + for: v1Schema, + configurations: [v1Configuration]) + v1Container?.mainContext.insert(DashSchemaV1.PersistentWallet( + walletId: walletId, + network: .testnet)) + let v1Funding = DashSchemaV1.PersistentTransaction( + txid: txid, + transactionData: Data([0x03, 0x00]), + context: 3, + blockHeight: 50, + netAmount: 2_000) + v1Container?.mainContext.insert(v1Funding) + let v1Coin = DashSchemaV1.PersistentTxo( + transaction: v1Funding, + vout: 1, + amount: 2_000, + address: "yV1Coin", + height: 50) + v1Coin.walletId = walletId + v1Container?.mainContext.insert(v1Coin) + try v1Container?.mainContext.save() + v1Container = nil + + let v4Schema = Schema(versionedSchema: DashSchemaV4.self) + let v4Configuration = ModelConfiguration( + "DashChainMigrationTest", + schema: v4Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v4Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v4Configuration]) + + let wallets = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.map(\.walletId), [walletId]) + XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) + let transactions = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(transactions.map(\.txid), [txid]) + XCTAssertEqual(transactions.first?.context, 3) + XCTAssertEqual(transactions.first?.netAmount, 2_000) + let coins = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(coins.count, 1) + XCTAssertEqual(coins.first?.vout, 1) + XCTAssertEqual(coins.first?.amount, 2_000) + XCTAssertEqual(coins.first?.walletId, walletId) + XCTAssertEqual(coins.first?.isSpent, false) + XCTAssertNil(coins.first?.supersededByTxid) + XCTAssertEqual( + coins.first?.transaction?.txid, txid, + "the coin's relationship to its funding transaction survives three stages") } /// What makes the V3 -> V4 stage lightweight: the two versions name the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index f0d254ec2df..0656e587879 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -817,6 +817,92 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertNil(freed.supersededByTxid) } + /// The case `settledSpenderLinkIsKept` exists for, which the mempool + /// variant above does not reach: a plain in-block arrival (context 2) + /// against a spender that is only IS-locked (context 1, unmined). Under + /// DIP-10 the lock already settled the input, so an in-block + /// double-spend of it is the losing side of a conflict, not newer + /// evidence — the link stays with F. The one sanctioned takeover is + /// chainlock-over-IS-lock, pinned in the second half. + func testAnInBlockArrivalDoesNotTakeTheLinkFromAnInstantSendLockedSpender() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let finalizedTxid = Data(repeating: 0x46, count: 32) + let chainlockedTxid = Data(repeating: 0x48, count: 32) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 200_000 + ) + let finalized = PersistentTransaction( + txid: finalizedTxid, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(finalized) + let settledCoin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + settledCoin.walletId = walletId + settledCoin.isSpent = false + settledCoin.spendingTransaction = finalized + context.insert(settledCoin) + try context.save() + + // L arrives IN A BLOCK, spending the coin F holds under its lock. + deliverRecord( + handler, + txid: sweptTxid, + context: 2, + inputOutpoints: [(txid: fundingTxid, vout: 0)] + ) + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual( + held.spendingTransaction?.txid, + finalizedTxid, + "an in-block arrival does not take the link from an IS-locked spender" + ) + XCTAssertTrue(held.isSpent, "but the coin is spent either way — the flag never lowers") + + // L is swept (its block lost to the lock) with the coin in the + // release set upstream computed from live records that no longer + // include F: the surviving link vetoes the release. + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + )]) + let afterSweep = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(afterSweep.isSpent, "the coin an IS-locked spender consumed stays spent") + XCTAssertEqual(afterSweep.spendingTransaction?.txid, finalizedTxid) + + // A chainlocked arrival is the one thing that outranks the lock. + deliverRecord( + handler, + txid: chainlockedTxid, + context: 3, + inputOutpoints: [(txid: fundingTxid, vout: 0)] + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).spendingTransaction?.txid, + chainlockedTxid, + "chainlock-over-IS-lock is the sanctioned takeover" + ) + } + /// The backstop for rows written before holds named their winner: a /// coin held spent with neither a spender nor a `supersededByTxid` /// stamp has nothing durable behind it, so the wallet re-delivering it From 00532b3da24e827aeecf778817d6845dc515df85 Mon Sep 17 00:00:00 2001 From: romchornyi Date: Tue, 8 Sep 2026 12:27:48 +0300 Subject: [PATCH 8/9] fix(kotlin-sdk): act on swept transactions in the Room store (#4590) Co-authored-by: Roman <51091564+jeanpierreroma@users.noreply.github.com> --- .../11.json | 4162 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 84 +- .../dashsdk/ffi/NativePersistenceBridge.kt | 103 + .../dashsdk/persistence/DashDatabase.kt | 63 +- .../PlatformWalletPersistenceHandler.kt | 803 +++- .../dashsdk/persistence/dao/AssetLockDao.kt | 14 +- .../dashsdk/persistence/dao/DocumentDao.kt | 122 +- .../dashsdk/persistence/dao/TransactionDao.kt | 18 + .../dashsdk/persistence/dao/TxoDao.kt | 108 + .../dashsdk/persistence/dao/WalletDao.kt | 17 + .../entities/PendingInputEntity.kt | 73 +- .../dashsdk/persistence/entities/TxoEntity.kt | 38 +- .../persistence/entities/WalletEntity.kt | 13 + .../dashsdk/wallet/PlatformWalletManager.kt | 16 + .../dashsdk/persistence/DashDatabaseTest.kt | 29 + .../PlatformWalletPersistenceHandlerTest.kt | 3295 ++++++++++++- .../rs-unified-sdk-jni/src/persistence.rs | 300 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 2 +- 18 files changed, 8966 insertions(+), 294 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json new file mode 100644 index 00000000000..725ba983524 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json @@ -0,0 +1,4162 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "f124080579cecd914cdc8f96827ee79b", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f124080579cecd914cdc8f96827ee79b')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index e6ce11bee92..ef90b3804a1 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -394,13 +394,89 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v10 → v11 adds the four sweep-hold columns — `txos.supersededByTxid` + * (nullable), `pending_inputs.isSweptTombstone` (defaulted `false`), + * `pending_inputs.winnerMinedHeight` (nullable) and + * `wallets.lastAppliedChainLockHeight` (nullable) — plus the two + * `pending_inputs` indexes the sweep lookup and the collector use. All + * additive. Pre-existing rows in each table must survive and read back + * with the new columns at their defaults (an unstamped, non-tombstone + * row is never collected; a wallet with no chainlock height has no + * finality boundary), the nullable columns must accept an explicit + * value on write, and `runMigrationsAndValidate` pins the indexes + * against the exported 11.json. + */ + @Test + fun migrate10To11AddsSweepHoldColumnsAndIndexes() { + val legacy = helper.createDatabase(dbName, 10) + legacy.execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + legacy.execSQL( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, " + + "transactionType, transactionTypeKind, netAmount, label, firstSeen, " + + "createdAt, lastUpdated) " + + "VALUES (x'02', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)", + ) + legacy.execSQL( + "INSERT INTO txos (outpoint, vout, amount, address, scriptPubKey, height, " + + "isCoinbase, isConfirmed, isInstantLocked, isLocked, isSpent, createdAt, " + + "lastUpdated, walletId, txid) " + + "VALUES (x'0201', 1, 1000, 'y', x'00', 0, 0, 0, 0, 0, 0, 0, 0, x'01', x'02')", + ) + legacy.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, walletId, " + + "createdAt) VALUES (x'0301', 0, x'02', x'01', 0)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 11, true, DashDatabase.MIGRATION_10_11) + db.query("SELECT supersededByTxid FROM txos WHERE outpoint = x'0201'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(c.isNull(0)) + } + db.query( + "SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs WHERE outpoint = x'0301'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + assertTrue("pre-migration rows read back unstamped", c.isNull(1)) + } + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration wallets have no chainlock height on record", c.isNull(0)) + } + db.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + + "walletId, createdAt, isSweptTombstone, winnerMinedHeight) " + + "VALUES (x'07', 0, x'05', x'01', 0, 1, 1234)", + ) + db.query( + "SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs WHERE outpoint = x'07'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + assertEquals(1234, c.getInt(1)) + } + db.execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321 WHERE walletId = x'01'") + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(4321, c.getInt(0)) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 10, + 11, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -408,16 +484,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, ).close() } - /** The full chain from v1 must also land on a valid v10 schema. */ + /** The full chain from v1 must also land on a valid v11 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 10, + 11, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -428,6 +505,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 65c25e423d0..d199a07ee36 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -60,6 +60,17 @@ abstract class NativePersistenceBridge { open fun persistenceCapabilitiesBits(): Long = 0L + companion object { + /** + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL` (bit 11, `0x800`). + * The one Kotlin home of this bit: `PlatformWalletPersistenceHandler` + * declares it through [persistenceCapabilitiesBits] and the public + * diagnostic mirror (`PlatformWalletPersistenceCapabilities`) aliases + * it, so the declaration and the mirror can never drift apart. + */ + const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800 + } + // ── Transactional bracketing ────────────────────────────────────── /** `on_changeset_begin_fn` — descriptor `([B)I`. */ @@ -294,6 +305,98 @@ abstract class NativePersistenceBridge { /** Close the current account bucket. Descriptor `([BI)I`. */ open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * Transactions the wallet removed in one sweep batch: [txidCount] raw + * 32-byte txids packed back to back in [txids], the single transaction + * [supersededBy] that settled their inputs, and the + * [releasedOutpointCount] 36-byte outpoint keys (raw txid followed by a + * little-endian vout, the same packing as `onWalletChangesetTransaction`'s + * `inputOutpoints`) packed in [releasedOutpoints] that this batch + * actually freed. Descriptor `([B[BI[B[BIZI)I`. + * + * Order within a round, stated once here (`store()` in + * `rs-platform-wallet-ffi/src/persistence.rs`): native fires the + * changeset callback — the header, then every account slice + * (transactions, then `utxos_added`, then `utxos_spent` per account) — + * then the chainlock-height slot ([onWalletChangesetChainLockHeight]) + * when the round carries a chainlock, then this slot once PER BATCH in + * the round's emission order, and only when the round swept + * something. Batches are non-commutative — each release is true only + * of the wallet its own sweep saw, and a later batch can keep spent a + * coin an earlier one freed — so an implementation must apply every + * call's holds before its releases and must apply the calls in order. + * It may buffer them until the round's end (the handler does, so the + * co-swept set spans the round), but it must never reorder them. + * + * [hasWinnerMinedHeight] says whether [winnerMinedHeight] is the + * winner's own mined block height (a block-context sweep) or + * meaningless (an InstantSend-locked winner not yet mined). It keys the + * lifetime of the durable claim every non-released input retains: a + * stamped hold is collectible once the chainlock finality boundary + * reaches the stamp, while the unmined case leaves the SAME hold + * UNSTAMPED — an IS-locked winner has no mining deadline, so no + * boundary can prove the held input's funding delivered-or-never — and + * no collector may ever remove an unstamped hold: it resolves only + * through proof, when the funding TXO materializes it, a later + * block-context sweep re-stamps it, or a release deletes it. An + * implementation that drops the hold instead (either by skipping it + * for an unmined winner or by aging it out) deletes the only + * cross-restart carrier of a consumed coin's spend claim and later + * restores that coin as spendable. + * + * Each removed transaction was a recorded spend that its winner beat to + * one of its inputs, so it can never confirm. Every other slot on this + * bus is additive; this is the only removal, and an implementation that + * ignores it keeps dead rows that are handed back at the next load and + * re-create a balance the wallet has already corrected. + * + * [releasedOutpoints] is wallet-scoped, not attributed per removal: an + * implementation holds every input of every row it deletes, so it only + * needs to know which of them came free. Everything else it holds was + * taken by the transaction that won those inputs and must stay spent. + * The set cannot be inferred from [supersededBy] — that transaction may + * pay entirely to outside addresses and never be reported here at all. + * + * Native delivers these through the persistence extension's + * size-negotiated sweep callback (not the wallet-changeset struct, whose + * bare-pointer ABI cannot version itself). The JNI layer wires that + * slot only when the concrete bridge OVERRIDES this method + * (`rs-unified-sdk-jni/src/persistence.rs`, `bridge_overrides`), and + * Rust's own derivation — slot present AND + * [CAPABILITY_CORE_SWEEP_REMOVAL] declared through + * [persistenceCapabilitiesBits] — is the gate: a subclass that declares + * the bit without overriding never has the slot wired, so Rust strips + * the bit and the sync watermark with it rather than trusting a + * removal that would never be applied. This default is therefore the + * benign ignore, never reached in production for a wired slot. + */ + open fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: ByteArray, + txidCount: Int, + supersededBy: ByteArray, + releasedOutpoints: ByteArray, + releasedOutpointCount: Int, + hasWinnerMinedHeight: Boolean, + winnerMinedHeight: Int, + ): Int = 0 + + /** + * The round's numeric chainlock height, fired on every round whose + * changeset carries a chainlock, after the changeset callback and + * before the sweep batches (see [onWalletChangesetTransactionsSwept] + * for the full order). Descriptor `([BI)I`. + * + * The bincode chainlock blob on the header call is opaque to Kotlin, + * and this scalar is the half of the swept-tombstone collection + * boundary `min(chainlockHeight, syncedHeight)` an implementation + * cannot otherwise know. Purely additive: a host that ignores it + * simply never collects tombstones, which is the safe direction — + * holding a tombstone forever is junk, collecting one early is a + * wrongly-freed claim. + */ + open fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 13e78e16471..822c08a242a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -119,9 +119,32 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * document id, ownership/sale state, counterparty, document timestamps and * marketplace reconciliation watermark. Defaults keep every legacy label an * owned, unlisted row until the first native marketplace sync refreshes it. + * + * Version 11 (durable sweep holds): adds `txos.supersededByTxid`, + * `pending_inputs.isSweptTombstone`, `pending_inputs.winnerMinedHeight` and + * `wallets.lastAppliedChainLockHeight`, plus two `pending_inputs` indexes. + * A sweep's winner can beat a loser to an input whose funding TXO has not + * landed here yet, and until now the only record of that claim was the + * loser's own `pending_inputs` row, which cascades away with the loser it + * names — leaving the funding TXO's later arrival free to re-insert the + * outpoint as an ordinary unspent UTXO. `supersededByTxid` is the durable + * hold on a materialised coin (the SQLite store's `spent_in_txid`); + * `isSweptTombstone` marks the detached pending row that carries the same + * hold for a coin that has not materialised; `winnerMinedHeight` is the + * winner's own mined height stamped on that tombstone, the horizon the + * end-of-round collector compares against the chainlock finality boundary + * `min(chainlockHeight, syncedHeight)`; and `lastAppliedChainLockHeight` + * is the numeric chainlock height `onWalletChangesetChainLockHeight` + * delivers, the chainlock half of that boundary (the bincode chainlock + * blob is opaque here). The `spendingTxid` index serves the sweep's + * claimed-row lookup; the `(walletId, isSweptTombstone, winnerMinedHeight)` + * index covers the collector. All four columns are additive: every + * pre-migration row reads back as an ordinary, unstamped, non-tombstone + * entry, and a wallet with no recorded chainlock height has no boundary + * at all (nothing collects). */ @Database( - version = 10, + version = 11, exportSchema = true, entities = [ WalletEntity::class, @@ -556,6 +579,43 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v10 → v11: the four additive sweep-hold columns and the two + * `pending_inputs` indexes — see the version-11 class doc above. + * `isSweptTombstone` is defaulted so every existing row reads as + * "not a tombstone"; the other three are nullable and need no + * default (pre-migration tombstones read back unstamped and are + * never collected; the chainlock height starts NULL, so no + * boundary exists until `onWalletChangesetChainLockHeight` + * records one). Column order = entity field order, and the index + * SQL is the exported schema's verbatim so Room's validation of a + * migrated database passes. + */ + val MIGRATION_10_11: Migration = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `txos` ADD COLUMN `supersededByTxid` BLOB") + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `isSweptTombstone` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `winnerMinedHeight` INTEGER", + ) + db.execSQL( + "ALTER TABLE `wallets` ADD COLUMN `lastAppliedChainLockHeight` INTEGER", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` " + + "ON `pending_inputs` (`spendingTxid`)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` " + + "ON `pending_inputs` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -574,6 +634,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, + MIGRATION_10_11, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index fa807e83c00..d68d1758f93 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -122,6 +122,15 @@ class PlatformWalletPersistenceHandler( * Null = unscoped (unit tests exercising raw persistence only). */ private val network: org.dashfoundation.dashsdk.Network? = null, + /** + * Where the sweep pass gets a stored loser's input outpoints (the + * outpoint-keyed hold, see `applySweptTransactions`). The production + * default decodes the stored consensus bytes through key-wallet-ffi's + * `transaction_decode`; tests inject a fake keyed by txid, since the + * native library is not loadable under Robolectric. + */ + private val storedTransactionInputs: StoredTransactionInputs = + NativeStoredTransactionInputs(network), ) : NativePersistenceBridge(), AutoCloseable { override fun persistenceCapabilitiesVersion(): Int = PERSISTENCE_CAPABILITIES_VERSION @@ -135,7 +144,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_UNSIGNED_TOKEN_STORAGE or CAPABILITY_WALLET_RESTORE or CAPABILITY_DPNS_NAME_STATES or - CAPABILITY_TRACKED_ASSET_LOCKS + CAPABILITY_TRACKED_ASSET_LOCKS or + CAPABILITY_CORE_SWEEP_REMOVAL /** * The single-thread executor created when no [dispatcher] is injected. @@ -195,6 +205,22 @@ class PlatformWalletPersistenceHandler( val pendingKeyDeltas: MutableList<(Map) -> Map> = mutableListOf() + + /** + * The round's sweep batches, in emission order. Buffered rather + * than staged as ops so [onChangesetEnd] can apply them as one + * pass after every account slice — the co-swept predicate must see + * the union of every batch's txids — and before the collector. + */ + val sweepBatches: MutableList = mutableListOf() + + /** + * Set when the round advanced the synced height (header slot) or + * the chainlock height (chainlock-height slot): the tombstone + * collection boundary may have moved, so [onChangesetEnd] runs + * [collectFinalizedSweptTombstones] once, last. + */ + var finalityAdvanced: Boolean = false } /** Open rounds keyed by walletId hex (a round is per-walletId). */ @@ -375,8 +401,7 @@ class PlatformWalletPersistenceHandler( * in its own transaction (the standalone-callback path). */ private fun stage(walletId: ByteArray, op: suspend (DashDatabase) -> Unit) { - val key = walletId.toHex() - val buffer = buffers[key] + val buffer = openRound(walletId) if (buffer != null) { buffer.ops.add(op) } else { @@ -386,6 +411,9 @@ class PlatformWalletPersistenceHandler( } } + /** The open round for [walletId], or null on the standalone-callback path. */ + private fun openRound(walletId: ByteArray): ChangesetBuffer? = buffers[walletId.toHex()] + // ── Bracketing ──────────────────────────────────────────────────── override fun onChangesetBegin(walletId: ByteArray): Int = guarded { @@ -421,6 +449,17 @@ class PlatformWalletPersistenceHandler( for (op in buffer.ops) { op(database) } + // Sweeps run after every account slice, so a winner + // arriving in this very round has its own rows written + // before the removal touches the coins it took; the + // collector runs last, after the sweeps, on the wallet + // row as this round left it. + if (buffer.sweepBatches.isNotEmpty()) { + applySweepRound(database, walletId, buffer.sweepBatches) + } + if (buffer.finalityAdvanced) { + collectFinalizedSweptTombstones(database, walletId) + } } } // Rows committed — the aliases are discoverable the normal way, @@ -693,6 +732,13 @@ class PlatformWalletPersistenceHandler( lockedDelta: Long, lastAppliedChainLockBytes: ByteArray, ): Int = guarded { + // The synced-height half of the tombstone collection boundary + // moved: flag the round so `onChangesetEnd` runs the collector once, + // after every slice and every sweep (see + // [collectFinalizedSweptTombstones]). Outside a round the write and + // the collection run together in the standalone transaction. + val round = openRound(walletId) + if (hasSyncedHeight) round?.finalityAdvanced = true stage(walletId) { db -> // Drop stale post-deletion callbacks (can't resurrect a wallet). val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage @@ -704,10 +750,69 @@ class PlatformWalletPersistenceHandler( lastUpdated = now(), ), ) + if (hasSyncedHeight && round == null) collectFinalizedSweptTombstones(db, walletId) + } + 0 + } + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistWalletChangesetChainLockHeight`: record the round's numeric + * chainlock height on the wallet row — monotonic max, through the + * narrow [org.dashfoundation.dashsdk.persistence.dao.WalletDao.advanceChainLockHeight] + * UPDATE so it cannot clobber what the header slot wrote moments + * earlier in the same round — and flag the round for the end-of-round + * collector. This slot is what turns the boundary on at all (no numeric + * height, no collection), so a chainlock-advancing round collects too, + * not only a header round. + */ + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { + val round = openRound(walletId) + round?.finalityAdvanced = true + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + val updated = db.walletDao().advanceChainLockHeight( + walletId, height, System.currentTimeMillis(), + ) + if (updated == 0) return@stage + if (round == null) collectFinalizedSweptTombstones(db, walletId) } 0 } + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `collectFinalizedSweptTombstones`, the Room mirror of the SQLite + * store's `collect_finalized_tombstones`. Delete this wallet's swept + * tombstones whose winner's mined height the chainlock finality + * boundary `min(chainlockHeight, syncedHeight)` has reached — + * key-wallet's `prune_finalized_observed_spends` condition verbatim. + * Both halves must be on record: without a numeric chainlock height + * nothing is provably final, and without filter coverage up to the + * winner's height the funding output could still be delivered by the + * unscanned range. + * + * Runs ONCE per round, at the end — from [onChangesetEnd], after every + * account slice and after every sweep batch, gated by + * [ChangesetBuffer.finalityAdvanced] — and reads both halves back from + * the wallet row as this round left it, so no caller assembles a + * fresh/stored pair. Running earlier (from the header, as it once did) + * was unsound: a round can fold a backward rescan that delivers a + * tombstone's funding output through `utxos_added` together with the + * synced height that finalizes it, and a header-time collection would + * delete the tombstone before the drain could move its hold onto the + * TXO, landing a provably consumed coin unspent. + */ + private suspend fun collectFinalizedSweptTombstones(db: DashDatabase, walletId: ByteArray) { + val wallet = db.walletDao().getByWalletId(walletId) ?: return + val chainLockHeight = wallet.lastAppliedChainLockHeight ?: return + if (wallet.syncedHeight <= 0) return + db.documentDao().collectFinalizedSweptTombstones( + walletId, + boundary = minOf(chainLockHeight, wallet.syncedHeight), + ) + } + override fun onWalletChangesetAccountBegin( walletId: ByteArray, accountIndex: Int, @@ -784,6 +889,14 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val existing = db.transactionDao().getByTxid(txid) + // A record for a txid an earlier round swept is upstream's newer + // word — the wallet's sweep state is not monotonic (a chainlocked + // return beats the IS-locked conflict that swept it), and the + // sweep deleted the row outright, so this upsert simply + // re-creates it. Its outputs come back only if this round also + // carries a fresh `onWalletChangesetUtxoAdded` for them, the same + // way any transaction's outputs ordinarily arrive alongside its + // record; nothing here can reconstruct them. // firstSeen: adopt non-zero from FFI; else keep existing; // else stamp now (never leave a placeholder zero). val resolvedFirstSeen = when { @@ -836,9 +949,9 @@ class PlatformWalletPersistenceHandler( TransactionAccountInvolvementEntity(txid, account.id), ) } - // Reconcile every spent input outpoint against our TXOs — a 1:1 - // port of Swift resolveInputOutpoint - // (PlatformWalletPersistenceHandler.swift:688-785). `inputOutpoints` + // Reconcile every spent input outpoint against our TXOs — a port + // of Swift `resolveInputOutpoint` + // (PlatformWalletPersistenceHandler.swift). `inputOutpoints` // carries EVERY input of this spending tx (even ones whose funding // TXO isn't known yet — Rust builds it from tx.input directly, not // the classified utxos_spent slice), so a spend observed before its @@ -846,31 +959,47 @@ class PlatformWalletPersistenceHandler( // link the spend now; otherwise we stage a pending row that the // funding TXO's later upsert drains. Without this the UTXO-restore // path (CORE-06) would hand a consumed output back to Rust as - // spendable after relaunch. Replaces the old getUnspentBySpendingTxid - // flip pass, which had no Swift analog and could not see - // out-of-order / unclassified inputs. + // spendable after relaunch. for (i in 0 until inputOutpointCount) { val outpoint = inputOutpoints.copyOfRange(i * 36, i * 36 + 36) val txo = db.txoDao().getByOutpoint(outpoint) if (txo != null) { - // Found: link the spend. Monotonic — only a confirmed - // (in-block) context flips isSpent; a mempool re-emit never - // downgrades a flag that is already true (mirrors spendIsInBlock). - db.txoDao().upsert( - txo.copy( - isSpent = txo.isSpent || context >= CONTEXT_IN_BLOCK, - spendingTxid = txid, - spendingInputIndex = i, - lastUpdated = now(), - ), - ) - for (p in db.documentDao().getPendingInputsByOutpoint(outpoint)) { - db.documentDao().deletePendingInput(p) + // Found: link the spend through the one link writer + // ([linkSpender]) shared with the `utxos_spent` channel + // and the pending drain. The LINK is guarded, not + // last-writer-wins: a network-final spender keeps it + // against any lower-context arrival + // ([keepSettledSpenderLink]), because the sweep release + // pass reads the link as a veto and upstream cannot + // re-supply a claim it has pruned or lost across a + // restart. A stamped, unlinked row ADOPTS this spender's + // link — attribution matters for `walletFundedTransaction` + // — but keeps `isSpent` and its stamp: the hold is the + // stamp, not the link. + val keepExistingLink = keepSettledSpenderLink(db, txo, txid, context) + db.txoDao().upsert(linkSpender(txo, txid, i, context, keepExistingLink)) + // Pending rows on an outpoint whose TXO exists are stale. + // When this record's claim was refused, only its OWN rows + // are stale; another wallet's claim or tombstone on the + // outpoint is not this record's to erase. Otherwise this + // wallet's ordinary rows are stale (their spend is now + // linked or displaced); tombstones stay — they carry a + // hold the collector or a release owns. + val stale = db.documentDao().getPendingInputsByOutpoint(outpoint).filter { p -> + if (keepExistingLink) { + p.spendingTransactionTxid?.contentEquals(txid) == true + } else { + p.walletId.contentEquals(walletId) && !p.isSweptTombstone + } } - } else if (db.documentDao().getPendingInput(outpoint, txid) == null) { + for (p in stale) db.documentDao().deletePendingInput(p) + } else if (db.documentDao().getPendingInput(outpoint, txid, walletId) == null) { // Funding TXO unknown — defer via a pending row (dedup-guarded - // on outpoint+spendingTxid). FK parent = the tx row upserted - // just above, so the CASCADE relationship holds. + // on outpoint + spendingTxid + walletId: a second wallet + // recording the same transaction gets its own row, because + // sweep holds and releases are decided per wallet). FK + // parent = the tx row upserted just above, so the CASCADE + // relationship holds. db.documentDao().upsertPendingInput( PendingInputEntity( outpoint = outpoint, @@ -910,6 +1039,22 @@ class PlatformWalletPersistenceHandler( } val existing = db.txoDao().getByOutpoint(outpoint) val coreAddressId = if (address.isNotEmpty()) address else null + // A materialised coin the wallet re-delivers unspent follows the + // wallet — the mirror of the SQLite store's upsert valve, which + // holds only never-materialised placeholders. The wallet knows + // this coin, and any network-final spender of a coin it knows is + // wallet-relevant by BIP158 prevout matching, so its own scan + // re-discovers the spend; refusing the re-delivery would lock a + // real coin out forever after a reorg of the winner, and on this + // side of the FFI a row at `isSpent = true` is never restored to + // Rust again. So an UNLINKED row — a sweep hold with its stamp, or + // a legacy flag with nothing behind it — is cleared, stamp + // included. A LINKED row keeps its flag and stamp: the link is + // this store's recorded spend attribution, the pending drain + // below and the sweep pass own that transition, and a spender + // that reached a block is confirmed evidence a re-delivery never + // displaces. + val linked = existing?.spendingTxid != null val row = TxoEntity( outpoint = outpoint, vout = vout, @@ -921,7 +1066,7 @@ class PlatformWalletPersistenceHandler( isConfirmed = isConfirmed, isInstantLocked = isInstantLocked, isLocked = isLocked, - isSpent = existing?.isSpent ?: false, + isSpent = linked && existing!!.isSpent, walletId = walletId, txid = txid, spendingTxid = existing?.spendingTxid, @@ -930,29 +1075,61 @@ class PlatformWalletPersistenceHandler( coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), createdAt = existing?.createdAt ?: java.util.Date(), lastUpdated = now(), + supersededByTxid = if (linked) existing!!.supersededByTxid else null, ) db.txoDao().upsert(row) // Drain any pending-input rows staged before this funding TXO - // existed — a 1:1 port of the Swift upsertUtxo drain - // (PlatformWalletPersistenceHandler.swift:895-953). A spend that - // arrived first was deferred (see onWalletChangesetTransaction); - // now that the funding output is here, link the newest pending - // spend (reorg/double-spend: newest wins) and clear the rows so - // the UTXO-restore path won't hand this consumed output back to + // existed — a port of the Swift `upsertUtxo` drain + // (PlatformWalletPersistenceHandler.swift). A spend that arrived + // first was deferred (see onWalletChangesetTransaction); now that + // the funding output is here, resolve the claim and clear the rows + // so the UTXO-restore path won't hand this consumed output back to // Rust as spendable. val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) if (pending.isNotEmpty()) { - val chosen = pending.maxByOrNull { it.createdAt }!! - val spending = db.transactionDao().getByTxid(chosen.spendingTxid) - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK - db.txoDao().upsert( - row.copy( - isSpent = row.isSpent || spentInBlock, - spendingTxid = chosen.spendingTxid, - spendingInputIndex = chosen.inputIndex, - lastUpdated = now(), - ), - ) + // A tombstone outranks every ordinary row regardless of age: + // ordinary rows are competing *observations*, a tombstone is + // the sweep's settled verdict that its winner consumed this + // coin. Prefer the tombstone tagged with the delivering + // wallet; failing that any tombstone on the outpoint still + // holds — the stamp is a txid fact, not a per-wallet one. + val tombstones = pending.filter { it.isSweptTombstone } + val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } + .maxByOrNull { it.createdAt } + ?: tombstones.maxByOrNull { it.createdAt } + if (tombstone != null) { + // A drained tombstone STAMPS, it never mints a spender + // link: the winner need not have its own `transactions` + // row, and a link would make the coin non-releasable + // (the release pass frees stamped, unlinked rows) when a + // later sweep proves the winner never took it. The + // existing link, if any, is carried as it was. + db.txoDao().upsert( + row.copy( + isSpent = true, + supersededByTxid = tombstone.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + // Competing ordinary observations: a network-final spender + // outranks a newer mempool one (its row is the settled + // claim the link guard protects); among equals the newest + // wins, as before (reorg / double-spend: newest wins). + val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } + val (chosen, spending) = ranked.maxWithOrNull( + compareBy>( + { it.second?.context ?: 0 }, + { it.first.createdAt }, + ), + )!! + val spendingContext = spending?.context ?: 0 + val keepExistingLink = + keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) + db.txoDao().upsert( + linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), + ) + } for (p in pending) db.documentDao().deletePendingInput(p) } } @@ -968,15 +1145,32 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> val outpoint = makeOutpoint(txid, vout) val txo = db.txoDao().getByOutpoint(outpoint) ?: return@stage - // Only mark spent when the spending tx exists in-block (never - // flap false on an unresolved spend), mirroring markUtxoSpent. + // Port of Swift `markUtxoSpent` + // (PlatformWalletPersistenceHandler.swift), through the same link + // writer as `onWalletChangesetTransaction` ([linkSpender]): the + // link is guarded by [keepSettledSpenderLink] and `isSpent` is + // monotonic — an + // arrival that is not in-block never lowers a flag a block, a + // sweep stamp, or a healed asset-lock spend already set. This + // channel IS reachable with a conflicting spender: + // `buildUtxoRestoreData` deliberately restores rows whose + // spender is IS-locked (context 1), so a later conflicting + // `utxos_spent` for that outpoint arrives here, and the guard is + // the only thing stopping an in-block usurper from stealing the + // settled link. A spender with no row yet cannot be linked (the + // FK forbids it) — the record channel links it when its record + // lands. val spending = db.transactionDao().getByTxid(spendingTxid) - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + val keepExistingLink = + spending == null || keepSettledSpenderLink(db, txo, spendingTxid, spending.context) db.txoDao().upsert( - txo.copy( - spendingTxid = if (spending != null) spendingTxid else txo.spendingTxid, - isSpent = if (spending != null) spentInBlock else txo.isSpent, - lastUpdated = now(), + linkSpender( + txo, + spendingTxid, + // This channel carries no vin index; a link it moves starts unindexed. + inputIndex = null, + spenderContext = spending?.context ?: 0, + keepExistingLink = keepExistingLink, ), ) } @@ -985,6 +1179,430 @@ class PlatformWalletPersistenceHandler( override fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * The one place a spender link and `isSpent` are decided, shared by the + * record channel, the `utxos_spent` channel and the ordinary pending + * drain. `isSpent` is monotonic on every channel: + * `existing || spender in-block || stamped` — a block flips it, a sweep + * stamp keeps it, and no lower-context arrival lowers it. In-block + * evidence counts even when the link is refused ([keepExistingLink]): + * the coin is provably consumed whichever spender is attributed. The + * link itself moves to [spender] unless the existing one is kept; a + * stamped, unlinked row adopts the new link and keeps its stamp. + */ + private fun linkSpender( + txo: TxoEntity, + spender: ByteArray, + inputIndex: Int?, + spenderContext: Int, + keepExistingLink: Boolean, + ): TxoEntity = + txo.copy( + isSpent = txo.isSpent || spenderContext >= CONTEXT_IN_BLOCK || txo.supersededByTxid != null, + spendingTxid = if (keepExistingLink) txo.spendingTxid else spender, + spendingInputIndex = if (keepExistingLink) txo.spendingInputIndex else inputIndex, + lastUpdated = now(), + ) + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `settledSpenderLinkIsKept`: whether [txo]'s existing `spendingTxid` + * link must survive an arriving spender ([newSpendingTxid], at + * [newContext]) that also claims the outpoint. A network-final spender's + * link is load-bearing — the sweep release pass reads it as a veto, and + * upstream cannot re-supply it for a spender it has pruned or lost + * across a restart. + * + * Kept when the existing spender's row still exists and is + * network-final: IS-locked, in-block, or chainlocked + * (context >= [CONTEXT_INSTANT_SEND]). Two mempool spenders keep + * last-writer-wins — neither claim outranks the other and a final + * winner sorts them out. The single sanctioned takeover mirrors DIP-10 + * precedence: a chainlocked arrival (context == [CONTEXT_CHAIN_LOCKED]) + * may take the coin from a spender that was only IS-locked — a plain + * in-block arrival may not, exactly as upstream's sweep gate refuses a + * plain block against a signed lock. A re-emit of the same spender is + * never a takeover. An unlinked row (stamped or not) keeps nothing — + * the stamp is not a link, and adoption is what attributes the coin. + */ + private suspend fun keepSettledSpenderLink( + db: DashDatabase, + txo: TxoEntity, + newSpendingTxid: ByteArray, + newContext: Int, + ): Boolean { + val existingTxid = txo.spendingTxid ?: return false + if (existingTxid.contentEquals(newSpendingTxid)) return false + val existing = db.transactionDao().getByTxid(existingTxid) ?: return false + if (existing.context < CONTEXT_INSTANT_SEND) return false + val chainlockOverIsLock = + newContext >= CONTEXT_CHAIN_LOCKED && existing.context == CONTEXT_INSTANT_SEND + return !chainlockOverIsLock + } + + // ── Sweeps ──────────────────────────────────────────────────────── + + /** One sweep batch, unpacked from the JNI trampoline's flat arrays at the callback. */ + private class SweepBatch( + val txids: List, + val supersededBy: ByteArray, + val releasedOutpoints: List, + /** The winner's own mined height for a block-context sweep; null for an IS-locked, unmined winner. */ + val winnerMinedHeight: Int?, + ) + + /** + * One input a swept loser claimed: the outpoint and, when the loser's + * bytes named it, its vin index (informational on a minted tombstone). + */ + private class LoserInput(val outpoint: ByteArray, val inputIndex: Int?) + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistWalletChangesetSweeps`: buffer one sweep batch into the open + * round. Batches are applied in order by [applySweepRound] from + * [onChangesetEnd] — after every account slice, before the collector — + * so the co-swept predicate sees the union of every batch's txids in + * the round, exactly as the SQLite store's `swept_txids` spans + * `cs.sweeps`. Outside a round (no `onChangesetBegin`) the batch is a + * round of its own, applied in its own transaction. + */ + override fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: ByteArray, + txidCount: Int, + supersededBy: ByteArray, + releasedOutpoints: ByteArray, + releasedOutpointCount: Int, + hasWinnerMinedHeight: Boolean, + winnerMinedHeight: Int, + ): Int = guarded { + require(supersededBy.size == 32) { "sweep winner must be a 32-byte txid" } + val batch = SweepBatch( + txids = unpackFixed(txids, txidCount, 32, "sweep txids"), + supersededBy = supersededBy.copyOf(), + releasedOutpoints = unpackFixed(releasedOutpoints, releasedOutpointCount, 36, "released outpoints"), + winnerMinedHeight = winnerMinedHeight.takeIf { hasWinnerMinedHeight }, + ) + val round = openRound(walletId) + if (round != null) { + round.sweepBatches += batch + } else { + runBlockingCatching { + database.withTransaction { applySweepRound(database, walletId, listOf(batch)) } + } + } + 0 + } + + /** + * Apply a round's sweep batches, in order. The co-swept set — an input + * whose funding txid is itself swept this round is a dead parent's + * output, deleted rather than held — spans every batch of the round; + * everything else is per batch, because each release is true only of + * the wallet its own sweep saw and a later batch has to be able to keep + * spent a coin an earlier one freed. + */ + private suspend fun applySweepRound(db: DashDatabase, walletId: ByteArray, batches: List) { + // Drop stale post-deletion callbacks (can't resurrect a wallet). + if (db.walletDao().getByWalletId(walletId) == null) return + val sweptTxidKeys = batches.flatMapTo(HashSet()) { batch -> batch.txids.map { it.toHex() } } + for (batch in batches) applySweptTransactions(db, walletId, batch, sweptTxidKeys) + } + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `applySweptTransaction`, for one batch of losers at once — the Room + * mirror of the SQLite store's `apply_sweep` plus its by-outpoint + * release pass. + * + * Each loser was a recorded spend that its winner beat to one of its + * inputs, so it can never confirm and Rust has already dropped it. + * Keeping the row would hand it back at the next load and re-create a + * balance the wallet has already corrected; its own outputs are dead + * coins for every wallet. + * + * THE HOLD IS KEYED BY OUTPOINT, NOT BY LINK. A loser's inputs are + * decoded from its stored bytes ([storedTransactionInputs]); a link can + * move between the record and the sweep — a winner recorded in the + * same round takes it first, at `isSpent = 0` while it is only + * IS-locked — and a hold keyed by `spendingTxid = loser` would miss + * exactly the coin the winner consumed. For every input NOT in this + * wallet's released set: a `txos` row is stamped + * (`isSpent = 1`, `supersededByTxid = winner`) whatever it is linked to + * — only a link that points at a swept loser is detached; a link to the + * winner or to any other surviving record is kept; every wallet's + * pending row claimed by the loser becomes a tombstone; and where + * nothing carries the claim for this wallet a tombstone is minted, so + * the funding output's later arrival drains into a stamp instead of + * landing the coin unspent. The loser's rows are the record-lost + * fallback: rows still linked to it and pending rows still claimed by + * it are unioned into the input set, so a loser whose bytes are gone + * (or a stub row `utxos_added` wrote) still holds by link. + * + * THE HOLD IS GLOBAL, THE RELEASE IS PER WALLET. `supersededBy` is a + * txid fact, so the first callback that sees the sweep holds every + * wallet's rows for the loser's inputs and then deletes the loser's row + * unconditionally (hold before delete, so the FK `SET NULL` / cascade + * only clears links, never the hold). Each wallet's own callback + * applies ITS released set to ITS rows: its pending rows on a released + * input are deleted outright (never a freed tombstone), and its `txos` + * row is freed by [applyReleases] — by outpoint, after the losers, so a + * later callback for the same loser from another wallet, finding no + * row, still applies its releases. A callback that never arrives + * leaves a coin conservatively held, not restorable. + * + * A released input is REFUSED when a stored network-final spender + * still claims it ([releaseVetoed]) — the mirror of the SQLite store's + * `surviving_stored_input_claims` — and a released outpoint whose + * funding transaction is itself swept this round is deleted, not freed. + * + * Every statement is a chunked bulk form (`IN (:chunk)`, + * [SWEEP_BIND_CHUNK]) so the arity never crosses API 29's 999-variable + * ceiling; the pending-row writes are rowid-keyed. + */ + private suspend fun applySweptTransactions( + db: DashDatabase, + walletId: ByteArray, + batch: SweepBatch, + sweptTxidKeys: Set, + ) { + val losers = batch.txids + val releasedKeys = batch.releasedOutpoints.mapTo(HashSet()) { it.toHex() } + val loserRows = chunkedFlatMap(losers) { db.transactionDao().getByTxids(it) } + .associateBy { it.txid.toHex() } + // Dead coins first: the losers' own outputs, for every wallet. + chunked(losers) { db.txoDao().deleteByTxids(it) } + + // The losers' inputs, by outpoint: decoded bytes first, then the + // link-keyed and claim-keyed fallbacks. + val linkedRows = chunkedFlatMap(losers) { db.txoDao().getBySpendingTxids(it) } + val claimedRows = chunkedFlatMap(losers) { db.documentDao().getPendingInputsBySpendingTxids(it) } + val inputs = LinkedHashMap() + for (loser in losers) { + val row = loserRows[loser.toHex()] ?: continue + if (row.transactionData.isEmpty()) continue + storedTransactionInputs.inputOutpoints(loser, row.transactionData).forEachIndexed { i, outpoint -> + inputs.putIfAbsent(outpoint.toHex(), LoserInput(outpoint, i)) + } + } + for (txo in linkedRows) { + inputs.putIfAbsent(txo.outpoint.toHex(), LoserInput(txo.outpoint, txo.spendingInputIndex)) + } + for (claim in claimedRows) { + inputs.putIfAbsent(claim.outpoint.toHex(), LoserInput(claim.outpoint, claim.inputIndex)) + } + val claimsByOutpoint = claimedRows.groupBy { it.outpoint.toHex() } + + val coSwept = ArrayList() + val held = ArrayList() + val released = ArrayList() + for ((key, input) in inputs) { + when { + sweptTxidKeys.contains(outpointTxid(input.outpoint).toHex()) -> coSwept += input.outpoint + releasedKeys.contains(key) -> released += input.outpoint + else -> held += input + } + } + + // Co-swept: a dead parent's output — nobody's coin, not something + // the winner took. Upstream's descendant closure always sweeps + // parent and child together and excludes exactly these outpoints + // from the released set, so the claim is neither released nor + // legitimate to hold; holding it would wedge the parent's + // chainlocked reinstatement (the re-delivered output would drain + // into the tombstone). Deleted outright, every wallet's claim. + chunked(coSwept) { db.txoDao().deleteByOutpoints(it) } + chunked(coSwept.flatMap { claimsByOutpoint[it.toHex()].orEmpty() }.map { it.id }) { + db.documentDao().deletePendingInputsByIds(it) + } + + // Held, globally: stamp the rows that exist, tombstone every + // wallet's claim, mint this wallet's tombstone where nothing + // carries the claim. A block-context winner stamps its mined + // height; an IS-locked, unmined winner leaves a new tombstone + // unstamped and an existing stamp untouched. + val heldOutpoints = held.map { it.outpoint } + val heldTxoKeys = chunkedFlatMap(heldOutpoints) { db.txoDao().getByOutpoints(it) } + .mapTo(HashSet()) { it.outpoint.toHex() } + chunked(heldOutpoints) { db.txoDao().holdByOutpoints(it, batch.supersededBy) } + val heldClaimIds = held.flatMap { claimsByOutpoint[it.outpoint.toHex()].orEmpty() }.map { it.id } + chunked(heldClaimIds) { ids -> + db.documentDao().tombstonePendingInputs( + ids, batch.supersededBy, + hasWinnerMinedHeight = batch.winnerMinedHeight != null, + winnerMinedHeight = batch.winnerMinedHeight ?: 0, + ) + } + val minted = held.filter { input -> + val key = input.outpoint.toHex() + !heldTxoKeys.contains(key) && + claimsByOutpoint[key].orEmpty().none { it.walletId.contentEquals(walletId) } + }.map { input -> + PendingInputEntity( + outpoint = input.outpoint, + inputIndex = input.inputIndex ?: 0, + spendingTxid = batch.supersededBy, + spendingTransactionTxid = null, + walletId = walletId, + isSweptTombstone = true, + winnerMinedHeight = batch.winnerMinedHeight, + ) + } + if (minted.isNotEmpty()) db.documentDao().insertPendingInputs(minted) + + // Released, per wallet: this wallet's claims on a released input are + // deleted outright; another wallet's claims are held — its own + // callback carries its own verdict. The `txos` rows are decided by + // the by-outpoint pass below. + val (ownReleasedClaims, foreignReleasedClaims) = + released.flatMap { claimsByOutpoint[it.toHex()].orEmpty() } + .partition { it.walletId.contentEquals(walletId) } + chunked(ownReleasedClaims.map { it.id }) { db.documentDao().deletePendingInputsByIds(it) } + chunked(foreignReleasedClaims.map { it.id }) { ids -> + db.documentDao().tombstonePendingInputs( + ids, batch.supersededBy, + hasWinnerMinedHeight = batch.winnerMinedHeight != null, + winnerMinedHeight = batch.winnerMinedHeight ?: 0, + ) + } + + // Only now the dead links and the rows themselves: every hold above + // is already in place, so the cascade can only take claims that were + // released or attached to nothing. + chunked(losers) { db.txoDao().detachSpenders(it) } + chunked(losers) { db.transactionDao().deleteByTxids(it) } + + applyReleases(db, walletId, batch, sweptTxidKeys) + } + + /** + * The by-outpoint release pass for one batch, this wallet's rows only. + * Releases are outpoint-keyed facts applied after the losers rather + * than only through each loser's decoded inputs: the loser freeing a + * coin need not have a row here any more (another wallet's callback + * deleted it, or a fatal flush wiped the round that carried it), and + * dropping the release with it would leave the hold in place forever. + * + * A released outpoint whose funding transaction is swept in this round + * is deleted whatever its shape — a coin created by a dead transaction + * cannot be unspent, only gone — together with every wallet's tombstone + * on it. Every other released row of this wallet is freed unless a + * stored network-final spender vetoes it ([releaseVetoed]); this + * wallet's tombstones on released outpoints are deleted (a released + * placeholder is never left as a freed tombstone). Another wallet's row + * on an outpoint this wallet released is not this wallet's to decide. + */ + private suspend fun applyReleases( + db: DashDatabase, + walletId: ByteArray, + batch: SweepBatch, + sweptTxidKeys: Set, + ) { + if (batch.releasedOutpoints.isEmpty()) return + val (deadOutputs, candidates) = batch.releasedOutpoints.partition { + sweptTxidKeys.contains(outpointTxid(it).toHex()) + } + chunked(deadOutputs) { + db.txoDao().deleteByOutpoints(it) + db.documentDao().deleteSweptTombstonesByOutpoints(it) + } + val finalClaims = HashMap() + val freed = ArrayList() + for (row in chunkedFlatMap(candidates) { db.txoDao().getByOutpoints(it) }) { + if (!row.walletId.contentEquals(walletId)) continue + if (releaseVetoed(db, row, sweptTxidKeys, finalClaims)) continue + freed += row.outpoint + } + chunked(freed) { db.txoDao().releaseByOutpoints(it, walletId) } + chunked(candidates) { db.documentDao().deleteWalletSweptTombstonesByOutpoints(walletId, it) } + } + + /** + * A stored network-final claimant of a released coin, memoised per + * txid within one release pass. [inputs] is the set of outpoint keys + * its stored bytes spend; null when those bytes could not be decoded, + * in which case the claim vetoes every outpoint it is asked about — + * failing CLOSED, as the SQLite store's claim scan does, rather than + * silently dropping a veto. + */ + private class FinalClaim(val inputs: Set?) + + /** + * The release veto — the mirror of the SQLite store's + * `surviving_stored_input_claims`: a release of [row]'s outpoint is + * refused when the row is linked to a stored transaction with context + * >= InstantSend-locked that is not swept in this round, or when its + * stamp names such a transaction AND that transaction's stored bytes + * actually spend the outpoint (a hold stamps the winner on every + * non-released input of a loser, including one a different surviving + * record claimed, so the stamp alone is not proof the winner took the + * coin — the reference vetoes by the claimant's inputs, and so does + * this). A stamp whose transaction has no stored row (a chained sweep + * already deleted it, or it never paid this wallet) does not veto. + * Bare mempool claimants never veto: a mempool row is the one context + * that can go stale forever, and letting it veto an authoritative + * release would strand the coin. + */ + private suspend fun releaseVetoed( + db: DashDatabase, + row: TxoEntity, + sweptTxidKeys: Set, + finalClaims: HashMap, + ): Boolean { + val link = row.spendingTxid + if (link != null && !sweptTxidKeys.contains(link.toHex())) { + val spender = db.transactionDao().getByTxid(link) + if (spender != null && spender.context >= CONTEXT_INSTANT_SEND) return true + } + val stamp = row.supersededByTxid ?: return false + val stampKey = stamp.toHex() + if (sweptTxidKeys.contains(stampKey)) return false + val claim = finalClaims.getOrPut(stampKey) { + val winner = db.transactionDao().getByTxid(stamp) + if (winner == null || winner.context < CONTEXT_INSTANT_SEND || winner.transactionData.isEmpty()) { + null + } else { + val inputs = try { + storedTransactionInputs.inputOutpoints(stamp, winner.transactionData) + .mapTo(HashSet()) { it.toHex() } + } catch (t: Throwable) { + Log.w(TAG, "sweep release: stored winner bytes undecodable; vetoing its stamped coins", t) + null + } + FinalClaim(inputs) + } + } ?: return false + val inputs = claim.inputs ?: return true + return inputs.contains(row.outpoint.toHex()) + } + + /** Run [op] over [items] in [SWEEP_BIND_CHUNK]-sized slices (no-op on an empty list). */ + private suspend fun chunked(items: List, op: suspend (List) -> Unit) { + for (slice in items.chunked(SWEEP_BIND_CHUNK)) op(slice) + } + + /** [chunked] for reads: the concatenation of every slice's rows. */ + private suspend fun chunkedFlatMap(items: List, op: suspend (List) -> List): List { + if (items.isEmpty()) return emptyList() + val out = ArrayList() + for (slice in items.chunked(SWEEP_BIND_CHUNK)) out += op(slice) + return out + } + + /** + * Split a flat `count × width` byte array (the JNI trampoline's packing + * for txids and outpoints) into its elements, refusing a length that + * disagrees with the count — a descriptor or packing drift must fail + * the round, not silently truncate a sweep. + */ + private fun unpackFixed(packed: ByteArray, count: Int, width: Int, what: String): List { + require(count >= 0 && packed.size == count * width) { + "$what: expected $count × $width bytes, got ${packed.size}" + } + return List(count) { i -> packed.copyOfRange(i * width, (i + 1) * width) } + } + // ── Identities ──────────────────────────────────────────────────── override fun onPersistIdentityUpsert( @@ -1679,7 +2297,7 @@ class PlatformWalletPersistenceHandler( // flip the linked TXOs here. Monotonic, and keyed strictly to // TXOs already linked to THIS lock's funding txid. if (incomingStatus >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { - val fundingTxid = outPoint.copyOfRange(0, 32) + val fundingTxid = outpointTxid(outPoint) db.txoDao().markSpentBySpendingTxid(fundingTxid, now()) } } @@ -1688,18 +2306,13 @@ class PlatformWalletPersistenceHandler( override fun onPersistAssetLockRemoval(walletId: ByteArray, outPoint: ByteArray): Int = guarded { stage(walletId) { db -> - val outPointHex = encodeOutPointHex(outPoint) - // Same terminal rule as the upsert guard above: a Consumed (4) - // row is deliberately retained for historical lookup and the - // only removal emitter (`untrack_asset_lock`) targets rejected - // Built rows — a removal reaching a consumed row is by - // construction a stale write. Mirrors Swift `persistAssetLocks` - // (PlatformWalletPersistenceHandler.swift:310). - val existing = db.assetLockDao().getByOutPointHex(outPointHex) - if (existing != null && existing.statusRaw == ASSET_LOCK_STATUS_CONSUMED) { - return@stage - } - db.assetLockDao().deleteByOutPointHex(outPointHex) + // The terminal rule — a Consumed row is retained for historical + // lookup, so a removal reaching one is a stale write — lives in + // the DAO's `statusRaw != 4` clause (see + // `AssetLockDao.deleteByOutPointHex`), the same predicate the + // SQLite store's DELETE carries; mirrors Swift `persistAssetLocks` + // (PlatformWalletPersistenceHandler.swift). + db.assetLockDao().deleteByOutPointHex(encodeOutPointHex(outPoint)) } 0 } @@ -2735,7 +3348,7 @@ class PlatformWalletPersistenceHandler( val out = ArrayList(locks.size) for (lock in locks) { val outPoint = decodeOutPointHex(lock.outPointHex) ?: continue - val txid = outPoint.copyOfRange(0, 32) + val txid = outpointTxid(outPoint) val tx = database.transactionDao().getByTxid(txid) ?: continue if (tx.transactionData.isEmpty()) continue out.add( @@ -3357,9 +3970,21 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_WALLET_RESTORE: Long = 0x80 internal const val CAPABILITY_DPNS_NAME_STATES: Long = 0x100 internal const val CAPABILITY_TRACKED_ASSET_LOCKS: Long = 0x200 + internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL private const val TAG = "DashPersistence" + /** + * Slice size for the sweep pass's `IN (:chunk)` statements: well + * under the 999-variable ceiling API 29's framework SQLite still + * carries, with room for the statement's fixed binds. + */ + private const val SWEEP_BIND_CHUNK = 500 + + /** `TransactionContext::InstantSend` — network-final under DIP-10. */ + private const val CONTEXT_INSTANT_SEND = 1 + /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 @@ -3374,6 +3999,8 @@ class PlatformWalletPersistenceHandler( */ private const val ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED = 2 + /** `TransactionContext::InChainLockedBlock` — outranks an IS lock. */ + private const val CONTEXT_CHAIN_LOCKED = 3 /** `Network.testnet` rawValue — the Swift fallback network. */ private const val NETWORK_TESTNET = 1 @@ -3471,6 +4098,49 @@ interface PrivateKeyDeriver { */ data class DerivedKeyStoreResult(val identifier: String, val wasNewlyCreated: Boolean) +/** + * Names a stored transaction's input outpoints for the sweep pass, which + * keys its hold by OUTPOINT: a swept loser's inputs are read from its own + * stored bytes, never inferred from which rows happen to link to it. The + * txid is passed alongside the bytes so an implementation can refuse a + * key/record disagreement — the typed key is what named the row a swept + * loser, and processing some other record's inputs under it would hold or + * free the wrong coins. + * + * Must throw on bytes it cannot decode: the round then fails and rolls + * back (fail closed, as the SQLite store's `apply_sweep` does on a bad + * blob) rather than sweeping a loser whose inputs are unknown. + */ +fun interface StoredTransactionInputs { + /** 36-byte outpoints ([makeOutpoint] layout) of every input of [txid], in vin order. */ + fun inputOutpoints(txid: ByteArray, txData: ByteArray): List +} + +/** + * Production [StoredTransactionInputs]: key-wallet-ffi's + * `transaction_decode` through [org.dashfoundation.dashsdk.keywallet.TransactionDecoder]. + * The decoder is a stateless marshaler — it takes no wallet-manager lock — + * so calling it from inside a persistence callback (which runs while Rust + * holds that lock) cannot deadlock; the "no native calls under + * `callbackExclusion`" rule guards the manager lock, not this. [network] + * only shapes the decoder's address rendering, which this caller discards, + * so an unscoped handler decodes on the default network. + */ +class NativeStoredTransactionInputs( + private val network: org.dashfoundation.dashsdk.Network?, +) : StoredTransactionInputs { + override fun inputOutpoints(txid: ByteArray, txData: ByteArray): List { + val decoded = org.dashfoundation.dashsdk.keywallet.TransactionDecoder.decode( + txData, + network ?: org.dashfoundation.dashsdk.Network.DEFAULT, + ) + check(decoded.txid.contentEquals(txid)) { + "stored transaction bytes disagree with their txid key" + } + return decoded.inputs.map { makeOutpoint(it.prevTxid, it.prevVout) } + } +} + // ── Free functions (unit-testable, no `this`) ───────────────────────── /** Lowercase hex of a byte array (used as the changeset-buffer key). */ @@ -3574,7 +4244,7 @@ internal fun base58Encode(input: ByteArray): String { */ internal fun encodeOutPointHex(outPoint: ByteArray): String { require(outPoint.size == 36) { "outpoint must be 36 bytes, got ${outPoint.size}" } - val txidWire = outPoint.copyOfRange(0, 32) + val txidWire = outpointTxid(outPoint) val displayTxid = txidWire.reversedArray() val vout = (outPoint[32].toInt() and 0xFF) or ((outPoint[33].toInt() and 0xFF) shl 8) or @@ -3618,6 +4288,9 @@ internal fun decodeOutPointHex(hex: String): ByteArray? { return out } +/** The 32-byte wire-order txid half of a 36-byte outpoint built by [makeOutpoint]. */ +internal fun outpointTxid(outpoint: ByteArray): ByteArray = outpoint.copyOfRange(0, 32) + /** Build a 36-byte outpoint from a wire-order txid + vout (matches `makeOutpoint`). */ internal fun makeOutpoint(txid: ByteArray, vout: Int): ByteArray { val out = ByteArray(36) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt index 92924b2ebc0..6e3fcea24f2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt @@ -172,8 +172,18 @@ interface AssetLockDao { @Delete suspend fun delete(assetLock: AssetLockEntity) - /** Consumed-lock removal path (`$0.outPointHex == hex`). */ - @Query("DELETE FROM asset_locks WHERE outPointHex = :outPointHex") + /** + * Asset-lock removal path (`onPersistAssetLockRemoval`). The + * `statusRaw != 4` guard is the same terminal rule SQLite's DELETE + * (`status != 'consumed'`) and Swift's `statusRaw == 4` skip apply: a + * Consumed row is deliberately retained for historical lookup, and + * neither removal producer can legitimately name one — a Built row + * rejected at broadcast never got that far, and a sweep of the funding + * transaction 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. + */ + @Query("DELETE FROM asset_locks WHERE outPointHex = :outPointHex AND statusRaw != 4") suspend fun deleteByOutPointHex(outPointHex: String) /** Wallet teardown mirror of `deleteWalletData`'s asset-lock pass. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt index 3f343d8d267..649df5271c4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt @@ -2,6 +2,7 @@ package org.dashfoundation.dashsdk.persistence.dao import androidx.room.Dao import androidx.room.Delete +import androidx.room.Insert import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @@ -163,17 +164,132 @@ interface DocumentDao { @Query("SELECT * FROM pending_inputs WHERE outpoint = :outpoint") suspend fun getPendingInputsByOutpoint(outpoint: ByteArray): List - /** Duplicate guard used before inserting a pending row. */ + /** + * Duplicate guard used before inserting a pending row. Keyed by + * `(outpoint, spendingTxid, walletId)`: a sweep's hold and release + * verdicts are per wallet, so a second wallet recording the same + * transaction must get its own row — otherwise the first wallet's + * collector or release could erase the only hold the second was + * entitled to keep. + */ @Query( "SELECT * FROM pending_inputs WHERE outpoint = :outpoint " + - "AND spendingTxid = :spendingTxid" + "AND spendingTxid = :spendingTxid AND walletId = :walletId LIMIT 1" ) - suspend fun getPendingInput(outpoint: ByteArray, spendingTxid: ByteArray): PendingInputEntity? + suspend fun getPendingInput( + outpoint: ByteArray, + spendingTxid: ByteArray, + walletId: ByteArray, + ): PendingInputEntity? /** Per-wallet pending-input scan (cleanup / diagnostics). */ @Query("SELECT * FROM pending_inputs WHERE walletId = :walletId") fun observePendingInputsByWallet(walletId: ByteArray): Flow> + /** + * Every wallet's rows claimed by one of [spendingTxids] — the ordinary + * rows a loser staged (`spendingTxid == spendingTransactionTxid`) and + * the tombstones an earlier sweep re-pointed at it (`spendingTxid` + * alone, the FK already detached). One bulk read per sweep batch, all + * wallets, because the hold is global: the first callback that sees a + * sweep tombstones every wallet's claim on the loser's inputs. Chunked + * by the caller (`SWEEP_BIND_CHUNK`) so the arity stays under the + * 999-variable ceiling API 29's framework SQLite still carries. + */ + @Query("SELECT * FROM pending_inputs WHERE spendingTxid IN (:spendingTxids)") + suspend fun getPendingInputsBySpendingTxids(spendingTxids: List): List + + /** + * Turn the rows with these ids into swept tombstones held by [winner]: + * detach the FK (the loser's row is about to be deleted and must not + * cascade the claim away), re-point the scalar at the winner, flag the + * row, and stamp the winner's mined height when this sweep has one — + * an IS-locked, unmined winner (`hasWinnerMinedHeight = false`) keeps + * whatever stamp the row already carries, because upstream's + * observed-spend entry is never retracted by an unconfirmed conflict + * and collection at the old height stays sound. Rowid-keyed and + * chunked by the caller. + */ + @Query( + "UPDATE pending_inputs SET spendingTransactionTxid = NULL, spendingTxid = :winner, " + + "isSweptTombstone = 1, " + + "winnerMinedHeight = CASE WHEN :hasWinnerMinedHeight THEN :winnerMinedHeight " + + "ELSE winnerMinedHeight END " + + "WHERE id IN (:ids)", + ) + suspend fun tombstonePendingInputs( + ids: List, + winner: ByteArray, + hasWinnerMinedHeight: Boolean, + winnerMinedHeight: Int, + ) + + /** Rowid-keyed bulk delete; chunked by the caller. */ + @Query("DELETE FROM pending_inputs WHERE id IN (:ids)") + suspend fun deletePendingInputsByIds(ids: List) + + /** + * Delete every wallet's tombstones on [outpoints] — outputs of a + * transaction swept in this round, dead coins nobody may hold a claim + * on (holding one would wedge the parent's chainlocked reinstatement). + * Chunked by the caller. + */ + @Query( + "DELETE FROM pending_inputs WHERE isSweptTombstone = 1 AND outpoint IN (:outpoints)", + ) + suspend fun deleteSweptTombstonesByOutpoints(outpoints: List) + + /** + * Delete [walletId]'s tombstones on [outpoints] — this wallet's + * release of those coins. A released placeholder is deleted outright, + * never left as a freed tombstone: no row is the correct end state, and + * the funding output's own later upsert creates the real row freshly + * unspent. Ordinary rows on the same outpoints are NOT touched — they + * are some surviving spender's spend-before-funding claim, not the + * swept loser's. Chunked by the caller. + */ + @Query( + "DELETE FROM pending_inputs WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND outpoint IN (:outpoints)", + ) + suspend fun deleteWalletSweptTombstonesByOutpoints(walletId: ByteArray, outpoints: List) + + /** Bulk insert of freshly minted tombstones; Room binds one row at a time. */ + @Insert + suspend fun insertPendingInputs(rows: List) + + /** + * Bounded tombstone lifetime: delete this wallet's swept tombstones + * whose winner's mined height the chainlock finality boundary has + * reached (`:boundary` = `min(chainlockHeight, syncedHeight)`, read + * by the caller from the wallet row at the end of the round) — + * key-wallet's `prune_finalized_observed_spends` condition verbatim, + * no observation-age margin: the stamp IS the winner's height, so at + * the boundary the funding transaction (mined at or below it) has been + * filter-scanned with no false negatives. A tombstone still + * collectible here never drained — its funding TXO never arrived — so + * the junk case (a foreign input of a swept incoming payment) is + * exactly what this removes; a genuine claim's row was already + * deleted by the drain that moved the hold onto the TXO. Selects + * tombstones only, served by the + * `(walletId, isSweptTombstone, winnerMinedHeight)` index; ordinary + * pending rows are never materialised here. Unstamped rows are never + * collected — and they are a CURRENT, deliberate shape, not legacy + * data: a mempool-context sweep (IS-locked, unmined winner) writes its + * tombstone with a null stamp, because such a winner has no mining + * deadline and no boundary can prove the held funding + * delivered-or-never. An unstamped hold resolves only through proof — + * the funding TXO drains it, a later block-context sweep re-stamps it + * into this collector's reach, or a release deletes it — and holding + * an unresolved one forever is the contract, not a safe fallback. + */ + @Query( + "DELETE FROM pending_inputs " + + "WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND winnerMinedHeight IS NOT NULL AND winnerMinedHeight <= :boundary", + ) + suspend fun collectFinalizedSweptTombstones(walletId: ByteArray, boundary: Int) + @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index 322750d27a4..c72a5569832 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -69,6 +69,7 @@ interface TransactionDao { /** * Provider kinds 2…5 scoped through explicit account membership. The * ordering preserves Core's same-block transaction order when present. + */ @Query( "SELECT DISTINCT transactions.* FROM transactions " + @@ -93,6 +94,23 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE txid = :txid") suspend fun deleteByTxid(txid: ByteArray) + /** + * Point lookups for one sweep batch's losers in one statement; chunked + * by the caller (`SWEEP_BIND_CHUNK`) to stay under the 999-variable + * ceiling API 29's framework SQLite still carries. + */ + @Query("SELECT * FROM transactions WHERE txid IN (:txids)") + suspend fun getByTxids(txids: List): List + + /** + * Delete a sweep batch's losers in one statement, after every hold on + * their inputs is in place: the FK cascade takes any still-attached + * pending row and any remaining own output with it, and `SET NULL` + * clears any link still pointing at a loser. Chunked by the caller. + */ + @Query("DELETE FROM transactions WHERE txid IN (:txids)") + suspend fun deleteByTxids(txids: List) + /** * Orphan sweep run after a wallet wipe (Swift `deleteWalletData`'s * post-delete pass): drop transactions no longer referenced by any diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index 361c8e37b98..ac484917836 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt @@ -68,6 +68,114 @@ interface TxoDao { "WHERE outpoint = :outpoint AND isSpent = 0", ) suspend fun markSpentByOutpoint(outpoint: ByteArray, now: Date): Int + /** + * Rows keyed by outpoint — the sweep pass's bulk read of a loser's + * decoded inputs and of a batch's released outpoints. Callers chunk + * the list (`SWEEP_BIND_CHUNK`) so the statement arity stays under + * the 999-variable ceiling API 29's framework SQLite still carries. + */ + @Query("SELECT * FROM txos WHERE outpoint IN (:outpoints)") + suspend fun getByOutpoints(outpoints: List): List + + /** + * Every row still linked to one of [spendingTxids] — the sweep pass's + * link-keyed fallback for a loser whose stored bytes cannot name its + * inputs (record lost, or a stub row written by `utxos_added` before + * the record arrived). Chunked by the caller. + */ + @Query("SELECT * FROM txos WHERE spendingTxid IN (:spendingTxids)") + suspend fun getBySpendingTxids(spendingTxids: List): List + + /** + * Hold the coins at [outpoints] out of the restore set, attributed to + * [supersededBy] — the same stamp the SQLite store writes as + * `spent_in_txid`, and the same one the pending-input drain writes when + * the claim had no TXO row yet. The hold is keyed by OUTPOINT, computed + * from the swept loser's own decoded inputs, never by this row's link: + * a link can move between the record and the sweep (a winner recorded + * in the same round takes it first), and a hold keyed by link would + * miss exactly the coin the winner consumed. The link is left alone + * here — [detachSpenders] drops only links that point at a swept loser; + * a link to the winner or to any other surviving record is kept, and + * the stamp holds the coin regardless. + * + * Global, not wallet-scoped: `supersededBy` is a txid fact, and the + * first callback that sees the sweep holds every wallet's rows for the + * loser's inputs; only the RELEASE is per wallet ([releaseByOutpoints]). + * A stamped hold only ever comes free through a release or through the + * wallet re-delivering the unlinked coin unspent. Chunked by the caller. + */ + @Query( + "UPDATE txos SET isSpent = 1, supersededByTxid = :supersededBy " + + "WHERE outpoint IN (:outpoints)", + ) + suspend fun holdByOutpoints(outpoints: List, supersededBy: ByteArray) + + /** + * Drop every link that points at one of [spendingTxids] — the swept + * losers of one batch. The foreign key would null these on the losers' + * delete anyway; doing it explicitly, before the delete, keeps the + * order the sweep pass documents (hold by outpoint, detach the dead + * link, delete the row) independent of FK enforcement. Chunked by the + * caller. + */ + @Query( + "UPDATE txos SET spendingTxid = NULL, spendingInputIndex = NULL " + + "WHERE spendingTxid IN (:spendingTxids)", + ) + suspend fun detachSpenders(spendingTxids: List) + + /** + * Mark [walletId]'s own coins at [outpoints] unspent again — coins a + * sweep released, meaning no surviving transaction spent them *at the + * time the sweep was computed*. Keyed by outpoint because that is how + * upstream reports it: the transaction that took the other inputs may + * never be recorded here at all, so the released set is the only + * authority on which coins came free. + * + * Per wallet, unlike [holdByOutpoints]: a released set is only ever + * true of the wallet that computed it, so it never touches another + * wallet's row. The caller has already excluded every vetoed outpoint + * (a row linked to, or stamped with, a stored network-final spender + * that this round did not sweep) and every outpoint whose funding + * transaction is itself swept this round (deleted instead). The link + * is not touched: a link to a swept loser was detached by + * [detachSpenders], and a link to a surviving mempool spender is kept + * as attribution at `isSpent = 0`, exactly what such a link means on + * the record channel. + * + * `supersededByTxid` clears in the same statement, the way the SQLite + * store's release UPDATE clears `spent_in_txid`: a released coin + * keeping its dead winner's marker would read as a durable claim to + * every later hold on this outpoint. Chunked by the caller. + */ + @Query( + "UPDATE txos SET isSpent = 0, supersededByTxid = NULL " + + "WHERE outpoint IN (:outpoints) AND walletId = :walletId", + ) + suspend fun releaseByOutpoints(outpoints: List, walletId: ByteArray) + + /** + * Delete every TXO created by one of [txids] — the swept losers' own + * outputs, dead coins for every wallet. The FK from `txos.txid` to + * `transactions.txid` (CASCADE) does this on the losers' delete too; + * the explicit form runs first so the sweep pass never depends on FK + * enforcement for the one removal that is a funds fact. Chunked by the + * caller. + */ + @Query("DELETE FROM txos WHERE txid IN (:txids)") + suspend fun deleteByTxids(txids: List) + + /** + * Delete the rows at [outpoints] outright — outputs of a transaction + * swept in this round that some loser claimed or some release named. + * A coin created by a dead transaction cannot be unspent, only gone: + * a chainlocked reinstatement of the parent re-delivers it through the + * ordinary `utxos_added` upsert with nothing left standing in its way. + * Chunked by the caller. + */ + @Query("DELETE FROM txos WHERE outpoint IN (:outpoints)") + suspend fun deleteByOutpoints(outpoints: List) @Upsert suspend fun upsert(txo: TxoEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt index a58721b0abd..31bf8e81ba5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt @@ -61,6 +61,23 @@ interface WalletDao { ) suspend fun updateName(walletId: ByteArray, name: String?, nowMillis: Long): Int + /** + * Record the round's numeric chainlock height, monotonic max — a + * stale round's chainlock never lowers the finality boundary, matching + * the SQLite store's `upsert_sync_state`. A narrow column write rather + * than a full-row [upsert], so it cannot clobber a sibling column the + * header slot wrote moments earlier in the same round. + * + * @param nowMillis epoch millis for the `lastUpdated` stamp. + * @return number of rows updated (0 when the wallet row is gone). + */ + @Query( + "UPDATE wallets SET lastAppliedChainLockHeight = " + + "MAX(COALESCE(lastAppliedChainLockHeight, -1), :height), " + + "lastUpdated = :nowMillis WHERE walletId = :walletId" + ) + suspend fun advanceChainLockHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int + @Delete suspend fun delete(wallet: WalletEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt index ef9b4c5c019..2725ba1b667 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.persistence.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -10,12 +11,17 @@ import java.util.Date * Port of `PersistentPendingInput.swift` — side-table row tracking a * transaction input whose previous-output TXO hasn't landed yet. * - * Deliberately NO unique constraint on [outpoint] (per the Swift doc: a - * re-org / double-spend could produce two pending rows for one outpoint - * and both should resolve naturally) — hence a surrogate rowid PK, - * mirroring SwiftData's hidden `persistentModelID`. + * Deliberately NO unique constraint on [outpoint] (per the Swift doc): the + * dedup at record time is `(outpoint, spendingTxid, walletId)`, so a + * re-org / double-spend produces one row per conflicting spender and a + * second wallet recording the same transaction gets its own row — hence a + * surrogate rowid PK, mirroring SwiftData's hidden `persistentModelID`. * - * Swift `#Index([\.outpoint], [\.walletId])` → the two indices below. + * Swift `#Index([\.outpoint], [\.walletId])` → the first two indices below. + * The `spendingTxid` index serves the sweep's claimed-row lookup (a + * tombstone is findable only by that scalar once detached from the FK), + * and the `(walletId, isSweptTombstone, winnerMinedHeight)` index covers + * the per-round tombstone collector exactly. * * [spendingTransactionTxid] materializes the optional * `spendingTransaction` relationship (CASCADE per @@ -29,6 +35,8 @@ import java.util.Date Index(value = ["outpoint"]), Index(value = ["walletId"]), Index(value = ["spendingTransactionTxid"]), + Index(value = ["spendingTxid"]), + Index(value = ["walletId", "isSweptTombstone", "winnerMinedHeight"]), ], foreignKeys = [ ForeignKey( @@ -46,11 +54,62 @@ data class PendingInputEntity( val outpoint: ByteArray, /** Position of this input in the spending tx. Swift `UInt32` → [Int]. */ val inputIndex: Int, - /** 32-byte txid of the spending transaction (denorm, always set). */ + /** + * 32-byte txid of the transaction that claims this input (denorm, + * always set). For an ordinary row that is the spender that staged it; + * for a tombstone it is the sweep WINNER the hold is attributed to. + */ val spendingTxid: ByteArray, /** FK materialization of the Swift `spendingTransaction` relationship. */ val spendingTransactionTxid: ByteArray? = null, - /** Wallet id denorm for cleanup / per-wallet diagnostics. */ + /** Wallet id denorm — the per-wallet release scope and the dedup key's third half. */ val walletId: ByteArray, val createdAt: Date = Date(), + /** + * Port of Swift `PersistentPendingInput.isSweptTombstone`. Set by the + * sweep pass (`PlatformWalletPersistenceHandler.applySweptTransaction`, + * the port of `PlatformWalletPersistenceHandler.swift`'s + * `applySweptTransaction`) for a held input of a swept loser that has + * no `txos` row: [spendingTransactionTxid] is cleared (detaching the FK + * so the row survives the loser's cascade-delete) and [spendingTxid] is + * overwritten with the winner's txid. When the funding TXO later + * arrives, `onWalletChangesetUtxoAdded` drains the tombstone into a + * STAMP — `TxoEntity.isSpent = true`, `TxoEntity.supersededByTxid` = + * this row's [spendingTxid] — and never into a spender link: the winner + * need not have its own `transactions` row, and the hold is the stamp, + * not the link. Defaulted `false` so pre-migration rows read as + * ordinary pending entries. + * + * Declares its default so the exported schema agrees with what + * `MIGRATION_10_11` writes: SQLite requires one on a NOT NULL + * `ADD COLUMN`, and Room compares defaults when validating a migrated + * database against the entity — a mismatch fails the upgrade outright. + */ + @ColumnInfo(defaultValue = "0") + val isSweptTombstone: Boolean = false, + /** + * The mined block height of the WINNER that swept this tombstone's + * loser — the winner's own height, carried on the sweep event itself, + * not any observation watermark. This stamp is the row's whole + * lifetime rule: the end-of-round collector deletes the tombstone once + * the chainlock finality boundary `min(chainlockHeight, syncedHeight)` + * reaches it — key-wallet's `prune_finalized_observed_spends` + * condition verbatim, no observation-age margin — because at that + * boundary the funding transaction (necessarily mined at or below + * the winner's height) has been filter-scanned with no false + * negatives, so an undrained row is provably not the wallet's coin. + * A genuine claim drains into its TXO on funding arrival and leaves + * the collectible set with the row. + * + * NULL is never collected. A mempool/IS-context sweep (unmined + * winner) writes its tombstone unstamped on purpose: under DIP-10 + * the IS lock alone settles the input, but the winner has no mining + * deadline, so no boundary can ever prove its funding output + * delivered-or-never — the hold lasts until the funding TXO drains + * it, a later block-context sweep stamps it, or a release deletes + * it. An IS-locked re-point likewise keeps the existing stamp. + * Nullable, so the ADD COLUMN migration needs no default and + * pre-migration rows read as unstamped. + */ + val winnerMinedHeight: Int? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt index 9f3255848b4..9ee5f4749ac 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt @@ -77,7 +77,15 @@ data class TxoEntity( val isConfirmed: Boolean = false, val isInstantLocked: Boolean = false, val isLocked: Boolean = false, - /** Denormalized `spendingTxid != null`; kept explicit (hot filter path). */ + /** + * Whether this coin is out of the restore set; kept explicit (hot + * filter path). Monotonic on the record and `utxos_spent` channels + * (`isSpent = existing || spender in-block || stamped`), so it is + * true for a spender that reached a block, for every stamped hold + * ([supersededByTxid] set), and for a healed asset-lock spend — and + * false for a coin whose only claim is a mempool/IS-locked link. Only + * a sweep release or an unlinked re-delivery of the coin lowers it. + */ val isSpent: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), @@ -100,6 +108,34 @@ data class TxoEntity( * navigation pointer. */ val coreAddressId: String? = null, + /** + * Port of Swift `PersistentTxo.supersededByTxid` — the winner a sweep + * attributed this coin's consumption to, mirroring the SQLite store's + * `spent_in_txid`. Two writers set it: the sweep pass + * (`PlatformWalletPersistenceHandler.applySweptTransaction`), for + * every held input of a swept loser that has a row, keyed by the + * loser's decoded input outpoints rather than by this row's link; and + * `onWalletChangesetUtxoAdded` draining a `pending_inputs` tombstone — + * the funding output arrived only after the loser that spent it was + * swept and deleted. Deliberately NOT an FK: the winner named here + * need not have its own `transactions` row (it can be + * wallet-irrelevant), so this column has to hold a bare txid that + * `transactions(txid)` may never contain. + * + * The hold is the stamp, not the link. A stamped row keeps + * `isSpent = true` whatever later happens to [spendingTxid] — a new + * spender may adopt the link (attribution for `walletFundedTransaction`) + * without lowering the flag — and a stamped row that is UNLINKED is + * what the sweep release pass frees. Cleared by exactly two events: + * a sweep release of this outpoint (a later sweep proved the coin came + * free after all, and no stored network-final spender vetoes it), and + * the wallet re-delivering the coin unspent while the row is unlinked + * (`onWalletChangesetUtxoAdded`: the wallet knows the coin, so any + * network-final spender of it is re-discovered by its own scan; holding + * the row would lock a real coin out forever after a reorg of the + * winner). + */ + val supersededByTxid: ByteArray? = null, ) { override fun equals(other: Any?): Boolean = other is TxoEntity && outpoint.contentEquals(other.outpoint) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt index fcf5d678420..200edbe1947 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt @@ -55,6 +55,19 @@ data class WalletEntity( * Opaque passthrough — decoded only by Rust; never re-encoded here. */ val lastAppliedChainLockBytes: ByteArray? = null, + /** + * The numeric block height of the last applied chainlock, delivered + * separately by `onWalletChangesetChainLockHeight` (the bincode blob + * above is opaque on this side of the FFI) and written through the + * narrow `WalletDao.advanceChainLockHeight` UPDATE. Monotonic max — a + * stale round never lowers it. This is the chainlock half of the + * swept-tombstone collection boundary `min(chainlockHeight, + * syncedHeight)` the end-of-round collector reads back from this row; + * while NULL no finality boundary exists and the collector never + * runs, mirroring the SQLite store's "no-op until a chainlock height + * has been persisted". + */ + val lastAppliedChainLockHeight: Int? = null, val isImported: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index d8f0cf26b6f..aba0c4ceb2d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -25,6 +25,7 @@ import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.DpnsMarketplaceNative import org.dashfoundation.dashsdk.ffi.FundingNative +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.funding.ShieldedProver @@ -61,6 +62,21 @@ data class PlatformWalletPersistenceCapabilities( const val WALLET_RESTORE: Long = 1L shl 7 const val DPNS_NAME_STATES: Long = 1L shl 8 const val TRACKED_ASSET_LOCKS: Long = 1L shl 9 + /** + * A stored core changeset's non-empty sweeps are durably applied + * in order: each swept transaction and its outputs are deleted, + * each released outpoint of the wallet's own is freed unless a + * stored network-final spender still claims it, and each + * non-released input RETAINS a durable spend claim — a stamp on + * the materialised coin, or a tombstone where the funding TXO has + * not materialised yet — that outlives the loser's deletion, or a + * post-restart funding delivery would credit a coin the network + * already consumed. Mirrors `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; + * aliased to the bridge's declaration so the mirror cannot drift + * from the bit the handler attests (bit 10, `TRACKED_MASTERNODES`, + * is deliberately absent: Android never attests it). + */ + const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 523474cc0b8..524f6d057e8 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -232,6 +232,35 @@ class DashDatabaseTest { assertEquals(1L, db.walletDao().count().first()) } + @Test + fun schemaIsAtVersion11WithTheSweepHoldIndexes() = runTest { + // The sweep-hold columns land in ONE migration (10 → 11), with the + // two `pending_inputs` indexes the sweep's claimed-row lookup + // (`spendingTxid`) and the end-of-round collector + // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. + assertEquals(11, db.openHelper.readableDatabase.version) + val indexes = mutableSetOf() + db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> + val nameColumn = c.getColumnIndexOrThrow("name") + while (c.moveToNext()) indexes += c.getString(nameColumn) + } + assertTrue(indexes.contains("index_pending_inputs_spendingTxid")) + assertTrue(indexes.contains("index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight")) + } + + @Test + fun advanceChainLockHeightIsANarrowMonotonicMaxWrite() = runTest { + db.walletDao().upsert(WalletEntity(walletId = walletId, networkRaw = 1, name = "w", syncedHeight = 7)) + assertEquals(1, db.walletDao().advanceChainLockHeight(walletId, 500, 1L)) + assertEquals(500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + assertEquals(1, db.walletDao().advanceChainLockHeight(walletId, 400, 2L)) + assertEquals("a stale height never lowers it", 500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + val row = db.walletDao().getByWalletId(walletId)!! + assertEquals("sibling columns are untouched", 7, row.syncedHeight) + assertEquals("w", row.name) + assertEquals(0, db.walletDao().advanceChainLockHeight(ByteArray(32) { 9 }, 1, 3L)) + } + @Test fun storageCountsCoverEveryTable() = runTest { val counts = db.storageCountsDao() diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 5008ade780b..e9f941bcfd1 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -22,6 +22,7 @@ import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity import org.dashfoundation.dashsdk.persistence.entities.TransactionEntity import org.dashfoundation.dashsdk.persistence.entities.TxoEntity import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity +import org.dashfoundation.dashsdk.persistence.entities.PendingInputEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity import org.junit.After @@ -59,10 +60,27 @@ class PlatformWalletPersistenceHandlerTest { private val groupId = ByteArray(32) { 2 } private val testnet = 1 + /** + * What the sweep pass "decodes" from a stored record's bytes, keyed by + * txid. Every fixture records transactions with dummy bytes + * (`ByteArray(10) { 5 }`) that key-wallet-ffi could never decode, and + * the native decoder is not loadable under Robolectric anyway, so + * [recordTransaction] registers each record's `inputOutpoints` here and + * [storedInputs] hands them back. A txid never registered throws, as + * the production decoder would on bytes it cannot parse — a fixture + * that seeds a loser row directly must register its inputs. + */ + private val recordedInputs = HashMap>() + + private val storedInputs = StoredTransactionInputs { txid, _ -> + recordedInputs[txid.toHex()] + ?: error("test decoder: no inputs registered for ${txid.toHex()}") + } + @Before fun setUp() { db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + handler = newHandler() } @After @@ -70,6 +88,130 @@ class PlatformWalletPersistenceHandlerTest { db.close() } + /** A handler over [db] wired to the test decoder — also the suite's "restart" idiom. */ + private fun newHandler(deriver: PrivateKeyDeriver? = null): PlatformWalletPersistenceHandler = + PlatformWalletPersistenceHandler( + db, + Dispatchers.Unconfined, + deriver, + storedTransactionInputs = storedInputs, + ) + + /** + * `onWalletChangesetTransaction` through the test decoder: registers + * the record's [inputOutpointCount] input outpoints under [txid] so a + * later sweep of it can key its hold by outpoint, then forwards the + * call unchanged. + */ + private fun recordTransaction( + h: PlatformWalletPersistenceHandler, + walletId: ByteArray, + txid: ByteArray, + txData: ByteArray, + context: Int, + blockHeight: Int, + blockHash: ByteArray, + blockTimestamp: Int, + direction: Int, + transactionType: String, + transactionTypeKind: Int, + netAmount: Long, + fee: Long, + hasFee: Boolean, + label: String, + firstSeen: Long, + inputOutpoints: ByteArray, + inputOutpointCount: Int, + accountTypeTag: Byte = (-1).toByte(), + accountStandardTag: Byte = 0, + accountIndex: Int = -1, + accountRegistrationIndex: Int = 0, + accountKeyClass: Int = 0, + accountUserIdentityId: ByteArray = ByteArray(0), + accountFriendIdentityId: ByteArray = ByteArray(0), + blockPosition: Int = 0, + hasBlockPosition: Boolean = false, + ): Int { + registerInputs(txid, List(inputOutpointCount) { i -> inputOutpoints.copyOfRange(i * 36, i * 36 + 36) }) + return h.onWalletChangesetTransaction( + walletId, txid, txData, context, blockHeight, blockHash, blockTimestamp, direction, + transactionType, transactionTypeKind, netAmount, fee, hasFee, label, firstSeen, + inputOutpoints, inputOutpointCount, accountTypeTag, accountStandardTag, accountIndex, + accountRegistrationIndex, accountKeyClass, accountUserIdentityId, + accountFriendIdentityId, blockPosition, hasBlockPosition, + ) + } + + /** Register what the test decoder returns for [txid] (for rows seeded directly). */ + private fun registerInputs(txid: ByteArray, inputs: List) { + recordedInputs[txid.toHex()] = inputs + } + + /** + * The sweep slot as the JNI trampoline packs it: [losers] as one flat + * 32·N array plus count, [released] as one flat 36·M array plus count, + * and the winner's mined height as the `(has, height)` pair — -1 here + * means an IS-locked, unmined winner (`has = false`). + */ + private fun sweep( + h: PlatformWalletPersistenceHandler, + wallet: ByteArray, + losers: List, + winner: ByteArray, + released: List, + winnerMinedHeight: Int, + ): Int = h.onWalletChangesetTransactionsSwept( + wallet, + losers.fold(ByteArray(0)) { acc, txid -> acc + txid }, + losers.size, + winner, + released.fold(ByteArray(0)) { acc, outpoint -> acc + outpoint }, + released.size, + winnerMinedHeight >= 0, + if (winnerMinedHeight >= 0) winnerMinedHeight else 0, + ) + + /** One committed round carrying a single sweep batch. */ + private fun sweepRound( + wallet: ByteArray, + losers: List, + winner: ByteArray, + released: List = emptyList(), + winnerMinedHeight: Int = 400, + h: PlatformWalletPersistenceHandler = handler, + ) { + h.onChangesetBegin(wallet) + assertEquals(0, sweep(h, wallet, losers, winner, released, winnerMinedHeight)) + assertEquals(0, h.onChangesetEnd(wallet, success = true)) + } + + /** + * The wallet + BIP44 account + one `CoreAddressEntity` prologue every + * restore-facing fixture needs: a TXO on [address] routes to the + * account through `core_addresses` (Android txos carry no accountId + * FK), which is what `onLoadWalletList` needs to hand it back. + */ + private suspend fun seedWalletWithAddress( + wallet: ByteArray, + address: String, + xpubFill: Byte = 30, + ) { + handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) + handler.onPersistAccountRegistration( + wallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { xpubFill }, + ) + val account = db.accountDao().observeByWallet(wallet).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = address, + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + } + @Test fun persistenceCapabilitiesAreExplicitAndFailClosedByDefault() { val noOpBridge = object : NativePersistenceBridge() {} @@ -77,7 +219,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0x3bfL, handler.persistenceCapabilitiesBits()) + assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -90,6 +232,40 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) + } + + @Test + fun sweepSlotDefaultIsTheBenignIgnoreWhateverTheDeclaredBitsSay() { + // The gate against "declared the bit, never overrode the slot" is + // not in Kotlin any more: the JNI layer wires the sweep slot only + // for a bridge whose class overrides the method + // (`bridge_overrides` in rs-unified-sdk-jni), and Rust derives the + // effective capability from "slot present AND bit declared" — a + // declaring-but-not-overriding subclass never gets the slot, so + // Rust strips the bit and the watermark with it. The inherited + // body is therefore the benign ignore for every subclass; a runtime + // bit inspection here would gate one bit out of eleven that all + // share the declared-but-not-overridden hazard. + val declaringButNotOverriding = object : NativePersistenceBridge() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + } + val nonAttesting = object : NativePersistenceBridge() {} + val walletId = ByteArray(32) { 1 } + for (bridge in listOf(declaringButNotOverriding, nonAttesting)) { + assertEquals( + 0, + bridge.onWalletChangesetTransactionsSwept( + walletId, ByteArray(32) { 2 }, 1, ByteArray(32) { 3 }, ByteArray(0), 0, true, 400, + ), + ) + } + assertEquals( + "the diagnostic mirror aliases the bridge's declaration, so the two cannot drift", + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL, + PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL, + ) } // ── Standalone (non-bracketed) writes ───────────────────────────── @@ -357,7 +533,8 @@ class PlatformWalletPersistenceHandlerTest { val txid = ByteArray(32) { (marker ?: 0).toByte() } assertEquals( 0, - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId = id, txid = txid, txData = marker?.let { byteArrayOf(it.toByte()) } ?: ByteArray(0), @@ -518,7 +695,8 @@ class PlatformWalletPersistenceHandlerTest { internalHighestUsed = -1, hasInternalHighestUsed = false, ) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId = walletId, txid = txid, txData = ByteArray(10) { 4 }, @@ -652,7 +830,7 @@ class PlatformWalletPersistenceHandlerTest { // A fresh handler models process restart. Its restore payload must // carry the canonical tuple, not the conflicting callback tuple. - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() val restored = restarted.onLoadWalletList().single().platformAddressBalances.single() assertEquals(2, restored.accountIndex) assertEquals(7, restored.addressIndex) @@ -946,7 +1124,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun identityKeyUpsertDerivesAndRecordsPrivateKeyIdentifier() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 12 } seedIdentity(identityId) @@ -1012,7 +1190,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun identityKeyUpsertSkipsDeriveForWatchOnlyKey() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 14 } seedIdentity(identityId) @@ -1034,7 +1212,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun rolledBackRoundScrubsDeriverWrittenAliases() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 15 } seedIdentity(identityId) @@ -1062,7 +1240,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun rolledBackRoundDoesNotScrubPreExistingAliases() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 17 } seedIdentity(identityId) @@ -1088,7 +1266,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun failedAliasDeletionRetainsCleanupStateUntilRetrySucceeds() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 18 } seedIdentity(identityId) @@ -1120,7 +1298,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun committedRoundKeepsDeriverWrittenAliases() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 16 } seedIdentity(identityId) @@ -1177,7 +1355,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun derivationFailureIsRecordedAsAPendingIdentityKey() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 15 } seedIdentity(identityId) @@ -1260,7 +1438,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun markIdentityKeyRepairedClearsThePendingEntry() = runTest { // A derive failure records the key as pending… - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 18 } seedIdentity(identityId) val pubkey = ByteArray(33) { 13 } @@ -1286,7 +1464,7 @@ class PlatformWalletPersistenceHandlerTest { // removing that key (onPersistIdentityKeyRemoval) must drop the now- // phantom entry — a repair could never re-derive a key into an identity // that no longer carries it. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 20 } seedIdentity(identityId) val pubkey = ByteArray(33) { 15 } @@ -1307,7 +1485,7 @@ class PlatformWalletPersistenceHandlerTest { // The removal's pending-clear is staged with the round (mirroring the // upsert path): an aborted round discards both the row deletion and the // pending-clear, so the pre-round pending entry survives untouched. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 21 } seedIdentity(identityId) val pubkey = ByteArray(33) { 16 } @@ -1329,7 +1507,7 @@ class PlatformWalletPersistenceHandlerTest { // identity is a phantom afterwards — a repair could never re-derive a // key into an identity that no longer exists. All of them must clear // (not just one keyId, as onPersistIdentityKeyRemoval handles). - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 22 } seedIdentity(identityId) // Two watch-only keys under the same identity, different keyIds. @@ -1353,7 +1531,7 @@ class PlatformWalletPersistenceHandlerTest { // The identity-removal pending-clear is staged with the round: an // aborted round discards both the identity deletion and the clear, so // the pre-round pending entry survives untouched. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 23 } seedIdentity(identityId) val pubkey = ByteArray(33) { 19 } @@ -1374,7 +1552,7 @@ class PlatformWalletPersistenceHandlerTest { // scoped to that wallet is a phantom afterwards. deleteWalletData must // prune them (Room's cascade cannot mutate the process-local // StateFlow). - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 24 } seedIdentity(identityId) val pubkey = ByteArray(33) { 20 } @@ -1395,7 +1573,7 @@ class PlatformWalletPersistenceHandlerTest { */ @Test fun abortedRoundLeavesNoPhantomPendingKeyState() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 19 } seedIdentity(identityId) @@ -1491,7 +1669,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun reconstructionSeedsPendingFromBreadcrumbRowsWithNullIdentifier() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 21 } seedIdentity(identityId) val pubkey = ByteArray(33) { 12 } @@ -1499,7 +1677,7 @@ class PlatformWalletPersistenceHandlerTest { // Model a process restart: a fresh handler starts with an empty // in-memory map, then rebuilds it from the durable rows. - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() assertTrue(restarted.pendingIdentityKeys.value.isEmpty()) restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { false }, @@ -1522,14 +1700,14 @@ class PlatformWalletPersistenceHandlerTest { // The derive SUCCEEDED at persist time (identifier recorded), but the // stored blob no longer passes the cheap capability check — e.g. the // Keystore keypair was replaced. The repair slot must resurface. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + handler = newHandler(FakeDeriver()) val identityId = ByteArray(32) { 22 } seedIdentity(identityId) val pubkey = ByteArray(33) { 13 } upsertIdentityKey(pubkey, identityId) assertTrue(handler.pendingIdentityKeys.value.isEmpty()) // healthy at persist time - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { false }, // blob stranded ) @@ -1540,13 +1718,13 @@ class PlatformWalletPersistenceHandlerTest { fun reconstructionSkipsHealthyRows() = runTest { // Identifier recorded AND the blob still decrypts: nothing to repair, // so a restart must not fabricate pending state. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + handler = newHandler(FakeDeriver()) val identityId = ByteArray(32) { 23 } seedIdentity(identityId) val pubkey = ByteArray(33) { 14 } upsertIdentityKey(pubkey, identityId) - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { true }, ) @@ -1558,7 +1736,7 @@ class PlatformWalletPersistenceHandlerTest { // A failed derive leaves a pending row; the repair path later records // the identifier on the Room row (and the blob decrypts). The next // restart's reconstruction must NOT resurrect the repaired key. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 24 } seedIdentity(identityId) val pubkey = ByteArray(33) { 16 } @@ -1570,7 +1748,7 @@ class PlatformWalletPersistenceHandlerTest { row.copy(privateKeyKeychainIdentifier = "privkey." + pubkey.toHex()), ) - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { true }, ) @@ -1589,7 +1767,7 @@ class PlatformWalletPersistenceHandlerTest { */ @Test fun signingKeyInvalidationSeedsPendingDespiteAUsableCheapCheck() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + handler = newHandler(FakeDeriver()) val identityId = ByteArray(32) { 26 } seedIdentity(identityId) val pubkey = ByteArray(33) { 18 } @@ -1611,7 +1789,7 @@ class PlatformWalletPersistenceHandlerTest { // And the SAME durable path re-seeds after a restart, still despite // the cheap check claiming usable. - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { true }, ) @@ -1620,7 +1798,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun reconstructionNeverOverwritesALiveEntry() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 25 } seedIdentity(identityId) val pubkey = ByteArray(33) { 17 } @@ -1688,7 +1866,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithCorrectBreadcrumbsDerivesVerifiesAndClearsPending() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 30 } seedIdentity(identityId) @@ -1720,7 +1898,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithMismatchedBreadcrumbsIsRejectedAndLeavesPending() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 31 } seedIdentity(identityId) @@ -1761,7 +1939,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithoutPersistedBreadcrumbsFailsAndLeavesPending() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 32 } seedIdentity(identityId) @@ -1795,7 +1973,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithFailedDurableWriteLeavesPendingIntact() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 33 } seedIdentity(identityId) @@ -1961,32 +2139,68 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(380_987L, restored.asOfHeight) } + /** + * The bridge emits a round's `transactions` before its `utxos_added` + * (`rs-unified-sdk-jni/src/persistence.rs`, `persist_changeset_account`; + * same order as Swift's `applyAccountChangeset`). A spend whose funding + * output arrives in the SAME round therefore stages a pending row first + * and must drain it when the TXO lands a few ops later: the coin ends + * the round linked to its spender, spent per the spender's context, with + * no pending row left behind. Separate-round drains are covered + * elsewhere; this pins the one-round fold. + */ + @Test + fun aFundingOutputAndItsSpenderInOneRoundLeaveTheCoinLinkedAndSpent() = runTest { + seedWalletWithAddress(walletId, "ySameRoundAddr") + + val fundingTxid = ByteArray(32) { 61 } + val spendingTxid = ByteArray(32) { 62 } + val outpoint = makeOutpoint(fundingTxid, 0) + + handler.onChangesetBegin(walletId) + // The spender first — its input has no TXO yet, so this stages a + // pending row keyed by the outpoint. + recordTransaction( + handler, + walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -60_000, 0, false, "", 1_700_000_100, + outpoint, 1, + ) + // Then the funding output, in the same round. + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 60_000, "ySameRoundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val txo = db.txoDao().getByOutpoint(outpoint) + assertNotNull("the funding output materialised", txo) + assertTrue("the in-block spender's claim drained onto the TXO", spendingTxid.contentEquals(txo!!.spendingTxid)) + assertEquals("vin index carried from the staged claim", 0, txo.spendingInputIndex) + assertTrue("spent per the spender's in-block context", txo.isSpent) + assertTrue( + "the staged claim is consumed by the drain, not left behind", + db.documentDao().getPendingInputsByOutpoint(outpoint).isEmpty(), + ) + assertTrue( + "and the coin is not handed back as spendable", + handler.onLoadWalletList().single().utxos.none { it.prevTxid.contentEquals(fundingTxid) && it.vout == 0 }, + ) + } + @Test fun loadWalletListRestoresUnspentUtxosAndExcludesConfirmedSpends() = runTest { // CORE-06 regression: persisted unspent TXOs must come back on // the restore row (routed to their owning account through // core_addresses — Android txos carry no accountId FK), and a // TXO whose spend has confirmed must NOT rehydrate as spendable. - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, - ) - val account = db.accountDao().observeByWallet(walletId).first().single() - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yUtxoAddr", - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/44'/1'/0'/0/0", - accountId = account.id, - ), - ) + seedWalletWithAddress(walletId, "yUtxoAddr") val fundingTxid = ByteArray(32) { 21 } val spendingTxid = ByteArray(32) { 22 } handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, ByteArray(0), 0, // funding tx: no inputs of ours @@ -2006,7 +2220,8 @@ class PlatformWalletPersistenceHandlerTest { // and the row stays in the restore set (iOS semantics — the // post-restart classifier needs the TXO back). handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, spendingTxid, ByteArray(10) { 5 }, 1, 0, ByteArray(32), 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_100, makeOutpoint(fundingTxid, 1), 1, // spends fundingTxid:1 @@ -2022,7 +2237,8 @@ class PlatformWalletPersistenceHandlerTest { // must flip `isSpent` (the flag would otherwise never converge // — the CORE-06 over-count hazard)… handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, 1_700_000_200, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_100, makeOutpoint(fundingTxid, 1), 1, // spends fundingTxid:1 @@ -2046,145 +2262,2208 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore() = runTest { - // CORE-06, out-of-order arrival: an in-block spending tx is persisted - // BEFORE its funding TXO is known (Rust's utxos_spent slice is empty - // because the previous output wasn't classified yet). The spend must - // not be lost — `inputOutpoints` stages a pending-input row that the - // funding TXO's later upsert drains, so the consumed output is excluded - // from the restore set instead of being handed back to Rust as - // spendable. 1:1 mirror of Swift resolveInputOutpoint + upsertUtxo drain. - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + fun sweptTransactionIsDeletedAndFreesOnlyItsOwnInputs() = runTest { + // A recorded spend that a later, final transaction beat to an input + // can never confirm; Rust drops it and names it here. The mirror has + // to drop it too — otherwise the row comes back on the next load and + // re-creates a balance the wallet already corrected. + // + // Shape: the loser (unconfirmed, as every swept loser is) spends A + // and B; the winner is wallet-relevant, in-block, and takes only A. + // A must stay out of the restore set, B must return to it. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 41 } + val sweptTxid = ByteArray(32) { 42 } + val winnerTxid = ByteArray(32) { 44 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, ) - val account = db.accountDao().observeByWallet(walletId).first().single() - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yFundAddr", - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/44'/1'/0'/0/0", - accountId = account.id, - ), + // A (vout 0) and B (vout 1). + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 40_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) + handler.onChangesetEnd(walletId, success = true) - val fundingTxid = ByteArray(32) { 41 } - val spendingTxid = ByteArray(32) { 42 } + // The doomed transaction: mempool context — upstream only ever + // sweeps unconfirmed records, so its inputs are linked to it without + // `isSpent` ever flipping. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + makeOutpoint(fundingTxid, 1), 2, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, sweptTxid) + handler.onWalletChangesetUtxoAdded( + walletId, sweptTxid, 0, 60_000, "yUtxoAddr", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertFalse( + "a pre-block spender links but must not flip isSpent", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) - // Changeset 1: the in-block spending tx arrives first. Its funding TXO - // is unknown, so a pending-input row is staged (no utxos_spent fires). + // The winner confirms, taking A, then the sweep runs — the ordering + // the persist path guarantees inside one round. handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( - walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, - 1_700_000_200, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, - makeOutpoint(fundingTxid, 0), 1, // spends fundingTxid:0 (TXO unknown) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 2, 102, ByteArray(32) { 9 }, + 1_700_000_200, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_150, + makeOutpoint(fundingTxid, 0), 1, ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + sweep(handler, walletId, listOf(sweptTxid), winnerTxid, listOf(makeOutpoint(fundingTxid, 1)), 400) handler.onChangesetEnd(walletId, success = true) - val staged = db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)) - assertEquals(1, staged.size) - assertTrue(spendingTxid.contentEquals(staged.single().spendingTxid)) - // Funding TXO absent → nothing to restore yet. - assertEquals(0, handler.onLoadWalletList().single().utxos.size) + assertNull("the swept transaction row is gone", db.transactionDao().getByTxid(sweptTxid)) + assertNull( + "the change it created is gone with it", + db.txoDao().getByOutpoint(makeOutpoint(sweptTxid, 0)), + ) + assertNotNull("the funding transaction is untouched", db.transactionDao().getByTxid(fundingTxid)) + + val winnerTaken = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the winner took stays spent", winnerTaken.isSpent) + assertTrue(winnerTxid.contentEquals(winnerTaken.spendingTxid)) + + // B was only ever claimed by the loser, so it is spendable again. + val released = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 1))!! + assertFalse("the loser's own input is free again", released.isSpent) + assertNull(released.spendingTxid) + val restored = handler.onLoadWalletList().single().utxos.single() + assertEquals(1, restored.vout) + } + + @Test + fun anAbsentWinnerStillKeepsItsOwnInputSpent() = runTest { + // The winner can spend our coin and pay only outside addresses. It + // sweeps the loser all the same, but no record for it ever reaches + // the persister — so nothing in this store could work out that the + // coin is gone. Upstream can, and reports it by leaving the coin out + // of the released set. A swept loser is unconfirmed, so its input is + // linked at `isSpent = 0`; deleting the loser and stopping there + // would return a coin the chain has already spent as spendable. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 45 } + val sweptTxid = ByteArray(32) { 46 } + val irrelevantWinner = ByteArray(32) { 47 } - // Changeset 2: the funding TXO finally lands. The drain links the spend - // (in-block → isSpent) and clears the pending row. handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) handler.onWalletChangesetUtxoAdded( - walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) - val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) - assertNotNull(txo) - assertTrue(txo!!.isSpent) - assertTrue(spendingTxid.contentEquals(txo.spendingTxid!!)) + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onChangesetEnd(walletId, success = true) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + + handler.onChangesetBegin(walletId) + // Upstream knows the winner took this coin even though it never + // reports the winner itself, so nothing is released. + sweep(handler, walletId, listOf(sweptTxid), irrelevantWinner, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertNull(db.transactionDao().getByTxid(sweptTxid)) + val held = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the unrecorded winner may have taken is held", held.isSpent) + assertNull("with no spender invented for it", held.spendingTxid) assertTrue( - db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).isEmpty(), + "but with the winner stamped, the same attribution SQLite " + + "records as spent_in_txid", + irrelevantWinner.contentEquals(held.supersededByTxid), ) - // The consumed output must NOT be handed back to Rust as spendable. - assertEquals(0, handler.onLoadWalletList().single().utxos.size) + assertTrue( + "and it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } @Test - fun loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { - // prior-2 regression: the persisted Core address pools must come - // back on the restore row so every restored address maps to its - // derivation path — including addresses PAST the gap-limit window - // (`DEFAULT_GAP_LIMIT` = 20) that `ManagedWalletInfo::from_wallet` - // pre-derives. Without this, a restored UTXO on an out-of-window - // address has no derivation-path mapping and the wallet cannot - // sign a core-to-core spend after a cold restart. Mirror of the - // Swift `buildCoreAddressPoolBuffer` round-trip. - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, - ) - val account = db.accountDao().observeByWallet(walletId).first().single() + fun aStampedUnlinkedCoinTheWalletRedeliversUnspentFollowsTheWallet() = runTest { + // The rule this test USED to pin was the opposite — "a re-delivery + // cannot outrank the sweep's verdict, only a release frees a + // stamped hold". That rule locked a real coin out forever: a + // materialised coin is one the wallet knows, any network-final + // spender of a coin it knows is wallet-relevant by BIP158 prevout + // matching, so the wallet's own scan re-discovers the spend — and + // if it instead re-delivers the coin UNSPENT, the winner was reorged + // out (or was never mined) and there is nothing to hold it against. + // On this side of the FFI a row at `isSpent = true` is never + // restored to Rust again, so refusing meant the coin was gone for + // good. Same answer as the SQLite store's upsert valve, which now + // holds only never-materialised placeholders: a stamped, UNLINKED + // row the wallet hands back as a UTXO is cleared, stamp included. + // A row still LINKED to a spender keeps its flag — the link is the + // store's recorded spend attribution and the sweep pass owns it. + seedWalletWithAddress(walletId, "yUtxoAddr") + val fundingTxid = ByteArray(32) { 48 } + val coin = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 49 } + val irrelevantWinner = ByteArray(32) { 54 } - // An external (pool tag 0) address well beyond the gap window, - // used and carrying a balance + a full derivation path + pubkey. - val pubkey = ByteArray(33) { 4 } - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yFarAddr", - publicKey = pubkey, - poolTypeTag = 0, - addressIndex = 100, - derivationPath = "m/44'/1'/0'/0/100", - isUsed = true, - balance = 12_345, - accountId = account.id, - ), + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, ) - // A second, unused internal (pool tag 1) address — proves grouping - // by pool type emits a distinct pool for the change chain. - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yChangeAddr", - publicKey = ByteArray(0), - poolTypeTag = 1, - addressIndex = 3, - derivationPath = "m/44'/1'/0'/1/3", - isUsed = false, - accountId = account.id, - ), + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + coin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + sweepRound(walletId, listOf(loserTxid), irrelevantWinner) + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: held by the stamp, unlinked", held.isSpent && held.spendingTxid == null) + assertTrue(irrelevantWinner.contentEquals(held.supersededByTxid)) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - val pools = list[0].coreAddressPools - // One pool per (account, poolType) group, ascending tag order. - assertEquals(2, pools.size) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) - val external = pools[0] - assertEquals(0.toByte(), external.poolTypeTag) - // The pool routes via the account tuple (xpub omitted — the loader - // ignores it on this path). - assertEquals(0.toByte(), external.account.typeTag) - assertEquals(0, external.account.index) - assertEquals(0, external.account.accountXpubBytes.size) - assertEquals(1, external.addresses.size) - val far = external.addresses[0] - assertEquals("yFarAddr", far.addressBase58) - // The out-of-window address keeps its derivation path — the whole - // point of the fix. - assertEquals("m/44'/1'/0'/0/100", far.derivationPath) - assertEquals(100, far.addressIndex) - assertTrue(far.isUsed) - assertEquals(12_345L, far.balance) - assertTrue(pubkey.contentEquals(far.publicKey)) - assertEquals(0.toByte(), far.poolTypeTag) + val redelivered = db.txoDao().getByOutpoint(coin)!! + assertFalse("the wallet re-delivering the coin unspent lifts the hold", redelivered.isSpent) + assertNull("stamp included", redelivered.supersededByTxid) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } - val internal = pools[1] - assertEquals(1.toByte(), internal.poolTypeTag) - assertEquals(1, internal.addresses.size) - val change = internal.addresses[0] - assertEquals("yChangeAddr", change.addressBase58) + @Test + fun aWinnersLateSpentEmitDoesNotDowngradeAStampedHold() = runTest { + // The winner's own record can reach this store only after the sweep + // and the funding TXO already did — IS-locked, not yet in a block. + // Its record pass is monotonic and merely links the spender, but + // the utxos_spent emit that rides with it resolved the in-block + // gate to false and wrote it, flipping a durable stamped hold back + // into the restore set until the winner confirmed — contradicting + // the verdict the sweep already recorded. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 56 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 57 } + val winnerTxid = ByteArray(32) { 58 } + + // The doomed spend, before its funding output. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(loserTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertTrue(db.txoDao().getByOutpoint(pOutpoint)!!.isSpent) + + // The winner's own record finally arrives, IS-locked (context 1 < + // in-block), with the spent emit riding along the way a real round + // delivers both. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + handler.onChangesetEnd(walletId, success = true) + + val held = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue( + "the winner's own unconfirmed arrival must not downgrade the stamped hold", + held.isSpent, + ) + assertTrue(winnerTxid.contentEquals(held.supersededByTxid)) + assertTrue( + "the spender is linked all the same", + winnerTxid.contentEquals(held.spendingTxid), + ) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aReleaseNamingACoinASettledSpenderStillClaimsIsRefused() = runTest { + // The pruned-finalized-release defect, on this store's terms: a + // chainlocked spender F is pruned upstream to a bare txid, so a + // later loser L that pays this wallet while reusing F's input (plus + // an attacker-owned one) sweeps with F's coin wrongly named in + // `releasedOutpoints`. F's row and its `spendingTxid` link survive + // HERE, and the link guard keeps L's record pass from stealing the + // attribution — so the release pass finds F's coin linked to a + // stored network-final spender and refuses it, while the coin only + // L claimed still comes free in the same batch. The restore surface is the + // restart: what `onLoadWalletList` hands back is what a relaunch + // spends from. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 60 } + val settledCoin = makeOutpoint(fundingTxid, 0) + val losersOwnCoin = makeOutpoint(fundingTxid, 1) + val attackerInput = makeOutpoint(ByteArray(32) { 61 }, 0) + val finalizedTxid = ByteArray(32) { 62 } + val loserTxid = ByteArray(32) { 63 } + val winnerTxid = ByteArray(32) { 64 } + + // Fund both coins. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 200_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // F: the chainlocked spender of `settledCoin` — upstream keeps only + // its txid from here on; this store keeps the row and the link. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, finalizedTxid, ByteArray(10) { 5 }, 3, 120, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + settledCoin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + val linked = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue("sanity: F's spend marked", linked.isSpent) + assertTrue("sanity: F holds the link", finalizedTxid.contentEquals(linked.spendingTxid)) + + // L: arrives after F's pruning — pays this wallet, reuses F's input + // alongside the attacker's and one coin of its own. Its record pass + // must NOT steal F's link. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 0, "Standard", 0, 50_000, 0, false, "", 1_700_000_200, + settledCoin + attackerInput + losersOwnCoin, 3, + ) + handler.onChangesetEnd(walletId, success = true) + val guarded = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue( + "a settled spender's link is not stolen by a conflicting record", + finalizedTxid.contentEquals(guarded.spendingTxid), + ) + assertTrue( + "the loser's own coin links normally", + loserTxid.contentEquals(db.txoDao().getByOutpoint(losersOwnCoin)!!.spendingTxid), + ) + + // W (final) beats 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. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(loserTxid), winnerTxid, listOf(settledCoin, losersOwnCoin), 400) + handler.onChangesetEnd(walletId, success = true) + + val settled = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue( + "a released coin a settled stored spender still claims must stay spent", + settled.isSpent, + ) + assertTrue(finalizedTxid.contentEquals(settled.spendingTxid)) + val freed = db.txoDao().getByOutpoint(losersOwnCoin)!! + assertFalse("a coin only the swept loser claimed must come free", freed.isSpent) + assertNull(freed.spendingTxid) + assertEquals( + "the restore surface hands back exactly the freed coin", + 1, + handler.onLoadWalletList().single().utxos.size, + ) + } + + @Test + fun aPreStampHoldStillFreesOnRedelivery() = runTest { + // The same rule as + // aStampedUnlinkedCoinTheWalletRedeliversUnspentFollowsTheWallet, + // for the shape no current writer produces: a coin held spent with + // neither a spender nor a `supersededByTxid` stamp. An UNLINKED row + // follows the wallet whatever it carries, so the wallet + // re-delivering it as a UTXO lifts the mark; only a link is spend + // evidence a re-delivery leaves alone. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 55 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + db.transactionDao().upsert( + TransactionEntity(txid = fundingTxid, transactionData = ByteArray(0)), + ) + db.txoDao().upsert( + TxoEntity( + outpoint = pOutpoint, + vout = 0, + amount = 100_000, + address = "yUtxoAddr", + isSpent = true, + walletId = walletId, + txid = fundingTxid, + ), + ) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + assertFalse( + "a hold with nothing durable behind it frees on re-delivery", + db.txoDao().getByOutpoint(pOutpoint)!!.isSpent, + ) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun aReleasedCoinAlreadyReclaimedInTheSameRoundKeepsItsNewSpender() = runTest { + // 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 written before sweeps are processed. + // By the time the release runs the coin is claimed again, and freeing + // it would hand a spent coin back to the restore set. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 50 } + val sweptTxid = ByteArray(32) { 51 } + val winnerTxid = ByteArray(32) { 52 } + val reclaimerTxid = ByteArray(32) { 53 } + val freedCoin = makeOutpoint(fundingTxid, 1) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 40_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // The doomed transaction claims both coins, unconfirmed as every + // swept loser is. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + freedCoin, 2, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, sweptTxid) + handler.onChangesetEnd(walletId, success = true) + + // One round now carries the winner, the sweep releasing the coin the + // winner did not take, and a later transaction that already spent + // that freed coin. Records are applied first, sweeps last. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_090, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + recordTransaction( + handler, + walletId, reclaimerTxid, ByteArray(10) { 7 }, 2, 102, ByteArray(32) { 9 }, + 1_700_000_200, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_150, + freedCoin, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, reclaimerTxid) + sweep(handler, walletId, listOf(sweptTxid), winnerTxid, listOf(freedCoin), 400) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the swept transaction row is still gone", db.transactionDao().getByTxid(sweptTxid)) + + val reclaimed = db.txoDao().getByOutpoint(freedCoin)!! + assertTrue( + "the later spender keeps its claim", + reclaimerTxid.contentEquals(reclaimed.spendingTxid), + ) + assertTrue("so the coin stays spent", reclaimed.isSpent) + assertTrue( + "and never returns to the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aLaterSweepKeepingACoinSpentOverridesAnEarlierRelease() = runTest { + // JNI delivers one call per sweep batch, in order. The first frees a + // coin, a second transaction spends it, and the second sweep removes + // that spender while freeing nothing — its own winner took the coin. + // The later answer has to win, which is what applying the calls in + // sequence gives: each one holds its losers' inputs before releasing. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 70 } + val firstLoser = ByteArray(32) { 71 } + val secondLoser = ByteArray(32) { 72 } + val contested = makeOutpoint(fundingTxid, 0) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // Both losers claim the coin; each is unconfirmed, as swept losers are. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, firstLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + contested, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, firstLoser) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_100, + contested, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, secondLoser) + handler.onChangesetEnd(walletId, success = true) + + // One round, two batches, in order. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(firstLoser), ByteArray(32) { 73 }, listOf(contested), 400) + sweep(handler, walletId, listOf(secondLoser), ByteArray(32) { 74 }, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + val row = db.txoDao().getByOutpoint(contested)!! + assertTrue("the later sweep kept the coin spent", row.isSpent) + assertTrue( + "so it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + /** + * Seed the review finding's exact shape: one loser transaction shared by + * two wallets, spending one coin from each. Upstream computes each + * wallet's released set independently + * (`per_wallet_released_outpoints`), and neither wallet's own winner row + * is ever created here — matching the "the winner can pay only outside + * addresses" case the released set exists to handle. Both coins live in + * the same funding transaction purely for setup convenience; what makes + * the loser shared is that it spends a TXO owned by each wallet. + * + * Returns the funding txid and the loser txid so callers can build the + * outpoints and drive the sweep. + */ + private suspend fun seedSharedLoserAcrossTwoWallets(walletA: ByteArray, walletB: ByteArray): Pair { + handler.onPersistWalletMetadata(walletA, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + // Distinct xpubs — `accountExtendedPubKeyBytes` carries a unique + // index, so two accounts sharing one would silently fail the second + // registration (`guarded` swallows the constraint violation). + handler.onPersistAccountRegistration( + walletA, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + handler.onPersistAccountRegistration( + walletB, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 31 }, + ) + val accountA = db.accountDao().observeByWallet(walletA).first().single() + val accountB = db.accountDao().observeByWallet(walletB).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletA", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountA.id, + ), + ) + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletB", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountB.id, + ), + ) + + val fundingTxid = ByteArray(32) { 80 } + val loserTxid = ByteArray(32) { 81 } + + // P (vout 0) — wallet A's coin. + handler.onChangesetBegin(walletA) + recordTransaction( + handler, + walletA, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletA, fundingTxid, 0, 100_000, "yWalletA", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletA, success = true) + + // Q (vout 1) — wallet B's coin, same funding transaction. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 1, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + // The shared loser: unconfirmed, spends both P and Q. + handler.onChangesetBegin(walletA) + recordTransaction( + handler, + walletA, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + makeOutpoint(fundingTxid, 1), 2, + ) + handler.onWalletChangesetUtxoSpent(walletA, fundingTxid, 0, loserTxid) + handler.onWalletChangesetUtxoSpent(walletA, fundingTxid, 1, loserTxid) + handler.onChangesetEnd(walletA, success = true) + + return fundingTxid to loserTxid + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletBThenWalletA() = runTest { + // The hold is global, the release is per wallet. The FIRST callback + // that sees the sweep holds EVERY wallet's rows for the loser's + // inputs (stamped with the winner, links to the loser detached) and + // deletes the loser's row outright; each wallet's own callback then + // applies ITS released set to ITS rows, by outpoint — so a later + // callback for the same loser, finding no row, still frees what it + // was entitled to. Wallet B (which releases nothing) runs first: it + // holds A's coin too — conservatively, until A's own verdict lands. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val q = makeOutpoint(fundingTxid, 1) + + sweepRound(walletB, listOf(loserTxid), winnerTxid) + + assertNull( + "the first callback deletes the shared row — the hold outlives it", + db.transactionDao().getByTxid(loserTxid), + ) + val heldP = db.txoDao().getByOutpoint(p)!! + assertTrue("wallet A's coin is held until A's own release names it", heldP.isSpent) + assertNull("the link to the dead loser is detached", heldP.spendingTxid) + assertTrue(winnerTxid.contentEquals(heldP.supersededByTxid)) + + // Wallet A second: the loser's row is gone, and its release of P + // still lands by outpoint. + sweepRound(walletId, listOf(loserTxid), winnerTxid, released = listOf(p)) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's own release must free its own coin", freedP.isSpent) + assertNull(freedP.spendingTxid) + assertNull("the stamp goes with the hold", freedP.supersededByTxid) + + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's own decision to hold Q survives wallet A's callback", heldQ.isSpent) + assertNull(heldQ.spendingTxid) + assertTrue(winnerTxid.contentEquals(heldQ.supersededByTxid)) + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletAThenWalletB() = runTest { + // Mirror of the ordering above: wallet A (which releases P) runs + // first and holds B's coin; B's callback releases nothing. The end + // state must be the same. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 92 } + val p = makeOutpoint(fundingTxid, 0) + val q = makeOutpoint(fundingTxid, 1) + + sweepRound(walletId, listOf(loserTxid), winnerTxid, released = listOf(p)) + + assertNull("the first callback deletes the shared row", db.transactionDao().getByTxid(loserTxid)) + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's coin is held by A's callback until B's own verdict", heldQ.isSpent) + assertNull(heldQ.spendingTxid) + assertTrue(winnerTxid.contentEquals(heldQ.supersededByTxid)) + assertFalse("wallet A's own coin came free at once", db.txoDao().getByOutpoint(p)!!.isSpent) + + sweepRound(walletB, listOf(loserTxid), winnerTxid) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's earlier release must survive wallet B's callback", freedP.isSpent) + assertNull(freedP.spendingTxid) + + val stillHeldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's own decision to hold its coin must stick", stillHeldQ.isSpent) + assertNull(stillHeldQ.spendingTxid) + } + + /** + * [seedSharedLoserAcrossTwoWallets] plus an output of the loser's own — + * phantom money, since a transaction that never confirms funded + * nothing. Driven through the ordinary [onWalletChangesetUtxoAdded] + * write path, the same as every other row in this fixture, rather than + * reaching into the DB directly. + */ + private suspend fun seedSharedLoserWithOwnOutputAcrossTwoWallets( + walletA: ByteArray, + walletB: ByteArray, + ): Pair { + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletA, walletB) + handler.onChangesetBegin(walletA) + handler.onWalletChangesetUtxoAdded( + walletA, loserTxid, 2, 60_000, "yLoserChange", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletA, success = true) + return fundingTxid to loserTxid + } + + @Test + fun sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits() = runTest { + // `commit_batch` calls `store()` once per wallet and each commits + // independently, so wallet A's callback may never arrive at all — a + // crash, a rejection, or simply never coming. One committed callback + // must already be the whole removal: the row and its phantom output + // gone, `onGetCoreTxRecord` blind to it, and A's coin HELD rather + // than restorable — a missing callback leaves a coin conservatively + // held, never a wrongly-spent or resurrectable one. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val phantomOutput = makeOutpoint(loserTxid, 2) + + // Only wallet B's callback ever runs, and it releases nothing. + sweepRound(walletB, listOf(loserTxid), winnerTxid) + + assertNull("one committed callback deletes the row", db.transactionDao().getByTxid(loserTxid)) + assertNull("and the loser's own output with it", db.txoDao().getByOutpoint(phantomOutput)) + val heldP = db.txoDao().getByOutpoint(p)!! + assertTrue("wallet A's coin is held, not returned, while A's verdict is missing", heldP.isSpent) + assertTrue(winnerTxid.contentEquals(heldP.supersededByTxid)) + + // "Restart": a fresh handler bound to the same underlying store. + // Wallet A's own callback never happens. + val restarted = newHandler() + + assertNull("the phantom output must not resurrect across a restart", db.txoDao().getByOutpoint(phantomOutput)) + assertNull( + "wallet A must not read the swept loser back as a live transaction", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + val utxosA = restarted.onLoadWalletList().first { it.walletId.contentEquals(walletId) }.utxos + assertTrue( + "neither the phantom output nor the held coin is handed back as restorable", + utxosA.isEmpty(), + ) + } + + @Test + fun twoWalletsEachReleaseTheirOwnPendingClaimOnASharedLoser() = runTest { + // A shared loser holds one unresolved pending claim per wallet. + // Upstream computes each wallet's released set from that wallet's + // own records (every input of the loser that the winner did not + // take and no surviving record of that wallet still claims), so + // both wallets name both coins. The first callback (A) deletes its + // own released claim, tombstones B's — B's verdict is not in yet, + // and a callback that never arrives must leave a coin held — and + // deletes the row; B's callback, finding no row, still applies its + // release by outpoint and deletes its tombstone. No row and no + // claim survives, and never a freed tombstone. + val walletB = ByteArray(32) { 8 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 65 } + val pA = makeOutpoint(fundingTxid, 8) + val pB = makeOutpoint(fundingTxid, 9) + val loserTxid = ByteArray(32) { 66 } + val winnerTxid = ByteArray(32) { 67 } + + // The loser's row plus one still-unfunded pending claim per wallet + // — what each wallet's own record pass would have staged. + db.transactionDao().upsert( + TransactionEntity(txid = loserTxid, transactionData = ByteArray(10) { 5 }), + ) + registerInputs(loserTxid, listOf(pA, pB)) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pA, inputIndex = 0, spendingTxid = loserTxid, + spendingTransactionTxid = loserTxid, walletId = walletId, + ), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pB, inputIndex = 1, spendingTxid = loserTxid, + spendingTransactionTxid = loserTxid, walletId = walletB, + ), + ) + + sweepRound(walletId, listOf(loserTxid), winnerTxid, released = listOf(pA, pB)) + assertNull("the first callback deletes the row", db.transactionDao().getByTxid(loserTxid)) + assertTrue("A's released claim is deleted outright", db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + val heldB = db.documentDao().getPendingInputsByOutpoint(pB).single() + assertTrue("B's claim is held until B's own verdict", heldB.isSweptTombstone) + assertTrue(walletB.contentEquals(heldB.walletId)) + assertTrue(winnerTxid.contentEquals(heldB.spendingTxid)) + + sweepRound(walletB, listOf(loserTxid), winnerTxid, released = listOf(pA, pB)) + assertTrue( + "B's release reaches its tombstone with the row already gone", + db.documentDao().getPendingInputsByOutpoint(pB).isEmpty(), + ) + assertTrue(db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + } + + @Test + fun aReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() = runTest { + // Cross-round reinstatement: the sweep and its reinstating record + // land in two SEPARATE callback rounds. Upstream's sweep state is + // not monotonic — per CoreChangeSet::merge's documented + // IS-lock-precedence sequence, a transaction swept by an IS-locked + // conflict can return chainlocked and sweep that conflict in turn — + // and the sweep deleted the row outright, so the later record is + // simply an ordinary record of a txid this store no longer holds: + // nothing marks it as "the reinstating one", nothing can refuse it, + // and its output rides along in the same round. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val phantomOutput = makeOutpoint(loserTxid, 2) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing — the row, the phantom output and A's coin's link are gone; + // A's coin is held by the stamp. + sweepRound(walletB, listOf(loserTxid), winnerTxid) + assertNull("sanity: the row is gone after round 1", db.transactionDao().getByTxid(loserTxid)) + assertNull("sanity: the loser's own output is gone after round 1", db.txoDao().getByOutpoint(phantomOutput)) + assertTrue("sanity: A's coin is held", db.txoDao().getByOutpoint(p)!!.isSpent) + + // Round 2, a SEPARATE callback: the wallet returns chainlocked, with + // its own output riding along — transaction before utxo per the + // JNI bridge's account ordering. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 3, 200, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoAdded( + walletId, loserTxid, 2, 60_000, "yLoserChange", ByteArray(25) { 6 }, + 200, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val reinstated = db.transactionDao().getByTxid(loserTxid)!! + assertEquals(200, reinstated.blockHeight) + + val revivedOutput = db.txoDao().getByOutpoint(phantomOutput) + assertNotNull("the reinstated transaction's own output must come back", revivedOutput) + assertEquals(60_000L, revivedOutput!!.amount) + + val reclaimedP = db.txoDao().getByOutpoint(p)!! + assertTrue("wallet A's coin stays spent — now by its own live record", reclaimedP.isSpent) + assertTrue( + "the stamped, unlinked row adopts the reinstated spender's link", + loserTxid.contentEquals(reclaimedP.spendingTxid), + ) + + assertNotNull( + "wallet A must be able to read the reinstated transaction as live again", + handler.onGetCoreTxRecord(walletId, loserTxid), + ) + + // "Restart": the reinstatement has to be durable. + val restarted = newHandler() + assertNotNull("the reinstatement must survive a restart", db.transactionDao().getByTxid(loserTxid)) + assertNotNull("the revived output must survive a restart", db.txoDao().getByOutpoint(phantomOutput)) + assertTrue("the reclaimed input must survive a restart", db.txoDao().getByOutpoint(p)!!.isSpent) + assertNotNull( + "the reinstated transaction must still be readable as live after a restart", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + } + + @Test + fun aSweepReleasingMoreOutpointsThanSqliteCanBindStillCommits() = runTest { + // The released set's size follows the input count of a transaction a + // remote sender chooses, so it is not bounded by anything this wallet + // controls. Binding it one variable per outpoint crosses the + // 999-variable ceiling API 29's framework SQLite still carries: the + // statement throws, the whole atomic round fails, and the watermark + // freezes on a loser that would be re-swept into the same failure + // after every restart. + // + // The count is far past 999 because this suite runs on the host's + // SQLite, whose own ceiling is much higher — at 1200 the pre-fix code + // passed here while still being broken on API 29. What this pins is + // therefore the property that matters, that the query arity does not + // grow with the set at all, rather than one platform's exact limit. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + + val loser = ByteArray(32) { 80 } + // Comfortably past the limit, and past the 1000-variable default of + // newer SQLite too. + val released = (0 until 40000).map { i -> + makeOutpoint(ByteArray(32) { 81 }, i) + } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -1_000, 0, false, "", 1_700_000_000, + ByteArray(0), 0, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + val code = sweep(handler, walletId, listOf(loser), ByteArray(32) { 82 }, released, 400) + val committed = handler.onChangesetEnd(walletId, success = true) + + assertEquals("the sweep callback must not fail on a large release set", 0, code) + assertEquals(0, committed) + assertNull("and the round must actually commit", db.transactionDao().getByTxid(loser)) + } + + @Test + fun sweptTransactionRollsBackWithItsRound() = runTest { + // The deletion is staged in the same buffered transaction as every + // other write in the round, so a round that fails must not take the + // rows with it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val txid = ByteArray(32) { 43 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, txid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(txid), ByteArray(32) { 44 }, emptyList(), 400) + handler.onChangesetEnd(walletId, success = false) + + assertNotNull(db.transactionDao().getByTxid(txid)) + } + + @Test + fun spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore() = runTest { + // CORE-06, out-of-order arrival: an in-block spending tx is persisted + // BEFORE its funding TXO is known (Rust's utxos_spent slice is empty + // because the previous output wasn't classified yet). The spend must + // not be lost — `inputOutpoints` stages a pending-input row that the + // funding TXO's later upsert drains, so the consumed output is excluded + // from the restore set instead of being handed back to Rust as + // spendable. 1:1 mirror of Swift resolveInputOutpoint + upsertUtxo drain. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 41 } + val spendingTxid = ByteArray(32) { 42 } + + // Changeset 1: the in-block spending tx arrives first. Its funding TXO + // is unknown, so a pending-input row is staged (no utxos_spent fires). + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, + makeOutpoint(fundingTxid, 0), 1, // spends fundingTxid:0 (TXO unknown) + ) + handler.onChangesetEnd(walletId, success = true) + + val staged = db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)) + assertEquals(1, staged.size) + assertTrue(spendingTxid.contentEquals(staged.single().spendingTxid)) + // Funding TXO absent → nothing to restore yet. + assertEquals(0, handler.onLoadWalletList().single().utxos.size) + + // Changeset 2: the funding TXO finally lands. The drain links the spend + // (in-block → isSpent) and clears the pending row. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull(txo) + assertTrue(txo!!.isSpent) + assertTrue(spendingTxid.contentEquals(txo.spendingTxid!!)) + assertTrue( + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).isEmpty(), + ) + // The consumed output must NOT be handed back to Rust as spendable. + assertEquals(0, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded() = runTest { + // The loser can be persisted before its own funding output ever is + // (see spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore + // above) — the spend arrives as a `pending_inputs` row rather than a + // `TxoEntity` update. When the sweep holds that input (it's not in + // `releasedOutpoints`), there is no TXO row to mark — the only record + // of the claim is the pending row, which cascades away with the loser + // it names (`spendingTransactionTxid`'s FK) unless + // `onWalletChangesetTransactionsSwept` rescues it first. This is the + // regression the review finding described: seed the pending spend, + // sweep it, restart the store, and only then let the funding UTXO + // arrive. The coin must come back spent, attributed to the winner, + // not as a fresh unspent row. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 61 } + val sweptTxid = ByteArray(32) { 62 } + val winnerTxid = ByteArray(32) { 64 } + + // Changeset 1: the doomed spend arrives with no prior + // `onWalletChangesetUtxoAdded` for `fundingTxid:0` — the funding side + // of that outpoint has not been observed yet. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull( + "sanity: the funding TXO has not arrived yet", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)), + ) + assertEquals( + 1, + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).size, + ) + + // Changeset 2: the sweep holds the input (not in `releasedOutpoints`), + // with nothing on hand to update. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(sweptTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the loser is gone", db.transactionDao().getByTxid(sweptTxid)) + + // Restart: a fresh persister loading the same on-disk store — same + // Room database, new handler, matching this suite's own restart + // idiom (e.g. addressBalanceConflictPreservesDerivationIndicesAcrossRestart above). + val restarted = newHandler() + + // The funding transaction finally arrives and hands the outpoint + // back as a UTXO — the ordinary path a rescan or late block takes. + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull("the funding UTXO's own upsert must still create the row", coin) + assertTrue( + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival", + coin!!.isSpent, + ) + assertTrue(winnerTxid.contentEquals(coin.supersededByTxid)) + assertEquals(0, restarted.onLoadWalletList().single().utxos.size) + } + + @Test + fun aWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() = runTest { + // Records precede sweeps within a round, so a wallet-relevant winner + // whose own funding side is ALSO unobserved stages an ordinary + // pending row for the same outpoint moments before the sweep + // repoints the loser's row into a tombstone. The tombstone keeps the + // loser's original, older `createdAt`, so the drain's newest-wins + // pick would select the winner's ordinary row, take the gated + // branch (`isSpent` stays false until the winner confirms — never, + // for an IS-locked unconfirmed winner), skip the `supersededByTxid` + // stamp, and delete every pending row including the tombstone: the + // durable hold evaporates and the consumed coin re-enters the + // restore set. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 91 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 92 } + val winnerTxid = ByteArray(32) { 93 } + + // Changeset 1: the doomed spend arrives before its funding output. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // The loser's pending row must be strictly older than the winner's, + // as it always is in reality — `createdAt` has millisecond + // resolution and both rows land in the same test-run instant + // otherwise. + Thread.sleep(5) + + // Changeset 2: the winner's record (IS-locked, still unconfirmed) + // and the sweep it caused, records first — the order the persist + // path guarantees inside one round. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, + ) + sweep(handler, walletId, listOf(loserTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + val rows = db.documentDao().getPendingInputsByOutpoint(pOutpoint) + assertEquals(2, rows.size) + assertEquals(1, rows.count { it.isSweptTombstone }) + + // The funding TXO finally arrives and drains both rows. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue( + "the sweep's hold must survive the winner's own coexisting pending row", + coin.isSpent, + ) + assertTrue(winnerTxid.contentEquals(coin.supersededByTxid)) + assertTrue( + "the consumed coin must stay out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aBatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() = runTest { + // The multi-loser batch shape upstream's descendant closure always + // produces — parent P and child C removed together — which no + // fixture here ever exercised: C spends P:0, still unfunded, so the + // claim lives as a pending row. Upstream never releases a + // loser-funded outpoint, so without a co-swept check the sweep + // tombstones the claim to the winner — and P's chainlocked + // reinstatement then re-delivers P:0 straight into the + // tombstone-outranks drain: isSpent = true, supersededByTxid = + // winner, a hold on a coin the winner never took. A dead parent's + // output is nobody's coin; the claim must be deleted with the + // batch. + seedWalletWithAddress(walletId, "yFundAddr") + + val parentTxid = ByteArray(32) { 101 } // P — record never persisted + val pOutpoint = makeOutpoint(parentTxid, 0) + val childTxid = ByteArray(32) { 102 } // C + val winnerTxid = ByteArray(32) { 103 } // W + + // C arrives spending the still-unfunded P:0 — parked as a pending + // claim. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, childTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(1, db.documentDao().getPendingInputsByOutpoint(pOutpoint).size) + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(parentTxid, childTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "a claim on a co-swept parent's output must be deleted, not tombstoned", + db.documentDao().getPendingInputsByOutpoint(pOutpoint).isEmpty(), + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parentTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint)!! + assertFalse( + "the reinstated parent's output must not be wedged by its dead child's claim", + coin.isSpent, + ) + assertNull(coin.supersededByTxid) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun chainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() = runTest { + // Regression for the review finding on + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded above: + // that fix repoints a held-but-unfunded pending input at its sweep's + // winner and detaches it from `spendingTransactionTxid` so it + // survives the loser's cascade-delete. But a SECOND sweep of that + // winner — the sweep's staged-row fetch matches + // `spendingTransactionTxid = :txid`, which the first tombstoning + // already cleared to null — cannot find the row that way anymore. + // L spends P; W spends P and Q and sweeps L, holding the still- + // unfunded P; X spends Q and sweeps W, this time releasing P. P's + // funding TXO finally arrives and must come back spendable. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 71 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val qOutpoint = makeOutpoint(ByteArray(32) { 72 }, 0) + val firstLoserTxid = ByteArray(32) { 73 } // L + val secondLoserTxid = ByteArray(32) { 74 } // W + val finalWinnerTxid = ByteArray(32) { 75 } // X + + // L spends only P, and P's funding side has never been observed. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, firstLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_070, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P (still unfunded). + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(firstLoserTxid), secondLoserTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(pOutpoint).single() + assertTrue("the first sweep must tombstone the pending row", tombstone.isSweptTombstone) + assertTrue(secondLoserTxid.contentEquals(tombstone.spendingTxid)) + assertNull( + "the tombstone must have detached from the doomed loser's FK", + tombstone.spendingTransactionTxid, + ) + + // W's own record — spends P and Q — must be on hand for the second + // sweep to find, the same requirement any sweep of a wallet-relevant + // loser has. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_071, + pOutpoint + qOutpoint, 2, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W, releasing P this time. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoserTxid), finalWinnerTxid, listOf(pOutpoint), 400) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "a released outpoint's tombstone must not survive a chained sweep", + db.documentDao().getPendingInputsByOutpoint(pOutpoint).isEmpty(), + ) + + // P's funding TXO finally arrives. + val restarted = newHandler() + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint) + assertNotNull(coin) + assertFalse( + "the final sweep released this coin, so it must come back spendable " + + "even though an earlier sweep in the chain had tombstoned it", + coin!!.isSpent, + ) + } + + @Test + fun aReleasedCoinDropsItsDeadWinnersMarker() = runTest { + // The funding-BEFORE-release ordering of the chained scenario above: + // the funding TXO arrives between the sweep that held the coin and + // the sweep that frees it, so the tombstone drains into + // `TxoEntity.supersededByTxid` and the pending row is gone by the + // time the release runs. The release must clear that column with + // the hold: W has no stored row, so its stamp cannot veto, and a + // released coin keeping its dead winner's marker would read as a + // durable claim to every later hold on this outpoint. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 96 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 97 } // L + val intermediateWinner = ByteArray(32) { 98 } // W — never recorded here + val finalWinner = ByteArray(32) { 99 } // X + + // L spends the still-unfunded P. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(loserTxid), intermediateWinner, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + // P's funding TXO arrives NOW — the drain consumes the tombstone + // and stamps the claim onto the row itself. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val stamped = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue("sanity: the drained claim holds the coin", stamped.isSpent) + assertTrue(intermediateWinner.contentEquals(stamped.supersededByTxid)) + + // Second sweep: X beats W, and this time upstream frees P. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(intermediateWinner), finalWinner, listOf(pOutpoint), 400) + handler.onChangesetEnd(walletId, success = true) + + val freed = db.txoDao().getByOutpoint(pOutpoint)!! + assertFalse("the released coin is spendable again", freed.isSpent) + assertNull( + "and its dead winner's marker goes with the hold it carried", + freed.supersededByTxid, + ) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun chainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() = runTest { + // The held (not released) half of the chained scenario above: the + // second sweep keeps P spent instead of releasing it, and the + // tombstone must end up attributed to the NEW winner rather than the + // intermediate one that no longer has a row. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 81 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val firstLoserTxid = ByteArray(32) { 83 } // L + val secondLoserTxid = ByteArray(32) { 84 } // W + val finalWinnerTxid = ByteArray(32) { 85 } // X + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, firstLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_080, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(firstLoserTxid), secondLoserTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + // W's own record, needed by the second sweep below. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_081, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W, still holding the same input. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoserTxid), finalWinnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(pOutpoint).single() + assertTrue(tombstone.isSweptTombstone) + assertTrue( + "the tombstone must be repointed at the FINAL winner, not the " + + "intermediate one the second sweep already removed", + finalWinnerTxid.contentEquals(tombstone.spendingTxid), + ) + + val restarted = newHandler() + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint) + assertNotNull(coin) + assertTrue( + "the final winner's claim must survive both sweeps and the " + + "funding UTXO's own arrival", + coin!!.isSpent, + ) + assertTrue(finalWinnerTxid.contentEquals(coin.supersededByTxid)) + } + + @Test + fun sharedWinnerDeletedByAnotherWalletsCallbackStillAppliesThisWalletsReleaseToItsOwnTombstones() = runTest { + // Multi-wallet continuation of the chained-before-funding scenarios + // above. The hold is global and the row goes with the FIRST + // callback, so wallet B's callback for the shared winner W arrives + // after W's row is gone: it must still apply B's own release by + // outpoint to B's own tombstones (deleting a released one, never + // leaving a freed tombstone), while the held tombstones — every + // wallet's — were already re-pointed at X by A's callback. + val walletB = ByteArray(32) { 9 } + seedWalletWithAddress(walletId, "yWalletA", xpubFill = 30) + seedWalletWithAddress(walletB, "yWalletB", xpubFill = 31) + + val fundingTxid = ByteArray(32) { 101 } + val pA = makeOutpoint(fundingTxid, 0) + val pB = makeOutpoint(fundingTxid, 1) + val rB = makeOutpoint(fundingTxid, 2) + val sharedLoser = ByteArray(32) { 103 } // L + val sharedWinner = ByteArray(32) { 104 } // W + val finalWinner = ByteArray(32) { 105 } // X + + // The shared loser L claims one still-unfunded coin of wallet A's + // and two of wallet B's. Its record arrives through wallet A's + // round; a pending row carries the wallet of the round that wrote + // it, so wallet B's two claims are seeded directly in the exact + // shape B's own round would have written them. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sharedLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pA + pB + rB, 3, + ) + handler.onChangesetEnd(walletId, success = true) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pB, inputIndex = 1, spendingTxid = sharedLoser, + spendingTransactionTxid = sharedLoser, walletId = walletB, + ), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = rB, inputIndex = 2, spendingTxid = sharedLoser, + spendingTransactionTxid = sharedLoser, walletId = walletB, + ), + ) + + // First sweep: W beats L, holding everything (nothing funded, + // nothing released). A's callback tombstones every wallet's claim + // and deletes L; B's callback finds nothing left to do. + sweepRound(walletId, listOf(sharedLoser), sharedWinner) + assertNull("L is gone with the first callback", db.transactionDao().getByTxid(sharedLoser)) + sweepRound(walletB, listOf(sharedLoser), sharedWinner) + for (outpoint in listOf(pA, pB, rB)) { + val rows = db.documentDao().getPendingInputsByOutpoint(outpoint) + assertTrue("every claim on ${outpoint.toHex()} is a tombstone held by W", rows.all { it.isSweptTombstone && sharedWinner.contentEquals(it.spendingTxid) }) + } + + // W's own record arrives through A's round, claiming all three + // outpoints. A's `(pA, W)` tombstone occupies the duplicate-guard + // key; B's tombstones are B's, so A stages its own ordinary claims + // on pB and rB. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sharedWinner, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_091, + pA + pB + rB, 3, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W. Wallet A's callback runs first, releasing + // pA and rB (X took only pB) — its own claims on those are deleted, + // B's claims on them are held until B speaks — and deletes W's row. + sweepRound(walletId, listOf(sharedWinner), finalWinner, released = listOf(pA, rB)) + assertNull( + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive", + db.transactionDao().getByTxid(sharedWinner), + ) + assertTrue("A's released claim on pA is gone", db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + val heldForB = db.documentDao().getPendingInputsByOutpoint(rB).single() + assertTrue("B's claim on rB is held by A's callback, re-pointed at X", heldForB.isSweptTombstone) + assertTrue(walletB.contentEquals(heldForB.walletId)) + assertTrue(finalWinner.contentEquals(heldForB.spendingTxid)) + + // Wallet B's callback arrives after the row is gone, releasing rB + // and holding pB. + sweepRound(walletB, listOf(sharedWinner), finalWinner, released = listOf(rB)) + + val heldTombstones = db.documentDao().getPendingInputsByOutpoint(pB) + assertTrue(heldTombstones.isNotEmpty()) + for (tombstone in heldTombstones) { + assertTrue(tombstone.isSweptTombstone) + assertTrue( + "the held tombstones follow the chain to X even though W's row was " + + "already deleted by wallet A's callback", + finalWinner.contentEquals(tombstone.spendingTxid), + ) + } + assertTrue( + "wallet B's release reaches its tombstone even though W's row was " + + "already deleted by wallet A's callback", + db.documentDao().getPendingInputsByOutpoint(rB).isEmpty(), + ) + + // The funding TXOs finally arrive, one round per owning wallet. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yWalletA", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 1, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 2, 20_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + assertFalse("wallet A's released coin comes back spendable", db.txoDao().getByOutpoint(pA)!!.isSpent) + val heldCoin = db.txoDao().getByOutpoint(pB)!! + assertTrue("wallet B's held coin stays spent", heldCoin.isSpent) + assertTrue( + "the held coin must be attributed to the final winner, not the deleted W", + finalWinner.contentEquals(heldCoin.supersededByTxid), + ) + val releasedCoin = db.txoDao().getByOutpoint(rB)!! + assertFalse( + "wallet B's released coin must not resurrect spent under the obsolete winner", + releasedCoin.isSpent, + ) + assertNull(releasedCoin.supersededByTxid) + } + + @Test + fun anotherWalletsTombstoneStillHoldsACoinAtDrainWhenTheOwnerHasNone() = runTest { + // The per-wallet half of the drain preference: the delivering + // wallet's own tombstone is preferred, but when it has none, any + // tombstone on the outpoint still holds — the stamp is a txid fact, + // not a per-wallet one, and the owner's callback may simply never + // have arrived. Wallet A recorded a loser spending B's still-unfunded + // coin; only A's sweep callback ever ran. + val walletB = ByteArray(32) { 9 } + seedWalletWithAddress(walletId, "yWalletA", xpubFill = 30) + seedWalletWithAddress(walletB, "yWalletB", xpubFill = 31) + val fundingTxid = ByteArray(32) { 0x61 } + val coinOfB = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 0x62 } + val winner = ByteArray(32) { 0x63 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + coinOfB, 1, + ) + handler.onChangesetEnd(walletId, success = true) + sweepRound(walletId, listOf(loser), winner) + val tombstone = db.documentDao().getPendingInputsByOutpoint(coinOfB).single() + assertTrue(tombstone.isSweptTombstone && walletId.contentEquals(tombstone.walletId)) + + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 0, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + val coin = db.txoDao().getByOutpoint(coinOfB)!! + assertTrue("A's tombstone holds B's coin at drain", coin.isSpent) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + assertTrue("and the drained rows are gone", db.documentDao().getPendingInputsByOutpoint(coinOfB).isEmpty()) + } + + // ── Outpoint-keyed holds, settled claims, round-scoped passes ───── + + /** + * Wallet, address, and one funded coin at `fundingTxid:0` (in-block, + * recorded + delivered in one round). Returns the coin's outpoint. + */ + private suspend fun seedFundedCoin(fundingTxid: ByteArray, address: String = "yUtxoAddr"): ByteArray { + seedWalletWithAddress(walletId, address) + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, address, ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + return makeOutpoint(fundingTxid, 0) + } + + /** One committed round recording a mempool spend of [inputs] by [txid]. */ + private fun recordMempoolSpend(txid: ByteArray, vararg inputs: ByteArray, context: Int = 0, h: PlatformWalletPersistenceHandler = handler) { + h.onChangesetBegin(walletId) + recordTransaction( + h, + walletId, txid, ByteArray(10) { 5 }, context, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + inputs.fold(ByteArray(0)) { acc, op -> acc + op }, inputs.size, + ) + h.onChangesetEnd(walletId, success = true) + } + + @Test + fun aWinnerRecordedInTheSameRoundDoesNotHideTheLosersInputFromTheHold() = runTest { + // The hold is keyed by OUTPOINT, decoded from the loser's stored + // bytes, not by which rows still link to the loser. Own coin O is + // linked to mempool loser L. One round carries the winner's record + // (IS-locked, spends O) and the sweep of L. Records precede sweeps, + // so W takes the link first — at `isSpent = 0`, since only a block + // flips the flag on the record channel — and a hold keyed by + // `spendingTxid = L` then finds nothing: after a restart the store + // hands O back as spendable while the winner sits unmined. With the + // hold keyed by L's decoded inputs, O is stamped whatever it links + // to, and the link to W is kept. + val fundingTxid = ByteArray(32) { 0x30 } + val coin = seedFundedCoin(fundingTxid) + val loser = ByteArray(32) { 0x31 } + val winner = ByteArray(32) { 0x32 } + recordMempoolSpend(loser, coin) + assertTrue(loser.contentEquals(db.txoDao().getByOutpoint(coin)!!.spendingTxid)) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winner, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + coin, 1, + ) + sweep(handler, walletId, listOf(loser), winner, emptyList(), -1) + handler.onChangesetEnd(walletId, success = true) + + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("the coin the winner took is held although its link moved before the sweep", held.isSpent) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertTrue("the link to the winner is kept — only a link to the loser is detached", winner.contentEquals(held.spendingTxid)) + assertNull(db.transactionDao().getByTxid(loser)) + assertTrue("and it stays out of the restore set", newHandler().onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aLoserWithNoStoredBytesStillHoldsTheCoinsLinkedToIt() = runTest { + // The record-lost fallback: a loser whose row carries no bytes (a + // stub `utxos_added` wrote, or a record whose data never arrived) + // cannot name its inputs, so the rows still linked to it and the + // pending rows still claimed by it are the input set. The coin is + // held all the same. + val fundingTxid = ByteArray(32) { 0x33 } + val coin = seedFundedCoin(fundingTxid) + val loser = ByteArray(32) { 0x34 } + val winner = ByteArray(32) { 0x35 } + db.transactionDao().upsert(TransactionEntity(txid = loser, transactionData = ByteArray(0))) + db.txoDao().upsert(db.txoDao().getByOutpoint(coin)!!.copy(spendingTxid = loser, spendingInputIndex = 0)) + + sweepRound(walletId, listOf(loser), winner) + + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue(held.isSpent) + assertNull(held.spendingTxid) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertNull(db.transactionDao().getByTxid(loser)) + } + + @Test + fun aLoserWhoseStoredBytesCannotBeDecodedFailsTheRoundClosed() = runTest { + // A stored record the decoder rejects fails the round rather than + // sweeping a loser whose inputs are unknown: the typed key named the + // row a swept loser, and processing it blind could free the wrong + // coins. Same verdict as the SQLite store's `apply_sweep` on a bad + // blob. The round rolls back, so nothing — not even the delete — + // lands. + val fundingTxid = ByteArray(32) { 0x36 } + val coin = seedFundedCoin(fundingTxid) + val loser = ByteArray(32) { 0x37 } + val winner = ByteArray(32) { 0x38 } + recordMempoolSpend(loser, coin) + recordedInputs.remove(loser.toHex()) + + handler.onChangesetBegin(walletId) + assertEquals(0, sweep(handler, walletId, listOf(loser), winner, emptyList(), 400)) + assertEquals("the round is refused", 1, handler.onChangesetEnd(walletId, success = true)) + + assertNotNull("nothing landed: the loser's row survives", db.transactionDao().getByTxid(loser)) + val untouched = db.txoDao().getByOutpoint(coin)!! + assertFalse(untouched.isSpent) + assertTrue(loser.contentEquals(untouched.spendingTxid)) + } + + @Test + fun aReleaseOfACoinItsStoredFinalWinnerSpendsIsRefusedByTheStamp() = runTest { + // The settled-claim veto by STAMP, on a row with no settled link to + // veto through. W (IS-locked, stored, spends O) was recorded before + // O's funding arrived, so its claim was a pending row; the sweep of + // L tombstoned L's claim to W, and O's arrival drained the + // tombstone into a stamp — unlinked, because a drain never mints a + // link, and W's own ordinary claim went with the drain. A later + // conflicting mempool L2 adopts the link; L3 IS-locks L2's other + // input and sweeps L2 with O in its released set — upstream's live + // view has no record claiming O. The stored W is a network-final + // claim on O, so the release is refused; without the stamp veto the + // hold pass would detach L2 and the release would flip a provably + // consumed coin unspent. + seedWalletWithAddress(walletId, "yUtxoAddr") + val fundingTxid = ByteArray(32) { 0x39 } + val coin = makeOutpoint(fundingTxid, 0) + val other = makeOutpoint(ByteArray(32) { 0x3A }, 0) + val loser = ByteArray(32) { 0x3B } + val winner = ByteArray(32) { 0x3C } + val laterLoser = ByteArray(32) { 0x3D } + val finalWinner = ByteArray(32) { 0x3E } + recordMempoolSpend(loser, coin) + recordMempoolSpend(winner, coin, context = 1) + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = -1) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: held by the stamp, unlinked", held.isSpent && held.spendingTxid == null) + assertTrue(winner.contentEquals(held.supersededByTxid)) + + // A conflicting mempool spend adopts the link; the hold is the stamp. + recordMempoolSpend(laterLoser, coin, other) + val adopted = db.txoDao().getByOutpoint(coin)!! + assertTrue(laterLoser.contentEquals(adopted.spendingTxid)) + assertTrue("adoption keeps the flag and the stamp", adopted.isSpent) + assertTrue(winner.contentEquals(adopted.supersededByTxid)) + + sweepRound(walletId, listOf(laterLoser), finalWinner, released = listOf(coin), winnerMinedHeight = -1) + + val stillHeld = db.txoDao().getByOutpoint(coin)!! + assertTrue("a release of a coin a stored final winner spends is refused", stillHeld.isSpent) + assertTrue(winner.contentEquals(stillHeld.supersededByTxid)) + assertNull("the dead link is detached all the same", stillHeld.spendingTxid) + assertTrue(newHandler().onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aStampNamingAFinalTransactionThatDoesNotSpendTheCoinDoesNotVeto() = runTest { + // The stamp alone is not proof the winner took the coin: a hold + // stamps the winner on EVERY non-released input of a loser, and an + // input can be unreleased because a different surviving record + // claims it. So the veto reads the stamped winner's stored bytes — + // as the SQLite store's claim scan reads every claimant's inputs — + // and vetoes only when they spend the coin. Here W (IS-locked, + // stored) spends only P; O was held under W's stamp because own + // record R also claimed it; when R is swept with O released, W's + // stamp must not strand O. + val fundingTxid = ByteArray(32) { 0x40 } + val coin = seedFundedCoin(fundingTxid) + val p = makeOutpoint(ByteArray(32) { 0x41 }, 0) + val loser = ByteArray(32) { 0x42 } + val rival = ByteArray(32) { 0x43 } + val winner = ByteArray(32) { 0x44 } + val laterWinner = ByteArray(32) { 0x45 } + recordMempoolSpend(loser, coin, p) + recordMempoolSpend(rival, coin) + recordMempoolSpend(winner, p, context = 1) + // W beats L on P; O is not released because R still claims it. + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = -1) + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: held under W's stamp, linked to R", held.isSpent) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertTrue(rival.contentEquals(held.spendingTxid)) + + // R is beaten in turn and O comes free. + sweepRound(walletId, listOf(rival), laterWinner, released = listOf(coin), winnerMinedHeight = -1) + + val freed = db.txoDao().getByOutpoint(coin)!! + assertFalse("W never spent O, so its stamp does not veto the release", freed.isSpent) + assertNull(freed.supersededByTxid) + assertNull(freed.spendingTxid) + } + + @Test + fun aConflictingMempoolSpentEmitDoesNotLowerAHealedSpendFlag() = runTest { + // `isSpent` is monotonic on the `utxos_spent` channel. O is linked + // to asset-lock funding tx F stuck at mempool context and was + // healed to `isSpent = 1` (the SPV-miss case). A conflicting mempool + // spend N arrives via `utxos_spent`: F is not settled, so N takes + // the link — but the flag must not be re-answered from N's context. + // Before the fix it was, O re-entered the restore set, and the + // asset-lock heal was no longer consulted because the link was N's. + val fundingTxid = ByteArray(32) { 0x46 } + val coin = seedFundedCoin(fundingTxid) + val lockTx = ByteArray(32) { 0x47 } + val conflicting = ByteArray(32) { 0x48 } + recordMempoolSpend(lockTx, coin) + db.txoDao().markSpentBySpendingTxid(lockTx, java.util.Date()) + assertTrue("sanity: healed", db.txoDao().getByOutpoint(coin)!!.isSpent) + db.transactionDao().upsert(TransactionEntity(txid = conflicting, transactionData = ByteArray(10) { 9 })) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, conflicting) + handler.onChangesetEnd(walletId, success = true) + + val row = db.txoDao().getByOutpoint(coin)!! + assertTrue("a mempool usurper never lowers the flag", row.isSpent) + assertTrue("though it takes the link from a mempool spender", conflicting.contentEquals(row.spendingTxid)) + } + + @Test + fun aTombstoneWhoseFundingArrivesInTheFinalizingRoundDrainsBeforeTheCollector() = runTest { + // The collector runs once per round, at the END — after every + // account slice and every sweep. A tombstone T (O → W, mined 400) + // survives from an earlier round; the chainlock already covers 400. + // A later round folds a backward rescan delivering O together with + // the synced height that completes the boundary. Collecting at the + // header would delete T before the drain could move its hold onto + // O, and O would land unspent although W provably consumed it. + seedWalletWithAddress(walletId, "yFundAddr") + chainLockHeightRound(handler, 10_000) + val fundingTxid = ByteArray(32) { 0x49 } + val coin = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 0x4A } + val winner = ByteArray(32) { 0x4B } + seedSweptTombstone(coin, loser, winner, winnerMinedHeight = 400) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, hasSyncedHeight = true, syncedHeight = 400, hasBalance = false, + confirmedDelta = 0, unconfirmedDelta = 0, immatureDelta = 0, lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(84) { 9 }, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val drained = db.txoDao().getByOutpoint(coin)!! + assertTrue("the funding delivery drained the tombstone before anything collected it", drained.isSpent) + assertTrue(winner.contentEquals(drained.supersededByTxid)) + assertTrue(db.documentDao().getPendingInputsByOutpoint(coin).isEmpty()) + } + + @Test + fun theCoSweptSetSpansEveryBatchOfTheRound() = runTest { + // One round carries two batches: {P by W1} then {C by W2}, where + // child C's pending row names P:0. Evaluated per batch, the second + // batch does not know P is swept and tombstones the claim to W2 — + // a hold on a dead parent's output that wedges P's chainlocked + // reinstatement. Evaluated against the union of the round's txids, + // the claim is deleted. + seedWalletWithAddress(walletId, "yFundAddr") + val parent = ByteArray(32) { 0x4C } + val child = ByteArray(32) { 0x4D } + val w1 = ByteArray(32) { 0x4E } + val w2 = ByteArray(32) { 0x4F } + val parentOutput = makeOutpoint(parent, 0) + recordMempoolSpend(parent, makeOutpoint(ByteArray(32) { 0x50 }, 0)) + recordMempoolSpend(child, parentOutput) + assertEquals(1, db.documentDao().getPendingInputsByOutpoint(parentOutput).size) + + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(parent), w1, emptyList(), 400) + sweep(handler, walletId, listOf(child), w2, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "the child's claim on the co-swept parent's output is deleted, not tombstoned", + db.documentDao().getPendingInputsByOutpoint(parentOutput).isEmpty(), + ) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parent, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertFalse("the reinstated parent's output lands spendable", db.txoDao().getByOutpoint(parentOutput)!!.isSpent) + } + + @Test + fun aReleaseNamingAnOutputOfACoSweptParentDeletesItRatherThanFreeingIt() = runTest { + // A released outpoint whose funding transaction is swept in this + // round is deleted whatever its shape: a coin created by a dead + // transaction cannot be unspent, only gone. P's output materialised + // (P's own record never did — a stub row carries it); C spends it; + // the round sweeps both and a release names P:0. + seedWalletWithAddress(walletId, "yFundAddr") + val parent = ByteArray(32) { 0x51 } + val child = ByteArray(32) { 0x52 } + val winner = ByteArray(32) { 0x53 } + val parentOutput = makeOutpoint(parent, 0) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parent, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + recordMempoolSpend(child, parentOutput) + assertTrue(child.contentEquals(db.txoDao().getByOutpoint(parentOutput)!!.spendingTxid)) + + sweepRound(walletId, listOf(parent, child), winner, released = listOf(parentOutput)) + + assertNull("a dead parent's output is deleted, never freed", db.txoDao().getByOutpoint(parentOutput)) + assertNull(db.transactionDao().getByTxid(parent)) + assertNull(db.transactionDao().getByTxid(child)) + } + + @Test + fun aDrainedTombstoneStampsWithoutLinkingSoALaterReleaseCanFreeTheCoin() = runTest { + // A drained tombstone STAMPS, it never mints a spender link — even + // when the winner's own row exists. An input can be unreleased + // because another live record claims it, not because the winner + // took it; a link to W would make the coin non-releasable when that + // record is swept in turn with O released. Own L (spends O + P) and + // own R (spends O + Q); W (spends P only) sweeps L; O is not + // released (R claims it). O's funding arrives: the tombstone drains + // into a stamp, unlinked. W2 sweeps R with O released: O comes free. + seedWalletWithAddress(walletId, "yFundAddr") + val fundingTxid = ByteArray(32) { 0x54 } + val coin = makeOutpoint(fundingTxid, 0) + val p = makeOutpoint(ByteArray(32) { 0x55 }, 0) + val q = makeOutpoint(ByteArray(32) { 0x56 }, 0) + val loser = ByteArray(32) { 0x57 } + val rival = ByteArray(32) { 0x58 } + val winner = ByteArray(32) { 0x59 } + val laterWinner = ByteArray(32) { 0x5A } + recordMempoolSpend(loser, coin, p) + recordMempoolSpend(rival, coin, q) + recordMempoolSpend(winner, p, context = 1) + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = -1) + assertTrue(db.documentDao().getPendingInputsByOutpoint(coin).any { it.isSweptTombstone }) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + val drained = db.txoDao().getByOutpoint(coin)!! + assertTrue(drained.isSpent) + assertTrue(winner.contentEquals(drained.supersededByTxid)) + assertNull("the drain stamps; it does not link the winner", drained.spendingTxid) + assertNull(drained.spendingInputIndex) + + sweepRound(walletId, listOf(rival), laterWinner, released = listOf(coin), winnerMinedHeight = -1) + val freed = db.txoDao().getByOutpoint(coin)!! + assertFalse("a stamped, unlinked coin is exactly what a release can free", freed.isSpent) + assertNull(freed.supersededByTxid) + } + + @Test + fun aSecondWalletRecordingTheSameSpendGetsItsOwnPendingRow() = runTest { + // Pending rows are per (outpoint, spendingTxid, walletId). Sweep + // holds and releases are decided per wallet, so a second wallet + // recording the same spend of a not-yet-materialised coin must get + // its own row — with one shared row, the first wallet's release or + // collector could erase the only hold the second was entitled to + // keep. + val walletB = ByteArray(32) { 9 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + val coin = makeOutpoint(ByteArray(32) { 0x5B }, 0) + val spender = ByteArray(32) { 0x5C } + for (wallet in listOf(walletId, walletB)) { + handler.onChangesetBegin(wallet) + recordTransaction( + handler, + wallet, spender, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + coin, 1, + ) + handler.onChangesetEnd(wallet, success = true) + } + val rows = db.documentDao().getPendingInputsByOutpoint(coin) + assertEquals(2, rows.size) + assertEquals( + setOf(walletId.toHex(), walletB.toHex()), + rows.map { it.walletId.toHex() }.toSet(), + ) + // And a re-emit for the same wallet is still deduplicated. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, spender, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + coin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(2, db.documentDao().getPendingInputsByOutpoint(coin).size) + } + + @Test + fun aRefusedClaimDoesNotEraseAnotherWalletsTombstoneOnTheOutpoint() = runTest { + // The found-TXO branch of the record channel prunes pending rows on + // the outpoint. When the arriving record's claim is REFUSED (a + // settled spender keeps the link), only that record's own rows are + // stale; another wallet's tombstone on the outpoint is that + // wallet's hold and not this record's to erase. When the claim is + // accepted, only this wallet's ordinary rows go. + val walletB = ByteArray(32) { 9 } + val fundingTxid = ByteArray(32) { 0x5D } + val coin = seedFundedCoin(fundingTxid) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + val settled = ByteArray(32) { 0x5E } + val usurper = ByteArray(32) { 0x5F } + val someWinner = ByteArray(32) { 0x60 } + recordMempoolSpend(settled, coin, context = 1) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = coin, inputIndex = 0, spendingTxid = someWinner, + spendingTransactionTxid = null, walletId = walletB, isSweptTombstone = true, + ), + ) + + recordMempoolSpend(usurper, coin) + + val row = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: the settled spender kept its link", settled.contentEquals(row.spendingTxid)) + val survivor = db.documentDao().getPendingInputsByOutpoint(coin).single() + assertTrue("wallet B's tombstone survives a refused claim", survivor.isSweptTombstone) + assertTrue(walletB.contentEquals(survivor.walletId)) + } + + @Test + fun aBatchSweepingMoreLosersThanSqliteCanBindStillCommits() = runTest { + // The loser side of the arity discipline: every per-batch statement + // is a chunked `IN (:chunk)` form, so a batch of more losers than + // SQLite can bind in one statement still commits. The count is past + // the host's own ceiling (32766) for the same reason + // aSweepReleasingMoreOutpointsThanSqliteCanBindStillCommits gives: + // what is pinned is that the arity does not grow with the batch. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val count = 33_000 + val losers = (0 until count).map { i -> + ByteArray(32).also { it[0] = (i and 0xFF).toByte(); it[1] = (i shr 8).toByte(); it[2] = 0x7E } + } + // Stub rows (no bytes — nothing to decode), seeded in one SQL + // transaction; every one is a loser this batch names. + val raw = db.openHelper.writableDatabase + raw.beginTransaction() + try { + val insert = raw.compileStatement( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, transactionType, " + + "transactionTypeKind, netAmount, label, firstSeen, createdAt, lastUpdated) " + + "VALUES (?, x'', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)", + ) + for (loser in losers) { + insert.bindBlob(1, loser) + insert.executeInsert() + } + raw.setTransactionSuccessful() + } finally { + raw.endTransaction() + } + assertEquals(count.toLong(), db.transactionDao().count().first()) + val winner = ByteArray(32) { 0x7C } + + sweepRound(walletId, losers, winner) + + assertEquals("every loser's row is gone", 0L, db.transactionDao().count().first()) + } + + @Test + fun aSweepBatchWhosePackedLengthDisagreesWithItsCountFailsTheRound() = runTest { + // The trampoline ships txids and released outpoints as flat arrays + // plus counts; a descriptor or packing drift must fail the round, + // never silently truncate a sweep. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onChangesetBegin(walletId) + val code = handler.onWalletChangesetTransactionsSwept( + walletId, ByteArray(31), 1, ByteArray(32) { 3 }, ByteArray(0), 0, true, 400, + ) + assertTrue("a malformed batch is refused at the callback", code != 0) + handler.onChangesetEnd(walletId, success = false) + } + + @Test + fun loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { + // prior-2 regression: the persisted Core address pools must come + // back on the restore row so every restored address maps to its + // derivation path — including addresses PAST the gap-limit window + // (`DEFAULT_GAP_LIMIT` = 20) that `ManagedWalletInfo::from_wallet` + // pre-derives. Without this, a restored UTXO on an out-of-window + // address has no derivation-path mapping and the wallet cannot + // sign a core-to-core spend after a cold restart. Mirror of the + // Swift `buildCoreAddressPoolBuffer` round-trip. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + val account = db.accountDao().observeByWallet(walletId).first().single() + + // An external (pool tag 0) address well beyond the gap window, + // used and carrying a balance + a full derivation path + pubkey. + val pubkey = ByteArray(33) { 4 } + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFarAddr", + publicKey = pubkey, + poolTypeTag = 0, + addressIndex = 100, + derivationPath = "m/44'/1'/0'/0/100", + isUsed = true, + balance = 12_345, + accountId = account.id, + ), + ) + // A second, unused internal (pool tag 1) address — proves grouping + // by pool type emits a distinct pool for the change chain. + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yChangeAddr", + publicKey = ByteArray(0), + poolTypeTag = 1, + addressIndex = 3, + derivationPath = "m/44'/1'/0'/1/3", + isUsed = false, + accountId = account.id, + ), + ) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + val pools = list[0].coreAddressPools + // One pool per (account, poolType) group, ascending tag order. + assertEquals(2, pools.size) + + val external = pools[0] + assertEquals(0.toByte(), external.poolTypeTag) + // The pool routes via the account tuple (xpub omitted — the loader + // ignores it on this path). + assertEquals(0.toByte(), external.account.typeTag) + assertEquals(0, external.account.index) + assertEquals(0, external.account.accountXpubBytes.size) + assertEquals(1, external.addresses.size) + val far = external.addresses[0] + assertEquals("yFarAddr", far.addressBase58) + // The out-of-window address keeps its derivation path — the whole + // point of the fix. + assertEquals("m/44'/1'/0'/0/100", far.derivationPath) + assertEquals(100, far.addressIndex) + assertTrue(far.isUsed) + assertEquals(12_345L, far.balance) + assertTrue(pubkey.contentEquals(far.publicKey)) + assertEquals(0.toByte(), far.poolTypeTag) + + val internal = pools[1] + assertEquals(1.toByte(), internal.poolTypeTag) + assertEquals(1, internal.addresses.size) + val change = internal.addresses[0] + assertEquals("yChangeAddr", change.addressBase58) assertEquals("m/44'/1'/0'/1/3", change.derivationPath) assertEquals(3, change.addressIndex) assertFalse(change.isUsed) @@ -2638,7 +4917,8 @@ class PlatformWalletPersistenceHandlerTest { val fundingOutpoint = makeOutpoint(fundingTxid, 0) val fundingTxData = ByteArray(24) { 52 } handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, fundingTxid, fundingTxData, 2, 200, ByteArray(32) { 60 }, 1_700_000_000, 0, "Standard", 0, 90_000, 0, false, "", 1_699_999_000, ByteArray(0), 0, // funding tx: no inputs of ours @@ -2718,27 +4998,119 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun assetLockPersistRoundTrips() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 40 }, 1) + fun assetLockPersistRoundTrips() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 40 }, 1) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 41 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 100_000, + status = 1, // Broadcast + proofBytes = null, + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) + assertNotNull(row) + assertEquals(100_000L, row!!.amountDuffs) + assertEquals(1, row.statusRaw) + assertFalse(row.proofBytes != null) + } + + @Test + fun assetLockUpsertNeverRegressesAConsumedRow() = runTest { + // The upsert-side twin of the delete guard below, matching Swift's + // skip and SQLite's WHERE clause: Consumed is the terminal state, + // and a stale reconstruction/enrichment snapshot folded after the + // live consumption write must not regress it. + val outpoint = makeOutpoint(ByteArray(32) { 48 }, 0) handler.onChangesetBegin(walletId) handler.onPersistAssetLockUpsert( walletId = walletId, outPoint = outpoint, - transactionBytes = ByteArray(20) { 41 }, + transactionBytes = ByteArray(20) { 49 }, accountIndex = 0, fundingType = 0, identityIndex = 0, - amountDuffs = 100_000, - status = 1, // Broadcast + amountDuffs = 70_000, + status = 4, // Consumed — terminal + proofBytes = ByteArray(8) { 50 }, + ) + // The stale snapshot arrives after the consumption write. + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 49 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 70_000, + status = 1, // Broadcast — a stale pre-consumption view proofBytes = null, ) handler.onChangesetEnd(walletId, success = true) val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) assertNotNull(row) - assertEquals(100_000L, row!!.amountDuffs) - assertEquals(1, row.statusRaw) - assertFalse(row.proofBytes != null) + assertEquals( + "a stale non-Consumed snapshot must not regress the terminal", + 4, + row!!.statusRaw, + ) + } + + @Test + fun assetLockRemovalNeverDeletesAConsumedRow() = runTest { + // Parity with SQLite (`status != 'consumed'`) and Swift + // (`statusRaw == 4` skip): a Consumed row is deliberately retained + // for historical lookup, and neither removal producer — a + // rejected-at-broadcast Built row, or the sweep cascade for a swept + // funding tx — can legitimately name one, so a removal reaching a + // consumed row is by construction a stale write. Kotlin deleted + // unconditionally. + val liveOutpoint = makeOutpoint(ByteArray(32) { 43 }, 0) + val consumedOutpoint = makeOutpoint(ByteArray(32) { 44 }, 1) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = liveOutpoint, + transactionBytes = ByteArray(20) { 45 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 100_000, + status = 1, // Broadcast — a removal may take this one + proofBytes = null, + ) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = consumedOutpoint, + transactionBytes = ByteArray(20) { 46 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 1, + amountDuffs = 55_000, + status = 4, // Consumed — terminal, retained for history + proofBytes = ByteArray(8) { 47 }, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockRemoval(walletId, liveOutpoint) + handler.onPersistAssetLockRemoval(walletId, consumedOutpoint) + handler.onChangesetEnd(walletId, success = true) + + assertNull( + "a live row is removable", + db.assetLockDao().getByOutPointHex(encodeOutPointHex(liveOutpoint)), + ) + val consumed = db.assetLockDao().getByOutPointHex(encodeOutPointHex(consumedOutpoint)) + assertNotNull("a stale removal must never take the Consumed terminal", consumed) + assertEquals(4, consumed!!.statusRaw) } // ── Invitations (DIP-13) ────────────────────────────────────────── @@ -2979,20 +5351,7 @@ class PlatformWalletPersistenceHandlerTest { txid: ByteArray, xpubFill: Byte, ) { - handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) - handler.onPersistAccountRegistration( - wallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { xpubFill }, - ) - val account = db.accountDao().observeByWallet(wallet).first().single() - db.coreAddressDao().upsert( - CoreAddressEntity( - address = address, - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/44'/1'/0'/0/0", - accountId = account.id, - ), - ) + seedWalletWithAddress(wallet, address, xpubFill) handler.onChangesetBegin(wallet) handler.onWalletChangesetUtxoAdded( @@ -3019,7 +5378,8 @@ class PlatformWalletPersistenceHandlerTest { seedRestorableWallet(wallet, address, fundingTxid, xpubFill) handler.onChangesetBegin(wallet) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, wallet, lockTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), 0, 1, "AssetLock", 0, -999_545, 0, false, "", 1_700_000_100, makeOutpoint(fundingTxid, 0), 1, @@ -3170,7 +5530,7 @@ class PlatformWalletPersistenceHandlerTest { .allowMainThreadQueries() .openHelperFactory(faults) .build() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + handler = newHandler() val fundingTxid = ByteArray(32) { 71 } val lockTxid = ByteArray(32) { 72 } @@ -3442,6 +5802,271 @@ class PlatformWalletPersistenceHandlerTest { ) } + // ── Bounded tombstone lifetime ──────────────────────────────────── + + /** One committed round: synced height + (optionally) chainlock bytes. */ + private fun headerRound( + h: PlatformWalletPersistenceHandler, + synced: Int, + chainLockBytes: ByteArray = ByteArray(84) { 9 }, + ) { + h.onChangesetBegin(walletId) + h.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = synced, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = chainLockBytes, + ) + h.onChangesetEnd(walletId, success = true) + } + + /** + * One committed round delivering the numeric chainlock height, the way + * the JNI bridge does — its own slot, after the header's. + */ + private fun chainLockHeightRound(h: PlatformWalletPersistenceHandler, height: Int) { + h.onChangesetBegin(walletId) + h.onWalletChangesetChainLockHeight(walletId, height) + h.onChangesetEnd(walletId, success = true) + } + + /** + * Record a loser spending [outpoint] (funding unknown), then sweep it + * in the given winner context — a mined height (default 400) leaves + * the block-context tombstone the collection tests reason about, -1 + * (an IS-locked, unmined winner) leaves the same tombstone unstamped, + * which the collector never touches. + */ + private fun seedSweptTombstone( + outpoint: ByteArray, + loser: ByteArray, + winner: ByteArray, + winnerMinedHeight: Int = 400, + ) { + recordMempoolSpend(loser, outpoint) + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = winnerMinedHeight) + } + + @Test + fun aSweptTombstoneIsCollectedAtFinalityAndNotBefore() = runTest { + // The attacker-shaped row: a swept incoming payment's foreign input + // leaves a pending tombstone that never drains — no funding TXO + // ever arrives — and before the collector existed it was permanent, + // growable one row per input by repeatedly double-spending payments + // at this wallet. The collector deletes it exactly when the + // chainlock finality boundary min(chainlockHeight, syncedHeight) + // reaches the WINNER'S mined height — no observation-age margin: + // the stamp is the winner's own height, carried on the sweep event + // itself, so nothing here guesses when the winner mined. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 71 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 72 }, ByteArray(32) { 73 }, winnerMinedHeight = 400) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the sweep flagged the row", tombstone.isSweptTombstone) + assertEquals( + "the tombstone is stamped with the winner's own mined height, " + + "not any observation watermark", + 400, tombstone.winnerMinedHeight, + ) + + // Chainlocks race far ahead; the filter scan is one block short of + // the winner — the boundary has not reached the spend, so the + // funding output could still be delivered by the unscanned range. + chainLockHeightRound(handler, 10_000) + headerRound(handler, 399) + assertEquals( + "boundary min(10000, 399) = 399 is below the winner's height 400 — the hold stays", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 400) + assertTrue( + "the boundary reaching the winner's height collects the row — no margin", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aSweptTombstoneOutlivesAnySyncProgressWithoutAChainLockHeight() = runTest { + // Synced height alone is not finality — and neither is the mere + // PRESENCE of chainlock bytes on the wallet row: the bincode blob + // is opaque here, so "bytes exist" proves nothing about WHICH + // block is final (the unsound gate the review flagged). Every + // round below carries chainlock bytes; only the numeric height + // delivered by onWalletChangesetChainLockHeight supplies a + // boundary, and the moment one lands the finalized stamp collects. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 74 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 75 }, ByteArray(32) { 76 }, winnerMinedHeight = 400) + + headerRound(handler, 100_000) + assertEquals( + "chainlock bytes are on record but no numeric height is — the " + + "hold outlasts any amount of synced-height progress", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + chainLockHeightRound(handler, 100_000) + assertTrue( + "the first numeric chainlock height supplies the boundary and " + + "the finalized stamp collects", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aDrainedClaimIsImmuneToTheCollector() = runTest { + // The genuine claim the tombstone exists for: its funding TXO + // arrives, the drain moves the hold onto the TXO row + // (supersededByTxid) and deletes the pending rows — so no amount of + // later sync progress may touch the materialised hold. + seedWalletWithAddress(walletId, "yFundAddr") + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 77 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 79 } + seedSweptTombstone(p, ByteArray(32) { 78 }, winner, winnerMinedHeight = 400) + assertEquals( + "sanity: held, undrained, stamped with the winner's height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + headerRound(handler, 10_000) + chainLockHeightRound(handler, 10_000) + + val coin = db.txoDao().getByOutpoint(p) + assertNotNull("the materialised claim's row survives collection", coin) + assertTrue("still held spent by the winner's claim", coin!!.isSpent) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + } + + @Test + fun aTombstoneWithoutAWinnerHeightIsNeverCollected() = runTest { + // A tombstone with a NULL stamp 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 (the v10 → v11 migration leaves pre-existing + // tombstones NULL) read identically. With no proof of finality + // the safe reading is to hold it forever rather than guess it + // collectible. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 80 } + val p = makeOutpoint(fundingTxid, 0) + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + seedSweptTombstone(p, ByteArray(32) { 81 }, ByteArray(32) { 82 }, winnerMinedHeight = -1) + + // 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. + chainLockHeightRound(handler, 1_000_000) + headerRound(handler, 1_000_000) + headerRound(handler, 1_000_010) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() + assertNull( + "no winner height, no proof of finality — the hold outlasts any boundary", + row.winnerMinedHeight, + ) + assertTrue(row.isSweptTombstone) + } + + @Test + fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { + // 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. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 86 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 87 } + val secondLoser = ByteArray(32) { 88 } + val finalWinner = ByteArray(32) { 89 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) + assertEquals( + "sanity: stamped with the first winner's mined height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + // The first winner's own record, then its sweep — mined 50 blocks + // later — the carry-forward path that re-points the earlier + // tombstone. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_091, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoser), finalWinner, emptyList(), 450) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + assertTrue("sanity: the claim survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertTrue(row.isSweptTombstone) + assertTrue(finalWinner.contentEquals(row.spendingTxid)) + assertEquals( + "re-pointed ⇒ re-stamped to the later WINNER'S mined height", + 450, row.winnerMinedHeight, + ) + } + } + + @Test + fun aBlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() = runTest { + // The reviewer's unrelated-advancement scenario: the chainlock can + // run arbitrarily far ahead, but while the 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 + // scan catches up. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 111 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 112 }, ByteArray(32) { 113 }, winnerMinedHeight = 400) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + chainLockHeightRound(handler, 10_400) + headerRound(handler, 399) + assertEquals( + "min(chainlock, synced) = 399 is below the winner's height 400 — any " + + "amount of unrelated chainlock progress must not collect the hold", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 400) + assertTrue( + "the scan reaching the winner's height completes the boundary and collects", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + @Test fun shouldNotClobberMarketplaceColumnsWhenAnIdentitySnapshotStillCarriesTheLabel() = runTest { handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) @@ -3579,6 +6204,296 @@ class PlatformWalletPersistenceHandlerTest { // Promote-only and idempotent: a second pass matches nothing. assertEquals(0, db.identityDao().healIsLocalFlags()) } + + @Test + fun aMempoolContextSweepPreservesAnUnstampedTombstone() = runTest { + // 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 deletes the loser and retains them in the + // account's `spent_outpoints`, a hold with no height that no + // record survives to rebuild (the winner need not be + // wallet-relevant). The tombstone 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 + // materializes — and it is unstamped because an IS-locked winner + // has no mining deadline, so no boundary may ever collect it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + for (i in 0 until 3) { + val p = makeOutpoint(ByteArray(32) { (114 + i).toByte() }, 0) + seedSweptTombstone( + p, + ByteArray(32) { (117 + i).toByte() }, + ByteArray(32) { (120 + i).toByte() }, + winnerMinedHeight = -1, + ) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue( + "an unmined IS-locked winner must leave a held tombstone for input #$i", + row.isSweptTombstone, + ) + assertNull("and it carries no finality stamp", row.winnerMinedHeight) + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + chainLockHeightRound(handler, 1_000_000) + headerRound(handler, 1_000_000) + headerRound(handler, 1_000_010) + assertEquals( + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release resolves one", + 3L, db.documentDao().countPendingInputs().first(), + ) + } + + @Test + fun aMempoolContextSweepStillSpendMarksAMaterialisedCoin() = runTest { + // 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 eventual + // block delivery is the durable evidence. Only the never-funded + // tombstone is what the mempool path refuses to create. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 123 } + val p = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 124 } + val winner = ByteArray(32) { 125 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 50_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + seedSweptTombstone(p, loser, winner, winnerMinedHeight = -1) + + val coin = db.txoDao().getByOutpoint(p)!! + assertTrue( + "a materialised coin is spend-marked by the IS-locked winner", + coin.isSpent, + ) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + assertTrue( + "and no pending tombstone rides alongside the real row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() = runTest { + // 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 unstamped + // tombstone is the claim's only durable carrier, so the funding + // delivery must drain INTO it and land spent: crediting the coin + // would hand coin selection an outpoint the network has provably + // consumed. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 126 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 0x7F } + seedSweptTombstone(p, ByteArray(32) { 127 }, winner, winnerMinedHeight = -1) + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the mempool-context sweep left a tombstone", tombstone.isSweptTombstone) + assertNull("unstamped — no finality horizon exists", tombstone.winnerMinedHeight) + + // Restart: a fresh handler bound to the same underlying store — + // this suite's restart idiom (see + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded). + val restarted = newHandler() + + // Arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold. + headerRound(restarted, 25_000) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetChainLockHeight(walletId, 25_000) + restarted.onChangesetEnd(walletId, success = true) + assertEquals( + "the unstamped hold survives the restart and every boundary", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + // The funding output is finally delivered and classified: it must + // drain into the tombstone and stay spent. + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(p) + assertNotNull(coin) + assertTrue( + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart", + coin!!.isSpent, + ) + assertTrue( + "held by the winner the sweep named", + winner.contentEquals(coin.supersededByTxid), + ) + assertTrue( + "the claim drained into the TXO row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + assertTrue( + "a spent coin never reaches the restored UTXO set", + restarted.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aMempoolRepointedTombstoneKeepsItsBlockContextStamp() = runTest { + // 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. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 106 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 107 } + val secondLoser = ByteArray(32) { 108 } + val finalWinner = ByteArray(32) { 109 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) + + // The first winner is evicted by an IS-locked, unmined conflict + // that also claims the unfunded input. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_092, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoser), finalWinner, emptyList(), -1) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + .filter { it.isSweptTombstone } + assertTrue("sanity: the tombstone survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertTrue( + "an unmined winner re-points the claim", + finalWinner.contentEquals(row.spendingTxid), + ) + assertEquals( + "without touching the earlier block-context stamp", + 400, row.winnerMinedHeight, + ) + } + + chainLockHeightRound(handler, 10_000) + headerRound(handler, 400) + assertTrue( + "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", + db.documentDao().getPendingInputsByOutpoint(p) + .none { it.isSweptTombstone }, + ) + } + + @Test + fun anUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() = runTest { + // 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. One + // of the three resolution channels that bound the unstamped + // population. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 115 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 116 } + val secondLoser = ByteArray(32) { 118 } + val finalWinner = ByteArray(32) { 119 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = -1) + assertNull( + "sanity: held and unstamped", + db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_093, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoser), finalWinner, emptyList(), 450) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + .filter { it.isSweptTombstone } + assertTrue("sanity: the claim survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertEquals( + "the block-context re-point stamps the previously unstamped hold", + 450, row.winnerMinedHeight, + ) + } + + chainLockHeightRound(handler, 10_000) + headerRound(handler, 450) + assertTrue( + "once stamped, the ordinary finality boundary collects the row", + db.documentDao().getPendingInputsByOutpoint(p).none { it.isSweptTombstone }, + ) + } + + @Test + fun onWalletChangesetChainLockHeightStoresMonotonicMaxOnTheWalletRow() = runTest { + // The numeric chainlock height is the finality half of the + // collection boundary, so a stale round's chainlock must never + // lower it — monotonic max, matching the SQLite store's + // `upsert_sync_state`. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + assertNull( + "no height on record until the slot fires", + db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight, + ) + + chainLockHeightRound(handler, 500) + assertEquals(500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + + chainLockHeightRound(handler, 400) + assertEquals( + "a stale round must not lower the stored height", + 500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight, + ) + + chainLockHeightRound(handler, 600) + assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + } } /** diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..2d136151719 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -48,7 +48,7 @@ #![allow(clippy::missing_safety_doc)] use crate::support::{guard, net_from_ord, JVM}; -use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JString, JValue}; +use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JObjectArray, JString, JValue}; use jni::sys::jstring; use jni::JNIEnv; use platform_wallet_ffi::{ @@ -56,11 +56,11 @@ use platform_wallet_ffi::{ AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - InvitationEntryFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, + InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, - SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, + TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, + WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -195,13 +195,82 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { /// Assemble the additive, size/version-tagged persistence callbacks. It shares /// the legacy vtable's context and release hook; this value is copied by the /// native manager during creation and owns nothing itself. -pub(crate) fn build_extension() -> PersistenceCallbacksExtension { +/// +/// The sweep slot is wired only when the concrete `bridge` OVERRIDES +/// `onWalletChangesetTransactionsSwept` (see [`bridge_overrides`]). Rust +/// derives the effective `CORE_SWEEP_REMOVAL` capability from "slot present +/// AND bit declared", so a subclass that declares the bit without +/// overriding the method — a promise of removals its inherited no-op body +/// would silently swallow — never gets the slot, Rust strips the bit and the +/// sync watermark with it, and the round is refused one layer up instead of +/// advancing past a removal that never happened. Wiring the slot for every +/// subclass would make "slot present" prove nothing. +pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> PersistenceCallbacksExtension { + let sweeps_overridden = bridge_overrides(env, bridge, "onWalletChangesetTransactionsSwept"); PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: if sweeps_overridden { + Some(tramp_persist_wallet_changeset_sweeps) + } else { + None + }, + on_persist_wallet_changeset_chain_lock_height_fn: Some( + tramp_persist_wallet_changeset_chain_lock_height, + ), ..Default::default() } } +/// Whether `bridge`'s concrete class — or any superclass strictly below +/// `NativePersistenceBridge` — declares a method named `name`. A Kotlin +/// `override fun` is a declared method of the overriding class, so walking +/// `getDeclaredMethods()` up the hierarchy until the abstract bridge answers +/// "did a subclass supply its own body". Any JNI failure counts as "not +/// overridden" (the pending exception is cleared): the consequence is a +/// slot left unwired, which Rust turns into a stripped capability — the +/// safe direction, never a silently swallowed removal. +fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { + fn probe(env: &mut JNIEnv, bridge: &JObject, name: &str) -> Result { + let base = env.find_class("org/dashfoundation/dashsdk/ffi/NativePersistenceBridge")?; + let mut class = env.get_object_class(bridge)?; + loop { + if env.is_same_object(&class, &base)? { + return Ok(false); + } + let methods: JObjectArray = env + .call_method(&class, "getDeclaredMethods", "()[Ljava/lang/reflect/Method;", &[])? + .l()? + .into(); + let count = env.get_array_length(&methods)?; + for i in 0..count { + let method = env.get_object_array_element(&methods, i)?; + let method_name: JString = env + .call_method(&method, "getName", "()Ljava/lang/String;", &[])? + .l()? + .into(); + let matches = env.get_string(&method_name)?.to_str().map(|s| s == name).unwrap_or(false); + if matches { + return Ok(true); + } + } + let superclass = env + .call_method(&class, "getSuperclass", "()Ljava/lang/Class;", &[])? + .l()?; + if superclass.is_null() { + return Ok(false); + } + class = superclass.into(); + } + } + match probe(env, bridge, name) { + Ok(overridden) => overridden, + Err(_) => { + let _ = env.exception_clear(); + false + } + } +} + /// `release_fn` for the persistence vtable: frees the boxed /// [`KotlinPersistenceCtx`] when the native manager's last persister /// reference drops. The FFI guarantees exactly one call, which may land @@ -616,7 +685,7 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( &[ (&wid).into(), JValue::Bool(has_synced as u8), - JValue::Int(synced_height as i32), + JValue::Int(if has_synced { jint_height(synced_height)? } else { 0 }), JValue::Bool(cs.has_balance as u8), JValue::Long(cs.balance.confirmed_delta), JValue::Long(cs.balance.unconfirmed_delta), @@ -637,10 +706,137 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( return Ok(code); } } + Ok(0) }) } +/// Extension-callback trampoline for the round's sweep batches. These used +/// to ride at the tail of [`WalletChangeSetFFI`]; they now arrive through +/// `PersistenceCallbacksExtension`'s size-negotiated sweep slot (the bare +/// changeset pointer cannot prove to a consumer that its producer allocated +/// a tail field — see the layout note on that struct). Round order, as +/// `store()` in `rs-platform-wallet-ffi` fires it: the changeset callback +/// (`tramp_persist_wallet_changeset`: header, then every account slice), +/// then the chainlock-height slot, then this one — so the Kotlin bridge sees +/// records, then the finality boundary, then removals. +/// +/// One bridge call per batch, in order: a later sweep can keep a coin spent +/// that an earlier one freed, and only replaying them in sequence preserves +/// that. The Kotlin handler buffers the calls and applies them in the same +/// order at the round's end, so the ordering holds there too. The batch +/// count is not bounded by this ABI, so — as with the account loop in the +/// changeset trampoline — each batch's marshalling and call runs inside its +/// own local frame; without it the per-batch arrays would pile up in the +/// trampoline's own frame across every batch, and a large enough round can +/// exhaust ART's local-reference table before the callback ever returns. +unsafe extern "C" fn tramp_persist_wallet_changeset_sweeps( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + for batch in slice_or_empty(sweeps, sweeps_count) { + let code = env.with_local_frame(8, |env| { + persist_changeset_sweep_batch(env, bridge, &wid, batch) + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) + }) +} + +/// Descriptor of `NativePersistenceBridge.onWalletChangesetTransactionsSwept`: +/// `(walletId, txids, txidCount, supersededBy, releasedOutpoints, +/// releasedOutpointCount, hasWinnerMinedHeight, winnerMinedHeight)`. Txids +/// and released outpoints are shipped as ONE flat `byte[]` each (32·N and +/// 36·N bytes) plus a count — the same packing `persist_changeset_transaction` +/// uses for `inputOutpoints`, sliced with `copyOfRange` on the Kotlin side +/// — rather than a `byte[][]` with one JVM allocation per element; the +/// loser count is network-influenced and this projection runs synchronously +/// inside the atomic persistence callback. The single winner rides as one +/// 32-byte array, and the winner's mined height as a `(Z, I)` pair like the +/// header's `(hasSyncedHeight, syncedHeight)`, not a sentinel. +const WALLET_CHANGESET_SWEEPS_DESCRIPTOR: &str = "([B[BI[B[BIZI)I"; + +unsafe fn persist_changeset_sweep_batch( + env: &mut JNIEnv, + bridge: &JObject, + wid: &JByteArray, + batch: &SweepBatchFFI, +) -> Result { + let txids = slice_or_empty(batch.txids, batch.txids_count); + let mut packed_txids = Vec::with_capacity(txids.len() * 32); + for txid in txids { + packed_txids.extend_from_slice(txid); + } + let txids_arr = env.byte_array_from_slice(&packed_txids)?; + let winner = env.byte_array_from_slice(&batch.superseded_by)?; + // Released outpoints ride as 36-byte keys (raw txid + a little-endian + // vout, `pack_outpoint_key`), the shape the handler stores them in. + let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); + let mut packed_released = Vec::with_capacity(released.len() * 36); + for outpoint in released { + packed_released.extend_from_slice(&pack_outpoint_key(outpoint)); + } + let released_arr = env.byte_array_from_slice(&packed_released)?; + // The winner's finality context: its mined height for a block-context + // sweep, absent for an InstantSend-locked winner still waiting to be + // mined. The handler keys a pending-input tombstone's LIFETIME on it, + // never its existence: every non-released input keeps a durable claim + // in either context, stamped and collectible at the chainlock finality + // boundary when the winner mined, unstamped and held until resolved by + // proof (funding arrival, a later block-context re-stamp, or a release) + // when it did not. + env.call_method( + bridge, + "onWalletChangesetTransactionsSwept", + WALLET_CHANGESET_SWEEPS_DESCRIPTOR, + &[ + wid.into(), + (&txids_arr).into(), + JValue::Int(txids.len() as i32), + (&winner).into(), + (&released_arr).into(), + JValue::Int(released.len() as i32), + JValue::Bool(batch.has_winner_mined_height as u8), + JValue::Int(if batch.has_winner_mined_height { + jint_height(batch.winner_mined_height)? + } else { + 0 + }), + ], + )? + .i() +} + +/// Deliver the round's numeric chainlock height (see +/// `PersistWalletChangesetChainLockHeightFn`), between the changeset callback +/// and the sweep batches. One scalar, one call — the bincode chainlock blob +/// on the header call is opaque to Kotlin, and this is the half of the +/// tombstone-collection boundary `min(chainlockHeight, syncedHeight)` the +/// handler cannot otherwise know. +unsafe extern "C" fn tramp_persist_wallet_changeset_chain_lock_height( + context: *mut c_void, + wallet_id: *const u8, + chain_lock_height: u32, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + env.call_method( + bridge, + "onWalletChangesetChainLockHeight", + "([BI)I", + &[(&wid).into(), JValue::Int(jint_height(chain_lock_height)?)], + )? + .i() + }) +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -674,6 +870,20 @@ unsafe fn persist_changeset_account( return Ok(code); } + // Transactions before their UTXOs — matches the Swift bridge's + // `applyAccountChangeset` order (transactions, then utxos_added, then + // utxos_spent). Parity, not a guard: the handler tolerates either order + // (`onWalletChangesetUtxoAdded` writes a stub parent row when no record + // exists yet, and the record's later upsert overwrites it), so nothing + // on the Kotlin side depends on this sequence. + for t in slice_or_empty(acc.transactions, acc.transactions_count) { + let code = env.with_local_frame(40, |env| { + persist_changeset_transaction(env, bridge, wid, acc, t) + })?; + if code != 0 { + return Ok(code); + } + } for u in slice_or_empty(acc.utxos_added, acc.utxos_added_count) { let code = env.with_local_frame(24, |env| persist_changeset_utxo_added(env, bridge, wid, u))?; @@ -688,14 +898,6 @@ unsafe fn persist_changeset_account( return Ok(code); } } - for t in slice_or_empty(acc.transactions, acc.transactions_count) { - let code = env.with_local_frame(40, |env| { - persist_changeset_transaction(env, bridge, wid, acc, t) - })?; - if code != 0 { - return Ok(code); - } - } env.call_method( bridge, @@ -774,15 +976,14 @@ unsafe fn persist_changeset_transaction( let tx_type = cstr(env, t.transaction_type)?; let label = cstr(env, t.label)?; // Input outpoints (one per tx input, in vin order; empty for coinbase). - // Flatten to txid[32] || vout(u32 LE) = 36 bytes each — byte-identical to - // Kotlin/Swift makeOutpoint, so the pending-input join key matches with no - // per-element conversion on the Kotlin side. Dropping these is what left a - // spend-before-funding output restorable as spendable (CORE-06). + // Flattened 36-byte keys (see `pack_outpoint_key`), so the pending-input + // join key matches with no per-element conversion on the Kotlin side. + // Dropping these is what left a spend-before-funding output restorable + // as spendable (CORE-06). let ops = slice_or_empty(t.input_outpoints, t.input_outpoints_count); let mut packed = Vec::with_capacity(ops.len() * 36); for op in ops { - packed.extend_from_slice(&op.txid); - packed.extend_from_slice(&op.vout.to_le_bytes()); + packed.extend_from_slice(&pack_outpoint_key(op)); } let input_outpoints = env.byte_array_from_slice(&packed)?; let input_outpoint_count = ops.len() as i32; @@ -3951,6 +4152,29 @@ unsafe fn slice_or_empty<'a, T>(ptr: *const T, count: usize) -> &'a [T] { } } +/// Pack an [`OutPointFFI`] into the 36-byte key (raw txid ‖ little-endian +/// vout) the Kotlin handler stores outpoints under — byte-identical to +/// Kotlin's `makeOutpoint` (and Swift's). This is the join key sweep +/// releases use to find additive-path rows, so every packing site routes +/// through here rather than re-inlining the layout. +/// A block height for a JNI `I` slot. Heights are `u32` on the Rust side +/// and `Int` on the Kotlin side; a value past `i32::MAX` would wrap +/// negative and be read as "absent" (or as a bogus boundary) by a handler +/// that has no way to tell. Unreachable for any real chain height, so it +/// is refused rather than reinterpreted: the round fails closed +/// (`with_bridge` maps the error to `ERR_JNI`). +fn jint_height(height: u32) -> Result { + i32::try_from(height) + .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::InvalidArguments)) +} + +fn pack_outpoint_key(outpoint: &OutPointFFI) -> [u8; 36] { + let mut key = [0u8; 36]; + key[..32].copy_from_slice(&outpoint.txid); + key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + key +} + /// `Vec` → `(*const T, len)`; empty vec yields `(null, 0)`. A non-null /// pointer is a leaked `Box<[T]>` the matching load-free trampoline /// reconstructs and drops — mint it only once the whole load succeeded. @@ -4270,6 +4494,21 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onWalletChangesetTransaction", WALLET_CHANGESET_TRANSACTION_DESCRIPTOR, ), + // Missing from this table let a sweep-round-only descriptor drift pass + // the smoke check and surface only when a live sweep first called it — + // right where a failed round freezes the wallet's watermark. The same + // constant is bound at the `call_method` site in + // `persist_changeset_sweep_batch`, so the two cannot drift. + ( + "onWalletChangesetTransactionsSwept", + WALLET_CHANGESET_SWEEPS_DESCRIPTOR, + ), + // Same drift risk as the sweeps descriptor above: this slot fires on + // chainlock-advancing rounds only, so a stale descriptor would surface + // exactly when the first real chainlock crossed. Must track the + // literal at the `call_method` site in + // `tramp_persist_wallet_changeset_chain_lock_height`. + ("onWalletChangesetChainLockHeight", "([BI)I"), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -4441,6 +4680,25 @@ mod tests { ); } + /// A height past `i32::MAX` must refuse the call, never wrap into a + /// negative `Int` the handler would read as absent or as a bogus + /// collection boundary. + #[test] + fn a_height_past_i32_max_is_refused_rather_than_wrapped() { + assert_eq!(jint_height(0).unwrap(), 0); + assert_eq!(jint_height(i32::MAX as u32).unwrap(), i32::MAX); + assert!(jint_height(i32::MAX as u32 + 1).is_err()); + assert!(jint_height(u32::MAX).is_err()); + } + + #[test] + fn sweeps_callback_descriptor_ships_flat_arrays_and_an_explicit_height_pair() { + // walletId, packed txids + count, winner, packed released outpoints + // + count, (hasWinnerMinedHeight, winnerMinedHeight) — must match + // `NativePersistenceBridge.onWalletChangesetTransactionsSwept`. + assert_eq!(WALLET_CHANGESET_SWEEPS_DESCRIPTOR, "([B[BI[B[BIZI)I"); + } + #[test] fn vtable_layout_remains_independent_of_capability_declaration() { let callbacks = build_vtable(ptr::null_mut()); diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1342df0ed37..c1ac4b1d871 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -155,7 +155,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n let persistence_ctx = Box::into_raw(Box::new(KotlinPersistenceCtx::new(persistence_global))); let persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); - let persistence_extension = build_extension(); + let persistence_extension = build_extension(env, &persistence_bridge); let persistence_capabilities = PersistenceCapabilitiesFFI { version: declared_capabilities_version, reserved: 0, From 7ea9be2a99b1c9c07d3ccb1e8f771a4f019f2f94 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:41:53 +0300 Subject: [PATCH 9/9] style(sdk-jni): rustfmt the checked height conversion call sites `cargo fmt --check` is a CI gate; the checked `u32 -> Int` conversion added for the sweep and header slots was hand-written. --- .../rs-unified-sdk-jni/src/persistence.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 2d136151719..732534d25f0 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -238,7 +238,12 @@ fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { return Ok(false); } let methods: JObjectArray = env - .call_method(&class, "getDeclaredMethods", "()[Ljava/lang/reflect/Method;", &[])? + .call_method( + &class, + "getDeclaredMethods", + "()[Ljava/lang/reflect/Method;", + &[], + )? .l()? .into(); let count = env.get_array_length(&methods)?; @@ -248,7 +253,11 @@ fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { .call_method(&method, "getName", "()Ljava/lang/String;", &[])? .l()? .into(); - let matches = env.get_string(&method_name)?.to_str().map(|s| s == name).unwrap_or(false); + let matches = env + .get_string(&method_name)? + .to_str() + .map(|s| s == name) + .unwrap_or(false); if matches { return Ok(true); } @@ -685,7 +694,11 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( &[ (&wid).into(), JValue::Bool(has_synced as u8), - JValue::Int(if has_synced { jint_height(synced_height)? } else { 0 }), + JValue::Int(if has_synced { + jint_height(synced_height)? + } else { + 0 + }), JValue::Bool(cs.has_balance as u8), JValue::Long(cs.balance.confirmed_delta), JValue::Long(cs.balance.unconfirmed_delta),