From 171e4cbef008733850d98f19ba24ad45a938d6c3 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:46:04 +0300 Subject: [PATCH 001/102] chore: bump rust-dashcore to the latest dev revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings in dashpay/rust-dashcore#961, which stops a never-broadcast transaction from crediting money that does not exist, plus the seven commits ahead of the previous pin. #961 adds `WalletEvent::TransactionsSwept`, the first subtractive event on the wallet bus: it names transactions the wallet removed because a later, final transaction provably beat them to their inputs. Three consumers matched exhaustively on `WalletEvent` and now handle it. - The balance handler routes it like any other balance-bearing variant. A sweep is the one event that can lower the balance, and its snapshot is post-removal like every other; dropping it would leave the corrected-away amount on screen until some later event happened to arrive. - The DashPay payment hooks ignore it: it carries txids, not records. A sent payment whose transaction was swept stays `Pending` — the hooks only advance a payment forward, and inventing a failure transition is a change to the payment state machine, not to event routing. - The core bridge projects it into a new `CoreChangeSet.swept_txids`, the only subtractive field on that type, and `is_empty_no_records` counts it — that filter decides whether the persister is called at all, so a sweep-only round has to survive it on the strength of the txids alone. Nothing consumes `swept_txids` yet; the persistence seam follows. --- Cargo.lock | 24 ++--- Cargo.toml | 16 +-- .../src/changeset/changeset.rs | 40 +++++++- .../src/changeset/core_bridge.rs | 99 +++++++++++++++++++ .../src/wallet/core/balance_handler.rs | 9 ++ .../identity/network/payment_handler.rs | 14 ++- 6 files changed, 177 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6775bdd0c17..58ddd918ac8 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "bincode", "dashcore-private", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" [[package]] name = "glob" @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 5238bf2a982..f40542d6104 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index fa425fbde56..222df920521 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -62,10 +62,10 @@ use crate::wallet::identity::{ /// `WalletEvent` bus delivers. /// /// Built by the platform-wallet event adapter from `WalletEvent` variants -/// emitted by `WalletManager`. Every field is purely additive — the -/// merge implementation uses last-write-wins for the height watermarks -/// (monotonic-max), `extend` for the records / utxos vecs, and -/// last-write-wins for the IS-lock map. +/// emitted by `WalletManager`. Every field is additive except +/// [`Self::swept_txids`] — the merge implementation uses last-write-wins for +/// the height watermarks (monotonic-max), `extend` for the records / utxos +/// vecs, and last-write-wins for the IS-lock map. /// /// # Why a projection instead of the upstream type /// @@ -197,6 +197,23 @@ pub struct CoreChangeSet { /// lower height never overwrites a higher one — chain locks are /// strictly forward-advancing per upstream's contract). pub last_applied_chain_lock: Option, + + /// Transactions the wallet **removed**: each was a recorded spend that a + /// later, final transaction provably beat to one of its inputs, so it can + /// never confirm. From `WalletEvent::TransactionsSwept`. + /// + /// The one subtractive field on this type. Every other field is additive, + /// which is exactly why this one has to exist: a persister that only ever + /// appends keeps the dead rows and replays them on the next load, + /// re-creating a balance the wallet has already corrected. The persister + /// deletes the transaction rows named here along with any UTXO they + /// created, and drops its spend attribution to them — the transaction that + /// actually took those inputs re-asserts its own claim through `records` / + /// `spent_utxos`. + /// + /// Deduplicated on merge: a sweep is idempotent, and a flush can fold + /// several sweeps together. + pub swept_txids: Vec, } /// Highest-used derivation index per pool slot for one account, as @@ -332,10 +349,25 @@ impl Merge for CoreChangeSet { .or_default() .merge_max(indexes); } + + // Sweeps: append, first-seen order, deduplicated. Deleting the + // same txid twice is harmless at the persister, so the dedup is + // only there to keep a coalesced round's payload honest about how + // many distinct transactions died. + if !other.swept_txids.is_empty() { + let mut seen: std::collections::HashSet = + self.swept_txids.iter().copied().collect(); + for txid in other.swept_txids { + if seen.insert(txid) { + self.swept_txids.push(txid); + } + } + } } fn is_empty(&self) -> bool { self.records.is_empty() + && self.swept_txids.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index df1b4701cf9..286ece325e6 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -712,6 +712,32 @@ async fn build_core_changeset( cs.account_highest_used = account_highest_used; cs } + WalletEvent::TransactionsSwept { + txids, + superseded_by, + .. + } => { + // 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: the winner's own record + // flows through `TransactionDetected` / `BlockProcessed` and + // claims them. This arm only names the dead. + tracing::debug!( + swept = txids.len(), + superseded_by = %superseded_by, + "Mirroring swept transactions to the persister" + ); + CoreChangeSet { + swept_txids: txids.clone(), + ..CoreChangeSet::default() + } + } WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { synced_height: Some(*height), ..CoreChangeSet::default() @@ -1116,6 +1142,7 @@ impl CoreChangeSet { /// circuits on the common case. fn is_empty_no_records(&self) -> bool { self.records.is_empty() + && self.swept_txids.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -1128,6 +1155,78 @@ 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 swept(txids: Vec) -> WalletEvent { + WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids, + superseded_by: txid(0xff), + 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.swept_txids, vec![txid(1), txid(2)]); + // The winner's own claim on the inputs arrives 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"); + } + + #[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)); + } + + #[tokio::test] + async fn merged_sweeps_name_each_transaction_once() { + 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(2), txid(3)])).await; + + cs.merge(second); + + assert_eq!(cs.swept_txids, vec![txid(1), txid(2), txid(3)]); + } +} + #[cfg(test)] mod contact_watch_only_projection_tests { //! Regression coverage for the persist-time projection of records 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 27797ec92e0..2a498a180a9 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -50,6 +50,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/identity/network/payment_handler.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs index 62d174bb650..1670bd56556 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,14 @@ 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. A sent DashPay payment + // whose transaction was swept stays `Pending` here — the hooks + // below only ever advance a payment forward, and inventing a + // failure transition off this event is a change to the payment + // state machine, not to event routing. WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => Vec::new(), } @@ -274,7 +281,12 @@ 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. + WalletEvent::TransactionsSwept { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => false, } } From d32504973ba7810cbd8eecaf23dbb632afeb900d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:46:38 +0300 Subject: [PATCH 002/102] fix(swift-sdk): delete the mirror of a transaction the wallet swept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence seam had no way to say "this row is gone". Every field on the changeset was additive, so a swept transaction — a recorded spend that a later, final transaction beat to one of its inputs, and that can therefore never confirm — stayed on disk after Rust dropped it, came back at the next load, and re-created the balance the wallet had just corrected. That is the bug rust-dashcore#961 fixes, reappearing one layer up on every consumer that mirrors state. `WalletChangeSetFFI` gains `swept_txids`, wallet-scoped rather than per-account: the upstream event is wallet-scoped and the persister deletes by txid, so the row it deletes carries its own account link. Both persisters apply it the same way, after the additive part of the round — the transaction that beat the swept one to its inputs usually rides along in the same changeset, so by the time the removal runs its claim is already recorded: - the transaction row goes, and the outputs it created go with it (a cascade on both sides — SwiftData `PersistentTransaction.outputs`, the Room `txos.txid` foreign key); - the coins it claimed to *spend* are released first. The relationship only nils the link and would leave `isSpent` set, i.e. a coin marked spent by a transaction that no longer exists — invisible to the wallet and to the restore set, the same lost-funds shape as the phantom balance, inverted. On Android the release has to run before the delete: once the FK nulls `spendingTxid` there is nothing left to find those rows by. Transaction rows are keyed by txid alone and shared across wallets by design, and a sweep is a statement about the transaction rather than about one wallet's view of it, so neither persister narrows the delete to the emitting wallet. --- .../dashsdk/ffi/NativePersistenceBridge.kt | 17 ++ .../PlatformWalletPersistenceHandler.kt | 34 ++++ .../dashsdk/persistence/dao/TxoDao.kt | 15 ++ .../PlatformWalletPersistenceHandlerTest.kt | 97 ++++++++++ .../src/core_wallet_types.rs | 42 +++++ .../rs-unified-sdk-jni/src/persistence.rs | 28 +++ .../PlatformWalletPersistenceHandler.swift | 48 +++++ .../SweptTransactionPersistTests.swift | 166 ++++++++++++++++++ 8 files changed, 447 insertions(+) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift 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..3073d4fc40e 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 @@ -294,6 +294,23 @@ abstract class NativePersistenceBridge { /** Close the current account bucket. Descriptor `([BI)I`. */ open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * Transactions the wallet removed this round, as raw 32-byte txids. + * Fired once after the per-account decomposition, and only when the + * round swept something. Descriptor `([B[[B)I`. + * + * Each named transaction was a recorded spend that a later, final + * transaction 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. + */ + open fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: Array, + ): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** 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 993fd2f8a7d..9560e738d33 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 @@ -985,6 +985,40 @@ class PlatformWalletPersistenceHandler( override fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * Delete the mirror of transactions the wallet swept. + * + * Each was a recorded spend that a later, final transaction beat to one + * of its inputs, so it can never confirm and Rust has already dropped + * it. Keeping the rows would hand them back at the next load and + * re-create a balance the wallet has already corrected. + * + * The TXOs the transaction created go with it (`txos.txid` cascades). + * The ones it *spent* need the explicit release below first: the + * foreign key nulls `spendingTxid` on delete but leaves `isSpent` set, + * which would strand the coin as spent by a transaction that no longer + * exists — and once the link is nulled there is nothing left to find + * those rows by. + * + * Transaction rows are keyed by txid alone, shared across wallets by + * design, and a sweep is a statement about the transaction rather than + * about one wallet's view of it — so the row goes without narrowing to + * the emitting wallet. + */ + override fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: Array, + ): Int = guarded { + stage(walletId) { db -> + if (db.walletDao().getByWalletId(walletId) == null) return@stage + for (txid in txids) { + db.txoDao().releaseSpendClaim(txid) + db.transactionDao().deleteByTxid(txid) + } + } + 0 + } + // ── Identities ──────────────────────────────────────────────────── override fun onPersistIdentityUpsert( 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 e78606e9ec5..43cab2da3f6 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 @@ -43,6 +43,21 @@ interface TxoDao { @Query("SELECT * FROM txos WHERE spendingTxid = :spendingTxid AND isSpent = 0") suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List + /** + * Release the spend claim a swept transaction held on the TXOs it + * named as inputs. + * + * The `spendingTxid` foreign key already nulls itself when the + * spending row is deleted, but `isSpent` is a plain column and would + * survive — leaving a coin marked spent by a transaction that no + * longer exists. Run this *before* deleting the transaction, while the + * link that identifies those rows is still there; the transaction that + * actually took the inputs re-asserts its own claim through the + * additive part of the changeset. + */ + @Query("UPDATE txos SET isSpent = 0, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") + suspend fun releaseSpendClaim(spendingTxid: ByteArray) + @Upsert suspend fun upsert(txo: TxoEntity) 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 0ab618d6db4..2f7b35ffb24 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 @@ -2033,6 +2033,103 @@ class PlatformWalletPersistenceHandlerTest { assertFalse(restored.isInstantLocked) } + @Test + fun sweptTransactionIsDeletedAndReleasesItsSpendClaim() = runTest { + // A recorded spend that a later, final transaction beat to its + // 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. + 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, + ), + ) + + val fundingTxid = ByteArray(32) { 41 } + val sweptTxid = ByteArray(32) { 42 } + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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) + + // The doomed transaction: spends the funding TXO and creates change + // of its own, in-block so `isSpent` flips. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sweptTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoAdded( + walletId, sweptTxid, 0, 60_000, "yUtxoAddr", ByteArray(25) { 6 }, + 101, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept(walletId, arrayOf(sweptTxid)) + handler.onChangesetEnd(walletId, success = true) + + 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)) + + // The coin it claimed is spendable again: a coin left marked spent + // by a transaction that no longer exists is invisible to the + // wallet and to the restore set. + val released = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull(released) + assertFalse(released!!.isSpent) + assertNull(released.spendingTxid) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept(walletId, arrayOf(txid)) + 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 diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 900a5b07e73..e82562061e4 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -236,6 +236,22 @@ pub struct WalletChangeSetFFI { /// `proof.rs` can't fire until SPV re-applies a fresh CL). pub last_applied_chain_lock_bytes: *mut u8, pub last_applied_chain_lock_bytes_len: usize, + /// Transactions the wallet removed this round, as raw 32-byte txids + /// (internal byte order, same as every other txid on this surface). + /// + /// The only subtractive field in this struct. Each named transaction + /// was a recorded spend that a later, final transaction beat to one of + /// its inputs, so it can never confirm; the wallet has already dropped + /// it. **A persister must act on this**: delete the transaction row and + /// any UTXO row it created, and drop its spend attribution. Every other + /// field here is additive, so ignoring this one leaves dead rows that + /// are handed back at the next load and re-create a balance the wallet + /// has already corrected. + /// + /// `null` / `0` when nothing was swept, which is the overwhelmingly + /// common case. + pub swept_txids: *mut [u8; 32], + pub swept_txids_count: usize, } // --------------------------------------------------------------------------- @@ -450,6 +466,20 @@ impl WalletChangeSetFFI { None => (std::ptr::null_mut(), 0), }; + // Swept transactions travel at the top level, not per account: + // the upstream event is wallet-scoped, and the persister deletes + // by txid — the row it deletes carries its own account link. + let swept: Vec<[u8; 32]> = cs + .swept_txids + .iter() + .map(|txid| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(txid.as_ref()); + raw + }) + .collect(); + let swept_txids_count = swept.len(); + WalletChangeSetFFI { has_chain, chain, @@ -459,6 +489,8 @@ impl WalletChangeSetFFI { accounts_count, last_applied_chain_lock_bytes, last_applied_chain_lock_bytes_len, + swept_txids: vec_to_ptr(swept), + swept_txids_count, } } } @@ -1708,6 +1740,16 @@ pub unsafe fn free_wallet_changeset_ffi(cs: &WalletChangeSetFFI) { )); } + // Before the accounts early-return below: a sweep-only round carries + // no accounts at all, and its txid buffer still has to be released. + if !cs.swept_txids.is_null() && cs.swept_txids_count > 0 { + drop(Vec::from_raw_parts( + cs.swept_txids, + cs.swept_txids_count, + cs.swept_txids_count, + )); + } + if cs.accounts.is_null() || cs.accounts_count == 0 { return; } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..485674434ee 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -637,6 +637,34 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( return Ok(code); } } + + // Sweeps last, and only when there are any: the transaction that + // beat these to their inputs rides in the additive part above, so + // by the time the removal runs its claim on those inputs is + // already recorded. + let swept = slice_or_empty(cs.swept_txids, cs.swept_txids_count); + if !swept.is_empty() { + let byte_array_cls = env.find_class("[B")?; + let empty = env.byte_array_from_slice(&[])?; + let arr = env.new_object_array(swept.len() as i32, &byte_array_cls, &empty)?; + for (i, txid) in swept.iter().enumerate() { + env.with_local_frame(4, |env| { + let t = env.byte_array_from_slice(txid)?; + env.set_object_array_element(&arr, i as i32, &t) + })?; + } + let code = env + .call_method( + bridge, + "onWalletChangesetTransactionsSwept", + "([B[[B)I", + &[(&wid).into(), (&arr).into()], + )? + .i()?; + if code != 0 { + return Ok(code); + } + } Ok(0) }) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b8b1bcde6cb..ee21aacf9fe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -821,10 +821,58 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Swept transactions, applied last: the transaction that beat + // them to their inputs usually rides in the same round, and + // running the additive part first means its claim on those + // inputs is already recorded when the removal below decides + // whether a spend link still points at a dead transaction. + if cs.swept_txids_count > 0, let sweptPtr = cs.swept_txids { + for i in 0..( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + descriptor.relationshipKeyPathsForPrefetching = [\.inputs] + guard let row = try? backgroundContext.fetch(descriptor).first else { return } + + for txo in row.inputs { + txo.spendingTransaction = nil + txo.isSpent = false + txo.lastUpdated = Date() + } + backgroundContext.delete(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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift new file mode 100644 index 00000000000..d812dfc0660 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -0,0 +1,166 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the one subtractive part of the changeset path: +/// `WalletChangeSetFFI.swept_txids`. +/// +/// 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. Every other field on that +/// struct is additive, so a mirror that ignores this one 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. +@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 func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// Seed the shape a confirmed spend leaves behind: a funding + /// transaction with one output, a spending transaction that claimed + /// it (linked and flagged spent), and the change that spend created. + private func seedSpend(in container: ModelContainer) 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: 100_000 + ) + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 2, + blockHeight: 101, + netAmount: -40_000 + ) + context.insert(funding) + context.insert(swept) + + let fundedOutput = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + fundedOutput.walletId = walletId + fundedOutput.isSpent = true + fundedOutput.spendingTransaction = swept + context.insert(fundedOutput) + + let change = PersistentTxo( + transaction: swept, + vout: 0, + amount: 60_000, + address: "yChangeAddr", + height: 101 + ) + change.walletId = walletId + context.insert(change) + + try context.save() + } + + /// Drive one changeset round that sweeps `sweptTxid`, through the same + /// entry point the Rust persister calls. + private func sweep(_ handler: PlatformWalletPersistenceHandler, txids: [Data]) { + var raw: [(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)] = [] + for txid in txids { + var tuple = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0)) + withUnsafeMutableBytes(of: &tuple) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + raw.append(tuple) + } + + handler.beginChangeset(walletId: walletId) + raw.withUnsafeMutableBufferPointer { buf in + var cs = WalletChangeSetFFI() + cs.swept_txids = buf.baseAddress + cs.swept_txids_count = UInt(buf.count) + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + 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 coin stay. + func testSweptTransactionAndItsOutputsAreDeleted() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container) + + sweep(handler, txids: [sweptTxid]) + + 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 coin the swept transaction claimed becomes spendable again. + /// Left as-is it would be marked spent by a transaction that no longer + /// exists — invisible to the wallet and to the restore set, which is + /// the same lost-funds shape as the phantom balance, inverted. + func testSweepReleasesTheSpendClaimOnItsInputs() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container) + + sweep(handler, txids: [sweptTxid]) + + let funded = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(funded) + XCTAssertFalse(funded!.isSpent, "the claim died with the transaction that made it") + XCTAssertNil(funded!.spendingTransaction) + } + + /// 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) + + sweep(handler, txids: [Data(repeating: 0x99, count: 32)]) + + XCTAssertNotNil(transaction(container, txid: sweptTxid)) + XCTAssertNotNil(transaction(container, txid: fundingTxid)) + } +} From 49e5a5f7f4322205e8d0d05c094e8576387701fa Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:02:34 +0300 Subject: [PATCH 003/102] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20k?= =?UTF-8?q?eep=20the=20winner's=20coin=20spent,=20fail=20a=20sweep=20that?= =?UTF-8?q?=20throws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings. **A released input could be one the winner consumed.** Upstream is explicit that a sweep frees only the loser's *extra* inputs — "a loser spending A+B against a winner spending only A must leave A marked and free B" — and the winner does not have to be wallet-relevant: `test_an_irrelevant_winner_ still_sweeps_its_loser` covers a winner that spends our funding output and pays entirely to outside addresses, so no record for it ever reaches the persister. Both persisters released every claim the loser held, so after a restart that consumed coin came back in the unspent restore set with no winner record left to re-spend it. The changeset now carries the pairing: `CoreChangeSet.swept_transactions` (and `SweptTransactionFFI`) name the removed transaction *and* the transaction that settled its inputs. That is enough to tell the two kinds apart without shipping the winner's input list: - a wallet-relevant winner has re-pointed the shared inputs at itself earlier in the same round, so releasing whatever still points at the loser releases exactly the loser's extras; - a winner absent from the store is the irrelevant case, where nothing distinguishes them — so the claims stand. The wallet holds no UTXO for either kind either, and upstream documents a rescan as the recovery path for the freed ones. Keeping a coin out of the restore set is recoverable; handing back one the chain has already spent is not. **A failed fetch read as "no such transaction".** `try?` collapsed a SwiftData failure into the same no-op as a successful miss, and the round still reported success — Rust would clear the sweep while the row it named survived to be replayed at the next load. The lookups throw now, and `persistWalletChangeset` returns a failure the C shim forwards, so the round rolls back. Tests: the irrelevant-winner scenario end to end on both persisters, plus the A/B split, on top of the existing deletion coverage. --- .../dashsdk/ffi/NativePersistenceBridge.kt | 23 ++- .../PlatformWalletPersistenceHandler.kt | 36 ++-- .../dashsdk/persistence/dao/TxoDao.kt | 20 +- .../PlatformWalletPersistenceHandlerTest.kt | 121 ++++++++++-- .../src/core_wallet_types.rs | 74 ++++--- .../src/changeset/changeset.rs | 69 +++++-- .../src/changeset/core_bridge.rs | 44 ++++- .../rs-unified-sdk-jni/src/persistence.rs | 23 ++- .../PlatformWalletPersistenceHandler.swift | 113 ++++++++--- .../SweptTransactionPersistTests.swift | 180 ++++++++++++------ 10 files changed, 511 insertions(+), 192 deletions(-) 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 3073d4fc40e..9155a09975e 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 @@ -295,20 +295,27 @@ abstract class NativePersistenceBridge { open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 /** - * Transactions the wallet removed this round, as raw 32-byte txids. + * Transactions the wallet removed this round, as raw 32-byte txids, + * each paired by index with the transaction that settled its inputs. * Fired once after the per-account decomposition, and only when the - * round swept something. Descriptor `([B[[B)I`. + * round swept something. Descriptor `([B[[B[[B)I`. * - * Each named transaction was a recorded spend that a later, final - * transaction 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. + * Each named 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. + * + * The winner decides what happens to the coins the removed transaction + * claimed: the ones it took are still spent, only the loser's extra + * inputs are free. A winner paying entirely to outside addresses is + * never reported as a record, so `supersededBy` is the only signal an + * implementation gets about it. */ open fun onWalletChangesetTransactionsSwept( walletId: ByteArray, txids: Array, + supersededBy: Array, ): Int = 0 // ── Identities ──────────────────────────────────────────────────── 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 9560e738d33..a9aa4a4961e 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 @@ -988,17 +988,27 @@ class PlatformWalletPersistenceHandler( /** * Delete the mirror of transactions the wallet swept. * - * Each was a recorded spend that a later, final transaction beat to one - * of its inputs, so it can never confirm and Rust has already dropped - * it. Keeping the rows would hand them back at the next load and - * re-create a balance the wallet has already corrected. + * Each 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 + * rows would hand them back at the next load and re-create a balance the + * wallet has already corrected. * * The TXOs the transaction created go with it (`txos.txid` cascades). - * The ones it *spent* need the explicit release below first: the - * foreign key nulls `spendingTxid` on delete but leaves `isSpent` set, - * which would strand the coin as spent by a transaction that no longer - * exists — and once the link is nulled there is nothing left to find - * those rows by. + * The ones it *spent* split in two, and the winner is what tells them + * apart: inputs the winner also took are still spent, inputs only the + * loser named are free again. + * + * A wallet-relevant winner has already re-pointed the shared inputs at + * itself earlier in this round, so releasing whatever still points at + * the loser releases exactly the loser's extras. A winner that pays only + * to outside addresses sends no record at all — nothing here can tell + * the two kinds apart then, and releasing would hand a coin the winner + * consumed back to the wallet as spendable, so the claims stand. The + * wallet holds no UTXO for either kind either; upstream documents a + * rescan as the recovery path for the freed ones. + * + * The release has to run before the delete: the foreign key nulls + * `spendingTxid` on delete, and after that nothing finds those rows. * * Transaction rows are keyed by txid alone, shared across wallets by * design, and a sweep is a statement about the transaction rather than @@ -1008,11 +1018,15 @@ class PlatformWalletPersistenceHandler( override fun onWalletChangesetTransactionsSwept( walletId: ByteArray, txids: Array, + supersededBy: Array, ): Int = guarded { stage(walletId) { db -> if (db.walletDao().getByWalletId(walletId) == null) return@stage - for (txid in txids) { - db.txoDao().releaseSpendClaim(txid) + for ((index, txid) in txids.withIndex()) { + val winner = supersededBy.getOrNull(index) + if (winner != null && db.transactionDao().getByTxid(winner) != null) { + db.txoDao().releaseSpendClaim(txid) + } db.transactionDao().deleteByTxid(txid) } } 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 43cab2da3f6..32a2803d0b3 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 @@ -44,16 +44,18 @@ interface TxoDao { suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List /** - * Release the spend claim a swept transaction held on the TXOs it - * named as inputs. + * Release the spend claim [spendingTxid] still holds — used when that + * transaction was swept and the coins it named are genuinely free. * - * The `spendingTxid` foreign key already nulls itself when the - * spending row is deleted, but `isSpent` is a plain column and would - * survive — leaving a coin marked spent by a transaction that no - * longer exists. Run this *before* deleting the transaction, while the - * link that identifies those rows is still there; the transaction that - * actually took the inputs re-asserts its own claim through the - * additive part of the changeset. + * The `spendingTxid` foreign key already nulls itself when the spending + * row is deleted, but `isSpent` is a plain column and would survive, + * leaving a coin marked spent by a transaction that no longer exists. + * Run this *before* deleting the transaction, while the link that + * identifies those rows is still there. + * + * Only rows still pointing at [spendingTxid] are touched, which is what + * makes this safe for a sweep: the winner has already re-pointed the + * inputs it took at itself, so what remains is the loser's own. */ @Query("UPDATE txos SET isSpent = 0, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") suspend fun releaseSpendClaim(spendingTxid: ByteArray) 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 2f7b35ffb24..e994bd25d87 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 @@ -2034,11 +2034,15 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun sweptTransactionIsDeletedAndReleasesItsSpendClaim() = runTest { - // A recorded spend that a later, final transaction beat to its - // 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. + 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. + // + // The loser spends A and B; the winner (wallet-relevant, so its + // record rides in the same round) takes only A. A must stay spent — + // the winner has it — while B goes back to spendable. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } handler.onPersistAccountRegistration( @@ -2057,6 +2061,7 @@ class PlatformWalletPersistenceHandlerTest { val fundingTxid = ByteArray(32) { 41 } val sweptTxid = ByteArray(32) { 42 } + val winnerTxid = ByteArray(32) { 44 } handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( @@ -2064,21 +2069,27 @@ class PlatformWalletPersistenceHandlerTest { 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, ByteArray(0), 0, ) + // 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) - // The doomed transaction: spends the funding TXO and creates change - // of its own, in-block so `isSpent` flips. + // The doomed transaction: spends both coins, creates change of its + // own, in-block so `isSpent` flips. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( walletId, sweptTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, - 1_700_000_100, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_050, - makeOutpoint(fundingTxid, 0), 1, + 1_700_000_100, 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 }, 101, false, true, false, false, @@ -2086,8 +2097,18 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetEnd(walletId, success = true) assertTrue(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + // The winner arrives and re-points A at itself, then the sweep runs + // — the ordering the persist path guarantees inside one round. handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransactionsSwept(walletId, arrayOf(sweptTxid)) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), + ) handler.onChangesetEnd(walletId, success = true) assertNull("the swept transaction row is gone", db.transactionDao().getByTxid(sweptTxid)) @@ -2097,16 +2118,80 @@ class PlatformWalletPersistenceHandlerTest { ) assertNotNull("the funding transaction is untouched", db.transactionDao().getByTxid(fundingTxid)) - // The coin it claimed is spendable again: a coin left marked spent - // by a transaction that no longer exists is invisible to the - // wallet and to the restore set. - val released = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) - assertNotNull(released) - assertFalse(released!!.isSpent) + 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) assertEquals(1, handler.onLoadWalletList().single().utxos.size) } + @Test + fun sweepByAnIrrelevantWinnerKeepsTheSpendClaim() = 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 here can tell the input the winner took + // from one only the loser named. Releasing would hand a coin that is + // provably gone back to the wallet as spendable, so the claim stands. + 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, + ), + ) + + val fundingTxid = ByteArray(32) { 45 } + val sweptTxid = ByteArray(32) { 46 } + val irrelevantWinner = ByteArray(32) { 47 } + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sweptTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_100, 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) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(irrelevantWinner), + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull(db.transactionDao().getByTxid(sweptTxid)) + val stillSpent = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the winner consumed must not come back", stillSpent.isSpent) + assertTrue( + "and it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + @Test fun sweptTransactionRollsBackWithItsRound() = runTest { // The deletion is staged in the same buffered transaction as every @@ -2124,7 +2209,9 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetEnd(walletId, success = true) handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransactionsSwept(walletId, arrayOf(txid)) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(txid), arrayOf(ByteArray(32) { 44 }), + ) handler.onChangesetEnd(walletId, success = false) assertNotNull(db.transactionDao().getByTxid(txid)) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index e82562061e4..318fd4560cc 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -236,22 +236,41 @@ pub struct WalletChangeSetFFI { /// `proof.rs` can't fire until SPV re-applies a fresh CL). pub last_applied_chain_lock_bytes: *mut u8, pub last_applied_chain_lock_bytes_len: usize, - /// Transactions the wallet removed this round, as raw 32-byte txids - /// (internal byte order, same as every other txid on this surface). + /// Transactions the wallet removed this round. /// - /// The only subtractive field in this struct. Each named transaction - /// was a recorded spend that a later, final transaction beat to one of - /// its inputs, so it can never confirm; the wallet has already dropped - /// it. **A persister must act on this**: delete the transaction row and - /// any UTXO row it created, and drop its spend attribution. Every other - /// field here is additive, so ignoring this one leaves dead rows that - /// are handed back at the next load and re-create a balance the wallet - /// has already corrected. + /// The only subtractive field in this struct. Each named transaction was + /// a recorded spend that a later, final transaction beat to one of its + /// inputs, so it can never confirm; the wallet has already dropped it. + /// **A persister must act on this**: delete the transaction row and any + /// UTXO row it created. Every other field here is additive, so ignoring + /// this one leaves dead rows that are handed back at the next load and + /// re-create a balance the wallet has already corrected. /// /// `null` / `0` when nothing was swept, which is the overwhelmingly /// common case. - pub swept_txids: *mut [u8; 32], - pub swept_txids_count: usize, + pub swept: *mut SweptTransactionFFI, + pub swept_count: usize, +} + +/// One removed transaction and the transaction that settled its inputs. +/// +/// Both raw 32-byte txids, internal byte order, same as every other txid on +/// this surface. +#[repr(C)] +pub struct SweptTransactionFFI { + /// The removed transaction: delete this row and the UTXOs it created. + pub txid: [u8; 32], + /// The transaction whose arrival settled the inputs. Final, and not + /// necessarily wallet-relevant — it can pay entirely to outside + /// addresses and still sweep, in which case no record for it reaches + /// the persister at all. + /// + /// It decides what happens to the coins the removed transaction claimed + /// to spend: the ones this transaction took are still spent, and only + /// the loser's *extra* inputs are free. A persister that released every + /// input would hand a coin the winner already consumed back to the + /// wallet as spendable. + pub superseded_by: [u8; 32], } // --------------------------------------------------------------------------- @@ -469,16 +488,21 @@ impl WalletChangeSetFFI { // Swept transactions travel at the top level, not per account: // the upstream event is wallet-scoped, and the persister deletes // by txid — the row it deletes carries its own account link. - let swept: Vec<[u8; 32]> = cs - .swept_txids + let swept: Vec = cs + .swept_transactions .iter() - .map(|txid| { - let mut raw = [0u8; 32]; - raw.copy_from_slice(txid.as_ref()); - raw + .map(|swept| { + let mut txid = [0u8; 32]; + txid.copy_from_slice(swept.txid.as_ref()); + let mut superseded_by = [0u8; 32]; + superseded_by.copy_from_slice(swept.superseded_by.as_ref()); + SweptTransactionFFI { + txid, + superseded_by, + } }) .collect(); - let swept_txids_count = swept.len(); + let swept_count = swept.len(); WalletChangeSetFFI { has_chain, @@ -489,8 +513,8 @@ impl WalletChangeSetFFI { accounts_count, last_applied_chain_lock_bytes, last_applied_chain_lock_bytes_len, - swept_txids: vec_to_ptr(swept), - swept_txids_count, + swept: vec_to_ptr(swept), + swept_count, } } } @@ -1742,11 +1766,11 @@ pub unsafe fn free_wallet_changeset_ffi(cs: &WalletChangeSetFFI) { // Before the accounts early-return below: a sweep-only round carries // no accounts at all, and its txid buffer still has to be released. - if !cs.swept_txids.is_null() && cs.swept_txids_count > 0 { + if !cs.swept.is_null() && cs.swept_count > 0 { drop(Vec::from_raw_parts( - cs.swept_txids, - cs.swept_txids_count, - cs.swept_txids_count, + cs.swept, + cs.swept_count, + cs.swept_count, )); } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 222df920521..97969d045f0 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -63,7 +63,7 @@ use crate::wallet::identity::{ /// /// Built by the platform-wallet event adapter from `WalletEvent` variants /// emitted by `WalletManager`. Every field is additive except -/// [`Self::swept_txids`] — the merge implementation uses last-write-wins for +/// [`Self::swept_transactions`] — the merge implementation uses last-write-wins for /// the height watermarks (monotonic-max), `extend` for the records / utxos /// vecs, and last-write-wins for the IS-lock map. /// @@ -205,15 +205,39 @@ pub struct CoreChangeSet { /// The one subtractive field on this type. Every other field is additive, /// which is exactly why this one has to exist: a persister that only ever /// appends keeps the dead rows and replays them on the next load, - /// re-creating a balance the wallet has already corrected. The persister - /// deletes the transaction rows named here along with any UTXO they - /// created, and drops its spend attribution to them — the transaction that - /// actually took those inputs re-asserts its own claim through `records` / - /// `spent_utxos`. + /// re-creating a balance the wallet has already corrected. /// - /// Deduplicated on merge: a sweep is idempotent, and a flush can fold - /// several sweeps together. - pub swept_txids: Vec, + /// Deduplicated on merge by the removed txid: a sweep is idempotent, and a + /// flush can fold several sweeps together. + pub swept_transactions: Vec, +} + +/// One transaction the wallet removed, paired with the transaction whose +/// arrival settled its inputs. +/// +/// The pairing is the point. A persister deleting the removed row also has to +/// decide what to do with the coins that row claimed to spend, and the answer +/// depends entirely on the winner: the inputs it took are still spent, while +/// any *extra* inputs the loser named are not. Upstream keeps exactly that +/// split (`release_spent_marks`: "a loser spending A+B against a winner +/// spending only A must leave A marked and free B"), and a persister that +/// released everything would hand a coin the winner already consumed back to +/// the wallet as spendable. +/// +/// A winner that is itself wallet-relevant re-asserts its claim through +/// `records` in the same round, which is what lets a persister tell the two +/// apart without carrying the winner's input list: see the persistence +/// handlers' sweep paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SweptTransaction { + /// The removed transaction. Its row and every UTXO it created go. + pub txid: Txid, + /// The transaction whose arrival settled the inputs — final, and + /// therefore the reason the removed one can never confirm. Not + /// necessarily wallet-relevant: it can pay entirely to outside + /// addresses and still sweep. + pub superseded_by: Txid, } /// Highest-used derivation index per pool slot for one account, as @@ -350,16 +374,21 @@ impl Merge for CoreChangeSet { .merge_max(indexes); } - // Sweeps: append, first-seen order, deduplicated. Deleting the - // same txid twice is harmless at the persister, so the dedup is - // only there to keep a coalesced round's payload honest about how - // many distinct transactions died. - if !other.swept_txids.is_empty() { - let mut seen: std::collections::HashSet = - self.swept_txids.iter().copied().collect(); - for txid in other.swept_txids { - if seen.insert(txid) { - self.swept_txids.push(txid); + // Sweeps: append, first-seen order, deduplicated by the removed + // txid. Deleting the same transaction twice is harmless at the + // persister, so the dedup is only there to keep a coalesced round's + // payload honest about how many distinct transactions died — and + // first-seen wins, so the earliest winner recorded for a txid is + // the one the persister sees. + if !other.swept_transactions.is_empty() { + let mut seen: std::collections::HashSet = self + .swept_transactions + .iter() + .map(|swept| swept.txid) + .collect(); + for swept in other.swept_transactions { + if seen.insert(swept.txid) { + self.swept_transactions.push(swept); } } } @@ -367,7 +396,7 @@ impl Merge for CoreChangeSet { fn is_empty(&self) -> bool { self.records.is_empty() - && self.swept_txids.is_empty() + && self.swept_transactions.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 286ece325e6..492c54ef07c 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -53,6 +53,7 @@ use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, + SweptTransaction, }; use crate::changeset::merge::Merge; use crate::changeset::traits::PlatformWalletPersistence; @@ -725,16 +726,24 @@ async fn build_core_changeset( // 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: the winner's own record - // flows through `TransactionDetected` / `BlockProcessed` and - // claims them. This arm only names the dead. + // No `spent_utxos` entry for the inputs: a wallet-relevant winner + // claims them through its own record. This arm only names the + // dead — and the winner alongside each, because the persister + // cannot decide what to do with the loser's inputs without it + // (upstream keeps the winner's share marked and frees the rest). tracing::debug!( swept = txids.len(), superseded_by = %superseded_by, "Mirroring swept transactions to the persister" ); CoreChangeSet { - swept_txids: txids.clone(), + swept_transactions: txids + .iter() + .map(|txid| SweptTransaction { + txid: *txid, + superseded_by: *superseded_by, + }) + .collect(), ..CoreChangeSet::default() } } @@ -1142,7 +1151,7 @@ impl CoreChangeSet { /// circuits on the common case. fn is_empty_no_records(&self) -> bool { self.records.is_empty() - && self.swept_txids.is_empty() + && self.swept_transactions.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -1196,8 +1205,21 @@ mod swept_transaction_projection_tests { 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.swept_txids, vec![txid(1), txid(2)]); - // The winner's own claim on the inputs arrives through its own + assert_eq!( + cs.swept_transactions, + vec![ + SweptTransaction { + txid: txid(1), + superseded_by: txid(0xff), + }, + SweptTransaction { + txid: txid(2), + superseded_by: txid(0xff), + }, + ], + "each dead transaction is paired with the winner that settled its inputs" + ); + // 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"); @@ -1223,7 +1245,13 @@ mod swept_transaction_projection_tests { cs.merge(second); - assert_eq!(cs.swept_txids, vec![txid(1), txid(2), txid(3)]); + assert_eq!( + cs.swept_transactions + .iter() + .map(|swept| swept.txid) + .collect::>(), + vec![txid(1), txid(2), txid(3)] + ); } } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 485674434ee..46a20b58617 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -642,23 +642,30 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( // beat these to their inputs rides in the additive part above, so // by the time the removal runs its claim on those inputs is // already recorded. - let swept = slice_or_empty(cs.swept_txids, cs.swept_txids_count); + let swept = slice_or_empty(cs.swept, cs.swept_count); if !swept.is_empty() { + // Parallel arrays, index-aligned: the removed txid and the + // transaction that settled its inputs. The pairing is what lets + // the handler decide which of the loser's inputs are actually + // free — see `onWalletChangesetTransactionsSwept`. let byte_array_cls = env.find_class("[B")?; let empty = env.byte_array_from_slice(&[])?; - let arr = env.new_object_array(swept.len() as i32, &byte_array_cls, &empty)?; - for (i, txid) in swept.iter().enumerate() { - env.with_local_frame(4, |env| { - let t = env.byte_array_from_slice(txid)?; - env.set_object_array_element(&arr, i as i32, &t) + let txids = env.new_object_array(swept.len() as i32, &byte_array_cls, &empty)?; + let winners = env.new_object_array(swept.len() as i32, &byte_array_cls, &empty)?; + for (i, entry) in swept.iter().enumerate() { + env.with_local_frame(8, |env| { + let txid = env.byte_array_from_slice(&entry.txid)?; + env.set_object_array_element(&txids, i as i32, &txid)?; + let winner = env.byte_array_from_slice(&entry.superseded_by)?; + env.set_object_array_element(&winners, i as i32, &winner) })?; } let code = env .call_method( bridge, "onWalletChangesetTransactionsSwept", - "([B[[B)I", - &[(&wid).into(), (&arr).into()], + "([B[[B[[B)I", + &[(&wid).into(), (&txids).into(), (&winners).into()], )? .i()?; if code != 0 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index ee21aacf9fe..64940f1fdf2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -771,9 +771,21 @@ 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. Only the subtractive part can report this today: 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 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. + guard let wallet = findWalletRecord(walletId: walletId) else { return true } let cs = changeset.pointee // Chain update. @@ -821,54 +833,93 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - // Swept transactions, applied last: the transaction that beat - // them to their inputs usually rides in the same round, and - // running the additive part first means its claim on those - // inputs is already recorded when the removal below decides - // whether a spend link still points at a dead transaction. - if cs.swept_txids_count > 0, let sweptPtr = cs.swept_txids { - for i in 0.. 0, let sweptPtr = cs.swept { + for i in 0..( predicate: #Predicate { $0.txid == txid } ) descriptor.fetchLimit = 1 descriptor.relationshipKeyPathsForPrefetching = [\.inputs] - guard let row = try? backgroundContext.fetch(descriptor).first else { return } + // A successful fetch that finds nothing is an ordinary no-op: sweeps + // are idempotent and can name a transaction this store never had. + guard let row = try backgroundContext.fetch(descriptor).first else { return } - for txo in row.inputs { - txo.spendingTransaction = nil - txo.isSpent = false - txo.lastUpdated = Date() + var winnerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == supersededBy } + ) + winnerDescriptor.fetchLimit = 1 + let winnerIsKnown = try backgroundContext.fetch(winnerDescriptor).first != nil + + if winnerIsKnown { + for txo in row.inputs { + txo.spendingTransaction = nil + txo.isSpent = false + txo.lastUpdated = Date() + } } backgroundContext.delete(row) } @@ -6729,8 +6780,10 @@ 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 `on_changeset_begin_fn`. Forwards to diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index d812dfc0660..94fde1a4ad8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -4,21 +4,24 @@ import DashSDKFFI @testable import SwiftDashSDK /// Coverage for the one subtractive part of the changeset path: -/// `WalletChangeSetFFI.swept_txids`. +/// `WalletChangeSetFFI.swept`. /// -/// 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. Every other field on that -/// struct is additive, so a mirror that ignores this one 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. +/// 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. Every other field on that struct is additive, so a +/// mirror that ignores this one 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: the loser +/// spends A and B, the winner takes only A. @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() @@ -26,10 +29,14 @@ final class SweptTransactionPersistTests: XCTestCase { return (handler, container) } - /// Seed the shape a confirmed spend leaves behind: a funding - /// transaction with one output, a spending transaction that claimed - /// it (linked and flagged spent), and the change that spend created. - private func seedSpend(in container: ModelContainer) throws { + /// 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)) @@ -38,29 +45,58 @@ final class SweptTransactionPersistTests: XCTestCase { transactionData: Data(repeating: 0x04, count: 10), context: 2, blockHeight: 100, - netAmount: 100_000 + netAmount: 140_000 ) let swept = PersistentTransaction( txid: sweptTxid, transactionData: Data(repeating: 0x05, count: 10), context: 2, blockHeight: 101, - netAmount: -40_000 + netAmount: -140_000 ) context.insert(funding) context.insert(swept) - let fundedOutput = PersistentTxo( + 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. + let coinA = PersistentTxo( transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100 ) - fundedOutput.walletId = walletId - fundedOutput.isSpent = true - fundedOutput.spendingTransaction = swept - context.insert(fundedOutput) + coinA.walletId = walletId + coinA.isSpent = true + coinA.spendingTransaction = winner ?? swept + context.insert(coinA) + + // B — named only by the loser. + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.isSpent = true + coinB.spendingTransaction = swept + context.insert(coinB) let change = PersistentTxo( transaction: swept, @@ -75,34 +111,36 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() } - /// Drive one changeset round that sweeps `sweptTxid`, through the same - /// entry point the Rust persister calls. - private func sweep(_ handler: PlatformWalletPersistenceHandler, txids: [Data]) { - var raw: [(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)] = [] - for txid in txids { - var tuple = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), - UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), - UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), - UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0)) - withUnsafeMutableBytes(of: &tuple) { dst in - txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + /// Drive one changeset round of sweeps through the same entry point the + /// Rust persister calls. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ pairs: [(loser: Data, winner: Data)] + ) -> Bool { + var entries: [SweptTransactionFFI] = [] + for pair in pairs { + var entry = SweptTransactionFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + pair.loser.withUnsafeBytes { src in dst.copyMemory(from: src) } } - raw.append(tuple) + Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in + pair.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entries.append(entry) } handler.beginChangeset(walletId: walletId) - raw.withUnsafeMutableBufferPointer { buf in + let applied = entries.withUnsafeMutableBufferPointer { buf -> Bool in var cs = WalletChangeSetFFI() - cs.swept_txids = buf.baseAddress - cs.swept_txids_count = UInt(buf.count) - withUnsafePointer(to: &cs) { csPtr in + cs.swept = buf.baseAddress + cs.swept_count = UInt(buf.count) + return withUnsafePointer(to: &cs) { csPtr in handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) } } - _ = handler.endChangeset(walletId: walletId, success: true) + _ = handler.endChangeset(walletId: walletId, success: applied) + return applied } private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { @@ -122,33 +160,59 @@ final class SweptTransactionPersistTests: XCTestCase { return try? context.fetch(descriptor).first } - /// The row and everything it created go; the funding transaction and - /// its coin stay. + /// 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) + try seedSpend(in: container, winnerTakesA: true) - sweep(handler, txids: [sweptTxid]) + sweep(handler, [(loser: sweptTxid, winner: winnerTxid)]) 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 coin the swept transaction claimed becomes spendable again. - /// Left as-is it would be marked spent by a transaction that no longer - /// exists — invisible to the wallet and to the restore set, which is - /// the same lost-funds shape as the phantom balance, inverted. - func testSweepReleasesTheSpendClaimOnItsInputs() throws { + /// With the winner in the store, releasing what still points at the + /// loser frees exactly the loser's own input: the winner re-pointed the + /// shared one at itself earlier in the round. + func testSweepFreesOnlyTheInputsTheWinnerDidNotTake() throws { let (handler, container) = try makeHandler() - try seedSpend(in: container) + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [(loser: sweptTxid, winner: winnerTxid)]) - sweep(handler, txids: [sweptTxid]) + 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 funded = txo(container, txid: fundingTxid, vout: 0) - XCTAssertNotNil(funded) - XCTAssertFalse(funded!.isSpent, "the claim died with the transaction that made it") - XCTAssertNil(funded!.spendingTransaction) + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse(losersOwn!.isSpent, "the loser's own input is free again") + XCTAssertNil(losersOwn!.spendingTransaction) + } + + /// A winner that pays only to outside addresses sweeps the loser without + /// ever being recorded here. Nothing then distinguishes the coin it + /// consumed from the loser's extras, and releasing would hand a coin + /// that is provably gone back to the wallet as spendable — so every + /// claim stands. + func testSweepByAnIrrelevantWinnerKeepsTheSpendClaims() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + sweep(handler, [(loser: sweptTxid, winner: winnerTxid)]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") + for vout: UInt32 in [0, 1] { + let coin = txo(container, txid: fundingTxid, vout: vout) + XCTAssertNotNil(coin) + XCTAssertTrue( + coin!.isSpent, + "a coin the unrecorded winner may have consumed must not come back" + ) + } } /// A txid the store has never seen is not an error: sweeps are @@ -156,10 +220,14 @@ final class SweptTransactionPersistTests: XCTestCase { /// recorded in the first place. func testSweepingAnUnknownTransactionIsANoOp() throws { let (handler, container) = try makeHandler() - try seedSpend(in: container) + try seedSpend(in: container, winnerTakesA: true) - sweep(handler, txids: [Data(repeating: 0x99, count: 32)]) + let applied = sweep( + handler, + [(loser: Data(repeating: 0x99, count: 32), winner: winnerTxid)] + ) + XCTAssertTrue(applied, "an absent row is a successful no-op, not a failed round") XCTAssertNotNil(transaction(container, txid: sweptTxid)) XCTAssertNotNil(transaction(container, txid: fundingTxid)) } From f435dcc84b212209ee9c4a9e6a1779f2a0f6cbf6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:02:16 +0300 Subject: [PATCH 004/102] fix: hold a swept loser's inputs when the winner is not in the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round paired each loser with its winner but still leaned on the winner's record to keep the shared input spent, and that only works when such a record exists. It usually does not look like the tests said it did. Upstream sweeps only *unconfirmed* records (`!record.is_confirmed()`), and both mirrors flip `isSpent` solely for a spender that reached a block — so a real swept loser holds its inputs by link alone, `isSpent == false`. Deleting the loser nils the link, and every coin it named, the winner's included, fell straight back into the restore query (`isSpent == false`). The earlier tests hid this by seeding the loser at `InBlock` with `isSpent = true`, a state upstream never sweeps. So the branch that cannot prove anything now holds rather than releases: - winner present in the store — it is wallet-relevant, its record has already re-pointed the inputs it took at itself, so what still points at the loser is the loser's own and stays spendable; - winner absent — it pays only to outside addresses and is never recorded. Nothing separates the coin it consumed from the loser's extras, so all of them are marked spent with no spender named, keeping them out of the restore set. The wallet holds no UTXO for either kind either. Handing back a coin the chain has already spent is the one outcome that cannot be undone from here, which is why the uncertainty resolves that way — and the hold is not permanent: the wallet is authoritative about which coins are free, and the utxo-added path now clears a mark that has no spender behind it, so a rescan re-delivering a coin releases it. Tests now model the unconfirmed loser upstream actually sweeps, and cover the release path, the hold, and the re-delivery that lifts it, on both persisters. --- .../PlatformWalletPersistenceHandler.kt | 42 ++++++--- .../dashsdk/persistence/dao/TxoDao.kt | 18 ++++ .../PlatformWalletPersistenceHandlerTest.kt | 63 ++++++++----- .../PlatformWalletPersistenceHandler.swift | 59 ++++++++---- .../SweptTransactionPersistTests.swift | 90 ++++++++++++++++--- 5 files changed, 207 insertions(+), 65 deletions(-) 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 a9aa4a4961e..9895bdb3e51 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 @@ -921,7 +921,15 @@ class PlatformWalletPersistenceHandler( isConfirmed = isConfirmed, isInstantLocked = isInstantLocked, isLocked = isLocked, - isSpent = existing?.isSpent ?: false, + // The wallet is handing this outpoint over as a UTXO, so it + // holds it unspent — authoritative, and the only thing that + // lifts a mark with no spender behind it. The sweep path + // parks the inputs it cannot resolve in exactly that state + // (`holdSpentWithoutSpender`); a rescan re-delivering the + // coin lands here and frees it. A row whose spend is still + // on record keeps its flag — the pending drain below owns + // that transition. + isSpent = existing?.isSpent == true && existing.spendingTxid != null, walletId = walletId, txid = txid, spendingTxid = existing?.spendingTxid, @@ -995,19 +1003,27 @@ class PlatformWalletPersistenceHandler( * * The TXOs the transaction created go with it (`txos.txid` cascades). * The ones it *spent* split in two, and the winner is what tells them - * apart: inputs the winner also took are still spent, inputs only the - * loser named are free again. + * apart: inputs the winner also took are gone, inputs only the loser + * named are untouched on chain. * - * A wallet-relevant winner has already re-pointed the shared inputs at - * itself earlier in this round, so releasing whatever still points at - * the loser releases exactly the loser's extras. A winner that pays only - * to outside addresses sends no record at all — nothing here can tell - * the two kinds apart then, and releasing would hand a coin the winner - * consumed back to the wallet as spendable, so the claims stand. The - * wallet holds no UTXO for either kind either; upstream documents a - * rescan as the recovery path for the freed ones. + * A swept loser is always unconfirmed upstream, and this store flips + * `isSpent` only for a spender that reached a block — so its inputs are + * linked to it at `isSpent = 0`. Deleting the row nils the link, so + * doing nothing else would return every one of those coins to the + * restore set, the winner's included. * - * The release has to run before the delete: the foreign key nulls + * So each case is handled by what the store can prove: + * - the winner is here, meaning it is wallet-relevant and its record has + * re-pointed the inputs it took at itself earlier in this round — + * whatever still points at the loser is the loser's own and stays + * spendable; + * - the winner is absent, meaning it pays only to outside addresses and + * is never recorded — nothing tells the two kinds apart, so all of + * them are held out of the restore set. The wallet holds no UTXO for + * either kind either, and the free ones come back when it re-delivers + * them as UTXOs after a rescan. + * + * Both updates have to run before the delete: the foreign key nulls * `spendingTxid` on delete, and after that nothing finds those rows. * * Transaction rows are keyed by txid alone, shared across wallets by @@ -1026,6 +1042,8 @@ class PlatformWalletPersistenceHandler( val winner = supersededBy.getOrNull(index) if (winner != null && db.transactionDao().getByTxid(winner) != null) { db.txoDao().releaseSpendClaim(txid) + } else { + db.txoDao().holdSpentWithoutSpender(txid) } db.transactionDao().deleteByTxid(txid) } 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 32a2803d0b3..434c399e5b3 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 @@ -60,6 +60,24 @@ interface TxoDao { @Query("UPDATE txos SET isSpent = 0, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") suspend fun releaseSpendClaim(spendingTxid: ByteArray) + /** + * Hold the coins [spendingTxid] named out of the restore set, without + * naming a spender for them. + * + * The sweep counterpart to [releaseSpendClaim], for the case where the + * transaction that actually settled these inputs is not in this store — + * a winner paying only to outside addresses is never recorded here. A + * swept loser is always unconfirmed, so its inputs sit at + * `isSpent = 0`; deleting it would otherwise return every one of them, + * including the one the winner consumed, as spendable. + * + * The coins that really are free come back the authoritative way: the + * wallet re-delivers them as UTXOs after a rescan, and the utxo-added + * path clears a mark with no spender behind it. + */ + @Query("UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") + suspend fun holdSpentWithoutSpender(spendingTxid: ByteArray) + @Upsert suspend fun upsert(txo: TxoEntity) 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 e994bd25d87..0babf0462ab 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 @@ -2040,9 +2040,9 @@ class PlatformWalletPersistenceHandlerTest { // to drop it too — otherwise the row comes back on the next load and // re-creates a balance the wallet already corrected. // - // The loser spends A and B; the winner (wallet-relevant, so its - // record rides in the same round) takes only A. A must stay spent — - // the winner has it — while B goes back to spendable. + // 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. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } handler.onPersistAccountRegistration( @@ -2066,7 +2066,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( 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, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, ByteArray(0), 0, ) // A (vout 0) and B (vout 1). @@ -2080,25 +2080,29 @@ class PlatformWalletPersistenceHandlerTest { ) handler.onChangesetEnd(walletId, success = true) - // The doomed transaction: spends both coins, creates change of its - // own, in-block so `isSpent` flips. + // 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) handler.onWalletChangesetTransaction( - walletId, sweptTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, - 1_700_000_100, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + 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 }, - 101, false, true, false, false, + 0, false, false, false, false, ) handler.onChangesetEnd(walletId, success = true) - assertTrue(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + assertFalse( + "a pre-block spender links but must not flip isSpent", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) - // The winner arrives and re-points A at itself, then the sweep runs - // — the ordering the persist path guarantees inside one round. + // The winner confirms, taking A, then the sweep runs — the ordering + // the persist path guarantees inside one round. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( walletId, winnerTxid, ByteArray(10) { 6 }, 2, 102, ByteArray(32) { 9 }, @@ -2126,16 +2130,18 @@ class PlatformWalletPersistenceHandlerTest { val released = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 1))!! assertFalse("the loser's own input is free again", released.isSpent) assertNull(released.spendingTxid) - assertEquals(1, handler.onLoadWalletList().single().utxos.size) + val restored = handler.onLoadWalletList().single().utxos.single() + assertEquals(1, restored.vout) } @Test - fun sweepByAnIrrelevantWinnerKeepsTheSpendClaim() = runTest { + fun sweepByAnIrrelevantWinnerHoldsTheInputsOutOfTheRestoreSet() = 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 here can tell the input the winner took - // from one only the loser named. Releasing would hand a coin that is - // provably gone back to the wallet as spendable, so the claim stands. + // the persister. 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 to the restore set + // as spendable. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } handler.onPersistAccountRegistration( @@ -2170,12 +2176,13 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( - walletId, sweptTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, - 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + 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) handler.onWalletChangesetTransactionsSwept( @@ -2184,12 +2191,26 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetEnd(walletId, success = true) assertNull(db.transactionDao().getByTxid(sweptTxid)) - val stillSpent = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! - assertTrue("the coin the winner consumed must not come back", stillSpent.isSpent) + 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( "and it stays out of the restore set", handler.onLoadWalletList().single().utxos.isEmpty(), ) + + // The wallet is the authority on which of those coins are actually + // free: re-delivering one as a UTXO (what a rescan does) lifts the + // hold. + 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(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) } @Test diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 64940f1fdf2..9d52439d687 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -876,19 +876,33 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// cascades), as do its pending inputs. The coins it claimed to *spend* /// are the delicate part, because the two kinds are not alike: /// - /// - inputs the winner also took are still spent, by the winner; - /// - inputs only the loser named are free again. + /// - inputs the winner also took are gone, spent by the winner; + /// - inputs only the loser named are untouched on chain. /// - /// Upstream keeps exactly that split. A winner that is wallet-relevant - /// has already re-pointed the shared inputs at itself earlier in this - /// same round, so releasing whatever still points at the loser releases - /// precisely the loser's extra inputs. A winner that is *not* - /// wallet-relevant — it can spend our coin and pay only outside - /// addresses — sends no record at all, and then nothing distinguishes - /// the two kinds here: releasing would hand a coin the winner consumed - /// back to the wallet as spendable, so the claims are left standing. - /// The wallet's own state agrees; it holds no UTXO for either kind, and - /// upstream documents a rescan as the recovery path for the freed ones. + /// Note what a swept loser actually looks like here. Upstream only ever + /// sweeps *unconfirmed* records, and this store flips `isSpent` only for + /// a spender that reached a block — so the loser's inputs are linked to + /// it with `isSpent == false`. Deleting the row nils the link, and doing + /// nothing else would put every one of those coins straight back into + /// the restore set, including the one the winner consumed. + /// + /// So the two cases are handled by what the store can actually prove: + /// + /// - the winner is known here — it is wallet-relevant and its record + /// re-pointed the inputs it took at itself earlier in this same round, + /// so whatever still points at the loser is the loser's own, and stays + /// spendable; + /// - the winner is absent — it pays only outside addresses and no record + /// for it ever arrives. Nothing distinguishes the two kinds then, so + /// every one of the loser's inputs is held out of the restore set. The + /// wallet's own state agrees: it holds no UTXO for either kind. The + /// coins that really are free come back the authoritative way, when + /// the wallet re-delivers them as UTXOs after a rescan (see + /// `upsertUtxo`, which clears a mark left with no spender). + /// + /// Handing back a coin the chain has already spent is the one outcome + /// that cannot be undone from here, so that is the direction the + /// uncertainty is resolved in. /// /// Transaction rows are shared across wallets by design (see /// `PersistentTransaction`), and a sweep is a statement about the @@ -914,12 +928,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { winnerDescriptor.fetchLimit = 1 let winnerIsKnown = try backgroundContext.fetch(winnerDescriptor).first != nil - if winnerIsKnown { - for txo in row.inputs { - txo.spendingTransaction = nil - txo.isSpent = false - txo.lastUpdated = Date() - } + for txo in row.inputs { + txo.isSpent = !winnerIsKnown + txo.spendingTransaction = nil + txo.lastUpdated = Date() } backgroundContext.delete(row) } @@ -1407,6 +1419,17 @@ 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 + // left with no spender on record. `applySweptTransaction` parks the + // inputs of a sweep it cannot resolve in exactly that state; a + // rescan re-delivering the coin lands here and frees it. A row whose + // spend is still on record is left alone: the pending-input resolve + // below owns that transition. + if record.isSpent, record.spendingTransaction == 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 94fde1a4ad8..55cb5523e63 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -13,8 +13,11 @@ import DashSDKFFI /// 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: the loser -/// spends A and B, the winner takes only A. +/// 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. @MainActor final class SweptTransactionPersistTests: XCTestCase { @@ -47,11 +50,12 @@ final class SweptTransactionPersistTests: XCTestCase { 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: 2, - blockHeight: 101, + context: 0, + blockHeight: 0, netAmount: -140_000 ) context.insert(funding) @@ -72,7 +76,10 @@ final class SweptTransactionPersistTests: XCTestCase { winner = nil } - // A — the coin the winner also takes. + // 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, @@ -81,11 +88,11 @@ final class SweptTransactionPersistTests: XCTestCase { height: 100 ) coinA.walletId = walletId - coinA.isSpent = true + coinA.isSpent = winner != nil coinA.spendingTransaction = winner ?? swept context.insert(coinA) - // B — named only by the loser. + // B — named only by the loser, and so still unspent. let coinB = PersistentTxo( transaction: funding, vout: 1, @@ -94,7 +101,6 @@ final class SweptTransactionPersistTests: XCTestCase { height: 100 ) coinB.walletId = walletId - coinB.isSpent = true coinB.spendingTransaction = swept context.insert(coinB) @@ -103,7 +109,7 @@ final class SweptTransactionPersistTests: XCTestCase { vout: 0, amount: 60_000, address: "yChangeAddr", - height: 101 + height: 0 ) change.walletId = walletId context.insert(change) @@ -194,11 +200,11 @@ final class SweptTransactionPersistTests: XCTestCase { } /// A winner that pays only to outside addresses sweeps the loser without - /// ever being recorded here. Nothing then distinguishes the coin it - /// consumed from the loser's extras, and releasing would hand a coin - /// that is provably gone back to the wallet as spendable — so every - /// claim stands. - func testSweepByAnIrrelevantWinnerKeepsTheSpendClaims() throws { + /// ever being recorded here. The loser was unconfirmed, so both coins it + /// named are linked to it and unspent — deleting it and stopping there + /// would return the one the chain already spent to the restore set. With + /// nothing to tell the two apart, both are held instead. + func testSweepByAnIrrelevantWinnerHoldsTheInputsOutOfTheRestoreSet() throws { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: false) @@ -212,7 +218,63 @@ final class SweptTransactionPersistTests: XCTestCase { coin!.isSpent, "a coin the unrecorded winner may have consumed must not come back" ) + XCTAssertNil(coin!.spendingTransaction, "and no spender is invented for it") + } + } + + /// The wallet decides which of the held coins are actually free: it + /// re-delivers them as UTXOs after a rescan, and that lifts the hold. + /// Without this the conservative branch above would be permanent. + func testWalletReDeliveringAHeldCoinFreesIt() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + sweep(handler, [(loser: sweptTxid, winner: winnerTxid)]) + XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) + + redeliverCoinB(handler) + + let freed = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(freed) + XCTAssertFalse(freed!.isSpent, "the wallet holds it as a UTXO, so the hold is lifted") + 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) } /// A txid the store has never seen is not an error: sweeps are From b57fb2045a60e6c053234a445916ed19f52a0a91 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:33:13 +0300 Subject: [PATCH 005/102] fix: carry the outpoints a sweep released instead of inferring them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inferring the split from the winner's row was wrong twice over, and the second way is not fixable downstream: the block path emits `TransactionsSwept` per winning transaction *before* the `BlockProcessed` that carries the winner's record, and `run_wallet_event_adapter` ends its non-waiting drain as soon as `try_recv` sees an empty channel. So a sweep can commit a whole round before a wallet-relevant winner is even queued. For a loser spending A+B against a winner taking only A, both mobile handlers then held A and B; the winner's later record re-pointed A and never touched B, stranding a genuinely unspent coin outside cold-start restoration for good. Upstream already draws the line and now reports it (rust-dashcore#961's `release_spent_marks`, exposed by dashpay/rust-dashcore#962): the pin moves to 51eafd8c and `WalletEvent::TransactionsSwept.released_outpoints` names the inputs no surviving transaction spends. That set flows through `CoreChangeSet.swept_released_outpoints` and `WalletChangeSetFFI` to all three persisters, which now apply it verbatim — an outpoint it names goes back to spendable, every other input the removed transaction claimed stays spent, and neither depends on when the winner's record shows up or whether it exists at all. Also fixes the second blocker: the canonical SQLite persister ignored `swept_transactions` entirely, so a sweep-only round flushed successfully while the dead row stayed in `core_transactions`, its outputs in `core_utxos`, and its inputs untouched — leaving an InstantSend loser answerable through `get_core_tx_record`, which sent-payment reconciliation reads as final and would use to advance a dead DashPay payment to `Confirmed`. `core_state::apply` now applies sweeps in the same transaction as the rest of the round. The Swift and Kotlin backstop stays: a coin marked spent with no spender on record is cleared when the wallet re-delivers it as a UTXO, so a rescan still recovers anything an older row was left holding. --- Cargo.lock | 24 ++--- Cargo.toml | 16 ++-- .../dashsdk/ffi/NativePersistenceBridge.kt | 23 +++-- .../PlatformWalletPersistenceHandler.kt | 51 +++++----- .../dashsdk/persistence/dao/TxoDao.kt | 47 ++++----- .../PlatformWalletPersistenceHandlerTest.kt | 22 +++-- .../src/core_wallet_types.rs | 42 +++++++- .../src/changeset/changeset.rs | 52 +++++++--- .../src/changeset/core_bridge.rs | 69 ++++++++++++- .../rs-unified-sdk-jni/src/persistence.rs | 29 +++++- .../PlatformWalletPersistenceHandler.swift | 72 +++++++------- .../SweptTransactionPersistTests.swift | 96 +++++++++++++------ 12 files changed, 363 insertions(+), 180 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58ddd918ac8..e83690b8389 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=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" 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=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "bincode", "dashcore-private", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" [[package]] name = "glob" @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=639e70e002c43b645e6011c346a354170ff2b260#639e70e002c43b645e6011c346a354170ff2b260" +source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index f40542d6104..b5d729fcf90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "639e70e002c43b645e6011c346a354170ff2b260" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } tokio-metrics = "0.5" 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 9155a09975e..126e606ea89 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 @@ -296,26 +296,31 @@ abstract class NativePersistenceBridge { /** * Transactions the wallet removed this round, as raw 32-byte txids, - * each paired by index with the transaction that settled its inputs. - * Fired once after the per-account decomposition, and only when the - * round swept something. Descriptor `([B[[B[[B)I`. + * each paired by index with the transaction that settled its inputs, + * plus the outpoints the removals actually freed. Fired once after the + * per-account decomposition, and only when the round swept something. + * Descriptor `([B[[B[[B[[B)I`. * - * Each named transaction was a recorded spend that its winner beat to + * 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. * - * The winner decides what happens to the coins the removed transaction - * claimed: the ones it took are still spent, only the loser's extra - * inputs are free. A winner paying entirely to outside addresses is - * never reported as a record, so `supersededBy` is the only signal an - * implementation gets about it. + * [releasedOutpoints] holds 36-byte keys (raw txid followed by a + * little-endian vout) and 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. */ open fun onWalletChangesetTransactionsSwept( walletId: ByteArray, txids: Array, supersededBy: Array, + releasedOutpoints: Array, ): Int = 0 // ── Identities ──────────────────────────────────────────────────── 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 9895bdb3e51..f3cbb43c7ce 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 @@ -1002,28 +1002,21 @@ class PlatformWalletPersistenceHandler( * wallet has already corrected. * * The TXOs the transaction created go with it (`txos.txid` cascades). - * The ones it *spent* split in two, and the winner is what tells them - * apart: inputs the winner also took are gone, inputs only the loser - * named are untouched on chain. + * The ones it *spent* split in two, and [releasedOutpoints] is the + * authority on which is which: an outpoint named there came free, and + * every other input the loser claimed was taken by the transaction that + * beat it and is gone for good. * - * A swept loser is always unconfirmed upstream, and this store flips - * `isSpent` only for a spender that reached a block — so its inputs are - * linked to it at `isSpent = 0`. Deleting the row nils the link, so - * doing nothing else would return every one of those coins to the - * restore set, the winner's included. + * That split cannot be worked out here. A swept loser is always + * unconfirmed upstream, and this store flips `isSpent` only for a + * spender that reached a block, so the loser holds its inputs by link + * alone at `isSpent = 0`; deleting the row nils the link and every one + * of those coins would return to the restore set, the winner's + * included. Nor can the winner's own row settle it — it may pay only to + * outside addresses and never be recorded here, and even a relevant one + * is not guaranteed to land in the same round as the sweep. * - * So each case is handled by what the store can prove: - * - the winner is here, meaning it is wallet-relevant and its record has - * re-pointed the inputs it took at itself earlier in this round — - * whatever still points at the loser is the loser's own and stays - * spendable; - * - the winner is absent, meaning it pays only to outside addresses and - * is never recorded — nothing tells the two kinds apart, so all of - * them are held out of the restore set. The wallet holds no UTXO for - * either kind either, and the free ones come back when it re-delivers - * them as UTXOs after a rescan. - * - * Both updates have to run before the delete: the foreign key nulls + * Both updates run before the delete: the foreign key nulls * `spendingTxid` on delete, and after that nothing finds those rows. * * Transaction rows are keyed by txid alone, shared across wallets by @@ -1035,16 +1028,20 @@ class PlatformWalletPersistenceHandler( walletId: ByteArray, txids: Array, supersededBy: Array, + releasedOutpoints: Array, ): Int = guarded { stage(walletId) { db -> if (db.walletDao().getByWalletId(walletId) == null) return@stage - for ((index, txid) in txids.withIndex()) { - val winner = supersededBy.getOrNull(index) - if (winner != null && db.transactionDao().getByTxid(winner) != null) { - db.txoDao().releaseSpendClaim(txid) - } else { - db.txoDao().holdSpentWithoutSpender(txid) - } + // Hold every input first, then free the ones upstream named: the + // released set is wallet-scoped across the round's removals, so + // it is applied once rather than per transaction. + for (txid in txids) { + db.txoDao().holdSpentWithoutSpender(txid) + } + for (outpoint in releasedOutpoints) { + db.txoDao().releaseByOutpoint(outpoint) + } + for (txid in txids) { db.transactionDao().deleteByTxid(txid) } } 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 434c399e5b3..01f58e450b3 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 @@ -44,39 +44,34 @@ interface TxoDao { suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List /** - * Release the spend claim [spendingTxid] still holds — used when that - * transaction was swept and the coins it named are genuinely free. + * Hold every coin [spendingTxid] claimed out of the restore set, without + * naming a spender for them. * - * The `spendingTxid` foreign key already nulls itself when the spending - * row is deleted, but `isSpent` is a plain column and would survive, - * leaving a coin marked spent by a transaction that no longer exists. - * Run this *before* deleting the transaction, while the link that - * identifies those rows is still there. + * Used when [spendingTxid] was swept: it can never confirm, so its claim + * is not a spend, but most of the coins it named really were taken — by + * the transaction that beat it. A swept transaction is always + * unconfirmed, so its inputs sit at `isSpent = 0`, and deleting it would + * otherwise return all of them, the consumed one included. * - * Only rows still pointing at [spendingTxid] are touched, which is what - * makes this safe for a sweep: the winner has already re-pointed the - * inputs it took at itself, so what remains is the loser's own. + * Run this *before* deleting the transaction, while the link that + * identifies those rows is still there — the foreign key nulls + * `spendingTxid` on delete, and afterwards nothing finds them. Then + * clear the genuinely free ones with [releaseByOutpoint]. */ - @Query("UPDATE txos SET isSpent = 0, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") - suspend fun releaseSpendClaim(spendingTxid: ByteArray) + @Query("UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") + suspend fun holdSpentWithoutSpender(spendingTxid: ByteArray) /** - * Hold the coins [spendingTxid] named out of the restore set, without - * naming a spender for them. - * - * The sweep counterpart to [releaseSpendClaim], for the case where the - * transaction that actually settled these inputs is not in this store — - * a winner paying only to outside addresses is never recorded here. A - * swept loser is always unconfirmed, so its inputs sit at - * `isSpent = 0`; deleting it would otherwise return every one of them, - * including the one the winner consumed, as spendable. + * Mark one outpoint unspent again — a coin a sweep released, meaning no + * surviving transaction spends it. * - * The coins that really are free come back the authoritative way: the - * wallet re-delivers them as UTXOs after a rescan, and the utxo-added - * path clears a mark with no spender behind it. + * Keyed by outpoint rather than by spender 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. */ - @Query("UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") - suspend fun holdSpentWithoutSpender(spendingTxid: ByteArray) + @Query("UPDATE txos SET isSpent = 0, spendingTxid = NULL, spendingInputIndex = NULL WHERE outpoint = :outpoint") + suspend fun releaseByOutpoint(outpoint: ByteArray) @Upsert suspend fun upsert(txo: TxoEntity) 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 0babf0462ab..0b8e5cca9e1 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 @@ -2112,6 +2112,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) handler.onWalletChangesetTransactionsSwept( walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), + arrayOf(makeOutpoint(fundingTxid, 1)), ) handler.onChangesetEnd(walletId, success = true) @@ -2135,13 +2136,14 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun sweepByAnIrrelevantWinnerHoldsTheInputsOutOfTheRestoreSet() = runTest { + 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. 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 to the restore set - // as spendable. + // 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. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } handler.onPersistAccountRegistration( @@ -2187,6 +2189,9 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( walletId, arrayOf(sweptTxid), arrayOf(irrelevantWinner), + // Upstream knows the winner took this coin even though it never + // reports the winner itself, so nothing is released. + emptyArray(), ) handler.onChangesetEnd(walletId, success = true) @@ -2199,9 +2204,8 @@ class PlatformWalletPersistenceHandlerTest { handler.onLoadWalletList().single().utxos.isEmpty(), ) - // The wallet is the authority on which of those coins are actually - // free: re-delivering one as a UTXO (what a rescan does) lifts the - // hold. + // The hold is not a dead end either: the wallet re-delivering the + // coin as a UTXO, which a rescan does, still lifts it. handler.onChangesetBegin(walletId) handler.onWalletChangesetUtxoAdded( walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, @@ -2231,7 +2235,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(txid), arrayOf(ByteArray(32) { 44 }), + walletId, arrayOf(txid), arrayOf(ByteArray(32) { 44 }), emptyArray(), ) handler.onChangesetEnd(walletId, success = false) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 318fd4560cc..1dc22af2e39 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -250,6 +250,21 @@ pub struct WalletChangeSetFFI { /// common case. pub swept: *mut SweptTransactionFFI, pub swept_count: usize, + /// Of the inputs those removed transactions claimed, the ones that came + /// free — no surviving transaction spends them too. + /// + /// A persister holds every input of every row it deletes, so this is the + /// only thing that tells it which of them to hand back as spendable; + /// everything else it holds stays spent. It cannot be derived from + /// `swept`: the transaction that took the rest may never reach this store + /// at all, since a winner paying entirely to outside addresses is never + /// wallet-relevant, and even a relevant one is not guaranteed to arrive + /// in the same round as the sweep. + /// + /// `null` / `0` when a sweep freed nothing — the ordinary resend, where + /// the winner took every input the removed transaction named. + pub swept_released_outpoints: *mut OutPointFFI, + pub swept_released_outpoints_count: usize, } /// One removed transaction and the transaction that settled its inputs. @@ -504,6 +519,22 @@ impl WalletChangeSetFFI { .collect(); let swept_count = swept.len(); + // Wallet-scoped like the removals themselves: which removal freed + // which coin does not matter to a persister that holds all of them. + let released: Vec = cs + .swept_released_outpoints + .iter() + .map(|outpoint| { + let mut txid = [0u8; 32]; + txid.copy_from_slice(outpoint.txid.as_ref()); + OutPointFFI { + txid, + vout: outpoint.vout, + } + }) + .collect(); + let swept_released_outpoints_count = released.len(); + WalletChangeSetFFI { has_chain, chain, @@ -515,6 +546,8 @@ impl WalletChangeSetFFI { last_applied_chain_lock_bytes_len, swept: vec_to_ptr(swept), swept_count, + swept_released_outpoints: vec_to_ptr(released), + swept_released_outpoints_count, } } } @@ -1765,7 +1798,7 @@ pub unsafe fn free_wallet_changeset_ffi(cs: &WalletChangeSetFFI) { } // Before the accounts early-return below: a sweep-only round carries - // no accounts at all, and its txid buffer still has to be released. + // no accounts at all, and its buffers still have to be released. if !cs.swept.is_null() && cs.swept_count > 0 { drop(Vec::from_raw_parts( cs.swept, @@ -1773,6 +1806,13 @@ pub unsafe fn free_wallet_changeset_ffi(cs: &WalletChangeSetFFI) { cs.swept_count, )); } + if !cs.swept_released_outpoints.is_null() && cs.swept_released_outpoints_count > 0 { + drop(Vec::from_raw_parts( + cs.swept_released_outpoints, + cs.swept_released_outpoints_count, + cs.swept_released_outpoints_count, + )); + } if cs.accounts.is_null() || cs.accounts_count == 0 { return; diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 97969d045f0..6b6abf25da0 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -210,24 +210,38 @@ pub struct CoreChangeSet { /// Deduplicated on merge by the removed txid: a sweep is idempotent, and a /// flush can fold several sweeps together. pub swept_transactions: Vec, + + /// Of the inputs those removed transactions claimed, the ones that came + /// free — no surviving record spends them too. From + /// `WalletEvent::TransactionsSwept.released_outpoints`. + /// + /// The other half of the sweep, and the half a persister cannot work out + /// for itself. Deleting a removed transaction leaves its inputs in two + /// kinds: those a surviving transaction also took, which are gone, and + /// those only the dead one named, which are spendable again. Upstream + /// draws that line (`release_spent_marks`: "a loser spending A+B against + /// a winner spending only A must leave A marked and free B") and reports + /// the result here, because the survivor may be invisible to this wallet + /// — it can spend our coin while paying only external addresses, and + /// then it never appears in the event stream at all. + /// + /// Wallet-scoped rather than attributed per removal, matching upstream: a + /// persister holds every input of every transaction it deletes, so it + /// only needs to know which of them came free. Everything else it holds + /// stays spent. + /// + /// Deduplicated on merge, same reasoning as the removals themselves. + pub swept_released_outpoints: Vec, } /// One transaction the wallet removed, paired with the transaction whose /// arrival settled its inputs. /// -/// The pairing is the point. A persister deleting the removed row also has to -/// decide what to do with the coins that row claimed to spend, and the answer -/// depends entirely on the winner: the inputs it took are still spent, while -/// any *extra* inputs the loser named are not. Upstream keeps exactly that -/// split (`release_spent_marks`: "a loser spending A+B against a winner -/// spending only A must leave A marked and free B"), and a persister that -/// released everything would hand a coin the winner already consumed back to -/// the wallet as spendable. -/// -/// A winner that is itself wallet-relevant re-asserts its claim through -/// `records` in the same round, which is what lets a persister tell the two -/// apart without carrying the winner's input list: see the persistence -/// handlers' sweep paths. +/// The pairing is provenance, not policy: which of the removed transaction's +/// inputs actually came free is answered by +/// [`CoreChangeSet::swept_released_outpoints`], never by looking the winner up +/// — it need not be wallet-relevant, and even when it is, nothing guarantees +/// its record reaches a persister in the same round as the sweep. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SweptTransaction { @@ -392,6 +406,18 @@ impl Merge for CoreChangeSet { } } } + + // The released set folds the same way: a coalesced round frees a coin + // once however many sweeps named it. + if !other.swept_released_outpoints.is_empty() { + let mut seen: std::collections::HashSet = + self.swept_released_outpoints.iter().copied().collect(); + for outpoint in other.swept_released_outpoints { + if seen.insert(outpoint) { + self.swept_released_outpoints.push(outpoint); + } + } + } } fn is_empty(&self) -> bool { diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 492c54ef07c..86057ed83c7 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -716,6 +716,7 @@ async fn build_core_changeset( WalletEvent::TransactionsSwept { txids, superseded_by, + released_outpoints, .. } => { // The only subtractive event upstream emits. Each txid was a @@ -727,12 +728,15 @@ async fn build_core_changeset( // 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 only names the - // dead — and the winner alongside each, because the persister - // cannot decide what to do with the loser's inputs without it - // (upstream keeps the winner's share marked and frees the rest). + // 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, "Mirroring swept transactions to the persister" ); @@ -744,6 +748,7 @@ async fn build_core_changeset( superseded_by: *superseded_by, }) .collect(), + swept_released_outpoints: released_outpoints.clone(), ..CoreChangeSet::default() } } @@ -1191,11 +1196,23 @@ mod swept_transaction_projection_tests { Txid::from_byte_array([byte; 32]) } + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: txid(byte), + vout, + } + } + 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), + released_outpoints, balance: WalletCoreBalance::default(), account_balances: BTreeMap::new(), } @@ -1238,6 +1255,29 @@ mod swept_transaction_projection_tests { 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.swept_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.swept_released_outpoints.is_empty()); + } + #[tokio::test] async fn merged_sweeps_name_each_transaction_once() { let mut cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; @@ -1253,6 +1293,27 @@ mod swept_transaction_projection_tests { vec![txid(1), txid(2), txid(3)] ); } + + #[tokio::test] + async fn merged_sweeps_free_each_coin_once() { + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![outpoint(9, 0), outpoint(9, 1)]), + ) + .await; + let second = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(2)], vec![outpoint(9, 1), outpoint(9, 2)]), + ) + .await; + + cs.merge(second); + + assert_eq!( + cs.swept_released_outpoints, + vec![outpoint(9, 0), outpoint(9, 1), outpoint(9, 2)] + ); + } } #[cfg(test)] diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 46a20b58617..605068cdcf6 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -660,12 +660,37 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( env.set_object_array_element(&winners, i as i32, &winner) })?; } + // The released outpoints ride along as 36-byte keys (raw txid + + // little-endian vout), the same shape the handler stores them + // in. They are wallet-scoped, not per removal: the handler holds + // every input of every row it deletes, so it only needs to know + // which of them came free. + let released = slice_or_empty( + cs.swept_released_outpoints, + cs.swept_released_outpoints_count, + ); + let released_arr = + env.new_object_array(released.len() as i32, &byte_array_cls, &empty)?; + for (i, outpoint) in released.iter().enumerate() { + let mut key = [0u8; 36]; + key[..32].copy_from_slice(&outpoint.txid); + key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + env.with_local_frame(4, |env| { + let k = env.byte_array_from_slice(&key)?; + env.set_object_array_element(&released_arr, i as i32, &k) + })?; + } let code = env .call_method( bridge, "onWalletChangesetTransactionsSwept", - "([B[[B[[B)I", - &[(&wid).into(), (&txids).into(), (&winners).into()], + "([B[[B[[B[[B)I", + &[ + (&wid).into(), + (&txids).into(), + (&winners).into(), + (&released_arr).into(), + ], )? .i()?; if code != 0 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 9d52439d687..5b703de40bd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -839,12 +839,26 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // the removal below decides which links are left pointing at a // dead transaction. if cs.swept_count > 0, let sweptPtr = cs.swept { + // The coins the sweep freed, as the 36-byte keys the TXO rows + // are stored under. Wallet-scoped across the round's + // removals, matching the upstream event. + var released = Set() + if cs.swept_released_outpoints_count > 0, + let releasedPtr = cs.swept_released_outpoints { + for i in 0..) throws { var descriptor = FetchDescriptor( predicate: #Predicate { $0.txid == txid } ) @@ -922,14 +924,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // are idempotent and can name a transaction this store never had. guard let row = try backgroundContext.fetch(descriptor).first else { return } - var winnerDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == supersededBy } - ) - winnerDescriptor.fetchLimit = 1 - let winnerIsKnown = try backgroundContext.fetch(winnerDescriptor).first != nil - for txo in row.inputs { - txo.isSpent = !winnerIsKnown + txo.isSpent = !released.contains(txo.outpoint) txo.spendingTransaction = nil txo.lastUpdated = Date() } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 55cb5523e63..36fa391da9b 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -17,7 +17,8 @@ import DashSDKFFI /// 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. +/// re-entering the restore set, and only the released set upstream carries +/// says which of them belongs there. @MainActor final class SweptTransactionPersistTests: XCTestCase { @@ -122,7 +123,8 @@ final class SweptTransactionPersistTests: XCTestCase { @discardableResult private func sweep( _ handler: PlatformWalletPersistenceHandler, - _ pairs: [(loser: Data, winner: Data)] + _ pairs: [(loser: Data, winner: Data)], + released: [(txid: Data, vout: UInt32)] = [] ) -> Bool { var entries: [SweptTransactionFFI] = [] for pair in pairs { @@ -136,13 +138,27 @@ final class SweptTransactionPersistTests: XCTestCase { entries.append(entry) } + var freed: [OutPointFFI] = [] + for outpoint in released { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + freed.append(entry) + } + handler.beginChangeset(walletId: walletId) let applied = entries.withUnsafeMutableBufferPointer { buf -> Bool in - var cs = WalletChangeSetFFI() - cs.swept = buf.baseAddress - cs.swept_count = UInt(buf.count) - return withUnsafePointer(to: &cs) { csPtr in - handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + freed.withUnsafeMutableBufferPointer { freedBuf -> Bool in + var cs = WalletChangeSetFFI() + cs.swept = buf.baseAddress + cs.swept_count = UInt(buf.count) + cs.swept_released_outpoints = freedBuf.baseAddress + cs.swept_released_outpoints_count = UInt(freedBuf.count) + return withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } } } _ = handler.endChangeset(walletId: walletId, success: applied) @@ -172,21 +188,28 @@ final class SweptTransactionPersistTests: XCTestCase { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: true) - sweep(handler, [(loser: sweptTxid, winner: winnerTxid)]) + sweep( + handler, + [(loser: sweptTxid, winner: winnerTxid)], + 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") } - /// With the winner in the store, releasing what still points at the - /// loser frees exactly the loser's own input: the winner re-pointed the - /// shared one at itself earlier in the round. + /// 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, [(loser: sweptTxid, winner: winnerTxid)]) + sweep( + handler, + [(loser: sweptTxid, winner: winnerTxid)], + released: [(txid: fundingTxid, vout: 1)] + ) let takenByWinner = txo(container, txid: fundingTxid, vout: 0) XCTAssertNotNil(takenByWinner) @@ -199,32 +222,43 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertNil(losersOwn!.spendingTransaction) } - /// A winner that pays only to outside addresses sweeps the loser without - /// ever being recorded here. The loser was unconfirmed, so both coins it - /// named are linked to it and unspent — deleting it and stopping there - /// would return the one the chain already spent to the restore set. With - /// nothing to tell the two apart, both are held instead. - func testSweepByAnIrrelevantWinnerHoldsTheInputsOutOfTheRestoreSet() throws { + /// 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, [(loser: sweptTxid, winner: winnerTxid)]) + sweep( + handler, + [(loser: sweptTxid, winner: winnerTxid)], + released: [(txid: fundingTxid, vout: 1)] + ) XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") - for vout: UInt32 in [0, 1] { - let coin = txo(container, txid: fundingTxid, vout: vout) - XCTAssertNotNil(coin) - XCTAssertTrue( - coin!.isSpent, - "a coin the unrecorded winner may have consumed must not come back" - ) - XCTAssertNil(coin!.spendingTransaction, "and no spender is invented for it") - } + + 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") + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse( + losersOwn!.isSpent, + "the loser's own input is free, winner record or not" + ) } - /// The wallet decides which of the held coins are actually free: it - /// re-delivers them as UTXOs after a rescan, and that lifts the hold. - /// Without this the conservative branch above would be permanent. + /// A coin held spent with no spender is not a dead end: the wallet is + /// the authority on what it holds, so re-delivering the coin as a UTXO — + /// what a rescan does — lifts the mark. This is the backstop for a sweep + /// that released nothing, and for any older row left in that state. func testWalletReDeliveringAHeldCoinFreesIt() throws { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: false) From 4e0bd3a4c90fff52ca1af40a8fd3d67a4152657c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:59:47 +0300 Subject: [PATCH 006/102] fix(kotlin-sdk): do not free a coin a later transaction already re-claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `releaseByOutpoint` matched on the outpoint alone, so it cleared whatever spend claim the row happened to hold. 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 — so by the time the release ran the coin could already be claimed again. Clearing that claim put a spent coin back in the restore set, which is the failure the sweep handling exists to prevent. Restrict the update to rows with `spendingTxid IS NULL`. Paired with the existing hold-then-release order that is exactly the right set: holding detaches the rows this round's removals still claim, so only those qualify, while a row a live transaction claims keeps it. Swift never had this: `applySweptTransaction` walks `PersistentTransaction.inputs`, the inverse of `spendingTransaction`, so it only ever touches rows still pointing at the removed transaction. Keying the Kotlin query on the outpoint is what lost that property. --- .../PlatformWalletPersistenceHandler.kt | 6 ++ .../dashsdk/persistence/dao/TxoDao.kt | 17 +++- .../PlatformWalletPersistenceHandlerTest.kt | 92 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) 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 f3cbb43c7ce..181603d3eea 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 @@ -1035,6 +1035,12 @@ class PlatformWalletPersistenceHandler( // Hold every input first, then free the ones upstream named: the // released set is wallet-scoped across the round's removals, so // it is applied once rather than per transaction. + // + // The order is load-bearing, not cosmetic. Holding detaches the + // rows this round's removals still claim, and the release only + // touches detached rows — so a coin some later transaction in the + // same round already re-claimed keeps that claim instead of being + // freed out from under it. for (txid in txids) { db.txoDao().holdSpentWithoutSpender(txid) } 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 01f58e450b3..380fe1f0c09 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 @@ -63,14 +63,25 @@ interface TxoDao { /** * Mark one outpoint unspent again — a coin a sweep released, meaning no - * surviving transaction spends it. + * surviving transaction spent it *at the time the sweep was computed*. * * Keyed by outpoint rather than by spender because that is how upstream - * reports it: the transaction that took the *other* inputs may never be + * 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. + * + * `spendingTxid IS NULL` is what keeps that from overreaching. 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 — so by the time + * this runs the coin may already be claimed again. Only rows + * [holdSpentWithoutSpender] just detached qualify; anything a live + * transaction still claims keeps that claim. */ - @Query("UPDATE txos SET isSpent = 0, spendingTxid = NULL, spendingInputIndex = NULL WHERE outpoint = :outpoint") + @Query( + "UPDATE txos SET isSpent = 0, spendingInputIndex = NULL " + + "WHERE outpoint = :outpoint AND spendingTxid IS NULL", + ) suspend fun releaseByOutpoint(outpoint: ByteArray) @Upsert 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 0b8e5cca9e1..c2fa104d951 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 @@ -2217,6 +2217,98 @@ class PlatformWalletPersistenceHandlerTest { 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. + 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, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), arrayOf(freedCoin), + ) + 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 sweptTransactionRollsBackWithItsRound() = runTest { // The deletion is staged in the same buffered transaction as every From 6060cb4a9cbe987f47e3e1d85cf4052e3afd0982 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:00:56 +0300 Subject: [PATCH 007/102] fix(platform-wallet-storage): apply transaction sweeps in the SQLite persister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swept_transactions` became a non-empty part of `CoreChangeSet`, but `core_state::apply` never read it. A sweep-only changeset was therefore accepted and flushed successfully while the dead row stayed in `core_transactions`, the outputs it created stayed in `core_utxos`, and its input state was untouched — the subtractive guarantee simply did not hold for this first-party backend. It also left an InstantSend loser answerable through `get_core_tx_record`, which sent-payment reconciliation treats as final and can use to advance a dead DashPay payment to `Confirmed`. Apply each sweep in the same transaction as the rest of the round, after the additive writes: delete the removed transaction and the UTXOs it created, then resolve the coins it claimed to spend from `swept_released_outpoints` — an outpoint named there goes back to spendable, every other input it claimed stays spent because the transaction that beat it took them. Each input is written outright rather than only when it changes, since a coin the sweep did not free must end the round out of the unspent query even when nothing had marked it spent yet: upstream sweeps only unconfirmed records, whose spends this schema does not mark. --- .../src/sqlite/schema/core_state.rs | 91 ++++ .../tests/sqlite_transaction_sweeps.rs | 400 ++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 129819b0bce..7831d9fc7f3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -2,6 +2,7 @@ #[cfg(any(test, feature = "__test-helpers"))] use std::collections::BTreeMap; +use std::collections::HashSet; use rusqlite::{params, Connection, OptionalExtension, Transaction}; @@ -129,6 +130,96 @@ pub fn apply( if cs.last_processed_height.is_some() || cs.synced_height.is_some() { upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; } + // Sweeps run last so a winner arriving in this very changeset has its + // own rows committed before the removal below touches the coins it took. + if !cs.swept_transactions.is_empty() { + let released: HashSet = + cs.swept_released_outpoints.iter().copied().collect(); + for swept in &cs.swept_transactions { + apply_sweep(tx, wallet_id, &swept.txid, &released)?; + } + } + Ok(()) +} + +/// Delete a swept transaction's row and outputs, then resolve the coins it +/// claimed to spend. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm — the wallet +/// has already dropped it. Leaving the mirrored row in place would hand it +/// back at the next `load()` and replay a balance the wallet has already +/// corrected. It would also leave an InstantSend loser answerable through +/// `get_core_tx_record`, which sent-payment reconciliation reads as final and +/// would use to advance a dead DashPay payment to `Confirmed`. +/// +/// Deleting the row and the UTXOs it created is the easy half. The coins it +/// claimed to *spend* split in two, and `released` — computed upstream and +/// carried on the changeset — is the authority on which is which: an input +/// named there came free, because no surviving transaction spends it too; +/// every other input the loser claimed was taken by the transaction that beat +/// it and is gone for good. +/// +/// Recomputing that split here is not an option even though this schema +/// stores whole records. The transaction that took the rest need not be +/// wallet-relevant at all — it can spend our coin while paying only external +/// addresses, and then it is never recorded anywhere in this store — and even +/// a relevant one is not guaranteed to arrive in the same round as the sweep. +/// +/// Idempotent: a txid this store never recorded is a successful no-op, not an +/// error. A sweep can legitimately name a transaction this wallet dropped, or +/// never derived an address for in the first place. +fn apply_sweep( + tx: &Transaction<'_>, + wallet_id: &WalletId, + loser_txid: &dashcore::Txid, + released: &HashSet, +) -> Result<(), WalletStorageError> { + let loser_blob: Option> = tx + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + |row| row.get(0), + ) + .optional()?; + let Some(loser_blob) = loser_blob else { + return Ok(()); + }; + let loser: TransactionRecord = blob::decode(&loser_blob)?; + + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; + let mut delete_output_stmt = + tx.prepare_cached("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2")?; + for vout in 0..loser.transaction.output.len() as u32 { + let op = blob::encode_outpoint(&dashcore::OutPoint { + txid: *loser_txid, + vout, + })?; + delete_output_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; + } + drop(delete_output_stmt); + + // Each input is set outright rather than only touched when it changes: + // whichever way it went, the row must end this round agreeing with the + // wallet, and a coin the sweep did not free stays out of the unspent + // query even if nothing had marked it spent yet (upstream sweeps only + // unconfirmed records, whose spends this schema does not mark). + let mut spend_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = ?3 WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + for input in &loser.transaction.input { + let outpoint = input.previous_output; + let key = blob::encode_outpoint(&outpoint)?; + spend_stmt.execute(params![ + wallet_id.as_slice(), + &key[..], + !released.contains(&outpoint) + ])?; + } + Ok(()) } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs new file mode 100644 index 00000000000..150a820fb10 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -0,0 +1,400 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Coverage for `core_state::apply`'s handling of `CoreChangeSet::swept_transactions` +//! (the subtractive sweep-removal field — see `core_state.rs::apply_sweep`). +//! +//! Exercises the writer directly through `core_state::apply` on a hand-rolled +//! `rusqlite::Transaction`, same style as `sqlite_structural_hardening.rs`, so +//! each case can pre-seed exactly the rows a sweep needs to reason about +//! without going through the full changeset-merge/buffer machinery. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +use dashcore::hashes::Hash; +use dashcore::{Address, Network, OutPoint, Transaction, TxIn, TxOut, Txid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use key_wallet::Utxo; +use platform_wallet::changeset::changeset::SweptTransaction; +use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_state; +use rusqlite::params; + +fn p2pkh(byte: u8) -> Address { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + let hash = PubkeyHash::from_byte_array([byte; 20]); + Address::new(Network::Testnet, Payload::PubkeyHash(hash)) +} + +fn make_utxo(addr: &Address, txid: Txid, vout: u32, value: u64) -> Utxo { + let outpoint = OutPoint::new(txid, vout); + let txout = TxOut { + value, + script_pubkey: addr.script_pubkey(), + }; + Utxo::new(outpoint, txout, addr.clone(), 10, false) +} + +fn derive_address(conn: &rusqlite::Connection, w: &WalletId, account_index: u32, addr: &Address) { + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, 'standard', ?2, ?3, '0/0', 0)", + params![w.as_slice(), account_index as i64, addr.to_string()], + ) + .unwrap(); +} + +/// Build a `TransactionRecord` whose `transaction.input`/`.output` are the +/// real, decodable fields `apply_sweep` reads back for its outpoint math — +/// as opposed to `input_details`/`output_details`, which only cover the +/// wallet-relevant subset and are left empty here on purpose. +fn tx_record(txid: Txid, inputs: Vec, outputs: Vec) -> TransactionRecord { + let inner = Transaction { + version: 3, + lock_time: 0, + input: inputs + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .collect(), + output: outputs, + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + inner, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record +} + +fn unspent(conn: &rusqlite::Connection, w: &WalletId) -> std::collections::BTreeSet { + core_state::list_unspent_utxos(conn, w) + .unwrap() + .into_values() + .flatten() + .map(|row| row.outpoint) + .collect() +} + +fn row_exists(conn: &rusqlite::Connection, w: &WalletId, op: &OutPoint) -> bool { + let bytes = platform_wallet_storage::sqlite::schema::blob::encode_outpoint(op).unwrap(); + conn.query_row( + "SELECT 1 FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |_| Ok(()), + ) + .optional() + .unwrap() + .is_some() +} + +use rusqlite::OptionalExtension; + +/// A changeset carrying nothing but a sweep still deletes: the loser's +/// `core_transactions` row and every `core_utxos` row it created go, even +/// though `records` / `new_utxos` / everything else on the changeset is +/// empty. This is the guard against the bug the review finding described — +/// `apply` skipping `swept_transactions` entirely because every other +/// `if !cs..is_empty()` block was false. +#[test] +fn sweep_only_changeset_deletes_loser_row_and_its_outputs() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE0); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x01); + let loser_txid = Txid::from_byte_array([0x10; 32]); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let loser_output = OutPoint::new(loser_txid, 0); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + new_utxos: vec![make_utxo(&addr, loser_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &loser_output), + "sanity: the loser's output must exist before the sweep" + ); + } + + // The sweep-only round: nothing else populated on the changeset. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + swept_transactions: vec![SweptTransaction { + txid: loser_txid, + superseded_by: Txid::from_byte_array([0x11; 32]), + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let record: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(record.is_none(), "swept transaction row must be gone"); + assert!( + !row_exists(&conn, &w, &loser_output), + "the swept transaction's own output must be gone" + ); +} + +/// A sweep naming a txid this store never recorded is a successful +/// no-op — sweeps are idempotent and can arrive for a transaction this +/// wallet dropped, or ran again after the first sweep already applied. +#[test] +fn sweeping_an_unknown_txid_is_a_no_op() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + swept_transactions: vec![SweptTransaction { + txid: Txid::from_byte_array([0x20; 32]), + superseded_by: Txid::from_byte_array([0x21; 32]), + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).expect("unknown txid must not error"); + tx.commit().unwrap(); +} + +/// The released set is applied verbatim: an outpoint it names becomes +/// spendable again, and every other input the loser claimed stays out of +/// the unspent set because the transaction that beat the loser took it. +#[test] +fn the_released_set_frees_exactly_the_inputs_it_names() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE2); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x02); + let funding_txid = Txid::from_byte_array([0x30; 32]); + let shared_input = OutPoint::new(funding_txid, 0); + let exclusive_input = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x31; 32]); + let winner_txid = Txid::from_byte_array([0x32; 32]); + + let loser = tx_record( + loser_txid, + vec![shared_input, exclusive_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + // The winner only claimed the shared input. + let winner = tx_record( + winner_txid, + vec![shared_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + + // Fund both inputs as ordinary unspent UTXOs, then record the loser + // spending both (mirroring the ordinary flow before it was swept). + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // Record the winner, which re-claims only the shared input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![winner], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Sanity: before the sweep, neither input shows up as unspent. + assert!(!unspent(&conn, &w).contains(&shared_input)); + assert!(!unspent(&conn, &w).contains(&exclusive_input)); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + swept_transactions: vec![SweptTransaction { + txid: loser_txid, + superseded_by: winner_txid, + }], + swept_released_outpoints: vec![exclusive_input], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let after = unspent(&conn, &w); + assert!( + after.contains(&exclusive_input), + "an outpoint the sweep released must come back as spendable" + ); + assert!( + !after.contains(&shared_input), + "shared input stays spent — the winner took it" + ); +} + +/// The winner does not have to reach this store at all: it can spend our +/// coin while paying only external addresses, and then no record for it is +/// ever written here. The released set still resolves both inputs +/// correctly, which is the whole reason it is carried rather than +/// recomputed from the rows on hand. +#[test] +fn an_absent_winner_still_keeps_its_own_input_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE3); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x03); + let funding_txid = Txid::from_byte_array([0x40; 32]); + let taken_by_winner = OutPoint::new(funding_txid, 0); + let loser_exclusive = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x41; 32]); + let unrecorded_winner_txid = Txid::from_byte_array([0x42; 32]); + + let loser = tx_record( + loser_txid, + vec![taken_by_winner, loser_exclusive], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + // `superseded_by` never arrives in this store; upstream still + // knows which of the loser's inputs it did not take. + swept_transactions: vec![SweptTransaction { + txid: loser_txid, + superseded_by: unrecorded_winner_txid, + }], + swept_released_outpoints: vec![loser_exclusive], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let after = unspent(&conn, &w); + assert!( + !after.contains(&taken_by_winner), + "a coin the chain has already spent must not return as spendable" + ); + assert!( + after.contains(&loser_exclusive), + "the loser's own input is free, winner record or not" + ); + // Both rows survive either way — held or freed, never deleted. + assert!(row_exists(&conn, &w, &taken_by_winner)); + assert!(row_exists(&conn, &w, &loser_exclusive)); +} From b172c0a0344f2aa9538ee2c989240e3e610e82e8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:07:30 +0300 Subject: [PATCH 008/102] fix(platform-wallet-storage): defer a release to a surviving record's claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the SQLite sweep, both found in review. The release was applied unconditionally. 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 — so the coin could already be claimed again by the time the sweep ran, and setting `spent = 0` handed a consumed coin back to the unspent query. The mobile mirrors settle this by looking at who currently claims the row, but `core_utxos` never records that: `spent_in_txid` stays null on every write path. The changeset carries the answer instead — a record in this round that is not itself being swept and spends a released outpoint is the live claim — so the release now defers to it. This is the SQLite half of the same defect fixed on the Kotlin side by `spendingTxid IS NULL`. Second, a swept transaction's InstantLock row survived it. A chainlocked winner may evict an InstantSend-locked loser, so a swept transaction can own a row in `core_instant_locks`, and nothing ties that table to `core_transactions` — no foreign key, no trigger. Delete it in the same transaction. Both regressions are covered, and both tests were confirmed to fail without their fix. --- .../src/sqlite/schema/core_state.rs | 36 +++- .../tests/sqlite_transaction_sweeps.rs | 166 ++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 7831d9fc7f3..5486f21d3e2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -133,8 +133,32 @@ pub fn apply( // Sweeps run last so a winner arriving in this very changeset has its // own rows committed before the removal below touches the coins it took. if !cs.swept_transactions.is_empty() { - let released: HashSet = - cs.swept_released_outpoints.iter().copied().collect(); + // The released set describes the wallet when each sweep was emitted, + // and a round can fold in a later transaction that legitimately spent + // one of the freed coins. `core_utxos` never records *who* spent a + // row (`spent_in_txid` stays null on every write path), so unlike the + // mobile mirrors this cannot tell a live claim from the dead one by + // looking at the table — but the changeset carries the answer: any + // record in this round that is not itself being swept and spends a + // released outpoint is that live claim, and the coin stays spent. + let swept_txids: HashSet = cs + .swept_transactions + .iter() + .map(|swept| swept.txid) + .collect(); + let claimed_by_survivors: HashSet = cs + .records + .iter() + .filter(|record| !swept_txids.contains(&record.txid)) + .flat_map(|record| record.transaction.input.iter()) + .map(|input| input.previous_output) + .collect(); + let released: HashSet = cs + .swept_released_outpoints + .iter() + .filter(|outpoint| !claimed_by_survivors.contains(outpoint)) + .copied() + .collect(); for swept in &cs.swept_transactions { apply_sweep(tx, wallet_id, &swept.txid, &released)?; } @@ -191,6 +215,14 @@ fn apply_sweep( "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], )?; + // An InstantSend-locked loser is evictable by a chainlocked winner, so a + // swept transaction can own a row here. Nothing ties that table to + // `core_transactions` — no foreign key, no trigger — so the lock would + // outlive the transaction it describes forever. + tx.execute( + "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; let mut delete_output_stmt = tx.prepare_cached("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2")?; for vout in 0..loser.transaction.output.len() as u32 { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 150a820fb10..5ced8c02497 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -398,3 +398,169 @@ fn an_absent_winner_still_keeps_its_own_input_spent() { assert!(row_exists(&conn, &w, &taken_by_winner)); assert!(row_exists(&conn, &w, &loser_exclusive)); } + +/// A round can carry both a release and a later transaction that legitimately +/// spends the freed coin: merging folds several events together, and every +/// record is applied before sweeps. `core_utxos` never records who spent a +/// row, so the release has to defer to the surviving record in the changeset +/// itself — otherwise it hands a coin the later transaction consumed back to +/// the unspent set. +#[test] +fn a_released_coin_a_surviving_record_reclaims_stays_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE4); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x04); + let funding_txid = Txid::from_byte_array([0x50; 32]); + let freed_coin = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x51; 32]); + let winner_txid = Txid::from_byte_array([0x52; 32]); + let reclaimer_txid = Txid::from_byte_array([0x53; 32]); + + let loser = tx_record( + loser_txid, + vec![OutPoint::new(funding_txid, 0), freed_coin], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let reclaimer = tx_record( + reclaimer_txid, + vec![freed_coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // One round: the sweep frees the coin, and a surviving record in the very + // same round already spent it. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![reclaimer], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + swept_transactions: vec![SweptTransaction { + txid: loser_txid, + superseded_by: winner_txid, + }], + swept_released_outpoints: vec![freed_coin], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !unspent(&conn, &w).contains(&freed_coin), + "a coin a surviving record in the same round already spent must stay spent" + ); +} + +/// A chainlocked winner may evict an InstantSend-locked loser, so a swept +/// transaction can own a row in `core_instant_locks`. Nothing ties that table +/// to `core_transactions`, so the lock has to be deleted explicitly or it +/// outlives the transaction it describes forever. +#[test] +fn sweeping_a_transaction_deletes_its_instant_lock() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE5); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x05); + let loser_txid = Txid::from_byte_array([0x60; 32]); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.execute( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, ?3)", + params![ + w.as_slice(), + AsRef::<[u8]>::as_ref(&loser_txid), + vec![0u8; 8] + ], + ) + .unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + instant_lock_count(&conn, &w, &loser_txid), + 1, + "sanity: the lock is there" + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + swept_transactions: vec![SweptTransaction { + txid: loser_txid, + superseded_by: Txid::from_byte_array([0x61; 32]), + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + instant_lock_count(&conn, &w, &loser_txid), + 0, + "the swept transaction's InstantLock must go with it" + ); +} + +fn instant_lock_count(conn: &rusqlite::Connection, w: &WalletId, txid: &Txid) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(txid)], + |row| row.get(0), + ) + .unwrap() +} From 04a76c4812418f46af42094edcc6ea69cfda6a39 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:45:01 +0300 Subject: [PATCH 009/102] fix: keep sweeps as ordered batches instead of one folded release set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release is only true of the wallet the sweep that made it saw — it is not a property of the whole drain. The adapter folds every event buffered in one pass into a single changeset, so two sweeps that disagree were being reconciled by unioning their release sets, and the earlier answer won. The shape that breaks: a sweep frees B, a later transaction spends B, and a final winner consumes B while sweeping that spender. The second sweep frees nothing, precisely because its winner took B. Unioned, B stays in the release set; the spender is in `swept_transactions`, so SQLite excludes it from `claimed_by_survivors` and the mobile handlers detach its claim before applying the same global set. All three backends then persist a coin the chain consumed as spendable. Replace `swept_transactions` + `swept_released_outpoints` with `sweeps: Vec`, each carrying its own removals, winner and release set, merged by appending rather than folding. Every backend applies them in sequence, so a later batch corrects the one before it — which is what the wallet itself did. The FFI mirrors the nesting (`SweepBatchFFI`), and JNI now makes one bridge call per batch, so the Kotlin handler's signature is unchanged and its existing hold-then-release gives the ordering for free. Regression coverage on all three backends plus the merge itself, each confirmed to fail against the folded set. --- .../PlatformWalletPersistenceHandlerTest.kt | 77 ++++++++ .../src/core_wallet_types.rs | 174 +++++++++--------- .../src/sqlite/schema/core_state.rs | 24 ++- .../tests/sqlite_transaction_sweeps.rs | 125 +++++++++++-- .../src/changeset/changeset.rs | 111 ++++------- .../src/changeset/core_bridge.rs | 93 +++++----- .../rs-unified-sdk-jni/src/persistence.rs | 47 ++--- .../PlatformWalletPersistenceHandler.swift | 66 ++++--- .../SweptTransactionPersistTests.swift | 168 ++++++++++++----- 9 files changed, 554 insertions(+), 331 deletions(-) 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 c2fa104d951..0999b330fd8 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 @@ -2309,6 +2309,83 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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. + 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, + ), + ) + + val fundingTxid = ByteArray(32) { 70 } + val firstLoser = ByteArray(32) { 71 } + val secondLoser = ByteArray(32) { 72 } + val contested = makeOutpoint(fundingTxid, 0) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(firstLoser), arrayOf(ByteArray(32) { 73 }), arrayOf(contested), + ) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(ByteArray(32) { 74 }), emptyArray(), + ) + 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(), + ) + } + @Test fun sweptTransactionRollsBackWithItsRound() = runTest { // The deletion is staged in the same buffered transaction as every diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 1dc22af2e39..e43a77caa3a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -236,56 +236,43 @@ pub struct WalletChangeSetFFI { /// `proof.rs` can't fire until SPV re-applies a fresh CL). pub last_applied_chain_lock_bytes: *mut u8, pub last_applied_chain_lock_bytes_len: usize, - /// Transactions the wallet removed this round. + /// Sweeps this round carries, in the order the wallet emitted them. /// - /// The only subtractive field in this struct. Each named transaction was - /// a recorded spend that a later, final transaction beat to one of its - /// inputs, so it can never confirm; the wallet has already dropped it. - /// **A persister must act on this**: delete the transaction row and any - /// UTXO row it created. Every other field here is additive, so ignoring - /// this one leaves dead rows that are handed back at the next load and - /// re-create a balance the wallet has already corrected. + /// The only subtractive field in this struct, and the only one whose + /// order matters: each entry describes the wallet as that sweep saw it, + /// and a later entry can keep a coin spent that an earlier one freed. + /// **A persister must apply them in sequence** — folding them together + /// lets the first answer outlive the last one that is actually true. /// - /// `null` / `0` when nothing was swept, which is the overwhelmingly - /// common case. - pub swept: *mut SweptTransactionFFI, - pub swept_count: usize, - /// Of the inputs those removed transactions claimed, the ones that came - /// free — no surviving transaction spends them too. + /// Every other field here is additive, so ignoring this one leaves dead + /// rows that are handed back at the next load and re-create a balance + /// the wallet has already corrected. /// - /// A persister holds every input of every row it deletes, so this is the - /// only thing that tells it which of them to hand back as spendable; - /// everything else it holds stays spent. It cannot be derived from - /// `swept`: the transaction that took the rest may never reach this store - /// at all, since a winner paying entirely to outside addresses is never - /// wallet-relevant, and even a relevant one is not guaranteed to arrive - /// in the same round as the sweep. - /// - /// `null` / `0` when a sweep freed nothing — the ordinary resend, where - /// the winner took every input the removed transaction named. - pub swept_released_outpoints: *mut OutPointFFI, - pub swept_released_outpoints_count: usize, + /// `null` / `0` when the round swept nothing, the overwhelmingly common + /// case. + pub sweeps: *mut SweepBatchFFI, + pub sweeps_count: usize, } -/// One removed transaction and the transaction that settled its inputs. -/// -/// Both raw 32-byte txids, internal byte order, same as every other txid on -/// this surface. +/// One sweep: the transactions it removed, the transaction that beat them, +/// and the coins its removal actually freed. #[repr(C)] -pub struct SweptTransactionFFI { - /// The removed transaction: delete this row and the UTXOs it created. - pub txid: [u8; 32], +pub struct SweepBatchFFI { + /// Removed transactions, raw 32-byte txids. Delete these rows and every + /// UTXO they created. + pub txids: *mut [u8; 32], + pub txids_count: usize, /// The transaction whose arrival settled the inputs. Final, and not /// necessarily wallet-relevant — it can pay entirely to outside - /// addresses and still sweep, in which case no record for it reaches - /// the persister at all. - /// - /// It decides what happens to the coins the removed transaction claimed - /// to spend: the ones this transaction took are still spent, and only - /// the loser's *extra* inputs are free. A persister that released every - /// input would hand a coin the winner already consumed back to the - /// wallet as spendable. + /// addresses and never reach this store at all, which is why what it + /// took cannot be worked out by looking it up. pub superseded_by: [u8; 32], + /// Of the inputs the removed transactions claimed, the ones that came + /// free. Everything else they claimed was taken by `superseded_by` and + /// stays spent — a persister holds every input of what it deletes, so + /// this is the only thing telling it which to hand back. + pub released_outpoints: *mut OutPointFFI, + pub released_outpoints_count: usize, } // --------------------------------------------------------------------------- @@ -500,40 +487,51 @@ impl WalletChangeSetFFI { None => (std::ptr::null_mut(), 0), }; - // Swept transactions travel at the top level, not per account: - // the upstream event is wallet-scoped, and the persister deletes - // by txid — the row it deletes carries its own account link. - let swept: Vec = cs - .swept_transactions + // Sweeps travel at the top level, not per account: the upstream + // events are wallet-scoped, and the persister deletes by txid — the + // row it deletes carries its own account link. Order is preserved. + let sweeps: Vec = cs + .sweeps .iter() - .map(|swept| { - let mut txid = [0u8; 32]; - txid.copy_from_slice(swept.txid.as_ref()); + .map(|batch| { + let txids: Vec<[u8; 32]> = batch + .txids + .iter() + .map(|txid| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(txid.as_ref()); + raw + }) + .collect(); + let txids_count = txids.len(); + + let released: Vec = batch + .released_outpoints + .iter() + .map(|outpoint| { + let mut txid = [0u8; 32]; + txid.copy_from_slice(outpoint.txid.as_ref()); + OutPointFFI { + txid, + vout: outpoint.vout, + } + }) + .collect(); + let released_outpoints_count = released.len(); + let mut superseded_by = [0u8; 32]; - superseded_by.copy_from_slice(swept.superseded_by.as_ref()); - SweptTransactionFFI { - txid, - superseded_by, - } - }) - .collect(); - let swept_count = swept.len(); + superseded_by.copy_from_slice(batch.superseded_by.as_ref()); - // Wallet-scoped like the removals themselves: which removal freed - // which coin does not matter to a persister that holds all of them. - let released: Vec = cs - .swept_released_outpoints - .iter() - .map(|outpoint| { - let mut txid = [0u8; 32]; - txid.copy_from_slice(outpoint.txid.as_ref()); - OutPointFFI { - txid, - vout: outpoint.vout, + SweepBatchFFI { + txids: vec_to_ptr(txids), + txids_count, + superseded_by, + released_outpoints: vec_to_ptr(released), + released_outpoints_count, } }) .collect(); - let swept_released_outpoints_count = released.len(); + let sweeps_count = sweeps.len(); WalletChangeSetFFI { has_chain, @@ -544,10 +542,8 @@ impl WalletChangeSetFFI { accounts_count, last_applied_chain_lock_bytes, last_applied_chain_lock_bytes_len, - swept: vec_to_ptr(swept), - swept_count, - swept_released_outpoints: vec_to_ptr(released), - swept_released_outpoints_count, + sweeps: vec_to_ptr(sweeps), + sweeps_count, } } } @@ -1799,18 +1795,28 @@ pub unsafe fn free_wallet_changeset_ffi(cs: &WalletChangeSetFFI) { // Before the accounts early-return below: a sweep-only round carries // no accounts at all, and its buffers still have to be released. - if !cs.swept.is_null() && cs.swept_count > 0 { - drop(Vec::from_raw_parts( - cs.swept, - cs.swept_count, - cs.swept_count, - )); - } - if !cs.swept_released_outpoints.is_null() && cs.swept_released_outpoints_count > 0 { + if !cs.sweeps.is_null() && cs.sweeps_count > 0 { + let batches = std::slice::from_raw_parts(cs.sweeps, cs.sweeps_count); + for batch in batches { + if !batch.txids.is_null() && batch.txids_count > 0 { + drop(Vec::from_raw_parts( + batch.txids, + batch.txids_count, + batch.txids_count, + )); + } + if !batch.released_outpoints.is_null() && batch.released_outpoints_count > 0 { + drop(Vec::from_raw_parts( + batch.released_outpoints, + batch.released_outpoints_count, + batch.released_outpoints_count, + )); + } + } drop(Vec::from_raw_parts( - cs.swept_released_outpoints, - cs.swept_released_outpoints_count, - cs.swept_released_outpoints_count, + cs.sweeps, + cs.sweeps_count, + cs.sweeps_count, )); } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 5486f21d3e2..7bdb4392600 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -131,20 +131,24 @@ pub fn apply( upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; } // Sweeps run last so a winner arriving in this very changeset has its - // own rows committed before the removal below touches the coins it took. - if !cs.swept_transactions.is_empty() { - // The released set describes the wallet when each sweep was emitted, + // own rows committed before the removal below touches the coins it took, + // and batch by batch in order: each sweep is only true of the wallet it + // saw, so a later one keeping a coin spent has to be able to correct an + // earlier one that freed it. + for batch in &cs.sweeps { + // The released set describes the wallet when this sweep was emitted, // and a round can fold in a later transaction that legitimately spent // one of the freed coins. `core_utxos` never records *who* spent a // row (`spent_in_txid` stays null on every write path), so unlike the // mobile mirrors this cannot tell a live claim from the dead one by // looking at the table — but the changeset carries the answer: any - // record in this round that is not itself being swept and spends a + // record in this round that is not swept by *any* batch and spends a // released outpoint is that live claim, and the coin stays spent. let swept_txids: HashSet = cs - .swept_transactions + .sweeps .iter() - .map(|swept| swept.txid) + .flat_map(|b| b.txids.iter()) + .copied() .collect(); let claimed_by_survivors: HashSet = cs .records @@ -153,14 +157,14 @@ pub fn apply( .flat_map(|record| record.transaction.input.iter()) .map(|input| input.previous_output) .collect(); - let released: HashSet = cs - .swept_released_outpoints + let released: HashSet = batch + .released_outpoints .iter() .filter(|outpoint| !claimed_by_survivors.contains(outpoint)) .copied() .collect(); - for swept in &cs.swept_transactions { - apply_sweep(tx, wallet_id, &swept.txid, &released)?; + for loser_txid in &batch.txids { + apply_sweep(tx, wallet_id, loser_txid, &released)?; } } Ok(()) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 5ced8c02497..1fbc395197b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -18,7 +18,7 @@ use key_wallet::account::{AccountType, StandardAccountType}; use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; use key_wallet::transaction_checking::{TransactionContext, TransactionType}; use key_wallet::Utxo; -use platform_wallet::changeset::changeset::SweptTransaction; +use platform_wallet::changeset::changeset::SweepBatch; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet_storage::sqlite::schema::core_state; @@ -159,9 +159,10 @@ fn sweep_only_changeset_deletes_loser_row_and_its_outputs() { let mut conn = persister.lock_conn_for_test(); let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { - swept_transactions: vec![SweptTransaction { - txid: loser_txid, + sweeps: vec![SweepBatch { + txids: vec![loser_txid], superseded_by: Txid::from_byte_array([0x11; 32]), + released_outpoints: vec![], }], ..Default::default() }; @@ -197,9 +198,10 @@ fn sweeping_an_unknown_txid_is_a_no_op() { let mut conn = persister.lock_conn_for_test(); let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { - swept_transactions: vec![SweptTransaction { - txid: Txid::from_byte_array([0x20; 32]), + sweeps: vec![SweepBatch { + txids: vec![Txid::from_byte_array([0x20; 32])], superseded_by: Txid::from_byte_array([0x21; 32]), + released_outpoints: vec![], }], ..Default::default() }; @@ -291,11 +293,11 @@ fn the_released_set_frees_exactly_the_inputs_it_names() { { let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { - swept_transactions: vec![SweptTransaction { - txid: loser_txid, + sweeps: vec![SweepBatch { + txids: vec![loser_txid], superseded_by: winner_txid, + released_outpoints: vec![exclusive_input], }], - swept_released_outpoints: vec![exclusive_input], ..Default::default() }; core_state::apply(&tx, &w, &cs).unwrap(); @@ -374,11 +376,11 @@ fn an_absent_winner_still_keeps_its_own_input_spent() { let cs = CoreChangeSet { // `superseded_by` never arrives in this store; upstream still // knows which of the loser's inputs it did not take. - swept_transactions: vec![SweptTransaction { - txid: loser_txid, + sweeps: vec![SweepBatch { + txids: vec![loser_txid], superseded_by: unrecorded_winner_txid, + released_outpoints: vec![loser_exclusive], }], - swept_released_outpoints: vec![loser_exclusive], ..Default::default() }; core_state::apply(&tx, &w, &cs).unwrap(); @@ -471,11 +473,11 @@ fn a_released_coin_a_surviving_record_reclaims_stays_spent() { let cs = CoreChangeSet { records: vec![reclaimer], spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], - swept_transactions: vec![SweptTransaction { - txid: loser_txid, + sweeps: vec![SweepBatch { + txids: vec![loser_txid], superseded_by: winner_txid, + released_outpoints: vec![freed_coin], }], - swept_released_outpoints: vec![freed_coin], ..Default::default() }; core_state::apply(&tx, &w, &cs).unwrap(); @@ -539,9 +541,10 @@ fn sweeping_a_transaction_deletes_its_instant_lock() { { let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { - swept_transactions: vec![SweptTransaction { - txid: loser_txid, + sweeps: vec![SweepBatch { + txids: vec![loser_txid], superseded_by: Txid::from_byte_array([0x61; 32]), + released_outpoints: vec![], }], ..Default::default() }; @@ -564,3 +567,93 @@ fn instant_lock_count(conn: &rusqlite::Connection, w: &WalletId, txid: &Txid) -> ) .unwrap() } + +/// Two sweeps in one round, and the later one disagrees with the earlier. +/// +/// The first frees a coin; a transaction then spends it; the second sweep +/// removes that spender but keeps the coin spent, because its own winner +/// took it. The later answer is the true one, and only replaying the batches +/// in order makes it stick — folding the release sets together leaves the +/// first "free" outliving the last "spent". +#[test] +fn a_later_sweep_keeping_a_coin_spent_overrides_an_earlier_release() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE6); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x06); + let funding_txid = Txid::from_byte_array([0x70; 32]); + let contested = OutPoint::new(funding_txid, 0); + + let first_loser = Txid::from_byte_array([0x71; 32]); + let second_loser = Txid::from_byte_array([0x72; 32]); + + let first = tx_record( + first_loser, + vec![contested], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + // The transaction that took the freed coin, and that the second sweep + // removes. It is a loser too, so it is not a surviving claim. + let second = tx_record( + second_loser, + vec![contested], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![first, second], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![ + SweepBatch { + txids: vec![first_loser], + superseded_by: Txid::from_byte_array([0x7a; 32]), + released_outpoints: vec![contested], + }, + // The second winner consumed the coin, so this sweep frees + // nothing — and that has to override the release above. + SweepBatch { + txids: vec![second_loser], + superseded_by: Txid::from_byte_array([0x7b; 32]), + released_outpoints: vec![], + }, + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !unspent(&conn, &w).contains(&contested), + "the later sweep kept the coin spent, so it must not be spendable" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 6b6abf25da0..c0f4797c811 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -63,7 +63,7 @@ use crate::wallet::identity::{ /// /// Built by the platform-wallet event adapter from `WalletEvent` variants /// emitted by `WalletManager`. Every field is additive except -/// [`Self::swept_transactions`] — the merge implementation uses last-write-wins for +/// [`Self::sweeps`] — the merge implementation uses last-write-wins for /// the height watermarks (monotonic-max), `extend` for the records / utxos /// vecs, and last-write-wins for the IS-lock map. /// @@ -198,60 +198,46 @@ pub struct CoreChangeSet { /// strictly forward-advancing per upstream's contract). pub last_applied_chain_lock: Option, - /// Transactions the wallet **removed**: each was a recorded spend that a - /// later, final transaction provably beat to one of its inputs, so it can - /// never confirm. From `WalletEvent::TransactionsSwept`. + /// Sweeps this batch carries, in the order the wallet emitted them. /// - /// The one subtractive field on this type. Every other field is additive, + /// The one subtractive part of this type. Every other field is additive, /// which is exactly why this one has to exist: a persister that only ever /// appends keeps the dead rows and replays them on the next load, /// re-creating a balance the wallet has already corrected. /// - /// Deduplicated on merge by the removed txid: a sweep is idempotent, and a - /// flush can fold several sweeps together. - pub swept_transactions: Vec, - - /// Of the inputs those removed transactions claimed, the ones that came - /// free — no surviving record spends them too. From - /// `WalletEvent::TransactionsSwept.released_outpoints`. - /// - /// The other half of the sweep, and the half a persister cannot work out - /// for itself. Deleting a removed transaction leaves its inputs in two - /// kinds: those a surviving transaction also took, which are gone, and - /// those only the dead one named, which are spendable again. Upstream - /// draws that line (`release_spent_marks`: "a loser spending A+B against - /// a winner spending only A must leave A marked and free B") and reports - /// the result here, because the survivor may be invisible to this wallet - /// — it can spend our coin while paying only external addresses, and - /// then it never appears in the event stream at all. - /// - /// Wallet-scoped rather than attributed per removal, matching upstream: a - /// persister holds every input of every transaction it deletes, so it - /// only needs to know which of them came free. Everything else it holds - /// stays spent. - /// - /// Deduplicated on merge, same reasoning as the removals themselves. - pub swept_released_outpoints: Vec, + /// Kept as ordered batches rather than folded into one removal list plus + /// one release set. Each sweep describes the wallet at the moment it + /// fired, and those descriptions can disagree: an early sweep frees a + /// coin, something later spends it, and a later sweep removes that + /// spender while keeping the coin spent because its own winner took it. + /// Union the release sets and the first answer outlives the last one that + /// is actually true. Applied in order, each batch corrects the one before + /// it, which is what the wallet itself did. + pub sweeps: Vec, } -/// One transaction the wallet removed, paired with the transaction whose -/// arrival settled its inputs. +/// One `TransactionsSwept` event: the transactions it removed, the +/// transaction that beat them, and the coins its removal actually freed. /// -/// The pairing is provenance, not policy: which of the removed transaction's -/// inputs actually came free is answered by -/// [`CoreChangeSet::swept_released_outpoints`], never by looking the winner up -/// — it need not be wallet-relevant, and even when it is, nothing guarantees -/// its record reaches a persister in the same round as the sweep. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// The grouping is what makes ordering expressible. `released_outpoints` is +/// only true relative to the wallet as this event saw it, so it belongs with +/// the removals it came from rather than in a set shared with every other +/// sweep in the batch. +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct SweptTransaction { - /// The removed transaction. Its row and every UTXO it created go. - pub txid: Txid, +pub struct SweepBatch { + /// The removed transactions. Their rows and every UTXO they created go. + pub txids: Vec, /// The transaction whose arrival settled the inputs — final, and - /// therefore the reason the removed one can never confirm. Not - /// necessarily wallet-relevant: it can pay entirely to outside - /// addresses and still sweep. + /// therefore the reason the removed ones can never confirm. Not + /// necessarily wallet-relevant: it can pay entirely to outside addresses + /// and still sweep, which is why it cannot be looked up to work out what + /// it took. pub superseded_by: Txid, + /// Of the inputs those removed transactions claimed, the ones that came + /// free — no surviving transaction spends them too. Everything else they + /// claimed was taken by `superseded_by` and stays spent. + pub released_outpoints: Vec, } /// Highest-used derivation index per pool slot for one account, as @@ -388,41 +374,16 @@ impl Merge for CoreChangeSet { .merge_max(indexes); } - // Sweeps: append, first-seen order, deduplicated by the removed - // txid. Deleting the same transaction twice is harmless at the - // persister, so the dedup is only there to keep a coalesced round's - // payload honest about how many distinct transactions died — and - // first-seen wins, so the earliest winner recorded for a txid is - // the one the persister sees. - if !other.swept_transactions.is_empty() { - let mut seen: std::collections::HashSet = self - .swept_transactions - .iter() - .map(|swept| swept.txid) - .collect(); - for swept in other.swept_transactions { - if seen.insert(swept.txid) { - self.swept_transactions.push(swept); - } - } - } - - // The released set folds the same way: a coalesced round frees a coin - // once however many sweeps named it. - if !other.swept_released_outpoints.is_empty() { - let mut seen: std::collections::HashSet = - self.swept_released_outpoints.iter().copied().collect(); - for outpoint in other.swept_released_outpoints { - if seen.insert(outpoint) { - self.swept_released_outpoints.push(outpoint); - } - } - } + // Sweeps: appended, never folded. Order is the whole point — a later + // batch's decision to keep a coin spent has to survive an earlier + // batch's decision to free it, and only replaying them in sequence + // preserves that. + self.sweeps.extend(other.sweeps); } fn is_empty(&self) -> bool { self.records.is_empty() - && self.swept_transactions.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() diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 86057ed83c7..3ce48c0c037 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -52,8 +52,7 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ - AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, - SweptTransaction, + AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, SweepBatch, }; use crate::changeset::merge::Merge; use crate::changeset::traits::PlatformWalletPersistence; @@ -741,14 +740,11 @@ async fn build_core_changeset( "Mirroring swept transactions to the persister" ); CoreChangeSet { - swept_transactions: txids - .iter() - .map(|txid| SweptTransaction { - txid: *txid, - superseded_by: *superseded_by, - }) - .collect(), - swept_released_outpoints: released_outpoints.clone(), + sweeps: vec![SweepBatch { + txids: txids.clone(), + superseded_by: *superseded_by, + released_outpoints: released_outpoints.clone(), + }], ..CoreChangeSet::default() } } @@ -1156,7 +1152,7 @@ impl CoreChangeSet { /// circuits on the common case. fn is_empty_no_records(&self) -> bool { self.records.is_empty() - && self.swept_transactions.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() @@ -1223,18 +1219,12 @@ mod swept_transaction_projection_tests { let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; assert_eq!( - cs.swept_transactions, - vec![ - SweptTransaction { - txid: txid(1), - superseded_by: txid(0xff), - }, - SweptTransaction { - txid: txid(2), - superseded_by: txid(0xff), - }, - ], - "each dead transaction is paired with the winner that settled its inputs" + cs.sweeps, + vec![SweepBatch { + txids: vec![txid(1), txid(2)], + superseded_by: txid(0xff), + released_outpoints: vec![], + }] ); // A wallet-relevant winner claims the inputs through its own // record; this arm must not invent UTXO deltas of its own. @@ -1266,7 +1256,7 @@ mod swept_transaction_projection_tests { ) .await; - assert_eq!(cs.swept_released_outpoints, vec![outpoint(9, 1)]); + assert_eq!(cs.sweeps[0].released_outpoints, vec![outpoint(9, 1)]); } /// An ordinary resend frees nothing: the winner took every input the @@ -1275,43 +1265,60 @@ mod swept_transaction_projection_tests { async fn a_sweep_that_freed_nothing_releases_nothing() { let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; - assert!(cs.swept_released_outpoints.is_empty()); + 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_name_each_transaction_once() { + 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(2), txid(3)])).await; + let second = build_core_changeset(&test_manager(), &swept(vec![txid(3)])).await; cs.merge(second); - assert_eq!( - cs.swept_transactions - .iter() - .map(|swept| swept.txid) - .collect::>(), - vec![txid(1), txid(2), txid(3)] - ); + 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 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 merged_sweeps_free_each_coin_once() { + 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![outpoint(9, 0), outpoint(9, 1)]), - ) - .await; - let second = build_core_changeset( - &test_manager(), - &swept_releasing(vec![txid(2)], vec![outpoint(9, 1), outpoint(9, 2)]), + &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.swept_released_outpoints, - vec![outpoint(9, 0), outpoint(9, 1), outpoint(9, 2)] + 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" ); } } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 605068cdcf6..4e16712a3c4 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -638,37 +638,29 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( } } - // Sweeps last, and only when there are any: the transaction that - // beat these to their inputs rides in the additive part above, so - // by the time the removal runs its claim on those inputs is - // already recorded. - let swept = slice_or_empty(cs.swept, cs.swept_count); - if !swept.is_empty() { - // Parallel arrays, index-aligned: the removed txid and the - // transaction that settled its inputs. The pairing is what lets - // the handler decide which of the loser's inputs are actually - // free — see `onWalletChangesetTransactionsSwept`. + // Sweeps last, and 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. Each call does its own + // hold-then-release, so the ordering holds on the Kotlin side too. + for batch in slice_or_empty(cs.sweeps, cs.sweeps_count) { let byte_array_cls = env.find_class("[B")?; let empty = env.byte_array_from_slice(&[])?; - let txids = env.new_object_array(swept.len() as i32, &byte_array_cls, &empty)?; - let winners = env.new_object_array(swept.len() as i32, &byte_array_cls, &empty)?; - for (i, entry) in swept.iter().enumerate() { + + let txids = slice_or_empty(batch.txids, batch.txids_count); + let txids_arr = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; + let winners = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; + for (i, txid) in txids.iter().enumerate() { env.with_local_frame(8, |env| { - let txid = env.byte_array_from_slice(&entry.txid)?; - env.set_object_array_element(&txids, i as i32, &txid)?; - let winner = env.byte_array_from_slice(&entry.superseded_by)?; - env.set_object_array_element(&winners, i as i32, &winner) + let t = env.byte_array_from_slice(txid)?; + env.set_object_array_element(&txids_arr, i as i32, &t)?; + let w = env.byte_array_from_slice(&batch.superseded_by)?; + env.set_object_array_element(&winners, i as i32, &w) })?; } - // The released outpoints ride along as 36-byte keys (raw txid + - // little-endian vout), the same shape the handler stores them - // in. They are wallet-scoped, not per removal: the handler holds - // every input of every row it deletes, so it only needs to know - // which of them came free. - let released = slice_or_empty( - cs.swept_released_outpoints, - cs.swept_released_outpoints_count, - ); + + // Released outpoints ride as 36-byte keys (raw txid + a + // little-endian vout), the shape the handler stores them in. + let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); let released_arr = env.new_object_array(released.len() as i32, &byte_array_cls, &empty)?; for (i, outpoint) in released.iter().enumerate() { @@ -680,6 +672,7 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( env.set_object_array_element(&released_arr, i as i32, &k) })?; } + let code = env .call_method( bridge, @@ -687,7 +680,7 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( "([B[[B[[B[[B)I", &[ (&wid).into(), - (&txids).into(), + (&txids_arr).into(), (&winners).into(), (&released_arr).into(), ], diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 5b703de40bd..2c95db4d563 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -838,37 +838,45 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // means its claim on the shared inputs is already recorded when // the removal below decides which links are left pointing at a // dead transaction. - if cs.swept_count > 0, let sweptPtr = cs.swept { - // The coins the sweep freed, as the 36-byte keys the TXO rows - // are stored under. Wallet-scoped across the round's - // removals, matching the upstream event. - var released = Set() - if cs.swept_released_outpoints_count > 0, - let releasedPtr = cs.swept_released_outpoints { - for i in 0.. 0, let sweepsPtr = cs.sweeps { + // One batch at a time, in order. A later sweep can keep a + // coin spent that an earlier one freed — each batch is only + // true of the wallet it saw — so folding them together lets + // the first answer outlive the last one that still holds. + for batchIndex in 0..() + if batch.released_outpoints_count > 0, + let releasedPtr = batch.released_outpoints { + for i in 0.. 0, let txidsPtr = batch.txids else { continue } + for i in 0.. Bool { - var entries: [SweptTransactionFFI] = [] - for pair in pairs { - var entry = SweptTransactionFFI() - Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in - pair.loser.withUnsafeBytes { src in dst.copyMemory(from: src) } + // Every nested buffer has to stay alive for the whole call, so they + // are held here and only pointed at from the FFI structs. + var txidStorage: [[(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 releasedStorage: [[OutPointFFI]] = [] + + for batch in batches { + var raw: [(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)] = [] + for loser in batch.losers { + var tuple = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0)) + Swift.withUnsafeMutableBytes(of: &tuple) { dst in + loser.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + raw.append(tuple) } - Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in - pair.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } + txidStorage.append(raw) + + var freed: [OutPointFFI] = [] + for outpoint in batch.released { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + freed.append(entry) } - entries.append(entry) + releasedStorage.append(freed) } - var freed: [OutPointFFI] = [] - for outpoint in released { - var entry = OutPointFFI() - Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in - outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + var ffiBatches: [SweepBatchFFI] = [] + for (i, batch) in batches.enumerated() { + var entry = SweepBatchFFI() + txidStorage[i].withUnsafeMutableBufferPointer { buf in + entry.txids = buf.baseAddress + entry.txids_count = UInt(buf.count) + } + releasedStorage[i].withUnsafeMutableBufferPointer { buf in + entry.released_outpoints = buf.baseAddress + entry.released_outpoints_count = UInt(buf.count) + } + Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in + batch.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } } - entry.vout = outpoint.vout - freed.append(entry) + ffiBatches.append(entry) } handler.beginChangeset(walletId: walletId) - let applied = entries.withUnsafeMutableBufferPointer { buf -> Bool in - freed.withUnsafeMutableBufferPointer { freedBuf -> Bool in - var cs = WalletChangeSetFFI() - cs.swept = buf.baseAddress - cs.swept_count = UInt(buf.count) - cs.swept_released_outpoints = freedBuf.baseAddress - cs.swept_released_outpoints_count = UInt(freedBuf.count) - return withUnsafePointer(to: &cs) { csPtr in - handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) - } + let applied = ffiBatches.withUnsafeMutableBufferPointer { buf -> Bool in + var cs = WalletChangeSetFFI() + cs.sweeps = buf.baseAddress + cs.sweeps_count = UInt(buf.count) + return withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) } } _ = handler.endChangeset(walletId: walletId, success: applied) @@ -188,11 +225,9 @@ final class SweptTransactionPersistTests: XCTestCase { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: true) - sweep( - handler, - [(loser: sweptTxid, winner: winnerTxid)], - released: [(txid: fundingTxid, vout: 1)] - ) + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, 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") @@ -205,11 +240,9 @@ final class SweptTransactionPersistTests: XCTestCase { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: true) - sweep( - handler, - [(loser: sweptTxid, winner: winnerTxid)], - released: [(txid: fundingTxid, vout: 1)] - ) + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 1)]) + ]) let takenByWinner = txo(container, txid: fundingTxid, vout: 0) XCTAssertNotNil(takenByWinner) @@ -231,11 +264,9 @@ final class SweptTransactionPersistTests: XCTestCase { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: false) - sweep( - handler, - [(loser: sweptTxid, winner: winnerTxid)], - released: [(txid: fundingTxid, vout: 1)] - ) + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 1)]) + ]) XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") @@ -262,7 +293,7 @@ final class SweptTransactionPersistTests: XCTestCase { func testWalletReDeliveringAHeldCoinFreesIt() throws { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: false) - sweep(handler, [(loser: sweptTxid, winner: winnerTxid)]) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) redeliverCoinB(handler) @@ -311,6 +342,50 @@ final class SweptTransactionPersistTests: XCTestCase { _ = 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, released: [(txid: fundingTxid, vout: 1)]), + // Its winner consumed B, so this batch frees nothing. + Batch(losers: [secondLoser], winner: Data(repeating: 0x56, count: 32)), + ]) + + 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" + ) + } + /// 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. @@ -318,10 +393,9 @@ final class SweptTransactionPersistTests: XCTestCase { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: true) - let applied = sweep( - handler, - [(loser: Data(repeating: 0x99, count: 32), winner: winnerTxid)] - ) + let applied = sweep(handler, [ + Batch(losers: [Data(repeating: 0x99, count: 32)], winner: winnerTxid) + ]) XCTAssertTrue(applied, "an absent row is a successful no-op, not a failed round") XCTAssertNotNil(transaction(container, txid: sweptTxid)) From 0d81ce14f6bd1cec00b26724f99603db3d11bff7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:15:07 +0300 Subject: [PATCH 010/102] fix: let a record arriving after its sweep reinstate the transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordering the sweep batches fixed them relative to each other, but records still sit in their own vector and every persister writes all of them before replaying any sweep. So a transaction removed by a buffered sweep and then recorded again in the same round was deleted anyway, along with its outputs, while the in-memory wallet had kept it. Reachable through IS-lock precedence, which the pinned wallet permits: an unconfirmed transaction is swept when an IS-locked conflict arrives, then comes back chainlocked and sweeps that conflict in turn. One drain then holds records for both plus removals for both. Merging now drops a reinstated txid from any sweep already buffered — the record is the newer fact — and drops the batch entirely once nothing is left to remove. The batch's release set goes with it: it described a wallet in which that transaction was gone, and leaving those coins spent is the recoverable direction, since the wallet re-delivers a genuinely free one as a UTXO while a coin handed back that the chain consumed cannot be taken away again. Also fixes the Swift test helper, which stored `baseAddress` from `withUnsafeMutableBufferPointer` in the FFI structs and used it after those closures returned — a dangling pointer the FFI consumer then read. The buffers are allocated explicitly and freed after the call. --- .../src/changeset/changeset.rs | 25 +++++ .../src/changeset/core_bridge.rs | 80 +++++++++++++++ .../SweptTransactionPersistTests.swift | 97 +++++++++++-------- 3 files changed, 162 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c0f4797c811..ec7ca9f3377 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -273,6 +273,31 @@ impl HighestUsedIndexes { 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 batch's release set goes with the reinstated txid. It described + // a wallet in which that transaction was gone, which is no longer + // the case, and the safe direction is to leave those coins spent: + // the wallet re-delivers a genuinely free one as a UTXO, while a coin + // handed back that the chain consumed cannot be taken away again. + 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 { + let before = batch.txids.len(); + batch.txids.retain(|txid| !reinstated.contains(txid)); + if batch.txids.len() != before { + batch.released_outpoints.clear(); + } + } + self.sweeps.retain(|batch| !batch.txids.is_empty()); + } + // Records / utxo deltas: append-only. The event adapter never // produces duplicates within a single batch (each event covers // a distinct moment); cross-batch dedup is the persister's diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 3ce48c0c037..75873632480 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1199,6 +1199,33 @@ mod swept_transaction_projection_tests { } } + /// 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 + } + fn swept(txids: Vec) -> WalletEvent { swept_releasing(txids, vec![]) } @@ -1285,6 +1312,59 @@ mod swept_transaction_projection_tests { 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. + #[tokio::test] + async fn a_reinstated_record_only_rescues_its_own_transaction() { + let reinstated = txid(1); + let still_dead = txid(2); + + let mut cs = + build_core_changeset(&test_manager(), &swept(vec![reinstated, still_dead])).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]); + } + /// 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 3df7359b523..aea99f41e03 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -130,73 +130,90 @@ final class SweptTransactionPersistTests: XCTestCase { /// 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 { - // Every nested buffer has to stay alive for the whole call, so they - // are held here and only pointed at from the FFI structs. - var txidStorage: [[(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 releasedStorage: [[OutPointFFI]] = [] + 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 { - var raw: [(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)] = [] - for loser in batch.losers { - var tuple = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), - UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), - UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), - UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0)) + 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) } } - raw.append(tuple) + txids.advanced(by: i).initialize(to: tuple) } - txidStorage.append(raw) + txidBuffers.append(txids) - var freed: [OutPointFFI] = [] - for outpoint in batch.released { + 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.append(entry) + freed.advanced(by: i).initialize(to: entry) } - releasedStorage.append(freed) - } + releasedBuffers.append(freed) - var ffiBatches: [SweepBatchFFI] = [] - for (i, batch) in batches.enumerated() { var entry = SweepBatchFFI() - txidStorage[i].withUnsafeMutableBufferPointer { buf in - entry.txids = buf.baseAddress - entry.txids_count = UInt(buf.count) - } - releasedStorage[i].withUnsafeMutableBufferPointer { buf in - entry.released_outpoints = buf.baseAddress - entry.released_outpoints_count = UInt(buf.count) - } + entry.txids = txids + entry.txids_count = UInt(batch.losers.count) + entry.released_outpoints = 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) } } 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() + } + handler.beginChangeset(walletId: walletId) - let applied = ffiBatches.withUnsafeMutableBufferPointer { buf -> Bool in - var cs = WalletChangeSetFFI() - cs.sweeps = buf.baseAddress - cs.sweeps_count = UInt(buf.count) - return withUnsafePointer(to: &cs) { csPtr in - handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) - } + var cs = WalletChangeSetFFI() + cs.sweeps = sweeps + cs.sweeps_count = UInt(ffiBatches.count) + let applied = withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) } _ = handler.endChangeset(walletId: walletId, success: applied) return applied From 46f74e9a0e7053f0b6b3facd306ef5fa12449990 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:44:19 +0300 Subject: [PATCH 011/102] fix: keep a sweep's releases when only one of its losers is reinstated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `released_outpoints` is the aggregate for every loser in the batch, so clearing it on reinstatement discarded coins freed by the losers that are still going: a winner sweeping X and Y, where only Y also spends C, releases C — and X returning chainlocked left the batch keeping Y but losing C, so replaying it marked C spent though no final winner took it. Keep the set. 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. --- .../src/changeset/changeset.rs | 16 ++++++-------- .../src/changeset/core_bridge.rs | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index ec7ca9f3377..d23a6aad018 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -280,20 +280,18 @@ impl Merge for CoreChangeSet { // unconfirmed transaction is swept when an IS-locked conflict lands, // then returns chainlocked and sweeps that conflict in turn. // - // The batch's release set goes with the reinstated txid. It described - // a wallet in which that transaction was gone, which is no longer - // the case, and the safe direction is to leave those coins spent: - // the wallet re-delivers a genuinely free one as a UTXO, while a coin - // handed back that the chain consumed cannot be taken away again. + // 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 { - let before = batch.txids.len(); batch.txids.retain(|txid| !reinstated.contains(txid)); - if batch.txids.len() != before { - batch.released_outpoints.clear(); - } } self.sweeps.retain(|batch| !batch.txids.is_empty()); } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 75873632480..0970b0935e2 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1349,20 +1349,36 @@ mod swept_transaction_projection_tests { } /// Only the reinstated transaction leaves the batch; anything else it - /// removed still goes. + /// 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(vec![reinstated, still_dead])).await; + 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 From 73852a92bcc611c135e0a9773186ec4c783d5f19 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:36:09 +0300 Subject: [PATCH 012/102] fix: keep a sweep's claim durable when its funding TXO is still unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wallet-relevant loser can be persisted before one of its own funding outputs is materialized: the mobile handlers stage that spend as a pending-input row, and SQLite simply has no `core_utxos` row for the outpoint yet. When a later, unresolved-elsewhere winner sweeps that loser and does not release the input, every backend tried to update a row that did not exist — a no-op — then deleted the loser, which was the only place the claim lived. A pending-input row is cascade-owned by the transaction that created it, so it went with the loser too. Once the funding transaction was finally observed, even after a restart, its ordinary UTXO upsert had nothing telling it the coin was already spoken for, and inserted it back as spendable. Give the claim somewhere durable to live before deleting the loser. SQLite's `core_utxos.spent_in_txid` column already existed for exactly this and was never populated on any write path; `apply_sweep` now writes it for a held input with no existing row (a placeholder row the real funding upsert fills in later) and for one that does exist, and `execute_upsert_utxo`'s ON CONFLICT clause refuses to clear `spent` while it's set. Swift and Kotlin get the mobile-appropriate version: a held pending input is detached from its doomed loser (so the cascade-delete no longer reaches it) and repointed at the winner, flagged so the funding TXO's own later upsert forces `isSpent` unconditionally and stamps a new `supersededByTxid` column rather than waiting on the winner's own row to resolve. That column is deliberately not the same "no spender on record" state a plain held coin gets — clearing `isSpent` when the wallet re-delivers a coin as a UTXO stays gated on no spender *and* no superseding txid, so the existing recovery path for an unresolved sweep is untouched. Regression coverage on all three backends: seed the pending spend, sweep it holding the input, drop and reopen the store/persister, then let the funding UTXO arrive — the coin must not become spendable. Each was confirmed to fail without its half of the fix. Kotlin's schema move (`txos.supersededByTxid`, `pending_inputs.isSweptTombstone`) ships as Room migration v10→v11 with exported-schema and migration-path coverage. --- .../11.json | 4131 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 53 +- .../dashsdk/persistence/DashDatabase.kt | 27 +- .../PlatformWalletPersistenceHandler.kt | 76 +- .../dashsdk/persistence/dao/DocumentDao.kt | 23 + .../entities/PendingInputEntity.kt | 16 + .../dashsdk/persistence/entities/TxoEntity.kt | 26 +- .../PlatformWalletPersistenceHandlerTest.kt | 89 + packages/rs-platform-wallet-storage/SCHEMA.md | 16 +- .../src/sqlite/schema/core_state.rs | 70 +- .../tests/sqlite_transaction_sweeps.rs | 96 +- .../Persistence/DashModelContainer.swift | 8 + .../Models/PersistentPendingInput.swift | 15 + .../Persistence/Models/PersistentTxo.swift | 15 + .../PlatformWalletPersistenceHandler.swift | 64 +- .../SweptTransactionPersistTests.swift | 122 + 16 files changed, 4804 insertions(+), 43 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..a313f4e034a --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json @@ -0,0 +1,4131 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "df41ac2f487f62b80cc6ac2f65a9bfef", + "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, `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": "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, 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 + } + ], + "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`)" + } + ], + "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, 'df41ac2f487f62b80cc6ac2f65a9bfef')" + ] + } +} \ 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..e79925d277e 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,58 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v10 → v11 adds `txos.supersededByTxid` (nullable) and + * `pending_inputs.isSweptTombstone` (defaulted `false`) — both + * additive. Pre-existing rows in each table must survive and read back + * with the new columns at their defaults. + */ + @Test + fun migrate10To11AddsSweepClaimDurabilityColumns() { + 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 FROM pending_inputs WHERE outpoint = x'0301'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, 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 +453,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 +474,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/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 13e78e16471..2d57885a441 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,18 @@ 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 (sweep claim durability): adds `txos.supersededByTxid` and + * `pending_inputs.isSweptTombstone`. A sweep's winner can beat a loser to an + * input whose funding TXO hasn't 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. Both + * columns are additive with defaults, so every pre-migration row reads back + * as an ordinary (non-tombstone, non-superseded) entry. */ @Database( - version = 10, + version = 11, exportSchema = true, entities = [ WalletEntity::class, @@ -556,6 +565,21 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v10 → v11: additive sweep-claim-durability columns, both + * defaulted so every existing row reads as "not a tombstone, not + * superseded" (column order = entity field order). + */ + 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", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -574,6 +598,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 181603d3eea..54540e5a3dd 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 @@ -928,8 +928,13 @@ class PlatformWalletPersistenceHandler( // (`holdSpentWithoutSpender`); a rescan re-delivering the // coin lands here and frees it. A row whose spend is still // on record keeps its flag — the pending drain below owns - // that transition. - isSpent = existing?.isSpent == true && existing.spendingTxid != null, + // that transition. `supersededByTxid` is a different kind of + // "no spender" — a sweep's winner is known but its row never + // materialized here — and must not be lifted the same way, + // or a tombstone the drain below just wrote would be undone + // by the very next sync round that re-delivers this outpoint. + isSpent = existing?.isSpent == true && + (existing.spendingTxid != null || existing.supersededByTxid != null), walletId = walletId, txid = txid, spendingTxid = existing?.spendingTxid, @@ -938,6 +943,7 @@ class PlatformWalletPersistenceHandler( coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), createdAt = existing?.createdAt ?: java.util.Date(), lastUpdated = now(), + supersededByTxid = existing?.supersededByTxid, ) db.txoDao().upsert(row) // Drain any pending-input rows staged before this funding TXO @@ -952,15 +958,39 @@ class PlatformWalletPersistenceHandler( 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(), - ), - ) + if (chosen.isSweptTombstone) { + // `onWalletChangesetTransactionsSwept` repointed this row + // at the sweep's winner because the loser it originally + // recorded is gone. A sweep's winner is already final — + // there is no mempool state to wait out — so `isSpent` + // does not gate on `spending` the way an ordinary pending + // spend does; that lookup only succeeds when the winner + // happens to have its own materialized row, which isn't + // guaranteed (and `spendingTxid`'s FK forbids forcing the + // reference otherwise). `supersededByTxid` is what makes + // the mark durable either way — it is what the recovery + // clear above checks so this coin isn't handed back as + // spendable on a later sync. + db.txoDao().upsert( + row.copy( + isSpent = true, + spendingTxid = spending?.txid ?: row.spendingTxid, + spendingInputIndex = chosen.inputIndex, + supersededByTxid = chosen.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + 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(), + ), + ) + } for (p in pending) db.documentDao().deletePendingInput(p) } } @@ -1016,8 +1046,22 @@ class PlatformWalletPersistenceHandler( * outside addresses and never be recorded here, and even a relevant one * is not guaranteed to land in the same round as the sweep. * - * Both updates run before the delete: the foreign key nulls - * `spendingTxid` on delete, and after that nothing finds those rows. + * A held input can also have no `TxoEntity` at all yet — the loser was + * persisted before its own funding TXO was, so `onWalletChangesetTransaction` + * parked the claim as a `pending_inputs` row instead (see + * `PendingInputEntity`). That row's FK cascades on [txids]' own delete + * below just like the TXOs do, so left alone the claim would vanish with + * the loser, and the funding TXO's own later `onWalletChangesetUtxoAdded` + * — even after a restart — would have nothing to tell it the coin isn't + * really free. [DocumentDao.tombstoneUnreleasedPendingInputs] detaches a + * held pending input from its doomed loser and repoints it at the + * corresponding [supersededBy] entry instead, flagged so the drain in + * `onWalletChangesetUtxoAdded` knows to keep the coin spent — durably, + * via `TxoEntity.supersededByTxid` — once the funding TXO materializes. + * + * All updates run before the delete: the foreign key nulls `spendingTxid` + * (or, for a pending row already detached above, does nothing) on delete, + * and after that nothing finds those rows. * * Transaction rows are keyed by txid alone, shared across wallets by * design, and a sweep is a statement about the transaction rather than @@ -1041,8 +1085,10 @@ class PlatformWalletPersistenceHandler( // touches detached rows — so a coin some later transaction in the // same round already re-claimed keeps that claim instead of being // freed out from under it. - for (txid in txids) { - db.txoDao().holdSpentWithoutSpender(txid) + val released = releasedOutpoints.toList() + for (i in txids.indices) { + db.txoDao().holdSpentWithoutSpender(txids[i]) + db.documentDao().tombstoneUnreleasedPendingInputs(txids[i], supersededBy[i], released) } for (outpoint in releasedOutpoints) { db.txoDao().releaseByOutpoint(outpoint) 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..09a394422b2 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 @@ -174,6 +174,29 @@ interface DocumentDao { @Query("SELECT * FROM pending_inputs WHERE walletId = :walletId") fun observePendingInputsByWallet(walletId: ByteArray): Flow> + /** + * Repoint every pending input still recorded against loser [txid] at + * [supersededBy] instead, except the outpoints named in + * [releasedOutpoints] — those came free and are left for + * `onWalletChangesetTransactionsSwept`'s own cascade-delete of [txid] + * to remove. [spendingTransactionTxid] is cleared first so the FK no + * longer targets the row about to be deleted (a live `transactions` + * row cascades its `pending_inputs` children), and `isSweptTombstone` + * marks the row so `onWalletChangesetUtxoAdded` knows this is a durable + * claim rather than an ordinary in-flight spend once the funding TXO + * finally lands. + */ + @Query( + "UPDATE pending_inputs SET spendingTransactionTxid = NULL, " + + "spendingTxid = :supersededBy, isSweptTombstone = 1 " + + "WHERE spendingTransactionTxid = :txid AND outpoint NOT IN (:releasedOutpoints)", + ) + suspend fun tombstoneUnreleasedPendingInputs( + txid: ByteArray, + supersededBy: ByteArray, + releasedOutpoints: List, + ) + @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) 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..e802e37022c 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 @@ -53,4 +53,20 @@ data class PendingInputEntity( /** Wallet id denorm for cleanup / per-wallet diagnostics. */ val walletId: ByteArray, val createdAt: Date = Date(), + /** + * Port of Swift `PersistentPendingInput.isSweptTombstone`. Set by + * `onWalletChangesetTransactionsSwept` when this row's spend turns out + * to belong to a swept loser and the input wasn't in `released`: + * [spendingTransactionTxid] is cleared (detaching the FK so the row + * survives the loser's cascade-delete) and [spendingTxid] is + * overwritten with the winner's txid. `onWalletChangesetUtxoAdded` + * checks this flag when it later drains the row — a tombstone forces + * `TxoEntity.isSpent = true` unconditionally (a sweep's winner is + * already final, unlike an ordinary pending spend whose confirmation is + * still pending) and stamps `TxoEntity.supersededByTxid` so the mark + * survives even when the winner's own `transactions` row never + * materializes. Defaulted `false` so pre-migration rows read as + * ordinary pending entries. + */ + val isSweptTombstone: Boolean = false, ) 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..6b51af1f55b 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,10 @@ data class TxoEntity( val isConfirmed: Boolean = false, val isInstantLocked: Boolean = false, val isLocked: Boolean = false, - /** Denormalized `spendingTxid != null`; kept explicit (hot filter path). */ + /** + * Denormalized `spendingTxid != null || supersededByTxid != null`; kept + * explicit (hot filter path). + */ val isSpent: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), @@ -100,6 +103,27 @@ data class TxoEntity( * navigation pointer. */ val coreAddressId: String? = null, + /** + * Port of Swift `PersistentTxo.supersededByTxid`. Set only by + * `onWalletChangesetUtxoAdded` resolving a `pending_inputs` row with + * `isSweptTombstone` — i.e. this TXO's funding output arrived after the + * loser that spent it was already swept and deleted, so there was never + * a live `spendingTxid` to carry forward. 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. + * + * `null` in every other case, including the plain "sweep held this coin + * with no spender on record" state that `holdSpentWithoutSpender` writes + * directly onto an already-materialized row (`spendingTxid = NULL`, no + * tombstone involved). That distinction is what the `isSpent` carry-over + * above and `onWalletChangesetUtxoAdded`'s recovery clear key on: a coin + * the wallet re-delivers as unspent only lifts `isSpent` when both + * `spendingTxid` and this are null, so a tombstoned coin isn't waved + * back into the restore set just because the winner's own row was never + * linked. + */ + 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/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 0999b330fd8..b05c3281d87 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 @@ -2475,6 +2475,95 @@ class PlatformWalletPersistenceHandlerTest { 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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), emptyArray(), + ) + 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 = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + // 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 loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { // prior-2 regression: the persisted Core address pools must come diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 8149fb16e23..fd28bacdce4 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -93,7 +93,7 @@ erDiagram INTEGER height "NULL if unconfirmed" INTEGER account_index INTEGER spent "0 | 1" - BLOB spent_in_txid "NULL until spend; cleared by trigger on tx delete" + BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" } CORE_INSTANT_LOCKS { @@ -381,10 +381,16 @@ is `1` once block context is present. ### `core_utxos` -One row per UTXO, spent or unspent. `spent_in_txid` is set to NULL -by a trigger when its referenced `core_transactions` row is deleted -(instead of a native `ON DELETE SET NULL`, which would also null the -NOT NULL `wallet_id` column). +One row per UTXO, spent or unspent. `spent_in_txid` is written only by +`apply_sweep`, naming the winner that took an input a swept loser claimed +but this store had no released record for. Its presence gates the funding +UTXO's own later upsert (`execute_upsert_utxo`): a coin held spent with a +`spent_in_txid` stays spent when the wallet redelivers it, unlike a coin +held spent with none (the ordinary "sweep couldn't resolve it" state, which +does clear on redelivery). It is set to NULL by a trigger when its +referenced `core_transactions` row is deleted (instead of a native +`ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` +column) — and by a later sweep that releases the same outpoint. - PK: `(wallet_id, outpoint)`. - FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 7bdb4392600..13afccfe7e3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -138,10 +138,11 @@ pub fn apply( for batch in &cs.sweeps { // The released set describes the wallet when this sweep was emitted, // and a round can fold in a later transaction that legitimately spent - // one of the freed coins. `core_utxos` never records *who* spent a - // row (`spent_in_txid` stays null on every write path), so unlike the - // mobile mirrors this cannot tell a live claim from the dead one by - // looking at the table — but the changeset carries the answer: any + // one of the freed coins. `apply_sweep` below is what attributes a + // held input to `superseded_by` via `spent_in_txid`, and that only + // happens once it runs — so at this point in the round the table + // cannot yet tell a live claim in *this* round from the one the sweep + // is about to displace. The changeset carries the answer instead: any // record in this round that is not swept by *any* batch and spends a // released outpoint is that live claim, and the coin stays spent. let swept_txids: HashSet = cs @@ -164,7 +165,7 @@ pub fn apply( .copied() .collect(); for loser_txid in &batch.txids { - apply_sweep(tx, wallet_id, loser_txid, &released)?; + apply_sweep(tx, wallet_id, loser_txid, &batch.superseded_by, &released)?; } } Ok(()) @@ -194,6 +195,18 @@ pub fn apply( /// addresses, and then it is never recorded anywhere in this store — and even /// a relevant one is not guaranteed to arrive in the same round as the sweep. /// +/// A held input can also have no `core_utxos` row at all: this wallet can +/// persist the loser before its own funding output was ever classified as +/// ours, so the outpoint the loser claims to spend has nothing to update. +/// Losing that claim would matter — the funding transaction has not shown up +/// yet, and when it eventually does, the ordinary UTXO upsert would treat the +/// outpoint as freshly unspent — so a held-but-absent input gets a row of its +/// own here: `spent = 1`, `spent_in_txid = superseded_by`, everything else a +/// placeholder the real funding data overwrites on arrival. +/// `execute_upsert_utxo`'s conflict clause is what makes that placeholder +/// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the +/// claim survives the funding upsert instead of being upserted away by it. +/// /// Idempotent: a txid this store never recorded is a successful no-op, not an /// error. A sweep can legitimately name a transaction this wallet dropped, or /// never derived an address for in the first place. @@ -201,6 +214,7 @@ fn apply_sweep( tx: &Transaction<'_>, wallet_id: &WalletId, loser_txid: &dashcore::Txid, + superseded_by: &dashcore::Txid, released: &HashSet, ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx @@ -243,17 +257,45 @@ fn apply_sweep( // wallet, and a coin the sweep did not free stays out of the unspent // query even if nothing had marked it spent yet (upstream sweeps only // unconfirmed records, whose spends this schema does not mark). + // `spent_in_txid` moves with `spent`: a released input clears back to + // NULL (nobody's claim), a held one is attributed to `superseded_by` so + // the claim outlives this row's own deletion below. let mut spend_stmt = tx.prepare_cached( - "UPDATE core_utxos SET spent = ?3 WHERE wallet_id = ?1 AND outpoint = ?2", + "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4 \ + WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + // Only reached for a held input with no existing row — see the + // doc comment above. `value`/`script`/`height`/`account_index` are + // placeholders; the funding UTXO's own upsert overwrites them (and, + // thanks to the `spent_in_txid` guard in `execute_upsert_utxo`, does + // not clear `spent` while doing it). + let mut tombstone_stmt = tx.prepare_cached( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3)", )?; for input in &loser.transaction.input { let outpoint = input.previous_output; let key = blob::encode_outpoint(&outpoint)?; - spend_stmt.execute(params![ + let freed = released.contains(&outpoint); + let spent_in_txid: Option<&[u8]> = if freed { + None + } else { + Some(AsRef::<[u8]>::as_ref(superseded_by)) + }; + let affected = spend_stmt.execute(params![ wallet_id.as_slice(), &key[..], - !released.contains(&outpoint) + !freed, + spent_in_txid ])?; + if affected == 0 && !freed { + tombstone_stmt.execute(params![ + wallet_id.as_slice(), + &key[..], + AsRef::<[u8]>::as_ref(superseded_by) + ])?; + } } Ok(()) @@ -265,6 +307,15 @@ fn apply_sweep( const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = "SELECT account_index FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2"; +// `spent` only takes the incoming value when the existing row has no +// `spent_in_txid`. A coin held spent with no spender on record is the +// documented recovery state — the wallet handing it back as a UTXO is +// what clears it. A coin held spent *with* `spent_in_txid` set is +// `apply_sweep`'s tombstone for an input the loser claimed but the funding +// row hadn't arrived for yet; the funding upsert (this statement) is +// exactly the arrival that tombstone exists to survive, so it must not +// double as the thing that erases it. `spent_in_txid` itself is left out of +// the SET list entirely — untouched, it carries the claim forward. const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -273,7 +324,8 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ script = excluded.script, \ height = excluded.height, \ account_index = excluded.account_index, \ - spent = excluded.spent"; + spent = CASE WHEN core_utxos.spent_in_txid IS NOT NULL \ + THEN core_utxos.spent ELSE excluded.spent END"; fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 1fbc395197b..488f6853a50 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -10,7 +10,7 @@ mod common; -use common::{ensure_wallet_meta, fresh_persister, wid}; +use common::{ensure_wallet_meta, fresh_persister, wid, SqlitePersister, SqlitePersisterConfig}; use dashcore::hashes::Hash; use dashcore::{Address, Network, OutPoint, Transaction, TxIn, TxOut, Txid}; @@ -657,3 +657,97 @@ fn a_later_sweep_keeping_a_coin_spent_overrides_an_earlier_release() { "the later sweep kept the coin spent, so it must not be spendable" ); } + +/// A loser can be persisted before its own funding output is: this store +/// only learns about a TXO through `new_utxos`/`spent_utxos`, so a spend can +/// name an outpoint `core_utxos` has never heard of. When such an input is +/// held (not released) by the sweep, `apply_sweep` has no row to update and +/// must leave a claim of its own — otherwise deleting the loser's +/// `core_transactions` row (the only place that input was ever recorded) +/// erases the claim entirely, and the funding output arriving later — even +/// after a full restart — would insert it back as a plain unspent UTXO. +#[test] +fn a_held_input_with_no_utxo_row_survives_restart_and_stays_spent_when_funded() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x07); + let funding_txid = Txid::from_byte_array([0x80; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + + let loser_txid = Txid::from_byte_array([0x81; 32]); + let winner_txid = Txid::from_byte_array([0x82; 32]); + + let loser = tx_record( + loser_txid, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + // The loser's spend arrives with no prior `new_utxos`/`spent_utxos` + // for `unfunded_input` — the funding side of that outpoint has not + // been observed yet. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + assert!( + !row_exists(&conn, &w, &unfunded_input), + "sanity: no core_utxos row exists for the unfunded input yet" + ); + } + + // The sweep holds the input (it is not in `released_outpoints`), with + // nothing on hand to update. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + + // Restart: a fresh persister loading the same on-disk store, exactly as + // a relaunch would see it. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + + // The funding transaction finally arrives and hands the outpoint back + // as a UTXO — the ordinary path a rescan or late block takes. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&unfunded_input), + "the winner's claim on this input must survive the loser's deletion, \ + a restart, and the funding UTXO's own arrival" + ); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 47d3e29fbe4..1c347fa0195 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -219,6 +219,14 @@ 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. /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index a3e5f5626de..0b629bc566f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -78,6 +78,21 @@ 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 + public init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 1775eda311e..4432316b848 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -86,6 +86,21 @@ 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, set only by `upsertUtxo` resolving a + /// `PersistentPendingInput` tombstone (`isSweptTombstone`) — i.e. this + /// TXO's funding output arrived after its loser was already swept and + /// deleted, so there was never a `spendingTransaction` row to link. + /// `nil` in every other case, including the ordinary "sweep held this + /// coin with no spender on record" state that `applySweptTransaction` + /// writes directly onto an already-materialized row (`spendingTransaction + /// = nil`, no tombstone involved). That distinction is what + /// `upsertUtxo`'s recovery clear keys on: a coin the wallet re-delivers + /// as unspent lifts `isSpent` only when both `spendingTransaction` and + /// this are nil, so a tombstoned coin isn't waved back into the restore + /// set just because nobody ever linked the winner's own row. + 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/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 2c95db4d563..57bcbc6759d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -860,11 +860,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + let supersededBy = Swift.withUnsafeBytes(of: batch.superseded_by) { Data($0) } + guard batch.txids_count > 0, let txidsPtr = batch.txids else { continue } for i in 0..) throws { + private func applySweptTransaction( + txid: Data, + supersededBy: Data, + released: Set + ) throws { var descriptor = FetchDescriptor( predicate: #Predicate { $0.txid == txid } ) descriptor.fetchLimit = 1 - descriptor.relationshipKeyPathsForPrefetching = [\.inputs] + descriptor.relationshipKeyPathsForPrefetching = [\.inputs, \.pendingInputs] // A successful fetch that finds nothing is an ordinary no-op: sweeps // are idempotent and can name a transaction this store never had. guard let row = try backgroundContext.fetch(descriptor).first else { return } @@ -937,6 +963,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txo.spendingTransaction = nil txo.lastUpdated = Date() } + for pending in row.pendingInputs where !released.contains(pending.outpoint) { + pending.spendingTransaction = nil + pending.spendingTxid = supersededBy + pending.isSweptTombstone = true + } backgroundContext.delete(row) } @@ -1429,8 +1460,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // inputs of a sweep it cannot resolve in exactly that state; a // rescan re-delivering the coin lands here and frees it. A row whose // spend is still on record is left alone: the pending-input resolve - // below owns that transition. - if record.isSpent, record.spendingTransaction == nil { + // below owns that transition. `supersededByTxid` is a different + // kind of "no spender" — a sweep's winner is known but its row + // never materialized here — and must not be lifted the same way, + // or a tombstone the pending-resolve below just wrote would be + // undone by the very next sync round that re-delivers this outpoint. + if record.isSpent, record.spendingTransaction == nil, record.supersededByTxid == nil { record.isSpent = false } @@ -1500,7 +1535,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.spendingTransaction?.txid != spending.txid { record.spendingTransaction = spending } - if let spending = resolvedSpending { + if chosen.isSweptTombstone { + // `applySweptTransaction` repointed this row at the sweep's + // winner because the loser it originally recorded is gone. + // A sweep's winner is already final — there is no mempool + // state to wait out — so `isSpent` does not gate on + // `resolvedSpending` 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 — it is what the recovery clear above checks so this + // coin isn't handed back as spendable on a later sync. + record.isSpent = true + record.supersededByTxid = chosen.spendingTxid + } else if let spending = resolvedSpending { record.isSpent = Self.spendIsInBlock(spending) } record.lastUpdated = Date() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index aea99f41e03..b3658c8fd7a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -33,6 +33,21 @@ final class SweptTransactionPersistTests: XCTestCase { 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. @@ -418,4 +433,111 @@ final class SweptTransactionPersistTests: XCTestCase { 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)]) + + 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) + } + + /// 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 + ) { + 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) + } } From 8b58966093d2c9017f4c7d56cc75431333f69d7d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:42:14 +0300 Subject: [PATCH 013/102] fix(kotlin-sdk): declare the tombstone column's default so the upgrade validates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MIGRATION_10_11` adds `isSweptTombstone` as `INTEGER NOT NULL DEFAULT 0` — SQLite requires a default on a NOT NULL `ADD COLUMN` — but the entity did not declare one, so the exported schema carried none. Room compares defaults when it validates a migrated database against the entity, so an upgraded install would have failed to open where a fresh one was fine. Declare it the way every other migration-added flag in this schema already does (`paymentChannelBroken`, `contactHidden`), and re-export v11. Not caught by the suite: the migration test that would have is an androidTest and needs a device, which this machine has none of. --- .../11.json | 9 +++++---- .../dashsdk/persistence/entities/PendingInputEntity.kt | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) 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 index a313f4e034a..e973d2ec544 100644 --- 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 @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "df41ac2f487f62b80cc6ac2f65a9bfef", + "identityHash": "1cad63f34d38c2a9c8b5bb328235ac55", "entities": [ { "tableName": "wallets", @@ -2792,7 +2792,7 @@ }, { "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, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -2839,7 +2839,8 @@ "fieldPath": "isSweptTombstone", "columnName": "isSweptTombstone", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -4125,7 +4126,7 @@ ], "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, 'df41ac2f487f62b80cc6ac2f65a9bfef')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1cad63f34d38c2a9c8b5bb328235ac55')" ] } } \ No newline at end of file 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 e802e37022c..6025a15c890 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 @@ -67,6 +68,12 @@ data class PendingInputEntity( * survives even when the winner's own `transactions` row never * materializes. 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, ) From 4b38b64cfacbcfeaa9da5bc5aac43013d78ce21d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:14:48 +0300 Subject: [PATCH 014/102] fix: repoint chained-sweep tombstones instead of losing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A held-but-unfunded pending input's tombstone (from the previous commit) is keyed to the sweep that wrote it: the row detaches from its doomed loser and is repointed at that sweep's winner. If that winner is itself swept later, the mobile backends' own repoint query only matches pending rows still attached to the loser via `spendingTransactionTxid` — exactly the relationship the first tombstoning already cleared. A second sweep of the winner therefore neither deletes the tombstone when its outpoint is finally released nor repoints it to the new winner when it isn't, and the funding TXO's later arrival resurrects a coin the final sweep either freed or attributed to a transaction that no longer has a row. Kotlin's `DocumentDao` gains `deleteReleasedSweptTombstones` / `retargetSweptTombstones`, matched by scalar `spendingTxid` + `isSweptTombstone` rather than the relationship column, and `onWalletChangesetTransactionsSwept` runs them alongside the existing repoint for every loser in the batch. Swift's `applySweptTransaction` gets a second `PersistentPendingInput` lookup by the same scalar key, since a detached tombstone no longer appears in `row.pendingInputs`. SQLite needed no fix: `apply_sweep` always re-derives a loser's inputs from its own `core_transactions` blob rather than from any state a prior sweep left behind, and resolves `core_utxos` by outpoint alone — so a placeholder written by one sweep is found and correctly repointed or released by the next regardless of chain length. Two new tests confirm this rather than changing any SQLite code. Regression coverage on all three backends: L spends P, W spends P and Q and sweeps L while P's funding TXO is unknown, then X spends Q and sweeps W. Both the release-P and hold-P variants are covered, each confirmed to fail without its half of the fix. --- .../PlatformWalletPersistenceHandler.kt | 19 ++ .../dashsdk/persistence/dao/DocumentDao.kt | 34 +++ .../PlatformWalletPersistenceHandlerTest.kt | 186 +++++++++++++ .../tests/sqlite_transaction_sweeps.rs | 251 +++++++++++++++++- .../PlatformWalletPersistenceHandler.swift | 29 ++ .../SweptTransactionPersistTests.swift | 178 +++++++++++++ 6 files changed, 696 insertions(+), 1 deletion(-) 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 54540e5a3dd..0f84bb18844 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 @@ -1059,6 +1059,17 @@ class PlatformWalletPersistenceHandler( * `onWalletChangesetUtxoAdded` knows to keep the coin spent — durably, * via `TxoEntity.supersededByTxid` — once the funding TXO materializes. * + * A tombstoned row can itself need to move again: [supersededBy] is a + * winner in this round, but nothing stops it from losing a later round + * to a further winner while [supersededBy]'s own funding TXO is still + * unresolved. [DocumentDao.tombstoneUnreleasedPendingInputs] can't see + * that earlier tombstone — it already detached from the relationship + * that query matches on — so [DocumentDao.deleteReleasedSweptTombstones] + * and [DocumentDao.retargetSweptTombstones] look it up the only other + * way it is still findable, by the scalar `spendingTxid` it was + * repointed to, and carry it the rest of the chain: deleted if this + * round finally frees its outpoint, repointed at the new winner if not. + * * All updates run before the delete: the foreign key nulls `spendingTxid` * (or, for a pending row already detached above, does nothing) on delete, * and after that nothing finds those rows. @@ -1089,6 +1100,14 @@ class PlatformWalletPersistenceHandler( for (i in txids.indices) { db.txoDao().holdSpentWithoutSpender(txids[i]) db.documentDao().tombstoneUnreleasedPendingInputs(txids[i], supersededBy[i], released) + // A pending input an EARLIER sweep already tombstoned to + // txids[i] (that txid was itself a sweep's winner, and is + // now being swept in turn) detached from the relationship + // `tombstoneUnreleasedPendingInputs` above matches on, so it + // has to be found and carried forward separately — see + // [DocumentDao.deleteReleasedSweptTombstones]. + db.documentDao().deleteReleasedSweptTombstones(txids[i], released) + db.documentDao().retargetSweptTombstones(txids[i], supersededBy[i], released) } for (outpoint in releasedOutpoints) { db.txoDao().releaseByOutpoint(outpoint) 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 09a394422b2..6c2fddb85e6 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 @@ -197,6 +197,40 @@ interface DocumentDao { releasedOutpoints: List, ) + /** + * Chained-sweep continuation of [tombstoneUnreleasedPendingInputs]: a + * pending row that an earlier sweep already tombstoned to [txid] + * detached itself from the `spendingTransactionTxid` relationship at + * that point, so a sweep of [txid] itself cannot find it there — only + * the scalar `spendingTxid` this row was repointed to still names it. + * Delete the ones this round frees. Nothing else owns them once + * detached — unlike a live pending row, there is no cascade-delete of + * [txid]'s `transactions` row left to do that job for them. + */ + @Query( + "DELETE FROM pending_inputs WHERE spendingTxid = :txid AND isSweptTombstone = 1 " + + "AND outpoint IN (:releasedOutpoints)", + ) + suspend fun deleteReleasedSweptTombstones(txid: ByteArray, releasedOutpoints: List) + + /** + * The held half of [deleteReleasedSweptTombstones]: repoint every + * surviving tombstone of [txid] at the new [supersededBy] instead, so a + * third sweep down the chain can still find it by scalar `spendingTxid`. + * [isSweptTombstone] is already set from the first tombstoning and + * stays set. + */ + @Query( + "UPDATE pending_inputs SET spendingTxid = :supersededBy " + + "WHERE spendingTxid = :txid AND isSweptTombstone = 1 " + + "AND outpoint NOT IN (:releasedOutpoints)", + ) + suspend fun retargetSweptTombstones( + txid: ByteArray, + supersededBy: ByteArray, + releasedOutpoints: List, + ) + @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) 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 b05c3281d87..ab61efd23d3 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 @@ -2564,6 +2564,192 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0, restarted.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 — `tombstoneUnreleasedPendingInputs` 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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), + ) + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), arrayOf(pOutpoint), + ) + 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 = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + 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 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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), + ) + handler.onChangesetEnd(walletId, success = true) + + // W's own record, needed by the second sweep below. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), emptyArray(), + ) + 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 = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + 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 loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { // prior-2 regression: the persisted Core address pools must come diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 488f6853a50..f5e025cfeb9 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -21,7 +21,7 @@ use key_wallet::Utxo; use platform_wallet::changeset::changeset::SweepBatch; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; -use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::sqlite::schema::{blob, core_state}; use rusqlite::params; fn p2pkh(byte: u8) -> Address { @@ -751,3 +751,252 @@ fn a_held_input_with_no_utxo_row_survives_restart_and_stays_spent_when_funded() a restart, and the funding UTXO's own arrival" ); } + +/// A held-but-unfunded input's placeholder (see the test above) can itself +/// need to move again: its first winner can go on to lose a later sweep +/// while the outpoint is still unfunded. Unlike the mobile backends' pending- +/// input table, this schema has no separate relationship the placeholder +/// detaches from — `apply_sweep` always looks up the loser's inputs fresh +/// from its own `core_transactions` blob and touches `core_utxos` by +/// outpoint alone, so the second sweep finds the same placeholder row the +/// first one wrote without any chain-specific bookkeeping. This is the +/// released half: L spends P; W spends P and Q and sweeps L holding P (P is +/// still unfunded); X spends Q and sweeps W, this time releasing P. +#[test] +fn a_chained_sweep_before_funding_still_frees_an_earlier_tombstone_on_release() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE8); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x08); + let funding_txid = Txid::from_byte_array([0x90; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + let funded_input = OutPoint::new(funding_txid, 1); + + let first_loser = Txid::from_byte_array([0x91; 32]); // L + let second_loser = Txid::from_byte_array([0x92; 32]); // W + let final_winner = Txid::from_byte_array([0x93; 32]); // X + + let l = tx_record( + first_loser, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let w_record = tx_record( + second_loser, + vec![unfunded_input, funded_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + + // `funded_input` is an ordinary UTXO from the start; `unfunded_input`'s + // funding side is never observed until the very end. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // L's spend of the unfunded input arrives with no core_utxos row for it. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![l], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // First sweep: W beats L, holding the still-unfunded input. This is what + // writes the placeholder row this test is about. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![first_loser], + superseded_by: second_loser, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &unfunded_input), + "sanity: the first sweep must have left a placeholder row" + ); + // W's own record has to be on hand for the second sweep to look its + // inputs up — the same requirement any ordinary (non-chained) sweep has. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![w_record], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // Second sweep: X beats W, and this time releases the input that has + // been sitting unfunded since the first sweep. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + released_outpoints: vec![unfunded_input], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + unspent(&conn, &w).contains(&unfunded_input), + "the chained sweep released this input, and its own funding TXO is \ + still unobserved — it must read as an ordinary spendable UTXO, not \ + stay stuck under the first sweep's placeholder" + ); + assert!( + !unspent(&conn, &w).contains(&funded_input), + "the second sweep's winner took the other input" + ); +} + +/// The held (not released) half of the chained-before-funding scenario +/// above: the second sweep keeps the still-unfunded input spent instead of +/// releasing it, and the placeholder must end up attributed to the NEW +/// winner rather than the one the second sweep just removed. Verified +/// across a full restart, then confirmed by finally funding the input — it +/// must still read as spent, and the persisted placeholder must name the +/// final winner rather than the intermediate one that no longer has a row. +#[test] +fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winner() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x09); + let funding_txid = Txid::from_byte_array([0xA0; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + + let first_loser = Txid::from_byte_array([0xA1; 32]); // L + let second_loser = Txid::from_byte_array([0xA2; 32]); // W + let final_winner = Txid::from_byte_array([0xA3; 32]); // X + + let l = tx_record( + first_loser, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let w_record = tx_record( + second_loser, + vec![unfunded_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![l], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // First sweep: W beats L, holding the unfunded input. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![first_loser], + superseded_by: second_loser, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // W's own record, needed by the second sweep below. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![w_record], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // Second sweep: X beats W, still holding the same unfunded input. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + + // The funding transaction finally arrives. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&unfunded_input), + "the final winner's claim must survive both sweeps, the restart, \ + and the funding UTXO's own arrival" + ); + let spent_in_txid: Vec = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + w.as_slice(), + &blob::encode_outpoint(&unfunded_input).unwrap()[..] + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + spent_in_txid, + AsRef::<[u8]>::as_ref(&final_winner).to_vec(), + "the placeholder must be attributed to the final winner, not the \ + intermediate one the second sweep already removed" + ); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 57bcbc6759d..7759488ecbc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -936,6 +936,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// left for the cascade, the same as a released materialized input needs /// no special handling beyond the loop above. /// + /// 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. + /// /// Transaction rows are shared across wallets by design (see /// `PersistentTransaction`), and a sweep is a statement about the /// transaction itself rather than about one wallet's view of it, so the @@ -969,6 +980,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { pending.isSweptTombstone = true } backgroundContext.delete(row) + + // Chained-sweep continuation: a pending row an EARLIER sweep already + // tombstoned to `txid` (this transaction, itself a sweep's winner + // until now) is no longer reachable through `row.pendingInputs` — + // see the doc comment above. Find it by the scalar `spendingTxid` + // it carries instead. + var tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.spendingTxid == txid && $0.isSweptTombstone == true } + ) + tombstoneDescriptor.includePendingChanges = true + let priorTombstones = try backgroundContext.fetch(tombstoneDescriptor) + for pending in priorTombstones { + if released.contains(pending.outpoint) { + backgroundContext.delete(pending) + } else { + pending.spendingTxid = supersededBy + } + } } /// Find or create the `PersistentWallet` row for `walletId`. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index b3658c8fd7a..4c949224c82 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -497,6 +497,184 @@ final class SweptTransactionPersistTests: XCTestCase { 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)]) + + 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, 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)]) + + // 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)]) + + 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) + } + /// 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 From 253ed2b6beae62c0ca9a572f70e332eb07e84bd7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:17:34 +0300 Subject: [PATCH 015/102] fix: fail closed when a persister has not attested sweep-removal support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WalletChangeSetFFI` has no size or version header, so a callback compiled against the pre-sweep struct layout — an old C consumer, or a Kotlin `NativePersistenceBridge` subclass that never overrode `onWalletChangesetTransactionsSwept` — reads the unchanged prefix and returns success without ever seeing `sweeps`. `store()` coming back `Ok` in that case proves nothing about whether the removal actually happened; the wallet-event adapter was trusting it anyway, clearing the round and letting a swept loser return at the next `load()`. Add `PersistenceCapabilities::CORE_SWEEP_REMOVAL`, the same fail-closed contract mechanism already used for invitations, asset-lock reconciliation and the rest: a bit a backend must explicitly attest, not one inferred from schema presence or a generic successful write. The FFI persister's structural half requires `on_persist_wallet_changeset_fn` to be wired (the only callback that ever carries `sweeps`) — necessary but not sufficient, since that pointer's signature didn't change — and the semantic half comes only from the host's own declared-capabilities value, which an unrecompiled binary has no way to have set for a bit it predates. The gate itself lives in `core_bridge::commit_batch`, the single choke point every core changeset — including a sweep-only round — passes through before reaching the persister: a `store()` that succeeds on a sweep-bearing round is treated as durable only when the backend attests the bit; otherwise the round is faulted exactly like a `store()` rejection, via the fail-closed watermark-freeze guard dashpay/platform#4069 already added for this class of problem. That means "fail closed" here is neither refusing to register the wallet nor refusing to start sync — both are all-or-nothing and would break every wallet on a backend that is otherwise fine, including SQLite before this same commit adds the bit to it. Freezing only the affected wallet's durable sync watermark keeps the guarantee local to the actual gap: the round's non-sweep data still lands, nothing is ever reported durable that the backend cannot apply, and the host-visible hard-fault signal from #4069 surfaces the problem instead of hiding it. All three in-tree backends now attest the bit: SQLite (`apply_sweep` resolves chained sweeps by outpoint with no extra state, per the previous commit), and Swift/Kotlin (both fixed by the previous two commits). `NativePersistenceBridge.onWalletChangesetTransactionsSwept`'s default body is documented as the exact shape this bit exists to catch: a subclass overriding it must also add the bit to its own `persistenceCapabilitiesBits()`. --- .../dashsdk/ffi/NativePersistenceBridge.kt | 8 + .../PlatformWalletPersistenceHandler.kt | 4 +- .../dashsdk/wallet/PlatformWalletManager.kt | 7 + .../PlatformWalletPersistenceHandlerTest.kt | 3 +- .../rs-platform-wallet-ffi/src/persistence.rs | 65 +++++- .../src/sqlite/persister.rs | 9 + .../src/changeset/core_bridge.rs | 202 ++++++++++++++++++ .../src/changeset/persistence_capabilities.rs | 15 ++ .../PlatformWalletManager.swift | 5 + .../PlatformWalletPersistenceHandler.swift | 1 + .../InvitationPersistenceTests.swift | 4 + 11 files changed, 320 insertions(+), 3 deletions(-) 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 126e606ea89..ef535897e20 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 @@ -315,6 +315,14 @@ abstract class NativePersistenceBridge { * spent. The set cannot be inferred from [supersededBy] — that * transaction may pay entirely to outside addresses and never be * reported here at all. + * + * The default body below returns success without deleting anything — + * exactly the "reads the unchanged prefix and reports success" failure + * mode `PersistenceCapabilities::CORE_SWEEP_REMOVAL` exists to catch. A + * subclass overriding this must also add that bit to + * [PlatformWalletPersistenceHandler.persistenceCapabilitiesBits]'s + * result; the Rust side will not trust a bare `Int` return of `0` here + * as proof the removal happened. */ open fun onWalletChangesetTransactionsSwept( walletId: ByteArray, 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 0f84bb18844..7b881c7a942 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 @@ -135,7 +135,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. @@ -3312,6 +3313,7 @@ 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 = 0x400 private const val TAG = "DashPersistence" 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 07d143c1c57..723c0b19e8a 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 @@ -60,6 +60,13 @@ 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 swept transactions are durably removed: + * the loser's row (and any tombstoned pending-input claim standing + * in for a not-yet-materialized UTXO) actually leaves Room. Mirrors + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL`. + */ + const val CORE_SWEEP_REMOVAL: Long = 1L shl 10 } } 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 ab61efd23d3..1a5872eb565 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 @@ -65,7 +65,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0x3bfL, handler.persistenceCapabilitiesBits()) + assertEquals(0x7bfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -78,6 +78,7 @@ 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)) } // ── Standalone (non-bracketed) writes ───────────────────────────── diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 04a4e29ea1d..5b27ea98d60 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -115,6 +115,7 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DEFERRED_CONTACT_CRYPTO: u64 = pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << 9; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 10; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -1062,6 +1063,21 @@ impl FFIPersister { if self.callbacks.on_persist_token_balances_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } + // `on_persist_wallet_changeset_fn` is the one callback that ever + // carries `WalletChangeSetFFI.sweeps` — it is also the callback + // `PROVIDER_TRANSACTIONS` above gates on, and its C signature did not + // change when the sweep fields were appended to the struct it + // receives a pointer to. So its presence alone proves nothing about + // whether the host actually reads those fields: an out-of-tree + // caller built against the pre-sweep struct layout still has this + // pointer wired, reads the unchanged prefix, and returns success. + // That gap is exactly why this bit is also gated by + // `declared_capabilities` in `persistence_capabilities()` below — + // the intersection requires the host to explicitly attest the + // semantic contract, not just have the vtable slot filled in. + if self.callbacks.on_persist_wallet_changeset_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + } #[cfg(feature = "shielded")] if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() @@ -6187,6 +6203,48 @@ mod tests { assert!(!capabilities.contains(PersistenceCapabilities::WALLET_RESTORE)); } + /// `CORE_SWEEP_REMOVAL` rides the same callback pointer as + /// `PROVIDER_TRANSACTIONS` (`on_persist_wallet_changeset_fn`), and that + /// pointer's C signature is unchanged by the sweep fields appended to + /// `WalletChangeSetFFI` — an out-of-tree host built before this bit + /// existed still has it wired. The bit must therefore come from the + /// host's explicit declaration, not from the callback's mere presence: + /// wired-but-undeclared and declared-but-unwired must each attest + /// nothing, and only both together attest the bit. + #[test] + fn core_sweep_removal_requires_both_the_callback_and_the_declaration() { + fn wired_callbacks() -> PersistenceCallbacks { + PersistenceCallbacks { + on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + ..Default::default() + } + } + + // Structurally complete, but the host never declared it (the + // pre-sweep-aware binary case): absent. + assert!( + !declared_persister(wired_callbacks(), PersistenceCapabilities::NONE) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL) + ); + + // Declared, but the callback pointer isn't even wired: absent. + assert!(!declared_persister( + PersistenceCallbacks::default(), + PersistenceCapabilities::CORE_SWEEP_REMOVAL + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Both: attested. + assert!(declared_persister( + wired_callbacks(), + PersistenceCapabilities::CORE_SWEEP_REMOVAL + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -6241,7 +6299,8 @@ mod tests { .union(PersistenceCapabilities::PROVIDER_TRANSACTIONS) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::WALLET_RESTORE) - .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); cb.on_changeset_begin_fn = Some(noop_begin); cb.on_changeset_end_fn = Some(noop_end); cb.on_persist_account_registrations_fn = Some(noop_registrations); @@ -6372,6 +6431,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS, PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL, + PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d331530e9b2..9ffd3959c9f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -829,6 +829,14 @@ impl PlatformWalletPersistence for SqlitePersister { // Do NOT attest WALLET_RESTORE (and therefore not provider restore): // `load()` still reports `ClientStartState::wallets` in // `LOAD_UNIMPLEMENTED`. Shielded state lives in a separate store. + // `core_state::apply_sweep` deletes the loser row and resolves every + // input it claimed via `released` — including the held-but-unfunded + // case, where it leaves a `core_utxos` placeholder keyed by outpoint + // rather than by any relationship to the loser. That is what makes a + // later sweep of the winner that replaces it chain-safe with no + // extra bookkeeping: the next `apply_sweep` call matches the same + // outpoint directly, so it repoints or releases the placeholder + // regardless of how many sweeps deep it is. PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -836,6 +844,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) } /// Merge `changeset` into the per-wallet buffer. diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 0970b0935e2..a32b419c00c 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -55,6 +55,7 @@ use crate::changeset::changeset::{ 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; @@ -450,6 +451,20 @@ where // 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; + + // `WalletChangeSetFFI` has no size/version header, so a persister + // compiled against a pre-sweep struct layout — an old C callback, or + // a Kotlin subclass that never overrode + // `onWalletChangesetTransactionsSwept` — reads the unchanged prefix + // 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); + let cs = PlatformWalletChangeSet { core: Some(core), // Tracked-asset-lock rows reconstructed from this drain's @@ -460,6 +475,38 @@ where ..PlatformWalletChangeSet::default() }; match persister.store(wallet_id, cs) { + 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 let Some(h) = offered_height { + diag.record_rejected(h); + } + fault.fault_wallet(wallet_id, sync_fault); + if !is_faulted { + diag.faulted += 1; + } + if !*freeze_logged { + *freeze_logged = true; + 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); @@ -2171,6 +2218,7 @@ mod tests { struct ProbePersister { obs: UnboundedSender, fail_once: Mutex>, + capabilities: crate::changeset::PersistenceCapabilities, } impl ProbePersister { @@ -2178,6 +2226,19 @@ mod tests { Self { obs, fail_once: Mutex::new(HashSet::new()), + 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) } } fn fail_next(&self, wallet_id: WalletId) { @@ -2186,6 +2247,10 @@ mod tests { } impl PlatformWalletPersistence for ProbePersister { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + self.capabilities + } + fn store( &self, wallet_id: WalletId, @@ -2616,6 +2681,143 @@ mod tests { } } + /// 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]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + /// dashpay/platform#4406 (finding 2): `WalletChangeSetFFI` has no size or + /// version header, so an older callback compiled against the pre-sweep + /// struct layout reads the unchanged prefix, returns success, and never + /// sees `core.sweeps` at all. 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 = obs_rx.recv().await.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 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 the loop has produced at least one store carrying the + // watermark, or the channel goes quiet. + for _ in 0..10 { + match obs_rx.recv().await { + Some(observed) => { + assert!(!observed.rejected); + if let Some(h) = observed.synced_height { + last_synced = Some(h); + break; + } + } + None => 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 diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 0364200492f..deea74faee7 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -51,6 +51,16 @@ impl PersistenceCapabilities { /// Tracked asset-lock rows, including status and proof updates, can be /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); + /// A stored `CoreChangeSet` whose `sweeps` are non-empty is durably + /// applied: the swept loser's row (and any tombstoned pending-input + /// claim standing in for a not-yet-materialized UTXO) actually leaves + /// the backing store, not merely accepted-and-ignored. `WalletChangeSetFFI` + /// has no size/version header, so an older callback compiled against a + /// pre-sweep struct layout reads the unchanged prefix, returns success, + /// and never sees the appended fields at all — this bit is what tells the + /// wallet the round-trip was actually implemented rather than silently + /// truncated. + pub const CORE_SWEEP_REMOVAL: Self = Self(1 << 10); /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -131,6 +141,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::TRACKED_ASSET_LOCKS, "tracked_asset_locks", ), + ( + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + "core_sweep_removal", + ), ]; KNOWN @@ -160,6 +174,7 @@ mod tests { assert_eq!(PersistenceCapabilities::WALLET_RESTORE.bits(), 0x80); assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); assert_eq!(PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits(), 0x200); + assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x400); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c3e33764823..675ea92a491 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -65,6 +65,11 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Tracked asset-lock rows, including status and proof updates, can be /// persisted. Restart hydration is separately attested by `walletRestore`. public static let trackedAssetLocks: UInt64 = 1 << 9 + /// A stored core changeset's `sweeps` are durably applied: the swept + /// loser's row (and any tombstoned pending-input claim standing in for + /// a not-yet-materialized UTXO) actually leaves the store. Mirrors + /// `PersistenceCapabilities::CORE_SWEEP_REMOVAL`. + public static let coreSweepRemoval: UInt64 = 1 << 10 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 7759488ecbc..608eb8d986e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1680,6 +1680,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.walletRestore | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks + | PlatformWalletPersistenceCapabilities.coreSweepRemoval ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index d9ffd747989..eb9d4653fc1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -57,6 +57,7 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDPNSName`, so this bit is genuinely attested. | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks + | PlatformWalletPersistenceCapabilities.coreSweepRemoval XCTAssertEqual( capabilities.version, @@ -79,6 +80,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 From f503cd5d41922264cd5d1d987651063ffc57d917 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:27:53 +0300 Subject: [PATCH 016/102] fix: release JNI local references per sweep batch Every iteration of the sweep-batch loop in `tramp_persist_wallet_changeset` built `byte_array_cls`, `empty`, `txids_arr`, `winners`, and `released_arr` directly in the trampoline's own local frame, same as the account loop just above it used to. The nested `with_local_frame` calls only cover the temporary per-element byte-array references; the five per-batch locals piled up in the outer frame across every batch. The number of ordered sweep batches in one changeset is not bounded by this ABI, so a large enough one could exhaust ART's local-reference table before the callback returns. Factor the per-batch body into `persist_changeset_sweep_batch` and run it inside its own `with_local_frame`, matching `persist_changeset_account`'s existing pattern for the per-account loop. --- .../rs-unified-sdk-jni/src/persistence.rs | 108 ++++++++++-------- 1 file changed, 62 insertions(+), 46 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 4e16712a3c4..7a508b764fc 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -58,9 +58,9 @@ use platform_wallet_ffi::{ IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, 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; @@ -642,50 +642,17 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( // sweep can keep a coin spent that an earlier one freed, and only // replaying them in sequence preserves that. Each call does its own // hold-then-release, so the ordering holds on the Kotlin side too. + // The batch count is not bounded by this ABI, so — as with the + // account loop above — the whole per-batch construction and call + // runs inside its own local frame; without it, `byte_array_cls`, + // `empty`, and the three per-batch arrays would all pile up in the + // trampoline's own frame across every batch in the changeset, and a + // large enough one can exhaust ART's local-reference table before + // the callback ever returns. for batch in slice_or_empty(cs.sweeps, cs.sweeps_count) { - let byte_array_cls = env.find_class("[B")?; - let empty = env.byte_array_from_slice(&[])?; - - let txids = slice_or_empty(batch.txids, batch.txids_count); - let txids_arr = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; - let winners = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; - for (i, txid) in txids.iter().enumerate() { - env.with_local_frame(8, |env| { - let t = env.byte_array_from_slice(txid)?; - env.set_object_array_element(&txids_arr, i as i32, &t)?; - let w = env.byte_array_from_slice(&batch.superseded_by)?; - env.set_object_array_element(&winners, i as i32, &w) - })?; - } - - // Released outpoints ride as 36-byte keys (raw txid + a - // little-endian vout), the shape the handler stores them in. - let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); - let released_arr = - env.new_object_array(released.len() as i32, &byte_array_cls, &empty)?; - for (i, outpoint) in released.iter().enumerate() { - let mut key = [0u8; 36]; - key[..32].copy_from_slice(&outpoint.txid); - key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); - env.with_local_frame(4, |env| { - let k = env.byte_array_from_slice(&key)?; - env.set_object_array_element(&released_arr, i as i32, &k) - })?; - } - - let code = env - .call_method( - bridge, - "onWalletChangesetTransactionsSwept", - "([B[[B[[B[[B)I", - &[ - (&wid).into(), - (&txids_arr).into(), - (&winners).into(), - (&released_arr).into(), - ], - )? - .i()?; + let code = env.with_local_frame(16, |env| { + persist_changeset_sweep_batch(env, bridge, &wid, batch) + })?; if code != 0 { return Ok(code); } @@ -694,6 +661,55 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( }) } +unsafe fn persist_changeset_sweep_batch( + env: &mut JNIEnv, + bridge: &JObject, + wid: &JByteArray, + batch: &SweepBatchFFI, +) -> Result { + let byte_array_cls = env.find_class("[B")?; + let empty = env.byte_array_from_slice(&[])?; + + let txids = slice_or_empty(batch.txids, batch.txids_count); + let txids_arr = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; + let winners = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; + for (i, txid) in txids.iter().enumerate() { + env.with_local_frame(8, |env| { + let t = env.byte_array_from_slice(txid)?; + env.set_object_array_element(&txids_arr, i as i32, &t)?; + let w = env.byte_array_from_slice(&batch.superseded_by)?; + env.set_object_array_element(&winners, i as i32, &w) + })?; + } + + // Released outpoints ride as 36-byte keys (raw txid + a + // little-endian vout), the shape the handler stores them in. + let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); + let released_arr = env.new_object_array(released.len() as i32, &byte_array_cls, &empty)?; + for (i, outpoint) in released.iter().enumerate() { + let mut key = [0u8; 36]; + key[..32].copy_from_slice(&outpoint.txid); + key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + env.with_local_frame(4, |env| { + let k = env.byte_array_from_slice(&key)?; + env.set_object_array_element(&released_arr, i as i32, &k) + })?; + } + + env.call_method( + bridge, + "onWalletChangesetTransactionsSwept", + "([B[[B[[B[[B)I", + &[ + wid.into(), + (&txids_arr).into(), + (&winners).into(), + (&released_arr).into(), + ], + )? + .i() +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, From 78fd0593014305305eff59220f9df5736ac391ea Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:01:41 +0300 Subject: [PATCH 017/102] fix: withhold the watermark from the round whose sweep cannot be applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability gate faulted the wallet only after `store()` returned, which protects later rounds but not the one that carried the sweep. The adapter folds whatever is buffered, so a `TransactionsSwept` and a following `SyncHeightAdvanced` land in a single changeset — and `synced_height` sits in the unchanged prefix a pre-sweep persister does read and commit. Faulting afterwards 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 is lost for good. Strip `synced_height` before the changeset is handed over. `offered_height` is captured earlier, so the round is still diagnosed as a withheld advance rather than as one that carried no watermark at all. The existing negative test waits for the sweep's store before sending its watermark, so it never exercised the folded path; the new one buffers both events before the adapter starts, which makes the coalescing deterministic rather than racy. It was confirmed to fail without the fix, observing Some(900) where None is required. --- .../src/changeset/core_bridge.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index a32b419c00c..157dac54cce 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -464,6 +464,19 @@ where && !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), @@ -2759,6 +2772,61 @@ mod tests { 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(), + )); + + let observed = obs_rx + .recv() + .await + .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 From a23f1325e9534f41720dc860225062e957089ffa Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:01:41 +0300 Subject: [PATCH 018/102] perf(platform-wallet-storage): hoist the invariant sweep claim sets `swept_txids` and `claimed_by_survivors` depend on the whole changeset, not on any one batch, but both were rebuilt for every sweep batch with the write transaction open. The adapter folds up to a full drain into one store, so that re-hashed every swept txid and every surviving record input once per sweep. Build them once; only the per-batch release set stays inside the loop, since that is the part a later batch is meant to be able to correct. --- .../src/sqlite/schema/core_state.rs | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 13afccfe7e3..a4a30a8dba1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -135,29 +135,35 @@ pub fn apply( // and batch by batch in order: each sweep is only true of the wallet it // saw, so a later one keeping a coin spent has to be able to correct an // earlier one that freed it. + // The surviving claims are a property of the whole changeset, not of any + // one batch, so they are built once: the adapter folds up to a full drain + // into a single store, and rebuilding them per batch would re-hash every + // swept txid and every surviving record input once per sweep, with the + // write transaction open the whole time. + // + // `apply_sweep` below is what attributes a held input to `superseded_by` + // via `spent_in_txid`, and that only happens once it runs — so at this + // point in the round the table cannot yet tell a live claim in *this* + // round from the one a sweep is about to displace. The changeset carries + // the answer instead: any record in this round that is not swept by *any* + // batch and spends a released outpoint is that live claim, and the coin + // stays spent. + let swept_txids: HashSet = cs + .sweeps + .iter() + .flat_map(|b| b.txids.iter()) + .copied() + .collect(); + let claimed_by_survivors: HashSet = cs + .records + .iter() + .filter(|record| !swept_txids.contains(&record.txid)) + .flat_map(|record| record.transaction.input.iter()) + .map(|input| input.previous_output) + .collect(); for batch in &cs.sweeps { - // The released set describes the wallet when this sweep was emitted, - // and a round can fold in a later transaction that legitimately spent - // one of the freed coins. `apply_sweep` below is what attributes a - // held input to `superseded_by` via `spent_in_txid`, and that only - // happens once it runs — so at this point in the round the table - // cannot yet tell a live claim in *this* round from the one the sweep - // is about to displace. The changeset carries the answer instead: any - // record in this round that is not swept by *any* batch and spends a - // released outpoint is that live claim, and the coin stays spent. - let swept_txids: HashSet = cs - .sweeps - .iter() - .flat_map(|b| b.txids.iter()) - .copied() - .collect(); - let claimed_by_survivors: HashSet = cs - .records - .iter() - .filter(|record| !swept_txids.contains(&record.txid)) - .flat_map(|record| record.transaction.input.iter()) - .map(|input| input.previous_output) - .collect(); + // Only this stays per batch: a release is true of the wallet its own + // sweep saw, which is what lets a later batch correct an earlier one. let released: HashSet = batch .released_outpoints .iter() From fe594ebecbe8a9399d48d4e84e99af942492e7d1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:01:42 +0300 Subject: [PATCH 019/102] fix(swift-sdk): fail the round when the sweep's wallet lookup throws `findWalletRecord` swallows the error with `try?`, so a thrown SwiftData fetch was indistinguishable from a successful "no such wallet" and the callback returned success without applying the sweep. Rust then discarded the subtractive event; the round withholds its own watermark, but a later successful callback can persist a newer height beyond the removal that never landed, and the swept transaction returns after restart. Split out a throwing `fetchWalletRecord` and use it here: a successful empty result stays a no-op, a failure fails the round. `applySweptTransaction` already handles its own lookups this way. --- .../PlatformWalletPersistenceHandler.swift | 27 +++++++++++++-- .../SweptTransactionPersistTests.swift | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 608eb8d986e..4d739d56680 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -784,8 +784,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) -> Bool { onQueue { // A stale post-deletion callback is not a failure — there is - // simply nothing left to write to. - guard let wallet = findWalletRecord(walletId: walletId) else { return true } + // 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. @@ -1019,10 +1032,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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 4c949224c82..3f97cf140fd 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -418,6 +418,40 @@ final class SweptTransactionPersistTests: XCTestCase { ) } + /// 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)]) + + XCTAssertTrue(applied, "a stale post-deletion callback is not a failure") + XCTAssertNotNil( + transaction(container, txid: sweptTxid), + "and it must not have applied anything either" + ) + } + /// 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. From 38b5f7b92620718edf1d3b5b74bab8ea682607ee Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:01:42 +0300 Subject: [PATCH 020/102] perf(platform-wallet-storage): skip the survivor set on a sweepless round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invariant sets serve only the sweep loop, but were built for every changeset — hashing every input of every record, with the write transaction open, for a loop that does not run on the ordinary path. Return early when the round carries no sweeps. Also bounds the capability test's drain loop. Its comment claimed the loop stops when the channel goes quiet, but the adapter and the probe both keep the sender alive, so a regression that stopped the watermark would hang the test until the outer CI timeout instead of failing on its assertion. --- .../src/sqlite/schema/core_state.rs | 7 ++++++ .../src/changeset/core_bridge.rs | 23 +++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index a4a30a8dba1..495b81a2351 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -135,6 +135,13 @@ pub fn apply( // and batch by batch in order: each sweep is only true of the wallet it // saw, so a later one keeping a coin spent has to be able to correct an // earlier one that freed it. + if cs.sweeps.is_empty() { + // The ordinary round. Everything below serves the sweep loop, and + // building the survivor set would hash every input of every record + // for a loop that never runs — with the write transaction open. + return Ok(()); + } + // The surviving claims are a property of the whole changeset, not of any // one batch, so they are built once: the adapter folds up to a full drain // into a single store, and rebuilding them per batch would re-hash every diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 157dac54cce..6a042dfdbf5 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -2420,7 +2420,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), @@ -2759,7 +2762,10 @@ mod tests { // 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 = 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(40)); assert_eq!( sentinel.synced_height, None, @@ -2856,18 +2862,21 @@ mod tests { tx.send(sync_height_event(wallet_id, 700)).unwrap(); let mut last_synced = None; - // Drain until the loop has produced at least one store carrying the - // watermark, or the channel goes quiet. + // 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 obs_rx.recv().await { - Some(observed) => { + 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; } } - None => break, + Ok(None) | Err(_) => break, } } assert_eq!( From eff82093a7c5816cc40f36971b3be6645745c4f8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:53:02 +0300 Subject: [PATCH 021/102] fix: scope a swept transaction's per-input decisions to the owning wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transaction row is shared across every wallet that touches it — the same loser can spend a coin from wallet A and a coin from wallet B in one transaction — but upstream computes `released_outpoints` separately per wallet (`CheckTransactionsResult::per_wallet_released_outpoints`). The mobile persistence handlers did not respect that: the first wallet's sweep callback applied its own released set to every input on the shared row, including inputs it did not own, then deleted the row outright. A second wallet's callback for the same loser found the row already gone and became a silent no-op, so its own coin's release-or-hold decision was never applied — a coin one wallet was told came free could stay wrongly marked spent forever, or vice versa, depending on which wallet's callback happened to run first. Split the operation by what is actually global versus per-wallet. Deleting the loser's row and cascading away the outputs it created is correct to do once — the loser is dead for every wallet. The spend decision on each input is per-wallet: a callback now only touches (holds, releases, or tombstones) the inputs and pending-inputs it owns, and deletes the shared row only once no other wallet's input still references it. Whichever wallet's callback is the last to run performs the delete, so processing order stops mattering; a wallet whose callback never arrives leaves a dead row behind with every other wallet's inputs already correctly decided, cleaned up by a re-emitted sweep. Swift's `applySweptTransaction` now takes the calling `walletId` and resolves ownership through `resolvedWalletId(of:)` rather than a raw `PersistentTxo.walletId` compare — that column is empty on rows migrated from an older schema, and comparing it raw would silently leave those coins undecided forever. Kotlin's `TxoDao`/`DocumentDao` gain a `walletId` filter on `holdSpentWithoutSpender`, `releaseByOutpoint`, and the tombstone queries, plus `hasOtherWalletSpender`/`hasOtherWalletPendingInput` existence checks that gate the delete; `TxoEntity.walletId` and `PendingInputEntity.walletId` have no equivalent migration gap (both were present in the schema from the start), so a direct compare is safe there. No Room entity changed, so no migration is needed. SQLite needed no fix: `core_transactions` and `core_utxos` are keyed by `(wallet_id, txid)` / `(wallet_id, outpoint)`, so two wallets persisting the same loser txid each get their own row — there is nothing here for one wallet's `apply_sweep` call to leak into another's. A new test confirms this rather than changing any SQLite code. Regression coverage on both mobile backends: a loser spends one coin from each of two wallets, a winner takes only one, and both wallets' callbacks are driven in both orders — each new test confirmed to fail on the ordering that used to lose data before the fix. --- .../PlatformWalletPersistenceHandler.kt | 53 +++-- .../dashsdk/persistence/dao/DocumentDao.kt | 57 ++++-- .../dashsdk/persistence/dao/TxoDao.kt | 51 ++++- .../PlatformWalletPersistenceHandlerTest.kt | 182 ++++++++++++++++++ .../tests/sqlite_transaction_sweeps.rs | 151 +++++++++++++++ .../PlatformWalletPersistenceHandler.swift | 61 +++++- .../SweptTransactionPersistTests.swift | 155 +++++++++++++++ 7 files changed, 670 insertions(+), 40 deletions(-) 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 7b881c7a942..72fe8f5aaf0 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 @@ -1076,9 +1076,20 @@ class PlatformWalletPersistenceHandler( * and after that nothing finds those rows. * * Transaction rows are keyed by txid alone, shared across wallets by - * design, and a sweep is a statement about the transaction rather than - * about one wallet's view of it — so the row goes without narrowing to - * the emitting wallet. + * design — the same loser can spend coins from more than one wallet at + * once — but [releasedOutpoints] is not shared: upstream computes it per + * wallet (`per_wallet_released_outpoints`), so this call's set says + * nothing about an input a *different* wallet's coin claims on the same + * row. Every DAO call above therefore carries [walletId] and only + * touches that wallet's own rows (own `TxoEntity`s via `TxoDao`'s + * `walletId` column, own `PendingInputEntity`s via the same column on + * that table). The row itself stays a statement about the transaction + * as a whole, so it is deleted only once [hasOtherWalletClaim] finds + * nothing left pointing at it — whichever wallet's callback is the last + * one to run performs the delete, so processing order stops mattering. + * A wallet whose callback never arrives 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. */ override fun onWalletChangesetTransactionsSwept( walletId: ByteArray, @@ -1089,8 +1100,8 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> if (db.walletDao().getByWalletId(walletId) == null) return@stage // Hold every input first, then free the ones upstream named: the - // released set is wallet-scoped across the round's removals, so - // it is applied once rather than per transaction. + // released set spans the whole round's removals, so it is + // applied once rather than per transaction. // // The order is load-bearing, not cosmetic. Holding detaches the // rows this round's removals still claim, and the release only @@ -1099,27 +1110,47 @@ class PlatformWalletPersistenceHandler( // freed out from under it. val released = releasedOutpoints.toList() for (i in txids.indices) { - db.txoDao().holdSpentWithoutSpender(txids[i]) - db.documentDao().tombstoneUnreleasedPendingInputs(txids[i], supersededBy[i], released) + db.txoDao().holdSpentWithoutSpender(txids[i], walletId) + db.documentDao().tombstoneUnreleasedPendingInputs( + txids[i], supersededBy[i], released, walletId, + ) // A pending input an EARLIER sweep already tombstoned to // txids[i] (that txid was itself a sweep's winner, and is // now being swept in turn) detached from the relationship // `tombstoneUnreleasedPendingInputs` above matches on, so it // has to be found and carried forward separately — see // [DocumentDao.deleteReleasedSweptTombstones]. - db.documentDao().deleteReleasedSweptTombstones(txids[i], released) - db.documentDao().retargetSweptTombstones(txids[i], supersededBy[i], released) + db.documentDao().deleteReleasedSweptTombstones(txids[i], released, walletId) + db.documentDao().retargetSweptTombstones(txids[i], supersededBy[i], released, walletId) } for (outpoint in releasedOutpoints) { - db.txoDao().releaseByOutpoint(outpoint) + db.txoDao().releaseByOutpoint(outpoint, walletId) } for (txid in txids) { - db.transactionDao().deleteByTxid(txid) + if (!hasOtherWalletClaim(db, txid, walletId)) { + db.transactionDao().deleteByTxid(txid) + } } } 0 } + /** + * Whether some wallet other than [walletId] still has a TXO or pending + * input pointing at [txid] as its spender, after this call's own + * hold/release/tombstone updates above have already cleared or detached + * everything [walletId] itself owns. See the class doc on + * [onWalletChangesetTransactionsSwept] for why this is what decides + * whether the shared `transactions` row is safe to delete yet. + */ + private suspend fun hasOtherWalletClaim( + db: DashDatabase, + txid: ByteArray, + walletId: ByteArray, + ): Boolean = + db.txoDao().hasOtherWalletSpender(txid, walletId) || + db.documentDao().hasOtherWalletPendingInput(txid, walletId) + // ── Identities ──────────────────────────────────────────────────── override fun onPersistIdentityUpsert( 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 6c2fddb85e6..5b8ad286048 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 @@ -175,9 +175,9 @@ interface DocumentDao { fun observePendingInputsByWallet(walletId: ByteArray): Flow> /** - * Repoint every pending input still recorded against loser [txid] at - * [supersededBy] instead, except the outpoints named in - * [releasedOutpoints] — those came free and are left for + * Repoint every pending input of [walletId]'s own still recorded + * against loser [txid] at [supersededBy] instead, except the outpoints + * named in [releasedOutpoints] — those came free and are left for * `onWalletChangesetTransactionsSwept`'s own cascade-delete of [txid] * to remove. [spendingTransactionTxid] is cleared first so the FK no * longer targets the row about to be deleted (a live `transactions` @@ -185,16 +185,25 @@ interface DocumentDao { * marks the row so `onWalletChangesetUtxoAdded` knows this is a durable * claim rather than an ordinary in-flight spend once the funding TXO * finally lands. + * + * [txid] can be shared across wallets — the same loser can spend coins + * from more than one of them — and upstream hands each wallet its own + * [releasedOutpoints], computed only from that wallet's point of view. + * The `walletId` filter is what keeps this call from repointing or + * tombstoning a row a different wallet owns using a release decision + * that was never made about it. */ @Query( "UPDATE pending_inputs SET spendingTransactionTxid = NULL, " + "spendingTxid = :supersededBy, isSweptTombstone = 1 " + - "WHERE spendingTransactionTxid = :txid AND outpoint NOT IN (:releasedOutpoints)", + "WHERE spendingTransactionTxid = :txid AND walletId = :walletId " + + "AND outpoint NOT IN (:releasedOutpoints)", ) suspend fun tombstoneUnreleasedPendingInputs( txid: ByteArray, supersededBy: ByteArray, releasedOutpoints: List, + walletId: ByteArray, ) /** @@ -206,30 +215,56 @@ interface DocumentDao { * Delete the ones this round frees. Nothing else owns them once * detached — unlike a live pending row, there is no cascade-delete of * [txid]'s `transactions` row left to do that job for them. + * + * A tombstone names one specific wallet's coin — the `walletId` it was + * written with — so [walletId] here has to be the same wallet whose + * [releasedOutpoints] produced it; otherwise this would apply one + * wallet's release decision to a claim it was never entitled to make. */ @Query( "DELETE FROM pending_inputs WHERE spendingTxid = :txid AND isSweptTombstone = 1 " + - "AND outpoint IN (:releasedOutpoints)", + "AND walletId = :walletId AND outpoint IN (:releasedOutpoints)", + ) + suspend fun deleteReleasedSweptTombstones( + txid: ByteArray, + releasedOutpoints: List, + walletId: ByteArray, ) - suspend fun deleteReleasedSweptTombstones(txid: ByteArray, releasedOutpoints: List) /** * The held half of [deleteReleasedSweptTombstones]: repoint every - * surviving tombstone of [txid] at the new [supersededBy] instead, so a - * third sweep down the chain can still find it by scalar `spendingTxid`. - * [isSweptTombstone] is already set from the first tombstoning and - * stays set. + * surviving tombstone of [txid] owned by [walletId] at the new + * [supersededBy] instead, so a third sweep down the chain can still + * find it by scalar `spendingTxid`. [isSweptTombstone] is already set + * from the first tombstoning and stays set. */ @Query( "UPDATE pending_inputs SET spendingTxid = :supersededBy " + - "WHERE spendingTxid = :txid AND isSweptTombstone = 1 " + + "WHERE spendingTxid = :txid AND isSweptTombstone = 1 AND walletId = :walletId " + "AND outpoint NOT IN (:releasedOutpoints)", ) suspend fun retargetSweptTombstones( txid: ByteArray, supersededBy: ByteArray, releasedOutpoints: List, + walletId: ByteArray, + ) + + /** + * Whether some wallet other than [walletId] still has a live pending + * input pointing at [txid] as its spending transaction. + * + * Mirrors [TxoDao.hasOtherWalletSpender] for the pending-input side of + * the same shared-row problem: [txid]'s `transactions` row is a + * statement about the transaction as a whole, so only the callback that + * finds no other wallet's claim left on it — TXO or pending input — is + * allowed to delete it. + */ + @Query( + "SELECT EXISTS(SELECT 1 FROM pending_inputs " + + "WHERE spendingTransactionTxid = :txid AND walletId != :walletId)", ) + suspend fun hasOtherWalletPendingInput(txid: ByteArray, walletId: ByteArray): Boolean @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) 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 380fe1f0c09..dd7f7dfbf41 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 @@ -44,8 +44,8 @@ interface TxoDao { suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List /** - * Hold every coin [spendingTxid] claimed out of the restore set, without - * naming a spender for them. + * Hold every coin of [walletId]'s own that [spendingTxid] claimed out of + * the restore set, without naming a spender for them. * * Used when [spendingTxid] was swept: it can never confirm, so its claim * is not a spend, but most of the coins it named really were taken — by @@ -53,17 +53,29 @@ interface TxoDao { * unconfirmed, so its inputs sit at `isSpent = 0`, and deleting it would * otherwise return all of them, the consumed one included. * + * [spendingTxid] can be shared: the same `transactions` row spends coins + * from more than one wallet at once, and upstream computes a separate + * released set per wallet (`per_wallet_released_outpoints`). This + * wallet's set has no say over a coin a *different* wallet owns, so the + * `walletId` filter keeps this call from holding a coin some other + * wallet's own callback — already run, still to come, or never coming + * at all — is the only one entitled to decide. + * * Run this *before* deleting the transaction, while the link that * identifies those rows is still there — the foreign key nulls * `spendingTxid` on delete, and afterwards nothing finds them. Then * clear the genuinely free ones with [releaseByOutpoint]. */ - @Query("UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL WHERE spendingTxid = :spendingTxid") - suspend fun holdSpentWithoutSpender(spendingTxid: ByteArray) + @Query( + "UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL " + + "WHERE spendingTxid = :spendingTxid AND walletId = :walletId", + ) + suspend fun holdSpentWithoutSpender(spendingTxid: ByteArray, walletId: ByteArray) /** - * Mark one outpoint unspent again — a coin a sweep released, meaning no - * surviving transaction spent it *at the time the sweep was computed*. + * Mark one outpoint of [walletId]'s own unspent again — a coin a sweep + * released, meaning no surviving transaction spent it *at the time the + * sweep was computed*. * * Keyed by outpoint rather than by spender because that is how upstream * reports it: the transaction that took the other inputs may never be @@ -76,13 +88,34 @@ interface TxoDao { * every record is written before sweeps are processed — so by the time * this runs the coin may already be claimed again. Only rows * [holdSpentWithoutSpender] just detached qualify; anything a live - * transaction still claims keeps that claim. + * transaction still claims keeps that claim. The `walletId` filter is + * the same ownership guard as [holdSpentWithoutSpender]: a released set + * is only ever true of the wallet that computed it, so it should never + * be able to touch another wallet's row even if an outpoint were ever + * to collide. */ @Query( "UPDATE txos SET isSpent = 0, spendingInputIndex = NULL " + - "WHERE outpoint = :outpoint AND spendingTxid IS NULL", + "WHERE outpoint = :outpoint AND spendingTxid IS NULL AND walletId = :walletId", ) - suspend fun releaseByOutpoint(outpoint: ByteArray) + suspend fun releaseByOutpoint(outpoint: ByteArray, walletId: ByteArray) + + /** + * Whether some wallet other than [walletId] still has a TXO pointing at + * [spendingTxid] as its spender. + * + * `transactions` rows are shared across wallets — the same on-chain tx + * can spend coins from several of them — so [spendingTxid]'s row is a + * statement about the transaction as a whole and only one wallet's + * callback should ever delete it. This is the check that lets each + * callback decide whether it is that one: after [holdSpentWithoutSpender] + * and [releaseByOutpoint] have applied *this* wallet's own decisions + * (which always clear or detach its own rows), anything still pointing + * at [spendingTxid] belongs to a wallet that has not weighed in yet, and + * the delete has to wait for it. + */ + @Query("SELECT EXISTS(SELECT 1 FROM txos WHERE spendingTxid = :spendingTxid AND walletId != :walletId)") + suspend fun hasOtherWalletSpender(spendingTxid: ByteArray, walletId: ByteArray): Boolean @Upsert suspend fun upsert(txo: TxoEntity) 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 1a5872eb565..13f8035f594 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 @@ -2387,6 +2387,188 @@ class PlatformWalletPersistenceHandlerTest { ) } + /** + * 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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 { + // Before the fix, whichever wallet's callback ran FIRST deleted the + // shared loser row outright, using only its own released set to + // decide every input on the row — including the other wallet's + // coin. Running wallet B (which releases nothing) first used to + // delete the row before wallet A's release of P ever landed, so + // A's later call found nothing to update and P stayed wrongly + // spent forever. This pins the fix: the row must survive until + // both wallets have weighed in, and each wallet's coin must reflect + // only that wallet's own decision. + 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) + + // Wallet B first: its own released set names nothing, so its coin + // (Q) is held rather than freed. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + ) + handler.onChangesetEnd(walletB, success = true) + + assertNotNull( + "wallet B alone must not delete a row wallet A still has a claim on", + db.transactionDao().getByTxid(loserTxid), + ) + val untouchedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet B's callback must not touch wallet A's coin", untouchedP.isSpent) + assertTrue( + "P is still linked to the loser, untouched", + loserTxid.contentEquals(untouchedP.spendingTxid), + ) + + // Wallet A second: releases P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the last wallet to run performs the delete", db.transactionDao().getByTxid(loserTxid)) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's own release must free its own coin", freedP.isSpent) + assertNull(freedP.spendingTxid) + + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue( + "wallet B's earlier decision to hold Q must survive wallet A's callback", + heldQ.isSpent, + ) + assertNull(heldQ.spendingTxid) + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletAThenWalletB() = runTest { + // Mirror of the ordering above: wallet A (which releases P) runs + // first this time. The fix is meant to be order-independent, so + // this must land on the exact same end state. + 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) + + // Wallet A first: releases P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), + ) + handler.onChangesetEnd(walletId, success = true) + + assertNotNull( + "wallet A alone must not delete a row wallet B still has a claim on", + db.transactionDao().getByTxid(loserTxid), + ) + val untouchedQ = db.txoDao().getByOutpoint(q)!! + assertFalse("wallet A's callback must not touch wallet B's coin", untouchedQ.isSpent) + assertTrue( + "Q is still linked to the loser, untouched", + loserTxid.contentEquals(untouchedQ.spendingTxid), + ) + + // Wallet B second: releases nothing. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + ) + handler.onChangesetEnd(walletB, success = true) + + assertNull("the last wallet to run performs the delete", db.transactionDao().getByTxid(loserTxid)) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's earlier release must survive wallet B's callback", freedP.isSpent) + assertNull(freedP.spendingTxid) + + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's own decision to hold its coin must stick", heldQ.isSpent) + assertNull(heldQ.spendingTxid) + } + @Test fun sweptTransactionRollsBackWithItsRound() = runTest { // The deletion is staged in the same buffered transaction as every diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index f5e025cfeb9..0a6440909b9 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1000,3 +1000,154 @@ fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winne intermediate one the second sweep already removed" ); } + +/// Confirmation, not a fix: the review finding that motivated the Swift/ +/// Kotlin backend changes (a shared `PersistentTransaction` row updated with +/// one wallet's `released_outpoints` before another wallet's own callback +/// gets a turn) has no analog here. `core_transactions` and `core_utxos` are +/// keyed by `(wallet_id, txid)` / `(wallet_id, outpoint)` — there is no row +/// for a "loser shared across wallets" to BE, only two wallets each holding +/// their own copy of a transaction that happens to carry the same txid. +/// `apply_sweep` re-derives every input from the loser's own stored blob and +/// matches `core_utxos` strictly within the calling wallet's rows, so one +/// wallet's sweep call cannot see, let alone touch, another wallet's copy. +/// +/// This seeds the reviewer's exact shape — the same loser txid persisted +/// independently by two wallets, each holding a different coin of its own — +/// and sweeps them in opposite decisions (wallet 1 releases its coin, +/// wallet 2 holds its own) to show neither call perturbs the other wallet's +/// row at all, regardless of which runs first. +#[test] +fn sweep_of_a_shared_loser_txid_is_independent_per_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let w1: WalletId = wid(0xE8); + let w2: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x31); + let addr2 = p2pkh(0x32); + let funding_txid = Txid::from_byte_array([0x30; 32]); + // Same txid recorded independently in both wallets' storage — as two + // wallets sharing one on-chain transaction each would. + let loser_txid = Txid::from_byte_array([0x33; 32]); + let winner_txid = Txid::from_byte_array([0x34; 32]); + let coin = OutPoint::new(funding_txid, 0); + + for (w, addr) in [(&w1, &addr1), (&w2, &addr2)] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, addr); + let tx = conn.transaction().unwrap(); + let funding = tx_record( + funding_txid, + vec![], + vec![TxOut { + value: 100_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let loser = tx_record(loser_txid, vec![coin], vec![]); + let cs = CoreChangeSet { + records: vec![funding, loser], + new_utxos: vec![make_utxo(addr, funding_txid, 0, 100_000)], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Wallet 1 sweeps its copy of the loser and releases its own coin. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + released_outpoints: vec![coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w1.as_slice(), &blob::encode_outpoint(&coin).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 0, "wallet 1's release frees its own coin"); + assert!(spent_in_txid.is_none()); + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_some(), + "wallet 2's own copy of the same-txid loser is a separate row, \ + untouched by wallet 1's sweep" + ); + assert!( + row_exists(&conn, &w2, &coin), + "wallet 2's coin is unaffected — it has not swept yet" + ); + } + + // Wallet 2 now sweeps its own copy of the same txid, releasing nothing. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w2, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_none(), + "wallet 2's own sweep removes its own row" + ); + + assert!( + row_exists(&conn, &w2, &coin), + "wallet 2 released nothing, so its coin stays held with a row of its own" + ); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&coin).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 1, "wallet 2's coin is held spent"); + assert_eq!( + spent_in_txid, + Some(AsRef::<[u8]>::as_ref(&winner_txid).to_vec()), + "held and attributed to wallet 2's own winner, per apply_sweep's hold contract — \ + wallet 1's earlier release of the SAME txid's other coin never touched this row" + ); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4d739d56680..a85de85c2a0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -880,6 +880,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let txid = Swift.withUnsafeBytes(of: txidsPtr[i]) { Data($0) } do { try applySweptTransaction( + walletId: walletId, txid: txid, supersededBy: supersededBy, released: released @@ -960,15 +961,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// chain below: deleted if this round finally frees its outpoint, /// repointed at the new winner if not. /// - /// Transaction rows are shared across wallets by design (see - /// `PersistentTransaction`), and a sweep is a statement about the - /// transaction itself rather than about one wallet's view of it, so the - /// row is removed without narrowing to the emitting wallet. + /// `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. + /// Only the deletion is a statement about the transaction as a whole — + /// the input decisions above are scoped to the inputs this wallet + /// actually owns, and the row itself is removed only 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. /// /// Throws if SwiftData cannot answer the lookup. The caller fails the /// round on that: a deletion silently skipped would let Rust clear the /// sweep while the dead row survives. private func applySweptTransaction( + walletId: Data, txid: Data, supersededBy: Data, released: Set @@ -982,25 +990,60 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // are idempotent and can name a transaction this store never had. guard let row = try backgroundContext.fetch(descriptor).first else { return } - for txo in row.inputs { + // `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 { txo.isSpent = !released.contains(txo.outpoint) txo.spendingTransaction = nil txo.lastUpdated = Date() } - for pending in row.pendingInputs where !released.contains(pending.outpoint) { + for pending in row.pendingInputs where pending.walletId == walletId { + guard !released.contains(pending.outpoint) else { continue } pending.spendingTransaction = nil pending.spendingTxid = supersededBy pending.isSweptTombstone = true } - backgroundContext.delete(row) + + // Whatever is still attached to `row` after the scoping above is + // either this wallet's own released pending input — deliberately + // left in place two paragraphs up so the cascade below removes it — + // or an input/pending row a different wallet has not yet weighed in + // on. Only the second case has to hold the delete back; the first + // would otherwise make a wallet wait on its own already-finished + // decision. 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. + 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 `txid` (this transaction, itself a sweep's winner // until now) is no longer reachable through `row.pendingInputs` — // see the doc comment above. Find it by the scalar `spendingTxid` - // it carries instead. + // it carries instead, 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. var tombstoneDescriptor = FetchDescriptor( - predicate: #Predicate { $0.spendingTxid == txid && $0.isSweptTombstone == true } + predicate: #Predicate { + $0.spendingTxid == txid && $0.isSweptTombstone == true && $0.walletId == walletId + } ) tombstoneDescriptor.includePendingChanges = true let priorTombstones = try backgroundContext.fetch(tombstoneDescriptor) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 3f97cf140fd..fbfacae8d57 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -154,6 +154,19 @@ final class SweptTransactionPersistTests: XCTestCase { 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, @@ -418,6 +431,148 @@ final class SweptTransactionPersistTests: XCTestCase { ) } + /// 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 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)], 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, 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, 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)], 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) + } + /// A failed wallet lookup must fail the round, not read as "no such /// wallet". /// From 3a8736659025840ba2a227adbbf6e7f22895a819 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:51:08 +0300 Subject: [PATCH 022/102] fix: make a swept transaction's exclusion durable in every wallet's callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit_batch calls store() once per wallet, and each of those commits independently. The previous round's fix scoped a swept loser's per-input decisions to the owning wallet and deferred the shared row's physical delete until no other wallet's claim remained — but deletion was still the ONLY thing that excluded the row and its outputs from restoration. If wallet A's callback committed first, that store() call returned success while the loser and its outputs stayed fully live and enumerable; if wallet B's callback was then rejected, or the process stopped before it ever arrived, the row stayed acknowledged-but-resurrectable indefinitely. After a restart, the retained loser's outputs could be enumerated as live funds and its involvedAccounts membership could still be handed back through restore-to-Rust paths, recreating the exact balance the sweep existed to remove. Split what is globally true from what is per-wallet, and make the global half durable in every callback rather than only the last one: - PersistentTransaction (Swift) / TransactionEntity (Kotlin, new isGloballySwept column) gain a durable flag set unconditionally, idempotently, in every callback that observes a row's sweep. - The row's own outputs are deleted unconditionally in every such callback too (Swift: `row.outputs`; Kotlin: TxoDao.deleteOwnOutputs) — they are nobody's coin regardless of which wallet's callback runs. - Every restore/enumeration path that can reach a PersistentTransaction / TransactionEntity row now excludes flagged rows: Swift's walletOwnsTransaction (the sole gate for walletCoreTxids), coreTxRecord, buildUnresolvedAssetLockTxRecordBuffer, and buildProviderSpecialTxRestoreBuffer; Kotlin's onGetCoreTxRecord, getProviderSpecialTransactionsByWallet, and buildUnresolvedAssetLockTxRecordData. Both backends' upsert paths (upsertTransaction/upsertUtxo, onWalletChangesetTransaction/ onWalletChangesetUtxoAdded) now bail on an already-flagged row instead of resurrecting it, as defense-in-depth against a stale re-emission. - The physical row delete is demoted to housekeeping: it still runs once no other wallet's claim remains, but correctness no longer depends on it, and the doc comments say so. SQLite needed no change: core_transactions / core_utxos are keyed by (wallet_id, txid) / (wallet_id, outpoint), so there is no shared row for a second wallet's callback to hold back in the first place — confirmed, not fixed, by a new durability test in sqlite_transaction_sweeps.rs. Kotlin's new column ships with @ColumnInfo(defaultValue = "0"), MIGRATION_11_12, a re-exported v12 schema (diffed against v11: only transactions.isGloballySwept added), and a migration test. The migration's own androidTest cannot run in this environment (no emulator). Regression coverage on both mobile backends: a loser shared by two wallets, with an output of its own, where only ONE wallet's callback ever commits and the other's never arrives — the phantom output and the row's enumerability are gone from that single callback alone, and stay gone across a simulated restart. Both new tests confirmed to fail without this fix (reverted, ran, restored). --- .../12.json | 4138 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 46 +- .../dashsdk/persistence/DashDatabase.kt | 29 +- .../PlatformWalletPersistenceHandler.kt | 95 +- .../dashsdk/persistence/dao/TransactionDao.kt | 16 + .../dashsdk/persistence/dao/TxoDao.kt | 21 + .../persistence/entities/TransactionEntity.kt | 18 + .../PlatformWalletPersistenceHandlerTest.kt | 94 + .../tests/sqlite_transaction_sweeps.rs | 106 + .../Models/PersistentTransaction.swift | 17 + .../PlatformWalletPersistenceHandler.swift | 104 +- .../SweptTransactionPersistTests.swift | 119 + 12 files changed, 4778 insertions(+), 25 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json new file mode 100644 index 00000000000..4afa0757c81 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json @@ -0,0 +1,4138 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "e77bbe3415070700cfe79d4d6f74a877", + "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, `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": "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, `isGloballySwept` 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 + }, + { + "fieldPath": "isGloballySwept", + "columnName": "isGloballySwept", + "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, 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" + } + ], + "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`)" + } + ], + "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, 'e77bbe3415070700cfe79d4d6f74a877')" + ] + } +} \ 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 e79925d277e..38ea3d84f60 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 @@ -439,13 +439,51 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v10 → v11 adds `transactions.isGloballySwept` (defaulted `false`) — + * additive. Pre-existing rows must survive and read back not swept, and + * the flag must accept an explicit `true` on write, mirroring + * `migrate10To11AddsSweepClaimDurabilityColumns` above for the sibling + * v11 columns. + */ + @Test + fun migrate11To12AddsGlobalSweptFlag() { + val legacy = helper.createDatabase(dbName, 11) + 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.close() + + val db = helper.runMigrationsAndValidate(dbName, 12, true, DashDatabase.MIGRATION_11_12) + db.query("SELECT isGloballySwept FROM transactions WHERE txid = x'02'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + db.execSQL( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, " + + "transactionType, transactionTypeKind, netAmount, label, firstSeen, " + + "createdAt, lastUpdated, isGloballySwept) " + + "VALUES (x'03', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0, 1)", + ) + db.query("SELECT isGloballySwept FROM transactions WHERE txid = x'03'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, 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, - 11, + 12, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -454,16 +492,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).close() } - /** The full chain from v1 must also land on a valid v11 schema. */ + /** The full chain from v1 must also land on a valid v12 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 11, + 12, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -475,6 +514,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).close() } } 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 2d57885a441..6ab66475410 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 @@ -128,9 +128,22 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * arrival free to re-insert the outpoint as an ordinary unspent UTXO. Both * columns are additive with defaults, so every pre-migration row reads back * as an ordinary (non-tombstone, non-superseded) entry. + * + * Version 12 (sweep deletion durability): adds `transactions.isGloballySwept`. + * `commit_batch` calls `store()` once per wallet and each commits + * independently, so a shared loser row could be held back for as long as a + * second wallet's own claim on it was outstanding — which, before this + * column existed, meant the row AND the outputs it created stayed fully live + * (enumerable, funds-bearing) for however long that second wallet's callback + * took to arrive, or forever if it never did. This flag is set in every + * wallet's callback that observes the sweep, not only the one whose delete + * happens to remove the row, so the exclusion from restore/enumeration is + * durable from the first committed callback regardless of what the others + * do. Additive with a default, so every pre-migration row reads back as not + * swept. */ @Database( - version = 11, + version = 12, exportSchema = true, entities = [ WalletEntity::class, @@ -580,6 +593,19 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v11 → v12: adds `transactions.isGloballySwept` (additive, + * defaulted `false`) — see the version-12 class doc above. + */ + val MIGRATION_11_12: Migration = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `transactions` ADD COLUMN `isGloballySwept` " + + "INTEGER NOT NULL DEFAULT 0", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -599,6 +625,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, + MIGRATION_11_12, ) .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 72fe8f5aaf0..303ccb1907b 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 @@ -785,6 +785,16 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val existing = db.transactionDao().getByTxid(txid) + // A sweep is upstream's final word on this txid — it never + // re-emits a live record for a transaction it has already + // proven can never confirm. A re-upsert reaching here for an + // `isGloballySwept` row would therefore be a stale/out-of-order + // signal at best, and applying it would resurrect exactly what + // `onWalletChangesetTransactionsSwept` excluded (live context/ + // blockHeight, a fresh involvement link, input reconciliation + // that re-links its inputs). Bail rather than let any of that + // happen. See TransactionEntity.isGloballySwept. + if (existing?.isGloballySwept == true) return@stage // firstSeen: adopt non-zero from FFI; else keep existing; // else stamp now (never leave a placeholder zero). val resolvedFirstSeen = when { @@ -902,9 +912,21 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val outpoint = makeOutpoint(txid, vout) + val parentTx = db.transactionDao().getByTxid(txid) + // 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 + // `onWalletChangesetTransactionsSwept` deletes on every callback + // that observes the sweep. Bail rather than attach a new row to + // a transaction already excluded from restoration; ordinary + // operation should never reach this (Rust does not re-emit a + // swept loser's own outputs), so this is defense-in-depth + // against a stale/out-of-order signal, not a path expected to + // fire. See TransactionEntity.isGloballySwept. + if (parentTx?.isGloballySwept == true) return@stage // Ensure a parent transaction row exists (stub if missing, so // the TXO FK holds; the real tx upsert overwrites it later). - if (db.transactionDao().getByTxid(txid) == null) { + if (parentTx == null) { db.transactionDao().upsert( TransactionEntity(txid = txid, transactionData = ByteArray(0)), ) @@ -1032,8 +1054,28 @@ class PlatformWalletPersistenceHandler( * rows would hand them back at the next load and re-create a balance the * wallet has already corrected. * - * The TXOs the transaction created go with it (`txos.txid` cascades). - * The ones it *spent* split in two, and [releasedOutpoints] is the + * `commit_batch` calls `store()` once per wallet, and each of those + * commits independently — there is no single transaction spanning every + * wallet a sweep touches. That splits what has to be durable in THIS + * callback from what may wait for a later one. The TXOs a loser created + * are phantom money for every wallet, not just whichever one's callback + * happens to run, and once Rust has proven a row dead no restore/ + * enumeration query may serve it to anyone again — waiting for the last + * wallet's callback to confirm that would leave it acknowledged-but- + * resurrectable for however long the others take, or forever if one of + * them is rejected or never arrives. So [TxoDao.deleteOwnOutputs] and + * [TransactionDao.markGloballySwept] run in EVERY callback that reaches + * this function, idempotently, before anything wallet-scoped below. + * Physically removing the `transactions` row itself is different: that + * is safe to defer, because `isGloballySwept` already makes the row + * inert the moment the first callback sets it — see [hasOtherWalletClaim] + * for why the row is still worth reclaiming once nothing points at it, + * now purely as housekeeping. + * + * The TXOs the transaction created go with it (`txos.txid` cascades, + * once the row itself is deleted — [TxoDao.deleteOwnOutputs] above does + * not wait for that). The ones it *spent* split in two, and + * [releasedOutpoints] is the * authority on which is which: an outpoint named there came free, and * every other input the loser claimed was taken by the transaction that * beat it and is gone for good. @@ -1083,13 +1125,17 @@ class PlatformWalletPersistenceHandler( * row. Every DAO call above therefore carries [walletId] and only * touches that wallet's own rows (own `TxoEntity`s via `TxoDao`'s * `walletId` column, own `PendingInputEntity`s via the same column on - * that table). The row itself stays a statement about the transaction - * as a whole, so it is deleted only once [hasOtherWalletClaim] finds - * nothing left pointing at it — whichever wallet's callback is the last - * one to run performs the delete, so processing order stops mattering. - * A wallet whose callback never arrives 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. + * that table). Deleting the row itself is different: nothing below + * depends on it for correctness anymore, since the global writes above + * already made the row inert in every callback that reaches them. It is + * deleted once [hasOtherWalletClaim] finds nothing left pointing at it — + * whichever wallet's callback is the last one to run performs the + * delete, so processing order stops mattering — but this is reclaiming + * the now-inert row's storage, not finishing the sweep. A wallet whose + * callback never arrives just leaves the row behind with every other + * wallet's inputs already correctly decided: a leaked dead row, not a + * wrongly-spent coin or a resurrectable one, and a re-emitted sweep + * cleans it up. */ override fun onWalletChangesetTransactionsSwept( walletId: ByteArray, @@ -1110,6 +1156,15 @@ class PlatformWalletPersistenceHandler( // freed out from under it. val released = releasedOutpoints.toList() for (i in txids.indices) { + // Global first, unconditionally, in every callback that + // reaches this loop — not gated on walletId and not waiting + // for whichever wallet ends up performing the row delete + // below. Both writes are idempotent, so a wallet reprocessing + // an already-flagged sweep (a retry after a crash) just + // re-applies the same state. + db.txoDao().deleteOwnOutputs(txids[i]) + db.transactionDao().markGloballySwept(txids[i]) + db.txoDao().holdSpentWithoutSpender(txids[i], walletId) db.documentDao().tombstoneUnreleasedPendingInputs( txids[i], supersededBy[i], released, walletId, @@ -1127,6 +1182,11 @@ class PlatformWalletPersistenceHandler( db.txoDao().releaseByOutpoint(outpoint, walletId) } for (txid in txids) { + // Housekeeping only from here down: the row's ability to + // contribute funds was already durably cut off above, in + // every callback that reaches this point, independent of + // whether this delete ever fires. Deleting it when nothing + // else claims it just reclaims the now-inert row's storage. if (!hasOtherWalletClaim(db, txid, walletId)) { db.transactionDao().deleteByTxid(txid) } @@ -1141,7 +1201,8 @@ class PlatformWalletPersistenceHandler( * hold/release/tombstone updates above have already cleared or detached * everything [walletId] itself owns. See the class doc on * [onWalletChangesetTransactionsSwept] for why this is what decides - * whether the shared `transactions` row is safe to delete yet. + * whether the shared `transactions` row is safe to physically reclaim + * yet — a housekeeping decision now, not a correctness one. */ private suspend fun hasOtherWalletClaim( db: DashDatabase, @@ -2231,6 +2292,13 @@ class PlatformWalletPersistenceHandler( runBlockingResult { // walletId unused — txid is globally unique. val tx = database.transactionDao().getByTxid(txid) ?: return@runBlockingResult null + // 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. + if (tx.isGloballySwept) return@runBlockingResult null if (tx.transactionData.isEmpty()) return@runBlockingResult null if (tx.context >= CONTEXT_IN_BLOCK && (tx.blockHash == null || tx.blockHash.size != 32) @@ -2724,6 +2792,11 @@ class PlatformWalletPersistenceHandler( val outPoint = decodeOutPointHex(lock.outPointHex) ?: continue val txid = outPoint.copyOfRange(0, 32) val tx = database.transactionDao().getByTxid(txid) ?: continue + // A globally-swept funding tx lost a double-spend on one of its + // own inputs — it never confirms, so there is no unresolved + // asset lock left to restore it into. Skip rather than hand + // Rust a dead transaction to re-track. + if (tx.isGloballySwept) continue if (tx.transactionData.isEmpty()) continue out.add( UnresolvedAssetLockTxRecordData( 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..6bf67adede2 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,11 @@ interface TransactionDao { /** * Provider kinds 2…5 scoped through explicit account membership. The * ordering preserves Core's same-block transaction order when present. + * + * `isGloballySwept = 0` excludes a provider transaction that itself lost + * a double-spend on one of its own inputs — an edge case (most losers + * are ordinary spends), but a swept row is never restorable regardless + * of kind. See [TransactionEntity.isGloballySwept]. */ @Query( "SELECT DISTINCT transactions.* FROM transactions " + @@ -78,6 +83,7 @@ interface TransactionDao { "WHERE accounts.walletId = :walletId " + "AND accounts.accountType BETWEEN 8 AND 11 " + "AND transactions.transactionTypeKind BETWEEN 2 AND 5 " + + "AND transactions.isGloballySwept = 0 " + "ORDER BY transactions.blockHeight ASC, " + "transactions.hasBlockPosition DESC, transactions.blockPosition ASC, " + "transactions.firstSeen ASC" @@ -90,6 +96,16 @@ interface TransactionDao { @Delete suspend fun delete(transaction: TransactionEntity) + /** + * Durable global exclusion for a swept loser — set in EVERY wallet's + * `onWalletChangesetTransactionsSwept` callback that observes the sweep, + * not only the one whose [deleteByTxid] happens to remove the shared + * row. Idempotent: re-flagging an already-flagged row is a no-op. See + * [TransactionEntity.isGloballySwept]. + */ + @Query("UPDATE transactions SET isGloballySwept = 1 WHERE txid = :txid") + suspend fun markGloballySwept(txid: ByteArray) + @Query("DELETE FROM transactions WHERE txid = :txid") suspend fun deleteByTxid(txid: ByteArray) 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 dd7f7dfbf41..7fad0a3f0c5 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 @@ -117,6 +117,27 @@ interface TxoDao { @Query("SELECT EXISTS(SELECT 1 FROM txos WHERE spendingTxid = :spendingTxid AND walletId != :walletId)") suspend fun hasOtherWalletSpender(spendingTxid: ByteArray, walletId: ByteArray): Boolean + /** + * Delete every TXO [txid] itself created — its own outputs — independent + * of whether the `transactions` row for [txid] is deleted in the same + * call. + * + * Ordinarily the FK from `txos.txid` to `transactions.txid` (CASCADE) + * would do this for free, but only once the parent row is deleted, and + * [TransactionDao.deleteByTxid] deliberately withholds that delete for + * as long as another wallet still has a claim on the row — which can be + * indefinite if that wallet's own callback is rejected or never arrives. + * These outputs are nobody's coin, ever, regardless: a transaction that + * can never confirm funded nothing, for every wallet, not just the one + * whose callback happens to run. [onWalletChangesetTransactionsSwept] + * calls this in EVERY wallet's callback that observes the sweep, so the + * deletion is durable from the first one rather than waiting on + * whichever happens to be last. Idempotent — a row with no outputs left + * is a no-op. + */ + @Query("DELETE FROM txos WHERE txid = :txid") + suspend fun deleteOwnOutputs(txid: ByteArray) + @Upsert suspend fun upsert(txo: TxoEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt index f48b9d626aa..a2c31d9c637 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.persistence.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -54,6 +55,23 @@ data class TransactionEntity( val firstSeen: Long = 0, val createdAt: Date = Date(), val lastUpdated: Date = Date(), + /** + * Port of Swift `PersistentTransaction.isGloballySwept`. Set by + * `onWalletChangesetTransactionsSwept` in EVERY wallet's callback that + * observes this row's sweep, not only the one whose + * `TransactionDao.deleteByTxid` happens to remove it — see that + * function's class doc for why the physical delete alone is not durable + * enough (each wallet's `store()` commits independently). `true` means + * Rust has already proven this transaction can never confirm; every + * restore/enumeration query must exclude the row regardless of whether + * it still physically exists. + * + * Declares its default so the exported schema agrees with what + * `MIGRATION_11_12` writes (see `PendingInputEntity.isSweptTombstone` + * for why this is required, not optional). + */ + @ColumnInfo(defaultValue = "0") + val isGloballySwept: Boolean = false, ) { override fun equals(other: Any?): Boolean = other is TransactionEntity && txid.contentEquals(other.txid) 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 13f8035f594..0ebe278024a 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 @@ -2569,6 +2569,100 @@ class PlatformWalletPersistenceHandlerTest { assertNull(heldQ.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 { + // The BLOCKING review finding: a shared loser's own output, and its + // reachability through onGetCoreTxRecord, must not survive 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 + // sharedLoserAppliesEachWalletsOwnReleaseSet* 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 and its phantom output stay fully + // live forever, so `onGetCoreTxRecord` keeps handing the dead + // transaction back as though it were still a candidate. + // + // 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. + val walletB = ByteArray(32) { 9 } + val (_, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val phantomOutput = makeOutpoint(loserTxid, 2) + + // 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. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + ) + handler.onChangesetEnd(walletB, success = true) + + assertNotNull( + "wallet A's own claim on P is still outstanding, so the row itself survives", + db.transactionDao().getByTxid(loserTxid), + ) + assertNull( + "the loser's own output must not survive even a single committed callback, " + + "regardless of which wallet's callback that was", + db.txoDao().getByOutpoint(phantomOutput), + ) + val row = db.transactionDao().getByTxid(loserTxid)!! + assertTrue( + "any callback that reaches the sweep must flag the row, not just wallet A's own", + row.isGloballySwept, + ) + + // "Restart": a fresh handler bound to the same underlying store — + // the same pattern `addressBalanceConflictPreservesDerivationIndicesAcrossRestart` + // and the pending-key restart tests below use. Wallet A's own + // callback never happens in this test, simulating a crash or a + // rejection that stops it from ever arriving — the exact scenario + // the finding describes. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + assertNull( + "the phantom output must not resurrect across a restart", + db.txoDao().getByOutpoint(phantomOutput), + ) + assertNull( + "wallet A must not be able to read the swept loser back as a live transaction " + + "after a restart, even though its own callback never ran", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + val utxosA = restarted.onLoadWalletList().first { it.walletId.contentEquals(walletId) }.utxos + assertFalse( + "the phantom output must not be handed back as a restorable UTXO", + utxosA.any { it.prevTxid.contentEquals(loserTxid) && it.vout == 2 }, + ) + } + @Test fun sweptTransactionRollsBackWithItsRound() = runTest { // The deletion is staged in the same buffered transaction as every diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 0a6440909b9..e5a1ebd2bde 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1151,3 +1151,109 @@ fn sweep_of_a_shared_loser_txid_is_independent_per_wallet() { wallet 1's earlier release of the SAME txid's other coin never touched this row" ); } + +/// Confirmation, not a fix, of this round's BLOCKING finding (a shared row +/// acknowledged as durably swept by one wallet's commit while a second +/// wallet's own callback is still outstanding — see the Swift/Kotlin +/// `PersistentTransaction.isGloballySwept` / `TransactionEntity. +/// isGloballySwept` flag those backends needed to add). The finding does not +/// apply here for the same structural reason as the independence test +/// above: there is no shared row for a second wallet's callback to hold +/// back in the first place, so wallet 1's own deletion has no cross-wallet +/// dependency to be durable *despite*. +/// +/// This confirms the corollary directly: wallet 1 sweeps and commits, wallet +/// 2's own callback for the same loser txid is never called again in this +/// test at all (a crash, a rejection, or it simply never coming), and the +/// persister is restarted from disk. Wallet 1's phantom output and row must +/// already be gone — nothing about their absence was waiting on wallet 2. +#[test] +fn sweep_deletion_is_durable_even_when_the_other_wallets_callback_never_arrives() { + let (persister, _tmp, path) = fresh_persister(); + let w1: WalletId = wid(0xEA); + let w2: WalletId = wid(0xEB); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x41); + let addr2 = p2pkh(0x42); + // Same loser txid recorded independently by both wallets, each with an + // output of its own — the "phantom money" the blocking finding is about. + let loser_txid = Txid::from_byte_array([0x43; 32]); + let winner_txid = Txid::from_byte_array([0x44; 32]); + + for (w, addr) in [(&w1, &addr1), (&w2, &addr2)] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, addr); + let tx = conn.transaction().unwrap(); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 60_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Only wallet 1 ever sweeps. Wallet 2's own callback for this sweep + // never arrives — this test never calls `apply` for w2 again. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + + let w1_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w1.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w1_loser.is_none(), + "wallet 1's own sweep commit is durable across a restart on its own — \ + nothing about it was waiting on wallet 2's callback" + ); + assert!( + !row_exists(&conn, &w1, &OutPoint::new(loser_txid, 0)), + "wallet 1's phantom output must not survive — its deletion never depended \ + on wallet 2's callback, which never arrives in this test" + ); + + // Wallet 2 never swept, so its own independent copy legitimately still + // stands — that is correct per-wallet state, not the bug under test. + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_some(), + "wallet 2's own row is untouched — it never ran its own sweep" + ); +} 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/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a85de85c2a0..7670c7c5efe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -54,6 +54,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 @@ -914,8 +923,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// re-create a balance the wallet has already corrected — this is the /// only removal the changeset path performs. /// - /// The outputs it created go with it (`PersistentTransaction.outputs` - /// cascades). The coins it claimed to *spend* split in two, and + /// `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; @@ -965,12 +991,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `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. - /// Only the deletion is a statement about the transaction as a whole — - /// the input decisions above are scoped to the inputs this wallet - /// actually owns, and the row itself is removed only 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. + /// 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. /// /// Throws if SwiftData cannot answer the lookup. The caller fails the /// round on that: a deletion silently skipped would let Rust clear the @@ -985,11 +1011,23 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.txid == txid } ) descriptor.fetchLimit = 1 - descriptor.relationshipKeyPathsForPrefetching = [\.inputs, \.pendingInputs] + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] // A successful fetch that finds nothing is an ordinary no-op: sweeps // are idempotent and can name a transaction this store never had. guard let row = try backgroundContext.fetch(descriptor).first else { return } + // 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 @@ -1023,6 +1061,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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. let otherWalletStillClaims = row.inputs.contains { txo in txo.spendingTransaction != nil && Self.resolvedWalletId(of: txo) != walletId } || row.pendingInputs.contains { pending in @@ -1266,8 +1310,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) + let existing = try? backgroundContext.fetch(descriptor).first + // A sweep is upstream's final word on this txid — it never + // re-emits a live record for a transaction it has already proven + // can never confirm. A re-upsert reaching here for an + // `isGloballySwept` row would therefore be a stale/out-of-order + // signal at best, and applying it would resurrect exactly what + // `applySweptTransaction` excluded: live `context`/`blockHeight`, + // a fresh `involvedAccounts` membership, input reconciliation that + // re-links its inputs. Bail rather than let any of that happen. + if existing?.isGloballySwept == true { return } + let record: PersistentTransaction - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing { record = existing } else { record = PersistentTransaction( @@ -1508,6 +1563,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) let parentTx: PersistentTransaction if let existingTx = try? backgroundContext.fetch(txDescriptor).first { + // 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 already excluded from + // restoration; ordinary operation should never reach this + // (Rust does not re-emit a swept loser's own outputs), so + // this is defense-in-depth against a stale/out-of-order + // signal, not a path expected to fire. + guard !existingTx.isGloballySwept else { return } parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -5787,6 +5853,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // funding body without its consensus bytes. Skip. continue } + // A globally-swept funding tx lost a double-spend on one of its + // own inputs — it never confirms, so there is no unresolved + // asset lock left to restore it into. Skip rather than hand + // Rust a dead transaction to re-track. + guard !txRow.isGloballySwept else { continue } let txBytes = txRow.transactionData guard !txBytes.isEmpty else { // A stub row whose real upsert never arrived; @@ -5850,9 +5921,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), @@ -6454,6 +6530,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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index fbfacae8d57..7e5c7ff6b54 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -486,6 +486,51 @@ final class SweptTransactionPersistTests: XCTestCase { 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 @@ -573,6 +618,80 @@ final class SweptTransactionPersistTests: XCTestCase { 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)], 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" + ) + } + /// A failed wallet lookup must fail the round, not read as "no such /// wallet". /// From 6a30295e93ae410acf00a44deffd398dae0e4fed Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:53:05 +0300 Subject: [PATCH 023/102] test(swift-sdk): pin the throwing wallet-lookup branch with a real seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testAMissingWalletIsASuccessfulNoOp's own doc comment admits it does not distinguish persistWalletChangeset's do/catch around fetchWalletRecord from the old try? it replaced: a successful fetch that finds no wallet row reads identically either way, so reverting that fix would not make the test fail. Add a genuinely throwing fetch instead of a mock: a file-backed store is truncated on disk, out from under the still-open container, before any context reads or writes through it. SwiftData's row cache is scoped to the persistent store coordinator rather than to any one ModelContext, so corrupting the file after a seed helper's throwaway context had already touched it left the wallet row served from that shared cache and never hit disk at all in an earlier attempt — corrupting before any read ever happens is what makes fetchWalletRecord's fetch the first real I/O this store performs, landing on the truncated file directly. Confirmed to fail without the fix: with fetchWalletRecord's do/catch temporarily reverted to try?, the corrupted fetch still throws, but the error is swallowed to nil and persistWalletChangeset reports success. --- .../SweptTransactionPersistTests.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 7e5c7ff6b54..91084ceebab 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -726,6 +726,42 @@ final class SweptTransactionPersistTests: XCTestCase { ) } + /// 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)]) + + XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") + } + /// 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. From 7bfa7013966af6d372d454f734aded0abef4750d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:58:59 +0300 Subject: [PATCH 024/102] fix(kotlin-sdk): re-export v12 so the schema records the tombstone default The entity carries `@ColumnInfo(defaultValue = "0")` and `MIGRATION_11_12` adds the column as `NOT NULL DEFAULT 0`, but the exported schema was written before the annotation landed and recorded no default. `MigrationTestHelper` builds the "before" database from that JSON, so the migration test would have validated against a shape neither the migration nor the entity produces. Regenerated it; the diff is `isGloballySwept` gaining its default, plus the identity hash that follows from it. --- .../12.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json index 4afa0757c81..dfe8467a953 100644 --- a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 12, - "identityHash": "e77bbe3415070700cfe79d4d6f74a877", + "identityHash": "d12a03ec9c3527a1a1a6120cdcd31e47", "entities": [ { "tableName": "wallets", @@ -268,7 +268,7 @@ }, { "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, `isGloballySwept` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "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, `isGloballySwept` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`txid`))", "fields": [ { "fieldPath": "txid", @@ -374,7 +374,8 @@ "fieldPath": "isGloballySwept", "columnName": "isGloballySwept", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -4132,7 +4133,7 @@ ], "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, 'e77bbe3415070700cfe79d4d6f74a877')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd12a03ec9c3527a1a1a6120cdcd31e47')" ] } } \ No newline at end of file From b73e4c9c00639a945cbec6782aa6fac99de97f5a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:11:06 +0300 Subject: [PATCH 025/102] chore: repin rust-dashcore to the merged sweep-released-outpoints commit dashpay/rust-dashcore#962 landed on `dev` as f4b907c3. The branch was pinned to its PR head while it was in review; point it at the merge commit so this PR no longer depends on an unmerged branch. --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e83690b8389..ed704686891 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=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" 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=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "bincode", "dashcore-private", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" [[package]] name = "glob" @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=51eafd8c82371ba88305ceb35b4c1604062bc6c8#51eafd8c82371ba88305ceb35b4c1604062bc6c8" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index b5d729fcf90..de60577d1be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "51eafd8c82371ba88305ceb35b4c1604062bc6c8" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } tokio-metrics = "0.5" From 83982b87d83921f68eb09fdad2ac1e2750928fcc Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:12:33 +0300 Subject: [PATCH 026/102] fix(swift-sdk): reinstate a swept transaction when a later record revives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep is upstream's word as of the callback that observed it, not a permanent verdict: CoreChangeSet::merge documents a reachable sequence where an unconfirmed transaction swept by an IS-locked conflict later returns chainlocked and sweeps that conflict in turn. When both events land in one changeset the merge already strips the sweep before it reaches the persister, but across two separate persistence rounds the merge-level fix can't help — the first round's sweep already durably tombstoned the row (`isGloballySwept = true`), and the second round's plain record for the same txid used to be silently discarded by `upsertTransaction`'s unconditional bail, taking `upsertUtxo`'s guard down with it since it also reads the same flag. Treat a later live record for an `isGloballySwept` txid as upstream's newer word: clear the tombstone and let the ordinary upsert path (context/blockHeight/involvedAccounts/input reconciliation) apply normally. The row's physically deleted outputs come back only if the reinstating round also carries fresh `utxos_added` entries for them, the same way any transaction's outputs ordinarily arrive alongside its record — that part is not this method's to fake if Rust doesn't re-emit them. Adds a cross-round regression test: sweep in round 1 with a second wallet's claim keeping the shared row physically present, then a separate round 2 delivering the reinstating record and its output, asserting both are live and survive a simulated restart. Confirmed to fail without the fix (reverted the guard, reran — 3 assertion failures on the tombstone, block height, and output; restored and reran green). --- .../PlatformWalletPersistenceHandler.swift | 68 ++++-- .../SweptTransactionPersistTests.swift | 196 ++++++++++++++++++ 2 files changed, 250 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 7670c7c5efe..5185543d39a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -923,6 +923,13 @@ 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 @@ -1311,15 +1318,39 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) let existing = try? backgroundContext.fetch(descriptor).first - // A sweep is upstream's final word on this txid — it never - // re-emits a live record for a transaction it has already proven - // can never confirm. A re-upsert reaching here for an - // `isGloballySwept` row would therefore be a stale/out-of-order - // signal at best, and applying it would resurrect exactly what - // `applySweptTransaction` excluded: live `context`/`blockHeight`, - // a fresh `involvedAccounts` membership, input reconciliation that - // re-links its inputs. Bail rather than let any of that happen. - if existing?.isGloballySwept == true { return } + // 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 { @@ -1568,11 +1599,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 already excluded from - // restoration; ordinary operation should never reach this - // (Rust does not re-emit a swept loser's own outputs), so - // this is defense-in-depth against a stale/out-of-order - // signal, not a path expected to fire. + // `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 { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 91084ceebab..713259ed695 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -692,6 +692,119 @@ final class SweptTransactionPersistTests: XCTestCase { ) } + /// 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)], 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". /// @@ -1062,4 +1175,87 @@ final class SweptTransactionPersistTests: XCTestCase { } _ = 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) + } } From 32010a55d60a21f6a1e54b4836eec3393176a469 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:13:37 +0300 Subject: [PATCH 027/102] fix(kotlin-sdk): reinstate a swept transaction when a later record revives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin port of the Swift fix in this round: onWalletChangesetTransaction bailed unconditionally when a row was isGloballySwept, permanently rejecting a later record that reinstates a txid a sweep previously tombstoned. CoreChangeSet::merge documents that a wallet's sweep state is not monotonic (IS-lock precedence: a chainlocked return beats the IS-locked conflict that swept it), and while the merge strips a sweep reversed within one changeset, two separate persistence rounds get no such help — the second round's plain record used to be discarded, and onWalletChangesetUtxoAdded's own isGloballySwept guard kept rejecting its output on the strength of a tombstone nothing could ever clear. Removing the bail is sufficient on its own: the @Upsert below always builds a fresh TransactionEntity without naming isGloballySwept, so it defaults to false and Room's full-row replace overwrites the stored true unconditionally. That alone was not enough on Android, though. persist_changeset_account in rs-unified-sdk-jni called utxos_added before transactions per account — backwards from the Swift bridge's order — so a reinstated transaction's own fresh output would hit onWalletChangesetUtxoAdded's guard before onWalletChangesetTransaction ever got a chance to clear the tombstone it depends on. Reordered in the companion Rust commit. Adds a cross-round regression test mirroring the Swift one: sweep in round 1 with a second wallet's claim keeping the shared row physically present, then a separate round 2 delivering the reinstating record and its output (in the corrected transaction-before-utxo order), asserting both are live and survive a simulated restart. Confirmed to fail without the fix (reverted the guard, reran via :sdk:testDebugUnitTest --tests, one assertion failure; restored and reran green — 91/91). No Room schema change: isGloballySwept already exists with its default and migration from an earlier commit on this branch. --- .../PlatformWalletPersistenceHandler.kt | 77 ++++++++++--- .../PlatformWalletPersistenceHandlerTest.kt | 104 ++++++++++++++++++ 2 files changed, 166 insertions(+), 15 deletions(-) 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 303ccb1907b..a9137465977 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 @@ -785,16 +785,45 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val existing = db.transactionDao().getByTxid(txid) - // A sweep is upstream's final word on this txid — it never - // re-emits a live record for a transaction it has already - // proven can never confirm. A re-upsert reaching here for an - // `isGloballySwept` row would therefore be a stale/out-of-order - // signal at best, and applying it would resurrect exactly what - // `onWalletChangesetTransactionsSwept` excluded (live context/ - // blockHeight, a fresh involvement link, input reconciliation - // that re-links its inputs). Bail rather than let any of that - // happen. See TransactionEntity.isGloballySwept. - if (existing?.isGloballySwept == true) return@stage + // A sweep is upstream's word at the moment it fired, but the + // wallet's sweep state is not monotonic: `CoreChangeSet::merge` + // (rs-platform-wallet) 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 [onWalletChangesetTransactionsSwept]), + // 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. + // + // No explicit clear is written here: the `@Upsert` below always + // constructs a fresh [TransactionEntity] without naming + // `isGloballySwept`, so it defaults to `false` and Room's + // upsert (a full-row replace on the `txid` primary key) + // overwrites the stored `true` unconditionally. What this does + // and does not restore: `context`/`blockHeight`, involvement, + // and this record's own input reconciliation all rebuild + // normally below since they're driven straight off the FFI + // params. The outputs `onWalletChangesetTransactionsSwept` + // physically deleted are a different story — they 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. + // The JNI bridge (`persist_changeset_account` in + // rs-unified-sdk-jni) calls this method before any + // `onWalletChangesetUtxoAdded` for the same account, + // specifically so that method's own `isGloballySwept` guard + // already sees this upsert's clear by the time it runs; see the + // comment there. If Rust doesn't re-emit the outputs, they + // cannot be reconstructed here from nothing. + // See TransactionEntity.isGloballySwept. // firstSeen: adopt non-zero from FFI; else keep existing; // else stamp now (never leave a placeholder zero). val resolvedFirstSeen = when { @@ -918,11 +947,22 @@ class PlatformWalletPersistenceHandler( // would (re-)create exactly the phantom output // `onWalletChangesetTransactionsSwept` deletes on every callback // that observes the sweep. Bail rather than attach a new row to - // a transaction already excluded from restoration; ordinary - // operation should never reach this (Rust does not re-emit a - // swept loser's own outputs), so this is defense-in-depth - // against a stale/out-of-order signal, not a path expected to - // fire. See TransactionEntity.isGloballySwept. + // a transaction still excluded from restoration. + // + // This does not fight [onWalletChangesetTransaction]'s + // reinstatement path — it relies on that method running first. + // The JNI bridge (`persist_changeset_account` in + // rs-unified-sdk-jni) calls `onWalletChangesetTransaction` for + // an account's `transactions` before this method for that same + // account's `utxos_added`, so a reinstating record for this + // txid in this same round has already cleared the tombstone by + // the time this guard reads it; only a UTXO entry with no + // accompanying record this round 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. See + // TransactionEntity.isGloballySwept. if (parentTx?.isGloballySwept == true) return@stage // Ensure a parent transaction row exists (stub if missing, so // the TXO FK holds; the real tx upsert overwrites it later). @@ -1054,6 +1094,13 @@ class PlatformWalletPersistenceHandler( * rows would hand them back at the next load and re-create a balance the * wallet has already corrected. * + * `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 [onWalletChangesetTransaction] + * 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 a sweep touches. That splits what has to be durable in THIS 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 0ebe278024a..3f4e0c479ac 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 @@ -2663,6 +2663,110 @@ class PlatformWalletPersistenceHandlerTest { ) } + @Test + fun aReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() = runTest { + // Cross-round reinstatement — the BLOCKING finding this round + // fixes. The sweep and its reinstating record land in two + // SEPARATE callback rounds, with wallet B's still-outstanding + // claim keeping the shared row physically present in between, + // exactly as + // sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits + // above establishes on its own. Before the fix, + // onWalletChangesetTransaction 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 onWalletChangesetUtxoAdded would + // keep rejecting its output on the strength of a tombstone nothing + // could ever clear. + 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. Wallet A's own claim on P 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. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + ) + handler.onChangesetEnd(walletB, success = true) + + val tombstoned = db.transactionDao().getByTxid(loserTxid)!! + assertTrue("sanity: the row is tombstoned after round 1", tombstoned.isGloballySwept) + assertNull( + "sanity: the loser's own output is gone after round 1", + db.txoDao().getByOutpoint(phantomOutput), + ) + + // 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, transaction before utxo per the + // JNI bridge's account ordering. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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)!! + assertFalse( + "a later record naming a tombstoned txid must clear the tombstone", + reinstated.isGloballySwept, + ) + 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 reclaims its input once its own record is live again", + reclaimedP.isSpent, + ) + assertTrue(loserTxid.contentEquals(reclaimedP.spendingTxid)) + + assertNotNull( + "wallet A must be able to read the reinstated transaction as live again", + handler.onGetCoreTxRecord(walletId, loserTxid), + ) + + // "Restart": a fresh handler bound to the same underlying store — + // the same pattern + // sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits + // above uses. The reinstatement has to be durable, not just + // visible to the handler instance that just applied it. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + val survived = db.transactionDao().getByTxid(loserTxid)!! + assertFalse("the reinstatement must survive a restart", survived.isGloballySwept) + assertNotNull( + "the revived output must survive a restart", + db.txoDao().getByOutpoint(phantomOutput), + ) + val survivedP = db.txoDao().getByOutpoint(p)!! + assertTrue("the reclaimed input must survive a restart", survivedP.isSpent) + assertNotNull( + "the reinstated transaction must still be readable as live after a restart", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + } + @Test fun sweptTransactionRollsBackWithItsRound() = runTest { // The deletion is staged in the same buffered transaction as every From 15579f0d2f7a090d8f945e52afc7315ea5e8f11d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:15:50 +0300 Subject: [PATCH 028/102] fix(rs-unified-sdk-jni): emit an account's transactions before its UTXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the Swift/Kotlin sweep-reinstatement fixes: persist_changeset_ account called onWalletChangesetUtxoAdded/onWalletChangesetUtxoSpent before onWalletChangesetTransaction for the same account, backwards from the Swift bridge's applyAccountChangeset order (transactions, then utxos_added, then utxos_spent). That ordering is load-bearing now, not just cosmetic. A reinstating record clears a row's isGloballySwept tombstone in onWalletChangesetTransaction; onWalletChangesetUtxoAdded bails when its parent is still isGloballySwept. With utxos_added running first, a reinstated transaction's own fresh output would hit that guard one callback before the record that was supposed to clear it, and the Kotlin-side fix alone could not make reinstatement work on Android. Reordered to match Swift so the tombstone is already cleared by the time the UTXO arrives. Also adds a confirmation test to platform-wallet-storage's SQLite backend: its core_transactions/core_utxos rows are keyed (wallet_id, txid), so a sweep's DELETE is unconditional and per-wallet — there is no shared row for a second wallet's claim to hold onto and no tombstone flag to begin with. A later round's plain record for the same (wallet_id, txid) is just an ordinary INSERT ... ON CONFLICT DO UPDATE into empty space, verified here across a sweep, a reinstating record in a separate `apply` call, and a restart. This is a confirmation, not a fix — SQLite was already unaffected by this round's blocking finding. --- .../tests/sqlite_transaction_sweeps.rs | 148 ++++++++++++++++++ .../rs-unified-sdk-jni/src/persistence.rs | 28 +++- 2 files changed, 168 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index e5a1ebd2bde..219a391d09c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1257,3 +1257,151 @@ fn sweep_deletion_is_durable_even_when_the_other_wallets_callback_never_arrives( "wallet 2's own row is untouched — it never ran its own sweep" ); } + +/// Confirmation, not a fix, of this round's BLOCKING finding on the mobile +/// backends (Swift `PersistentTransaction.isGloballySwept` / Kotlin +/// `TransactionEntity.isGloballySwept`): once a sweep is reversed by a +/// chainlocked return, a later-arriving record for the same txid must be +/// accepted as reinstatement rather than permanently rejected. +/// +/// That guard exists on the mobile backends only because their +/// `PersistentTransaction` / `TransactionEntity` rows are shared across +/// wallets and durably flagged the moment *any* wallet's callback observes +/// the sweep, before every wallet's own claim is known to be gone — a +/// second wallet's still-outstanding claim can keep the row physically +/// present after the first wallet's commit, which is exactly what forces a +/// flag instead of relying on row-absence. `apply_sweep` here has no such +/// row to hold onto: it is keyed `(wallet_id, txid)`, so the delete is +/// unconditional and wallet-local (`sweep_of_a_shared_loser_txid_is_ +/// independent_per_wallet` above), and a second wallet's own claim on the +/// same on-chain txid lives in an entirely separate row this wallet's sweep +/// never touches. There is therefore nothing left standing after `apply` +/// runs a sweep for the row's txid — no tombstone to clear, because there +/// is no row to protect from resurrection in the first place. A later round +/// carrying a plain record for the same `(wallet_id, txid)` is just an +/// ordinary `INSERT … ON CONFLICT DO UPDATE` into empty space, so this test +/// exercises that "reinstatement" is unconditionally already correct here, +/// across a separate `apply` call *and* a restart — the same cross-round +/// shape the mobile fix had to add tombstone-clearing for. +#[test] +fn a_record_reinstating_a_swept_txid_in_a_later_round_is_accepted_and_durable() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEC); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x51); + let txid = Txid::from_byte_array([0x53; 32]); + let winner_txid = Txid::from_byte_array([0x54; 32]); + let output = OutPoint::new(txid, 0); + + // Round 1: the transaction is recorded normally, with its own output. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let record = tx_record( + txid, + vec![], + vec![TxOut { + value: 45_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![record], + new_utxos: vec![make_utxo(&addr, txid, 0, 45_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Round 2, a separate `apply` call: an IS-locked conflict sweeps it — + // the row and its output are gone, same as `sweep_only_changeset_ + // deletes_loser_row_and_its_outputs` above. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let swept: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(swept.is_none(), "sanity: the sweep removed the row"); + assert!( + !row_exists(&conn, &w, &output), + "sanity: its output is gone too" + ); + } + + // Round 3, yet another separate `apply` call: the wallet returns + // chainlocked and sweeps the conflict in turn — upstream's newer word, + // carried here as a plain record the same way any fresh transaction + // would arrive. Nothing on this backend needs to know it is a + // "reinstatement" rather than a first sighting. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let record = tx_record( + txid, + vec![], + vec![TxOut { + value: 45_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![record], + new_utxos: vec![make_utxo(&addr, txid, 0, 45_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Durable across a restart — not merely visible within the open + // connection that just wrote it. + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + + let reinstated: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + reinstated.is_some(), + "the reinstating record must be live and durable — a later round is \ + upstream's newer word, and this backend has no tombstone standing \ + in its way" + ); + assert!( + row_exists(&conn, &w, &output), + "the reinstated transaction's own output must be live and durable too" + ); + assert!( + unspent(&conn, &w).contains(&output), + "and spendable — not left behind in some half-restored state" + ); +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 7a508b764fc..06a79aff6e3 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -743,6 +743,26 @@ 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) and, since the sweep-reinstatement fix, is load-bearing + // here too: `onWalletChangesetUtxoAdded` bails when its parent row is + // still `isGloballySwept`, and `onWalletChangesetTransaction` is what + // clears that flag on a reinstating record. Emitting a reinstated + // transaction's own fresh outputs before its record would have them + // walk straight into that guard and be silently dropped, one round + // before the record that was supposed to unlock them. Ordinary + // first-sighting transactions are unaffected either way — the stub + // row `onWalletChangesetUtxoAdded` creates when no parent exists yet + // still covers any residual cross-account race. + 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))?; @@ -757,14 +777,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, From de8879c24590f64b747012bab7576ed0c93fccb9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:50:37 +0300 Subject: [PATCH 029/102] fix(rs-unified-sdk-jni): add onWalletChangesetTransactionsSwept to the descriptor table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trampoline resolves onWalletChangesetTransactionsSwept against NativePersistenceBridge with descriptor ([B[[B[[B[[B)I at the persist_changeset_sweep_batch call site, but the method was missing from BRIDGE_METHOD_TABLE. That table exists so nativeVerifyPersistenceBridgeDescriptors can resolve every JNI method up front; without this entry a drifted descriptor for the sweep path passed the smoke check regardless, and would only have surfaced during a live sweep — where the round fails and the wallet's watermark freezes, per the withhold-the-watermark contract from an earlier round on this branch. --- packages/rs-unified-sdk-jni/src/persistence.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 06a79aff6e3..4a6ef56e667 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -4351,6 +4351,12 @@ 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. Descriptor + // must track the literal at the `call_method` site in + // `persist_changeset_sweep_batch` above. + ("onWalletChangesetTransactionsSwept", "([B[[B[[B[[B)I"), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ From 33758cf8567d4ce73beb57a61f1d7ac45ddfb56f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:50:49 +0300 Subject: [PATCH 030/102] test(rs-platform-wallet): bound a_coalesced_sweep_and_watermark_never_commits_the_height's wait This test awaited obs_rx.recv() with no timeout. Both the adapter and ProbePersister hold their own sender, so a regression that stops the folded round from reaching store() would hang the test instead of failing its assertion. The neighbouring capability tests in this same file already use a bounded receive; this one was missed when it was added. --- packages/rs-platform-wallet/src/changeset/core_bridge.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 6a042dfdbf5..50a96bf7452 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -2811,9 +2811,13 @@ mod tests { cancel.clone(), )); - let observed = obs_rx - .recv() + // 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, From ea8a193fafd21070d181c5abc194fec55391c1eb Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:41:19 +0300 Subject: [PATCH 031/102] fix(swift-sdk): reconcile detached sweep tombstones when the winner row is already gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySweptTransaction returned early when the swept txid had no PersistentTransaction row, treating that as an idempotent no-op. It is not always one: the row is shared across wallets, and a chained sweep can legitimately delete it out from under a wallet whose claims are detached tombstones. A first sweep repoints each wallet's unfunded pending inputs at winner W by scalar spendingTxid; when W's own record arrives, resolveInputOutpoint's (outpoint, spendingTxid) duplicate guard finds those tombstones and attaches nothing to W's row, so when W is swept in turn the first wallet's callback sees no other attached claim and deletes the row. A later wallet's independently committed callback then hit the early return and never applied its own release set: a released outpoint would resurrect spent under the obsolete W once funded, and a held tombstone stayed attributed to W, unable to follow any further sweep. The wallet-scoped scalar tombstone reconciliation now runs regardless of whether the shared row still exists; only the row-scoped half is conditional on the fetch. Regression: the multi-wallet chained-sweep-before-funding scenario, exercising both the released and the held tombstone against a winner row another wallet's callback has already deleted. Kotlin is not exposed: its sweep path issues the tombstone delete/retarget queries unconditionally, keyed on the scalar spendingTxid column (which carries no FK), so a missing transactions row changes nothing — pinned by the same multi-wallet scenario as a confirmation test. SQLite is structurally immune: core_transactions and core_utxos are keyed (wallet_id, txid) / (wallet_id, outpoint), so no other wallet's callback can remove the rows a wallet's own chain relies on — also pinned by a confirmation test. --- .../PlatformWalletPersistenceHandlerTest.kt | 166 ++++++++++++++++ .../tests/sqlite_transaction_sweeps.rs | 182 ++++++++++++++++++ .../PlatformWalletPersistenceHandler.swift | 153 +++++++++------ .../SweptTransactionPersistTests.swift | 149 ++++++++++++++ 4 files changed, 588 insertions(+), 62 deletions(-) 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 3f4e0c479ac..b14efcd82f0 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 @@ -10,6 +10,7 @@ import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.wallet.PlatformWalletPersistenceCapabilities import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity 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 @@ -3131,6 +3132,171 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(finalWinnerTxid.contentEquals(coin.supersededByTxid)) } + @Test + fun sharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() = runTest { + // Multi-wallet continuation of the chained-before-funding scenarios + // above, confirming this handler is NOT exposed to the Swift-side + // review finding on the missing-row early return: every query that + // carries a detached tombstone forward keys on the scalar + // `spendingTxid` (no FK — see [PendingInputEntity]) and runs + // unconditionally in `onWalletChangesetTransactionsSwept`, so the + // shared winner row having already been deleted by another wallet's + // independently committed callback must change nothing about this + // wallet's own release decision reaching its tombstones. + val walletB = ByteArray(32) { 9 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + handler.onPersistAccountRegistration( + walletId, 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(walletId).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) { 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) + handler.onWalletChangesetTransaction( + walletId, sharedLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pA, 1, + ) + 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, one independently committed callback per wallet: W + // beats L, holding everything (nothing funded, nothing released). + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), + ) + handler.onChangesetEnd(walletB, success = true) + assertNull("L is gone once both wallets ran", db.transactionDao().getByTxid(sharedLoser)) + + // W's own record arrives claiming all three outpoints. Each + // `(outpoint, W)` tombstone occupies 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. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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, releases + // its own coin, and — finding no attached claim of any other + // wallet's — deletes the shared row. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(pA), + ) + handler.onChangesetEnd(walletId, success = true) + 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), + ) + + // Wallet B's callback arrives after the row is gone, releasing one + // of its two coins and holding the other. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(rB), + ) + handler.onChangesetEnd(walletB, success = true) + + val heldTombstone = db.documentDao().getPendingInputsByOutpoint(pB).single() + assertTrue(heldTombstone.isSweptTombstone) + assertTrue( + "the held tombstone must follow the chain to X even though W's row was " + + "already deleted by wallet A's callback", + finalWinner.contentEquals(heldTombstone.spendingTxid), + ) + assertTrue( + "wallet B's release decision must reach 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 loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { // prior-2 regression: the persisted Core address pools must come diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 219a391d09c..d194f6583c4 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1001,6 +1001,188 @@ fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winne ); } +/// Confirmation, not a fix, of this round's BLOCKING finding on the mobile +/// backends' missing-row early return: there, one wallet's callback can +/// delete the shared winner row while a second wallet's detached tombstones +/// still name it, and the second wallet's own sweep of that winner then has +/// to reconcile them against a row that no longer exists. No such moment +/// exists here. `core_transactions` is keyed `(wallet_id, txid)`, so each +/// wallet sweeps its own copy of the winner and no other wallet's call can +/// have removed it first; and the tombstone is not a detached side-table row +/// but the wallet's own `core_utxos` placeholder, matched by `apply_sweep` +/// through the winner's own stored inputs — `(wallet_id, outpoint)`-scoped, +/// so the chain continues per wallet with nothing shared to lose. +/// +/// This is the reviewer's multi-wallet chained-sweep-before-funding shape +/// end to end: the same loser txid in two wallets, each claiming a +/// still-unfunded coin of its own; W beats L (both coins held as +/// placeholders); W's own record lands; X beats W, with wallet 1 releasing +/// its coin and wallet 2 holding — in that order, so wallet 1's whole chain +/// including its deletion of (its copy of) W commits before wallet 2's +/// callback runs. Each wallet's decision must land on its own coin only, and +/// each coin's eventual funding must respect it. +#[test] +fn a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombstones() { + let (persister, _tmp, _path) = fresh_persister(); + let w1: WalletId = wid(0xF1); + let w2: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x51); + let addr2 = p2pkh(0x52); + let funding_txid = Txid::from_byte_array([0x50; 32]); + // Wallet 1's coin and wallet 2's coin. Neither funding side has been + // observed in either wallet until the very end. + let p1 = OutPoint::new(funding_txid, 0); + let p2 = OutPoint::new(funding_txid, 1); + let shared_loser = Txid::from_byte_array([0x53; 32]); // L + let shared_winner = Txid::from_byte_array([0x54; 32]); // W + let final_winner = Txid::from_byte_array([0x55; 32]); // X + + // The raw transactions are the same for both wallets — a record is the + // whole on-chain transaction, inputs included — so each wallet's copy + // claims both outpoints even though only one is its own coin. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, if w == &w1 { &addr1 } else { &addr2 }); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(shared_loser, vec![p1, p2], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // First sweep in both wallets: W beats L, holding everything. Leaves + // each wallet a placeholder row per claimed outpoint, attributed to W. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_loser], + superseded_by: shared_winner, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // W's own record lands in both wallets, as any wallet-relevant winner's + // eventually does. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(shared_winner, vec![p1, p2], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Second sweep, wallet 1 first: X beats W and wallet 1 releases its own + // coin. Its copy of W's row is deleted in the same call. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_winner], + superseded_by: final_winner, + released_outpoints: vec![p1], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + let gone: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w1.as_slice(), AsRef::<[u8]>::as_ref(&shared_winner)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(gone.is_none(), "wallet 1's own copy of W is deleted"); + let w2_placeholder: Option> = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&p2).unwrap()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + w2_placeholder, + Some(AsRef::<[u8]>::as_ref(&shared_winner).to_vec()), + "wallet 1's whole chained sweep, deletion included, must leave wallet 2's \ + placeholder exactly where wallet 2's own first sweep put it" + ); + } + + // Wallet 2's callback runs only now, holding its coin. Its own copy of + // W is still on hand — nothing wallet 1 committed could have removed a + // `(wallet_id, txid)`-keyed row of wallet 2's. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_winner], + superseded_by: final_winner, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w2, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The funding transaction finally arrives, each coin through its own + // wallet's round. + for (w, addr, vout) in [(&w1, &addr1, 0u32), (&w2, &addr2, 1u32)] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(addr, funding_txid, vout, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w1).contains(&p1), + "wallet 1's released coin comes back spendable once funded" + ); + assert!( + !unspent(&conn, &w2).contains(&p2), + "wallet 2's held coin stays spent" + ); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&p2).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 1); + assert_eq!( + spent_in_txid, + Some(AsRef::<[u8]>::as_ref(&final_winner).to_vec()), + "wallet 2's placeholder followed its own chain to the final winner, \ + driven entirely by wallet 2's own calls" + ); +} + /// Confirmation, not a fix: the review finding that motivated the Swift/ /// Kotlin backend changes (a shared `PersistentTransaction` row updated with /// one wallet's `released_outpoints` before another wallet's own callback diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 5185543d39a..c66d533136b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1019,68 +1019,85 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) descriptor.fetchLimit = 1 descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] - // A successful fetch that finds nothing is an ordinary no-op: sweeps - // are idempotent and can name a transaction this store never had. - guard let row = try backgroundContext.fetch(descriptor).first else { return } - - // 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 { - txo.isSpent = !released.contains(txo.outpoint) - txo.spendingTransaction = nil - txo.lastUpdated = Date() - } - for pending in row.pendingInputs where pending.walletId == walletId { - guard !released.contains(pending.outpoint) else { continue } - pending.spendingTransaction = nil - pending.spendingTxid = supersededBy - pending.isSweptTombstone = true - } - - // Whatever is still attached to `row` after the scoping above is - // either this wallet's own released pending input — deliberately - // left in place two paragraphs up so the cascade below removes it — - // or an input/pending row a different wallet has not yet weighed in - // on. Only the second case has to hold the delete back; the first - // would otherwise make a wallet wait on its own already-finished - // decision. 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. - 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) + // A successful fetch that finds nothing skips only the row-scoped + // work below, 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. + // Returning here 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. + let row = try backgroundContext.fetch(descriptor).first + + 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 { + txo.isSpent = !released.contains(txo.outpoint) + txo.spendingTransaction = nil + txo.lastUpdated = Date() + } + for pending in row.pendingInputs where pending.walletId == walletId { + guard !released.contains(pending.outpoint) else { continue } + pending.spendingTransaction = nil + pending.spendingTxid = supersededBy + pending.isSweptTombstone = true + } + + // Whatever is still attached to `row` after the scoping above is + // either this wallet's own released pending input — deliberately + // left in place two paragraphs up so the cascade below removes it + // — or an input/pending row a different wallet has not yet + // weighed in on. Only the second case has to hold the delete + // back; the first would otherwise make a wallet wait on its own + // already-finished decision. 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. + 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 @@ -1091,6 +1108,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 outside the `if let row` above. 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. var tombstoneDescriptor = FetchDescriptor( predicate: #Predicate { $0.spendingTxid == txid && $0.isSweptTombstone == true && $0.walletId == walletId diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 713259ed695..c3e89f3d7f3 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -1132,6 +1132,142 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertEqual(coin.supersededByTxid, finalWinner) } + /// 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)], walletId: walletId) + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner)], 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, 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, 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 @@ -1140,6 +1276,19 @@ final class SweptTransactionPersistTests: XCTestCase { _ 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") From df88cdaa7288c92775d5c6bac88ed10e629d6dce Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:45:09 +0300 Subject: [PATCH 032/102] fix(platform-wallet-ffi): carry sweeps on the size-tagged extension, not WalletChangeSetFFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WalletChangeSetFFI crosses the C ABI by bare pointer — the changeset callback has no size or version field — so appending the sweep fields to it was safe in only one direction. A current Swift callback installed on the previous native library (nothing prevents that pairing: the callback signature and every manager-create entry point are unchanged) would unconditionally read cs.sweeps_count and could dereference cs.sweeps, both beyond the end of the older producer's allocation: undefined behavior on an ordinary changeset round. The CORE_SWEEP_REMOVAL capability bit gates semantics, not memory layout, and cannot make an out-of-bounds read safe. Restore the struct to its released layout and move the batches onto PersistenceCallbacksExtension — the crate's existing size-tagged transport, whose declared struct_size is exactly the proof of presence the bare struct cannot give. The slot is appended under version 1 (bumping the version would fail-close an existing host's DPNS callback too; struct_size alone carries the difference), Rust reads it only when the host's declared size covers it, and CORE_SWEEP_REMOVAL is now attested structurally by that slot rather than by the legacy changeset pointer whose presence proves nothing. The persister fires the sweep callback right after the changeset callback inside the same begin/end round, preserving records-before-removals ordering. Both cross-version pairings are now safe: an old host is simply never handed sweeps (and the core bridge already freezes its watermark for sweep-carrying rounds), and a new host on an old library reads only the unchanged struct prefix. Swift and the JNI trampoline move to the extension slot; the Kotlin bridge method and its descriptor are unchanged. Tests pin the append-only extension layout, the refusal to read the slot from a legacy-sized extension, the capability gate, and the in-order after-the-changeset delivery. --- .../dashsdk/ffi/NativePersistenceBridge.kt | 10 +- .../src/core_wallet_types.rs | 193 +++++----- .../rs-platform-wallet-ffi/src/manager.rs | 92 ++++- .../rs-platform-wallet-ffi/src/persistence.rs | 355 ++++++++++++++++-- .../src/changeset/core_bridge.rs | 36 +- .../src/changeset/persistence_capabilities.rs | 13 +- .../rs-unified-sdk-jni/src/persistence.rs | 44 ++- .../PlatformWalletManager.swift | 11 +- .../PlatformWalletPersistenceHandler.swift | 184 ++++++--- .../SweptTransactionPersistTests.swift | 26 +- 10 files changed, 729 insertions(+), 235 deletions(-) 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 ef535897e20..ca8b645610c 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 @@ -316,9 +316,13 @@ abstract class NativePersistenceBridge { * transaction may pay entirely to outside addresses and never be * reported here at all. * - * The default body below returns success without deleting anything — - * exactly the "reads the unchanged prefix and reports success" failure - * mode `PersistenceCapabilities::CORE_SWEEP_REMOVAL` exists to catch. A + * Native delivers these through the persistence extension's + * size-negotiated sweep callback (not the wallet-changeset struct, whose + * bare-pointer ABI cannot version itself), immediately after the + * changeset's own slots in the same round. The default body below still + * returns success without deleting anything — the + * accepted-but-never-applied failure mode + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL` exists to catch. A * subclass overriding this must also add that bit to * [PlatformWalletPersistenceHandler.persistenceCapabilitiesBits]'s * result; the Rust side will not trust a bare `Int` return of `0` here diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index e43a77caa3a..d1997d23f87 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -236,26 +236,36 @@ pub struct WalletChangeSetFFI { /// `proof.rs` can't fire until SPV re-applies a fresh CL). pub last_applied_chain_lock_bytes: *mut u8, pub last_applied_chain_lock_bytes_len: usize, - /// Sweeps this round carries, in the order the wallet emitted them. - /// - /// The only subtractive field in this struct, and the only one whose - /// order matters: each entry describes the wallet as that sweep saw it, - /// and a later entry can keep a coin spent that an earlier one freed. - /// **A persister must apply them in sequence** — folding them together - /// lets the first answer outlive the last one that is actually true. - /// - /// Every other field here is additive, so ignoring this one leaves dead - /// rows that are handed back at the next load and re-create a balance - /// the wallet has already corrected. - /// - /// `null` / `0` when the round swept nothing, the overwhelmingly common - /// case. - pub sweeps: *mut SweepBatchFFI, - pub sweeps_count: usize, + // This struct's layout is FROZEN here. It crosses the C ABI by bare + // pointer — `on_persist_wallet_changeset_fn` carries no size or version + // field — so appending anything makes the pairing of a new callback + // with an older native producer read past the end of the producer's + // allocation: the callback signature and the manager-create entry + // points are unchanged, so nothing stops that pairing, and a capability + // bit gates semantics, not memory layout — it cannot make an + // out-of-bounds read safe. The round's sweep batches, briefly appended + // here, now travel through the size-tagged + // `PersistenceCallbacksExtension` sweep callback instead (see + // `persistence.rs`), whose declared `struct_size` is exactly the proof + // of presence this struct cannot give. New per-round payloads must take + // that same route. } /// One sweep: the transactions it removed, the transaction that beat them, /// and the coins its removal actually freed. +/// +/// Delivered through `PersistenceCallbacksExtension`'s +/// `on_persist_wallet_changeset_sweeps_fn` — deliberately NOT a field on +/// [`WalletChangeSetFFI`], whose bare-pointer ABI cannot prove to a newer +/// consumer that an older producer allocated the field (see the layout note +/// there). The batches arrive in the order the wallet emitted them, and the +/// only subtractive part of a persistence round rides here: each entry +/// describes the wallet as that sweep saw it, and a later entry can keep a +/// coin spent that an earlier one freed. **A persister must apply them in +/// sequence** — folding them together lets the first answer outlive the +/// last one that is actually true. Ignoring them leaves dead rows that are +/// handed back at the next load and re-create a balance the wallet has +/// already corrected. #[repr(C)] pub struct SweepBatchFFI { /// Removed transactions, raw 32-byte txids. Delete these rows and every @@ -487,52 +497,6 @@ impl WalletChangeSetFFI { None => (std::ptr::null_mut(), 0), }; - // Sweeps travel at the top level, not per account: the upstream - // events are wallet-scoped, and the persister deletes by txid — the - // row it deletes carries its own account link. Order is preserved. - let sweeps: Vec = cs - .sweeps - .iter() - .map(|batch| { - let txids: Vec<[u8; 32]> = batch - .txids - .iter() - .map(|txid| { - let mut raw = [0u8; 32]; - raw.copy_from_slice(txid.as_ref()); - raw - }) - .collect(); - let txids_count = txids.len(); - - let released: Vec = batch - .released_outpoints - .iter() - .map(|outpoint| { - let mut txid = [0u8; 32]; - txid.copy_from_slice(outpoint.txid.as_ref()); - OutPointFFI { - txid, - vout: outpoint.vout, - } - }) - .collect(); - let released_outpoints_count = released.len(); - - let mut superseded_by = [0u8; 32]; - superseded_by.copy_from_slice(batch.superseded_by.as_ref()); - - SweepBatchFFI { - txids: vec_to_ptr(txids), - txids_count, - superseded_by, - released_outpoints: vec_to_ptr(released), - released_outpoints_count, - } - }) - .collect(); - let sweeps_count = sweeps.len(); - WalletChangeSetFFI { has_chain, chain, @@ -542,12 +506,86 @@ impl WalletChangeSetFFI { accounts_count, last_applied_chain_lock_bytes, last_applied_chain_lock_bytes_len, - sweeps: vec_to_ptr(sweeps), - sweeps_count, } } } +/// Backing storage for one [`SweepBatchFFI`]'s nested buffers. The C struct +/// borrows into it, so the caller keeps this alive for the callback window — +/// the same `(entries, storage)` discipline +/// `build_address_pools_for_callback` uses, rather than `Box::into_raw` + +/// a paired free: nothing outlives the call, so nothing needs a free path. +pub(crate) struct SweepBatchStorage { + txids: Vec<[u8; 32]>, + released: Vec, +} + +/// Build the C mirrors of a changeset's sweep batches for the extension +/// sweep callback (`on_persist_wallet_changeset_sweeps_fn`), preserving the +/// wallet's emission order — the one property a persister cannot recover on +/// its own, since a later batch can keep a coin spent that an earlier one +/// freed. Sweeps travel wallet-scoped, not per account: the upstream events +/// are wallet-scoped, and the persister deletes by txid — the row it +/// deletes carries its own account link. +pub(crate) fn build_sweep_batches_for_callback( + cs: &platform_wallet::changeset::CoreChangeSet, +) -> (Vec, Vec) { + let storage: Vec = cs + .sweeps + .iter() + .map(|batch| SweepBatchStorage { + txids: batch + .txids + .iter() + .map(|txid| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(txid.as_ref()); + raw + }) + .collect(), + released: batch + .released_outpoints + .iter() + .map(|outpoint| { + let mut txid = [0u8; 32]; + txid.copy_from_slice(outpoint.txid.as_ref()); + OutPointFFI { + txid, + vout: outpoint.vout, + } + }) + .collect(), + }) + .collect(); + + let batches: Vec = cs + .sweeps + .iter() + .zip(storage.iter()) + .map(|(batch, backing)| { + let mut superseded_by = [0u8; 32]; + superseded_by.copy_from_slice(batch.superseded_by.as_ref()); + SweepBatchFFI { + txids: if backing.txids.is_empty() { + std::ptr::null_mut() + } else { + backing.txids.as_ptr() as *mut [u8; 32] + }, + txids_count: backing.txids.len(), + superseded_by, + released_outpoints: if backing.released.is_empty() { + std::ptr::null_mut() + } else { + backing.released.as_ptr() as *mut OutPointFFI + }, + released_outpoints_count: backing.released.len(), + } + }) + .collect(); + + (batches, storage) +} + /// Returns the account "index" the FFI surfaces in `account_index`. /// /// For variants with a natural index field (`Standard`, `CoinJoin`, @@ -1793,33 +1831,6 @@ pub unsafe fn free_wallet_changeset_ffi(cs: &WalletChangeSetFFI) { )); } - // Before the accounts early-return below: a sweep-only round carries - // no accounts at all, and its buffers still have to be released. - if !cs.sweeps.is_null() && cs.sweeps_count > 0 { - let batches = std::slice::from_raw_parts(cs.sweeps, cs.sweeps_count); - for batch in batches { - if !batch.txids.is_null() && batch.txids_count > 0 { - drop(Vec::from_raw_parts( - batch.txids, - batch.txids_count, - batch.txids_count, - )); - } - if !batch.released_outpoints.is_null() && batch.released_outpoints_count > 0 { - drop(Vec::from_raw_parts( - batch.released_outpoints, - batch.released_outpoints_count, - batch.released_outpoints_count, - )); - } - } - drop(Vec::from_raw_parts( - cs.sweeps, - cs.sweeps_count, - cs.sweeps_count, - )); - } - if cs.accounts.is_null() || cs.accounts_count == 0 { return; } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3cfcebb4957..90949847575 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,8 +8,9 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, PersistDpnsNameStatesFn, PersistenceCallbacks, PersistenceCallbacksExtension, - PersistenceCapabilitiesFFI, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + FFIPersister, PersistDpnsNameStatesFn, PersistWalletChangesetSweepsFn, PersistenceCallbacks, + PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -76,6 +77,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create( PersistenceCapabilities::NONE, None, None, + None, out_handle, ) } @@ -102,6 +104,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_capabil declaration, None, None, + None, out_handle, ) } @@ -128,12 +131,14 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_extensi check_ptr!(persistence_extension); let declaration = persistence_capabilities_declaration(&*persistence_capabilities); let dpns_callback = persistence_extension_dpns_callback(persistence_extension); + let sweeps_callback = persistence_extension_sweeps_callback(persistence_extension); platform_wallet_manager_create_impl( sdk_ptr, persistence, event_handler, declaration, dpns_callback, + sweeps_callback, None, out_handle, ) @@ -158,6 +163,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( check_ptr!(event_extension); let declaration = persistence_capabilities_declaration(&*persistence_capabilities); let dpns_persistence_callback = persistence_extension_dpns_callback(persistence_extension); + let sweeps_persistence_callback = persistence_extension_sweeps_callback(persistence_extension); let dpns_event_callback = event_extension_dpns_callback(event_extension); platform_wallet_manager_create_impl( sdk_ptr, @@ -165,6 +171,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( event_handler, declaration, dpns_persistence_callback, + sweeps_persistence_callback, dpns_event_callback, out_handle, ) @@ -193,6 +200,36 @@ unsafe fn persistence_extension_dpns_callback( std::ptr::addr_of!((*extension).on_persist_dpns_name_states_fn).read() } +/// Same gate, later field: the sweeps slot is read only when the host's +/// declared `struct_size` proves it was allocated, so an extension built +/// before the slot existed keeps its DPNS callback and simply never has +/// sweeps read — the fail-closed half of the negotiation the changeset +/// struct itself cannot perform (dashpay/platform#4406, finding 2). The +/// version check stays an exact match on purpose: the version names the +/// field ordering, and appending under it is what `struct_size` exists for. +unsafe fn persistence_extension_sweeps_callback( + extension: *const PersistenceCallbacksExtension, +) -> Option { + let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); + let version_end = + std::mem::offset_of!(PersistenceCallbacksExtension, version) + std::mem::size_of::(); + if supplied_size < version_end { + return None; + } + let version = std::ptr::addr_of!((*extension).version).read(); + if version != PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION { + return None; + } + let callback_end = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + std::mem::size_of::>(); + if supplied_size < callback_end { + return None; + } + std::ptr::addr_of!((*extension).on_persist_wallet_changeset_sweeps_fn).read() +} + unsafe fn event_extension_dpns_callback( extension: *const EventHandlerCallbacksExtension, ) -> Option { @@ -222,6 +259,7 @@ unsafe fn platform_wallet_manager_create_impl( event_handler: *const EventHandlerCallbacks, declared_capabilities: PersistenceCapabilities, dpns_name_states_callback: Option, + wallet_changeset_sweeps_callback: Option, dpns_event_callback: Option, out_handle: *mut Handle, ) -> PlatformWalletFFIResult { @@ -259,10 +297,11 @@ unsafe fn platform_wallet_manager_create_impl( let sdk = Arc::new((*(sdk_ptr as *const Sdk)).clone()); let persister = Arc::new( - FFIPersister::new_with_persistence_capabilities_and_dpns_callback( + FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( std::ptr::read(persistence), declared_capabilities, dpns_name_states_callback, + wallet_changeset_sweeps_callback, ), ); let handler: Arc = Arc::new(FFIEventHandler::new( @@ -775,6 +814,15 @@ mod tests { 0 } + unsafe extern "C" fn persist_wallet_changeset_sweeps( + _context: *mut c_void, + _wallet_id: *const u8, + _sweeps: *const crate::core_wallet_types::SweepBatchFFI, + _sweeps_count: usize, + ) -> i32 { + 0 + } + fn persistence_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_changeset_begin_fn: Some(begin_changeset), @@ -1087,15 +1135,53 @@ mod tests { on_persist_dpns_name_states_fn ), on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), ..Default::default() }; let unknown = PersistenceCallbacksExtension { version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION + 1, on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), ..Default::default() }; assert!(unsafe { persistence_extension_dpns_callback(&short) }.is_none()); assert!(unsafe { persistence_extension_dpns_callback(&unknown) }.is_none()); + assert!(unsafe { persistence_extension_sweeps_callback(&short) }.is_none()); + assert!(unsafe { persistence_extension_sweeps_callback(&unknown) }.is_none()); + } + + /// The exact cross-version pairing the sweep transport exists for: a + /// host built when the extension ended at the DPNS slot declares that + /// smaller `struct_size` — bytes it filled with a live callback are + /// still bytes, so nothing but the declared size distinguishes this + /// from a current struct. The sweeps slot must be refused, never read + /// (reading it here would be exactly the past-the-allocation + /// dereference the changeset struct could not prevent), while the + /// DPNS slot the size does prove keeps working. + #[test] + fn a_legacy_sized_extension_refuses_the_sweeps_slot_but_keeps_dpns() { + let legacy_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ); + let legacy = PersistenceCallbacksExtension { + struct_size: legacy_size, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + // Set in the fixture to prove the gate never LOOKS: were the + // size check wrong, the read would find a live pointer and the + // assertion below would catch it. + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + ..Default::default() + }; + assert!(unsafe { persistence_extension_dpns_callback(&legacy) }.is_some()); + assert!(unsafe { persistence_extension_sweeps_callback(&legacy) }.is_none()); + + let current = PersistenceCallbacksExtension { + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + ..Default::default() + }; + assert!(unsafe { persistence_extension_sweeps_callback(¤t) }.is_some()); } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5b27ea98d60..082c9610008 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -43,7 +43,9 @@ use crate::contact_persistence::{ free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; -use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; +use crate::core_wallet_types::{ + build_sweep_batches_for_callback, free_wallet_changeset_ffi, SweepBatchFFI, WalletChangeSetFFI, +}; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ build_dpns_name_state_entries, free_dpns_name_state_entries, DpnsNameStateFFI, @@ -132,6 +134,23 @@ pub type PersistDpnsNameStatesFn = unsafe extern "C" fn( removed_count: usize, ) -> i32; +/// Carries a round's sweep batches — the removals of transactions a later, +/// final transaction provably beat to an input. Fired between the same +/// begin/end pair as the round's other per-kind callbacks, immediately +/// after `on_persist_wallet_changeset_fn`, so the additive half of the +/// round (including a wallet-relevant winner's own record) is already +/// staged when the removal decides which links point at a dead +/// transaction. Batches arrive in emission order and must be applied in +/// sequence; see [`SweepBatchFFI`]. A non-zero return fails the round like +/// any other per-kind callback — a deletion silently skipped would let +/// Rust clear the sweep while the dead row survives. +pub type PersistWalletChangesetSweepsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -163,6 +182,25 @@ pub struct PersistenceCallbacksExtension { removed_count: usize, ) -> i32, >, + /// The round's sweep batches (see [`PersistWalletChangesetSweepsFn`]). + /// Lives here rather than on [`WalletChangeSetFFI`] because that struct + /// crosses by bare pointer with no size field: appending the batches + /// there would let a newer callback dereference fields an older native + /// producer never allocated. Appended under the same version — the + /// version names the stable field ordering, and `struct_size` is what + /// proves how much of it a given host actually supplied: Rust reads + /// this slot only when the host's declared size covers it, so an older + /// extension simply never has its sweeps read rather than being + /// rejected outright (which a version bump would do, taking its DPNS + /// callback down with it). + pub on_persist_wallet_changeset_sweeps_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -172,6 +210,7 @@ impl Default for PersistenceCallbacksExtension { version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, reserved: 0, on_persist_dpns_name_states_fn: None, + on_persist_wallet_changeset_sweeps_fn: None, } } } @@ -951,6 +990,12 @@ pub struct FFIPersister { callbacks: PersistenceCallbacks, /// Additive callbacks negotiated outside the legacy unsized vtable. dpns_name_states_callback: Option, + /// `Some` only when the host's extension `struct_size` proved the slot + /// was allocated (see `persistence_extension_sweeps_callback` in + /// `manager.rs`) — which is also what makes it a real structural + /// attestation of `CORE_SWEEP_REMOVAL`, unlike the legacy changeset + /// callback whose unchanged signature proves nothing. + wallet_changeset_sweeps_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1014,10 +1059,25 @@ impl FFIPersister { callbacks: PersistenceCallbacks, declared_capabilities: PersistenceCapabilities, dpns_name_states_callback: Option, + ) -> Self { + Self::new_with_persistence_capabilities_and_extension_callbacks( + callbacks, + declared_capabilities, + dpns_name_states_callback, + None, + ) + } + + pub fn new_with_persistence_capabilities_and_extension_callbacks( + callbacks: PersistenceCallbacks, + declared_capabilities: PersistenceCapabilities, + dpns_name_states_callback: Option, + wallet_changeset_sweeps_callback: Option, ) -> Self { Self { callbacks, dpns_name_states_callback, + wallet_changeset_sweeps_callback, declared_capabilities, pending: RwLock::new(BTreeMap::new()), round_lock: Mutex::new(RoundGuardState::default()), @@ -1063,19 +1123,21 @@ impl FFIPersister { if self.callbacks.on_persist_token_balances_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } - // `on_persist_wallet_changeset_fn` is the one callback that ever - // carries `WalletChangeSetFFI.sweeps` — it is also the callback - // `PROVIDER_TRANSACTIONS` above gates on, and its C signature did not - // change when the sweep fields were appended to the struct it - // receives a pointer to. So its presence alone proves nothing about - // whether the host actually reads those fields: an out-of-tree - // caller built against the pre-sweep struct layout still has this - // pointer wired, reads the unchanged prefix, and returns success. - // That gap is exactly why this bit is also gated by - // `declared_capabilities` in `persistence_capabilities()` below — - // the intersection requires the host to explicitly attest the - // semantic contract, not just have the vtable slot filled in. - if self.callbacks.on_persist_wallet_changeset_fn.is_some() { + // Sweeps travel through the size-tagged extension callback, so — + // unlike the legacy `on_persist_wallet_changeset_fn`, whose + // unchanged C signature proves nothing about what a host actually + // reads — this slot being `Some` is a genuine structural + // attestation: it exists only when the host's declared extension + // `struct_size` covered the field. The changeset callback is still + // required alongside it because a sweep only corrects state that + // callback persists; a sweeps slot with no changeset slot would + // attest removals against rows the host never writes. The bit is + // still additionally gated by `declared_capabilities` in + // `persistence_capabilities()` below, like every other bit: the + // host must attest the semantic contract, not just wire pointers. + if self.wallet_changeset_sweeps_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + { capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); } #[cfg(feature = "shielded")] @@ -1403,6 +1465,38 @@ impl PlatformWalletPersistence for FFIPersister { round_success = false; } } + + // The round's sweeps ride their own size-negotiated extension + // callback rather than the changeset struct (see the layout note + // on `WalletChangeSetFFI`), fired immediately after it — still + // inside the same begin/end bracket — so the additive half of + // the round, a wallet-relevant winner's own record included, is + // already staged when the removal decides which links point at a + // dead transaction. A host without the slot simply never sees + // them; that is safe to leave silent here because such a host + // can never attest `CORE_SWEEP_REMOVAL`, and the core bridge + // already freezes the sync watermark for a sweep-carrying round + // against a persister without that capability. + if !core_cs.sweeps.is_empty() { + if let Some(cb) = self.wallet_changeset_sweeps_callback { + let (batches, _batch_storage) = build_sweep_batches_for_callback(core_cs); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + batches.as_ptr(), + batches.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet changeset sweeps persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } } // Send identity scalar changeset — upserts and removals. @@ -6041,6 +6135,14 @@ mod tests { ) -> i32 { 0 } + unsafe extern "C" fn noop_wallet_changeset_sweeps( + _ctx: *mut c_void, + _wallet_id: *const u8, + _sweeps: *const SweepBatchFFI, + _sweeps_count: usize, + ) -> i32 { + 0 + } unsafe extern "C" fn noop_token_balances( _ctx: *mut c_void, _wallet_id: *const u8, @@ -6203,16 +6305,25 @@ mod tests { assert!(!capabilities.contains(PersistenceCapabilities::WALLET_RESTORE)); } - /// `CORE_SWEEP_REMOVAL` rides the same callback pointer as - /// `PROVIDER_TRANSACTIONS` (`on_persist_wallet_changeset_fn`), and that - /// pointer's C signature is unchanged by the sweep fields appended to - /// `WalletChangeSetFFI` — an out-of-tree host built before this bit - /// existed still has it wired. The bit must therefore come from the - /// host's explicit declaration, not from the callback's mere presence: - /// wired-but-undeclared and declared-but-unwired must each attest - /// nothing, and only both together attest the bit. + /// `CORE_SWEEP_REMOVAL` requires the extension's size-negotiated + /// sweeps slot, the legacy changeset callback it corrects, AND the + /// host's explicit declaration. The legacy callback alone must never + /// attest it: its C signature never changed, so an out-of-tree host + /// built before sweeps existed still has that pointer wired — the + /// extension slot is the only structural fact that distinguishes a + /// sweep-aware host, because it exists only when the host's declared + /// `struct_size` proved it. #[test] - fn core_sweep_removal_requires_both_the_callback_and_the_declaration() { + fn core_sweep_removal_requires_the_extension_slot_and_the_declaration() { + fn persister_with( + callbacks: PersistenceCallbacks, + declared: PersistenceCapabilities, + sweeps: Option, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + callbacks, declared, None, sweeps, + ) + } fn wired_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), @@ -6220,31 +6331,178 @@ mod tests { } } - // Structurally complete, but the host never declared it (the - // pre-sweep-aware binary case): absent. - assert!( - !declared_persister(wired_callbacks(), PersistenceCapabilities::NONE) - .persistence_capabilities() - .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL) - ); + // The pre-sweep-aware binary shape: legacy changeset callback + // wired, declaration present (a host blindly OR-ing bits), but no + // extension slot — absent. + assert!(!persister_with( + wired_callbacks(), + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + None + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); - // Declared, but the callback pointer isn't even wired: absent. - assert!(!declared_persister( + // Extension slot wired and declared, but no changeset callback to + // persist the rows a sweep would correct: absent. + assert!(!persister_with( PersistenceCallbacks::default(), - PersistenceCapabilities::CORE_SWEEP_REMOVAL + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + Some(noop_wallet_changeset_sweeps) ) .persistence_capabilities() .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); - // Both: attested. - assert!(declared_persister( + // Structurally complete but never declared: absent. + assert!(!persister_with( wired_callbacks(), - PersistenceCapabilities::CORE_SWEEP_REMOVAL + PersistenceCapabilities::NONE, + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // All three: attested. + assert!(persister_with( + wired_callbacks(), + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + Some(noop_wallet_changeset_sweeps) ) .persistence_capabilities() .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); } + /// The delivery contract of the extension transport itself: a + /// sweep-carrying round hands its batches to the extension slot AFTER + /// the changeset callback, within the same round, in emission order and + /// with payloads intact — order is the one property a persister cannot + /// reconstruct, since a later batch can keep a coin spent that an + /// earlier one freed. The same round against a persister whose + /// extension never proved the slot must still succeed with the sweeps + /// simply undelivered: the adapter's `CORE_SWEEP_REMOVAL` gate is what + /// turns that into a withheld watermark rather than a false success. + #[test] + fn store_delivers_sweeps_through_the_extension_slot_after_the_changeset() { + use dashcore::hashes::Hash as _; + use platform_wallet::changeset::changeset::SweepBatch; + use platform_wallet::changeset::CoreChangeSet; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events.lock().unwrap().push("changeset".into()); + 0 + } + unsafe extern "C" fn record_sweeps( + ctx: *mut c_void, + _wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let mut events = sink.events.lock().unwrap(); + for batch in slice::from_raw_parts(sweeps, sweeps_count) { + let txids = slice::from_raw_parts(batch.txids, batch.txids_count); + let released = if batch.released_outpoints.is_null() { + &[][..] + } else { + slice::from_raw_parts(batch.released_outpoints, batch.released_outpoints_count) + }; + events.push(format!( + "sweep txids={:?} winner={} released={:?}", + txids.iter().map(|t| t[0]).collect::>(), + batch.superseded_by[0], + released + .iter() + .map(|o| (o.txid[0], o.vout)) + .collect::>(), + )); + } + 0 + } + + fn sweep_changeset() -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + sweeps: vec![ + SweepBatch { + txids: vec![dashcore::Txid::from_byte_array([0x11; 32])], + superseded_by: dashcore::Txid::from_byte_array([0x22; 32]), + released_outpoints: vec![dashcore::OutPoint::new( + dashcore::Txid::from_byte_array([0x33; 32]), + 7, + )], + }, + SweepBatch { + txids: vec![ + dashcore::Txid::from_byte_array([0x44; 32]), + dashcore::Txid::from_byte_array([0x55; 32]), + ], + superseded_by: dashcore::Txid::from_byte_array([0x66; 32]), + released_outpoints: vec![], + }, + ], + ..Default::default() + }), + ..Default::default() + } + } + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + callbacks, + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + None, + Some(record_sweeps), + ); + persister + .store([1u8; 32], sweep_changeset()) + .expect("sweep round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "sweep txids=[17] winner=34 released=[(51, 7)]".to_string(), + "sweep txids=[68, 85] winner=102 released=[]".to_string(), + ], + ); + drop(persister); + + // No extension slot: the round still succeeds, the changeset + // callback still fires, and the sweeps are never delivered — the + // legacy-host shape, safe because such a persister can never attest + // CORE_SWEEP_REMOVAL (see the capability test above). + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store([1u8; 32], sweep_changeset()) + .expect("sweepless-host round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + drop(persister); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -6311,7 +6569,15 @@ mod tests { cb.on_load_wallet_list_free_fn = Some(noop_free_wallets); cb.on_persist_wallet_changeset_fn = Some(noop_wallet_changeset); cb.on_persist_token_balances_fn = Some(noop_token_balances); - let capabilities = declared_persister(cb, expected).persistence_capabilities(); + // "Fully wired" includes the extension's sweeps slot — the legacy + // vtable alone can no longer attest CORE_SWEEP_REMOVAL. + let capabilities = FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + cb, + expected, + None, + Some(noop_wallet_changeset_sweeps), + ) + .persistence_capabilities(); assert_eq!(capabilities, expected); assert!(capabilities.contains(PersistenceCapabilities::INVITATION_CREATION)); @@ -6380,11 +6646,26 @@ mod tests { std::mem::size_of::() ); assert_eq!(PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, 1); + // The extension is append-only under version 1: the DPNS slot's end + // is exactly where the sweeps slot begins (a version-1 host that + // predates sweeps declared its struct_size at that boundary), and + // the sweeps slot is currently terminal. Reordering either would + // silently misread every extension already in the field. assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, on_persist_dpns_name_states_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 50a96bf7452..65fd497d3eb 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -452,14 +452,16 @@ where // persisted only in the `Ok` arm below. let offered_height = core.synced_height; - // `WalletChangeSetFFI` has no size/version header, so a persister - // compiled against a pre-sweep struct layout — an old C callback, or - // a Kotlin subclass that never overrode - // `onWalletChangesetTransactionsSwept` — reads the unchanged prefix - // 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. + // 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() @@ -2710,15 +2712,15 @@ mod tests { } } - /// dashpay/platform#4406 (finding 2): `WalletChangeSetFFI` has no size or - /// version header, so an older callback compiled against the pre-sweep - /// struct layout reads the unchanged prefix, returns success, and never - /// sees `core.sweeps` at all. 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 + /// 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() { diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index deea74faee7..c1ab5c6fa41 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -54,12 +54,13 @@ impl PersistenceCapabilities { /// A stored `CoreChangeSet` whose `sweeps` are non-empty is durably /// applied: the swept loser's row (and any tombstoned pending-input /// claim standing in for a not-yet-materialized UTXO) actually leaves - /// the backing store, not merely accepted-and-ignored. `WalletChangeSetFFI` - /// has no size/version header, so an older callback compiled against a - /// pre-sweep struct layout reads the unchanged prefix, returns success, - /// and never sees the appended fields at all — this bit is what tells the - /// wallet the round-trip was actually implemented rather than silently - /// truncated. + /// the backing store, not merely accepted-and-ignored. On the FFI + /// surface sweeps travel through the persistence extension's + /// size-negotiated sweep callback — a slot Rust never reads unless the + /// host's declared `struct_size` proved it exists — so an older host + /// processes the rest of the round, returns success, and never sees + /// the sweeps at all; this bit is what tells the wallet the round-trip + /// was actually implemented rather than silently truncated. pub const CORE_SWEEP_REMOVAL: Self = Self(1 << 10); /// Capabilities required before exporting and funding an invitation voucher. diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 4a6ef56e667..7485941c012 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -198,6 +198,7 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { pub(crate) fn build_extension() -> PersistenceCallbacksExtension { PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(tramp_persist_wallet_changeset_sweeps), ..Default::default() } } @@ -638,18 +639,37 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( } } - // Sweeps last, and 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. Each call does its own - // hold-then-release, so the ordering holds on the Kotlin side too. - // The batch count is not bounded by this ABI, so — as with the - // account loop above — the whole per-batch construction and call - // runs inside its own local frame; without it, `byte_array_cls`, - // `empty`, and the three per-batch arrays would all pile up in the - // trampoline's own frame across every batch in the changeset, and a - // large enough one can exhaust ART's local-reference table before - // the callback ever returns. - for batch in slice_or_empty(cs.sweeps, cs.sweeps_count) { + 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). Native fires this +/// right after `tramp_persist_wallet_changeset` in the same round, so the +/// Kotlin bridge still sees records before 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. Each call does its own hold-then-release, so the ordering holds on +/// the Kotlin side too. The batch count is not bounded by this ABI, so — +/// as with the account loop in the changeset trampoline — the whole +/// per-batch construction and call runs inside its own local frame; +/// without it, `byte_array_cls`, `empty`, and the three per-batch arrays +/// would all 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(16, |env| { persist_changeset_sweep_batch(env, bridge, &wid, batch) })?; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 675ea92a491..ad11d539dfb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -65,10 +65,13 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Tracked asset-lock rows, including status and proof updates, can be /// persisted. Restart hydration is separately attested by `walletRestore`. public static let trackedAssetLocks: UInt64 = 1 << 9 - /// A stored core changeset's `sweeps` are durably applied: the swept - /// loser's row (and any tombstoned pending-input claim standing in for - /// a not-yet-materialized UTXO) actually leaves the store. Mirrors - /// `PersistenceCapabilities::CORE_SWEEP_REMOVAL`. + /// A round's sweep batches — delivered through the persistence + /// extension's size-negotiated sweep callback — are durably applied: + /// the swept loser's row (and any tombstoned pending-input claim + /// standing in for a not-yet-materialized UTXO) actually leaves the + /// store. Mirrors `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; Rust + /// only honours the declaration when the extension actually carries + /// the callback. public static let coreSweepRemoval: UInt64 = 1 << 10 public let version: UInt32 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c66d533136b..3b7f774d055 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -783,9 +783,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// /// 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. Only the subtractive part can report this today: 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. + /// 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, @@ -855,57 +856,100 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - // Swept transactions, applied last: a wallet-relevant winner - // rides in the same round, and running the additive part first - // means its claim on the shared inputs is already recorded when - // the removal below decides which links are left pointing at a - // dead transaction. - if cs.sweeps_count > 0, let sweepsPtr = cs.sweeps { - // One batch at a time, in order. A later sweep can keep a - // coin spent that an earlier one freed — each batch is only - // true of the wallet it saw — so folding them together lets - // the first answer outlive the last one that still holds. - for batchIndex in 0..() - if batch.released_outpoints_count > 0, - let releasedPtr = batch.released_outpoints { - for i in 0..?, + 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 } + + // One batch at a time, in order. A later sweep can keep a + // coin spent that an earlier one freed — each batch is only + // true of the wallet it saw — so folding them together lets + // the first answer outlive the last one that still holds. + for batchIndex in 0..() + if batch.released_outpoints_count > 0, + let releasedPtr = batch.released_outpoints { + for i in 0.. 0, let txidsPtr = batch.txids else { continue } - for i in 0.. 0, let txidsPtr = batch.txids else { continue } + for i in 0..?, + 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 `on_changeset_begin_fn`. Forwards to /// `PlatformWalletPersistenceHandler.beginChangeset` so the handler /// can prep any wallet-scope batching it needs for the round. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index c3e89f3d7f3..f040dd91d46 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -3,13 +3,15 @@ import SwiftData import DashSDKFFI @testable import SwiftDashSDK -/// Coverage for the one subtractive part of the changeset path: -/// `WalletChangeSetFFI.swept`. +/// 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. Every other field on that struct is additive, so a -/// mirror that ignores this one keeps the dead row, hands it back at the +/// 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. /// @@ -236,13 +238,17 @@ final class SweptTransactionPersistTests: XCTestCase { 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) - var cs = WalletChangeSetFFI() - cs.sweeps = sweeps - cs.sweeps_count = UInt(ffiBatches.count) - let applied = withUnsafePointer(to: &cs) { csPtr in - handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) - } + let applied = handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: UnsafePointer(sweeps), + count: UInt(ffiBatches.count) + ) _ = handler.endChangeset(walletId: walletId, success: applied) return applied } From c4c8a1f4701d6fee0d52317fb6ff2d2ec700a3a5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:35:16 +0300 Subject: [PATCH 033/102] fix(platform-wallet-ffi): require an atomic round before attesting sweep removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving sweeps onto their own size-tagged extension slot fixed the out-of-bounds read, but it split one logical `CoreChangeSet` across two calls — and the capability gate still only asked for the two callbacks. A host with no begin/end boundary can therefore make the changeset call durable, watermark and additive rows included, and stop before the sweep call applies the removal. Rust never reaches its post-store fault handling, so nothing withholds the watermark; the host restarts past a deletion it never performed and reloads the dead transaction. Nothing before sweeps could fail this way — every core field arrived through one callback. Attest `CORE_SWEEP_REMOVAL` only when the begin/end pair is wired and the host declares `ATOMIC_CHANGESETS`, so a split round is bracketed or the existing gate withholds the watermark instead. All three in-tree backends already satisfy both. The test gains the two shapes that now must not attest: declared-atomic with no begin/end wired, and wired-but-not-declared. Confirmed to fail without the gate. --- .../rs-platform-wallet-ffi/src/persistence.rs | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 082c9610008..4e7b947ad0a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1135,8 +1135,23 @@ impl FFIPersister { // still additionally gated by `declared_capabilities` in // `persistence_capabilities()` below, like every other bit: the // host must attest the semantic contract, not just wire pointers. + // + // The begin/end pair and `ATOMIC_CHANGESETS` are required on top, + // and only for this bit, because moving sweeps onto their own slot + // split one logical `CoreChangeSet` across two calls. Without a + // round that commits or rolls back as a unit, the changeset call + // can make the watermark and the additive rows durable and the + // process can stop before the sweep call applies the removal — + // leaving a host that restarts past a deletion it never performed + // and reloads the dead transaction. Nothing before sweeps could + // fail this way: every core field arrived through one callback. if self.wallet_changeset_sweeps_callback.is_some() && self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self + .declared_capabilities + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) { capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); } @@ -6327,26 +6342,29 @@ mod tests { fn wired_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), ..Default::default() } } + /// Everything the bit needs except the atomic round. + fn declared() -> PersistenceCapabilities { + PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(PersistenceCapabilities::ATOMIC_CHANGESETS) + } // The pre-sweep-aware binary shape: legacy changeset callback // wired, declaration present (a host blindly OR-ing bits), but no // extension slot — absent. - assert!(!persister_with( - wired_callbacks(), - PersistenceCapabilities::CORE_SWEEP_REMOVAL, - None - ) - .persistence_capabilities() - .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + assert!(!persister_with(wired_callbacks(), declared(), None) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); // Extension slot wired and declared, but no changeset callback to // persist the rows a sweep would correct: absent. assert!(!persister_with( PersistenceCallbacks::default(), - PersistenceCapabilities::CORE_SWEEP_REMOVAL, + declared(), Some(noop_wallet_changeset_sweeps) ) .persistence_capabilities() @@ -6361,14 +6379,39 @@ mod tests { .persistence_capabilities() .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); - // All three: attested. - assert!(persister_with( + // Structurally complete and declared, but without the atomic round + // the split transport needs: absent. Sweeps arrive on their own + // call, so a host with no begin/end boundary can make the changeset + // durable and stop before the removal lands. + assert!(!persister_with( wired_callbacks(), PersistenceCapabilities::CORE_SWEEP_REMOVAL, Some(noop_wallet_changeset_sweeps) ) .persistence_capabilities() .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Declared atomic, but the begin/end pair is not actually wired: + // absent. The declaration alone cannot bracket the two calls. + assert!(!persister_with( + PersistenceCallbacks { + on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + ..Default::default() + }, + declared(), + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Everything present: attested. + assert!(persister_with( + wired_callbacks(), + declared(), + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); } /// The delivery contract of the extension transport itself: a From ab2da2b8ba853dd3ad3be04a72397b10c64df7ea Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:35:16 +0300 Subject: [PATCH 034/102] fix(platform-wallet): default `sweeps` when deserializing a pre-sweep changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CoreChangeSet` has an opt-in serde representation, and the new field is not optional, so a payload written before it existed fails to deserialize outright with the feature enabled. An empty vec is the exact backward-compatible reading — a changeset from then could not have carried a sweep. Covered by a pre-sweep JSON payload, confirmed to fail without the default. --- .../src/changeset/changeset.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index d23a6aad018..cada1a9d33f 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -213,6 +213,11 @@ pub struct CoreChangeSet { /// Union the release sets and the first answer outlives the last one that /// is actually true. Applied in order, each batch corrects the one before /// it, which is what the wallet itself did. + /// `serde(default)`: this field postdates the serialized representation, + /// so a payload written before it necessarily omits it. An empty vec is + /// the exact backward-compatible reading — a changeset from then could + /// not have carried a sweep. + #[cfg_attr(feature = "serde", serde(default))] pub sweeps: Vec, } @@ -1866,6 +1871,36 @@ impl Merge for PlatformWalletChangeSet { } } +#[cfg(all(test, feature = "serde"))] +mod serde_compat_tests { + use super::*; + + /// A changeset serialized before `sweeps` existed must still load. The + /// field postdates the representation, so an older payload simply omits + /// it — and an empty vec is the exact reading, since nothing back then + /// could have carried a sweep. Without `serde(default)` the whole + /// deserialization fails and every pre-sweep payload becomes unreadable. + #[test] + fn a_pre_sweep_payload_deserializes_with_no_sweeps() { + let json = r#"{ + "records": [], + "spent_utxos": [], + "new_utxos": [], + "instant_locks_for_non_final_records": {}, + "last_processed_height": 1000, + "synced_height": 900, + "account_highest_used": {}, + "last_applied_chain_lock": null + }"#; + + let cs: CoreChangeSet = + serde_json::from_str(json).expect("a pre-sweep payload must still deserialize"); + assert!(cs.sweeps.is_empty()); + assert_eq!(cs.last_processed_height, Some(1000)); + assert_eq!(cs.synced_height, Some(900)); + } +} + #[cfg(test)] mod tests { use super::*; From ecfefdc3f1aa9c43202cfbaebb86c6e01758e602 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:29:16 +0300 Subject: [PATCH 035/102] fix(swift-sdk): disable autosave so the atomic round is actually atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler's context is the transaction buffer for the begin → changeset → sweeps → end sequence, but autosave was on, so SwiftData could commit its pending mutations between those callbacks. Since sweeps moved to their own callback the round spans two calls: an autosave landing in between makes the watermark and the additive rows durable while the removal is still unstaged, and `rollback()` cannot take back a save that already happened. That was survivable before this branch. It is not now — Rust attests CORE_SWEEP_REMOVAL partly on this handler declaring ATOMIC_CHANGESETS, so the guarantee has to be real rather than nominal. Audited every write path before flipping it: the eight mutating callbacks that never save for themselves (identities, identity keys, token balances, asset locks, invitations, DPNS name states, contacts, sync state) each have exactly one invocation site, all inside `store()`'s begin/end bracket, so `endChangeset`'s single `save()` commits them. Every path that can fire outside a round already saves itself under `!inChangeset`. The full Swift suite passes with autosave off. --- .../PlatformWalletPersistenceHandler.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 3b7f774d055..3053db66582 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -152,7 +152,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.modelContainer = modelContainer self.network = network 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 } /// Synchronously run `body` on `serialQueue`. From 8e3c0aa841f8e0562421fd88e38a99f3337373b0 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:29:17 +0300 Subject: [PATCH 036/102] fix(platform-wallet-ffi): expose the sweep arrays as *const, not *mut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SweepBatchFFI` declared `txids` and `released_outpoints` as writable while the builder took them from `Vec::as_ptr()` on an immutable borrow and cast the constness away. `Vec::as_ptr`'s contract does not permit writes through that pointer or anything derived from it, so a callback taking the writable C ABI at its word would break Rust's aliasing rules. Nothing writes through them — both are read-only payloads. Declare them `*const` and build them with `as_ptr()`/`null()` so the ABI says what is actually true. --- .../src/core_wallet_types.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index d1997d23f87..833bc5b147f 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -270,7 +270,7 @@ pub struct WalletChangeSetFFI { pub struct SweepBatchFFI { /// Removed transactions, raw 32-byte txids. Delete these rows and every /// UTXO they created. - pub txids: *mut [u8; 32], + pub txids: *const [u8; 32], pub txids_count: usize, /// The transaction whose arrival settled the inputs. Final, and not /// necessarily wallet-relevant — it can pay entirely to outside @@ -281,7 +281,7 @@ pub struct SweepBatchFFI { /// free. Everything else they claimed was taken by `superseded_by` and /// stays spent — a persister holds every input of what it deletes, so /// this is the only thing telling it which to hand back. - pub released_outpoints: *mut OutPointFFI, + pub released_outpoints: *const OutPointFFI, pub released_outpoints_count: usize, } @@ -566,17 +566,22 @@ pub(crate) fn build_sweep_batches_for_callback( let mut superseded_by = [0u8; 32]; superseded_by.copy_from_slice(batch.superseded_by.as_ref()); SweepBatchFFI { + // `*const`, built straight from `as_ptr()`: the storage is + // borrowed immutably here, and `Vec::as_ptr` does not permit + // writes through the pointer or anything derived from it. + // Casting to `*mut` would advertise a C ABI that a callback + // could take literally, breaking Rust's aliasing rules. txids: if backing.txids.is_empty() { - std::ptr::null_mut() + std::ptr::null() } else { - backing.txids.as_ptr() as *mut [u8; 32] + backing.txids.as_ptr() }, txids_count: backing.txids.len(), superseded_by, released_outpoints: if backing.released.is_empty() { - std::ptr::null_mut() + std::ptr::null() } else { - backing.released.as_ptr() as *mut OutPointFFI + backing.released.as_ptr() }, released_outpoints_count: backing.released.len(), } From fab1a462a03d60077d55597884ec50b57009d42b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:46:54 +0300 Subject: [PATCH 037/102] fix(platform-wallet-ffi): allow the manager entry point its argument count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threading the sweeps extension callback through pushed `platform_wallet_manager_create_impl` to eight arguments, one past clippy's default, and CI runs clippy with `-D warnings` — so the workspace job failed to compile while `cargo check` stayed green locally. Allowed rather than restructured, matching how every other multi-callback entry point in this crate handles it: the arguments are the C signature's own shape, and grouping them behind a struct is not something the FFI can express. --- packages/rs-platform-wallet-ffi/src/manager.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 90949847575..353e2b42ac2 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -253,6 +253,10 @@ unsafe fn event_extension_dpns_callback( std::ptr::addr_of!((*extension).on_dpns_marketplace_sync_completed_fn).read() } +// The C entry point's own shape: every callback table and out-param the +// hosts pass, threaded straight through. Splitting it would only move the +// same arguments behind a struct the FFI cannot express. +#[allow(clippy::too_many_arguments)] unsafe fn platform_wallet_manager_create_impl( sdk_ptr: *const c_void, persistence: *const PersistenceCallbacks, From b3aeb1cadd8ff6294058094359e3b5bad2d8664b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:43:09 +0300 Subject: [PATCH 038/102] fix(swift-sdk): follow the sweep arrays' const-ness into the test helper Making `SweepBatchFFI.txids` and `released_outpoints` `*const` left the test builder assigning `UnsafeMutablePointer` into them. My local run predated the regenerated header, so it compiled here and failed in CI. The two `persistInvitations` calls that dropped their `Bool` also tripped the warnings-as-errors job. Asserted rather than discarded: both are setup steps whose failure should surface where it happens, matching the call below that already binds the result. --- .../InvitationPersistenceTests.swift | 12 ++++++++++-- .../SweptTransactionPersistTests.swift | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index eb9d4653fc1..4615ac6e553 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -95,7 +95,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 +114,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 index f040dd91d46..436b9fb0404 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -219,9 +219,9 @@ final class SweptTransactionPersistTests: XCTestCase { releasedBuffers.append(freed) var entry = SweepBatchFFI() - entry.txids = txids + entry.txids = UnsafePointer(txids) entry.txids_count = UInt(batch.losers.count) - entry.released_outpoints = freed + 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) } From ada626a475c3992a281e75ad5aca5c1e6a703a96 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:54:08 +0300 Subject: [PATCH 039/102] fix(kotlin-sdk): keep the sweep's SQL arity fixed regardless of release size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three tombstone queries bound `releasedOutpoints` into `IN`/`NOT IN`, which Room expands one variable per outpoint. That set's size follows the input count of a transaction a remote sender chooses — expose the wallet to an unconfirmed transaction with a large input vector and an output it owns, then have a final conflicting transaction take one input, and the rest are reported released. Past API 29's 999-variable ceiling the statement throws, the atomic round fails, Rust freezes the wallet's watermark, and the loser that is still persisted re-swept into the same failure after every restart. Fetch by the two fixed keys instead — the staging relationship, and the scalar winner txid for tombstones an earlier sweep detached — partition against an in-memory set, and apply the result through per-row `@Update` / `@Delete`. Arity no longer grows with the sweep at all. The regression uses 40000 outpoints, not a number just past 999: this suite runs on the host's SQLite, whose ceiling is far higher, and at 1200 the pre-fix code passed here while still being broken on API 29. Confirmed to fail without the fix at that size. --- .../PlatformWalletPersistenceHandler.kt | 37 +++++++-- .../dashsdk/persistence/dao/DocumentDao.kt | 78 ++++++++----------- .../PlatformWalletPersistenceHandlerTest.kt | 50 ++++++++++++ 3 files changed, 116 insertions(+), 49 deletions(-) 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 a9137465977..483b983efd7 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 @@ -1213,17 +1213,44 @@ class PlatformWalletPersistenceHandler( db.transactionDao().markGloballySwept(txids[i]) db.txoDao().holdSpentWithoutSpender(txids[i], walletId) - db.documentDao().tombstoneUnreleasedPendingInputs( - txids[i], supersededBy[i], released, walletId, - ) + + // The released set is partitioned in memory rather than + // bound into SQL: its size follows the input count of a + // transaction a remote sender picks, and one bind variable + // per outpoint can cross API 29's 999-variable ceiling — + // which would throw, fail the atomic round, and freeze the + // watermark on a loser that re-swept into the same failure + // on every restart. + val releasedKeys = released.mapTo(HashSet()) { it.toHex() } + val staged = db.documentDao().pendingInputsStagedBy(txids[i], walletId) + val heldStaged = staged.filterNot { releasedKeys.contains(it.outpoint.toHex()) } + if (heldStaged.isNotEmpty()) { + db.documentDao().updatePendingInputs( + heldStaged.map { + it.copy( + spendingTransactionTxid = null, + spendingTxid = supersededBy[i], + isSweptTombstone = true, + ) + }, + ) + } // A pending input an EARLIER sweep already tombstoned to // txids[i] (that txid was itself a sweep's winner, and is // now being swept in turn) detached from the relationship // `tombstoneUnreleasedPendingInputs` above matches on, so it // has to be found and carried forward separately — see // [DocumentDao.deleteReleasedSweptTombstones]. - db.documentDao().deleteReleasedSweptTombstones(txids[i], released, walletId) - db.documentDao().retargetSweptTombstones(txids[i], supersededBy[i], released, walletId) + val prior = db.documentDao().sweptTombstonesTargeting(txids[i], walletId) + val (freed, stillHeld) = prior.partition { releasedKeys.contains(it.outpoint.toHex()) } + if (freed.isNotEmpty()) { + db.documentDao().deletePendingInputs(freed) + } + if (stillHeld.isNotEmpty()) { + db.documentDao().updatePendingInputs( + stillHeld.map { it.copy(spendingTxid = supersededBy[i]) }, + ) + } } for (outpoint in releasedOutpoints) { db.txoDao().releaseByOutpoint(outpoint, walletId) 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 5b8ad286048..4ba419c7b2f 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 @@ -3,6 +3,7 @@ package org.dashfoundation.dashsdk.persistence.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Query +import androidx.room.Update import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import org.dashfoundation.dashsdk.persistence.entities.DocumentEntity @@ -193,62 +194,51 @@ interface DocumentDao { * tombstoning a row a different wallet owns using a release decision * that was never made about it. */ - @Query( - "UPDATE pending_inputs SET spendingTransactionTxid = NULL, " + - "spendingTxid = :supersededBy, isSweptTombstone = 1 " + - "WHERE spendingTransactionTxid = :txid AND walletId = :walletId " + - "AND outpoint NOT IN (:releasedOutpoints)", - ) - suspend fun tombstoneUnreleasedPendingInputs( - txid: ByteArray, - supersededBy: ByteArray, - releasedOutpoints: List, - walletId: ByteArray, - ) - /** - * Chained-sweep continuation of [tombstoneUnreleasedPendingInputs]: a - * pending row that an earlier sweep already tombstoned to [txid] - * detached itself from the `spendingTransactionTxid` relationship at - * that point, so a sweep of [txid] itself cannot find it there — only - * the scalar `spendingTxid` this row was repointed to still names it. - * Delete the ones this round frees. Nothing else owns them once - * detached — unlike a live pending row, there is no cascade-delete of - * [txid]'s `transactions` row left to do that job for them. + * This wallet's live pending inputs staged by [txid], for the sweep to + * partition in memory. * - * A tombstone names one specific wallet's coin — the `walletId` it was - * written with — so [walletId] here has to be the same wallet whose - * [releasedOutpoints] produced it; otherwise this would apply one - * wallet's release decision to a claim it was never entitled to make. + * The released set is not bound into SQL. It is bounded only by the + * input count of a transaction a remote sender can choose, so binding + * it one variable per outpoint can cross the 999-variable limit that + * API 29's framework SQLite still carries — the statement then throws, + * the whole atomic round fails, and the wallet's watermark freezes on a + * loser that will be re-swept into the same failure after every restart. + * Fetching by the two fixed keys and comparing outpoints against a + * `Set` keeps the arity constant no matter how large the sweep is. */ @Query( - "DELETE FROM pending_inputs WHERE spendingTxid = :txid AND isSweptTombstone = 1 " + - "AND walletId = :walletId AND outpoint IN (:releasedOutpoints)", - ) - suspend fun deleteReleasedSweptTombstones( - txid: ByteArray, - releasedOutpoints: List, - walletId: ByteArray, + "SELECT * FROM pending_inputs " + + "WHERE spendingTransactionTxid = :txid AND walletId = :walletId", ) + suspend fun pendingInputsStagedBy(txid: ByteArray, walletId: ByteArray): List /** - * The held half of [deleteReleasedSweptTombstones]: repoint every - * surviving tombstone of [txid] owned by [walletId] at the new - * [supersededBy] instead, so a third sweep down the chain can still - * find it by scalar `spendingTxid`. [isSweptTombstone] is already set - * from the first tombstoning and stays set. + * This wallet's tombstones already repointed at [txid] by an earlier + * sweep, found by the scalar `spendingTxid` — the only link left once + * the first tombstoning detached them from the relationship. Same + * fixed-arity discipline as [pendingInputsStagedBy]. + * + * A tombstone names one specific wallet's coin, so [walletId] must be + * the same wallet whose release decision is about to be applied; + * otherwise this would hand one wallet's claim to another's verdict. */ @Query( - "UPDATE pending_inputs SET spendingTxid = :supersededBy " + - "WHERE spendingTxid = :txid AND isSweptTombstone = 1 AND walletId = :walletId " + - "AND outpoint NOT IN (:releasedOutpoints)", + "SELECT * FROM pending_inputs " + + "WHERE spendingTxid = :txid AND isSweptTombstone = 1 AND walletId = :walletId", ) - suspend fun retargetSweptTombstones( + suspend fun sweptTombstonesTargeting( txid: ByteArray, - supersededBy: ByteArray, - releasedOutpoints: List, walletId: ByteArray, - ) + ): List + + /** Per-row update; Room binds one row at a time, so arity is fixed. */ + @Update + suspend fun updatePendingInputs(rows: List) + + /** Per-row delete, same fixed-arity reason as [updatePendingInputs]. */ + @Delete + suspend fun deletePendingInputs(rows: List) /** * Whether some wallet other than [walletId] still has a live pending 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 b14efcd82f0..e7c5a656c76 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 @@ -2768,6 +2768,56 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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) + handler.onWalletChangesetTransaction( + 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 = handler.onWalletChangesetTransactionsSwept( + walletId, + arrayOf(loser), + arrayOf(ByteArray(32) { 82 }), + released.toTypedArray(), + ) + 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 From 5e6d4d48ace99f96c82000669cea9e139e6a10b0 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:24 +0300 Subject: [PATCH 040/102] perf(swift-sdk): index the open round's unsaved inserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `FetchDescriptor` with the default `includePendingChanges` evaluates its predicate in memory against every unsaved insert of the target entity, and the `#Index`/`.unique` declarations on the models only accelerate the SQL half. Because a changeset round defers its `save()` to `endChangeset`, a large wallet's initial scan accumulates thousands of unsaved inserts in one round and every later fetch paid O(inserts so far) — quadratic over the round, and measured as ~99% of CPU on the persistence queue minutes after the SPV scan itself had finished. Keep a per-round index over those inserts, keyed the way the hot-path fetches filter, and let the lookup helpers consult it first with `includePendingChanges = false` so SQLite answers from its own indexes. --- .../PlatformWalletPersistenceHandler.swift | 309 ++++++++++++++---- 1 file changed, 243 insertions(+), 66 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 3053db66582..536fe8f49ae 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -119,6 +119,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 @@ -1358,6 +1424,115 @@ 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. + // + // `applySweptTransaction` stays on plain pending-changes fetches: its + // row fetch needs relationship prefetching, and its tombstone fetch + // keys on columns that MUTATE mid-round (`spendingTxid`, + // `isSweptTombstone`), which neither the index nor a store-only + // fetch can answer. Sweeps only target unconfirmed conflicts, so + // that path stays off the initial-scan hot loop. It 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 @@ -1379,9 +1554,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 @@ -1403,7 +1575,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) - let existing = try? backgroundContext.fetch(descriptor).first + 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 @@ -1453,6 +1625,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + roundIndex?.transactionsByTxid[txidData] = record } record.context = tx.context @@ -1569,10 +1742,7 @@ 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 { + if let txo = fetchTxoRow(outpoint: outpoint) { // `isSpent` only flips once the spending tx is in a block // (see `spendIsInBlock`'s doc) — a mempool sighting // alone links the spending relationship but keeps the @@ -1610,11 +1780,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, @@ -1623,6 +1796,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(pending) } } } @@ -1633,13 +1807,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) } } @@ -1652,11 +1823,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 @@ -1675,11 +1843,8 @@ 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 @@ -1711,6 +1876,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 = { @@ -1729,6 +1895,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record } record.amount = utxo.amount @@ -1761,11 +1928,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 } } @@ -1779,12 +1942,8 @@ 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 { + let pendingRows = pendingInputRows(outpoint: record.outpoint) + if !pendingRows.isEmpty { // Pick the freshest pending entry — under normal sync // there's only one, but a chain reorg or double-spend // observation could leave multiple. Newest wins so the @@ -1801,12 +1960,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let resolvedSpending: PersistentTransaction? if let spending = chosen.spendingTransaction { resolvedSpending = spending + // 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 } else { - let spendingTxid = chosen.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first + resolvedSpending = fetchTransactionRow(txid: chosen.spendingTxid) } // Carry the vin index forward so the spending tx's @@ -1848,10 +2009,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 @@ -1869,10 +2027,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) if let spending = spendingTx { txo.spendingTransaction = spending } @@ -1902,10 +2057,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() } @@ -2032,14 +2184,23 @@ 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 { _ = walletId self.inChangeset = true + // 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() } } @@ -2066,8 +2227,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() } @@ -3657,12 +3822,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( @@ -3676,6 +3837,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 @@ -3698,12 +3860,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 } } } From 1fdc3fa1b0312b8c3ef80930efc616fc8f1b3ada Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:41 +0300 Subject: [PATCH 041/102] fix(kotlin-sdk): build the sweep's release set once, and free released rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the sweep loop. `releasedKeys` was rebuilt per loser, inside the open Room transaction. A sweep can carry many losers — the mempool alone tracks up to a thousand conflicts — so a payload that is linear in size cost L×R hex encodings and hashes on the single persistence executor. Hoisted out of the loop; it does not vary with the loser. Released staged rows were left attached for the eventual cascade, but while attached they read as their wallet's claim in the ownership check. A shared loser holding one released input per wallet therefore 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, so this is storage rather than balance, but the row and both pending entries would be kept forever. Delete them outright instead. --- .../PlatformWalletPersistenceHandler.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 483b983efd7..0a2992f7ce7 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 @@ -1202,6 +1202,13 @@ class PlatformWalletPersistenceHandler( // same round already re-claimed keeps that claim instead of being // freed out from under it. val released = releasedOutpoints.toList() + // Hoisted: the set is invariant across the losers, and a sweep + // can carry many of them — the mempool alone tracks up to a + // thousand conflicts. Rebuilding it per loser would hex-encode + // and hash the whole set L times inside the open Room + // transaction, turning a linear payload into L×R work on the + // single persistence executor. + val releasedKeys = released.mapTo(HashSet()) { it.toHex() } for (i in txids.indices) { // Global first, unconditionally, in every callback that // reaches this loop — not gated on walletId and not waiting @@ -1221,9 +1228,21 @@ class PlatformWalletPersistenceHandler( // which would throw, fail the atomic round, and freeze the // watermark on a loser that re-swept into the same failure // on every restart. - val releasedKeys = released.mapTo(HashSet()) { it.toHex() } val staged = db.documentDao().pendingInputsStagedBy(txids[i], walletId) - val heldStaged = staged.filterNot { releasedKeys.contains(it.outpoint.toHex()) } + val (freedStaged, heldStaged) = + staged.partition { releasedKeys.contains(it.outpoint.toHex()) } + // Released staged rows go now rather than riding the + // eventual cascade. Left attached they count as this + // wallet's claim in `hasOtherWalletClaim` below, so two + // wallets each holding one released input for a shared + // loser deadlock: each sees the other's row and declines the + // delete, and replaying either callback reaches the same + // stalemate. The global marker keeps the dead transaction + // from contributing funds either way, but the row and both + // pending entries would otherwise be stored forever. + if (freedStaged.isNotEmpty()) { + db.documentDao().deletePendingInputs(freedStaged) + } if (heldStaged.isNotEmpty()) { db.documentDao().updatePendingInputs( heldStaged.map { From 363a3305dbd0edaaf7e2feb050eff6176ac73991 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:51 +0300 Subject: [PATCH 042/102] fix(swift-sdk): free a released pending input instead of leaving it attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A released pending input was left on the loser for the eventual cascade, but while attached it reads as its wallet's claim in the ownership check below. A shared loser holding one released input per wallet therefore deadlocks: each callback sees the other's row and declines the delete, and replaying either reaches the same stalemate. The dead transaction contributes no funds regardless — the global marker sees to that — so this is storage rather than balance, but the row and both pending entries would be kept forever. Delete them outright, and say so where the ownership check explains what still counts as a claim. The regression gives two wallets one unresolved released input each on the same shared loser; confirmed to fail against the previous behaviour. --- .../PlatformWalletPersistenceHandler.swift | 29 ++++++--- .../SweptTransactionPersistTests.swift | 59 +++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 536fe8f49ae..daa68699418 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1185,19 +1185,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txo.lastUpdated = Date() } for pending in row.pendingInputs where pending.walletId == walletId { - guard !released.contains(pending.outpoint) else { 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 + } pending.spendingTransaction = nil pending.spendingTxid = supersededBy pending.isSweptTombstone = true } - // Whatever is still attached to `row` after the scoping above is - // either this wallet's own released pending input — deliberately - // left in place two paragraphs up so the cascade below removes it - // — or an input/pending row a different wallet has not yet - // weighed in on. Only the second case has to hold the delete - // back; the first would otherwise make a wallet wait on its own - // already-finished decision. Whichever callback finds nothing + // 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 @@ -1212,7 +1221,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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. + // 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 436b9fb0404..63e59e8885d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -881,6 +881,65 @@ 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 { + 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, released: [(txid: fundingTxid, vout: 8)]) + ]) + sweep( + handler, + [Batch(losers: [sweptTxid], winner: winnerTxid, 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. From ab54e0cf79d4a10d962785631c8ce70738d6efd7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:31:52 +0300 Subject: [PATCH 043/102] fix(kotlin-sdk): keep the sweep tombstone when the winner's own pending row coexists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 sat in the restore set until the winner reached a block. Newest-wins arbitrates between competing observations; a tombstone is not an observation but the sweep's settled verdict, so it now outranks every ordinary row regardless of age. The regression drives the coexisting pair through the real callbacks and fails against the previous pick. --- .../PlatformWalletPersistenceHandler.kt | 17 +++- .../PlatformWalletPersistenceHandlerTest.kt | 89 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) 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 0a2992f7ce7..41b6bab1b1f 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 @@ -1019,7 +1019,22 @@ class PlatformWalletPersistenceHandler( // Rust as spendable. val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) if (pending.isNotEmpty()) { - val chosen = pending.maxByOrNull { it.createdAt }!! + // A tombstone outranks every ordinary row regardless of age. + // Newest-wins arbitrates between competing *observations* + // (reorg / double-spend sightings), but 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 for this + // outpoint moments before the sweep repoints the loser's row + // — which keeps its original, older `createdAt`. Letting the + // younger ordinary row win there would take the gated branch + // below (`isSpent` false until the winner confirms), never + // stamp `supersededByTxid`, and then delete every row + // including the tombstone — the durable hold evaporates and + // the consumed coin re-enters the restore set. + val chosen = pending.filter { it.isSweptTombstone }.maxByOrNull { it.createdAt } + ?: pending.maxByOrNull { it.createdAt }!! val spending = db.transactionDao().getByTxid(chosen.spendingTxid) if (chosen.isSweptTombstone) { // `onWalletChangesetTransactionsSwept` repointed this row 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 e7c5a656c76..0f8fdeff9eb 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 @@ -2996,6 +2996,95 @@ class PlatformWalletPersistenceHandlerTest { 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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, + ) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + ) + 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 chainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() = runTest { // Regression for the review finding on From 8d3b37c262764e6c6811e9646cd9b4577e3a3ebf Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:53:24 +0300 Subject: [PATCH 044/102] fix(swift-sdk): keep the sweep tombstone when the winner's own pending row coexists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 sat in the restore set until the winner reached a block. Newest-wins arbitrates between competing observations; a tombstone is not an observation but the sweep's settled verdict, so it now outranks every ordinary row regardless of age. Mirrors the Kotlin fix; the regression fails against the previous pick. --- .../PlatformWalletPersistenceHandler.swift | 19 ++++- .../SweptTransactionPersistTests.swift | 80 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index daa68699418..96fa7906061 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1960,7 +1960,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // observation could leave multiple. Newest wins so the // visible spendingTransaction matches the most recent // observation; the rest are dropped. - let chosen = pendingRows.max(by: { $0.createdAt < $1.createdAt }) ?? pendingRows[0] + // + // A tombstone outranks every ordinary row regardless of age. + // Newest-wins arbitrates between competing *observations*, but 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 for this + // outpoint moments before the sweep repoints the loser's row — + // which keeps its original, older `createdAt`. Letting the + // younger ordinary row win there would take the gated branch + // below (`isSpent` false until the winner confirms), never stamp + // `supersededByTxid`, and then delete every row including the + // tombstone — the durable hold evaporates and the consumed coin + // re-enters the restore set. + let chosen = pendingRows.filter(\.isSweptTombstone) + .max(by: { $0.createdAt < $1.createdAt }) + ?? pendingRows.max(by: { $0.createdAt < $1.createdAt }) + ?? pendingRows[0] // Resolve the spending tx (prefer the relationship; fall // back to a txid lookup if the row wasn't faulted in). diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 63e59e8885d..462f03a9dba 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -1019,6 +1019,86 @@ final class SweptTransactionPersistTests: XCTestCase { 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)]) + + // 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 From 5f8ea5faeb9cf84fb7c585c40bcda314e0a510b3 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:58:11 +0300 Subject: [PATCH 045/102] fix(kotlin-sdk): clear a released coin's supersededByTxid with the hold it carried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing ever cleared txos.supersededByTxid: the drain writes it when a tombstoned claim materializes onto its funding row, and releaseByOutpoint — the one path that declares such a coin free again — left it behind. The stale marker is not cosmetic. The redelivery carry-over in onWalletChangesetUtxoAdded reads a present marker as a durable claim, so after any later hold cycle on the same outpoint the coin would be stuck spent permanently — no redelivery could ever lift it again. SQLite's release UPDATE clears spent_in_txid in the same statement it clears spent; do the same here. The existing chained-sweep test only covers the funding-after-release ordering, where no stamp ever existed; the new regression funds the coin between the two sweeps so the claim drains onto the row first, and fails against the previous UPDATE. --- .../dashsdk/persistence/dao/TxoDao.kt | 12 ++- .../PlatformWalletPersistenceHandlerTest.kt | 79 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) 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 7fad0a3f0c5..5ee6bfb605a 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 @@ -93,9 +93,19 @@ interface TxoDao { * is only ever true of the wallet that computed it, so it should never * be able to touch another wallet's row even if an outpoint were ever * to collide. + * + * `supersededByTxid` clears in the same statement, the way the SQLite + * store's release UPDATE clears `spent_in_txid`. A chained sweep can + * free a coin whose claim already drained into that column (the funding + * TXO arrived between the sweep that held it and the one now freeing + * it), and this call is the only writer that ever clears it — a + * released coin keeping its dead winner's marker would turn the next + * hold on this outpoint permanent, because the redelivery carry-over in + * `onWalletChangesetUtxoAdded` reads a present marker as a durable + * claim and refuses to lift `isSpent` ever again. */ @Query( - "UPDATE txos SET isSpent = 0, spendingInputIndex = NULL " + + "UPDATE txos SET isSpent = 0, spendingInputIndex = NULL, supersededByTxid = NULL " + "WHERE outpoint = :outpoint AND spendingTxid IS NULL AND walletId = :walletId", ) suspend fun releaseByOutpoint(outpoint: ByteArray, walletId: ByteArray) 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 0f8fdeff9eb..4fd67250121 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 @@ -3186,6 +3186,85 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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. `releaseByOutpoint` is the only writer + // that ever clears that column — a released coin keeping its dead + // winner's marker would turn the next hold on this outpoint + // permanent, because the redelivery carry-over in + // `onWalletChangesetUtxoAdded` reads a present marker as a durable + // claim and refuses to lift `isSpent` ever again. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(intermediateWinner), emptyArray(), + ) + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(intermediateWinner), arrayOf(finalWinner), arrayOf(pOutpoint), + ) + 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 From 4a14991f5b0bba8aa6286b17e3531a2960e2031a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:03:01 +0300 Subject: [PATCH 046/102] fix(swift-sdk): apply sweep releases by outpoint, clearing the dead winner's marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases only reached coins still attached to a fetched loser row — row.inputs plus tombstones found by scalar spendingTxid. A claim that had already drained into PersistentTxo.supersededByTxid (funding TXO arrived between the sweep that held it and the one freeing it, pending row consumed) whose winner was never recorded here — or whose shared row another wallet's callback deleted while unlinked — was unreachable: the release was silently dropped and the coin stayed spent forever. And even a reachable release never cleared supersededByTxid, so a released coin kept its dead winner's marker, turning the next hold on the same outpoint permanent (upsertUtxo's recovery clear reads a present marker as a durable claim). Add a per-batch outpoint-keyed release pass after the loser loop — the form Kotlin's releaseByOutpoint and SQLite's outpoint-matched UPDATE both already implement — freeing detached holds only (a coin some surviving transaction re-claimed keeps that claim) and clearing the marker with the hold it carried. Plain pending-changes fetch, since the pass runs after relationship-driven mutations the round index cannot observe. Two regressions, one per shape: the drained-and-linked claim keeps isSpent correct but held its stale marker, and the never-recorded-winner claim stayed spent outright; both fail without this pass. --- .../PlatformWalletPersistenceHandler.swift | 88 ++++++++++-- .../SweptTransactionPersistTests.swift | 135 ++++++++++++++++++ 2 files changed, 208 insertions(+), 15 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 96fa7906061..be3f18f2025 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1008,28 +1008,86 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let supersededBy = Swift.withUnsafeBytes(of: batch.superseded_by) { Data($0) } - guard batch.txids_count > 0, let txidsPtr = batch.txids else { continue } - for i in 0.. 0, let txidsPtr = batch.txids { + for i in 0..( + predicate: #Predicate { $0.outpoint == outpoint } + ) + releasedDescriptor.fetchLimit = 1 + let row: PersistentTxo? do { - try applySweptTransaction( - walletId: walletId, - txid: txid, - supersededBy: supersededBy, - released: released - ) + row = try backgroundContext.fetch(releasedDescriptor).first } catch { - // Fail the round rather than report a deletion - // that did not happen: Rust would clear the sweep - // and the dead row would be replayed at the next - // load. + // Same contract as the loser loop: a release + // silently skipped would report a removal durable + // that never fully happened. print( - "⚠️ persistWalletChangesetSweeps: sweep of " - + "\(txid.prefix(8).toHexString())… failed: " + "⚠️ persistWalletChangesetSweeps: release lookup of " + + "\(outpoint.prefix(8).toHexString())… failed: " + "\(error.localizedDescription); failing the round" ) return false } + guard let txo = row, + Self.resolvedWalletId(of: txo) == walletId, + txo.spendingTransaction == nil else { continue } + txo.isSpent = false + txo.supersededByTxid = nil + txo.spendingInputIndex = nil + txo.lastUpdated = Date() } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 462f03a9dba..d7f26102217 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -1277,6 +1277,141 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertEqual(coin.supersededByTxid, finalWinner) } + /// 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)]) + + // 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, 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)]) + + // 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, + 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, From f4f6882aa7d5eb43cd0e4c57d7a4f5121ef4e26b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:08:05 +0300 Subject: [PATCH 047/102] fix(kotlin-sdk): stamp the winner on a sweep hold so re-delivery cannot resurrect the coin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit holdSpentWithoutSpender parked a winner-taken materialized input as isSpent with no spender and no supersededByTxid, and the recovery clear in onWalletChangesetUtxoAdded frees exactly that state. A restore-rescan re-finds the funding output — it is blind to an unconfirmed winner no block carries yet — so every restore-from-seed flipped a provably consumed coin back into the restore set until the winner confirmed, handing Rust a coin it would try to double-spend. SQLite stamps spent_in_txid = winner on the identical stream and its upsert valve keeps the coin spent, so the two attesting backends gave opposite answers to the same events. Align on SQLite's semantics: the hold names its winner, the same attribution the pending-input drain already writes, and only an explicit release frees it — releaseByOutpoint clears the stamp with the hold. Rows written before holds were stamped still free on re-delivery, pinned by a new backstop test; the absent-winner test now asserts the stamped hold survives re-delivery and fails against the unstamped hold. --- .../PlatformWalletPersistenceHandler.kt | 24 +++--- .../dashsdk/persistence/dao/TxoDao.kt | 23 +++++- .../dashsdk/persistence/entities/TxoEntity.kt | 28 +++---- .../PlatformWalletPersistenceHandlerTest.kt | 75 ++++++++++++++++++- 4 files changed, 120 insertions(+), 30 deletions(-) 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 41b6bab1b1f..ec2575750fb 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 @@ -986,16 +986,18 @@ class PlatformWalletPersistenceHandler( isLocked = isLocked, // The wallet is handing this outpoint over as a UTXO, so it // holds it unspent — authoritative, and the only thing that - // lifts a mark with no spender behind it. The sweep path - // parks the inputs it cannot resolve in exactly that state - // (`holdSpentWithoutSpender`); a rescan re-delivering the - // coin lands here and frees it. A row whose spend is still - // on record keeps its flag — the pending drain below owns - // that transition. `supersededByTxid` is a different kind of - // "no spender" — a sweep's winner is known but its row never - // materialized here — and must not be lifted the same way, - // or a tombstone the drain below just wrote would be undone - // by the very next sync round that re-delivers this outpoint. + // lifts a mark with neither a spender nor a winner behind + // it (a pre-stamp row from before `holdSpentWithoutSpender` + // named its winner; every hold written today is stamped). A + // row whose spend is still on record keeps its flag — the + // pending drain below owns that transition — and so does 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 + // (`releaseByOutpoint`) frees a stamped coin. isSpent = existing?.isSpent == true && (existing.spendingTxid != null || existing.supersededByTxid != null), walletId = walletId, @@ -1234,7 +1236,7 @@ class PlatformWalletPersistenceHandler( db.txoDao().deleteOwnOutputs(txids[i]) db.transactionDao().markGloballySwept(txids[i]) - db.txoDao().holdSpentWithoutSpender(txids[i], walletId) + db.txoDao().holdSpentWithoutSpender(txids[i], walletId, supersededBy[i]) // The released set is partitioned in memory rather than // bound into SQL: its size follows the input count of a 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 5ee6bfb605a..cb33aa46bc3 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 @@ -45,7 +45,8 @@ interface TxoDao { /** * Hold every coin of [walletId]'s own that [spendingTxid] claimed out of - * the restore set, without naming a spender for them. + * the restore set, attributed to [supersededBy] rather than to a linked + * spender. * * Used when [spendingTxid] was swept: it can never confirm, so its claim * is not a spend, but most of the coins it named really were taken — by @@ -53,6 +54,17 @@ interface TxoDao { * unconfirmed, so its inputs sit at `isSpent = 0`, and deleting it would * otherwise return all of them, the consumed one included. * + * `supersededByTxid` is what makes the hold durable — 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 winner need + * never be recorded here (it can pay only outside addresses), so the + * stamp cannot be a `spendingTxid` FK link; but leaving it off entirely + * would let the next re-delivery of the funding output — exactly what a + * restore-rescan does, blind to an unconfirmed winner it cannot see in + * any block — flip a provably-consumed coin back into the restore set. + * A stamped hold only ever comes free through an explicit release + * ([releaseByOutpoint], which clears the stamp with the hold). + * * [spendingTxid] can be shared: the same `transactions` row spends coins * from more than one wallet at once, and upstream computes a separate * released set per wallet (`per_wallet_released_outpoints`). This @@ -67,10 +79,15 @@ interface TxoDao { * clear the genuinely free ones with [releaseByOutpoint]. */ @Query( - "UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL " + + "UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL, " + + "supersededByTxid = :supersededBy " + "WHERE spendingTxid = :spendingTxid AND walletId = :walletId", ) - suspend fun holdSpentWithoutSpender(spendingTxid: ByteArray, walletId: ByteArray) + suspend fun holdSpentWithoutSpender( + spendingTxid: ByteArray, + walletId: ByteArray, + supersededBy: ByteArray, + ) /** * Mark one outpoint of [walletId]'s own unspent again — a coin a sweep 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 6b51af1f55b..89baf89fd13 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 @@ -104,24 +104,26 @@ data class TxoEntity( */ val coreAddressId: String? = null, /** - * Port of Swift `PersistentTxo.supersededByTxid`. Set only by + * 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: `holdSpentWithoutSpender`, when + * a sweep holds an already-materialized input, and * `onWalletChangesetUtxoAdded` resolving a `pending_inputs` row with - * `isSweptTombstone` — i.e. this TXO's funding output arrived after the - * loser that spent it was already swept and deleted, so there was never - * a live `spendingTxid` to carry forward. Deliberately NOT an FK: the + * `isSweptTombstone` — 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. * - * `null` in every other case, including the plain "sweep held this coin - * with no spender on record" state that `holdSpentWithoutSpender` writes - * directly onto an already-materialized row (`spendingTxid = NULL`, no - * tombstone involved). That distinction is what the `isSpent` carry-over - * above and `onWalletChangesetUtxoAdded`'s recovery clear key on: a coin - * the wallet re-delivers as unspent only lifts `isSpent` when both - * `spendingTxid` and this are null, so a tombstoned coin isn't waved - * back into the restore set just because the winner's own row was never - * linked. + * The stamp is what makes a hold durable. The `isSpent` carry-over + * above and `onWalletChangesetUtxoAdded`'s recovery clear both key on + * it: a coin the wallet re-delivers as unspent only lifts `isSpent` + * when both `spendingTxid` and this are null — 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 `releaseByOutpoint`, 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. */ val supersededByTxid: ByteArray? = null, ) { 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 4fd67250121..9e341951229 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 @@ -12,6 +12,8 @@ import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity 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.TransactionEntity +import org.dashfoundation.dashsdk.persistence.entities.TxoEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity import org.junit.After import org.junit.Assert.assertEquals @@ -2201,13 +2203,23 @@ class PlatformWalletPersistenceHandlerTest { 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( + "but with the winner stamped, the same attribution SQLite " + + "records as spent_in_txid", + irrelevantWinner.contentEquals(held.supersededByTxid), + ) assertTrue( "and it stays out of the restore set", handler.onLoadWalletList().single().utxos.isEmpty(), ) - // The hold is not a dead end either: the wallet re-delivering the - // coin as a UTXO, which a rescan does, still lifts it. + // 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. handler.onChangesetBegin(walletId) handler.onWalletChangesetUtxoAdded( walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, @@ -2215,7 +2227,64 @@ class PlatformWalletPersistenceHandlerTest { ) handler.onChangesetEnd(walletId, success = true) - assertFalse(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + val redelivered = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the stamped hold survives re-delivery", redelivered.isSpent) + assertTrue(irrelevantWinner.contentEquals(redelivered.supersededByTxid)) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aPreStampHoldStillFreesOnRedelivery() = runTest { + // 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. + 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, + ), + ) + + 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) } From ac36707ec3585c2479028181c02c2c9a5860f336 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:15:10 +0300 Subject: [PATCH 048/102] fix(swift-sdk): stamp the winner on a sweep hold so re-delivery cannot resurrect the coin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySweptTransaction parked a winner-taken materialized input as isSpent with no spender and no supersededByTxid, and upsertUtxo's recovery clear frees exactly that state. A restore-rescan re-finds the funding output — it is blind to an unconfirmed winner no block carries yet — so every restore-from-seed flipped a provably consumed coin back into the restore set until the winner confirmed, handing Rust a coin it would try to double-spend. SQLite stamps spent_in_txid = winner on the identical stream and its upsert valve keeps the coin spent, so two attesting backends gave opposite answers to the same events. Align on SQLite's semantics, mirroring the Kotlin fix: the hold names its winner — the same attribution the pending-input drain already writes — and only an explicit release frees it, the outpoint release pass clearing the stamp with the hold. Rows written before holds were stamped still free on re-delivery, pinned by a new backstop test; the re-delivery test now asserts the stamped hold survives and fails against the unstamped hold. --- .../Persistence/Models/PersistentTxo.swift | 30 +++++---- .../PlatformWalletPersistenceHandler.swift | 40 ++++++++---- .../SweptTransactionPersistTests.swift | 63 ++++++++++++++++--- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 4432316b848..0dae02f814b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -87,18 +87,24 @@ public final class PersistentTxo { public var spendingTransaction: PersistentTransaction? /// 32-byte txid of the transaction a sweep's winner is known to have - /// beaten this coin to, set only by `upsertUtxo` resolving a - /// `PersistentPendingInput` tombstone (`isSweptTombstone`) — i.e. this - /// TXO's funding output arrived after its loser was already swept and - /// deleted, so there was never a `spendingTransaction` row to link. - /// `nil` in every other case, including the ordinary "sweep held this - /// coin with no spender on record" state that `applySweptTransaction` - /// writes directly onto an already-materialized row (`spendingTransaction - /// = nil`, no tombstone involved). That distinction is what - /// `upsertUtxo`'s recovery clear keys on: a coin the wallet re-delivers - /// as unspent lifts `isSpent` only when both `spendingTransaction` and - /// this are nil, so a tombstoned coin isn't waved back into the restore - /// set just because nobody ever linked the winner's own row. + /// 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` diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index be3f18f2025..35acd67825f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1145,8 +1145,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// 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 named, - /// which keeps them out of the restore set. + /// 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 @@ -1238,7 +1239,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 { - txo.isSpent = !released.contains(txo.outpoint) + 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() } @@ -1977,15 +1992,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 - // left with no spender on record. `applySweptTransaction` parks the - // inputs of a sweep it cannot resolve in exactly that state; a - // rescan re-delivering the coin lands here and frees it. A row whose - // spend is still on record is left alone: the pending-input resolve - // below owns that transition. `supersededByTxid` is a different - // kind of "no spender" — a sweep's winner is known but its row - // never materialized here — and must not be lifted the same way, - // or a tombstone the pending-resolve below just wrote would be - // undone by the very next sync round that re-delivers this outpoint. + // 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 } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index d7f26102217..9ac2e537ee5 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -328,6 +328,11 @@ final class SweptTransactionPersistTests: XCTestCase { "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) @@ -337,11 +342,14 @@ final class SweptTransactionPersistTests: XCTestCase { ) } - /// A coin held spent with no spender is not a dead end: the wallet is - /// the authority on what it holds, so re-delivering the coin as a UTXO — - /// what a rescan does — lifts the mark. This is the backstop for a sweep - /// that released nothing, and for any older row left in that state. - func testWalletReDeliveringAHeldCoinFreesIt() throws { + /// 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)]) @@ -349,10 +357,47 @@ final class SweptTransactionPersistTests: XCTestCase { redeliverCoinB(handler) - let freed = txo(container, txid: fundingTxid, vout: 1) - XCTAssertNotNil(freed) - XCTAssertFalse(freed!.isSpent, "the wallet holds it as a UTXO, so the hold is lifted") - XCTAssertNil(freed!.spendingTransaction) + 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 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 From cda18de24b47f17952ff4ceb3564f2443339f402 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:18:07 +0300 Subject: [PATCH 049/102] fix(kotlin-sdk): keep a stamped hold spent when the winner's own spend emit arrives unconfirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record path is monotonic, but onWalletChangesetUtxoSpent resolved the in-block gate and wrote its answer outright. The sweep winner's own record reaches this store IS-locked — context below in-block — with the utxos_spent emit riding along, so the emit flipped a durable supersededByTxid hold back to unspent and the provably consumed coin re-entered the restore set until the winner confirmed, contradicting the verdict the sweep already recorded. A present stamp now pins isSpent through that emit, the same way the drain and the record reconciliation already treat it. The regression delivers the winner's record and spent emit after the stamped hold materialized, and fails against the ungated write. --- .../PlatformWalletPersistenceHandler.kt | 11 ++- .../PlatformWalletPersistenceHandlerTest.kt | 79 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) 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 ec2575750fb..ddb7bb4948d 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 @@ -1088,12 +1088,21 @@ class PlatformWalletPersistenceHandler( 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. + // A `supersededByTxid` hold is likewise off limits: this emit + // can carry the sweep winner's own IS-locked spend of a coin + // the sweep already proved consumed, and the in-block gate's + // answer would flip the durable hold back into the restore set + // until the winner reaches a block. val spending = db.transactionDao().getByTxid(spendingTxid) val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK db.txoDao().upsert( txo.copy( spendingTxid = if (spending != null) spendingTxid else txo.spendingTxid, - isSpent = if (spending != null) spentInBlock else txo.isSpent, + isSpent = if (spending != null) { + spentInBlock || txo.supersededByTxid != null + } else { + txo.isSpent + }, lastUpdated = now(), ), ) 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 9e341951229..b81b3f0d8fc 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 @@ -2233,6 +2233,85 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) } + @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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + ) + 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) + handler.onWalletChangesetTransaction( + 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 aPreStampHoldStillFreesOnRedelivery() = runTest { // The backstop for rows written before holds named their winner: a From d35021f70ac306e898a063c978e64c72223e1e33 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:20:20 +0300 Subject: [PATCH 050/102] fix(swift-sdk): stop the winner's own unconfirmed arrival downgrading a stamped hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three writers resolved the in-block gate and wrote its answer outright: resolveInputOutpoint's found-TXO branch, the pending drain's ordinary branch, and markUtxoSpent. The sweep winner's own record reaches this store IS-locked — context below in-block — so each of them flipped a durable supersededByTxid hold back to unspent and the provably consumed coin re-entered the restore set until the winner confirmed, contradicting the handler's own 'winner is already final' reasoning. The (outpoint, spendingTxid) duplicate guard lives only in the not-found branch, so the found branch had no protection at all. resolveInputOutpoint and the drain become monotonic, matching the form the Kotlin record path already ships (isSpent || in-block): a flag already true is backed by an in-block spend or a stamped hold, and flips to false stay with the paths that own them — the sweep release pass and upsertUtxo's recovery clear. markUtxoSpent keeps its flap-avoidance shape and gains the stamp pin. The regression delivers the winner's record and spent emit after the stamped hold materialized, and fails against the ungated writes. --- .../PlatformWalletPersistenceHandler.swift | 30 ++++- .../SweptTransactionPersistTests.swift | 118 ++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 35acd67825f..e9282996547 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1834,8 +1834,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // load can hand the TXO back to Rust for the post-restart // catch-up classifier to recognise as ours. The next // upsert of this same tx with a confirmed context flips - // `isSpent` then. - let expectedIsSpent = Self.spendIsInBlock(spendingTransaction) + // `isSpent` then. Monotonic, matching the Kotlin port: a + // flag already true is backed by something durable — an + // in-block spend, or a sweep hold stamped with its winner + // — and the arriving record must not downgrade it. The + // stamped case is the sharp one: the winner's own record + // arrives IS-locked (context below in-block) for a coin + // the sweep already proved consumed, and writing the + // gate's answer would flip the durable hold back into the + // restore set until the winner reaches a block. Flips to + // false stay with the paths that own them: the sweep + // release pass and `upsertUtxo`'s recovery clear. + let expectedIsSpent = txo.isSpent || Self.spendIsInBlock(spendingTransaction) let linkageChanged = txo.isSpent != expectedIsSpent || txo.spendingTransaction?.txid != spendingTxid @@ -2097,7 +2107,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.isSpent = true record.supersededByTxid = chosen.spendingTxid } else if let spending = resolvedSpending { - record.isSpent = Self.spendIsInBlock(spending) + // Monotonic like `resolveInputOutpoint` above (and the + // Kotlin drain): `record.isSpent` still true after the + // recovery clear is backed by a live spend or a stamped + // hold, and an unconfirmed pending spender must not + // downgrade it. + record.isSpent = record.isSpent || Self.spendIsInBlock(spending) } record.lastUpdated = Date() for row in pendingRows { @@ -2141,9 +2156,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // alone instead of writing `false`: the next upsert round // carrying the spending tx will run `resolveInputOutpoint` // and set it then. Writing `false` here would flap a - // previously-true `isSpent` on every reordered emit. + // previously-true `isSpent` on every reordered emit. A + // stamped hold is likewise off limits: this emit can carry + // the sweep winner's own IS-locked spend of a coin the sweep + // already proved consumed, and the gate's answer would flip + // the durable hold back into the restore set until the + // winner reaches a block. if let spending = spendingTx { - txo.isSpent = Self.spendIsInBlock(spending) + txo.isSpent = Self.spendIsInBlock(spending) || txo.supersededByTxid != nil } txo.lastUpdated = Date() // The spend signal landed both via the legacy diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 9ac2e537ee5..f92306a330f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -363,6 +363,124 @@ final class SweptTransactionPersistTests: XCTestCase { 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)]) + 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" + ) + } + + /// One changeset round carrying a transaction record and 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) + ) { + 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 + 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) + } + /// 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 24f7ef46fc4cd5ba7915e05eeea105de31a014d8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:34:18 +0300 Subject: [PATCH 051/102] fix(platform-wallet-storage): apply a sweep's releases even when the swept txid has no row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_sweep returns before its input loop when the swept txid has no core_transactions row, and that loop was the only place released outpoints were applied. A chained-sweep claim is a core_utxos placeholder that exists independently of any transaction row, and the loser now freeing it need not have one — a fatal flush error wipes a buffered round (the winner's record with it) while the faulted wallet keeps persisting later rounds. The winner's later sweep then dropped its release set on the early return, and the upsert valve held the placeholder's spent_in_txid forever: the release is the one channel that clears it. This contradicted the persister doc's chain-safety promise; the existing unknown-txid no-op test used an empty release set, so the path was unpinned. Releases are outpoint-keyed facts, so the batch now applies them by outpoint after its loser loop — the same idempotent UPDATE the loop performs for inputs it can decode, reaching the ones it cannot. The regression sweeps a never-recorded winner with a release set and fails against the early return. --- .../src/sqlite/persister.rs | 8 +- .../src/sqlite/schema/core_state.rs | 30 +++++- .../tests/sqlite_transaction_sweeps.rs | 101 ++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 9ffd3959c9f..3c9bf2f2735 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -834,9 +834,11 @@ impl PlatformWalletPersistence for SqlitePersister { // case, where it leaves a `core_utxos` placeholder keyed by outpoint // rather than by any relationship to the loser. That is what makes a // later sweep of the winner that replaces it chain-safe with no - // extra bookkeeping: the next `apply_sweep` call matches the same - // outpoint directly, so it repoints or releases the placeholder - // regardless of how many sweeps deep it is. + // extra bookkeeping: the next sweep matches the same outpoint + // directly — through the loser's decoded inputs when its row is on + // hand, and through the batch's own released set when it is not — + // so it repoints or releases the placeholder regardless of how many + // sweeps deep it is. PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 495b81a2351..f3903a2c80a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -180,6 +180,31 @@ pub fn apply( for loser_txid in &batch.txids { apply_sweep(tx, wallet_id, loser_txid, &batch.superseded_by, &released)?; } + // Releases are outpoint-keyed facts, so they are applied by outpoint + // once the batch's losers are done — not only through each loser's + // decoded inputs above. A chained-sweep claim is a `core_utxos` + // placeholder that exists independently of any transaction row, and + // the loser now freeing it need not have one: a fatal flush error + // wipes a buffered round (the winner's record with it) while the + // faulted wallet keeps persisting later rounds, and `apply_sweep` + // above returns before its input loop when the swept txid has no + // row. Dropping the release set there would leave the `:340` valve + // holding the placeholder's `spent_in_txid` forever — the release + // is the one channel that clears it. Running after the loser loop + // rather than inside it changes nothing for inputs the loop already + // freed (same UPDATE, idempotent), and a coin a surviving record in + // this round re-claimed was already filtered out of `released` + // above. + if !released.is_empty() { + let mut release_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \ + WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + for outpoint in &released { + let key = blob::encode_outpoint(outpoint)?; + release_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + } + } } Ok(()) } @@ -222,7 +247,10 @@ pub fn apply( /// /// Idempotent: a txid this store never recorded is a successful no-op, not an /// error. A sweep can legitimately name a transaction this wallet dropped, or -/// never derived an address for in the first place. +/// never derived an address for in the first place. Only the loser-scoped +/// work is skipped in that case — the batch's released outpoints are applied +/// by the caller, outside this function, precisely so a missing row cannot +/// swallow them. fn apply_sweep( tx: &Transaction<'_>, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index d194f6583c4..429457db810 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1587,3 +1587,104 @@ fn a_record_reinstating_a_swept_txid_in_a_later_round_is_accepted_and_durable() "and spendable — not left behind in some half-restored state" ); } + +/// A release must land even when the swept txid has no `core_transactions` +/// row of its own. A chained-sweep claim is a `core_utxos` placeholder that +/// exists independently of any transaction row, and the loser now freeing +/// it need not have one — a fatal flush error wipes a buffered round (the +/// winner's record with it) while the faulted wallet keeps persisting later +/// rounds. `apply_sweep` returns before its input loop for a missing row, +/// so if that loop were the only place releases were applied the set would +/// be silently dropped and the upsert valve would hold the placeholder's +/// `spent_in_txid` forever. +#[test] +fn a_release_applies_even_when_the_swept_txid_has_no_row() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x31); + let funding_txid = Txid::from_byte_array([0x30; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0x31; 32]); // L + let winner_txid = Txid::from_byte_array([0x32; 32]); // W — never recorded + let final_winner = Txid::from_byte_array([0x33; 32]); // X + + // Round 1: L, spending the still-unfunded P, is recorded and then swept + // by W with nothing released — leaving the held-but-absent placeholder. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(loser_txid, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &p), + "sanity: the held claim left its placeholder" + ); + assert!(unspent(&conn, &w).is_empty()); + } + + // Round 2: W is swept in turn, releasing P — but W's own record never + // reached this store, so there is no row and no input loop to walk. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![winner_txid], + superseded_by: final_winner, + released_outpoints: vec![p], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&p), + "the release must reach the placeholder with no loser row to walk" + ); + } + + // The funding output finally arrives: the shed hold must let the + // upsert's valve accept the coin as unspent, with its real value. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&p), + "the funded coin stays spendable — the valve has no stale claim to defend" + ); +} From d120e4ad1b6c3b56bc4c297c1315ff6b47c66891 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:41:24 +0300 Subject: [PATCH 052/102] fix(platform-wallet-storage): don't resurrect a co-swept parent's output as a held placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One batch can sweep a parent and the child that spends its output — upstream's descendant closure always removes them together, and its release computation filters out outpoints whose txid is itself a loser, so the child's claim on the parent's output is never in released. With the parent ordered first its pass deletes the output row, and the child's pass then found no row for a held input and re-created it as a placeholder (spent = 1, spent_in_txid = winner). If the parent later returned chainlocked, the funding upsert's valve defended that stale claim and the genuinely unspent coin was excluded from restore forever; short of that, the row was permanent junk with no deletion path. apply_sweep now skips inputs funded by a txid the same changeset sweeps: a dead parent's output is nobody's coin, and the parent's own pass owns the row. The regression sweeps parent and child in one batch, parent first, then reinstates the parent, and fails against the placeholder both on the leftover row and on the blocked restore. --- .../src/sqlite/schema/core_state.rs | 24 ++++- .../tests/sqlite_transaction_sweeps.rs | 96 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index f3903a2c80a..8d89a7f4d05 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -178,7 +178,14 @@ pub fn apply( .copied() .collect(); for loser_txid in &batch.txids { - apply_sweep(tx, wallet_id, loser_txid, &batch.superseded_by, &released)?; + apply_sweep( + tx, + wallet_id, + loser_txid, + &batch.superseded_by, + &released, + &swept_txids, + )?; } // Releases are outpoint-keyed facts, so they are applied by outpoint // once the batch's losers are done — not only through each loser's @@ -257,6 +264,7 @@ fn apply_sweep( loser_txid: &dashcore::Txid, superseded_by: &dashcore::Txid, released: &HashSet, + swept_txids: &HashSet, ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx .query_row( @@ -317,6 +325,20 @@ fn apply_sweep( )?; for input in &loser.transaction.input { let outpoint = input.previous_output; + // An input funded by a transaction this same changeset also sweeps + // is a dead parent's output, not a coin the winner took: upstream's + // descendant closure always sweeps parent and child together, its + // release computation excludes exactly these outpoints (so `freed` + // below can never be true for one), and the parent's own pass + // deletes the row. When the parent sorts before the child, holding + // the claim here would re-create the just-deleted row as a + // placeholder whose `spent_in_txid` the funding upsert's valve then + // defends — against the chainlocked reinstatement that is the one + // event that could bring the coin back — excluding a genuinely + // unspent coin from restore forever. + if swept_txids.contains(&outpoint.txid) { + continue; + } let key = blob::encode_outpoint(&outpoint)?; let freed = released.contains(&outpoint); let spent_in_txid: Option<&[u8]> = if freed { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 429457db810..908e55db9b0 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1688,3 +1688,99 @@ fn a_release_applies_even_when_the_swept_txid_has_no_row() { "the funded coin stays spendable — the valve has no stale claim to defend" ); } + +/// One batch can sweep a parent and the child that spends its output — +/// upstream's descendant closure always removes them together, and its +/// release computation filters out outpoints whose txid is itself a loser. +/// With the parent ordered first, its pass deletes the output row; the +/// child's pass must not re-create it as a held placeholder. The +/// placeholder's `spent_in_txid` is exactly what the funding upsert's +/// valve defends, so a chainlocked reinstatement of the parent — the one +/// event that can bring the coin back — would find its genuinely unspent +/// output locked out of the restore set forever. +#[test] +fn a_batch_sweeping_parent_and_child_leaves_no_placeholder_for_the_parents_output() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE8); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x41); + let parent_txid = Txid::from_byte_array([0x40; 32]); // L + let child_txid = Txid::from_byte_array([0x41; 32]); // C + let winner_txid = Txid::from_byte_array([0x42; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // L pays us and is funded; C spends L's output. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![ + tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + ), + tx_record(child_txid, vec![parent_output], vec![]), + ], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The batch removes both, parent first — the ordering that deletes the + // output row before the child's pass walks its inputs. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &parent_output), + "a dead parent's output is nobody's coin — no placeholder may re-create it" + ); + } + + // The chainlocked return: L is reinstated with its output re-emitted. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's genuinely unspent output must restore — no stale \ + spent_in_txid claim may stand in its way" + ); +} From 55bf35e1edaded3854cd2a6a83fc1b7b09dd158d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:53:00 +0300 Subject: [PATCH 053/102] fix(platform-wallet): cascade a sweep into the tracked asset locks its losers funded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconstruct_asset_locks_for_event's catch-all dropped TransactionsSwept, and nothing else cascades transaction removal into the tracked-lock table — in memory or in any store. A swept asset-lock funding tx can never confirm, so its entry was a zombie resume_asset_lock would re-broadcast and then wait on without bound, and the persisted mirror carried it forever. Add the subtractive arm: drop every tracked entry whose funding txid the sweep names and carry the tombstones through the changeset's existing removed channel — the same deletion path a rejected-at-broadcast Built row uses, which every store already applies. Removal is not a one-way door: a chainlocked return re-emits the funding record through the additive arms, which re-insert the entry the same way a restore scan does. The end-to-end regression tracks a lock via reconstruction, sweeps its funding tx, and fails without the arm on both the store()-borne tombstone and the surviving in-memory entry. --- .../src/changeset/core_bridge.rs | 164 ++++++++++++++++++ .../wallet/asset_lock/sync/reconstruction.rs | 52 ++++++ 2 files changed, 216 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 65fd497d3eb..7751deb5ee1 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -652,6 +652,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() { @@ -2223,6 +2240,7 @@ mod tests { last_processed_height: Option, n_records: usize, n_asset_locks: usize, + n_asset_locks_removed: usize, rejected: bool, } @@ -2283,6 +2301,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 { @@ -3030,6 +3053,147 @@ 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: the removal must ride an ordinary round, not + // trip the fail-closed capability gate. + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + 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]), + 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 `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/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 7afdd62c26e..6c732ea7f6e 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,58 @@ 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. +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; + } + 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 From af5599476a9e8d2ab2942c0f59498457c0705454 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:02:44 +0300 Subject: [PATCH 054/102] fix(platform-wallet): fail a sent DashPay payment whose transaction was swept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swept sent payment sat Pending forever: the payment hooks only ever advanced entries forward, PaymentStatus::Failed — documented for exactly 'broadcast failed or the transaction was dropped' — had no writer anywhere in the repo, and the sweep deletes the record that was the last thing reconcile_sent_payments_from_tx_history could have resolved the entry from. User-visible stuck payment with no terminal transition. Route TransactionsSwept through the payment hooks by txid, the same way TransactionInstantLocked already is, and give the state machine its missing terminal: Pending fails when a sweep names its transaction. Confirmed is never demoted, and the one legitimate reversal — a chainlocked reinstatement re-emitting the record confirmed — advances Failed to Confirmed, so the confirm path now accepts that correction. The regression drives the real hook dispatch through sweep, re-sweep, reinstating confirmation, and stale sweep, and fails without the routing on the first assertion. --- .../src/wallet/identity/network/mod.rs | 2 +- .../identity/network/payment_handler.rs | 64 ++++-- .../src/wallet/identity/network/payments.rs | 203 ++++++++++++++++-- 3 files changed, 242 insertions(+), 27 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 752fee202ce..6d48c7936c2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -54,7 +54,7 @@ pub(crate) use payment_handler::run_dashpay_payment_hooks; mod payments; pub(crate) use payments::{ confirm_sent_dashpay_payment, confirm_sent_dashpay_payment_by_txid, - record_incoming_dashpay_payments, + fail_swept_sent_dashpay_payments, record_incoming_dashpay_payments, }; mod profile; pub(crate) mod sdk_writer; 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 1670bd56556..b1283078da0 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 @@ -252,11 +252,12 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { inserted, updated, .. } => inserted.iter().chain(updated.iter()).collect(), // `TransactionsSwept` carries txids, not records: the wallet has - // already dropped the records these name. A sent DashPay payment - // whose transaction was swept stays `Pending` here — the hooks - // below only ever advance a payment forward, and inventing a - // failure transition off this event is a change to the payment - // state machine, not to event routing. + // already dropped the records these name. Like + // `TransactionInstantLocked`, it is routed by txid instead — the + // sweep hook in `run_dashpay_payment_hooks` fails the matching + // `Pending` sent payments, since a swept transaction can never + // confirm and its record (the last thing reconciliation could + // have resolved the entry from) is gone. WalletEvent::TransactionInstantLocked { .. } | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } @@ -281,21 +282,25 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { WalletEvent::BlockProcessed { inserted, updated, .. } => !inserted.is_empty() || !updated.is_empty(), + // Routed by txid, like `TransactionInstantLocked` above: each + // swept txid can name a `Pending` sent payment that must fail + // (the transaction can never confirm). An empty sweep has no + // payment work. + WalletEvent::TransactionsSwept { txids, .. } => !txids.is_empty(), // No records to route (see `dashpay_payment_records`), so a task // here would take and release the wallet-manager write lock for // nothing. - WalletEvent::TransactionsSwept { .. } - | WalletEvent::SyncHeightAdvanced { .. } - | WalletEvent::ChainLockProcessed { .. } => false, + 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 +/// payment, then resolve a matching sent payment — `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), `Pending` to `Failed` when a sweep proves it never +/// can. All paths are idempotent per txid, so re-detections, re-emitted +/// sweeps, and repeated block-processing rounds converge without +/// duplicating entries. pub(crate) async fn run_dashpay_payment_hooks( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -315,6 +320,20 @@ pub(crate) async fn run_dashpay_payment_hooks( .await; return; } + // A sweep also carries txids rather than records, and drives the + // opposite terminal: each swept transaction can never confirm, so a + // matching `Pending` sent payment fails instead of sitting `Pending` + // forever. + if let WalletEvent::TransactionsSwept { txids, .. } = event { + crate::wallet::identity::network::fail_swept_sent_dashpay_payments( + wallet_manager, + wallet_id, + persister, + txids, + ) + .await; + return; + } for record in dashpay_payment_records(event) { crate::wallet::identity::network::record_incoming_dashpay_payments( wallet_manager, @@ -470,6 +489,27 @@ mod tests { assert!(drives_payment_hooks(&event)); } + /// `TransactionsSwept` carries no record but DOES drive the payment + /// hooks — each swept txid can name a `Pending` sent payment that must + /// fail, since a swept transaction can never confirm. An empty sweep + /// has no payment work and must not spawn. + #[test] + fn transactions_swept_drives_payment_hooks_without_a_record() { + let swept = |txids: Vec| WalletEvent::TransactionsSwept { + wallet_id: [0u8; 32], + txids, + superseded_by: dashcore::Txid::from([0x22; 32]), + released_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + let event = swept(vec![dashcore::Txid::from([0x21; 32])]); + // No record to route, but the event must still drive the hooks. + assert!(dashpay_payment_records(&event).is_empty()); + assert!(drives_payment_hooks(&event)); + assert!(!drives_payment_hooks(&swept(Vec::new()))); + } + /// 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 c084ea667a1..0aed332332b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -973,15 +973,80 @@ pub(crate) async fn confirm_sent_dashpay_payment_by_txid( /// `Confirmed`, in place, preserving amount/memo/counterparty. /// /// No-op when no entry exists for `txid`, it is not a `Sent` entry, or it -/// is already past `Pending` (so repeated confirmed re-detections are -/// idempotent and skip the persistence round). Separated from the event -/// glue above so the state transition is unit-testable without -/// constructing a full `TransactionRecord`. +/// is already `Confirmed` (so repeated confirmed re-detections are +/// idempotent and skip the persistence round). A `Failed` entry DOES +/// advance: the only writer of `Failed` is the sweep hook below, a swept +/// transaction's one road back is a chainlocked reinstatement, and that +/// reinstatement re-emits the record confirmed — hard evidence the +/// verdict reversed, which must be able to correct it. async fn confirm_sent_payment_by_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, persister: &crate::wallet::persister::WalletPersister, txid: &str, +) { + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + resolve_sent_payment_by_txid( + wallet_manager, + wallet_id, + persister, + txid, + PaymentStatus::Confirmed, + ) + .await; +} + +/// Mark the `Pending` `Sent` [`PaymentEntry`]s of swept transactions +/// `Failed`, for a +/// [`WalletEvent::TransactionsSwept`](key_wallet_manager::WalletEvent::TransactionsSwept). +/// +/// A swept transaction was provably beaten to one of its inputs, so it can +/// never confirm — exactly the "transaction was dropped" case +/// [`PaymentStatus::Failed`](crate::wallet::identity::types::dashpay::payment::PaymentStatus::Failed) +/// documents, and this PR's sweep also deletes the record that was the last +/// thing `reconcile_sent_payments_from_tx_history` could have resolved the +/// entry from. Without a terminal transition here the sender's payment sat +/// `Pending` forever. `Confirmed` entries are never demoted — the sweep of +/// an already-confirmed payment's txid would be stale by definition — and +/// the one way the verdict reverses (chainlocked reinstatement) re-emits +/// the record confirmed, which `confirm_sent_payment_by_txid` accepts from +/// `Failed`. +pub(crate) async fn fail_swept_sent_dashpay_payments( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + txids: &[dashcore::Txid], +) { + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + for txid in txids { + resolve_sent_payment_by_txid( + wallet_manager, + wallet_id, + persister, + &txid.to_string(), + PaymentStatus::Failed, + ) + .await; + } +} + +/// Shared flip for the two resolutions above: move the `Sent` +/// [`PaymentEntry`] under `txid` to `to`, in place, preserving +/// amount/memo/counterparty. +/// +/// `Confirmed` is terminal — nothing demotes it. `Pending` advances to +/// either verdict, and `Failed` advances only to `Confirmed` (the +/// reinstatement correction); every other combination is a no-op, which is +/// what keeps re-detections and re-emitted sweeps idempotent and skipping +/// the persistence round. Separated from the event glue so the state +/// machine is unit-testable without constructing a full +/// `TransactionRecord`. +async fn resolve_sent_payment_by_txid( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + txid: &str, + to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, ) { use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; @@ -991,27 +1056,32 @@ async fn confirm_sent_payment_by_txid( }; // The sent transaction belongs to one managed identity; find the - // `Pending` `Sent` entry under this txid and confirm it in place. + // eligible `Sent` entry under this txid and resolve it in place. for owner in info.identity_manager.identity_ids() { let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { continue; }; - let confirmed = match managed.dashpay().payments.get(txid) { - Some(entry) - if entry.direction == PaymentDirection::Sent - && entry.status == PaymentStatus::Pending => - { + let resolved = match managed.dashpay().payments.get(txid) { + Some(entry) if entry.direction == PaymentDirection::Sent => { + let eligible = match (entry.status, to) { + (PaymentStatus::Pending, _) => true, + (PaymentStatus::Failed, PaymentStatus::Confirmed) => true, + _ => false, + }; + if !eligible { + continue; + } let mut updated = entry.clone(); - updated.status = PaymentStatus::Confirmed; + updated.status = to; updated } _ => continue, }; - tracing::info!(owner = %owner, %txid, "Confirming sent DashPay payment"); - if let Err(e) = managed.record_dashpay_payment(txid.to_string(), confirmed, persister) { + tracing::info!(owner = %owner, %txid, status = ?to, "Resolving sent DashPay payment"); + if let Err(e) = managed.record_dashpay_payment(txid.to_string(), resolved, persister) { tracing::warn!( error = %e, - "Failed to persist sent-payment confirmation; will retry on next detection" + "Failed to persist sent-payment resolution; will retry on next detection" ); } // txid is unique — only one identity can hold this entry. @@ -2883,6 +2953,111 @@ mod tests { ); } + /// A sweep naming a `Pending` sent payment's transaction must fail the + /// entry: the transaction was provably beaten to one of its inputs and + /// can never confirm, and the same sweep deletes the record that was + /// the last thing reconciliation could have resolved the entry from — + /// so without this transition the sender's payment sat `Pending` + /// forever with no terminal state. Also pins the two guard rails: a + /// `Confirmed` entry is never demoted by a stale sweep, and the one + /// legitimate reversal — a chainlocked reinstatement re-emitting the + /// record confirmed — advances `Failed` to `Confirmed`. + #[tokio::test] + async fn swept_sent_payment_fails_and_a_reinstating_confirmation_recovers_it() { + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xAB; 32]); + let txid_key = txid.to_string(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, Some("dinner".into())), + &p, + ) + .expect("record pending sent"); + } + + async fn status( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: &WalletId, + owner: &Identifier, + txid: &str, + ) -> PaymentStatus { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("info"); + info.identity_manager + .managed_identity(owner) + .unwrap() + .dashpay() + .payments + .get(txid) + .expect("entry") + .status + } + + let swept_event = || WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xCD; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + // The sweep fails the pending entry, through the real hook dispatch + // (that a sweep spawns the hooks at all is pinned in + // `payment_handler`'s routing tests). + super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) + .await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Failed, + "a swept transaction can never confirm — its sent payment must fail" + ); + + // Re-emitted sweep: idempotent no-op. + super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) + .await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Failed + ); + + // The chainlocked reinstatement re-emits the record confirmed; the + // hard evidence must be able to correct the Failed verdict. + super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid_key).await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Confirmed, + "a reinstated, confirmed transaction must recover the payment" + ); + + // And a stale sweep arriving after confirmation never demotes it. + super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) + .await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Confirmed, + "Confirmed is terminal — a stale sweep must not demote it" + ); + } + /// A sent payment confirmed by a block must flip `Pending → Confirmed`. /// /// The wallet sees its *own* broadcast in the mempool first From 7b5514f98ad2d95b88c96cb69247b0a08b0dff57 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:07:59 +0300 Subject: [PATCH 055/102] fix(kotlin-sdk): make a hand-declared sweep capability fail structurally without an override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep trampoline is wired unconditionally, so 'slot present' proves nothing on Android — the contract lives entirely in the capability bit, and the bridge's inherited slot body returned success while deleting nothing. A third-party subclass hand-declaring CORE_SWEEP_REMOVAL without overriding the slot therefore got sweeps silently swallowed with the watermark advancing past removals that never happened — the one permanent corruption the capability exists to prevent, encoded only in a doc comment. The inherited default now consults the subclass's own declaration: declared-but-not-overridden refuses the round (non-zero return, so onChangesetEnd rolls it back and the watermark cannot move), while a non-attesting subclass keeps the benign ignore — Rust strips the watermark before its store(), and failing its round would throw away the additive slots for no protection gained. The bit constant moves to the bridge so the guard and the declaration cannot drift; the handler's constant aliases it. The regression pins both halves and fails against the unconditional success body. --- .../dashsdk/ffi/NativePersistenceBridge.kt | 40 ++++++++++++++----- .../PlatformWalletPersistenceHandler.kt | 3 +- .../PlatformWalletPersistenceHandlerTest.kt | 31 ++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) 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 ca8b645610c..fb123f6608f 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,19 @@ abstract class NativePersistenceBridge { open fun persistenceCapabilitiesBits(): Long = 0L + companion object { + /** + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL` (bit 10, `0x400`). + * Declared here — on the class whose + * [onWalletChangesetTransactionsSwept] default consults it — so the + * fail-closed guard and the declaration a subclass makes through + * [persistenceCapabilitiesBits] can never drift apart. + * `PlatformWalletPersistenceHandler`'s capability constants alias + * this value. + */ + const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x400 + } + // ── Transactional bracketing ────────────────────────────────────── /** `on_changeset_begin_fn` — descriptor `([B)I`. */ @@ -319,21 +332,30 @@ abstract class NativePersistenceBridge { * Native delivers these through the persistence extension's * size-negotiated sweep callback (not the wallet-changeset struct, whose * bare-pointer ABI cannot version itself), immediately after the - * changeset's own slots in the same round. The default body below still - * returns success without deleting anything — the - * accepted-but-never-applied failure mode - * `PersistenceCapabilities::CORE_SWEEP_REMOVAL` exists to catch. A - * subclass overriding this must also add that bit to - * [PlatformWalletPersistenceHandler.persistenceCapabilitiesBits]'s - * result; the Rust side will not trust a bare `Int` return of `0` here - * as proof the removal happened. + * changeset's own slots in the same round — and unconditionally: the + * trampoline is wired for every subclass, so "slot present" proves + * nothing about whether removals are actually applied. What Rust trusts + * is [persistenceCapabilitiesBits] carrying + * [CAPABILITY_CORE_SWEEP_REMOVAL]; a subclass overriding this must add + * that bit, and the default body below is what encodes the other half + * of that contract structurally. A subclass that declares the bit + * WITHOUT overriding has promised removals it silently swallows — and + * because the declaration also stops Rust stripping the watermark, the + * sync height would advance past a removal that never happened, the + * one permanent corruption the capability exists to prevent. The + * default therefore refuses the round in exactly that case (non-zero + * return, so `onChangesetEnd` rolls it back and the watermark cannot + * move). A subclass that declares nothing keeps the benign ignore: + * Rust already strips the watermark before its `store()`, so returning + * success costs nothing and preserves the round's additive slots. */ open fun onWalletChangesetTransactionsSwept( walletId: ByteArray, txids: Array, supersededBy: Array, releasedOutpoints: Array, - ): Int = 0 + ): Int = + if (persistenceCapabilitiesBits() and CAPABILITY_CORE_SWEEP_REMOVAL != 0L) 1 else 0 // ── Identities ──────────────────────────────────────────────────── 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 ddb7bb4948d..1a64c2ccade 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 @@ -3536,7 +3536,8 @@ 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 = 0x400 + internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL private const val TAG = "DashPersistence" 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 b81b3f0d8fc..7f6128b3ebf 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 @@ -84,6 +84,37 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) } + @Test + fun sweepSlotDefaultRefusesARoundOnlyWhenTheCapabilityIsHandDeclared() { + // The trampoline is wired for every subclass, so "slot present" + // proves nothing — the contract lives in the capability bit. A + // subclass declaring CORE_SWEEP_REMOVAL without overriding the slot + // has promised removals it would silently swallow while the + // watermark advances; the inherited default must refuse the round + // instead. One that declares nothing keeps the benign ignore: Rust + // strips the watermark before its store(), and failing the round + // would throw away its additive slots for no protection gained. + val declaringButNotOverriding = object : NativePersistenceBridge() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + } + val walletId = ByteArray(32) { 1 } + assertTrue( + "a hand-declared capability with the inherited no-op body must fail the round", + declaringButNotOverriding.onWalletChangesetTransactionsSwept( + walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), + ) != 0, + ) + + val nonAttesting = object : NativePersistenceBridge() {} + assertEquals( + 0, + nonAttesting.onWalletChangesetTransactionsSwept( + walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), + ), + ) + } + // ── Standalone (non-bracketed) writes ───────────────────────────── @Test From 679fb316cb99fb7d4a0dc925a41cc45f5ec1da07 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:00:02 +0300 Subject: [PATCH 056/102] fix(platform-wallet-storage): scope the co-swept skip to parents whose row is on hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip added in d120e4ad1b assumed a co-swept parent's own pass deletes its output rows, but a parent with no core_transactions row — the same record-loss threat model the by-outpoint release pass exists for — early-returns before the output DELETE loop. The child's skip then suppressed the spend_stmt that previously marked the surviving output spent, and with upstream filtering loser-funded outpoints out of released, P:0 stayed spent = 0: a phantom spendable coin load() hands back. Gate the skip on the parent's row actually being present to delete, checked once before any loser's pass runs so the answer is identical in both batch orders. A present parent keeps the placeholder-resurrection fix; an absent one falls back to the child's claim exactly as before d120e4ad1b. The regression seeds the funded output and the child's record with the parent's record lost, sweeps both in one batch, and fails against the unguarded skip. --- .../src/sqlite/schema/core_state.rs | 63 ++++++++++++++---- .../tests/sqlite_transaction_sweeps.rs | 66 +++++++++++++++++++ 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 8d89a7f4d05..f63c3d7d0a0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -168,6 +168,34 @@ pub fn apply( .flat_map(|record| record.transaction.input.iter()) .map(|input| input.previous_output) .collect(); + // The swept txids whose rows this changeset will actually delete — + // checked once, before any loser's pass has run, so the answer does not + // depend on the order the losers are walked in. `apply_sweep`'s + // co-swept-parent skip is scoped to exactly this set: a co-swept parent + // that IS on hand has its output rows deleted by its own pass, so the + // child must not re-create one as a placeholder — but a parent whose + // record this store lost (the same record-loss threat the by-outpoint + // release pass below exists for) deletes nothing, and skipping the + // child's claim there would leave the parent's surviving output row + // `spent = 0`: a phantom spendable coin `load()` would hand back. + let deleted_swept_txids: HashSet = { + let mut present_stmt = tx + .prepare_cached("SELECT 1 FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2")?; + let mut present = HashSet::new(); + for txid in &swept_txids { + let found: bool = present_stmt + .query_row( + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + if found { + present.insert(*txid); + } + } + present + }; for batch in &cs.sweeps { // Only this stays per batch: a release is true of the wallet its own // sweep saw, which is what lets a later batch correct an earlier one. @@ -184,7 +212,7 @@ pub fn apply( loser_txid, &batch.superseded_by, &released, - &swept_txids, + &deleted_swept_txids, )?; } // Releases are outpoint-keyed facts, so they are applied by outpoint @@ -264,7 +292,7 @@ fn apply_sweep( loser_txid: &dashcore::Txid, superseded_by: &dashcore::Txid, released: &HashSet, - swept_txids: &HashSet, + deleted_swept_txids: &HashSet, ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx .query_row( @@ -326,17 +354,26 @@ fn apply_sweep( for input in &loser.transaction.input { let outpoint = input.previous_output; // An input funded by a transaction this same changeset also sweeps - // is a dead parent's output, not a coin the winner took: upstream's - // descendant closure always sweeps parent and child together, its - // release computation excludes exactly these outpoints (so `freed` - // below can never be true for one), and the parent's own pass - // deletes the row. When the parent sorts before the child, holding - // the claim here would re-create the just-deleted row as a - // placeholder whose `spent_in_txid` the funding upsert's valve then - // defends — against the chainlocked reinstatement that is the one - // event that could bring the coin back — excluding a genuinely - // unspent coin from restore forever. - if swept_txids.contains(&outpoint.txid) { + // AND whose row is on hand is a dead parent's output, not a coin + // the winner took: upstream's descendant closure always sweeps + // parent and child together, its release computation excludes + // exactly these outpoints (so `freed` below can never be true for + // one), and the parent's own pass deletes its output rows. When the + // parent sorts before the child, holding the claim here would + // re-create the just-deleted row as a placeholder whose + // `spent_in_txid` the funding upsert's valve then defends — against + // the chainlocked reinstatement that is the one event that could + // bring the coin back — excluding a genuinely unspent coin from + // restore forever. + // + // The presence guard is what keeps the skip from overreaching: a + // co-swept parent whose record this store lost deletes nothing, so + // the child's claim on its surviving output must still be applied + // below — skipping it would leave the dead output `spent = 0`, a + // phantom spendable coin `load()` hands back. The set was computed + // before any loser's pass ran, so the answer is the same in both + // batch orders. + if deleted_swept_txids.contains(&outpoint.txid) { continue; } let key = blob::encode_outpoint(&outpoint)?; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 908e55db9b0..74bb0b7bc87 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1784,3 +1784,69 @@ fn a_batch_sweeping_parent_and_child_leaves_no_placeholder_for_the_parents_outpu spent_in_txid claim may stand in its way" ); } + +/// The presence guard on the co-swept skip. The skip exists because a +/// parent that IS on hand has its output rows deleted by its own pass — +/// but a parent whose record this store lost (the same record-loss threat +/// the by-outpoint release pass exists for) deletes nothing, and skipping +/// the child's claim there would leave the dead parent's surviving output +/// row `spent = 0`: a phantom spendable coin `load()` hands back. The +/// child's pass must still mark it. +#[test] +fn a_co_swept_parent_with_no_row_still_has_its_output_marked_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x51); + let parent_txid = Txid::from_byte_array([0x50; 32]); // P — record lost + let child_txid = Txid::from_byte_array([0x51; 32]); // C + let winner_txid = Txid::from_byte_array([0x52; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // P's record round was wiped, but its funded output row and C's record + // both persisted. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(child_txid, vec![parent_output], vec![])], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "sanity: the parent's output starts live" + ); + } + + // The batch sweeps both. P's pass finds no row and deletes nothing; the + // child's claim on P:0 is the only thing that can take the dead coin + // out of the unspent set. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&parent_output), + "a dead parent's output must not survive as a phantom spendable coin \ + just because the parent's own record was lost" + ); +} From 75971577da37d640f9b3fcaf0d33409287805a6c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:01:37 +0300 Subject: [PATCH 057/102] style(platform-wallet): fold the sent-payment eligibility match into matches! Clippy's match_like_matches_macro fires on the two-arm bool match the resolution helper used, and CI runs clippy with -D warnings. Same truth table, one expression. --- .../src/wallet/identity/network/payments.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 0aed332332b..b0f22ae3356 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1063,11 +1063,10 @@ async fn resolve_sent_payment_by_txid( }; let resolved = match managed.dashpay().payments.get(txid) { Some(entry) if entry.direction == PaymentDirection::Sent => { - let eligible = match (entry.status, to) { - (PaymentStatus::Pending, _) => true, - (PaymentStatus::Failed, PaymentStatus::Confirmed) => true, - _ => false, - }; + let eligible = matches!( + (entry.status, to), + (PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed) + ); if !eligible { continue; } From c6f801d1cafed44d392406fb348782a374320e8a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:01:37 +0300 Subject: [PATCH 058/102] refactor(platform-wallet): share the fault-and-freeze bookkeeping between the two failure arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ok-but-unattested-sweeps arm cloned the rejection arm's full four-step sequence — record the withheld advance, fault the wallet, count it once per drain, latch the one-shot flag. One helper now owns the bookkeeping and reports whether this is the drain's first freeze; each arm keeps its own cause-specific log wording. --- .../src/changeset/core_bridge.rs | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 7751deb5ee1..0b58cf32f74 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -498,15 +498,15 @@ where // `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 let Some(h) = offered_height { - diag.record_rejected(h); - } - fault.fault_wallet(wallet_id, sync_fault); - if !is_faulted { - diag.faulted += 1; - } - if !*freeze_logged { - *freeze_logged = true; + if fault_and_freeze( + &mut 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 \ @@ -531,20 +531,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 { - *freeze_logged = true; + if fault_and_freeze( + &mut 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 \ @@ -564,6 +559,36 @@ 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: &mut bool, +) -> bool { + if let Some(h) = offered_height { + diag.record_rejected(h); + } + fault.fault_wallet(wallet_id, sync_fault); + if !entered_faulted { + diag.faulted += 1; + } + let first_freeze = !*freeze_logged; + *freeze_logged = true; + first_freeze +} + /// Durable-watermark guard for dashpay/platform#4069. /// /// When a wallet has faulted this session (a `store()` was rejected), its From 010c1a8c974719062bb2ba0f2b2994eaa2ecf2f7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:01:38 +0300 Subject: [PATCH 059/102] refactor(platform-wallet-ffi): single-source the negotiated slot gate and the outpoint conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three extension-slot readers carried token-for-token copies of the size/version negotiation; a macro now owns the gate and each reader names only its struct, version, field, and type. OutPoint -> OutPointFFI was hand-rolled at three sites and the 36-byte outpoint key was re-inlined in the JNI layer next to the loop that already packs it — both are the join keys sweep releases use to find additive-path rows, where a byte-order drift between copies would silently unlink them, so each now has exactly one authority (a From impl and pack_outpoint_key). --- .../src/core_wallet_types.rs | 40 +++---- .../rs-platform-wallet-ffi/src/manager.rs | 107 ++++++++---------- .../rs-unified-sdk-jni/src/persistence.rs | 29 +++-- 3 files changed, 86 insertions(+), 90 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 833bc5b147f..cf77bce0e05 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -14,6 +14,21 @@ pub struct OutPointFFI { pub vout: u32, } +impl From<&dashcore::OutPoint> for OutPointFFI { + /// The one authority for `OutPoint` → FFI conversion. This value is + /// the join key sweep releases use to find additive-path rows on the + /// host side, so a byte-order drift between hand-rolled copies would + /// silently unlink them — every conversion site routes through here. + fn from(outpoint: &dashcore::OutPoint) -> Self { + let mut txid = [0u8; 32]; + txid.copy_from_slice(outpoint.txid.as_ref()); + Self { + txid, + vout: outpoint.vout, + } + } +} + /// Outpoint of a TXO that was spent, paired with the spending /// transaction's txid. Replaces the bare `OutPointFFI` on /// `AccountChangeSetFFI.utxos_spent` so the Swift persister can @@ -546,14 +561,7 @@ pub(crate) fn build_sweep_batches_for_callback( released: batch .released_outpoints .iter() - .map(|outpoint| { - let mut txid = [0u8; 32]; - txid.copy_from_slice(outpoint.txid.as_ref()); - OutPointFFI { - txid, - vout: outpoint.vout, - } - }) + .map(OutPointFFI::from) .collect(), }) .collect(); @@ -997,13 +1005,8 @@ fn record_spent_outpoints_ffi( .iter() .filter_map(|d| { let input = rec.transaction.input.get(d.index as usize)?; - let mut txid = [0u8; 32]; - txid.copy_from_slice(input.previous_output.txid.as_ref()); Some(SpentOutPointFFI { - outpoint: OutPointFFI { - txid, - vout: input.previous_output.vout, - }, + outpoint: OutPointFFI::from(&input.previous_output), spending_txid, }) }) @@ -1725,14 +1728,7 @@ fn tx_record_to_ffi( tr.transaction .input .iter() - .map(|input| { - let mut prev_txid = [0u8; 32]; - prev_txid.copy_from_slice(input.previous_output.txid.as_ref()); - OutPointFFI { - txid: prev_txid, - vout: input.previous_output.vout, - } - }) + .map(|input| OutPointFFI::from(&input.previous_output)) .collect() }; let input_outpoints_count = input_outpoints_vec.len(); diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 353e2b42ac2..11212cbf104 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -177,80 +177,71 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( ) } +/// Read one negotiated slot out of a size/version-tagged extension struct +/// — the single authority for the gate every reader below applies. A slot +/// is read only when the host's declared `struct_size` proves it was +/// allocated, so an extension built before the slot existed keeps its +/// earlier callbacks and simply never has the new one read — the +/// fail-closed half of the negotiation a bare-pointer callback struct +/// cannot perform itself (dashpay/platform#4406, finding 2). The version +/// check stays an exact match on purpose: the version names the field +/// ordering, and appending under it is what `struct_size` exists for. +/// +/// # Safety +/// `$extension` must point to a live extension struct of type `$ext_ty` +/// whose `struct_size` honestly describes its allocation. +macro_rules! negotiated_extension_slot { + ($extension:expr, $ext_ty:ty, $version_const:expr, $field:ident, $fn_ty:ty) => {{ + let extension: *const $ext_ty = $extension; + let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); + let version_end = std::mem::offset_of!($ext_ty, version) + std::mem::size_of::(); + let callback_end = + std::mem::offset_of!($ext_ty, $field) + std::mem::size_of::>(); + if supplied_size < version_end + || std::ptr::addr_of!((*extension).version).read() != $version_const + || supplied_size < callback_end + { + None + } else { + std::ptr::addr_of!((*extension).$field).read() + } + }}; +} + unsafe fn persistence_extension_dpns_callback( extension: *const PersistenceCallbacksExtension, ) -> Option { - let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); - let version_end = - std::mem::offset_of!(PersistenceCallbacksExtension, version) + std::mem::size_of::(); - if supplied_size < version_end { - return None; - } - let version = std::ptr::addr_of!((*extension).version).read(); - if version != PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION { - return None; - } - let callback_end = std::mem::offset_of!( + negotiated_extension_slot!( + extension, PersistenceCallbacksExtension, - on_persist_dpns_name_states_fn - ) + std::mem::size_of::>(); - if supplied_size < callback_end { - return None; - } - std::ptr::addr_of!((*extension).on_persist_dpns_name_states_fn).read() + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + on_persist_dpns_name_states_fn, + PersistDpnsNameStatesFn + ) } -/// Same gate, later field: the sweeps slot is read only when the host's -/// declared `struct_size` proves it was allocated, so an extension built -/// before the slot existed keeps its DPNS callback and simply never has -/// sweeps read — the fail-closed half of the negotiation the changeset -/// struct itself cannot perform (dashpay/platform#4406, finding 2). The -/// version check stays an exact match on purpose: the version names the -/// field ordering, and appending under it is what `struct_size` exists for. unsafe fn persistence_extension_sweeps_callback( extension: *const PersistenceCallbacksExtension, ) -> Option { - let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); - let version_end = - std::mem::offset_of!(PersistenceCallbacksExtension, version) + std::mem::size_of::(); - if supplied_size < version_end { - return None; - } - let version = std::ptr::addr_of!((*extension).version).read(); - if version != PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION { - return None; - } - let callback_end = std::mem::offset_of!( + negotiated_extension_slot!( + extension, PersistenceCallbacksExtension, - on_persist_wallet_changeset_sweeps_fn - ) + std::mem::size_of::>(); - if supplied_size < callback_end { - return None; - } - std::ptr::addr_of!((*extension).on_persist_wallet_changeset_sweeps_fn).read() + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + on_persist_wallet_changeset_sweeps_fn, + PersistWalletChangesetSweepsFn + ) } unsafe fn event_extension_dpns_callback( extension: *const EventHandlerCallbacksExtension, ) -> Option { - let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); - let version_end = - std::mem::offset_of!(EventHandlerCallbacksExtension, version) + std::mem::size_of::(); - if supplied_size < version_end { - return None; - } - let version = std::ptr::addr_of!((*extension).version).read(); - if version != PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION { - return None; - } - let callback_end = std::mem::offset_of!( + negotiated_extension_slot!( + extension, EventHandlerCallbacksExtension, - on_dpns_marketplace_sync_completed_fn - ) + std::mem::size_of::>(); - if supplied_size < callback_end { - return None; - } - std::ptr::addr_of!((*extension).on_dpns_marketplace_sync_completed_fn).read() + PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION, + on_dpns_marketplace_sync_completed_fn, + DpnsMarketplaceSyncCompletedFn + ) } // The C entry point's own shape: every callback table and out-param the diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 7485941c012..4a16a554479 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -56,7 +56,7 @@ 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, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, @@ -707,9 +707,7 @@ unsafe fn persist_changeset_sweep_batch( let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); let released_arr = env.new_object_array(released.len() as i32, &byte_array_cls, &empty)?; for (i, outpoint) in released.iter().enumerate() { - let mut key = [0u8; 36]; - key[..32].copy_from_slice(&outpoint.txid); - key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + let key = pack_outpoint_key(outpoint); env.with_local_frame(4, |env| { let k = env.byte_array_from_slice(&key)?; env.set_object_array_element(&released_arr, i as i32, &k) @@ -875,15 +873,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; @@ -4052,6 +4049,18 @@ 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. +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. From 817608a5016ace16d22bf6cf345526f4425e7bc1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:06:38 +0300 Subject: [PATCH 060/102] test(kotlin-sdk): pin the two-wallet released-pending deadlock the Swift suite already pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kotlin half of the shared-loser deadlock fix shipped without its regression: the multi-wallet fixtures seed materialized TXOs only, so the released-pending variant — one unresolved released input per wallet on the same shared loser — was untested here. Ports the Swift testTwoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete: each wallet's independently committed callback releases only its own coin, and the loser row plus both pending entries must all be gone at the end. Fails when the freed staged rows are left attached for the cascade (each callback then reads the other wallet's row as a live claim and declines the delete). --- .../PlatformWalletPersistenceHandlerTest.kt | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) 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 7f6128b3ebf..4deccdfe012 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 @@ -2843,6 +2843,72 @@ class PlatformWalletPersistenceHandlerTest { ) } + @Test + fun twoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete() = runTest { + // Port of the Swift regression of the same name. A shared loser + // holds one unresolved pending input per wallet, and each wallet's + // own sweep releases its own coin. Released staged rows must be + // deleted outright: left attached they read as their wallet's claim + // in `hasOtherWalletClaim`, so each callback would see the other's + // row and decline the delete, and replaying either would reach the + // same stalemate — the dead row and both pending entries stored + // forever. The global marker keeps the funds correct either way; + // this pins the storage half. + 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 }), + ) + 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, + ), + ) + + // Each wallet's independently committed callback, each releasing + // only its own coin. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pA), + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pB), + ) + handler.onChangesetEnd(walletB, success = true) + + assertNull( + "a released pending input is not a claim once its own wallet has resolved it", + db.transactionDao().getByTxid(loserTxid), + ) + assertTrue(db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + assertTrue(db.documentDao().getPendingInputsByOutpoint(pB).isEmpty()) + } + @Test fun aReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() = runTest { // Cross-round reinstatement — the BLOCKING finding this round @@ -3271,7 +3337,7 @@ class PlatformWalletPersistenceHandlerTest { // 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 — `tombstoneUnreleasedPendingInputs` matches + // 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- From aeac2cd2bc58e9748a842774bf4327ca03731b6d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:07:15 +0300 Subject: [PATCH 061/102] docs(kotlin-sdk): drop references to the SQL repoint helpers the bind-limit rewrite deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep KDoc and the chained-tombstone comment still named DocumentDao.tombstoneUnreleasedPendingInputs, deleteReleasedSweptTombstones and retargetSweptTombstones — none exist since the fixed-arity rewrite replaced them with pendingInputsStagedBy / sweptTombstonesTargeting plus an in-memory partition — and DocumentDao carried the deleted SQL repoint's orphaned KDoc block above the query that superseded it. Describe the flow that actually ships. --- .../PlatformWalletPersistenceHandler.kt | 27 ++++++++++--------- .../dashsdk/persistence/dao/DocumentDao.kt | 19 ------------- 2 files changed, 15 insertions(+), 31 deletions(-) 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 1a64c2ccade..cdc947097dc 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 @@ -1169,22 +1169,24 @@ class PlatformWalletPersistenceHandler( * below just like the TXOs do, so left alone the claim would vanish with * the loser, and the funding TXO's own later `onWalletChangesetUtxoAdded` * — even after a restart — would have nothing to tell it the coin isn't - * really free. [DocumentDao.tombstoneUnreleasedPendingInputs] detaches a - * held pending input from its doomed loser and repoints it at the - * corresponding [supersededBy] entry instead, flagged so the drain in + * really free. The staged rows are therefore fetched by their loser's + * FK ([DocumentDao.pendingInputsStagedBy]) and partitioned in memory: + * a released one is deleted outright, a held one is detached from its + * doomed loser and repointed at the corresponding [supersededBy] entry, + * flagged `isSweptTombstone` so the drain in * `onWalletChangesetUtxoAdded` knows to keep the coin spent — durably, * via `TxoEntity.supersededByTxid` — once the funding TXO materializes. * * A tombstoned row can itself need to move again: [supersededBy] is a * winner in this round, but nothing stops it from losing a later round * to a further winner while [supersededBy]'s own funding TXO is still - * unresolved. [DocumentDao.tombstoneUnreleasedPendingInputs] can't see - * that earlier tombstone — it already detached from the relationship - * that query matches on — so [DocumentDao.deleteReleasedSweptTombstones] - * and [DocumentDao.retargetSweptTombstones] look it up the only other + * unresolved. The staged-row fetch can't see that earlier tombstone — + * it already detached from the relationship that query matches on — so + * [DocumentDao.sweptTombstonesTargeting] looks it up the only other * way it is still findable, by the scalar `spendingTxid` it was - * repointed to, and carry it the rest of the chain: deleted if this - * round finally frees its outpoint, repointed at the new winner if not. + * repointed to, and the same in-memory partition carries it the rest + * of the chain: deleted if this round finally frees its outpoint, + * repointed at the new winner if not. * * All updates run before the delete: the foreign key nulls `spendingTxid` * (or, for a pending row already detached above, does nothing) on delete, @@ -1283,9 +1285,10 @@ class PlatformWalletPersistenceHandler( // A pending input an EARLIER sweep already tombstoned to // txids[i] (that txid was itself a sweep's winner, and is // now being swept in turn) detached from the relationship - // `tombstoneUnreleasedPendingInputs` above matches on, so it - // has to be found and carried forward separately — see - // [DocumentDao.deleteReleasedSweptTombstones]. + // the staged-row fetch above matches on, so it has to be + // found by the scalar `spendingTxid` it was repointed to + // and carried forward separately — see + // [DocumentDao.sweptTombstonesTargeting]. val prior = db.documentDao().sweptTombstonesTargeting(txids[i], walletId) val (freed, stillHeld) = prior.partition { releasedKeys.contains(it.outpoint.toHex()) } if (freed.isNotEmpty()) { 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 4ba419c7b2f..565b68f1674 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 @@ -175,25 +175,6 @@ interface DocumentDao { @Query("SELECT * FROM pending_inputs WHERE walletId = :walletId") fun observePendingInputsByWallet(walletId: ByteArray): Flow> - /** - * Repoint every pending input of [walletId]'s own still recorded - * against loser [txid] at [supersededBy] instead, except the outpoints - * named in [releasedOutpoints] — those came free and are left for - * `onWalletChangesetTransactionsSwept`'s own cascade-delete of [txid] - * to remove. [spendingTransactionTxid] is cleared first so the FK no - * longer targets the row about to be deleted (a live `transactions` - * row cascades its `pending_inputs` children), and `isSweptTombstone` - * marks the row so `onWalletChangesetUtxoAdded` knows this is a durable - * claim rather than an ordinary in-flight spend once the funding TXO - * finally lands. - * - * [txid] can be shared across wallets — the same loser can spend coins - * from more than one of them — and upstream hands each wallet its own - * [releasedOutpoints], computed only from that wallet's point of view. - * The `walletId` filter is what keeps this call from repointing or - * tombstoning a row a different wallet owns using a release decision - * that was never made about it. - */ /** * This wallet's live pending inputs staged by [txid], for the sweep to * partition in memory. From 1308279ffd307e7c88de864664a58498b9901b00 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:07:15 +0300 Subject: [PATCH 062/102] docs(swift-sdk): stop describing the autosave fallback this branch disabled The payment-callback comment still promised that an unbracketed host call would be flushed by autosave; autosaveEnabled has been false on the background context since the changeset bracketing landed, so the stage actually rides the next round's own save. --- .../PlatformWallet/PlatformWalletPersistenceHandler.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e9282996547..5a4be9ce042 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3550,7 +3550,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). } } From 6e458d11e85ea63a563dcee13913c80776d75638 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:17:15 +0300 Subject: [PATCH 063/102] test(swift-sdk): pin the record-pass downgrade guard independently of the spent emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combined downgrade regression delivers the winner's record and its utxos_spent emit in one round, so markUtxoSpent's stamp pin re-applies the hold the record pass just dropped — reverting only resolveInputOutpoint's monotonic guard still passed. The record-only variant delivers the record with no spent emit (the shape a flush takes when the wallet has no live UTXO to classify), fails against exactly that partial revert, and leaves the combined test pinning the emit half. --- .../SweptTransactionPersistTests.swift | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index f92306a330f..632a4332427 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -423,15 +423,66 @@ final class SweptTransactionPersistTests: XCTestCase { ) } - /// One changeset round carrying a transaction record and 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. + /// 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)]) + 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) + inputOutpoint: (txid: Data, vout: UInt32), + includeSpentEmit: Bool = true ) { let name = strdup("Standard { index: 0 }") defer { free(name) } @@ -465,8 +516,10 @@ final class SweptTransactionPersistTests: XCTestCase { account.account_type_name = name account.transactions = recordPtr account.transactions_count = 1 - account.utxos_spent = spentPtr - account.utxos_spent_count = 1 + if includeSpentEmit { + account.utxos_spent = spentPtr + account.utxos_spent_count = 1 + } withUnsafeMutablePointer(to: &account) { accountPtr in var cs = WalletChangeSetFFI() cs.accounts = accountPtr From d3cedc0013c5d2f5ceba9c0e9bdebbe91ad772a5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:11:30 +0300 Subject: [PATCH 064/102] fix(platform-wallet): let a reinstating asset-lock upsert cancel a folded sweep tombstone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps became a second producer of AssetLockChangeSet.removed, but the merge still cancelled an earlier-folded tombstone only for a Consumed upsert. A single folded drain can sweep an unconfirmed asset-lock funding tx (tombstone) and then carry its chainlocked return, whose record re-inserts the entry through reconstruction at a non-Consumed status — the merged changeset then held 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. CoreChangeSet's merge already retracts reinstated txids from folded sweeps; the asset-lock sub-changeset lacked the mirror. Every accepted upsert now cancels the outpoint's earlier tombstone, and a folding tombstone takes any pending non-Consumed upsert with it — so a merged changeset never carries both for one outpoint, and every store's upserts-then-removals order becomes layout rather than load-bearing sequencing. Both comments that still claimed untrack_asset_lock was the only removal producer are trued up (SQLite and Swift). Regressions: both fold directions on the merge itself, and the coalesced sweep-then-chainlocked-reinstatement fold driven through the real producers; each fails against the Consumed-only cancel. --- .../src/sqlite/schema/asset_locks.rs | 17 ++- .../src/changeset/changeset.rs | 77 ++++++++--- .../src/changeset/core_bridge.rs | 126 ++++++++++++++++++ .../PlatformWalletPersistenceHandler.swift | 15 ++- 4 files changed, 210 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index b0c21a58a47..216897725fd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -73,10 +73,19 @@ pub fn apply( if !cs.removed.is_empty() { // Same terminal rule as the upsert guard: a stored `consumed` // row is never deleted by a stale tombstone. Consumed rows are - // deliberately retained for historical lookup, and the only - // removal emitter (`untrack_asset_lock`) fires exclusively for - // Built rows whose broadcast was rejected — so a removal - // reaching a consumed row is by construction a stale write. + // deliberately retained for historical lookup, and neither + // removal producer can legitimately name one — a Built row + // rejected at broadcast (`untrack_asset_lock`) never got that + // far, and a sweep of the funding transaction + // (`remove_tracked_asset_locks_for_swept`) only tombstones + // entries still tracked, which a consumed lock no longer is — + // so a removal reaching a consumed row is by construction a + // stale write. `AssetLockChangeSet::merge` guarantees a stored + // changeset never carries an upsert and a tombstone for the + // same outpoint (a reinstating reconstruction cancels a folded + // sweep tombstone; a folding tombstone takes the dead upsert + // with it), so the upserts-then-removals order here is layout, + // not load-bearing sequencing. let mut stmt = tx.prepare_cached( "DELETE FROM asset_locks \ WHERE wallet_id = ?1 AND outpoint = ?2 AND status != 'consumed'", diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index cada1a9d33f..897decf0fb4 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1044,32 +1044,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); } } @@ -2020,10 +2035,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 0b58cf32f74..45151cbe4f2 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -3219,6 +3219,132 @@ mod tests { 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 5a4be9ce042..50a8c8fb2e3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -391,10 +391,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 } From 86568b4d8a43714aa9f429acb88f79f5de333e50 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:20:30 +0300 Subject: [PATCH 065/102] fix(platform-wallet-storage): delete a co-swept parent's output outright instead of guarding on its row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the reviewer's remedy over the presence guard from 679fb316cb, because it is strictly better in the corner the guard left wrong: with the parent's record lost, the guard fell back to the child's ordinary hold (spent = 1, spent_in_txid = winner), and that claim is exactly what the funding upsert's valve defends — locking out the chainlocked reinstatement that is the one event able to bring the coin back for real. A dead parent's output is nobody's coin; the right end state is no row at all. The child's pass now deletes the exact co-swept input outpoint — idempotent against the parent's own pass in either batch order, present row or lost record alike — and the presence pre-scan goes away with the distinction it existed to draw. The record-loss regression grows a reinstatement leg asserting the row is deleted rather than held and that the re-emitted output lands freshly unspent; it fails against the presence-guard behavior. --- .../src/sqlite/schema/core_state.rs | 79 +++++++------------ .../tests/sqlite_transaction_sweeps.rs | 58 +++++++++++--- 2 files changed, 77 insertions(+), 60 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index f63c3d7d0a0..8d1032ca013 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -168,34 +168,6 @@ pub fn apply( .flat_map(|record| record.transaction.input.iter()) .map(|input| input.previous_output) .collect(); - // The swept txids whose rows this changeset will actually delete — - // checked once, before any loser's pass has run, so the answer does not - // depend on the order the losers are walked in. `apply_sweep`'s - // co-swept-parent skip is scoped to exactly this set: a co-swept parent - // that IS on hand has its output rows deleted by its own pass, so the - // child must not re-create one as a placeholder — but a parent whose - // record this store lost (the same record-loss threat the by-outpoint - // release pass below exists for) deletes nothing, and skipping the - // child's claim there would leave the parent's surviving output row - // `spent = 0`: a phantom spendable coin `load()` would hand back. - let deleted_swept_txids: HashSet = { - let mut present_stmt = tx - .prepare_cached("SELECT 1 FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2")?; - let mut present = HashSet::new(); - for txid in &swept_txids { - let found: bool = present_stmt - .query_row( - params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)], - |_| Ok(true), - ) - .optional()? - .unwrap_or(false); - if found { - present.insert(*txid); - } - } - present - }; for batch in &cs.sweeps { // Only this stays per batch: a release is true of the wallet its own // sweep saw, which is what lets a later batch correct an earlier one. @@ -212,7 +184,7 @@ pub fn apply( loser_txid, &batch.superseded_by, &released, - &deleted_swept_txids, + &swept_txids, )?; } // Releases are outpoint-keyed facts, so they are applied by outpoint @@ -292,7 +264,7 @@ fn apply_sweep( loser_txid: &dashcore::Txid, superseded_by: &dashcore::Txid, released: &HashSet, - deleted_swept_txids: &HashSet, + swept_txids: &HashSet, ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx .query_row( @@ -354,26 +326,35 @@ fn apply_sweep( for input in &loser.transaction.input { let outpoint = input.previous_output; // An input funded by a transaction this same changeset also sweeps - // AND whose row is on hand is a dead parent's output, not a coin - // the winner took: upstream's descendant closure always sweeps - // parent and child together, its release computation excludes - // exactly these outpoints (so `freed` below can never be true for - // one), and the parent's own pass deletes its output rows. When the - // parent sorts before the child, holding the claim here would - // re-create the just-deleted row as a placeholder whose - // `spent_in_txid` the funding upsert's valve then defends — against - // the chainlocked reinstatement that is the one event that could - // bring the coin back — excluding a genuinely unspent coin from - // restore forever. + // is a dead parent's output — nobody's coin, not something the + // winner took: upstream's descendant closure always sweeps parent + // and child together, and its release computation excludes exactly + // these outpoints (so `freed` below can never be true for one). The + // right end state is NO row, deleted here outright rather than + // assumed away or marked: + // + // - Assuming the parent's own pass deleted it fails when the + // parent's record was lost (the same record-loss threat the + // caller's by-outpoint release pass exists for) — that pass + // deletes nothing, and skipping the claim here would leave the + // dead output `spent = 0`, a phantom spendable coin `load()` + // hands back. + // - Holding it instead (`spent = 1`, `spent_in_txid = winner`, the + // ordinary path below) survives as a claim the funding upsert's + // valve then defends — against the chainlocked reinstatement + // that is the ONE event that can bring the coin back, whose + // re-emitted output must land freshly unspent. // - // The presence guard is what keeps the skip from overreaching: a - // co-swept parent whose record this store lost deletes nothing, so - // the child's claim on its surviving output must still be applied - // below — skipping it would leave the dead output `spent = 0`, a - // phantom spendable coin `load()` hands back. The set was computed - // before any loser's pass ran, so the answer is the same in both - // batch orders. - if deleted_swept_txids.contains(&outpoint.txid) { + // The delete is idempotent against the parent's own pass in either + // batch order, and a reinstatement re-creates the real row through + // the ordinary `utxos_added` upsert with nothing left standing in + // its way. + if swept_txids.contains(&outpoint.txid) { + let key = blob::encode_outpoint(&outpoint)?; + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..]], + )?; continue; } let key = blob::encode_outpoint(&outpoint)?; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 74bb0b7bc87..0432b4d5f03 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1785,15 +1785,17 @@ fn a_batch_sweeping_parent_and_child_leaves_no_placeholder_for_the_parents_outpu ); } -/// The presence guard on the co-swept skip. The skip exists because a -/// parent that IS on hand has its output rows deleted by its own pass — -/// but a parent whose record this store lost (the same record-loss threat -/// the by-outpoint release pass exists for) deletes nothing, and skipping -/// the child's claim there would leave the dead parent's surviving output -/// row `spent = 0`: a phantom spendable coin `load()` hands back. The -/// child's pass must still mark it. +/// The record-loss half of the co-swept rule. A parent whose record this +/// store lost (the same threat the by-outpoint release pass exists for) +/// deletes nothing in its own pass, so the child's pass must take the +/// surviving output row out of the restore set itself — leaving it +/// `spent = 0` would hand back a phantom spendable coin. And it must do +/// so by DELETING the row, not by holding it: a `spent_in_txid` claim is +/// exactly what the funding upsert's valve defends, which would lock out +/// the chainlocked reinstatement that is the one event able to bring the +/// coin back for real. #[test] -fn a_co_swept_parent_with_no_row_still_has_its_output_marked_spent() { +fn a_co_swept_parent_with_no_row_still_has_its_output_removed() { let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xE9); ensure_wallet_meta(&persister, &w); @@ -1843,10 +1845,44 @@ fn a_co_swept_parent_with_no_row_still_has_its_output_marked_spent() { core_state::apply(&tx, &w, &cs).unwrap(); tx.commit().unwrap(); } + { + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&parent_output), + "a dead parent's output must not survive as a phantom spendable coin \ + just because the parent's own record was lost" + ); + assert!( + !row_exists(&conn, &w, &parent_output), + "and it must be deleted, not held — a spent_in_txid claim would lock \ + out the reinstatement below" + ); + } + + // The chainlocked return: P is reinstated with its output re-emitted, + // and nothing this sweep left behind may stand in its way. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } let conn = persister.lock_conn_for_test(); assert!( - !unspent(&conn, &w).contains(&parent_output), - "a dead parent's output must not survive as a phantom spendable coin \ - just because the parent's own record was lost" + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's genuinely unspent output must restore even when \ + its record was lost at sweep time" ); } From 56ae3c31bc5c19e835a80bd23dcfad678b513820 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:29:37 +0300 Subject: [PATCH 066/102] perf(swift-sdk): stop rescanning the round's unsaved inserts once per swept loser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every plain pending-changes fetch pays an in-memory predicate pass over the round's unsaved inserts of its entity, and the sweep path paid it per loser three times over: the loser-row fetch and the scalar tombstone fetch ran once per swept txid, and the by-outpoint release pass once per released coin. A folded drain carries an initial scan's thousands of staged records into the same round as a network-derived conflict sweep, so the work was O(records x losers) — synchronous on the persistence queue, ahead of endChangeset, holding the wallet's watermark. Loser rows now resolve through a throwing, round-index-aware lookup that registers its store hits — safe on the miss path because every transaction row carrying staged state is already registered (record upserts, the drain's winner registration, and this helper itself, which covers isGloballySwept staged by an earlier batch). The tombstone scan and the release fetch run once per batch: pending-changes stays on (both must see rows staged earlier in the round), predicates name only immutable columns, and the mutable halves are read off live objects. applySweptTransaction becomes fetch-free, taking the resolved row and its losers' tombstones from the caller. The suite's chained, multi-wallet, and drain pins all hold, and a new cross-batch test pins the one shape the hoist must keep working: a second batch in the same round releasing the tombstone the first batch just wrote as unsaved staged state (confirmed to pass against the previous per-loser fetches too — this is behavior preservation for the refactor, not a bug regression). --- .../PlatformWalletPersistenceHandler.swift | 220 ++++++++++++------ .../SweptTransactionPersistTests.swift | 59 +++++ 2 files changed, 205 insertions(+), 74 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 50a8c8fb2e3..383bca1f7e7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1016,15 +1016,50 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let supersededBy = Swift.withUnsafeBytes(of: batch.superseded_by) { Data($0) } if batch.txids_count > 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 { $0.outpoint == outpoint } - ) - releasedDescriptor.fetchLimit = 1 - let row: PersistentTxo? + // ONE fetch for the whole batch, keyed by the immutable + // outpoint set — the per-outpoint form paid the + // pending-changes tax (an in-memory pass over every unsaved + // TXO insert) once per released coin, and a release set is + // sized by a remote sender's transaction. + if !released.isEmpty { + let rows: [PersistentTxo] do { - row = try backgroundContext.fetch(releasedDescriptor).first + let releasedDescriptor = FetchDescriptor( + 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 of " - + "\(outpoint.prefix(8).toHexString())… failed: " + "⚠️ persistWalletChangesetSweeps: release lookup failed: " + "\(error.localizedDescription); failing the round" ) return false } - guard let txo = row, - Self.resolvedWalletId(of: txo) == walletId, - txo.spendingTransaction == nil else { continue } - txo.isSpent = false - txo.supersededByTxid = nil - txo.spendingInputIndex = nil - txo.lastUpdated = Date() + 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() + } } } @@ -1194,34 +1240,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// wallet" is decided without an explicit cross-wallet coordination /// point. /// - /// Throws if SwiftData cannot answer the lookup. The caller fails the - /// round on that: a deletion silently skipped would let Rust clear the - /// sweep while the dead row survives. + /// 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, - txid: Data, supersededBy: Data, - released: Set - ) throws { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txid } - ) - descriptor.fetchLimit = 1 - descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] - // A successful fetch that finds nothing skips only the row-scoped - // work below, 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. - // Returning here 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. - let row = try backgroundContext.fetch(descriptor).first - + released: Set, + row: PersistentTransaction?, + priorTombstones: [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 @@ -1315,15 +1355,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } // Chained-sweep continuation: a pending row an EARLIER sweep already - // tombstoned to `txid` (this transaction, itself a sweep's winner - // until now) is no longer reachable through `row.pendingInputs` — - // see the doc comment above. Find it by the scalar `spendingTxid` - // it carries instead, 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. + // 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 outside the `if let row` above. A tombstone's very + // 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 @@ -1334,14 +1374,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // this wallet's private state; the row's fate says nothing about // whether they still need their release applied or their chain // continued. - var tombstoneDescriptor = FetchDescriptor( - predicate: #Predicate { - $0.spendingTxid == txid && $0.isSweptTombstone == true && $0.walletId == walletId - } - ) - tombstoneDescriptor.includePendingChanges = true - let priorTombstones = try backgroundContext.fetch(tombstoneDescriptor) - for pending in priorTombstones { + for pending in priorTombstones where !pending.isDeleted { if released.contains(pending.outpoint) { backgroundContext.delete(pending) } else { @@ -1350,6 +1383,42 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// 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 @@ -1539,17 +1608,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // store-only fetch still returns rows whose delete is staged but // unsaved. // - // `applySweptTransaction` stays on plain pending-changes fetches: its - // row fetch needs relationship prefetching, and its tombstone fetch - // keys on columns that MUTATE mid-round (`spendingTxid`, - // `isSweptTombstone`), which neither the index nor a store-only - // fetch can answer. Sweeps only target unconfirmed conflicts, so - // that path stays off the initial-scan hot loop. It 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. + // 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? { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 632a4332427..a4d65fc1412 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -1493,6 +1493,65 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertEqual(coin.supersededByTxid, finalWinner) } + /// 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), + Batch( + losers: [secondLoser], + winner: finalWinner, + 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 From 5cfca151fa95fbc15738ea6cb8f01044a2c80a63 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:39:41 +0300 Subject: [PATCH 067/102] fix(platform-wallet): retry a swept payment's failure persist instead of logging it away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record_dashpay_payment rolls its in-memory overwrite back when the store rejects, and the sweep hook logged that and moved on — sound for confirmations, which every later signal for the transaction re-drives, but the sweep path has no second signal: the independently persisted core sweep still deletes the loser and advances its durable state, so one transient rejection left the sent payment Pending durably with nothing ever able to move it again. resolve_sent_payment_by_txid now returns the persistence result (a no-op resolution stays Ok), the confirm path keeps its documented log-and-continue, and the sweep path retries the flip in place with a small bounded backoff — each attempt finds the entry Pending again thanks to the rollback, so the flip stays idempotent. A rejection outlasting the whole budget is logged as the audit trail with the consequence named, and the entry honestly stays Pending rather than holding an in-memory Failed the store never accepted. The regression injects one rejection beside a successful sweep (must land Failed — fails with the retry budget reverted to a single attempt) and an unbounded rejection (must stay Pending). --- .../src/wallet/identity/network/payments.rs | 223 ++++++++++++++++-- 1 file changed, 203 insertions(+), 20 deletions(-) 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 b0f22ae3356..13ffb0a5592 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -986,14 +986,26 @@ async fn confirm_sent_payment_by_txid( txid: &str, ) { use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - resolve_sent_payment_by_txid( + // Log-and-continue is sound for confirmations only: the flip rolled + // back in memory with the failed store, and every later signal for the + // same transaction — a confirmed re-detection, the block round, the + // IS-lock event — re-drives this path against the still-`Pending` + // entry. The sweep path below has no such second signal and handles + // its persistence failures itself. + if let Err(e) = resolve_sent_payment_by_txid( wallet_manager, wallet_id, persister, txid, PaymentStatus::Confirmed, ) - .await; + .await + { + tracing::warn!( + error = %e, + "Failed to persist sent-payment confirmation; will retry on next detection" + ); + } } /// Mark the `Pending` `Sent` [`PaymentEntry`]s of swept transactions @@ -1018,15 +1030,55 @@ pub(crate) async fn fail_swept_sent_dashpay_payments( txids: &[dashcore::Txid], ) { use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + // One store rejection must not strand the payment: a sweep is + // one-shot. Nothing re-emits it, and the same sweep deletes the + // loser's record — the last thing reconciliation could have resolved + // the entry from — so unlike a confirmation (re-driven by every later + // signal for its transaction) a failed flip here has no natural + // retry. Three brief in-place attempts ride out a transient backend + // error; `record_dashpay_payment` rolls the in-memory overwrite back + // with the failed store, so each attempt finds the entry `Pending` + // again and the flip stays idempotent. + const PERSIST_ATTEMPTS: u32 = 3; for txid in txids { - resolve_sent_payment_by_txid( - wallet_manager, - wallet_id, - persister, - &txid.to_string(), - PaymentStatus::Failed, - ) - .await; + let txid_hex = txid.to_string(); + for attempt in 1..=PERSIST_ATTEMPTS { + match resolve_sent_payment_by_txid( + wallet_manager, + wallet_id, + persister, + &txid_hex, + PaymentStatus::Failed, + ) + .await + { + Ok(()) => break, + Err(e) if attempt < PERSIST_ATTEMPTS => { + tracing::warn!( + txid = %txid_hex, + error = %e, + attempt, + "Sweep payment-failure persist rejected; retrying" + ); + tokio::time::sleep(std::time::Duration::from_millis(100 * u64::from(attempt))) + .await; + } + Err(e) => { + // The residual after the budget: the entry stays + // `Pending` durably, and the only remaining exits are a + // chainlocked reinstatement confirming it or a manual + // resolution — named here so the log is the audit + // trail, not the recovery. + tracing::error!( + txid = %txid_hex, + error = %e, + "Sweep payment-failure persist rejected on every attempt; \ + the sent payment stays Pending durably (no signal re-emits \ + a sweep)" + ); + } + } + } } } @@ -1041,18 +1093,26 @@ pub(crate) async fn fail_swept_sent_dashpay_payments( /// the persistence round. Separated from the event glue so the state /// machine is unit-testable without constructing a full /// `TransactionRecord`. +/// +/// A no-op resolution (no entry, not `Sent`, not eligible) is `Ok(())`; +/// `Err` means the flip was found, attempted, and its store rejected — the +/// in-memory overwrite has already been rolled back +/// (`record_dashpay_payment`'s contract), so the caller may retry or +/// accept per its own signal model. Not swallowed here, because the two +/// callers genuinely differ: a confirmation is re-driven by every later +/// signal for its transaction, a sweep never re-emits. async fn resolve_sent_payment_by_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, persister: &crate::wallet::persister::WalletPersister, txid: &str, to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, -) { +) -> Result<(), crate::changeset::PersistenceError> { use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; let mut wm = wallet_manager.write().await; let Some(info) = wm.get_wallet_info_mut(wallet_id) else { - return; + return Ok(()); }; // The sent transaction belongs to one managed identity; find the @@ -1077,15 +1137,11 @@ async fn resolve_sent_payment_by_txid( _ => continue, }; tracing::info!(owner = %owner, %txid, status = ?to, "Resolving sent DashPay payment"); - if let Err(e) = managed.record_dashpay_payment(txid.to_string(), resolved, persister) { - tracing::warn!( - error = %e, - "Failed to persist sent-payment resolution; will retry on next detection" - ); - } - // txid is unique — only one identity can hold this entry. - break; + // txid is unique — only one identity can hold this entry, so the + // first eligible hit decides the call's result either way. + return managed.record_dashpay_payment(txid.to_string(), resolved, persister); } + Ok(()) } // --------------------------------------------------------------------------- @@ -1588,6 +1644,9 @@ mod tests { #[derive(Default)] struct RecordingPersister { stores: Mutex>, + /// Fail the next N `store` calls with an injected backend error + /// before recording resumes — the shape of a transient rejection. + fail_next_stores: Mutex, } impl PlatformWalletPersistence for RecordingPersister { @@ -1596,6 +1655,13 @@ mod tests { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + { + let mut budget = self.fail_next_stores.lock().unwrap(); + if *budget > 0 { + *budget -= 1; + return Err(PersistenceError::backend("injected store failure")); + } + } self.stores.lock().unwrap().push((wallet_id, changeset)); Ok(()) } @@ -3057,6 +3123,123 @@ mod tests { ); } + /// A payment-store rejection alongside a successful core sweep must not + /// strand the sent payment `Pending`. The core sweep persists through + /// its own store round and deletes the loser's record, so unlike a + /// confirmation — re-driven by every later signal for its transaction — + /// nothing ever re-emits the failure signal; the sweep hook's bounded + /// in-place retry is the only recovery. Also pins the honest residual: + /// a rejection outlasting the whole budget leaves the entry `Pending` + /// rather than lying about durability. + #[tokio::test] + async fn a_store_rejection_does_not_strand_a_swept_payment_pending() { + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xAC; 32]); + let txid_key = txid.to_string(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, Some("dinner".into())), + &p, + ) + .expect("record pending sent"); + } + + async fn status( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: &WalletId, + owner: &Identifier, + txid: &str, + ) -> PaymentStatus { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("info"); + info.identity_manager + .managed_identity(owner) + .unwrap() + .dashpay() + .payments + .get(txid) + .expect("entry") + .status + } + + let swept_event = || WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xCE; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + + // One transient rejection: the retry must land the flip anyway. The + // in-memory status only reads Failed when a store SUCCEEDED + // (record_dashpay_payment rolls back on failure), so this assertion + // proves durability, not just the overlay. + *persister.fail_next_stores.lock().unwrap() = 1; + super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) + .await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Failed, + "one store rejection must not strand the swept payment Pending" + ); + + // The residual: a rejection outlasting the whole budget leaves the + // entry Pending — never an in-memory Failed the store did not + // accept. + let txid2 = dashcore::Txid::from([0xAD; 32]); + let txid2_key = txid2.to_string(); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid2_key.clone(), + PaymentEntry::new_sent(contact, 10_000, None), + &p, + ) + .expect("record second pending sent"); + } + *persister.fail_next_stores.lock().unwrap() = usize::MAX; + let swept_event2 = WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid2], + superseded_by: dashcore::Txid::from([0xCF; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event2) + .await; + *persister.fail_next_stores.lock().unwrap() = 0; + assert_eq!( + status(iw, &wallet_id, &owner, &txid2_key).await, + PaymentStatus::Pending, + "an exhausted budget leaves Pending in memory — never an unpersisted Failed" + ); + } + /// A sent payment confirmed by a block must flip `Pending → Confirmed`. /// /// The wallet sees its *own* broadcast in the mempool first From 0414469aa5ac14cb15af697b10da9d73097304fd Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:42:50 +0300 Subject: [PATCH 068/102] docs(platform-wallet): define CORE_SWEEP_REMOVAL by observable durability, not row deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public capability contract said the swept loser's row and any tombstoned pending-input claim actually leave the backing store — the opposite of what this seam implements and requires. The in-tree stores keep an inert globally-swept row until every wallet's scoped cleanup lands, and a detached tombstone MUST outlive its loser or the consumed coin later reads unspent; an out-of-tree persister attesting against the old wording could physically delete exactly the claim correctness depends on. Redefine the bit by what must be observable — swept rows excluded from restore and enumeration, released outpoints freed unless a surviving claim supersedes, non-released claims retained durably — with physical deletion an implementation detail. Also document the known foreign-input placeholder exposure at apply_sweep's tombstone INSERT: nothing on the record can prove an input foreign (input_details and direction are computed from the UTXO snapshot at record time, and the held-but-unfunded claim the placeholder exists for is indistinguishable from a foreign input by either), so gating client-side would trade bounded junk for lost holds; the clean fix is upstream-shaped — per-wallet held outpoints on TransactionsSwept symmetric to released_outpoints. --- .../src/sqlite/schema/core_state.rs | 16 ++++++++++++ .../src/changeset/persistence_capabilities.rs | 25 ++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 8d1032ca013..108442527e6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -252,6 +252,22 @@ pub fn apply( /// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the /// claim survives the funding upsert instead of being upserted away by it. /// +/// KNOWN EXPOSURE, deliberately not gated client-side: a swept INCOMING +/// payment reaches this loop too, and every sender-owned input it named +/// lands a placeholder that no funding upsert will ever overwrite and no +/// release will ever name — permanent zero-value junk, one row per foreign +/// input, growable by anyone willing to double-spend payments at this +/// wallet. It stays because nothing on the record can prove an input +/// foreign: `input_details` and `direction` are both computed from the +/// wallet's UTXO snapshot AT RECORD TIME, and the held-but-unfunded claim +/// this placeholder exists to preserve — our own coin, spent before its +/// funding output was classified — produces exactly a record whose input is +/// missing from `input_details` and whose direction reads `Incoming`, +/// indistinguishable from an attacker's fan-in. Gating on either would +/// trade bounded junk for lost holds. The clean fix is upstream-shaped: +/// carry per-wallet HELD outpoints on `TransactionsSwept` symmetric to +/// `released_outpoints`, so ownership is decided where it is known. +/// /// Idempotent: a txid this store never recorded is a successful no-op, not an /// error. A sweep can legitimately name a transaction this wallet dropped, or /// never derived an address for in the first place. Only the loser-scoped diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index c1ab5c6fa41..da379c4b75d 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -52,15 +52,22 @@ impl PersistenceCapabilities { /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); /// A stored `CoreChangeSet` whose `sweeps` are non-empty is durably - /// applied: the swept loser's row (and any tombstoned pending-input - /// claim standing in for a not-yet-materialized UTXO) actually leaves - /// the backing store, not merely accepted-and-ignored. On the FFI - /// surface sweeps travel through the persistence extension's - /// size-negotiated sweep callback — a slot Rust never reads unless the - /// host's declared `struct_size` proved it exists — so an older host - /// processes the rest of the round, returns success, and never sees - /// the sweeps at all; this bit is what tells the wallet the round-trip - /// was actually implemented rather than silently truncated. + /// applied batch by batch and in order: each swept transaction and its + /// outputs are excluded from every restore and enumeration path (whether + /// by physical deletion or a durable marker), each released outpoint is + /// freed unless a later surviving claim supersedes that release, and each + /// non-released input retains a durable spend claim even when its funding + /// TXO has not materialized yet. Physical row deletion is an + /// implementation detail, not the contract — the in-tree stores keep an + /// inert globally-swept row until every wallet's scoped cleanup lands, + /// and a detached tombstone MUST outlive its loser or the consumed coin + /// later reads unspent. On the FFI surface sweeps travel through the + /// persistence extension's size-negotiated sweep callback — a slot Rust + /// never reads unless the host's declared `struct_size` proved it exists + /// — so an older host processes the rest of the round, returns success, + /// and never sees the sweeps at all; this bit tells the wallet that the + /// complete sweep contract was implemented rather than silently + /// truncated. pub const CORE_SWEEP_REMOVAL: Self = Self(1 << 10); /// Capabilities required before exporting and funding an invitation voucher. From 1c8909e2a86ddb3e16d838e3bad6a46c42ea8052 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:13:16 +0300 Subject: [PATCH 069/102] perf(platform-wallet): hash the swept-txid set once before scanning tracked asset locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep cascade filtered the tracked map with a linear contains over the loser slice — O(entries x losers) under the wallet-manager write lock, with the loser count network-influenced (the mempool alone tracks up to a thousand conflicts). Build the HashSet once, before taking the lock. Pure lookup-strategy change, no behavioral delta: the existing cascade and fold regressions cover the path. --- .../src/wallet/asset_lock/sync/reconstruction.rs | 5 +++++ 1 file changed, 5 insertions(+) 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 6c732ea7f6e..22da3d04194 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 @@ -386,6 +386,11 @@ pub(crate) async fn remove_tracked_asset_locks_for_swept( 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; From f99789ba4d16ef948a18d03215cff757687f2e2f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:48:15 +0300 Subject: [PATCH 070/102] fix(platform-wallet): ride the swept payment's Failed flip on the sweep's own store round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded retry narrowed the loss window but was not durability: after the budget the payment stayed Pending, the independently persisted core sweep had already deleted the loser and advanced durable state, and nothing re-emits TransactionsSwept once its round is durable — the terminal Failed was lost for good. The root problem was the seam: any flip persisted separately from the sweep's round can be lost exactly once, unrecoverably, because the replay channel (the re-scan re-detecting the conflict) only exists while the loser's record does. Move the flip into the wallet-event adapter's own atomic round. The sweep arm flips the losers' Pending sent entries in memory and stages the changed rows as dashpay_payments_overlay on the same PlatformWalletChangeSet as the sweep — the bounded single-row carrier both SQLite and the FFI vtable already apply — so a store rejection discards flip and sweep together, the wallet faults, the re-scan re-emits the sweep, and the flip is recomputed; the adapter rolls the in-memory half back for rejected wallets so the replay finds the entries eligible again. The payment hooks no longer route sweeps (a second separately persisted write would race the round), the retry machinery is deleted, and the transition table is factored into sent_status_transition_allowed so the confirm path and the sweep flip cannot drift. The adapter-level regression pins all three legs — overlay in the same store() as the sweep, rejection rolling memory back to Pending, and the replayed sweep recomputing the flip — and fails against a version without the staging (leg 1) and one without the rollback (leg 2), verified by targeted reverts. The state-machine and rollback contracts stay pinned at the payments level. --- .../src/changeset/core_bridge.rs | 299 +++++++++++++- .../src/wallet/identity/network/mod.rs | 3 +- .../identity/network/payment_handler.rs | 75 ++-- .../src/wallet/identity/network/payments.rs | 382 ++++++++++-------- 4 files changed, 538 insertions(+), 221 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 45151cbe4f2..f234a28e25c 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -158,6 +158,11 @@ struct BatchDiagnostics { /// Wallets in this drain that are faulted — whether they entered faulted /// or were faulted by it. Each wallet counts at most once per drain. faulted: usize, + /// Wallets whose `store()` this drain REJECTED outright (the `Err` + /// arm) — not the nominal-success sweep-capability freeze, whose round + /// did store. The caller uses this to roll back the in-memory payment + /// flips whose durable half the rejection discarded. + rejected_wallets: std::collections::BTreeSet, } impl BatchDiagnostics { @@ -320,6 +325,18 @@ async fn run_wallet_event_adapter

( }; let mut batch: BTreeMap = BTreeMap::new(); + // The undo half of every sweep-failed payment flip staged into + // `batch` — kept OUTSIDE the batch because `commit_batch` consumes + // it, and the rollback only runs for wallets whose store was + // rejected (see below). + let mut payment_rollbacks: BTreeMap< + WalletId, + Vec<( + dpp::prelude::Identifier, + String, + crate::wallet::identity::PaymentEntry, + )>, + > = BTreeMap::new(); let mut closed = false; { let wallet_id = event.wallet_id(); @@ -329,9 +346,11 @@ async fn run_wallet_event_adapter

( // read lock on the manager. let core = build_core_changeset(&wallet_manager, &event).await; let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; + let flips = swept_payment_flips_for_event(&wallet_manager, &event).await; let entry = batch.entry(wallet_id).or_default(); entry.core.merge(core); entry.asset_locks.merge(asset_locks); + fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); } // Fold in whatever else is already buffered. `try_recv` never waits, @@ -345,9 +364,11 @@ async fn run_wallet_event_adapter

( let core = build_core_changeset(&wallet_manager, &event).await; let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; + let flips = swept_payment_flips_for_event(&wallet_manager, &event).await; let entry = batch.entry(wallet_id).or_default(); entry.core.merge(core); entry.asset_locks.merge(asset_locks); + fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); folded += 1; } Err(TryRecvError::Empty) => break, @@ -370,6 +391,25 @@ async fn run_wallet_event_adapter

( &mut freeze_logged, ); + // A rejected round leaves NOTHING durable — the loser's record and + // the payment flip alike — so memory must return to the durable + // state or the replayed sweep (re-emitted by the re-scan, since the + // rejection kept the loser's record) would find the entries already + // `Failed` in memory, skip them as ineligible, and the store would + // never learn. Only rejected wallets roll back: a stored round — + // including one that stored but froze the watermark for a + // non-attesting sweep backend — has the flip durably applied. + for wallet_id in &diag.rejected_wallets { + if let Some(rollback) = payment_rollbacks.remove(wallet_id) { + crate::wallet::identity::network::rollback_swept_payment_flips( + &wallet_manager, + wallet_id, + rollback, + ) + .await; + } + } + // One structured line per drain via the `log` facade so a tester // logcat is unambiguous about whether the watermark is advancing. // Every field reports an observed outcome — see [`BatchDiagnostics`]. @@ -422,6 +462,7 @@ where WalletBatch { mut core, asset_locks, + payments_overlay, }, ) in batch { @@ -442,7 +483,10 @@ where diag.record_frozen(h); } } - if core.is_empty_no_records() && Merge::is_empty(&asset_locks) { + if core.is_empty_no_records() + && Merge::is_empty(&asset_locks) + && payments_overlay.is_empty() + { // 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. @@ -487,6 +531,9 @@ where // same store round-trip so the row and the record that // implies it land atomically. asset_locks: (!Merge::is_empty(&asset_locks)).then_some(asset_locks), + // The sweep-failed payments ride the same atomic round as the + // sweep that proved them dead — see `WalletBatch::payments_overlay`. + dashpay_payments_overlay: (!payments_overlay.is_empty()).then_some(payments_overlay), ..PlatformWalletChangeSet::default() }; match persister.store(wallet_id, cs) { @@ -531,6 +578,9 @@ 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. + // Reported to the caller so the in-memory payment flips whose + // durable half this rejection discarded are rolled back. + diag.rejected_wallets.insert(wallet_id); if fault_and_freeze( &mut diag, offered_height, @@ -612,6 +662,20 @@ fn freeze_synced_height_if_faulted(core: &mut CoreChangeSet, persistence_faulted struct WalletBatch { core: CoreChangeSet, asset_locks: AssetLockChangeSet, + /// Sent DashPay payments a folded sweep failed, riding the SAME + /// `store()` as the sweep that proved them dead. This is the flip's + /// only durability: a sweep never re-emits once its round is durable, + /// so a separately persisted flip whose store failed was lost for + /// good — while here a rejection keeps the loser's record with it, + /// the wallet faults, the re-scan re-detects the conflict, and the + /// re-emitted sweep recomputes the flip (after + /// [`run_wallet_event_adapter`] rolls the in-memory half back). + /// Folded last-write-wins per `(owner, txid)`, matching + /// `PlatformWalletChangeSet::merge`'s overlay rule. + payments_overlay: std::collections::BTreeMap< + dpp::prelude::Identifier, + std::collections::BTreeMap, + >, } /// Rebuild missing tracked asset locks from the records an event @@ -702,6 +766,62 @@ async fn reconstruct_asset_locks_for_event( reconstruction::reconstruct_tracked_asset_locks(wallet_manager, &wallet_id, &candidates).await } +/// The payment half of a sweep: flip the losers' `Pending` sent DashPay +/// payments to `Failed` in memory and hand back the overlay + rollback the +/// drain loop stages into the sweep's own store round. Every other event +/// is a no-op. See [`WalletBatch::payments_overlay`] for why this rides +/// the round instead of the payment hooks' own store. +async fn swept_payment_flips_for_event( + wallet_manager: &Arc>>, + event: &WalletEvent, +) -> crate::wallet::identity::network::SweptPaymentFlips { + match event { + WalletEvent::TransactionsSwept { + wallet_id, txids, .. + } => { + crate::wallet::identity::network::flip_swept_sent_payments_for_store( + wallet_manager, + wallet_id, + txids, + ) + .await + } + _ => crate::wallet::identity::network::SweptPaymentFlips::default(), + } +} + +/// Stage one event's sweep-payment flips: the overlay folds into the +/// wallet's batch entry (last-write-wins per `(owner, txid)`, matching +/// `PlatformWalletChangeSet::merge`), the rollback into the drain's ledger. +fn fold_payment_flips( + entry: &mut WalletBatch, + payment_rollbacks: &mut BTreeMap< + WalletId, + Vec<( + dpp::prelude::Identifier, + String, + crate::wallet::identity::PaymentEntry, + )>, + >, + wallet_id: WalletId, + flips: crate::wallet::identity::network::SweptPaymentFlips, +) { + if flips.is_empty() { + return; + } + for (owner, rows) in flips.overlay { + entry + .payments_overlay + .entry(owner) + .or_default() + .extend(rows); + } + payment_rollbacks + .entry(wallet_id) + .or_default() + .extend(flips.rollback); +} + /// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable /// for atomic persistence. async fn build_core_changeset( @@ -2266,6 +2386,7 @@ mod tests { n_records: usize, n_asset_locks: usize, n_asset_locks_removed: usize, + n_payment_overlay_rows: usize, rejected: bool, } @@ -2331,6 +2452,11 @@ mod tests { .as_ref() .map(|a| a.removed.len()) .unwrap_or(0), + n_payment_overlay_rows: changeset + .dashpay_payments_overlay + .as_ref() + .map(|o| o.values().map(|rows| rows.len()).sum()) + .unwrap_or(0), rejected, }); if rejected { @@ -3219,6 +3345,175 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The sweep's payment flip is durable BECAUSE it rides the sweep's own + /// atomic store round: a sweep never re-emits once its round is + /// durable, so a separately persisted flip whose store failed was lost + /// for good. End to end through the real adapter loop: the flip's + /// overlay lands in the SAME `store()` as the sweep; a rejected round + /// rolls the in-memory flip back to the durable state (`Pending`), so + /// the replayed sweep finds the entry eligible and recomputes it; and + /// the replay's round carries the overlay again. + #[tokio::test] + async fn swept_payment_flip_rides_the_sweeps_round_and_rolls_back_on_rejection() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{funded_wallet_manager, NoopTestPersister}; + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let tx1 = dashcore::Txid::from([0xB1; 32]); + let tx2 = dashcore::Txid::from([0xB2; 32]); + + // Seed the identity and two Pending sent entries through a noop + // persister so the probe's observation stream carries ONLY the + // adapter's own stores. + let noop = WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &noop, + ) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + for (txid, amount) in [(tx1, 50_000u64), (tx2, 10_000u64)] { + managed + .record_dashpay_payment( + txid.to_string(), + PaymentEntry::new_sent(contact, amount, None), + &noop, + ) + .expect("record pending sent"); + } + } + + async fn status( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + owner: &dpp::prelude::Identifier, + txid: &str, + ) -> crate::wallet::identity::types::dashpay::payment::PaymentStatus { + let wm = wallet_manager.read().await; + wm.get_wallet_info(wallet_id) + .expect("info") + .identity_manager + .managed_identity(owner) + .expect("managed") + .dashpay() + .payments + .get(txid) + .expect("entry") + .status + } + + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + 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(), + ); + + let swept = |txid: dashcore::Txid| WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xC1; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + + // Leg 1: the flip's overlay rides the sweep's own store. + event_tx.send(swept(tx1)).expect("send sweep 1"); + let observed = obs_rx.recv().await.expect("sweep 1 store"); + assert!(!observed.rejected); + assert_eq!( + observed.n_payment_overlay_rows, 1, + "the Failed flip must ride the same store() as the sweep that proved it" + ); + assert_eq!( + status(&wallet_manager, &wallet_id, &owner, &tx1.to_string()).await, + PaymentStatus::Failed + ); + + // Leg 2: a rejected round rolls the in-memory flip back to the + // durable state, so the replayed sweep can recompute it. + persister.fail_next(wallet_id); + event_tx.send(swept(tx2)).expect("send sweep 2"); + let observed = obs_rx.recv().await.expect("sweep 2 store attempt"); + assert!(observed.rejected, "the probe rejects this round"); + assert_eq!( + observed.n_payment_overlay_rows, 1, + "the attempt carried the flip" + ); + // The rollback runs right after commit in the same drain iteration; + // bounded-poll memory rather than racing it. + let mut rolled_back = false; + for _ in 0..50 { + if status(&wallet_manager, &wallet_id, &owner, &tx2.to_string()).await + == PaymentStatus::Pending + { + rolled_back = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + rolled_back, + "a rejected round must roll the in-memory flip back to Pending — the \ + durable state, and the replayed sweep's eligibility" + ); + + // Leg 3: the replayed sweep (the re-scan re-emits it, because the + // rejected round kept the loser's record too) recomputes the flip + // and its round carries the overlay again. + event_tx.send(swept(tx2)).expect("send sweep 2 replay"); + let observed = obs_rx.recv().await.expect("replayed sweep store"); + assert!(!observed.rejected); + assert_eq!( + observed.n_payment_overlay_rows, 1, + "the replayed sweep must recompute the flip the rollback undid" + ); + assert_eq!( + status(&wallet_manager, &wallet_id, &owner, &tx2.to_string()).await, + PaymentStatus::Failed + ); + + 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 @@ -3553,6 +3848,7 @@ mod tests { super::WalletBatch { core: CoreChangeSet::default(), asset_locks, + payments_overlay: BTreeMap::new(), }, ); commit_batch( @@ -3611,6 +3907,7 @@ mod tests { WalletBatch { core, asset_locks: AssetLockChangeSet::default(), + payments_overlay: BTreeMap::new(), }, ); batch diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 6d48c7936c2..331d8211146 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -54,7 +54,8 @@ pub(crate) use payment_handler::run_dashpay_payment_hooks; mod payments; pub(crate) use payments::{ confirm_sent_dashpay_payment, confirm_sent_dashpay_payment_by_txid, - fail_swept_sent_dashpay_payments, record_incoming_dashpay_payments, + flip_swept_sent_payments_for_store, record_incoming_dashpay_payments, + rollback_swept_payment_flips, SweptPaymentFlips, }; mod profile; pub(crate) mod sdk_writer; 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 b1283078da0..28f38c7221e 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 @@ -252,12 +252,14 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { inserted, updated, .. } => inserted.iter().chain(updated.iter()).collect(), // `TransactionsSwept` carries txids, not records: the wallet has - // already dropped the records these name. Like - // `TransactionInstantLocked`, it is routed by txid instead — the - // sweep hook in `run_dashpay_payment_hooks` fails the matching - // `Pending` sent payments, since a swept transaction can never - // confirm and its record (the last thing reconciliation could - // have resolved the entry from) is gone. + // 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, and the + // wallet-event adapter owns that (see + // `payments::SweptPaymentFlips`). Routing it here as well would + // race a second, separately persisted write against that round. WalletEvent::TransactionInstantLocked { .. } | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } @@ -282,24 +284,25 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { WalletEvent::BlockProcessed { inserted, updated, .. } => !inserted.is_empty() || !updated.is_empty(), - // Routed by txid, like `TransactionInstantLocked` above: each - // swept txid can name a `Pending` sent payment that must fail - // (the transaction can never confirm). An empty sweep has no - // payment work. - WalletEvent::TransactionsSwept { txids, .. } => !txids.is_empty(), // No records to route (see `dashpay_payment_records`), so a task // here would take and release the wallet-manager write lock for - // nothing. - WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => false, + // nothing. The sweep's payment consequence rides the wallet-event + // adapter's own store round instead — see `dashpay_payment_records`. + WalletEvent::TransactionsSwept { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => false, } } /// Run the DashPay payment hooks for `event`: record any incoming DashPay -/// payment, then resolve a matching sent payment — `Pending` to -/// `Confirmed` once its transaction reaches finality (mined or -/// InstantSend-locked), `Pending` to `Failed` when a sweep proves it never -/// can. All paths are idempotent per txid, so re-detections, re-emitted -/// sweeps, and repeated block-processing rounds converge without +/// payment, then advance a matching sent payment from `Pending` (or a +/// sweep-written `Failed` — the reinstatement correction) to `Confirmed` +/// once its transaction reaches finality (mined or InstantSend-locked). +/// The opposite terminal — `Failed`, when a sweep proves the transaction +/// never can confirm — is applied by the wallet-event adapter on the +/// sweep's own atomic store round, not here (see +/// `payments::SweptPaymentFlips`). 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>>, @@ -320,20 +323,6 @@ pub(crate) async fn run_dashpay_payment_hooks( .await; return; } - // A sweep also carries txids rather than records, and drives the - // opposite terminal: each swept transaction can never confirm, so a - // matching `Pending` sent payment fails instead of sitting `Pending` - // forever. - if let WalletEvent::TransactionsSwept { txids, .. } = event { - crate::wallet::identity::network::fail_swept_sent_dashpay_payments( - wallet_manager, - wallet_id, - persister, - txids, - ) - .await; - return; - } for record in dashpay_payment_records(event) { crate::wallet::identity::network::record_incoming_dashpay_payments( wallet_manager, @@ -489,25 +478,25 @@ mod tests { assert!(drives_payment_hooks(&event)); } - /// `TransactionsSwept` carries no record but DOES drive the payment - /// hooks — each swept txid can name a `Pending` sent payment that must - /// fail, since a swept transaction can never confirm. An empty sweep - /// has no payment work and must not spawn. + /// `TransactionsSwept` must NOT drive the payment hooks: its payment + /// consequence — failing the losers' `Pending` sent payments — rides + /// the wallet-event adapter's own atomic store round (see + /// `payments::SweptPaymentFlips`), 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_drives_payment_hooks_without_a_record() { - let swept = |txids: Vec| WalletEvent::TransactionsSwept { + fn transactions_swept_does_not_drive_payment_hooks() { + let event = WalletEvent::TransactionsSwept { wallet_id: [0u8; 32], - txids, + txids: vec![dashcore::Txid::from([0x21; 32])], superseded_by: dashcore::Txid::from([0x22; 32]), released_outpoints: Vec::new(), balance: WalletCoreBalance::default(), account_balances: std::collections::BTreeMap::new(), }; - let event = swept(vec![dashcore::Txid::from([0x21; 32])]); - // No record to route, but the event must still drive the hooks. assert!(dashpay_payment_records(&event).is_empty()); - assert!(drives_payment_hooks(&event)); - assert!(!drives_payment_hooks(&swept(Vec::new()))); + assert!(!drives_payment_hooks(&event)); } /// A `BlockProcessed` that changed no records (syncing past an empty 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 13ffb0a5592..ca5f6ed28b7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1008,99 +1008,166 @@ async fn confirm_sent_payment_by_txid( } } -/// Mark the `Pending` `Sent` [`PaymentEntry`]s of swept transactions -/// `Failed`, for a -/// [`WalletEvent::TransactionsSwept`](key_wallet_manager::WalletEvent::TransactionsSwept). +/// The in-memory `Failed` flips for a sweep's losers, packaged for the +/// wallet-event adapter to ride on the sweep's OWN store round. /// /// A swept transaction was provably beaten to one of its inputs, so it can /// never confirm — exactly the "transaction was dropped" case /// [`PaymentStatus::Failed`](crate::wallet::identity::types::dashpay::payment::PaymentStatus::Failed) -/// documents, and this PR's sweep also deletes the record that was the last -/// thing `reconcile_sent_payments_from_tx_history` could have resolved the -/// entry from. Without a terminal transition here the sender's payment sat -/// `Pending` forever. `Confirmed` entries are never demoted — the sweep of -/// an already-confirmed payment's txid would be stale by definition — and -/// the one way the verdict reverses (chainlocked reinstatement) re-emits -/// the record confirmed, which `confirm_sent_payment_by_txid` accepts from +/// documents — and the sweep deletes the record that was the last thing +/// `reconcile_sent_payments_from_tx_history` could have resolved the entry +/// from. Durability is why this is a changeset payload rather than a hook +/// that persists on its own: a sweep never re-emits once its round is +/// durable, so a separately persisted flip that failed its store was lost +/// for good (a bounded retry only narrowed the window). Riding the same +/// atomic `store()` as the core sweep gives the flip the round's own +/// fail-closed machinery — a rejection keeps the loser's record too, the +/// wallet faults, and the re-scan re-detects the conflict and re-emits the +/// sweep, recomputing the flip. +/// +/// `Confirmed` entries are never demoted (the shared transition table), +/// and the one way the verdict reverses — a chainlocked reinstatement — +/// re-emits the record confirmed, which the confirm path accepts from /// `Failed`. -pub(crate) async fn fail_swept_sent_dashpay_payments( +#[derive(Debug, Default)] +pub(crate) struct SweptPaymentFlips { + /// `PlatformWalletChangeSet::dashpay_payments_overlay` payload — the + /// flipped rows, exactly as memory now holds them. + pub overlay: std::collections::BTreeMap< + Identifier, + std::collections::BTreeMap, + >, + /// What to restore if the round is rejected: `(owner, txid, previous + /// entry)` per flip, applied by + /// [`rollback_swept_payment_flips`] so memory returns to the durable + /// state and the replayed sweep finds the entries `Pending` again. + pub rollback: Vec<(Identifier, String, crate::wallet::identity::PaymentEntry)>, +} + +impl SweptPaymentFlips { + pub(crate) fn is_empty(&self) -> bool { + self.overlay.is_empty() + } +} + +/// Flip the `Pending` `Sent` entries under `txids` to `Failed` in memory +/// and return the store payload + rollback. Persists NOTHING itself — the +/// caller owns the store round (see [`SweptPaymentFlips`]). +pub(crate) async fn flip_swept_sent_payments_for_store( wallet_manager: &Arc>>, wallet_id: &WalletId, - persister: &crate::wallet::persister::WalletPersister, txids: &[dashcore::Txid], -) { - use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - // One store rejection must not strand the payment: a sweep is - // one-shot. Nothing re-emits it, and the same sweep deletes the - // loser's record — the last thing reconciliation could have resolved - // the entry from — so unlike a confirmation (re-driven by every later - // signal for its transaction) a failed flip here has no natural - // retry. Three brief in-place attempts ride out a transient backend - // error; `record_dashpay_payment` rolls the in-memory overwrite back - // with the failed store, so each attempt finds the entry `Pending` - // again and the flip stays idempotent. - const PERSIST_ATTEMPTS: u32 = 3; +) -> SweptPaymentFlips { + use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; + + let mut flips = SweptPaymentFlips::default(); + if txids.is_empty() { + return flips; + } + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return flips; + }; for txid in txids { - let txid_hex = txid.to_string(); - for attempt in 1..=PERSIST_ATTEMPTS { - match resolve_sent_payment_by_txid( - wallet_manager, - wallet_id, - persister, - &txid_hex, - PaymentStatus::Failed, - ) - .await - { - Ok(()) => break, - Err(e) if attempt < PERSIST_ATTEMPTS => { - tracing::warn!( - txid = %txid_hex, - error = %e, - attempt, - "Sweep payment-failure persist rejected; retrying" - ); - tokio::time::sleep(std::time::Duration::from_millis(100 * u64::from(attempt))) - .await; - } - Err(e) => { - // The residual after the budget: the entry stays - // `Pending` durably, and the only remaining exits are a - // chainlocked reinstatement confirming it or a manual - // resolution — named here so the log is the audit - // trail, not the recovery. - tracing::error!( - txid = %txid_hex, - error = %e, - "Sweep payment-failure persist rejected on every attempt; \ - the sent payment stays Pending durably (no signal re-emits \ - a sweep)" - ); + let key = txid.to_string(); + 'owners: for owner in info.identity_manager.identity_ids() { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + let previous = match managed.dashpay().payments.get(&key) { + Some(entry) + if entry.direction == PaymentDirection::Sent + && sent_status_transition_allowed(entry.status, PaymentStatus::Failed) => + { + entry.clone() } - } + _ => continue, + }; + let mut updated = previous.clone(); + updated.status = PaymentStatus::Failed; + tracing::info!( + owner = %owner, + txid = %key, + "Failing sent DashPay payment on its sweep's own store round" + ); + managed + .dashpay_payments_mut() + .insert(key.clone(), updated.clone()); + flips + .overlay + .entry(owner) + .or_default() + .insert(key.clone(), updated); + flips.rollback.push((owner, key.clone(), previous)); + // txid is unique — only one identity can hold this entry. + break 'owners; } } + flips +} + +/// Undo [`flip_swept_sent_payments_for_store`]'s in-memory flips after the +/// round they rode was rejected. Memory returns to the durable state +/// (`Pending`, matching the store the rejection left untouched), which is +/// what lets the replayed sweep — re-emitted by the re-scan, because the +/// rejected round kept the loser's record too — find the entries eligible +/// and recompute the flip. Without this, memory would read `Failed` ahead +/// of the store, the replay's eligibility check would skip the entries, +/// and the store would never learn. +pub(crate) async fn rollback_swept_payment_flips( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + rollback: Vec<(Identifier, String, crate::wallet::identity::PaymentEntry)>, +) { + if rollback.is_empty() { + return; + } + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return; + }; + for (owner, txid, previous) in rollback { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + managed.dashpay_payments_mut().insert(txid, previous); + } +} + +/// The sent-payment state machine's one transition table, shared by every +/// writer so the confirm path and the sweep flip can never drift: +/// `Confirmed` is terminal, `Pending` advances to either verdict, and +/// `Failed` advances only to `Confirmed` — the chainlocked-reinstatement +/// correction, whose record re-arrives confirmed. +pub(crate) fn sent_status_transition_allowed( + from: crate::wallet::identity::types::dashpay::payment::PaymentStatus, + to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, +) -> bool { + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + matches!( + (from, to), + (PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed) + ) } -/// Shared flip for the two resolutions above: move the `Sent` -/// [`PaymentEntry`] under `txid` to `to`, in place, preserving -/// amount/memo/counterparty. +/// The confirm path's flip: move the `Sent` [`PaymentEntry`] under `txid` +/// to `to`, in place, preserving amount/memo/counterparty, and persist it +/// through its own store round. (The `Failed` flip does NOT come through +/// here — it rides the sweep's atomic round; see [`SweptPaymentFlips`].) /// -/// `Confirmed` is terminal — nothing demotes it. `Pending` advances to -/// either verdict, and `Failed` advances only to `Confirmed` (the -/// reinstatement correction); every other combination is a no-op, which is -/// what keeps re-detections and re-emitted sweeps idempotent and skipping -/// the persistence round. Separated from the event glue so the state -/// machine is unit-testable without constructing a full +/// Eligibility is [`sent_status_transition_allowed`], shared with the +/// sweep flip so the state machine cannot drift; every ineligible +/// combination is a no-op, which is what keeps re-detections idempotent +/// and skipping the persistence round. Separated from the event glue so +/// the transition is unit-testable without constructing a full /// `TransactionRecord`. /// /// A no-op resolution (no entry, not `Sent`, not eligible) is `Ok(())`; /// `Err` means the flip was found, attempted, and its store rejected — the /// in-memory overwrite has already been rolled back /// (`record_dashpay_payment`'s contract), so the caller may retry or -/// accept per its own signal model. Not swallowed here, because the two -/// callers genuinely differ: a confirmation is re-driven by every later -/// signal for its transaction, a sweep never re-emits. +/// accept per its own signal model (a confirmation is re-driven by every +/// later signal for its transaction). async fn resolve_sent_payment_by_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -1108,7 +1175,7 @@ async fn resolve_sent_payment_by_txid( txid: &str, to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, ) -> Result<(), crate::changeset::PersistenceError> { - use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; + use crate::wallet::identity::types::dashpay::payment::PaymentDirection; let mut wm = wallet_manager.write().await; let Some(info) = wm.get_wallet_info_mut(wallet_id) else { @@ -1123,11 +1190,7 @@ async fn resolve_sent_payment_by_txid( }; let resolved = match managed.dashpay().payments.get(txid) { Some(entry) if entry.direction == PaymentDirection::Sent => { - let eligible = matches!( - (entry.status, to), - (PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed) - ); - if !eligible { + if !sent_status_transition_allowed(entry.status, to) { continue; } let mut updated = entry.clone(); @@ -3023,15 +3086,15 @@ mod tests { /// can never confirm, and the same sweep deletes the record that was /// the last thing reconciliation could have resolved the entry from — /// so without this transition the sender's payment sat `Pending` - /// forever with no terminal state. Also pins the two guard rails: a + /// forever with no terminal state. Driven through the flip the + /// wallet-event adapter stages onto the sweep's own store round. Also + /// pins the guard rails: a re-emitted sweep is an idempotent no-op, a /// `Confirmed` entry is never demoted by a stale sweep, and the one /// legitimate reversal — a chainlocked reinstatement re-emitting the /// record confirmed — advances `Failed` to `Confirmed`. #[tokio::test] async fn swept_sent_payment_fails_and_a_reinstating_confirmation_recovers_it() { use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use key_wallet::WalletCoreBalance; - use key_wallet_manager::WalletEvent; let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); @@ -3077,28 +3140,31 @@ mod tests { .status } - let swept_event = || WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid], - superseded_by: dashcore::Txid::from([0xCD; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: std::collections::BTreeMap::new(), - }; - // The sweep fails the pending entry, through the real hook dispatch - // (that a sweep spawns the hooks at all is pinned in - // `payment_handler`'s routing tests). - super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) - .await; + // The sweep's flip: memory moves to Failed and the overlay carries + // exactly the flipped row for the sweep's own store round. + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; + assert_eq!( + flips.overlay[&owner][&txid_key].status, + PaymentStatus::Failed, + "the overlay must carry the Failed row for the sweep's own round" + ); + assert_eq!(flips.rollback.len(), 1); assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, PaymentStatus::Failed, "a swept transaction can never confirm — its sent payment must fail" ); - // Re-emitted sweep: idempotent no-op. - super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) - .await; + // Re-emitted sweep: idempotent no-op (nothing eligible, empty flip). + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; + assert!( + flips.is_empty(), + "a re-emitted sweep must find nothing to flip" + ); assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, PaymentStatus::Failed @@ -3114,8 +3180,10 @@ mod tests { ); // And a stale sweep arriving after confirmation never demotes it. - super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) - .await; + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; + assert!(flips.is_empty()); assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, PaymentStatus::Confirmed, @@ -3123,19 +3191,17 @@ mod tests { ); } - /// A payment-store rejection alongside a successful core sweep must not - /// strand the sent payment `Pending`. The core sweep persists through - /// its own store round and deletes the loser's record, so unlike a - /// confirmation — re-driven by every later signal for its transaction — - /// nothing ever re-emits the failure signal; the sweep hook's bounded - /// in-place retry is the only recovery. Also pins the honest residual: - /// a rejection outlasting the whole budget leaves the entry `Pending` - /// rather than lying about durability. + /// The rejected-round contract: rolling the flip back returns memory to + /// the durable state (`Pending`), which is exactly what lets the + /// replayed sweep — re-emitted by the re-scan, because the rejected + /// round kept the loser's record too — find the entry eligible and + /// recompute the flip. Without the rollback, memory would read `Failed` + /// ahead of the store and the replay's eligibility check would skip the + /// entry forever. The adapter-level rejection wiring is pinned + /// end to end in `core_bridge`. #[tokio::test] - async fn a_store_rejection_does_not_strand_a_swept_payment_pending() { + async fn a_rolled_back_flip_is_recomputed_by_the_replayed_sweep() { use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use key_wallet::WalletCoreBalance; - use key_wallet_manager::WalletEvent; let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); @@ -3157,86 +3223,50 @@ mod tests { .expect("managed") .record_dashpay_payment( txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, Some("dinner".into())), + PaymentEntry::new_sent(contact, 10_000, None), &p, ) .expect("record pending sent"); } - async fn status( - iw: &crate::wallet::identity::IdentityWallet, - wallet_id: &WalletId, - owner: &Identifier, - txid: &str, - ) -> PaymentStatus { + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; + assert!(!flips.is_empty()); + super::rollback_swept_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; + + { let wm = iw.wallet_manager.read().await; - let info = wm.get_wallet_info(wallet_id).expect("info"); - info.identity_manager - .managed_identity(owner) + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let entry = info + .identity_manager + .managed_identity(&owner) .unwrap() .dashpay() .payments - .get(txid) + .get(&txid_key) .expect("entry") - .status + .clone(); + assert_eq!( + entry.status, + PaymentStatus::Pending, + "the rollback must return memory to the durable state" + ); + assert_eq!( + entry.amount_duffs, 10_000, + "the previous entry is restored whole" + ); } - let swept_event = || WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid], - superseded_by: dashcore::Txid::from([0xCE; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: std::collections::BTreeMap::new(), - }; - - // One transient rejection: the retry must land the flip anyway. The - // in-memory status only reads Failed when a store SUCCEEDED - // (record_dashpay_payment rolls back on failure), so this assertion - // proves durability, not just the overlay. - *persister.fail_next_stores.lock().unwrap() = 1; - super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event()) - .await; + // The replayed sweep finds the entry eligible again and recomputes + // the flip — the durability loop closes. + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, + flips.overlay[&owner][&txid_key].status, PaymentStatus::Failed, - "one store rejection must not strand the swept payment Pending" - ); - - // The residual: a rejection outlasting the whole budget leaves the - // entry Pending — never an in-memory Failed the store did not - // accept. - let txid2 = dashcore::Txid::from([0xAD; 32]); - let txid2_key = txid2.to_string(); - { - let mut wm = iw.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid2_key.clone(), - PaymentEntry::new_sent(contact, 10_000, None), - &p, - ) - .expect("record second pending sent"); - } - *persister.fail_next_stores.lock().unwrap() = usize::MAX; - let swept_event2 = WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid2], - superseded_by: dashcore::Txid::from([0xCF; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: std::collections::BTreeMap::new(), - }; - super::super::run_dashpay_payment_hooks(&iw.wallet_manager, &wallet_id, &p, &swept_event2) - .await; - *persister.fail_next_stores.lock().unwrap() = 0; - assert_eq!( - status(iw, &wallet_id, &owner, &txid2_key).await, - PaymentStatus::Pending, - "an exhausted budget leaves Pending in memory — never an unpersisted Failed" + "the replayed sweep must recompute the flip the rollback undid" ); } From 78bb2261f877209f657f1b1a7e5d36c2e3004153 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:26:59 +0300 Subject: [PATCH 071/102] fix(platform-wallet): retract a reinstated txid's payment flip from the same fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f99789ba4d added the payment overlay as a new sweep-derived channel beside core.sweeps and did not give it the retraction the others have — the third recurrence of one shape: CoreChangeSet::merge retracts reinstated txids from folded sweeps, AssetLockChangeSet::merge cancels the folded tombstone (d3cedc0013), and each newly added parallel channel missed its counterpart. A buffered [TransactionsSwept(X), BlockProcessed(chainlocked X)] fold therefore committed X's reinstated record beside a stale Failed overlay row, and because the payment hooks confirm X on their own task, that row could overwrite a Confirmed the hooks had already persisted. The rollback was also unconditional: a concurrent confirmation got clobbered back to the captured Pending, bypassing the very transition table added so writers could not drift. State the invariant once and enforce it where each channel folds: a merged changeset must never carry a sweep-derived assertion about a txid the same fold reinstates. The batch-level channels (overlay + rollback ledger) get retract_reinstated_payment_flips, keyed on the folding event's own core.records — the identical set CoreChangeSet::merge keys on, taken from the same projection, so the two cannot diverge — which drops the staged row, drops its ledger entry, and undoes the in-memory flip. The undo (both here and on rejection) is now guarded to revert only the sweep's own still-standing Failed write, per the table's terminal rule. The function's doc enumerates every sweep-derived channel and its retraction so the next channel cannot miss it. Regressions: the coalesced fold must store the reinstated record with zero overlay rows and Pending in memory (fails with the retraction disabled), and a late undo must not clobber a concurrently confirmed entry (fails with the guard removed). --- .../src/changeset/core_bridge.rs | 290 ++++++++++++++++++ .../src/wallet/identity/network/payments.rs | 106 ++++++- 2 files changed, 387 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index f234a28e25c..6e5415296f3 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -348,6 +348,14 @@ async fn run_wallet_event_adapter

( let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; let flips = swept_payment_flips_for_event(&wallet_manager, &event).await; let entry = batch.entry(wallet_id).or_default(); + retract_reinstated_payment_flips( + &wallet_manager, + entry, + &mut payment_rollbacks, + wallet_id, + &core.records, + ) + .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); @@ -366,6 +374,14 @@ async fn run_wallet_event_adapter

( reconstruct_asset_locks_for_event(&wallet_manager, &event).await; let flips = swept_payment_flips_for_event(&wallet_manager, &event).await; let entry = batch.entry(wallet_id).or_default(); + retract_reinstated_payment_flips( + &wallet_manager, + entry, + &mut payment_rollbacks, + wallet_id, + &core.records, + ) + .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); @@ -790,9 +806,98 @@ async fn swept_payment_flips_for_event( } } +/// The batch-level half of the reinstatement invariant: **a merged +/// changeset must never carry a sweep-derived assertion about a txid the +/// same fold reinstates.** Each sweep-derived channel enforces it where +/// that channel folds: +/// +/// - `core.sweeps.txids` — `CoreChangeSet::merge` retracts reinstated +/// txids from folded batches; +/// - `core.sweeps.released_outpoints` — deliberately NOT retracted; every +/// backend withholds an outpoint a surviving record claims, so the +/// reinstated transaction's own entries are inert (documented at the +/// merge); +/// - `asset_locks.removed` — `AssetLockChangeSet::merge` cancels a folded +/// sweep tombstone when the reinstating reconstruction upsert lands; +/// - `payments_overlay` + its rollback ledger — live at BATCH level, not +/// inside any sub-changeset's `Merge`, so their retraction lives here. +/// Any future sweep-derived channel carried on [`WalletBatch`] must get +/// its retraction in this function too. +/// +/// `reinstated` is exactly `core.records` of the event being folded — the +/// same set `CoreChangeSet::merge` keys its own retraction on, taken from +/// the same projection, so the two can never diverge. Without this, a +/// buffered `[TransactionsSwept(X), BlockProcessed(chainlocked X)]` fold +/// would commit X's reinstated record beside a stale `Failed` overlay row +/// — and because the payment hooks confirm X on their own task, that row +/// could overwrite a `Confirmed` the hooks had already persisted. +/// +/// Three moves per reinstated txid, all before the overlay can reach a +/// store: drop the staged overlay row, drop its rollback-ledger entry +/// (a later rejection of this round must not replay the dead undo), and +/// undo the in-memory flip through the guarded +/// [`rollback_swept_payment_flips`] — which leaves the entry alone if the +/// hooks already advanced it to `Confirmed`, the table's terminal. +async fn retract_reinstated_payment_flips( + wallet_manager: &Arc>>, + entry: &mut WalletBatch, + payment_rollbacks: &mut BTreeMap< + WalletId, + Vec<( + dpp::prelude::Identifier, + String, + crate::wallet::identity::PaymentEntry, + )>, + >, + wallet_id: WalletId, + records: &[TransactionRecord], +) { + if records.is_empty() { + return; + } + let ledger_live = payment_rollbacks + .get(&wallet_id) + .is_some_and(|ledger| !ledger.is_empty()); + if entry.payments_overlay.is_empty() && !ledger_live { + return; + } + let reinstated: std::collections::HashSet = records + .iter() + .map(|record| record.txid.to_string()) + .collect(); + + for rows in entry.payments_overlay.values_mut() { + rows.retain(|txid, _| !reinstated.contains(txid)); + } + entry.payments_overlay.retain(|_, rows| !rows.is_empty()); + + if let Some(ledger) = payment_rollbacks.get_mut(&wallet_id) { + let mut undo = Vec::new(); + ledger.retain(|(owner, txid, previous)| { + if reinstated.contains(txid) { + undo.push((*owner, txid.clone(), previous.clone())); + false + } else { + true + } + }); + if !undo.is_empty() { + crate::wallet::identity::network::rollback_swept_payment_flips( + wallet_manager, + &wallet_id, + undo, + ) + .await; + } + } +} + /// Stage one event's sweep-payment flips: the overlay folds into the /// wallet's batch entry (last-write-wins per `(owner, txid)`, matching /// `PlatformWalletChangeSet::merge`), the rollback into the drain's ledger. +/// The inverse — a later event in the same fold reinstating a flipped +/// txid — is [`retract_reinstated_payment_flips`]' job, which the drain +/// runs for every record-bearing event before merging it. fn fold_payment_flips( entry: &mut WalletBatch, payment_rollbacks: &mut BTreeMap< @@ -3514,6 +3619,191 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The payment channel's half of the reinstatement invariant, end to + /// end: a buffered `[TransactionsSwept(X), BlockProcessed(chainlocked + /// X)]` pair folds into ONE store round, and that round must carry X's + /// reinstated record with NO sweep-derived `Failed` overlay row beside + /// it — `CoreChangeSet::merge` retracts the sweep, and + /// `retract_reinstated_payment_flips` must retract the payment flip + /// keyed on the very same record set. The in-memory flip is undone + /// with it, so the entry reads `Pending` for the confirm path the + /// reinstated record drives (the payment hooks run on their own task; + /// this harness runs only the adapter). Without the retraction the + /// fold committed a stale `Failed` row that could overwrite a + /// `Confirmed` the hooks had already persisted. + /// + /// Both events are queued BEFORE the adapter task spawns, which is + /// what makes the single-fold deterministic: the first `recv` takes + /// the sweep and the backlog `try_recv` folds the record. + #[tokio::test] + async fn a_reinstating_record_in_the_same_fold_retracts_the_payment_flip() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + 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 super::spawn_wallet_event_adapter; + use crate::test_support::{funded_wallet_manager, NoopTestPersister}; + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + // X: the transaction that is swept and then returns chainlocked in + // the same buffered fold. + let tx = dashcore::Transaction { + version: 1, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint::new(dashcore::Txid::from([0xD0; 32]), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + { + use dashcore::hashes::Hash as _; + dashcore::BlockHash::all_zeros() + }, + 1_650_000_000, + )), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + let txid = record.txid; + let txid_key = txid.to_string(); + + let noop = WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &noop, + ) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, None), + &noop, + ) + .expect("record pending sent"); + } + + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + let (event_tx, event_rx) = unbounded_channel(); + + // Queue BOTH events before the adapter runs, so they land in one + // fold: the sweep of X, then the chainlocked record reinstating X. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xD1; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + 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 reinstating record"); + + 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(), + ); + + let observed = obs_rx.recv().await.expect("the folded store"); + assert!(!observed.rejected); + assert_eq!( + observed.n_records, 1, + "the reinstated record must ride the fold's store" + ); + assert_eq!( + observed.n_payment_overlay_rows, 0, + "a merged changeset must never carry a sweep-derived assertion about a \ + txid the same fold reinstates" + ); + + { + let wm = wallet_manager.read().await; + let status = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay() + .payments + .get(&txid_key) + .expect("entry") + .status; + assert_eq!( + status, + PaymentStatus::Pending, + "the retraction must undo the in-memory flip so the reinstated \ + record's own confirm path decides the entry" + ); + } + + 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 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 ca5f6ed28b7..52b82fb4fba 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1106,19 +1106,31 @@ pub(crate) async fn flip_swept_sent_payments_for_store( flips } -/// Undo [`flip_swept_sent_payments_for_store`]'s in-memory flips after the -/// round they rode was rejected. Memory returns to the durable state -/// (`Pending`, matching the store the rejection left untouched), which is -/// what lets the replayed sweep — re-emitted by the re-scan, because the -/// rejected round kept the loser's record too — find the entries eligible -/// and recompute the flip. Without this, memory would read `Failed` ahead -/// of the store, the replay's eligibility check would skip the entries, -/// and the store would never learn. +/// Undo [`flip_swept_sent_payments_for_store`]'s in-memory flips — after +/// the round they rode was rejected, or after the same fold reinstated +/// their transaction and the adapter retracted the staged overlay row. +/// Memory returns to the durable state (`Pending`, matching the store the +/// rejection left untouched), which is what lets the replayed sweep — +/// re-emitted by the re-scan, because the rejected round kept the loser's +/// record too — find the entries eligible and recompute the flip. Without +/// this, memory would read `Failed` ahead of the store, the replay's +/// eligibility check would skip the entries, and the store would never +/// learn. +/// +/// An undo is NOT a forward transition, so it does not go through +/// [`sent_status_transition_allowed`] — but it obeys the same authority: +/// it may only revert the sweep flip's own still-standing `Failed` write. +/// An entry that moved on — the payment hooks confirming it concurrently, +/// which the table permits from `Failed` — outranks the undo; restoring +/// the captured `Pending` over a `Confirmed` the store may already hold +/// would demote the terminal state the table exists to protect. pub(crate) async fn rollback_swept_payment_flips( wallet_manager: &Arc>>, wallet_id: &WalletId, rollback: Vec<(Identifier, String, crate::wallet::identity::PaymentEntry)>, ) { + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + if rollback.is_empty() { return; } @@ -1130,7 +1142,13 @@ pub(crate) async fn rollback_swept_payment_flips( let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { continue; }; - managed.dashpay_payments_mut().insert(txid, previous); + let payments = managed.dashpay_payments_mut(); + match payments.get(&txid) { + Some(current) if current.status == PaymentStatus::Failed => { + payments.insert(txid, previous); + } + _ => {} + } } } @@ -3270,6 +3288,76 @@ mod tests { ); } + /// The rollback may only revert the sweep flip's own still-standing + /// `Failed` write. The payment hooks run on their own task, so a + /// confirmation can land between the flip and its undo (a rejected + /// round, or a same-fold reinstatement) — and `Confirmed` is the + /// terminal `sent_status_transition_allowed` protects. An + /// unconditional restore would clobber it back to the captured + /// `Pending`, demoting a status the store may already hold. + #[tokio::test] + async fn rollback_does_not_clobber_a_concurrently_confirmed_entry() { + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xAE; 32]); + let txid_key = txid.to_string(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, None), + &p, + ) + .expect("record pending sent"); + } + + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; + assert!(!flips.is_empty()); + + // The reinstated transaction's confirmation races in before the + // undo — Failed → Confirmed, the table's permitted correction. + super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid_key).await; + + // The undo arrives late (rejected round or same-fold retraction); + // it must find its own write gone and leave the terminal alone. + super::rollback_swept_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; + + let wm = iw.wallet_manager.read().await; + let status = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay() + .payments + .get(&txid_key) + .expect("entry") + .status; + assert_eq!( + status, + PaymentStatus::Confirmed, + "an undo may only revert the sweep's own still-standing Failed write — \ + never a concurrently confirmed terminal" + ); + } + /// A sent payment confirmed by a block must flip `Pending → Confirmed`. /// /// The wallet sees its *own* broadcast in the mempool first From 45c70ec21c16a4d26b08604f6cb9f689439fc4ba Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:07:21 +0300 Subject: [PATCH 072/102] fix(platform-wallet): stop stale reconcile evidence terminally confirming a swept payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widened (Failed, Confirmed) eligibility could not tell reinstatement evidence from evidence read before the sweep. reconcile_sent_payments snapshots only Pending entries, so any Failed -> Confirmed flip it ever performed was by construction the stale-evidence race: it read the payment's persisted record, the sweep flipped the entry to Failed mid-flight (hooks and the record-deleting round are unordered tasks, so the window is the whole snapshot-to-confirm span), and the reconciler landed the dead payment terminally Confirmed — durably, since nothing demotes Confirmed and re-emitted sweeps are ineligible. The confirm path's own comment still claimed the write-lock re-check made a racing sweep safe; the widening had broken exactly that. Thread a from-state evidence declaration into the resolver, keeping the one transition table: eligibility is now the table INTERSECTED with what the caller's evidence can speak for. Live wrappers declare {Pending, Failed} — upstream never re-emits a record for a txid it still considers dead, so a live signal for a Failed entry is authoritative reinstatement — while the reconciler declares Pending-only, turning its stale read into the no-op the old code's comment promised. The paired recovery gap (a chainlocked reinstatement whose one live persist fails leaves a durable Failed) is documented at the confirm path rather than papered over: the reconciler cannot cover it safely for the same evidence-ordering reason, and the safe shape is adapter-owned, ordered against the sweep round. The regression freezes the race at its worst point — entry flipped to Failed between snapshot and confirm — and fails with the reconciler's evidence reverted to the pre-fix live eligibility; the live-evidence recovery leg still passes. --- .../src/wallet/identity/network/payments.rs | 255 ++++++++++++++++-- 1 file changed, 227 insertions(+), 28 deletions(-) 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 52b82fb4fba..5a2a43a2dbe 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -719,14 +719,25 @@ impl DashPayView<'_, B> { if sent_payment_status_for_record(&record) != PaymentStatus::Confirmed { continue; } - // Flip in place via the shared confirm path (re-checks the - // entry is still a `Pending` `Sent` under its own write lock, - // so it stays correct if a live event raced this sweep). + // Flip in place via the shared confirm path, declaring what + // this sweep's evidence can speak for: the record was read + // AFTER a snapshot that saw the entry `Pending`, so it proves + // nothing about an entry that has since moved. In particular a + // sweep hook can flip the entry to `Failed` anywhere in the + // snapshot→confirm span (hooks are unordered spawned tasks, and + // the sweep deletes the record on a third task) — this pass's + // record read may predate that verdict, and confirming from it + // would land a dead payment terminally `Confirmed`. The + // resolver re-checks under its own write lock against exactly + // this evidence set, so an entry no longer `Pending` is left + // for a caller whose evidence postdates the flip (the live + // reinstatement hook). confirm_sent_payment_by_txid( &self.wallet_manager, &self.wallet_id, &self.persister, &txid_str, + RECONCILE_CONFIRM_EVIDENCE, ) .await; confirmed += 1; @@ -949,6 +960,7 @@ pub(crate) async fn confirm_sent_dashpay_payment( wallet_id, persister, &record.txid.to_string(), + LIVE_CONFIRM_EVIDENCE, ) .await; } @@ -966,38 +978,83 @@ pub(crate) async fn confirm_sent_dashpay_payment_by_txid( persister: &crate::wallet::persister::WalletPersister, txid: &dashcore::Txid, ) { - confirm_sent_payment_by_txid(wallet_manager, wallet_id, persister, &txid.to_string()).await; + confirm_sent_payment_by_txid( + wallet_manager, + wallet_id, + persister, + &txid.to_string(), + LIVE_CONFIRM_EVIDENCE, + ) + .await; } -/// Flip the `Pending` `Sent` [`PaymentEntry`] under `txid` (if any) to -/// `Confirmed`, in place, preserving amount/memo/counterparty. +/// What a confirm caller's evidence can speak for — the from-states it is +/// entitled to advance. The transition table +/// ([`sent_status_transition_allowed`]) says which moves the machine +/// permits; this says which of them a given caller's evidence actually +/// supports, and the resolver requires both. The distinction exists +/// because `(Failed, Confirmed)` is only ever correct when the evidence +/// POSTDATES the sweep's verdict: +/// +/// - **Live evidence** — a wallet event carrying (or naming) the +/// transaction. Upstream never re-emits a record for a txid it still +/// considers dead, so a live record/IS-lock signal for a `Failed` entry +/// is authoritative reinstatement and may correct the verdict. +/// - **Reconcile evidence** — a persisted-record read made after a +/// snapshot that saw the entry `Pending`. If the entry has since moved +/// to `Failed`, the read raced the sweep (which deletes the record on +/// another task) and may predate it — confirming from it would land a +/// dead payment terminally `Confirmed`. `Pending`-only, by +/// construction. +const LIVE_CONFIRM_EVIDENCE: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus] = &[ + crate::wallet::identity::types::dashpay::payment::PaymentStatus::Pending, + crate::wallet::identity::types::dashpay::payment::PaymentStatus::Failed, +]; +/// See [`LIVE_CONFIRM_EVIDENCE`]. +const RECONCILE_CONFIRM_EVIDENCE: + &[crate::wallet::identity::types::dashpay::payment::PaymentStatus] = + &[crate::wallet::identity::types::dashpay::payment::PaymentStatus::Pending]; + +/// Flip the `Sent` [`PaymentEntry`] under `txid` (if any) to `Confirmed`, +/// in place, preserving amount/memo/counterparty. /// -/// No-op when no entry exists for `txid`, it is not a `Sent` entry, or it -/// is already `Confirmed` (so repeated confirmed re-detections are -/// idempotent and skip the persistence round). A `Failed` entry DOES -/// advance: the only writer of `Failed` is the sweep hook below, a swept -/// transaction's one road back is a chainlocked reinstatement, and that -/// reinstatement re-emits the record confirmed — hard evidence the -/// verdict reversed, which must be able to correct it. +/// No-op when no entry exists for `txid`, it is not a `Sent` entry, it is +/// already `Confirmed` (so repeated confirmed re-detections are idempotent +/// and skip the persistence round), or its current state is outside what +/// `evidence` can speak for. A `Failed` entry advances only under +/// [`LIVE_CONFIRM_EVIDENCE`]: a swept transaction's one road back is a +/// chainlocked reinstatement, whose re-emitted record is hard evidence the +/// verdict reversed — while a reconcile pass's record read can predate the +/// verdict entirely (see the constants above). async fn confirm_sent_payment_by_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, persister: &crate::wallet::persister::WalletPersister, txid: &str, + evidence: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus], ) { use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - // Log-and-continue is sound for confirmations only: the flip rolled - // back in memory with the failed store, and every later signal for the - // same transaction — a confirmed re-detection, the block round, the - // IS-lock event — re-drives this path against the still-`Pending` - // entry. The sweep path below has no such second signal and handles - // its persistence failures itself. + // Log-and-continue is sound for ordinary confirmations: the flip + // rolled back in memory with the failed store, and every later signal + // for the same transaction — a confirmed re-detection, the block + // round, the IS-lock event — re-drives this path against the + // still-`Pending` entry. The sweep path handles its persistence + // failures itself (it rides the sweep's own store round). One known, + // narrow residual: a `Failed → Confirmed` reinstatement whose record + // arrived already chainlocked gets no further detection, so a persist + // failure HERE leaves a durable `Failed` for a transaction that + // survived. The reconcile sweep cannot cover it — its snapshot + // evidence is `Pending`-only precisely because a persisted-record + // read can predate a racing sweep's verdict; a safe recovery needs + // evidence ordered against the sweep round (adapter-owned, like the + // flip itself), which is follow-up-sized. if let Err(e) = resolve_sent_payment_by_txid( wallet_manager, wallet_id, persister, txid, PaymentStatus::Confirmed, + evidence, ) .await { @@ -1173,10 +1230,12 @@ pub(crate) fn sent_status_transition_allowed( /// through its own store round. (The `Failed` flip does NOT come through /// here — it rides the sweep's atomic round; see [`SweptPaymentFlips`].) /// -/// Eligibility is [`sent_status_transition_allowed`], shared with the -/// sweep flip so the state machine cannot drift; every ineligible -/// combination is a no-op, which is what keeps re-detections idempotent -/// and skipping the persistence round. Separated from the event glue so +/// Eligibility is [`sent_status_transition_allowed`] (shared with the +/// sweep flip so the state machine cannot drift) INTERSECTED with the +/// caller's declared `evidence_from` (see [`LIVE_CONFIRM_EVIDENCE`]); +/// every ineligible combination is a no-op, which is what keeps +/// re-detections idempotent, skipping the persistence round, and a stale +/// reconcile snapshot unable to overrule a sweep verdict it never saw. Separated from the event glue so /// the transition is unit-testable without constructing a full /// `TransactionRecord`. /// @@ -1192,6 +1251,7 @@ async fn resolve_sent_payment_by_txid( persister: &crate::wallet::persister::WalletPersister, txid: &str, to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, + evidence_from: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus], ) -> Result<(), crate::changeset::PersistenceError> { use crate::wallet::identity::types::dashpay::payment::PaymentDirection; @@ -1208,7 +1268,15 @@ async fn resolve_sent_payment_by_txid( }; let resolved = match managed.dashpay().payments.get(txid) { Some(entry) if entry.direction == PaymentDirection::Sent => { - if !sent_status_transition_allowed(entry.status, to) { + // Both gates, deliberately: the table says the machine + // permits the move, `evidence_from` says this caller's + // evidence supports it. The re-check under this write lock + // is what turns a caller's stale snapshot into a safe + // no-op — an entry that moved outside the declared set + // means the evidence predates another writer's verdict. + if !evidence_from.contains(&entry.status) + || !sent_status_transition_allowed(entry.status, to) + { continue; } let mut updated = entry.clone(); @@ -3080,7 +3148,14 @@ mod tests { ); // A confirmed detection flips it to Confirmed, preserving fields. - super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid).await; + super::confirm_sent_payment_by_txid( + &iw.wallet_manager, + &wallet_id, + &p, + &txid, + super::LIVE_CONFIRM_EVIDENCE, + ) + .await; let entry = read_entry(iw, &wallet_id, &owner, &txid).await; assert_eq!( entry.status, @@ -3092,7 +3167,14 @@ mod tests { assert_eq!(entry.memo.as_deref(), Some("dinner"), "memo preserved"); // Idempotent: a second confirmed re-detection changes nothing. - super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid).await; + super::confirm_sent_payment_by_txid( + &iw.wallet_manager, + &wallet_id, + &p, + &txid, + super::LIVE_CONFIRM_EVIDENCE, + ) + .await; assert_eq!( read_entry(iw, &wallet_id, &owner, &txid).await.status, PaymentStatus::Confirmed @@ -3190,7 +3272,14 @@ mod tests { // The chainlocked reinstatement re-emits the record confirmed; the // hard evidence must be able to correct the Failed verdict. - super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid_key).await; + super::confirm_sent_payment_by_txid( + &iw.wallet_manager, + &wallet_id, + &p, + &txid_key, + super::LIVE_CONFIRM_EVIDENCE, + ) + .await; assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, PaymentStatus::Confirmed, @@ -3288,6 +3377,109 @@ mod tests { ); } + /// The stale-evidence race, frozen at its worst point: the reconcile + /// sweep snapshots an entry as `Pending` and reads its persisted + /// record, the sweep hook flips the entry to `Failed` mid-flight + /// (hooks are unordered spawned tasks, and the sweep deletes the + /// record on a third), and the reconciler then confirms from evidence + /// that predates the verdict — landing a dead payment terminally + /// `Confirmed`, durably, since nothing demotes `Confirmed` and + /// re-emitted sweeps are ineligible. The reconciler's declared + /// evidence (`RECONCILE_CONFIRM_EVIDENCE`, `Pending`-only) makes the + /// resolver's write-lock re-check turn exactly that into a no-op, + /// while live reinstatement evidence — which postdates any flip by + /// the event contract — still recovers the entry. + #[tokio::test] + async fn a_stale_reconcile_snapshot_cannot_confirm_a_swept_payment() { + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xAF; 32]); + let txid_key = txid.to_string(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, None), + &p, + ) + .expect("record pending sent"); + } + + async fn status( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: &WalletId, + owner: &Identifier, + txid: &str, + ) -> PaymentStatus { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("info"); + info.identity_manager + .managed_identity(owner) + .unwrap() + .dashpay() + .payments + .get(txid) + .expect("entry") + .status + } + + // The reconciler snapshotted the entry Pending; before it confirms, + // the sweep's verdict lands. + let flips = + super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) + .await; + assert!(!flips.is_empty()); + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Failed + ); + + // The racing reconciler now confirms from its stale read, declaring + // exactly the evidence the production sweep declares. + super::confirm_sent_payment_by_txid( + &iw.wallet_manager, + &wallet_id, + &p, + &txid_key, + super::RECONCILE_CONFIRM_EVIDENCE, + ) + .await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Failed, + "evidence read before the sweep's verdict must not confirm the dead payment" + ); + + // Live reinstatement evidence — which postdates any flip by the + // event contract — still recovers the entry. + super::confirm_sent_payment_by_txid( + &iw.wallet_manager, + &wallet_id, + &p, + &txid_key, + super::LIVE_CONFIRM_EVIDENCE, + ) + .await; + assert_eq!( + status(iw, &wallet_id, &owner, &txid_key).await, + PaymentStatus::Confirmed + ); + } + /// The rollback may only revert the sweep flip's own still-standing /// `Failed` write. The payment hooks run on their own task, so a /// confirmation can land between the flip and its undo (a rejected @@ -3332,7 +3524,14 @@ mod tests { // The reinstated transaction's confirmation races in before the // undo — Failed → Confirmed, the table's permitted correction. - super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid_key).await; + super::confirm_sent_payment_by_txid( + &iw.wallet_manager, + &wallet_id, + &p, + &txid_key, + super::LIVE_CONFIRM_EVIDENCE, + ) + .await; // The undo arrives late (rejected round or same-fold retraction); // it must find its own write gone and leave the terminal alone. From fbf8eec806bf05ce8ea1b239d4ed5a499a836bfe Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:09:19 +0300 Subject: [PATCH 073/102] test(platform-wallet-storage): pin the synthetic-spent-row route into the co-swept rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The co-swept corner has a second entrance with weaker preconditions than record loss: C arriving in order writes derive_spent_utxos' synthetic spent-only row for P:0 while P itself is never recorded at all. The outright DELETE closes it the same way as the first route — the skip is keyed on swept-set membership and intercepts before both the spend UPDATE and the tombstone INSERT, so row provenance never matters — but nothing pinned that. The regression drives the in-order synthetic row through the batch sweep and the chainlocked reinstatement; against the presence-guard predecessor (86568b4d8a~1) it fails at the attribution assertion, confirming the route was live there and the DELETE is what closed it. --- .../tests/sqlite_transaction_sweeps.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 0432b4d5f03..b3c74ef2d61 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -1886,3 +1886,99 @@ fn a_co_swept_parent_with_no_row_still_has_its_output_removed() { its record was lost at sweep time" ); } + +/// The second route into the co-swept-parent corner: P:0's row exists only +/// as the synthetic spent-only row `derive_spent_utxos` wrote when C's +/// record arrived IN ORDER (P's own record and funding never persisted — +/// weaker preconditions than the record-loss shape, no lost round needed). +/// The co-swept rule must treat it exactly like any other row for a dead +/// parent's output: DELETE it, never attribute it to the winner — a +/// `spent_in_txid` hold on it would survive into the upsert valve and lock +/// out P's chainlocked reinstatement forever, since no release ever names +/// a loser-funded outpoint. +#[test] +fn a_co_swept_parent_known_only_through_the_childs_spend_is_still_removed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xEA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x61); + let parent_txid = Txid::from_byte_array([0x60; 32]); // P — never recorded + let child_txid = Txid::from_byte_array([0x61; 32]); // C + let winner_txid = Txid::from_byte_array([0x62; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // C arrives in order, spending P:0 — the spent-utxos apply writes the + // synthetic spent-only row because no funded row exists. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(child_txid, vec![parent_output], vec![])], + spent_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &parent_output), + "sanity: the synthetic spent-only row exists" + ); + assert!(unspent(&conn, &w).is_empty()); + } + + // The batch sweeps both; P's pass has no record to walk, so only the + // co-swept rule in C's pass can decide the synthetic row's fate. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &parent_output), + "the dead parent's output must be deleted, not attributed to the winner" + ); + } + + // The chainlocked return: P reinstated with its output re-emitted must + // land spendable — nothing this sweep left behind may block the valve. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's output must restore even when its pre-sweep row \ + was only ever the synthetic spent-only one" + ); +} From f9f3e6630c244d84731d1f36781f0076a6cc4060 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:16:40 +0300 Subject: [PATCH 074/102] fix(kotlin-sdk): give the asset-lock removal the consumed-row guard the other stores have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite deletes WHERE status != 'consumed' and Swift skips statusRaw == 4; Kotlin deleted unconditionally. The asymmetry predates this branch, but the sweep cascade is now a second removal producer, so parity is one WHERE clause: a Consumed row is deliberately retained for historical lookup, and neither producer can legitimately name one — a Built row rejected at broadcast never got that far, and the sweep cascade 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. The regression removes a live and a consumed row through the handler and fails against the unconditional DELETE. --- .../dashsdk/persistence/dao/AssetLockDao.kt | 14 +++++- .../PlatformWalletPersistenceHandlerTest.kt | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) 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 dddd64d56db..88a008bc863 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 @@ -108,8 +108,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/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 4deccdfe012..e46e35d17ef 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 @@ -4392,6 +4392,56 @@ class PlatformWalletPersistenceHandlerTest { assertFalse(row.proofBytes != null) } + @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) ────────────────────────────────────────── @Test From 0b1e7051c73ae5ea504de2bef7065efaf0166b5c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:18:24 +0300 Subject: [PATCH 075/102] chore(platform-wallet): route the invitation outpoint through the one conversion authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invitation.rs still hand-rolled the OutPoint -> OutPointFFI copy the From impl's doc claims is fully routed; route it (the remaining inline struct literal there is the zero sentinel, not a conversion). Also record the deliberate judgment on the rejected-round asset-lock divergence at its producer: the sweep cascade carries no rejection undo because the divergence is session-local — the rejection faults the wallet, the frozen watermark already forces the restart, and load() plus the re-emitted sweep (or reconstruction, if the funding tx lived) re-converge memory and mirror there — so an undo ledger would buy nothing restart does not already guarantee. --- packages/rs-platform-wallet-ffi/src/invitation.rs | 12 ++++-------- .../src/wallet/asset_lock/sync/reconstruction.rs | 10 ++++++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index e007a3de056..721f0910b10 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -208,15 +208,11 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( let result = unwrap_option_or_return!(option); let invitation = unwrap_result_or_return!(result); - // Marshal the funding outpoint out. `Txid: AsRef<[u8]>`, matching the - // conversion convention used across this crate's changeset FFI. - let mut txid = [0u8; 32]; - txid.copy_from_slice(invitation.out_point.txid.as_ref()); + // Marshal the funding outpoint out through the crate's one conversion + // authority (`From<&OutPoint> for OutPointFFI`) — this value joins the + // same outpoint-keyed rows the sweep releases match on. unsafe { - *out_outpoint = OutPointFFI { - txid, - vout: invitation.out_point.vout, - }; + *out_outpoint = OutPointFFI::from(&invitation.out_point); } // The URI is a secret (embeds the voucher key). Do NOT log it — the error 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 22da3d04194..c1ca16562b9 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 @@ -377,6 +377,16 @@ pub(crate) async fn reconstruct_tracked_asset_locks( /// 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, From c9becb9c855d21a1654670e22a2b4224dae59a15 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:06:41 +0300 Subject: [PATCH 076/102] fix(kotlin-sdk): delete a co-swept loser's pending claims instead of tombstoning them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite co-swept rule never reached this platform: a batch sweeping parent P and child C — the exact shape upstream's descendant closure always produces, and one no fixture here exercised — tombstoned C's pending claim on P:0 to the winner, because the partitions consulted only the released set and upstream never releases a loser-funded outpoint. P's chainlocked reinstatement then re-delivered P:0 straight into the tombstone-outranks drain: isSpent true, supersededByTxid stamped, recovery clear refusing stamped holds — permanently unspendable. More reachable than the SQLite variant, since pending claims are the common spend-before-funding representation here. Both partitions — the staged rows and the prior-tombstone reconciliation — now delete outright any claim whose outpoint's funding txid names a loser of the same batch: a dead parent's output is nobody's coin, and the batch is all this callback sees (upstream keeps parent and child in one batch). The multi-loser fixture drives C's claim through the [P, C] batch and P's reinstatement, and fails against the released-set-only partitions. --- .../PlatformWalletPersistenceHandler.kt | 35 +++++++-- .../PlatformWalletPersistenceHandlerTest.kt | 77 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) 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 cdc947097dc..e693df4dc66 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 @@ -1237,6 +1237,20 @@ class PlatformWalletPersistenceHandler( // transaction, turning a linear payload into L×R work on the // single persistence executor. val releasedKeys = released.mapTo(HashSet()) { it.toHex() } + // The funding txids this batch itself removes. 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 to the + // winner 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. Deleted + // outright instead — the mobile mirror of the SQLite + // co-swept DELETE. + val sweptTxidKeys = txids.mapTo(HashSet()) { it.toHex() } for (i in txids.indices) { // Global first, unconditionally, in every callback that // reaches this loop — not gated on walletId and not waiting @@ -1257,8 +1271,10 @@ class PlatformWalletPersistenceHandler( // watermark on a loser that re-swept into the same failure // on every restart. val staged = db.documentDao().pendingInputsStagedBy(txids[i], walletId) - val (freedStaged, heldStaged) = - staged.partition { releasedKeys.contains(it.outpoint.toHex()) } + val (goneStaged, heldStaged) = staged.partition { + releasedKeys.contains(it.outpoint.toHex()) || + sweptTxidKeys.contains(it.outpoint.copyOfRange(0, 32).toHex()) + } // Released staged rows go now rather than riding the // eventual cascade. Left attached they count as this // wallet's claim in `hasOtherWalletClaim` below, so two @@ -1268,8 +1284,10 @@ class PlatformWalletPersistenceHandler( // stalemate. The global marker keeps the dead transaction // from contributing funds either way, but the row and both // pending entries would otherwise be stored forever. - if (freedStaged.isNotEmpty()) { - db.documentDao().deletePendingInputs(freedStaged) + // Claims on a co-swept loser's own outputs go with them — + // see `sweptTxidKeys` above. + if (goneStaged.isNotEmpty()) { + db.documentDao().deletePendingInputs(goneStaged) } if (heldStaged.isNotEmpty()) { db.documentDao().updatePendingInputs( @@ -1290,9 +1308,12 @@ class PlatformWalletPersistenceHandler( // and carried forward separately — see // [DocumentDao.sweptTombstonesTargeting]. val prior = db.documentDao().sweptTombstonesTargeting(txids[i], walletId) - val (freed, stillHeld) = prior.partition { releasedKeys.contains(it.outpoint.toHex()) } - if (freed.isNotEmpty()) { - db.documentDao().deletePendingInputs(freed) + val (gonePrior, stillHeld) = prior.partition { + releasedKeys.contains(it.outpoint.toHex()) || + sweptTxidKeys.contains(it.outpoint.copyOfRange(0, 32).toHex()) + } + if (gonePrior.isNotEmpty()) { + db.documentDao().deletePendingInputs(gonePrior) } if (stillHeld.isNotEmpty()) { db.documentDao().updatePendingInputs( 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 e46e35d17ef..e35ebacb1e9 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 @@ -3330,6 +3330,83 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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, and the recovery clear refuses stamped holds. A dead + // parent's output is nobody's coin; the claim must be deleted with + // the batch. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(parentTxid, childTxid), + arrayOf(winnerTxid, winnerTxid), emptyArray(), + ) + 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 From e4c940d1aa8f4f0cff7e6c804dc3253bf87096f6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:13:27 +0300 Subject: [PATCH 077/102] fix(swift-sdk): delete a co-swept loser's pending claims instead of tombstoning them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite co-swept rule never reached this platform: a batch sweeping parent P and child C — the exact shape upstream's descendant closure always produces, and one only a single fixture ever put more than one loser into — tombstoned C's pending claim on P:0 to the winner, because both the held path and the prior-tombstone reconciliation consulted only the released set and upstream never releases a loser-funded outpoint. P's chainlocked reinstatement then re-delivered P:0 straight into the tombstone-outranks drain: isSpent true, supersededByTxid stamped, recovery clear refusing stamped holds — permanently unspendable. More reachable than the SQLite variant, since pending claims are the common spend-before-funding representation here. The callback now builds the round's swept-txid set across every batch — the same changeset-wide set SQLite keys on — and threads it into applySweptTransaction, which deletes outright any pending claim (held path and prior-tombstone reconciliation alike) whose outpoint's funding txid names a co-swept loser. The multi-loser fixture drives C's claim through the [P, C] batch and P's reinstatement, and fails against the released-set-only checks on all three assertions, the wedge included. --- .../PlatformWalletPersistenceHandler.swift | 35 +++++++++- .../SweptTransactionPersistTests.swift | 64 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 383bca1f7e7..40aa958cddf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -992,6 +992,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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.., + coSwept: Set, row: PersistentTransaction?, priorTombstones: [PersistentPendingInput] ) { @@ -1305,6 +1328,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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 @@ -1375,7 +1407,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // whether they still need their release applied or their chain // continued. for pending in priorTombstones where !pending.isDeleted { - if released.contains(pending.outpoint) { + if released.contains(pending.outpoint) || coSwept.contains(pending.outpoint.prefix(32)) + { backgroundContext.delete(pending) } else { pending.spendingTxid = supersededBy diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index a4d65fc1412..19bd26a5e01 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -1493,6 +1493,70 @@ final class SweptTransactionPersistTests: XCTestCase { 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)]) + + 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 From aab4998e0345792eeece0ce88577ac7a5633c926 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:33:32 +0300 Subject: [PATCH 078/102] fix(platform-wallet): withhold the sweep's payment flip from a backend that cannot land it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android wires the sweeps slot but leaves on_persist_dashpay_payments_fn unset — its payment recording is deliberately in-memory-only — and the FFI overlay delivery is an if-let with no else and no round failure. So every sweep-driven Failed flip staged onto an Android round was silently dropped while commit_batch, having checked only bit 10, attested the round durable: the accepted-and-ignored shape the sweep capability exists to prevent, reproduced on the flip channel of an in-tree host. And with the reconcile path now correctly Pending-only, nothing could ever repair it after restart. Chosen remedy: couple the channel to a capability rather than build the payments store Android deliberately does not have. A new DASHPAY_PAYMENTS bit (1 << 11) attests that dashpay_payments_overlay rows are durably applied — declared AND structurally gated on the payments slot at the FFI, the same declared-intersect-structural shape as the sweep bit; SQLite attests outright, Swift declares it and genuinely lands the rows. The adapter stages the flip's overlay and rollback ledger only for attesting backends; a non-attesting host keeps the in-memory flip — the truthful session state, consistent with every other payment write there — with nothing round-coupled. Funds-safe by construction: payment entries are display metadata, and the funds-critical half of the sweep still gates on CORE_SWEEP_REMOVAL. Regressions: a sweep-capable but payments-blind backend must see zero overlay rows on its round while memory still flips (fails with the gate removed); declared-but-unwired and wired-but-undeclared both strip the bit at the FFI; the C constant is pinned to the shared value; the existing flip tests attest the new bit; and the Swift declaration pin covers it. The generated header gains the constant too — spliced into the local (gitignored) xcframework header for the suite run; the real header ships with the next FFI binary build. --- .../rs-platform-wallet-ffi/src/persistence.rs | 69 ++++++++ .../src/sqlite/persister.rs | 1 + .../src/changeset/core_bridge.rs | 156 +++++++++++++++++- .../src/changeset/persistence_capabilities.rs | 16 ++ .../PlatformWalletManager.swift | 24 ++- .../PlatformWalletPersistenceHandler.swift | 1 + .../InvitationPersistenceTests.swift | 5 + 7 files changed, 259 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4e7b947ad0a..efb7e3d20df 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -118,6 +118,7 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << 9; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 10; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 11; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -1123,6 +1124,16 @@ impl FFIPersister { if self.callbacks.on_persist_token_balances_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } + // The dashpay-payments slot is what the sweep's Failed flip rides + // (`dashpay_payments_overlay` on the store round). A host that + // never wired it — Android deliberately keeps payment recording + // in-memory-only — must not read as payment-durable, or the + // wallet-event adapter would couple the flip to a round that + // silently drops it: the accepted-and-ignored shape the sweep + // bit's own gating exists to prevent, reproduced one channel over. + if self.callbacks.on_persist_dashpay_payments_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); + } // Sweeps travel through the size-tagged extension callback, so — // unlike the legacy `on_persist_wallet_changeset_fn`, whose // unchanged C signature proves nothing about what a host actually @@ -6150,6 +6161,14 @@ mod tests { ) -> i32 { 0 } + unsafe extern "C" fn noop_dashpay_payments( + _ctx: *mut c_void, + _wallet_id: *const u8, + _entries: *const DashpayPaymentPersistEntryFFI, + _count: usize, + ) -> i32 { + 0 + } unsafe extern "C" fn noop_wallet_changeset_sweeps( _ctx: *mut c_void, _wallet_id: *const u8, @@ -6328,6 +6347,52 @@ mod tests { /// extension slot is the only structural fact that distinguishes a /// sweep-aware host, because it exists only when the host's declared /// `struct_size` proved it. + /// `DASHPAY_PAYMENTS` requires the payments slot AND the declaration — + /// the flip channel's mirror of the sweep bit's gating. Android's + /// vtable leaves `on_persist_dashpay_payments_fn` unset, so even a + /// host blindly OR-ing the bit must read as payments-blind: the + /// wallet-event adapter keys the sweep's Failed-flip staging on this + /// bit, and an accepted-and-dropped overlay is exactly the shape the + /// gating exists to prevent. + #[test] + fn dashpay_payments_requires_the_slot_and_the_declaration() { + fn persister_with( + callbacks: PersistenceCallbacks, + declared: PersistenceCapabilities, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities(callbacks, declared) + } + // Declared but slot unwired (the Android shape): absent. + assert!(!persister_with( + PersistenceCallbacks::default(), + PersistenceCapabilities::DASHPAY_PAYMENTS + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS)); + + // Slot wired but never declared: absent. + assert!(!persister_with( + PersistenceCallbacks { + on_persist_dashpay_payments_fn: Some(noop_dashpay_payments), + ..Default::default() + }, + PersistenceCapabilities::NONE + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS)); + + // Wired and declared: attested. + assert!(persister_with( + PersistenceCallbacks { + on_persist_dashpay_payments_fn: Some(noop_dashpay_payments), + ..Default::default() + }, + PersistenceCapabilities::DASHPAY_PAYMENTS + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS)); + } + #[test] fn core_sweep_removal_requires_the_extension_slot_and_the_declaration() { fn persister_with( @@ -6759,6 +6824,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL, PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS, + PersistenceCapabilities::DASHPAY_PAYMENTS.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 3c9bf2f2735..54801e4bd9f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -847,6 +847,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) } /// Merge `changeset` into the per-wallet buffer. diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 6e5415296f3..2e5f34c82be 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -305,6 +305,22 @@ async fn run_wallet_event_adapter

( // once per session rather than once per faulted batch. let mut freeze_logged = false; + // Whether the backend durably applies `dashpay_payments_overlay` + // rows. The sweep's Failed flip is staged onto the sweep's own store + // round ONLY when it does: a sweep never re-emits once its round is + // durable, so handing the overlay to a host that silently drops it + // (Android deliberately keeps payment recording in-memory-only, its + // payments slot unwired) would leave this adapter believing a flip + // persisted — the accepted-and-ignored shape the sweep capability's + // own gating exists to prevent, one channel over. A non-attesting + // backend still gets the in-memory flip (the truthful session state; + // the transaction IS dead) with nothing round-coupled — funds-safe, + // since payment entries are display metadata, and consistent with + // every other payment write on such hosts. + let payments_attested = persister + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS); + loop { // Block for the first event of a batch. Everything already sitting in // the channel behind it is folded in below without another await, so a @@ -358,7 +374,9 @@ async fn run_wallet_event_adapter

( .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); - fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); + if payments_attested { + fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); + } } // Fold in whatever else is already buffered. `try_recv` never waits, @@ -384,7 +402,9 @@ async fn run_wallet_event_adapter

( .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); - fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); + if payments_attested { + fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); + } folded += 1; } Err(TryRecvError::Empty) => break, @@ -3382,11 +3402,13 @@ mod tests { ); let (obs_tx, mut obs_rx) = unbounded_channel(); - // Attested for sweeps: the removal must ride an ordinary round, not - // trip the fail-closed capability gate. + // 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, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), )); let (event_tx, event_rx) = unbounded_channel(); let cancel = CancellationToken::new(); @@ -3450,6 +3472,124 @@ mod tests { handle.await.expect("adapter task joins"); } + /// A backend that never attested `DASHPAY_PAYMENTS` — Android, whose + /// payments slot is deliberately unwired — must not be handed the + /// sweep's Failed flip on the round at all: it would accept the round, + /// silently drop the overlay, and leave this adapter believing a flip + /// persisted that no store ever applied — the accepted-and-ignored + /// shape the sweep capability's own gating exists to prevent, one + /// channel over. The withhold keeps the in-memory flip (the truthful + /// session state) with nothing round-coupled. + #[tokio::test] + async fn a_payments_blind_backend_is_not_handed_the_sweeps_flip_on_the_round() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{funded_wallet_manager, NoopTestPersister}; + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xB9; 32]); + let txid_key = txid.to_string(); + + let noop = WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &noop, + ) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, None), + &noop, + ) + .expect("record pending sent"); + } + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // Sweep-capable but payments-blind: the exact Android shape. + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + 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(), + ); + + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xBA; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + + let observed = obs_rx.recv().await.expect("sweep store"); + assert!(!observed.rejected); + assert_eq!( + observed.n_payment_overlay_rows, 0, + "an overlay a payments-blind backend would silently drop must be withheld \ + from its round" + ); + + { + let wm = wallet_manager.read().await; + let status = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay() + .payments + .get(&txid_key) + .expect("entry") + .status; + assert_eq!( + status, + PaymentStatus::Failed, + "the in-memory flip still happens — the truthful session state" + ); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + /// The sweep's payment flip is durable BECAUSE it rides the sweep's own /// atomic store round: a sweep never re-emits once its round is /// durable, so a separately persisted flip whose store failed was lost @@ -3537,7 +3677,8 @@ mod tests { let (obs_tx, mut obs_rx) = unbounded_channel(); let persister = Arc::new(ProbePersister::with_capabilities( obs_tx, - crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), )); let (event_tx, event_rx) = unbounded_channel(); let cancel = CancellationToken::new(); @@ -3727,7 +3868,8 @@ mod tests { let (obs_tx, mut obs_rx) = unbounded_channel(); let persister = Arc::new(ProbePersister::with_capabilities( obs_tx, - crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), )); let (event_tx, event_rx) = unbounded_channel(); diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index da379c4b75d..f209bc65786 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -69,6 +69,22 @@ impl PersistenceCapabilities { /// complete sweep contract was implemented rather than silently /// truncated. pub const CORE_SWEEP_REMOVAL: Self = Self(1 << 10); + /// A stored changeset's `dashpay_payments_overlay` rows are durably + /// applied. This is what lets the wallet-event adapter couple a sweep's + /// payment consequence (`Pending → Failed` for the losers' sent + /// entries) to the sweep's own atomic store round: the flip is staged + /// onto the round ONLY for a backend attesting this bit, because a + /// sweep never re-emits once its round is durable — an + /// accepted-and-ignored overlay would leave the adapter believing a + /// flip persisted that a host without a payments store silently + /// dropped. A non-attesting backend keeps the in-memory flip (the + /// truthful session state; the transaction IS dead) with nothing + /// round-coupled — funds-safe, since payment entries are display + /// metadata; the funds-critical half of the sweep still gates on + /// `CORE_SWEEP_REMOVAL`. On the FFI surface Rust honours the + /// declaration only when `on_persist_dashpay_payments_fn` is actually + /// wired. + pub const DASHPAY_PAYMENTS: Self = Self(1 << 11); /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index ad11d539dfb..9f96905b64b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -66,13 +66,25 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// persisted. Restart hydration is separately attested by `walletRestore`. public static let trackedAssetLocks: UInt64 = 1 << 9 /// A round's sweep batches — delivered through the persistence - /// extension's size-negotiated sweep callback — are durably applied: - /// the swept loser's row (and any tombstoned pending-input claim - /// standing in for a not-yet-materialized UTXO) actually leaves the - /// store. Mirrors `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; Rust - /// only honours the declaration when the extension actually carries - /// the callback. + /// 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 << 10 + /// 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 << 11 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 40aa958cddf..24044bdd11c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2323,6 +2323,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | PlatformWalletPersistenceCapabilities.coreSweepRemoval + | PlatformWalletPersistenceCapabilities.dashpayPayments ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 4615ac6e553..64fdd8a1ef1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -58,6 +58,11 @@ final class InvitationPersistenceTests: XCTestCase { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | 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, From a4dc8295a4bae793000ddb21c60dc9302d6b1992 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:39:11 +0300 Subject: [PATCH 079/102] fix(kotlin-sdk): keep a Consumed asset lock terminal through the upsert too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upsert-side twin of the delete guard: Swift skips a non-Consumed snapshot over a Consumed row and the sqlite upsert's WHERE clause does the same, but Kotlin's last-write-wins upsert let a stale reconstruction/enrichment snapshot — folded after the live flow's synchronous consumption write — regress the terminal. One guard, mirroring the other stores and AssetLockChangeSet::merge; the regression replays a stale Broadcast snapshot over a Consumed row and fails against the unguarded upsert. --- .../PlatformWalletPersistenceHandler.kt | 14 ++++++- .../PlatformWalletPersistenceHandlerTest.kt | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) 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 e693df4dc66..576cc0bcf05 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 @@ -1970,6 +1970,18 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> val outPointHex = encodeOutPointHex(outPoint) val existing = db.assetLockDao().getByOutPointHex(outPointHex) + // Consumed (4) is the terminal lifecycle state — never let a + // non-Consumed snapshot regress it. Writers race: the + // wallet-event adapter's batched drain can deliver a stale + // reconstruction/enrichment snapshot AFTER the live flow's + // synchronous consumption write, and this upsert is otherwise + // last-write-wins. Mirrors the same guard in Swift's + // `persistAssetLocks`, the sqlite upsert's WHERE clause, and + // `AssetLockChangeSet::merge`; all other transitions stay + // last-write-wins because non-terminal statuses legitimately + // move both ways. + val statusValue = status.toInt() and 0xFF + if (existing?.statusRaw == 4 && statusValue != 4) return@stage db.assetLockDao().upsert( AssetLockEntity( outPointHex = outPointHex, @@ -1979,7 +1991,7 @@ class PlatformWalletPersistenceHandler( identityIndexRaw = identityIndex, accountIndexRaw = accountIndex, amountDuffs = amountDuffs, - statusRaw = status.toInt() and 0xFF, + statusRaw = statusValue, proofBytes = proofBytes, createdAt = existing?.createdAt ?: java.util.Date(), updatedAt = now(), 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 e35ebacb1e9..5d481d1eb86 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 @@ -4469,6 +4469,48 @@ class PlatformWalletPersistenceHandlerTest { 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) { 49 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + 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( + "a stale non-Consumed snapshot must not regress the terminal", + 4, + row!!.statusRaw, + ) + } + @Test fun assetLockRemovalNeverDeletesAConsumedRow() = runTest { // Parity with SQLite (`status != 'consumed'`) and Swift From 7e676ecce361346a4d2e0f46342a25081d51b2f8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:47:26 +0300 Subject: [PATCH 080/102] chore: repin rust-dashcore to the merged linear-retain revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/rust-dashcore#966 merged as 75f318bd, so the pin moves off f4b907c3 — that PR's own branch head, which was never reachable from dev — onto the merge commit. The revision carries the conflict sweep's surviving-input set built once with constant-time membership checks, replacing the per-candidate scan over every account, record and input that made release filtering O(R×H) against peer-influenced inputs. All eight workspace pins and Cargo.lock move together; no API changed, so nothing downstream needed adjusting. --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 16 ++++++++-------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed704686891..a028a40132e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" 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=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "bincode", "dashcore-private", @@ -2475,7 +2475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2536,7 +2536,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" [[package]] name = "glob" @@ -3840,7 +3840,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f4b907c3f62a05bc173640051f219373c5cc4d48#f4b907c3f62a05bc173640051f219373c5cc4d48" +source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" dependencies = [ "async-trait", "bincode", @@ -4652,7 +4652,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5752,7 +5752,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6560,7 +6560,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6573,7 +6573,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6632,7 +6632,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7492,7 +7492,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8941,7 +8941,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index de60577d1be..2afeed604ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "f4b907c3f62a05bc173640051f219373c5cc4d48" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } tokio-metrics = "0.5" From 559475dea32112f4d562006dd8e20924d51fd0e5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:20:17 +0300 Subject: [PATCH 081/102] fix(platform-wallet): re-validate staged payment flips against live memory at store time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same-fold retraction only sees records captured in its own adapter batch. A chainlocked reinstating record queued just after try_recv observed an empty channel is invisible to it: the payment hooks process that record on their own task and can advance the entry in memory and persist Confirmed on their own round BEFORE the older sweep batch obtains the persister's round; the batch's staged Failed row then lands after it and durably demotes the terminal state — memory Confirmed, storage Failed — while the live confirmation event has already been consumed. Atomicity within a store round does not order separate rounds. Apply the staged failure conditionally instead: at commit time, drop every staged overlay row whose in-memory entry is no longer Failed (together with its rollback-ledger entry, so a later rejection of the round cannot replay a dead undo). The manager READ lock is held from that re-validation through the store itself, and the hold is what makes the check sound rather than a narrower race: the confirm path advances memory and persists under one continuous hold of the manager WRITE lock, so the two critical sections are mutually exclusive — either the confirm ran first and the re-validation sees Confirmed and drops the row, or the sweep's round stores first and the confirm's later round performs the Failed-to-Confirmed advance the shared transition table permits. Batches with no staged overlay rows — every drain on a payments-blind backend, and every drain without a sweep — skip the lock and commit exactly as before. Composes with the existing mechanisms rather than overlapping them: retract_reinstated_payment_flips still owns the same-fold case, the guarded rollback_swept_payment_flips still owns rejected rounds, and this owns the cross-drain window between them. Covered by a commit-stage test that stages two flips exactly as the drain's fold does, runs the real confirm path against one of them inside the cross-drain window, and asserts the round carries only the row memory still stands behind. Revert-tested: with the re-validation reverted to a passthrough, the round carries the superseded row and the test fails. --- .../src/changeset/core_bridge.rs | 325 +++++++++++++++++- 1 file changed, 322 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 2e5f34c82be..1cccfbc0a7f 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -417,15 +417,22 @@ async fn run_wallet_event_adapter

( // Commit the folded batch. The channel is lossless, so the only way a // watermark is held back is a rejected `store()` (the fail-closed - // backstop inside `commit_batch`). - let diag = commit_batch( + // backstop inside `commit_batch`). When the batch stages payment + // overlay rows, the commit re-validates them against live memory + // under the manager lock — see + // [`commit_batch_with_payment_revalidation`] for why that lock is + // held across the store itself. + let diag = commit_batch_with_payment_revalidation( + &wallet_manager, &*persister, batch, + &mut payment_rollbacks, folded, &mut fault, &sync_fault, &mut freeze_logged, - ); + ) + .await; // A rejected round leaves NOTHING durable — the loser's record and // the payment flip alike — so memory must return to the durable @@ -858,6 +865,13 @@ async fn swept_payment_flips_for_event( /// undo the in-memory flip through the guarded /// [`rollback_swept_payment_flips`] — which leaves the entry alone if the /// hooks already advanced it to `Confirmed`, the table's terminal. +/// +/// This function only sees records THIS drain captured. A reinstating +/// record queued after `try_recv` stopped folding — whose payment hooks +/// may confirm the entry on their own task before this batch stores — is +/// the commit stage's job: [`commit_batch_with_payment_revalidation`] +/// re-validates every staged row against live memory under the manager +/// lock, held across the store. async fn retract_reinstated_payment_flips( wallet_manager: &Arc>>, entry: &mut WalletBatch, @@ -947,6 +961,144 @@ fn fold_payment_flips( .extend(flips.rollback); } +/// The cross-drain half of the reinstatement invariant: **a store round +/// must never carry a sweep's `Failed` overlay row that live memory has +/// already superseded.** [`retract_reinstated_payment_flips`] covers the +/// same-fold case, keyed on records this drain captured — but a +/// chainlocked reinstating record queued just after `try_recv` observed an +/// empty channel is invisible to it. The payment hooks process that record +/// on their own task and can advance the entry `Failed → Confirmed` in +/// memory and persist `Confirmed` on their own round BEFORE this sweep +/// batch reaches the persister; the staged `Failed` row would then land +/// after it and durably demote the terminal state, while the live +/// confirmation event has already been consumed. Atomicity within a store +/// round does not order separate rounds — so the staged failure is applied +/// conditionally instead. +/// +/// The manager READ lock is held from the re-validation through the +/// `store()` itself, and that hold is load-bearing. The confirm path +/// ([`resolve_sent_payment_by_txid`](crate::wallet::identity::network) via +/// the payment hooks) advances memory and persists under one continuous +/// hold of the manager WRITE lock, so the two critical sections are +/// mutually exclusive and totally ordered: +/// +/// - confirm first: this re-validation sees `Confirmed` and drops the +/// staged row (and its rollback-ledger entry — a later rejection of this +/// round must not replay an undo for a row the round never carried); +/// - this round first: the store lands `Failed` before the confirm can +/// run, and the confirm's own later round advances it — `Failed → +/// Confirmed` is exactly the transition the shared table permits. +/// +/// A check released before the store would reopen the race: the whole +/// confirm (memory advance + persist) could run inside the gap. Batches +/// that stage no overlay rows — every drain on a payments-blind backend +/// (bit 11 not attested; the fold never stages the overlay there), and +/// every drain without a sweep — skip the lock entirely and commit exactly +/// as before. +#[allow(clippy::too_many_arguments)] +async fn commit_batch_with_payment_revalidation

( + wallet_manager: &Arc>>, + persister: &P, + mut batch: BTreeMap, + payment_rollbacks: &mut BTreeMap< + WalletId, + Vec<( + dpp::prelude::Identifier, + String, + crate::wallet::identity::PaymentEntry, + )>, + >, + folded: usize, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + freeze_logged: &mut bool, +) -> BatchDiagnostics +where + P: PlatformWalletPersistence + ?Sized, +{ + if batch + .values() + .all(|entry| entry.payments_overlay.is_empty()) + { + return commit_batch(persister, batch, folded, fault, sync_fault, freeze_logged); + } + let wm = wallet_manager.read().await; + retract_superseded_payment_flips(&wm, &mut batch, payment_rollbacks); + // Deliberately still under `wm`: releasing the guard before the store + // is the race this function exists to close. `commit_batch` is + // synchronous and takes no manager lock, so this cannot deadlock. + commit_batch(persister, batch, folded, fault, sync_fault, freeze_logged) +} + +/// Drop every staged payment overlay row whose in-memory entry is no +/// longer `Failed`, together with its rollback-ledger entry. Called only +/// under the manager lock [`commit_batch_with_payment_revalidation`] holds +/// across the store. +/// +/// A staged row exists because [`flip_swept_sent_payments_for_store`] +/// (crate::wallet::identity::network) flipped the entry `Pending → Failed` +/// in this very drain, and the same-fold retraction removes both the row +/// and the flip when it undoes one — so at commit time the live entry is +/// either still `Failed` (keep the row: the store must learn the verdict) +/// or the payment hooks advanced it to `Confirmed`, the table's terminal +/// (drop the row: memory outran the batch, and the hooks' own round +/// carries the truth). Anything else — the entry or its wallet vanished — +/// is also dropped: the overlay asserts what memory holds, and memory no +/// longer holds it. +fn retract_superseded_payment_flips( + wm: &WalletManager, + batch: &mut BTreeMap, + payment_rollbacks: &mut BTreeMap< + WalletId, + Vec<( + dpp::prelude::Identifier, + String, + crate::wallet::identity::PaymentEntry, + )>, + >, +) { + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + + for (wallet_id, entry) in batch.iter_mut() { + if entry.payments_overlay.is_empty() { + continue; + } + let info = wm.get_wallet_info(wallet_id); + let mut superseded: Vec<(dpp::prelude::Identifier, String)> = Vec::new(); + for (owner, rows) in entry.payments_overlay.iter_mut() { + rows.retain(|txid, _| { + let still_failed = info + .and_then(|info| info.identity_manager.managed_identity(owner)) + .and_then(|managed| managed.dashpay().payments.get(txid)) + .is_some_and(|live| live.status == PaymentStatus::Failed); + if !still_failed { + tracing::info!( + owner = %owner, + txid = %txid, + "Retracting a staged sweep-failed payment row superseded in memory \ + before its round stored" + ); + superseded.push((*owner, txid.clone())); + } + still_failed + }); + } + entry.payments_overlay.retain(|_, rows| !rows.is_empty()); + if superseded.is_empty() { + continue; + } + if let Some(ledger) = payment_rollbacks.get_mut(wallet_id) { + ledger.retain(|(owner, txid, _)| { + !superseded + .iter() + .any(|(superseded_owner, superseded_txid)| { + superseded_owner == owner && superseded_txid == txid + }) + }); + } + } +} + /// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable /// for atomic persistence. async fn build_core_changeset( @@ -3946,6 +4098,173 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The CROSS-drain half of the reinstatement invariant: a chainlocked + /// reinstating record queued just after `try_recv` observed an empty + /// channel is invisible to the same-fold retraction, and the payment + /// hooks process it on their own task — they can advance the entry + /// `Failed → Confirmed` in memory and persist `Confirmed` on their own + /// round BEFORE the sweep batch reaches the persister. Storing the + /// batch's staged `Failed` row after that durably demotes the terminal + /// state (memory `Confirmed`, storage `Failed`) with the live + /// confirmation event already consumed. + /// + /// Drives the commit stage with the drain's exact staging sequence and + /// the confirm wedged into the cross-drain window — an interleaving the + /// live loop cannot be made to schedule deterministically from outside: + /// stage the sweep's flips exactly as the fold does, run the real + /// confirm path against one of them, then commit through + /// [`commit_batch_with_payment_revalidation`](super::commit_batch_with_payment_revalidation). + /// The round must carry only the row memory still stands behind, and + /// the superseded row's rollback-ledger entry must be gone with it. + #[tokio::test] + async fn a_confirmation_landing_before_the_sweeps_store_retracts_its_stale_failed_row() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + use key_wallet::account::account_type::StandardAccountType; + + use crate::test_support::{funded_wallet_manager, NoopTestPersister}; + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let reinstated = dashcore::Txid::from([0xE1; 32]); + let still_dead = dashcore::Txid::from([0xE2; 32]); + + let noop = WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &noop, + ) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + for txid in [reinstated, still_dead] { + managed + .record_dashpay_payment( + txid.to_string(), + PaymentEntry::new_sent(contact, 50_000, None), + &noop, + ) + .expect("record pending sent"); + } + } + + // Stage the sweep's flips exactly as the drain's fold does: both + // entries flip to `Failed` in memory, the overlay and rollback + // ledger ride the batch. + let flips = crate::wallet::identity::network::flip_swept_sent_payments_for_store( + &wallet_manager, + &wallet_id, + &[reinstated, still_dead], + ) + .await; + let mut batch: BTreeMap = BTreeMap::new(); + let mut payment_rollbacks = BTreeMap::new(); + super::fold_payment_flips( + batch.entry(wallet_id).or_default(), + &mut payment_rollbacks, + wallet_id, + flips, + ); + + // The cross-drain window: the payment hooks confirm `reinstated` + // from the chainlocked record's live evidence — memory `Failed → + // Confirmed`, persisted on the hooks' own round — before the sweep + // batch stores. + crate::wallet::identity::network::confirm_sent_dashpay_payment_by_txid( + &wallet_manager, + &wallet_id, + &noop, + &reinstated, + ) + .await; + + // Commit the sweep batch through the drain's commit stage. + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), + ); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let mut freeze_logged = false; + super::commit_batch_with_payment_revalidation( + &wallet_manager, + &persister, + batch, + &mut payment_rollbacks, + 1, + &mut fault, + &sync_fault, + &mut freeze_logged, + ) + .await; + + let observed = obs_rx.recv().await.expect("the sweep round's store"); + assert!(!observed.rejected); + assert_eq!( + observed.n_payment_overlay_rows, 1, + "the round must carry only the row memory still stands behind — \ + storing the superseded Failed row would durably demote a \ + Confirmed the hooks' round already persisted" + ); + + // The superseded row's undo left the ledger with it: a later + // rejection of this round must not replay an undo for a row the + // round never carried. + let ledger = payment_rollbacks.get(&wallet_id).expect("ledger"); + assert_eq!( + ledger.len(), + 1, + "only the retained row keeps its rollback entry" + ); + assert_eq!(ledger[0].1, still_dead.to_string()); + + // The retraction reads memory, never writes it: the confirmed + // entry keeps its terminal state, the still-dead one keeps the + // verdict its row just stored. + { + let wm = wallet_manager.read().await; + let payments = &wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay() + .payments; + assert_eq!( + payments.get(&reinstated.to_string()).expect("entry").status, + PaymentStatus::Confirmed + ); + assert_eq!( + payments.get(&still_dead.to_string()).expect("entry").status, + PaymentStatus::Failed + ); + } + } + /// 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 From 53d74e1335005fcfadaedfd7731d971c689333c2 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:30:03 +0300 Subject: [PATCH 082/102] perf(platform-wallet): index superseded payment keys before filtering the rollback ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit-time retraction accumulated dropped rows in a Vec and probed it with a linear scan per rollback-ledger entry — O(dropped x ledger) identifier-and-string comparisons, run immediately before the manager lock is held across storage, and a sweep event can carry many payment txids. Accumulate the dropped keys in an owner-keyed BTreeMap of txid sets instead and probe by lookup. Pure data-structure change on the path 559475dea3 added; the retained and dropped sets are identical, which the existing commit-stage regression test pins. --- .../src/changeset/core_bridge.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 1cccfbc0a7f..8f9c788d19d 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -32,7 +32,7 @@ //! manager's lifetime; on shutdown, fire the [`CancellationToken`] to //! make the task exit cleanly. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -1064,7 +1064,12 @@ fn retract_superseded_payment_flips( continue; } let info = wm.get_wallet_info(wallet_id); - let mut superseded: Vec<(dpp::prelude::Identifier, String)> = Vec::new(); + // Owner-keyed index of the dropped rows, probed once per ledger + // entry below. A sweep event can carry many payment txids, and a + // linear rescan of the dropped set per ledger entry would be + // O(dropped × ledger) identifier-and-string comparisons on the + // commit path. + let mut superseded: BTreeMap> = BTreeMap::new(); for (owner, rows) in entry.payments_overlay.iter_mut() { rows.retain(|txid, _| { let still_failed = info @@ -1078,7 +1083,7 @@ fn retract_superseded_payment_flips( "Retracting a staged sweep-failed payment row superseded in memory \ before its round stored" ); - superseded.push((*owner, txid.clone())); + superseded.entry(*owner).or_default().insert(txid.clone()); } still_failed }); @@ -1090,10 +1095,8 @@ fn retract_superseded_payment_flips( if let Some(ledger) = payment_rollbacks.get_mut(wallet_id) { ledger.retain(|(owner, txid, _)| { !superseded - .iter() - .any(|(superseded_owner, superseded_txid)| { - superseded_owner == owner && superseded_txid == txid - }) + .get(owner) + .is_some_and(|txids| txids.contains(txid)) }); } } From 3918f861edbb8156bd704ac10ab7b7751b675ee1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:34:27 +0300 Subject: [PATCH 083/102] perf(platform-wallet): scope the revalidation lock hold to the store it orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-time payment revalidation took the manager read lock as soon as ANY wallet in the folded batch staged an overlay row, then committed the entire multi-wallet batch under it — synchronous SQLite/FFI stores for unrelated wallets ran while every manager writer was blocked, and the guard stayed held even when revalidation dropped every staged row. The persistence trait explicitly permits inline I/O and marks calls under the manager lock latency-sensitive. Split the per-wallet unit out of commit_batch (behavior identical; commit_batch now loops over it) and scope the hold per wallet: a wallet with no staged rows commits outside any guard, a wallet whose rows all retract commits after the guard is released, and only a wallet with surviving rows stores under it. The narrowing does not weaken the ordering that makes the revalidation sound, because the mutual-exclusion argument is per store: the confirm path advances memory and persists under one continuous manager WRITE hold, and each overlay-carrying store still runs inside a read hold that began before its own rows were re-validated — either the confirm ran before that hold (the re-validation sees Confirmed and drops the row) or it runs after that store (its Confirmed round lands later, the allowed transition). The guard that previously covered other wallets' stores ordered nothing: those rounds carry no payment rows, and rows of a later wallet are re-validated under that wallet's own subsequent hold. --- .../src/changeset/core_bridge.rs | 225 ++++++++++++------ 1 file changed, 156 insertions(+), 69 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 8f9c788d19d..0c10fd4316a 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -500,14 +500,41 @@ where P: PlatformWalletPersistence + ?Sized, { let mut diag = BatchDiagnostics::new(folded, batch.len()); - for ( - wallet_id, - WalletBatch { - mut core, - asset_locks, - payments_overlay, - }, - ) in batch + for (wallet_id, wallet_batch) in batch { + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + ); + } + diag +} + +/// Commit one wallet's folded changeset — the per-wallet unit of +/// [`commit_batch`], split out so +/// [`commit_batch_with_payment_revalidation`] can scope its manager-lock +/// hold to exactly the store that lock orders instead of the whole +/// multi-wallet batch. +fn commit_wallet

( + persister: &P, + wallet_id: WalletId, + wallet_batch: WalletBatch, + diag: &mut BatchDiagnostics, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + freeze_logged: &mut bool, +) where + P: PlatformWalletPersistence + ?Sized, +{ + let WalletBatch { + mut core, + asset_locks, + payments_overlay, + } = wallet_batch; { // Hold this wallet's durable watermark at the last fully persisted // height once it has faulted. Records/UTXOs still persist — only the @@ -533,7 +560,7 @@ 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. @@ -589,7 +616,7 @@ where // re-emits the sweep and the idempotent removal is retried // against (hopefully, by then) a capable backend. if fault_and_freeze( - &mut diag, + diag, offered_height, fault, sync_fault, @@ -625,7 +652,7 @@ where // durable half this rejection discarded are rolled back. diag.rejected_wallets.insert(wallet_id); if fault_and_freeze( - &mut diag, + diag, offered_height, fault, sync_fault, @@ -649,7 +676,6 @@ where } } } - diag } /// The bookkeeping shared by the two ways a round fails to be durably @@ -975,12 +1001,14 @@ fn fold_payment_flips( /// round does not order separate rounds — so the staged failure is applied /// conditionally instead. /// -/// The manager READ lock is held from the re-validation through the -/// `store()` itself, and that hold is load-bearing. The confirm path +/// The manager READ lock is held from the re-validation of a wallet's +/// staged rows through THAT wallet's `store()`, and that hold is +/// load-bearing. The confirm path /// ([`resolve_sent_payment_by_txid`](crate::wallet::identity::network) via /// the payment hooks) advances memory and persists under one continuous -/// hold of the manager WRITE lock, so the two critical sections are -/// mutually exclusive and totally ordered: +/// hold of the manager WRITE lock, so for every store that carries payment +/// rows the two critical sections are mutually exclusive and totally +/// ordered: /// /// - confirm first: this re-validation sees `Confirmed` and drops the /// staged row (and its rollback-ledger entry — a later rejection of this @@ -990,16 +1018,30 @@ fn fold_payment_flips( /// Confirmed` is exactly the transition the shared table permits. /// /// A check released before the store would reopen the race: the whole -/// confirm (memory advance + persist) could run inside the gap. Batches -/// that stage no overlay rows — every drain on a payments-blind backend -/// (bit 11 not attested; the fold never stages the overlay there), and -/// every drain without a sweep — skip the lock entirely and commit exactly -/// as before. +/// confirm (memory advance + persist) could run inside the gap. +/// +/// The hold is exactly as wide as that argument requires and no wider — +/// the persistence trait permits inline I/O and calls made under the +/// manager lock are latency-sensitive, so a writer must never wait out a +/// synchronous store the lock is not ordering. Scoping per wallet keeps +/// the proof intact, because the ordering obligation is per store: each +/// overlay-carrying store runs inside a read hold that began before its +/// own rows were re-validated, which is all the mutual exclusion above +/// ever used — the guard that covered OTHER wallets' stores ordered +/// nothing. Concretely: +/// +/// - a wallet with no staged rows commits outside any guard (on a +/// payments-blind backend — bit 11 not attested — that is every wallet, +/// since the fold never stages the overlay there); +/// - a wallet whose re-validation drops EVERY staged row commits after +/// the guard is released: no payment row rides the round, so nothing +/// needs ordering, exactly as if it never staged; +/// - a wallet with surviving rows commits under the guard. #[allow(clippy::too_many_arguments)] async fn commit_batch_with_payment_revalidation

( wallet_manager: &Arc>>, persister: &P, - mut batch: BTreeMap, + batch: BTreeMap, payment_rollbacks: &mut BTreeMap< WalletId, Vec<( @@ -1016,18 +1058,64 @@ async fn commit_batch_with_payment_revalidation

( where P: PlatformWalletPersistence + ?Sized, { + // The hot path: no wallet staged a payment row — every drain on a + // payments-blind backend, and every drain without a sweep — so the + // whole batch commits exactly as before, without a lock or the + // per-wallet branching below. if batch .values() .all(|entry| entry.payments_overlay.is_empty()) { return commit_batch(persister, batch, folded, fault, sync_fault, freeze_logged); } - let wm = wallet_manager.read().await; - retract_superseded_payment_flips(&wm, &mut batch, payment_rollbacks); - // Deliberately still under `wm`: releasing the guard before the store - // is the race this function exists to close. `commit_batch` is - // synchronous and takes no manager lock, so this cannot deadlock. - commit_batch(persister, batch, folded, fault, sync_fault, freeze_logged) + let mut diag = BatchDiagnostics::new(folded, batch.len()); + for (wallet_id, mut wallet_batch) in batch { + if wallet_batch.payments_overlay.is_empty() { + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + ); + continue; + } + let wm = wallet_manager.read().await; + retract_superseded_payment_flips(&wm, wallet_id, &mut wallet_batch, payment_rollbacks); + if wallet_batch.payments_overlay.is_empty() { + // Every staged row was superseded: nothing left on this round + // needs ordering against the confirm path, so release the + // writers before the store. + drop(wm); + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + ); + } else { + // Deliberately still under `wm`: releasing the guard before + // this store is the race this function exists to close. + // `commit_wallet` is synchronous and takes no manager lock, so + // this cannot deadlock. + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + ); + drop(wm); + } + } + diag } /// Drop every staged payment overlay row whose in-memory entry is no @@ -1047,7 +1135,8 @@ where /// longer holds it. fn retract_superseded_payment_flips( wm: &WalletManager, - batch: &mut BTreeMap, + wallet_id: WalletId, + entry: &mut WalletBatch, payment_rollbacks: &mut BTreeMap< WalletId, Vec<( @@ -1059,46 +1148,44 @@ fn retract_superseded_payment_flips( ) { use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - for (wallet_id, entry) in batch.iter_mut() { - if entry.payments_overlay.is_empty() { - continue; - } - let info = wm.get_wallet_info(wallet_id); - // Owner-keyed index of the dropped rows, probed once per ledger - // entry below. A sweep event can carry many payment txids, and a - // linear rescan of the dropped set per ledger entry would be - // O(dropped × ledger) identifier-and-string comparisons on the - // commit path. - let mut superseded: BTreeMap> = BTreeMap::new(); - for (owner, rows) in entry.payments_overlay.iter_mut() { - rows.retain(|txid, _| { - let still_failed = info - .and_then(|info| info.identity_manager.managed_identity(owner)) - .and_then(|managed| managed.dashpay().payments.get(txid)) - .is_some_and(|live| live.status == PaymentStatus::Failed); - if !still_failed { - tracing::info!( - owner = %owner, - txid = %txid, - "Retracting a staged sweep-failed payment row superseded in memory \ - before its round stored" - ); - superseded.entry(*owner).or_default().insert(txid.clone()); - } - still_failed - }); - } - entry.payments_overlay.retain(|_, rows| !rows.is_empty()); - if superseded.is_empty() { - continue; - } - if let Some(ledger) = payment_rollbacks.get_mut(wallet_id) { - ledger.retain(|(owner, txid, _)| { - !superseded - .get(owner) - .is_some_and(|txids| txids.contains(txid)) - }); - } + if entry.payments_overlay.is_empty() { + return; + } + let info = wm.get_wallet_info(&wallet_id); + // Owner-keyed index of the dropped rows, probed once per ledger + // entry below. A sweep event can carry many payment txids, and a + // linear rescan of the dropped set per ledger entry would be + // O(dropped × ledger) identifier-and-string comparisons on the + // commit path. + let mut superseded: BTreeMap> = BTreeMap::new(); + for (owner, rows) in entry.payments_overlay.iter_mut() { + rows.retain(|txid, _| { + let still_failed = info + .and_then(|info| info.identity_manager.managed_identity(owner)) + .and_then(|managed| managed.dashpay().payments.get(txid)) + .is_some_and(|live| live.status == PaymentStatus::Failed); + if !still_failed { + tracing::info!( + owner = %owner, + txid = %txid, + "Retracting a staged sweep-failed payment row superseded in memory \ + before its round stored" + ); + superseded.entry(*owner).or_default().insert(txid.clone()); + } + still_failed + }); + } + entry.payments_overlay.retain(|_, rows| !rows.is_empty()); + if superseded.is_empty() { + return; + } + if let Some(ledger) = payment_rollbacks.get_mut(&wallet_id) { + ledger.retain(|(owner, txid, _)| { + !superseded + .get(owner) + .is_some_and(|txids| txids.contains(txid)) + }); } } From 1e709a83925b37a3ac0280da078fc86be63a4281 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:58:47 +0300 Subject: [PATCH 084/102] fix(platform-wallet): ride the reinstatement confirmation on the reinstating record's round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chainlocked reinstatement can be a one-shot: the record re-arrives already final, so no further detection follows it, and the reconcile pass is Pending-only by construction — its snapshot evidence can predate a racing sweep's verdict. The hooks' live confirm persists on its own round, so a rejection there had nothing left to retry against: memory rolled back to Failed per record_dashpay_payment's contract, the caller only logged, and the adapter round still persisted the reinstated core record — a durable Failed for a transaction that survived and is final. The adapter now owns the correction. When a drain folds a record the shared finality gate accepts for a Sent entry currently Failed, confirm_reinstated_sent_payments_for_store flips the entry in memory and stages the Confirmed row onto the SAME store round as the reinstated record, giving it the round's fail-closed machinery: a rejected round rolls the in-memory flip back to Failed (the durable state), keeps the watermark back, and the re-scan re-emits the chainlocked record, which recomputes the flip — the same durability contract the sweep's own Failed flip already gets. A durable retry queue was rejected as the same fix with extra machinery: the queue row itself would have to ride a round to survive the very rejection it exists to record. The undo ledger now carries the status each flip wrote (PaymentFlipUndo), and the rejected-round rollback reverts only a still-standing write of that status — the sweep direction's guard is unchanged, the reinstatement direction gets the mirrored one. The same-fold retraction touches only sweep-derived Failed rows (a Confirmed reinstatement row asserts exactly what the reinstating record says), and the commit-time revalidation keeps a row while the live entry still holds the status the row asserts — for a Confirmed row that is always, Confirmed being terminal, unless the entry vanished. The hooks' own confirm path remains as the low-latency duplicate: whichever writer runs first flips memory under the manager write lock, the other no-ops, and both rounds write the same terminal row. A read-locked fast path skips the flip's write lock for the common record-bearing event with no Failed entries; this drain task is the only Failed writer, so the fast path cannot miss a concurrent flip. Payments-blind backends still get the in-memory flip with nothing staged, unchanged. The reconcile-time insert of a reconstructed payment does NOT need this treatment: record_dashpay_payment removes an inserted entry when its store rejects, and the reconciler withholds the digest stamp for that contact window, so the next recurring pass re-enumerates and retries — its retry driver exists, unlike the one-shot reinstatement's. Covered end to end through the real adapter loop: the reinstating record's round carries the record and one Confirmed overlay row, a rejected round rolls memory back to Failed, and the replayed record's round carries the correction again. Revert-tested: with the drain wiring removed, the ride leg fails with no overlay row on the record's round. --- .../src/changeset/core_bridge.rs | 408 +++++++++++++++--- .../src/wallet/identity/network/mod.rs | 6 +- .../src/wallet/identity/network/payments.rs | 229 ++++++++-- 3 files changed, 540 insertions(+), 103 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 0c10fd4316a..3e813cb22c0 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -347,11 +347,7 @@ async fn run_wallet_event_adapter

( // rejected (see below). let mut payment_rollbacks: BTreeMap< WalletId, - Vec<( - dpp::prelude::Identifier, - String, - crate::wallet::identity::PaymentEntry, - )>, + Vec, > = BTreeMap::new(); let mut closed = false; { @@ -372,10 +368,21 @@ async fn run_wallet_event_adapter

( &core.records, ) .await; + // After the same-fold retraction, so a sweep undone by this + // very event reads `Pending` (the hooks' ordinary confirm owns + // it) and only a durably `Failed` entry is corrected here. + let reinstated_flips = + crate::wallet::identity::network::confirm_reinstated_sent_payments_for_store( + &wallet_manager, + &wallet_id, + &core.records, + ) + .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); if payments_attested { fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); + fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, reinstated_flips); } } @@ -400,10 +407,25 @@ async fn run_wallet_event_adapter

( &core.records, ) .await; + // See the first-fold site: after the retraction, only + // a durably `Failed` entry is corrected here. + let reinstated_flips = + crate::wallet::identity::network::confirm_reinstated_sent_payments_for_store( + &wallet_manager, + &wallet_id, + &core.records, + ) + .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); if payments_attested { fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); + fold_payment_flips( + entry, + &mut payment_rollbacks, + wallet_id, + reinstated_flips, + ); } folded += 1; } @@ -444,7 +466,7 @@ async fn run_wallet_event_adapter

( // non-attesting sweep backend — has the flip durably applied. for wallet_id in &diag.rejected_wallets { if let Some(rollback) = payment_rollbacks.remove(wallet_id) { - crate::wallet::identity::network::rollback_swept_payment_flips( + crate::wallet::identity::network::rollback_payment_flips( &wallet_manager, wallet_id, rollback, @@ -889,7 +911,7 @@ async fn swept_payment_flips_for_event( /// store: drop the staged overlay row, drop its rollback-ledger entry /// (a later rejection of this round must not replay the dead undo), and /// undo the in-memory flip through the guarded -/// [`rollback_swept_payment_flips`] — which leaves the entry alone if the +/// [`rollback_payment_flips`] — which leaves the entry alone if the /// hooks already advanced it to `Confirmed`, the table's terminal. /// /// This function only sees records THIS drain captured. A reinstating @@ -903,11 +925,7 @@ async fn retract_reinstated_payment_flips( entry: &mut WalletBatch, payment_rollbacks: &mut BTreeMap< WalletId, - Vec<( - dpp::prelude::Identifier, - String, - crate::wallet::identity::PaymentEntry, - )>, + Vec, >, wallet_id: WalletId, records: &[TransactionRecord], @@ -921,28 +939,35 @@ async fn retract_reinstated_payment_flips( if entry.payments_overlay.is_empty() && !ledger_live { return; } + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + let reinstated: std::collections::HashSet = records .iter() .map(|record| record.txid.to_string()) .collect(); + // Only sweep-derived `Failed` rows are retracted. A `Confirmed` + // reinstatement row staged earlier in this same fold asserts exactly + // what the reinstating record says and stays on the round. for rows in entry.payments_overlay.values_mut() { - rows.retain(|txid, _| !reinstated.contains(txid)); + rows.retain(|txid, row| { + !(reinstated.contains(txid) && row.status == PaymentStatus::Failed) + }); } entry.payments_overlay.retain(|_, rows| !rows.is_empty()); if let Some(ledger) = payment_rollbacks.get_mut(&wallet_id) { let mut undo = Vec::new(); - ledger.retain(|(owner, txid, previous)| { - if reinstated.contains(txid) { - undo.push((*owner, txid.clone(), previous.clone())); + ledger.retain(|entry_undo| { + if reinstated.contains(&entry_undo.txid) && entry_undo.wrote == PaymentStatus::Failed { + undo.push(entry_undo.clone()); false } else { true } }); if !undo.is_empty() { - crate::wallet::identity::network::rollback_swept_payment_flips( + crate::wallet::identity::network::rollback_payment_flips( wallet_manager, &wallet_id, undo, @@ -962,11 +987,7 @@ fn fold_payment_flips( entry: &mut WalletBatch, payment_rollbacks: &mut BTreeMap< WalletId, - Vec<( - dpp::prelude::Identifier, - String, - crate::wallet::identity::PaymentEntry, - )>, + Vec, >, wallet_id: WalletId, flips: crate::wallet::identity::network::SweptPaymentFlips, @@ -1044,11 +1065,7 @@ async fn commit_batch_with_payment_revalidation

( batch: BTreeMap, payment_rollbacks: &mut BTreeMap< WalletId, - Vec<( - dpp::prelude::Identifier, - String, - crate::wallet::identity::PaymentEntry, - )>, + Vec, >, folded: usize, fault: &mut AdapterFaultState, @@ -1118,36 +1135,33 @@ where diag } -/// Drop every staged payment overlay row whose in-memory entry is no -/// longer `Failed`, together with its rollback-ledger entry. Called only -/// under the manager lock [`commit_batch_with_payment_revalidation`] holds -/// across the store. +/// Drop every staged payment overlay row whose in-memory entry no longer +/// holds the status the row asserts, together with its rollback-ledger +/// entry. Called only under the manager lock +/// [`commit_batch_with_payment_revalidation`] holds across the store. /// -/// A staged row exists because [`flip_swept_sent_payments_for_store`] -/// (crate::wallet::identity::network) flipped the entry `Pending → Failed` -/// in this very drain, and the same-fold retraction removes both the row -/// and the flip when it undoes one — so at commit time the live entry is -/// either still `Failed` (keep the row: the store must learn the verdict) -/// or the payment hooks advanced it to `Confirmed`, the table's terminal -/// (drop the row: memory outran the batch, and the hooks' own round -/// carries the truth). Anything else — the entry or its wallet vanished — -/// is also dropped: the overlay asserts what memory holds, and memory no -/// longer holds it. +/// A staged row exists because a round-riding flip wrote it in this very +/// drain — the sweep's `Pending → Failed` +/// ([`flip_swept_sent_payments_for_store`](crate::wallet::identity::network)) +/// or the reinstatement's `Failed → Confirmed` +/// ([`confirm_reinstated_sent_payments_for_store`](crate::wallet::identity::network)) +/// — and the same-fold retraction removes both the row and the flip when +/// it undoes one. So at commit time the live entry either still holds the +/// written status (keep the row: the store must learn the verdict) or +/// another writer outran the batch — for a `Failed` row, the payment +/// hooks advancing the entry to `Confirmed`, whose own round carries the +/// truth (drop the row). Anything else — the entry or its wallet vanished +/// — is also dropped: the overlay asserts what memory holds, and memory +/// no longer holds it. fn retract_superseded_payment_flips( wm: &WalletManager, wallet_id: WalletId, entry: &mut WalletBatch, payment_rollbacks: &mut BTreeMap< WalletId, - Vec<( - dpp::prelude::Identifier, - String, - crate::wallet::identity::PaymentEntry, - )>, + Vec, >, ) { - use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - if entry.payments_overlay.is_empty() { return; } @@ -1159,21 +1173,26 @@ fn retract_superseded_payment_flips( // commit path. let mut superseded: BTreeMap> = BTreeMap::new(); for (owner, rows) in entry.payments_overlay.iter_mut() { - rows.retain(|txid, _| { - let still_failed = info + rows.retain(|txid, row| { + // A row is kept while the live entry still holds the status + // the row asserts. A sweep's `Failed` row loses its standing + // when the hooks advanced the entry to `Confirmed`; a + // reinstatement's `Confirmed` row can lose it only if the + // entry vanished, since `Confirmed` is terminal. + let still_standing = info .and_then(|info| info.identity_manager.managed_identity(owner)) .and_then(|managed| managed.dashpay().payments.get(txid)) - .is_some_and(|live| live.status == PaymentStatus::Failed); - if !still_failed { + .is_some_and(|live| live.status == row.status); + if !still_standing { tracing::info!( owner = %owner, txid = %txid, - "Retracting a staged sweep-failed payment row superseded in memory \ - before its round stored" + "Retracting a staged payment flip row superseded in memory before \ + its round stored" ); superseded.entry(*owner).or_default().insert(txid.clone()); } - still_failed + still_standing }); } entry.payments_overlay.retain(|_, rows| !rows.is_empty()); @@ -1181,10 +1200,10 @@ fn retract_superseded_payment_flips( return; } if let Some(ledger) = payment_rollbacks.get_mut(&wallet_id) { - ledger.retain(|(owner, txid, _)| { + ledger.retain(|undo| { !superseded - .get(owner) - .is_some_and(|txids| txids.contains(txid)) + .get(&undo.owner) + .is_some_and(|txids| txids.contains(&undo.txid)) }); } } @@ -2754,6 +2773,7 @@ mod tests { n_asset_locks: usize, n_asset_locks_removed: usize, n_payment_overlay_rows: usize, + n_payment_overlay_confirmed: usize, rejected: bool, } @@ -2824,6 +2844,19 @@ mod tests { .as_ref() .map(|o| o.values().map(|rows| rows.len()).sum()) .unwrap_or(0), + n_payment_overlay_confirmed: changeset + .dashpay_payments_overlay + .as_ref() + .map(|o| { + o.values() + .flat_map(|rows| rows.values()) + .filter(|entry| { + entry.status + == crate::wallet::identity::types::dashpay::payment::PaymentStatus::Confirmed + }) + .count() + }) + .unwrap_or(0), rejected, }); if rejected { @@ -4329,7 +4362,7 @@ mod tests { 1, "only the retained row keeps its rollback entry" ); - assert_eq!(ledger[0].1, still_dead.to_string()); + assert_eq!(ledger[0].txid, still_dead.to_string()); // The retraction reads memory, never writes it: the confirmed // entry keeps its terminal state, the still-dead one keeps the @@ -4355,6 +4388,263 @@ mod tests { } } + /// The one-shot reinstatement gets the round's durability, end to end + /// through the real adapter loop: a chainlocked reinstating record + /// arriving in a LATER drain than the sweep finds the entry durably + /// `Failed`, and the record re-arrives already final, so no further + /// detection follows it and the reconcile pass (`Pending`-only by + /// construction) cannot cover it — the hooks' own store round was the + /// last chance, and a rejection there left a durable `Failed` for a + /// transaction that survived. The adapter therefore owns the + /// correction: `confirm_reinstated_sent_payments_for_store` flips the + /// entry and rides the `Confirmed` row on the SAME store round as the + /// reinstated record. + /// + /// Three legs mirror the sweep-flip test: the flip rides the record's + /// round; a rejected round rolls the in-memory `Confirmed` back to + /// `Failed` (the durable state) so the replay can recompute it; and + /// the replayed record's round carries the row again. + #[tokio::test] + async fn a_chainlocked_reinstatement_rides_the_records_round_and_survives_rejection() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + 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 super::spawn_wallet_event_adapter; + use crate::test_support::{funded_wallet_manager, NoopTestPersister}; + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + // Two transactions, each later reinstated by its own chainlocked + // record: X drives the ride leg, Y the rejection-and-replay legs. + let chainlocked_record = |input_byte: u8| { + let tx = dashcore::Transaction { + version: 1, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint::new( + dashcore::Txid::from([input_byte; 32]), + 0, + ), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + { + use dashcore::hashes::Hash as _; + dashcore::BlockHash::all_zeros() + }, + 1_650_000_000, + )), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + }; + let record_x = chainlocked_record(0xD0); + let record_y = chainlocked_record(0xD1); + let (txid_x, txid_y) = (record_x.txid, record_y.txid); + + let noop = WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &noop, + ) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + for txid in [txid_x, txid_y] { + managed + .record_dashpay_payment( + txid.to_string(), + PaymentEntry::new_sent(contact, 50_000, None), + &noop, + ) + .expect("record pending sent"); + } + } + + async fn status( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + owner: &dpp::prelude::Identifier, + txid: &str, + ) -> crate::wallet::identity::types::dashpay::payment::PaymentStatus { + let wm = wallet_manager.read().await; + wm.get_wallet_info(wallet_id) + .expect("info") + .identity_manager + .managed_identity(owner) + .expect("managed") + .dashpay() + .payments + .get(txid) + .expect("entry") + .status + } + + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), + )); + 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(), + ); + + // The sweep lands durably in its own drain: both entries flip to + // `Failed`, the overlay rides the sweep's round. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid_x, txid_y], + superseded_by: dashcore::Txid::from([0xDD; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + let observed = obs_rx.recv().await.expect("sweep round"); + assert!(!observed.rejected); + assert_eq!(observed.n_payment_overlay_rows, 2); + assert_eq!(observed.n_payment_overlay_confirmed, 0); + + let reinstating = |record: TransactionRecord| 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![], + }; + + // Leg 1: X's reinstating record arrives in a LATER drain (the + // sweep's round above is already durable). Its round must carry + // the reinstated record AND the `Confirmed` correction — the flip + // rides the same atomic store as the record that justifies it. + event_tx + .send(reinstating(record_x.clone())) + .expect("send reinstating record for X"); + let observed = obs_rx.recv().await.expect("X's reinstating round"); + assert!(!observed.rejected); + assert_eq!(observed.n_records, 1, "the reinstated record rides"); + assert_eq!( + observed.n_payment_overlay_rows, 1, + "the reinstatement confirmation must ride the record's own round — \ + there is no later detection to retry from" + ); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "and the row asserts Confirmed, not a stale Failed" + ); + assert_eq!( + status(&wallet_manager, &wallet_id, &owner, &txid_x.to_string()).await, + PaymentStatus::Confirmed + ); + + // Leg 2: a rejected reinstating round rolls the in-memory flip + // back to `Failed` — the durable state — so the replayed record + // can recompute it. Without the rollback, memory would read + // `Confirmed` ahead of the store and the replay's eligibility + // check would skip the entry. + persister.fail_next(wallet_id); + event_tx + .send(reinstating(record_y.clone())) + .expect("send reinstating record for Y"); + let observed = obs_rx.recv().await.expect("Y's rejected round"); + assert!(observed.rejected); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "the attempt carried the correction" + ); + let mut rolled_back = false; + for _ in 0..50 { + if status(&wallet_manager, &wallet_id, &owner, &txid_y.to_string()).await + == PaymentStatus::Failed + { + rolled_back = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + rolled_back, + "a rejected round must roll the in-memory Confirmed back to Failed, \ + the durable state the rejection left in the store" + ); + + // Leg 3: the replayed record (the rejected round froze the + // watermark, so the re-scan re-emits the chainlocked block) + // recomputes the flip and its round carries the correction again. + event_tx + .send(reinstating(record_y)) + .expect("send replayed record for Y"); + let observed = obs_rx.recv().await.expect("Y's replayed round"); + assert!(!observed.rejected); + assert_eq!(observed.n_records, 1); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "the replay must recompute the correction the rollback undid" + ); + assert_eq!( + status(&wallet_manager, &wallet_id, &owner, &txid_y.to_string()).await, + PaymentStatus::Confirmed + ); + + 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 diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 331d8211146..53c0fc8158c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -53,9 +53,9 @@ pub(crate) use payment_handler::DashPayPaymentHandler; pub(crate) use payment_handler::run_dashpay_payment_hooks; mod payments; pub(crate) use payments::{ - confirm_sent_dashpay_payment, confirm_sent_dashpay_payment_by_txid, - flip_swept_sent_payments_for_store, record_incoming_dashpay_payments, - rollback_swept_payment_flips, SweptPaymentFlips, + confirm_reinstated_sent_payments_for_store, confirm_sent_dashpay_payment, + confirm_sent_dashpay_payment_by_txid, flip_swept_sent_payments_for_store, + record_incoming_dashpay_payments, rollback_payment_flips, PaymentFlipUndo, SweptPaymentFlips, }; mod profile; pub(crate) mod sdk_writer; 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 5a2a43a2dbe..f3ff2545470 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1039,15 +1039,16 @@ async fn confirm_sent_payment_by_txid( // for the same transaction — a confirmed re-detection, the block // round, the IS-lock event — re-drives this path against the // still-`Pending` entry. The sweep path handles its persistence - // failures itself (it rides the sweep's own store round). One known, - // narrow residual: a `Failed → Confirmed` reinstatement whose record - // arrived already chainlocked gets no further detection, so a persist - // failure HERE leaves a durable `Failed` for a transaction that - // survived. The reconcile sweep cannot cover it — its snapshot - // evidence is `Pending`-only precisely because a persisted-record - // read can predate a racing sweep's verdict; a safe recovery needs - // evidence ordered against the sweep round (adapter-owned, like the - // flip itself), which is follow-up-sized. + // failures itself (it rides the sweep's own store round). The one + // case with no later signal — a `Failed → Confirmed` reinstatement + // whose record arrived already chainlocked, which the reconcile sweep + // cannot cover either (its snapshot evidence is `Pending`-only + // precisely because a persisted-record read can predate a racing + // sweep's verdict) — is owned by the adapter instead: + // `confirm_reinstated_sent_payments_for_store` rides the reinstating + // record's own store round with the round's fail-closed machinery, so + // a store failure HERE is only ever a lost fast-path write, never the + // last chance. if let Err(e) = resolve_sent_payment_by_txid( wallet_manager, wallet_id, @@ -1094,11 +1095,28 @@ pub(crate) struct SweptPaymentFlips { Identifier, std::collections::BTreeMap, >, - /// What to restore if the round is rejected: `(owner, txid, previous - /// entry)` per flip, applied by - /// [`rollback_swept_payment_flips`] so memory returns to the durable - /// state and the replayed sweep finds the entries `Pending` again. - pub rollback: Vec<(Identifier, String, crate::wallet::identity::PaymentEntry)>, + /// What to restore if the round is rejected, applied by + /// [`rollback_payment_flips`] so memory returns to the durable + /// state and the replayed event finds the entries eligible again. + pub rollback: Vec, +} + +/// One staged payment flip's undo: what to restore if the store round the +/// flip rode is rejected, and the status the flip wrote — the undo applies +/// only while that write still stands (see [`rollback_payment_flips`]). +/// +/// Carried by both round-riding flips: the sweep's `Pending → Failed` +/// ([`flip_swept_sent_payments_for_store`], `wrote = Failed`) and the +/// reinstatement's `Failed → Confirmed` +/// ([`confirm_reinstated_sent_payments_for_store`], `wrote = Confirmed`). +#[derive(Debug, Clone)] +pub(crate) struct PaymentFlipUndo { + pub owner: Identifier, + pub txid: String, + /// The entry as it stood before the flip. + pub previous: crate::wallet::identity::PaymentEntry, + /// The status the flip wrote over it. + pub wrote: crate::wallet::identity::types::dashpay::payment::PaymentStatus, } impl SweptPaymentFlips { @@ -1155,7 +1173,12 @@ pub(crate) async fn flip_swept_sent_payments_for_store( .entry(owner) .or_default() .insert(key.clone(), updated); - flips.rollback.push((owner, key.clone(), previous)); + flips.rollback.push(PaymentFlipUndo { + owner, + txid: key.clone(), + previous, + wrote: PaymentStatus::Failed, + }); // txid is unique — only one identity can hold this entry. break 'owners; } @@ -1163,31 +1186,155 @@ pub(crate) async fn flip_swept_sent_payments_for_store( flips } -/// Undo [`flip_swept_sent_payments_for_store`]'s in-memory flips — after -/// the round they rode was rejected, or after the same fold reinstated -/// their transaction and the adapter retracted the staged overlay row. -/// Memory returns to the durable state (`Pending`, matching the store the -/// rejection left untouched), which is what lets the replayed sweep — -/// re-emitted by the re-scan, because the rejected round kept the loser's -/// record too — find the entries eligible and recompute the flip. Without -/// this, memory would read `Failed` ahead of the store, the replay's -/// eligibility check would skip the entries, and the store would never -/// learn. +/// The adapter-owned reinstatement confirmation: flip `Failed` `Sent` +/// entries whose transaction a folded record reinstates as final to +/// `Confirmed` in memory, and return the overlay + rollback for the +/// wallet-event adapter to ride on the reinstating record's OWN store +/// round. Persists NOTHING itself — the caller owns the round. +/// +/// Exists because a chainlocked reinstatement can be a one-shot: the +/// record re-arrives already final, so no further detection follows it, +/// and `reconcile_sent_payments_from_tx_history` is `Pending`-only by +/// construction (its snapshot evidence can predate a racing sweep's +/// verdict). The hooks' live confirm persists on its own round, and a +/// rejection there had nothing left to retry against — the durable entry +/// stayed `Failed` for a transaction that survived and is final. Riding +/// the same atomic `store()` as the reinstated record gives the +/// correction the round's fail-closed machinery instead: a rejected round +/// keeps the watermark back, the re-scan re-emits the chainlocked record, +/// and this flip is recomputed — the same durability contract the sweep's +/// own `Failed` flip gets from [`SweptPaymentFlips`]. +/// +/// Composes with the hooks rather than replacing them: whichever writer +/// runs first flips memory (both under the manager write lock), the other +/// finds the entry outside its from-set and no-ops, and both rounds write +/// the same terminal row. Only records the shared finality gate accepts +/// ([`sent_payment_status_for_record`] == `Confirmed`) qualify, and only +/// entries currently `Failed` are flipped — a `Pending` entry's ordinary +/// confirmation stays the hooks' job, which has recovery of its own +/// (every later signal for the transaction, and the reconcile pass). +pub(crate) async fn confirm_reinstated_sent_payments_for_store( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + records: &[key_wallet::managed_account::transaction_record::TransactionRecord], +) -> SweptPaymentFlips { + use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; + + let mut flips = SweptPaymentFlips::default(); + if records.is_empty() { + return flips; + } + // Fast path under the READ lock: a `Failed` sent entry exists only + // after a sweep, so almost every record-bearing event has nothing to + // correct — don't make the drain take the write lock per event just + // to discover that. Missing a concurrent flip is not possible: the + // adapter task calling this is the only writer of `Failed` + // (`flip_swept_sent_payments_for_store` and the rejected-round undo + // both run on it), and the write-lock re-check below still gates the + // flip itself, so an entry the hooks confirm in the gap is skipped. + { + let wm = wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(wallet_id) else { + return flips; + }; + let any_candidate = records.iter().any(|record| { + sent_payment_status_for_record(record) == PaymentStatus::Confirmed && { + let key = record.txid.to_string(); + info.identity_manager + .identity_ids() + .into_iter() + .any(|owner| { + info.identity_manager + .managed_identity(&owner) + .and_then(|managed| managed.dashpay().payments.get(&key)) + .is_some_and(|entry| { + entry.direction == PaymentDirection::Sent + && entry.status == PaymentStatus::Failed + }) + }) + } + }); + if !any_candidate { + return flips; + } + } + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return flips; + }; + for record in records { + if sent_payment_status_for_record(record) != PaymentStatus::Confirmed { + continue; + } + let key = record.txid.to_string(); + 'owners: for owner in info.identity_manager.identity_ids() { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + let previous = match managed.dashpay().payments.get(&key) { + Some(entry) + if entry.direction == PaymentDirection::Sent + && entry.status == PaymentStatus::Failed + && sent_status_transition_allowed( + entry.status, + PaymentStatus::Confirmed, + ) => + { + entry.clone() + } + _ => continue, + }; + let mut updated = previous.clone(); + updated.status = PaymentStatus::Confirmed; + tracing::info!( + owner = %owner, + txid = %key, + "Confirming reinstated sent DashPay payment on its record's own store round" + ); + managed + .dashpay_payments_mut() + .insert(key.clone(), updated.clone()); + flips + .overlay + .entry(owner) + .or_default() + .insert(key.clone(), updated); + flips.rollback.push(PaymentFlipUndo { + owner, + txid: key.clone(), + previous, + wrote: PaymentStatus::Confirmed, + }); + // txid is unique — only one identity can hold this entry. + break 'owners; + } + } + flips +} + +/// Undo round-riding in-memory payment flips — after the round they rode +/// was rejected, or after the same fold reinstated their transaction and +/// the adapter retracted the staged overlay row. Memory returns to the +/// durable state (the entry the rejection left untouched in the store), +/// which is what lets the replayed event — re-emitted by the re-scan, +/// because the rejected round kept its rows too — find the entries +/// eligible and recompute the flip. Without this, memory would read ahead +/// of the store, the replay's eligibility check would skip the entries, +/// and the store would never learn. /// /// An undo is NOT a forward transition, so it does not go through /// [`sent_status_transition_allowed`] — but it obeys the same authority: -/// it may only revert the sweep flip's own still-standing `Failed` write. -/// An entry that moved on — the payment hooks confirming it concurrently, -/// which the table permits from `Failed` — outranks the undo; restoring -/// the captured `Pending` over a `Confirmed` the store may already hold -/// would demote the terminal state the table exists to protect. -pub(crate) async fn rollback_swept_payment_flips( +/// it may only revert the flip's own still-standing write +/// ([`PaymentFlipUndo::wrote`]). An entry that moved on — the payment +/// hooks confirming a sweep-failed entry concurrently, which the table +/// permits from `Failed` — outranks the undo; restoring the captured +/// state over what another writer's round may already hold durably would +/// demote a verdict this undo has no authority over. +pub(crate) async fn rollback_payment_flips( wallet_manager: &Arc>>, wallet_id: &WalletId, - rollback: Vec<(Identifier, String, crate::wallet::identity::PaymentEntry)>, + rollback: Vec, ) { - use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - if rollback.is_empty() { return; } @@ -1195,14 +1342,14 @@ pub(crate) async fn rollback_swept_payment_flips( let Some(info) = wm.get_wallet_info_mut(wallet_id) else { return; }; - for (owner, txid, previous) in rollback { - let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + for undo in rollback { + let Some(managed) = info.identity_manager.managed_identity_mut(&undo.owner) else { continue; }; let payments = managed.dashpay_payments_mut(); - match payments.get(&txid) { - Some(current) if current.status == PaymentStatus::Failed => { - payments.insert(txid, previous); + match payments.get(&undo.txid) { + Some(current) if current.status == undo.wrote => { + payments.insert(undo.txid, undo.previous); } _ => {} } @@ -3340,7 +3487,7 @@ mod tests { super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) .await; assert!(!flips.is_empty()); - super::rollback_swept_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; + super::rollback_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; { let wm = iw.wallet_manager.read().await; @@ -3535,7 +3682,7 @@ mod tests { // The undo arrives late (rejected round or same-fold retraction); // it must find its own write gone and leave the terminal alone. - super::rollback_swept_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; + super::rollback_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; let wm = iw.wallet_manager.read().await; let status = wm From be5afc3f92a2981afa3d5d8d2a153ae241ccdc61 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:53:31 +0300 Subject: [PATCH 085/102] fix(platform-wallet): give DASHPAY_PAYMENTS a name in the diagnostics table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bit gates the payment-overlay path but was never added to `KNOWN`, so `names()` returned nothing for it. A host debugging why its overlay rows never landed would see every other capability listed and no trace of the one that withheld them — the bit was invisible in exactly the situation it exists to explain. The guard is the general form rather than one more assertion: every declarable bit must resolve to exactly one name, so the next capability cannot repeat this. It fails against the missing entry. --- .../src/changeset/persistence_capabilities.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index f209bc65786..41839ecf66d 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -169,6 +169,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::CORE_SWEEP_REMOVAL, "core_sweep_removal", ), + ( + PersistenceCapabilities::DASHPAY_PAYMENTS, + "dashpay_payments", + ), ]; KNOWN @@ -199,6 +203,7 @@ mod tests { assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); assert_eq!(PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits(), 0x200); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x400); + assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x800); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 @@ -215,4 +220,21 @@ mod tests { vec!["asset_lock_funding_indices", "wallet_restore"] ); } + + /// Every declarable bit must be nameable. A bit missing from `KNOWN` + /// still gates behaviour but vanishes from every diagnostic that + /// reports capabilities by name, so a host debugging why its rows + /// never landed sees nothing about the capability that withheld them + /// — which is exactly what `DASHPAY_PAYMENTS` did until this test. + #[test] + fn every_declared_bit_has_a_stable_name() { + for shift in 0..12u32 { + let bit = PersistenceCapabilities::from_bits_retain(1 << shift); + assert_eq!( + bit.names().len(), + 1, + "bit 1 << {shift} is declarable but has no name in KNOWN" + ); + } + } } From 6e6c30bfdd49f932c41bbe68d81e160bf79a9785 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:42:37 +0300 Subject: [PATCH 086/102] chore: repin rust-dashcore to the linear descendant-closure revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/rust-dashcore#969 merged as 5877d15f, so the pin moves off the #966 merge commit onto it. The revision replaces the conflict sweep's per-generation rescan of the whole retained history with a parent-to-children index built once and a queue traversal that visits each record exactly once — O(records + edges) instead of O(depth × history), which a peer could drive with a deep chain of unconfirmed wallet-relevant transactions followed by a finalized replacement for the root input. Skip semantics are unchanged: confirmed and InstantSend-locked records are still never followed, the winner is never a candidate, and an IS-locked initial loser still has its descendants walked. All eight workspace pins and Cargo.lock move together; no API changed. --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 16 ++++++++-------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a028a40132e..b54fd62ebd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" 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=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "bincode", "dashcore-private", @@ -2475,7 +2475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2536,7 +2536,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" [[package]] name = "glob" @@ -3840,7 +3840,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=75f318bdc6397ba483fc9a764c61fa6bc5cd5e36#75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" dependencies = [ "async-trait", "bincode", @@ -4652,7 +4652,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5752,7 +5752,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6560,7 +6560,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6573,7 +6573,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6632,7 +6632,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7492,7 +7492,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8941,7 +8941,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2afeed604ad..5151ecff7fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "75f318bdc6397ba483fc9a764c61fa6bc5cd5e36" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } tokio-metrics = "0.5" From 80ec1cb8ee179a47cc0f304765c109e8ea366f1c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:59:53 +0300 Subject: [PATCH 087/102] fix(platform-wallet): require the atomic round before staging payment flips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet-event adapter staged sweep-failed flips and one-shot reinstatement confirmations onto the triggering record's store round whenever the persister attested DASHPAY_PAYMENTS. That bit only proves the payments callback is wired and declared: on a host whose callbacks commit independently, the Core record and watermark can become durable while the process stops before the payments write — and a chainlocked reinstatement never re-emits, leaving the reinstatement durably recorded beside a payment durably Failed. Gate the staging on the new ROUND_COUPLED_PAYMENT_FLIPS composite (DASHPAY_PAYMENTS | ATOMIC_CHANGESETS), following the existing operation-composite shape (INVITATION_CREATION and friends) rather than folding atomicity into the bit itself: the bit's contract is per-callback durability, which a non-atomic host truthfully provides, and on the FFI surface the composite's atomic half is already structurally enforced — ATOMIC_CHANGESETS is only attested when the begin/end pair is wired AND declared. A host failing the stricter gate degrades exactly like a payments-blind one: the in-memory flip still happens with nothing round-coupled, which is funds-safe since payment entries are display metadata and the funds-critical half still gates on CORE_SWEEP_REMOVAL. SQLite and the Swift handler already attest both bits; Android's payments slot is unwired either way. --- .../rs-platform-wallet-ffi/src/persistence.rs | 16 +- .../src/changeset/core_bridge.rs | 176 ++++++++++++++++-- .../src/changeset/persistence_capabilities.rs | 29 +++ 3 files changed, 201 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index efb7e3d20df..a43db2c1885 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1131,6 +1131,13 @@ impl FFIPersister { // wallet-event adapter would couple the flip to a round that // silently drops it: the accepted-and-ignored shape the sweep // bit's own gating exists to prevent, reproduced one channel over. + // Unlike `CORE_SWEEP_REMOVAL` below, this bit does NOT fold in the + // begin/end pair: its contract is per-callback durability of the + // overlay rows, which holds on a non-atomic host too. The + // round-coupling the adapter's staging needs is expressed as the + // `ROUND_COUPLED_PAYMENT_FLIPS` composite (this bit plus + // `ATOMIC_CHANGESETS`), so atomicity stays attested once, by the + // bit that owns it. if self.callbacks.on_persist_dashpay_payments_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); } @@ -6352,8 +6359,13 @@ mod tests { /// vtable leaves `on_persist_dashpay_payments_fn` unset, so even a /// host blindly OR-ing the bit must read as payments-blind: the /// wallet-event adapter keys the sweep's Failed-flip staging on this - /// bit, and an accepted-and-dropped overlay is exactly the shape the - /// gating exists to prevent. + /// bit (composed with `ATOMIC_CHANGESETS` — the + /// `ROUND_COUPLED_PAYMENT_FLIPS` composite — since the staging also + /// needs the round to commit as one unit), and an accepted-and-dropped + /// overlay is exactly the shape the gating exists to prevent. The bit + /// itself deliberately stays atomicity-free: it attests per-callback + /// durability, and the positive case below is such a host — one the + /// adapter now refuses to stage round-coupled overlays for. #[test] fn dashpay_payments_requires_the_slot_and_the_declaration() { fn persister_with( diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 3e813cb22c0..4df89b52211 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -305,21 +305,32 @@ async fn run_wallet_event_adapter

( // once per session rather than once per faulted batch. let mut freeze_logged = false; - // Whether the backend durably applies `dashpay_payments_overlay` - // rows. The sweep's Failed flip is staged onto the sweep's own store - // round ONLY when it does: a sweep never re-emits once its round is - // durable, so handing the overlay to a host that silently drops it - // (Android deliberately keeps payment recording in-memory-only, its - // payments slot unwired) would leave this adapter believing a flip - // persisted — the accepted-and-ignored shape the sweep capability's - // own gating exists to prevent, one channel over. A non-attesting - // backend still gets the in-memory flip (the truthful session state; - // the transaction IS dead) with nothing round-coupled — funds-safe, - // since payment entries are display metadata, and consistent with - // every other payment write on such hosts. + // Whether the backend can give a payment flip the round-coupled + // durability this staging exists to provide — which takes BOTH bits + // of `ROUND_COUPLED_PAYMENT_FLIPS`. `DASHPAY_PAYMENTS` proves the + // overlay rows are durably applied: a sweep never re-emits once its + // round is durable, so handing the overlay to a host that silently + // drops it (Android deliberately keeps payment recording + // in-memory-only, its payments slot unwired) would leave this + // adapter believing a flip persisted — the accepted-and-ignored + // shape the sweep capability's own gating exists to prevent, one + // channel over. `ATOMIC_CHANGESETS` proves the round the flip rides + // commits or rolls back as one unit: on a host whose callbacks + // commit independently, the Core record and watermark can land + // durably and the process stop before the payments write — and for + // a one-shot chainlocked reinstatement nothing ever re-emits, so + // the reinstatement would stay durably recorded beside a payment + // durably `Failed`. Payments durability without the atomic round + // therefore gives neither the coupling nor the fail-closed + // watermark backstop, and such a host is treated exactly like a + // payments-blind one here: it still gets the in-memory flip (the + // truthful session state; the transaction IS dead) with nothing + // round-coupled — funds-safe, since payment entries are display + // metadata, and consistent with every other payment write on such + // hosts. let payments_attested = persister .persistence_capabilities() - .contains(PersistenceCapabilities::DASHPAY_PAYMENTS); + .contains(PersistenceCapabilities::ROUND_COUPLED_PAYMENT_FLIPS); loop { // Block for the first event of a batch. Everything already sitting in @@ -3683,7 +3694,8 @@ mod tests { 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::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), )); let (event_tx, event_rx) = unbounded_channel(); let cancel = CancellationToken::new(); @@ -3865,6 +3877,130 @@ mod tests { handle.await.expect("adapter task joins"); } + /// A backend that attests `DASHPAY_PAYMENTS` but NOT `ATOMIC_CHANGESETS` + /// must be treated exactly like a payments-blind one: the whole point + /// of staging a flip onto the triggering record's round is that the two + /// land or fail together, and a host whose callbacks commit + /// independently gives neither the coupling nor the fail-closed + /// watermark backstop. It can commit the Core record and watermark and + /// then stop before the payments write — and a one-shot chainlocked + /// reinstatement never re-emits, so its payment would stay durably + /// `Failed` beside a durably recorded reinstatement. Staging requires + /// the full `ROUND_COUPLED_PAYMENT_FLIPS` composite; this host keeps + /// the in-memory flip with nothing round-coupled. + #[tokio::test] + async fn an_atomicity_blind_backend_is_not_handed_payment_flips_on_the_round() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{funded_wallet_manager, NoopTestPersister}; + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xC9; 32]); + let txid_key = txid.to_string(); + + let noop = WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &noop, + ) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid_key.clone(), + PaymentEntry::new_sent(contact, 50_000, None), + &noop, + ) + .expect("record pending sent"); + } + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // Sweep-capable and payments-durable, but with no atomic round: + // each callback commits on its own, so the flip and the record + // cannot be made to land or fail together. + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), + )); + 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(), + ); + + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xCA; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + + let observed = obs_rx.recv().await.expect("sweep store"); + assert!(!observed.rejected); + assert_eq!( + observed.n_payment_overlay_rows, 0, + "an overlay that cannot ride an atomic round must be withheld from a host \ + whose callbacks commit independently" + ); + + { + let wm = wallet_manager.read().await; + let status = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay() + .payments + .get(&txid_key) + .expect("entry") + .status; + assert_eq!( + status, + PaymentStatus::Failed, + "the in-memory flip still happens — the truthful session state" + ); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + /// The sweep's payment flip is durable BECAUSE it rides the sweep's own /// atomic store round: a sweep never re-emits once its round is /// durable, so a separately persisted flip whose store failed was lost @@ -3953,7 +4089,8 @@ mod tests { 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::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), )); let (event_tx, event_rx) = unbounded_channel(); let cancel = CancellationToken::new(); @@ -4144,7 +4281,8 @@ mod tests { 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::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), )); let (event_tx, event_rx) = unbounded_channel(); @@ -4327,7 +4465,8 @@ mod tests { let persister = ProbePersister::with_capabilities( obs_tx, crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL - .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), ); let sync_fault = AtomicBool::new(false); let mut fault = AdapterFaultState::default(); @@ -4526,7 +4665,8 @@ mod tests { 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::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), )); let (event_tx, event_rx) = unbounded_channel(); let cancel = CancellationToken::new(); diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 41839ecf66d..7d09af5d7c5 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -84,6 +84,15 @@ impl PersistenceCapabilities { /// `CORE_SWEEP_REMOVAL`. On the FFI surface Rust honours the /// declaration only when `on_persist_dashpay_payments_fn` is actually /// wired. + /// + /// This bit alone attests only per-callback durability. The adapter's + /// round-coupled staging additionally requires `ATOMIC_CHANGESETS` + /// (see [`Self::ROUND_COUPLED_PAYMENT_FLIPS`]): on a host whose + /// callbacks commit independently, the Core record and watermark can + /// become durable while the process stops before the payments + /// callback — and a one-shot chainlocked reinstatement never + /// re-emits, so its payment would stay durably `Failed` beside a + /// durably recorded reinstatement. pub const DASHPAY_PAYMENTS: Self = Self(1 << 11); /// Capabilities required before exporting and funding an invitation voucher. @@ -103,6 +112,22 @@ impl PersistenceCapabilities { pub const ASSET_LOCK_RECONCILIATION: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); + /// Capabilities required before the wallet-event adapter stages a + /// sweep's `Failed` flip or a reinstatement's `Confirmed` correction + /// onto the triggering record's own store round. The point of that + /// staging is that the flip and the record land or fail together — + /// `DASHPAY_PAYMENTS` proves the overlay rows are durably applied, + /// and `ATOMIC_CHANGESETS` proves the round commits or rolls back as + /// one unit. A payments-durable host without the atomic round gives + /// neither the coupling nor the fail-closed watermark backstop: it + /// can commit the Core record and watermark, then stop before the + /// payments write — and a one-shot reinstatement never re-emits to + /// retry the orphaned flip. Such a host is treated as payments-blind + /// for staging (the in-memory flip still happens; funds-safe, as + /// payment entries are display metadata). + pub const ROUND_COUPLED_PAYMENT_FLIPS: Self = + Self(Self::ATOMIC_CHANGESETS.0 | Self::DASHPAY_PAYMENTS.0); + pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) } @@ -208,6 +233,10 @@ mod tests { PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 ); + assert_eq!( + PersistenceCapabilities::ROUND_COUPLED_PAYMENT_FLIPS.bits(), + 0x801 + ); } #[test] From 57a88e20a25558c9d239c387b12c136742323d67 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:53:03 +0300 Subject: [PATCH 088/102] fix(platform-wallet-storage): bound the lifetime of unmaterialised sweep tombstones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The held-but-absent placeholder apply_sweep writes for a swept incoming payment's foreign inputs was permanent: no funding upsert ever overwrites it and no release ever names it, so anyone repeatedly double-spending payments at a wallet could grow core_utxos without limit (the KNOWN EXPOSURE block, dashpay/platform#4406). Creation cannot be gated — nothing on the record or at the upstream sweep site can prove an input foreign (dashpay/rust-dashcore#968: the proposed attested-ours set is empty by construction) — so bound the row's lifetime instead, mirroring key-wallet's prune_finalized_observed_spends doctrine for the same shape: - stamp each tombstone with the round's best-known processed height (core_utxos.held_since_height, V006), re-stamping on chained-sweep re-point, clearing on materialisation; - persist the chainlock height the changeset already carried and the store previously dropped (core_sync_state.chainlock_height, monotonic max); - after any height-advancing round, collect never-materialised held rows (height IS NULL, spent = 1) once min(chainlock, synced) clears their stamp by a 2-block margin — the InstantSend-path winner customarily mines one block after the stamp, and beyond the margin BIP158 filters matching input prevout scripts guarantee any delivery path that ever classifies the funding output also delivers the winner's spend. Like upstream, a no-op until a chainlock has been persisted. Unstamped legacy rows are back-filled with the current height first, so they wait a full margin from first sight. Also stop releasing a never-materialised claim in place: the zero-value spent = 0 leftover read as a phantom spendable coin through list_unspent_utxos. A released unmaterialised row is deleted outright — the funding upsert recreates the real row if the coin ever classifies — and the collector sweeps up pre-existing leftovers. --- .../migrations/V006__utxo_tombstone_stamp.rs | 35 ++ .../src/sqlite/persister.rs | 6 +- .../src/sqlite/schema/core_state.rs | 275 ++++++++++-- .../tests/sqlite_transaction_sweeps.rs | 393 +++++++++++++++++- 4 files changed, 660 insertions(+), 49 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs diff --git a/packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs b/packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs new file mode 100644 index 00000000000..187b9bbdf1e --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs @@ -0,0 +1,35 @@ +//! Stamp sweep tombstones and pin the chainlock finality boundary. +//! +//! `core_utxos.held_since_height` is the creation stamp of an +//! unmaterialised sweep tombstone — the placeholder row `apply_sweep` +//! writes for a held input whose funding output has never classified +//! (`height IS NULL AND spent = 1`; no other writer leaves `height` +//! NULL). The stamp is the wallet's best-known processed height at the +//! round that created (or re-pointed) the claim, and exists so the +//! collector in `core_state::apply` can bound the row's lifetime the +//! way key-wallet's `prune_finalized_observed_spends` bounds the +//! equivalent in-memory entries: evict once +//! `min(chainlock_height, synced_height)` passes the stamp by the +//! sweep margin. NULL means "stamped before this column existed" — +//! the collector back-fills it with the current height rather than +//! guessing, so legacy rows wait a full margin from first sight. +//! +//! `core_sync_state.chainlock_height` is the monotonic-max height of +//! the last applied chainlock, mirrored from +//! `CoreChangeSet::last_applied_chain_lock` (previously dropped by +//! this store). It is one half of the collector's finality boundary; +//! rows are never collected before a chainlock has been persisted, +//! matching upstream's "no-op until a chainlock has been applied". +//! +//! The partial index covers exactly the unmaterialised rows — the +//! collector's scan set — so the per-round sweep touches tombstones +//! only, not the wallet's full spent history. + +pub fn migration() -> String { + "ALTER TABLE core_utxos ADD COLUMN held_since_height INTEGER; + ALTER TABLE core_sync_state ADD COLUMN chainlock_height INTEGER; + CREATE INDEX idx_core_utxos_unmaterialized + ON core_utxos(wallet_id, held_since_height) + WHERE height IS NULL;" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 54801e4bd9f..52d943f0ac7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -838,7 +838,11 @@ impl PlatformWalletPersistence for SqlitePersister { // directly — through the loser's decoded inputs when its row is on // hand, and through the batch's own released set when it is not — // so it repoints or releases the placeholder regardless of how many - // sweeps deep it is. + // sweeps deep it is. A placeholder that never materialises is + // bounded, not permanent: `core_state::collect_finalized_tombstones` + // evicts it once the persisted chainlock finality boundary passes + // its creation stamp, so foreign-input junk from swept incoming + // payments cannot grow the store without limit. PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 108442527e6..d0591b78f44 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -127,8 +127,21 @@ pub fn apply( ])?; } } - if cs.last_processed_height.is_some() || cs.synced_height.is_some() { - upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; + let chainlock_height = cs + .last_applied_chain_lock + .as_ref() + .map(|cl| cl.block_height); + let heights_advanced = cs.last_processed_height.is_some() + || cs.synced_height.is_some() + || chainlock_height.is_some(); + if heights_advanced { + upsert_sync_state( + tx, + wallet_id, + cs.last_processed_height, + cs.synced_height, + chainlock_height, + )?; } // Sweeps run last so a winner arriving in this very changeset has its // own rows committed before the removal below touches the coins it took, @@ -139,6 +152,9 @@ pub fn apply( // The ordinary round. Everything below serves the sweep loop, and // building the survivor set would hash every input of every record // for a loop that never runs — with the write transaction open. + if heights_advanced { + collect_finalized_tombstones(tx, wallet_id)?; + } return Ok(()); } @@ -168,6 +184,15 @@ pub fn apply( .flat_map(|record| record.transaction.input.iter()) .map(|input| input.previous_output) .collect(); + // Best-known processed height at this round, read AFTER the sync-state + // upsert above so a height carried by this same changeset is included. + // Stamped onto every tombstone this round creates or re-points; the + // collector compares it against the chainlock finality boundary. A + // sweeps-only round carries no heights of its own, which is exactly why + // this reads the stored watermark instead of `cs` directly. `None` on a + // wallet that has never recorded a height — the collector back-fills + // such stamps before it ever collects. + let stamp = tombstone_stamp(tx, wallet_id)?; for batch in &cs.sweeps { // Only this stays per batch: a release is true of the wallet its own // sweep saw, which is what lets a later batch correct an earlier one. @@ -185,6 +210,7 @@ pub fn apply( &batch.superseded_by, &released, &swept_txids, + stamp, )?; } // Releases are outpoint-keyed facts, so they are applied by outpoint @@ -203,16 +229,36 @@ pub fn apply( // this round re-claimed was already filtered out of `released` // above. if !released.is_empty() { + // A released claim that never materialised is deleted outright + // rather than flipped to `spent = 0`: the row is all placeholder + // (`value = 0`, `script = X''`, `height` NULL — no writer but the + // tombstone insert leaves `height` NULL), so releasing it in + // place would surface a zero-value phantom coin through + // `list_unspent_utxos`. No row is the correct end state — if the + // funding output ever classifies, its ordinary upsert creates + // the real row freshly unspent, exactly as if the dead claim had + // never existed. Materialised rows carry real funding data and + // are released in place as before. + let mut release_drop_stmt = tx.prepare_cached( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND height IS NULL", + )?; let mut release_stmt = tx.prepare_cached( "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \ WHERE wallet_id = ?1 AND outpoint = ?2", )?; for outpoint in &released { let key = blob::encode_outpoint(outpoint)?; - release_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + let dropped = release_drop_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + if dropped == 0 { + release_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + } } } } + if heights_advanced { + collect_finalized_tombstones(tx, wallet_id)?; + } Ok(()) } @@ -252,21 +298,27 @@ pub fn apply( /// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the /// claim survives the funding upsert instead of being upserted away by it. /// -/// KNOWN EXPOSURE, deliberately not gated client-side: a swept INCOMING -/// payment reaches this loop too, and every sender-owned input it named -/// lands a placeholder that no funding upsert will ever overwrite and no -/// release will ever name — permanent zero-value junk, one row per foreign -/// input, growable by anyone willing to double-spend payments at this -/// wallet. It stays because nothing on the record can prove an input -/// foreign: `input_details` and `direction` are both computed from the -/// wallet's UTXO snapshot AT RECORD TIME, and the held-but-unfunded claim -/// this placeholder exists to preserve — our own coin, spent before its -/// funding output was classified — produces exactly a record whose input is -/// missing from `input_details` and whose direction reads `Incoming`, -/// indistinguishable from an attacker's fan-in. Gating on either would -/// trade bounded junk for lost holds. The clean fix is upstream-shaped: -/// carry per-wallet HELD outpoints on `TransactionsSwept` symmetric to -/// `released_outpoints`, so ownership is decided where it is known. +/// The placeholder is created UNCONDITIONALLY, ownership unproven, and its +/// LIFETIME is what is bounded — not its creation. A swept INCOMING payment +/// reaches this loop too, and every sender-owned input it named lands a +/// placeholder no funding upsert will ever overwrite and no release will +/// ever name — zero-value junk, one row per foreign input, growable by +/// anyone willing to double-spend payments at this wallet. It cannot be +/// gated at creation because nothing anywhere can prove an input foreign: +/// `input_details` and `direction` are computed from the wallet's UTXO +/// snapshot AT RECORD TIME, so the held-but-unfunded claim this placeholder +/// exists to preserve — our own coin, spent before its funding output was +/// classified — is byte-identical to an attacker's fan-in; and the sweep +/// site upstream cannot attest ownership either, because recording the +/// loser already removed its inputs from `utxos` and the spent marks carry +/// no ownership (dashpay/rust-dashcore#968 — the once-proposed "held +/// outpoints attested ours" set is empty by construction). Instead +/// `collect_finalized_tombstones` evicts never-materialised placeholders +/// once the chainlock finality boundary passes their creation stamp, +/// mirroring key-wallet's `prune_finalized_observed_spends` doctrine for +/// the same shape — so attacker junk lives for the finality window +/// (minutes), while a genuine claim materialises via the funding upsert +/// (gaining a real `height`) and permanently leaves the collectible set. /// /// Idempotent: a txid this store never recorded is a successful no-op, not an /// error. A sweep can legitimately name a transaction this wallet dropped, or @@ -281,6 +333,7 @@ fn apply_sweep( superseded_by: &dashcore::Txid, released: &HashSet, swept_txids: &HashSet, + stamp: Option, ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx .query_row( @@ -325,19 +378,30 @@ fn apply_sweep( // `spent_in_txid` moves with `spent`: a released input clears back to // NULL (nobody's claim), a held one is attributed to `superseded_by` so // the claim outlives this row's own deletion below. + // A held, never-materialised claim (`height IS NULL`) is re-stamped + // with this round's height: the claim now belongs to this sweep's + // winner, whose own confirmation is what the collector's margin is + // measured from. Materialised rows (`height` set) keep their NULL stamp + // — they are outside the collector's reach either way. let mut spend_stmt = tx.prepare_cached( - "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4 \ + "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4, \ + held_since_height = CASE \ + WHEN ?3 AND height IS NULL THEN COALESCE(?5, held_since_height) \ + ELSE held_since_height END \ WHERE wallet_id = ?1 AND outpoint = ?2", )?; // Only reached for a held input with no existing row — see the // doc comment above. `value`/`script`/`height`/`account_index` are // placeholders; the funding UTXO's own upsert overwrites them (and, // thanks to the `spent_in_txid` guard in `execute_upsert_utxo`, does - // not clear `spent` while doing it). + // not clear `spent` while doing it). `held_since_height` is the + // creation stamp `collect_finalized_tombstones` measures the row's + // bounded lifetime from. let mut tombstone_stmt = tx.prepare_cached( "INSERT INTO core_utxos \ - (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ - VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3)", + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid, \ + held_since_height) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3, ?4)", )?; for input in &loser.transaction.input { let outpoint = input.previous_output; @@ -384,13 +448,15 @@ fn apply_sweep( wallet_id.as_slice(), &key[..], !freed, - spent_in_txid + spent_in_txid, + stamp.map(i64::from) ])?; if affected == 0 && !freed { tombstone_stmt.execute(params![ wallet_id.as_slice(), &key[..], - AsRef::<[u8]>::as_ref(superseded_by) + AsRef::<[u8]>::as_ref(superseded_by), + stamp.map(i64::from) ])?; } } @@ -413,6 +479,10 @@ const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = // exactly the arrival that tombstone exists to survive, so it must not // double as the thing that erases it. `spent_in_txid` itself is left out of // the SET list entirely — untouched, it carries the claim forward. +// `held_since_height` DOES clear: this statement always binds a real +// funding `height`, so the row it lands on is materialised from here on — +// permanently outside `collect_finalized_tombstones`'s reach — and a stale +// creation stamp would only mislead. const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -421,6 +491,7 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ script = excluded.script, \ height = excluded.height, \ account_index = excluded.account_index, \ + held_since_height = NULL, \ spent = CASE WHEN core_utxos.spent_in_txid IS NOT NULL \ THEN core_utxos.spent ELSE excluded.spent END"; @@ -477,35 +548,155 @@ fn upsert_sync_state( wallet_id: &WalletId, last_processed: Option, synced: Option, + chainlock: Option, ) -> Result<(), WalletStorageError> { // Monotonic-max semantics — keep the larger of (current, new). - let current_raw: (Option, Option) = tx + let current = read_sync_heights(tx, wallet_id)?; + let max_or = |a: Option, b: Option| match (a, b) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + let lp = max_or(current.0, last_processed); + let sy = max_or(current.1, synced); + let cl = max_or(current.2, chainlock); + tx.execute( + "INSERT INTO core_sync_state \ + (wallet_id, last_processed_height, synced_height, chainlock_height) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id) DO UPDATE SET \ + last_processed_height = excluded.last_processed_height, \ + synced_height = excluded.synced_height, \ + chainlock_height = excluded.chainlock_height", + params![ + wallet_id.as_slice(), + lp.map(i64::from), + sy.map(i64::from), + cl.map(i64::from), + ], + )?; + Ok(()) +} + +/// The wallet's `(last_processed_height, synced_height, chainlock_height)` +/// watermark triple as read back from `core_sync_state`. +type SyncHeights = (Option, Option, Option); + +/// Read the wallet's [`SyncHeights`] watermarks. All-`None` when the row +/// is absent. +fn read_sync_heights( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let raw: (Option, Option, Option) = tx .query_row( - "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + "SELECT last_processed_height, synced_height, chainlock_height \ + FROM core_sync_state WHERE wallet_id = ?1", params![wallet_id.as_slice()], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()? - .unwrap_or((None, None)); - let current = ( - sync_height_u32("core_sync_state.last_processed_height", current_raw.0)?, - sync_height_u32("core_sync_state.synced_height", current_raw.1)?, - ); - let lp = match (current.0, last_processed) { + .unwrap_or((None, None, None)); + Ok(( + sync_height_u32("core_sync_state.last_processed_height", raw.0)?, + sync_height_u32("core_sync_state.synced_height", raw.1)?, + sync_height_u32("core_sync_state.chainlock_height", raw.2)?, + )) +} + +/// Blocks the finality boundary must clear past a tombstone's stamp +/// before the row is collectible. +/// +/// The stamp is taken when the sweep is OBSERVED, which on the +/// InstantSend path is before the winner mines — customarily in the very +/// next block, so the margin's first block covers it and the second is +/// slack. Requiring `boundary >= stamp + 2` therefore means the winner's +/// typical block is itself chain-locked and filter-scanned before the +/// hold is dropped, matching upstream's eviction precondition of +/// "spend height at or below the boundary" without knowing the winner's +/// height (the winner need not be wallet-relevant, so no row records +/// it). A winner that mines later than the margin allows is covered by +/// convergence, not the tombstone: BIP158 filters match input prevout +/// scripts, so any delivery path that ever classifies the funding output +/// also delivers the winner's spend and re-marks the coin — and upstream +/// itself retains nothing in memory for an unmined or unrecorded winner, +/// so the bounded row is never less protection than the wallet it +/// mirrors. +const TOMBSTONE_COLLECT_MARGIN: u32 = 2; + +/// Best-known processed height for stamping a tombstone created this +/// round: the max of the stored watermarks (the caller upserts incoming +/// heights first, so a height carried by the same round is included). +fn tombstone_stamp( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let (lp, sy, _) = read_sync_heights(tx, wallet_id)?; + Ok(match (lp, sy) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), + }) +} + +/// Evict never-materialised sweep tombstones once the chainlock finality +/// boundary passes their creation stamp — the storage-side mirror of +/// key-wallet's `prune_finalized_observed_spends`, which applies the same +/// doctrine to the same shape in memory: an entry at or below +/// `min(chainlock_height, synced_height)` is safe to forget, because the +/// spend it guards is chain-locked and every filter below the boundary +/// has been matched, so no delivery path can hand the coin back +/// unobserved. This is what bounds the junk an attacker can grow by +/// double-spending incoming payments (see `apply_sweep`): a foreign-input +/// placeholder lives for the finality window instead of forever, while a +/// genuine claim materialises through the funding upsert — gaining a real +/// `height` — and permanently leaves the collectible set, so nothing this +/// function deletes can ever be a coin the wallet still holds data for. +/// +/// Three passes, all narrowed to `height IS NULL` (only the tombstone +/// insert leaves `height` NULL, so the set is exactly the +/// never-materialised rows, served by the partial index): +/// +/// 1. Released leftovers (`spent = 0`) are deleted outright — a released, +/// never-materialised claim holds nothing and would read as a +/// zero-value phantom coin. The release path now deletes these +/// in-line; this pass self-heals rows written before it did. +/// 2. Held rows with no stamp (written before `held_since_height` +/// existed, or on a wallet with no recorded heights) are back-filled +/// with the current best-known height, so they wait a full margin from +/// first sight rather than being guessed collectible. +/// 3. Held rows whose stamp sits at least [`TOMBSTONE_COLLECT_MARGIN`] +/// below the boundary are collected. +/// +/// Like upstream, a no-op until a chainlock height has been persisted — +/// without a finality boundary nothing can be proven final. +fn collect_finalized_tombstones( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result<(), WalletStorageError> { + tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 0", + params![wallet_id.as_slice()], + )?; + let (lp, sy, cl) = read_sync_heights(tx, wallet_id)?; + let (Some(sy), Some(cl)) = (sy, cl) else { + return Ok(()); }; - let sy = match (current.1, synced) { - (Some(a), Some(b)) => Some(a.max(b)), - (a, b) => a.or(b), + let best = lp.map_or(sy, |lp| lp.max(sy)); + tx.execute( + "UPDATE core_utxos SET held_since_height = ?2 \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ + AND held_since_height IS NULL", + params![wallet_id.as_slice(), i64::from(best)], + )?; + let boundary = cl.min(sy); + let Some(cut) = boundary.checked_sub(TOMBSTONE_COLLECT_MARGIN) else { + return Ok(()); }; tx.execute( - "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ - VALUES (?1, ?2, ?3) \ - ON CONFLICT(wallet_id) DO UPDATE SET \ - last_processed_height = excluded.last_processed_height, \ - synced_height = excluded.synced_height", - params![wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from),], + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ + AND held_since_height <= ?2", + params![wallet_id.as_slice(), i64::from(cut)], )?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index b3c74ef2d61..80eb3c5f352 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -866,15 +866,32 @@ fn a_chained_sweep_before_funding_still_frees_an_earlier_tombstone_on_release() } assert!( - unspent(&conn, &w).contains(&unfunded_input), - "the chained sweep released this input, and its own funding TXO is \ - still unobserved — it must read as an ordinary spendable UTXO, not \ - stay stuck under the first sweep's placeholder" + !row_exists(&conn, &w, &unfunded_input), + "the chained sweep released this input while its funding TXO is \ + still unobserved — the placeholder must be deleted outright, not \ + flipped to a zero-value phantom that list_unspent would report" ); assert!( !unspent(&conn, &w).contains(&funded_input), "the second sweep's winner took the other input" ); + + // The funding output finally classifies: with the dead claim's row gone, + // the ordinary upsert creates the coin freshly unspent with real data. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + unspent(&conn, &w).contains(&unfunded_input), + "the released coin arrives as an ordinary spendable UTXO once its \ + funding output classifies" + ); } /// The held (not released) half of the chained-before-funding scenario @@ -1665,8 +1682,9 @@ fn a_release_applies_even_when_the_swept_txid_has_no_row() { { let conn = persister.lock_conn_for_test(); assert!( - unspent(&conn, &w).contains(&p), - "the release must reach the placeholder with no loser row to walk" + !row_exists(&conn, &w, &p), + "the release must reach the placeholder with no loser row to walk \ + — and delete it outright, since it never materialised" ); } @@ -1982,3 +2000,366 @@ fn a_co_swept_parent_known_only_through_the_childs_spend_is_still_removed() { was only ever the synthetic spent-only one" ); } + +// ───────────────────────── tombstone collection ───────────────────────── +// +// `collect_finalized_tombstones` bounds the LIFETIME of the unconditional +// held-but-absent placeholder: attacker-created foreign-input rows live +// until the chainlock finality boundary passes their creation stamp by +// `TOMBSTONE_COLLECT_MARGIN`, instead of forever. These tests drive the +// collector through ordinary `core_state::apply` rounds. + +fn chain_lock_at(height: u32) -> dashcore::ephemerealdata::chain_lock::ChainLock { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::BlockHash; + dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0xCC; 32]), + signature: BLSSignature::from([0u8; 96]), + } +} + +/// Apply a round carrying only chain progress: processed/synced watermarks +/// and a chainlock at `height`. +fn apply_heights(conn: &mut rusqlite::Connection, w: &WalletId, height: u32) { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(height), + synced_height: Some(height), + last_applied_chain_lock: Some(chain_lock_at(height)), + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); +} + +/// `(spent, height, held_since_height)` of a `core_utxos` row, or `None` +/// when absent. +fn utxo_row_state( + conn: &rusqlite::Connection, + w: &WalletId, + op: &OutPoint, +) -> Option<(bool, Option, Option)> { + let bytes = blob::encode_outpoint(op).unwrap(); + conn.query_row( + "SELECT spent, height, held_since_height FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .unwrap() +} + +/// Record a loser spending `input` (no funding row exists), then sweep it — +/// leaving the held-but-absent placeholder the collection tests reason about. +fn seed_tombstone( + conn: &mut rusqlite::Connection, + w: &WalletId, + input: OutPoint, + loser: Txid, + winner: Txid, +) { + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(loser, vec![input], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser], + superseded_by: winner, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); +} + +/// The attacker-shaped row: a swept incoming payment's foreign input lands +/// a placeholder, and the collector deletes it once +/// `min(chainlock_height, synced_height)` clears its stamp by the margin — +/// and not one block sooner. Bounded lifetime is the whole fix: without the +/// collector this row was permanent, growable without limit by anyone +/// repeatedly double-spending payments at this wallet. +#[test] +fn a_never_materialised_tombstone_is_collected_at_finality_and_not_before() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF1); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x50; 32]), 0); + let loser = Txid::from_byte_array([0x51; 32]); + let winner = Txid::from_byte_array([0x52; 32]); + + let mut conn = persister.lock_conn_for_test(); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner); + + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(100))), + "sanity: the sweep left a held, never-materialised row stamped with \ + the round's best-known height" + ); + + // Boundary one short of stamp + margin: the hold must survive — the + // winner customarily mines at stamp + 1, and the margin keeps the claim + // through that block's own finality. + apply_heights(&mut conn, &w, 101); + assert!( + row_exists(&conn, &w, &p), + "boundary 101 has not cleared stamp 100 by the margin — the hold stays" + ); + + apply_heights(&mut conn, &w, 102); + assert!( + !row_exists(&conn, &w, &p), + "boundary 102 cleared stamp 100 by the margin — the junk row is gone" + ); +} + +/// Synced height alone is not finality: with no chainlock ever persisted +/// the collector must not run, mirroring upstream's "no-op until a +/// chainlock has been applied". +#[test] +fn a_tombstone_is_never_collected_without_a_persisted_chainlock() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x53; 32]), 0); + let loser = Txid::from_byte_array([0x54; 32]); + let winner = Txid::from_byte_array([0x55; 32]); + + let mut conn = persister.lock_conn_for_test(); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(100), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + seed_tombstone(&mut conn, &w, p, loser, winner); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(500), + synced_height: Some(500), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &p), + "without a chainlock there is no finality boundary — the hold must \ + outlast any amount of synced-height progress" + ); + + // The moment a chainlock does land, the boundary exists and the aged + // stamp collects immediately. + apply_heights(&mut conn, &w, 500); + assert!( + !row_exists(&conn, &w, &p), + "the first persisted chainlock supplies the boundary and the \ + long-aged stamp collects" + ); +} + +/// The genuine claim the tombstone exists for: its funding output +/// classifies, the upsert's valve keeps it spent, and materialising +/// (gaining a real `height`) takes it out of the collector's reach forever. +#[test] +fn a_materialised_claim_is_never_collected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF3); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x61); + let funding_txid = Txid::from_byte_array([0x56; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x57; 32]); + let winner = Txid::from_byte_array([0x58; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(100))), + "sanity: held, unmaterialised, stamped" + ); + + // The funding output classifies: the valve keeps the coin spent, the + // row gains real funding data, and the stale stamp clears. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "sanity: materialised — real height, stamp cleared, still spent" + ); + + apply_heights(&mut conn, &w, 10_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "a materialised claim is the wallet's own coin held spent — no \ + boundary may ever collect it" + ); +} + +/// A tombstone written before `held_since_height` existed (or on a wallet +/// with no recorded heights) has a NULL stamp. The collector back-fills it +/// with the current best-known height on first sight rather than guessing, +/// so it waits a full margin from then — never collected in the same round +/// that first saw it. +#[test] +fn an_unstamped_tombstone_is_backfilled_before_it_can_be_collected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF4); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x59; 32]), 0); + let loser = Txid::from_byte_array([0x5A; 32]); + let winner = Txid::from_byte_array([0x5B; 32]); + + let mut conn = persister.lock_conn_for_test(); + // No heights have ever been recorded: the sweep stamps NULL. + seed_tombstone(&mut conn, &w, p, loser, winner); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "sanity: no watermark existed, so the stamp is NULL" + ); + + apply_heights(&mut conn, &w, 1_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(1_000))), + "first collection pass back-fills the stamp instead of collecting" + ); + + apply_heights(&mut conn, &w, 1_002); + assert!( + !row_exists(&conn, &w, &p), + "the back-filled stamp ages out like any other" + ); +} + +/// A chained sweep that re-points a still-unfunded claim to a new winner +/// also re-stamps it: the claim now belongs to a winner whose confirmation +/// is measured from this round, not the original sweep's. +#[test] +fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF5); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x5C; 32]), 0); + let first_loser = Txid::from_byte_array([0x5D; 32]); + let second_loser = Txid::from_byte_array([0x5E; 32]); + let final_winner = Txid::from_byte_array([0x5F; 32]); + + let mut conn = persister.lock_conn_for_test(); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, first_loser, second_loser); + assert_eq!( + utxo_row_state(&conn, &w, &p).and_then(|(_, _, s)| s), + Some(100), + "sanity: stamped at the first sweep's height" + ); + + apply_heights(&mut conn, &w, 105); + // The first winner is itself swept, still holding the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(105))), + "the re-pointed claim is re-stamped to the later sweep's height" + ); +} + +/// Legacy shape self-heal: a zero-value released placeholder written +/// before the release path deleted them (`height` NULL, `spent = 0`) holds +/// no claim and is swept up by the collector's first pass — chainlock or +/// not — instead of reading as a phantom spendable coin forever. +#[test] +fn a_legacy_released_placeholder_is_swept_up_by_the_collector() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF6); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x60; 32]), 0); + let mut conn = persister.lock_conn_for_test(); + // Plant the pre-fix shape directly — the current release path can no + // longer produce it. + { + let bytes = blob::encode_outpoint(&p).unwrap(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 0, NULL)", + params![w.as_slice(), &bytes[..]], + ) + .unwrap(); + } + assert!( + unspent(&conn, &w).contains(&p), + "sanity: the legacy phantom" + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(100), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !row_exists(&conn, &w, &p), + "the first height-carrying round deletes the claimless leftover" + ); +} From 1d16f592fa7846cfa740dec1134859f3cd9c3863 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:02:41 +0300 Subject: [PATCH 089/102] fix(kotlin-sdk): bound the lifetime of swept pending-input tombstones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending_inputs row onWalletChangesetTransactionsSwept repurposes as a durable claim (isSweptTombstone) never drains when its outpoint is a foreign input of a swept incoming payment — no funding TXO ever arrives — so it was permanent junk an attacker could grow one row per input by repeatedly double-spending payments at the wallet: the Room half of the same exposure the SQLite store's core_utxos placeholder carried (dashpay/platform#4406). Ownership cannot be proven at creation (dashpay/rust-dashcore#968), so bound the row's lifetime instead, mirroring the SQLite store's collect_finalized_tombstones: - v13 adds pending_inputs.heldSinceHeight (nullable, additive), stamped with the wallet's synced height when a sweep flags a tombstone and re-stamped when a chained sweep re-points it; - onWalletChangesetHeader collects tombstones once the synced height clears their stamp by a 2-block margin, back-filling unstamped (pre-migration) rows with the current height first, and only after a chainlock has been applied — the chainlock's own height is bincode-opaque on this side of the FFI, so the boundary is the synced height, the filter-coverage half of the upstream doctrine. A genuine claim is untouched: its funding TXO's arrival drains the hold onto the TxoEntity and deletes the pending rows, leaving nothing for the collector to see. --- .../13.json | 4144 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 41 +- .../dashsdk/persistence/DashDatabase.kt | 27 +- .../PlatformWalletPersistenceHandler.kt | 61 +- .../dashsdk/persistence/dao/DocumentDao.kt | 29 + .../entities/PendingInputEntity.kt | 18 + .../PlatformWalletPersistenceHandlerTest.kt | 229 + 7 files changed, 4543 insertions(+), 6 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json new file mode 100644 index 00000000000..527d0089451 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json @@ -0,0 +1,4144 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "ee7037cf7dec388532aab3174187d2e2", + "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, `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": "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, `isGloballySwept` INTEGER NOT NULL DEFAULT 0, 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 + }, + { + "fieldPath": "isGloballySwept", + "columnName": "isGloballySwept", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `heldSinceHeight` 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": "heldSinceHeight", + "columnName": "heldSinceHeight", + "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`)" + } + ], + "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, 'ee7037cf7dec388532aab3174187d2e2')" + ] + } +} \ 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 38ea3d84f60..1ad197b4357 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 @@ -477,13 +477,46 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v12 → v13 adds `pending_inputs.heldSinceHeight` (nullable, no + * default) — additive. Pre-existing tombstones must survive and read + * back unstamped (NULL — the collector back-fills them before it ever + * collects), and the column must accept an explicit stamp on write. + */ + @Test + fun migrate12To13AddsTombstoneStamp() { + val legacy = helper.createDatabase(dbName, 12) + legacy.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + + "walletId, createdAt, isSweptTombstone) " + + "VALUES (x'04', 0, x'05', x'06', 0, 1)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 13, true, DashDatabase.MIGRATION_12_13) + db.query("SELECT heldSinceHeight FROM pending_inputs WHERE outpoint = x'04'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration tombstones read back unstamped", c.isNull(0)) + } + db.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + + "walletId, createdAt, isSweptTombstone, heldSinceHeight) " + + "VALUES (x'07', 0, x'05', x'06', 0, 1, 1234)", + ) + db.query("SELECT heldSinceHeight FROM pending_inputs WHERE outpoint = x'07'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1234, 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, - 12, + 13, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -493,16 +526,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, DashDatabase.MIGRATION_11_12, + DashDatabase.MIGRATION_12_13, ).close() } - /** The full chain from v1 must also land on a valid v12 schema. */ + /** The full chain from v1 must also land on a valid v13 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 12, + 13, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -515,6 +549,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, DashDatabase.MIGRATION_11_12, + DashDatabase.MIGRATION_12_13, ).close() } } 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 6ab66475410..25ec14dac2e 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 @@ -141,9 +141,19 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * durable from the first committed callback regardless of what the others * do. Additive with a default, so every pre-migration row reads back as not * swept. + * + * Version 13 (bounded tombstone lifetime): adds + * `pending_inputs.heldSinceHeight`, the creation stamp the header + * callback's collector measures a swept tombstone's bounded lifetime + * from. A tombstone for a foreign input of a swept incoming payment never + * drains, and before this column existed it was permanent — junk an + * attacker could grow without limit by double-spending payments at the + * wallet. Nullable and additive: pre-migration rows read back unstamped, + * and the collector back-fills them with the current synced height before + * it ever collects them. */ @Database( - version = 12, + version = 13, exportSchema = true, entities = [ WalletEntity::class, @@ -606,6 +616,20 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v12 → v13: adds `pending_inputs.heldSinceHeight` (additive, + * nullable — no default needed) — see the version-13 class doc + * above. Pre-migration tombstones read back unstamped and are + * back-filled by the collector before they can be collected. + */ + val MIGRATION_12_13: Migration = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `heldSinceHeight` INTEGER", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -626,6 +650,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, + MIGRATION_12_13, ) .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 576cc0bcf05..e71bce4dac2 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 @@ -705,6 +705,32 @@ class PlatformWalletPersistenceHandler( lastUpdated = now(), ), ) + // Bounded tombstone lifetime (the Room mirror of the SQLite + // store's `collect_finalized_tombstones`): once the synced + // height clears a swept tombstone's stamp by the margin, the + // row has provably never drained — a genuine claim's row is + // deleted by the drain in `onWalletChangesetUtxoAdded` when + // its funding TXO lands — so what remains is junk from foreign + // inputs of swept incoming payments, previously permanent and + // attacker-growable. Unstamped rows are back-filled first so + // they wait a full margin from first sight. Gated on a + // chainlock having been applied at some point, mirroring + // upstream's "no-op until a chainlock has been applied"; the + // chainlock's own height is bincode-opaque on this side of the + // FFI, so the boundary is the synced height alone — the + // winner's finality never depended on it (a sweep only fires + // for a chainlocked or InstantSend-locked winner), and synced + // height is the half of the upstream boundary that certifies + // filter coverage. + val chainLockApplied = lastAppliedChainLockBytes.isNotEmpty() || + wallet.lastAppliedChainLockBytes?.isNotEmpty() == true + if (hasSyncedHeight && syncedHeight > 0 && chainLockApplied) { + db.documentDao().backfillSweptTombstoneStamps(walletId, syncedHeight) + val cut = syncedHeight - SWEPT_TOMBSTONE_COLLECT_MARGIN + if (cut > 0) { + db.documentDao().collectFinalizedSweptTombstones(walletId, cut) + } + } } 0 } @@ -1219,7 +1245,14 @@ class PlatformWalletPersistenceHandler( releasedOutpoints: Array, ): Int = guarded { stage(walletId) { db -> - if (db.walletDao().getByWalletId(walletId) == null) return@stage + val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage + // Creation stamp for any tombstone this round flags or + // re-points: the wallet's best-known synced height, `null` + // while none has been recorded (the collector back-fills + // rather than guesses). The collector in + // `onWalletChangesetHeader` measures the tombstone's bounded + // lifetime from this. + val tombstoneStamp = wallet.syncedHeight.takeIf { it > 0 } // Hold every input first, then free the ones upstream named: the // released set spans the whole round's removals, so it is // applied once rather than per transaction. @@ -1296,6 +1329,7 @@ class PlatformWalletPersistenceHandler( spendingTransactionTxid = null, spendingTxid = supersededBy[i], isSweptTombstone = true, + heldSinceHeight = tombstoneStamp, ) }, ) @@ -1316,8 +1350,16 @@ class PlatformWalletPersistenceHandler( db.documentDao().deletePendingInputs(gonePrior) } if (stillHeld.isNotEmpty()) { + // Re-pointed to a new winner ⇒ re-stamped: the claim now + // belongs to a winner whose confirmation is measured from + // this round, not the original sweep's. db.documentDao().updatePendingInputs( - stillHeld.map { it.copy(spendingTxid = supersededBy[i]) }, + stillHeld.map { + it.copy( + spendingTxid = supersededBy[i], + heldSinceHeight = tombstoneStamp ?: it.heldSinceHeight, + ) + }, ) } } @@ -3580,6 +3622,21 @@ class PlatformWalletPersistenceHandler( /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 + /** + * Blocks the synced height must clear past a swept tombstone's + * [PendingInputEntity.heldSinceHeight] stamp before the collector + * in [onWalletChangesetHeader] deletes it. Mirrors the SQLite + * store's `TOMBSTONE_COLLECT_MARGIN`: the stamp is taken when the + * sweep is observed — on the InstantSend path before the winner + * mines, customarily in the very next block — so the margin's + * first block covers the winner's own confirmation and the second + * is slack. Past the margin, convergence carries the claim + * instead: BIP158 filters match input prevout scripts, so any + * delivery path that ever classifies the funding output also + * delivers the winner's spend. + */ + private const val SWEPT_TOMBSTONE_COLLECT_MARGIN = 2 + /** `Network.testnet` rawValue — the Swift fallback network. */ private const val NETWORK_TESTNET = 1 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 565b68f1674..0ec1e8b58a3 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 @@ -237,6 +237,35 @@ interface DocumentDao { ) suspend fun hasOtherWalletPendingInput(txid: ByteArray, walletId: ByteArray): Boolean + /** + * Back-fill unstamped swept tombstones with the current synced + * height, so rows written before `heldSinceHeight` existed (or while + * no height was on record) wait a full collection margin from first + * sight instead of being guessed collectible. + */ + @Query( + "UPDATE pending_inputs SET heldSinceHeight = :stamp " + + "WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND heldSinceHeight IS NULL", + ) + suspend fun backfillSweptTombstoneStamps(walletId: ByteArray, stamp: Int) + + /** + * Bounded tombstone lifetime: delete this wallet's swept tombstones + * whose stamp the synced height has cleared by the collection margin + * (`:cut` = synced − margin, computed by the caller). 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. + */ + @Query( + "DELETE FROM pending_inputs " + + "WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND heldSinceHeight IS NOT NULL AND heldSinceHeight <= :cut", + ) + suspend fun collectFinalizedSweptTombstones(walletId: ByteArray, cut: Int) + @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) 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 6025a15c890..6969e58c78e 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 @@ -76,4 +76,22 @@ data class PendingInputEntity( */ @ColumnInfo(defaultValue = "0") val isSweptTombstone: Boolean = false, + /** + * Creation stamp of a swept tombstone: the wallet's `syncedHeight` at + * the round that flagged (or re-pointed) this row. A tombstone whose + * outpoint is a foreign input of a swept incoming payment never + * drains — no funding TXO ever arrives — so without a bound it is + * permanent junk an attacker grows one row per input by repeatedly + * double-spending payments at this wallet. The header callback's + * collector deletes tombstones once `syncedHeight` clears this stamp + * by the sweep margin (the storage mirror of key-wallet's + * `prune_finalized_observed_spends` doctrine); a genuine claim drains + * into its TXO on funding arrival and leaves the collectible set with + * the row. NULL means "flagged before this column existed, or with no + * synced height on record" — the collector back-fills it with the + * current height rather than guessing, so such rows wait a full + * margin from first sight. Nullable, so the ADD COLUMN migration + * needs no default and pre-migration rows read as unstamped. + */ + val heldSinceHeight: Int? = null, ) 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 5d481d1eb86..5e4486e56ba 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 @@ -4712,4 +4712,233 @@ class PlatformWalletPersistenceHandlerTest { // Unchanged pre-invitation behavior: no account row conjured. assertTrue(db.accountDao().observeByWallet(walletId).first().isEmpty()) } + + // ── 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) + } + + /** Record a loser spending [outpoint] (funding unknown), then sweep it. */ + private fun seedSweptTombstone(outpoint: ByteArray, loser: ByteArray, winner: ByteArray) { + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + outpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loser), arrayOf(winner), emptyArray(), + ) + handler.onChangesetEnd(walletId, success = true) + } + + @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 header callback's collector deletes it once + // the synced height clears its stamp by the margin — and not one + // block sooner: the winner customarily mines at stamp + 1, and the + // margin keeps the claim through that block. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 71 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 72 }, ByteArray(32) { 73 }) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the sweep flagged the row", tombstone.isSweptTombstone) + assertEquals( + "the tombstone is stamped with the wallet's synced height", + 100, tombstone.heldSinceHeight, + ) + + headerRound(handler, 101) + assertEquals( + "boundary 101 has not cleared stamp 100 by the margin — the hold stays", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 102) + assertTrue( + "boundary 102 cleared stamp 100 by the margin — the junk row is gone", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aSweptTombstoneOutlivesAnySyncProgressWithoutAChainLock() = runTest { + // Synced height alone is not finality: until a chainlock has been + // applied the collector must not run, mirroring upstream's (and the + // SQLite store's) "no-op until a chainlock has been applied". The + // moment one lands, the aged stamp collects. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + headerRound(handler, 100, chainLockBytes = ByteArray(0)) + + val fundingTxid = ByteArray(32) { 74 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 75 }, ByteArray(32) { 76 }) + + headerRound(handler, 500, chainLockBytes = ByteArray(0)) + assertEquals( + "no chainlock has ever been applied — the hold outlasts any " + + "amount of synced-height progress", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 500) + assertTrue( + "the first applied chainlock supplies the boundary and the " + + "long-aged 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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 77 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 79 } + seedSweptTombstone(p, ByteArray(32) { 78 }, winner) + assertEquals( + "sanity: held, undrained, stamped", + 100, db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + ) + + 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) + + 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 anUnstampedTombstoneIsBackfilledBeforeItCanBeCollected() = runTest { + // A tombstone flagged while no synced height was on record (or + // written before `heldSinceHeight` existed — the v12 → v13 + // migration leaves those NULL) is back-filled with the current + // height on the collector's first sight of it, never collected in + // that same round, and then ages out like any other. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 80 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 81 }, ByteArray(32) { 82 }) + assertNull( + "sanity: no synced height existed, so the stamp is NULL", + db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + ) + + headerRound(handler, 1_000) + assertEquals( + "first collection pass back-fills the stamp instead of collecting", + 1_000, db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + ) + + headerRound(handler, 1_002) + assertTrue( + "the back-filled stamp ages out like any other", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { + // A chained sweep that re-points a still-unfunded claim to a new + // winner also re-stamps it: the claim now belongs to a winner whose + // confirmation is measured from this round, not the original + // sweep's. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + headerRound(handler, 100) + + 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) + assertEquals( + "sanity: stamped at the first sweep's height", + 100, db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + ) + + headerRound(handler, 105) + // The first winner's own record, then its sweep — the carry-forward + // path that re-points the earlier tombstone. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), + ) + 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 sweep's height", + 105, row.heldSinceHeight, + ) + } + } } From 78ae25d66a1c036de8e7f9a7de268def00215a1a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:11:28 +0300 Subject: [PATCH 090/102] test(platform-wallet-storage): keep the re-point fixture inside the collection margin Advancing the boundary to 105 let the collector reap the stamp-100 tombstone before the second sweep ran, so the test was exercising the insert path's re-creation rather than the UPDATE's re-stamp CASE. One block of progress keeps the row alive through the chained sweep and pins the genuine re-point + re-stamp behavior. --- .../tests/sqlite_transaction_sweeps.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 80eb3c5f352..e1b3f35062c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -2288,7 +2288,10 @@ fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { "sanity: stamped at the first sweep's height" ); - apply_heights(&mut conn, &w, 105); + // One block of progress — within the collection margin, so the + // tombstone survives to be re-pointed (through the UPDATE's re-stamp + // CASE) rather than collected and re-created by the insert path. + apply_heights(&mut conn, &w, 101); // The first winner is itself swept, still holding the unfunded input. { let tx = conn.transaction().unwrap(); @@ -2314,7 +2317,7 @@ fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(105))), + Some((true, None, Some(101))), "the re-pointed claim is re-stamped to the later sweep's height" ); } From 175353585023d1297a6363985b8b9b16097e4b4c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:12:27 +0300 Subject: [PATCH 091/102] fix(swift-sdk): bound the lifetime of swept pending-input tombstones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PersistentPendingInput row applySweptTransaction repurposes as a durable claim (isSweptTombstone) never drains when its outpoint is a foreign input of a swept incoming payment — no funding TXO ever arrives — so it was permanent junk an attacker could grow one row per input by repeatedly double-spending payments at the wallet: the SwiftData half of the same exposure the SQLite store's core_utxos placeholder carried (dashpay/platform#4406). Ownership cannot be proven at creation (dashpay/rust-dashcore#968), so bound the row's lifetime instead, mirroring the SQLite store's collect_finalized_tombstones: - heldSinceHeight (optional, lightweight-migrated) stamps a tombstone with the wallet's synced height when a sweep flags it and re-stamps it when a chained sweep re-points it; - persistWalletChangeset collects tombstones once the synced height clears their stamp by a 2-block margin, back-filling unstamped (pre-property) rows with the current height first, and only after a chainlock has been applied — the chainlock's own height is bincode-opaque on this side of the FFI, so the boundary is the synced height, the filter-coverage half of the upstream doctrine. A genuine claim is untouched: its funding TXO's arrival drains the hold onto the PersistentTxo and deletes the pending rows, leaving nothing for the collector to see. --- .../Models/PersistentPendingInput.swift | 18 ++ .../PlatformWalletPersistenceHandler.swift | 98 +++++++- .../SweptTransactionPersistTests.swift | 219 ++++++++++++++++++ 3 files changed, 332 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index 0b629bc566f..eb66cf3dabc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -93,6 +93,24 @@ public final class PersistentPendingInput { /// existing rows migrate as ordinary pending entries. public var isSweptTombstone: Bool = false + /// Creation stamp of a swept tombstone: the wallet's `syncedHeight` at + /// the round that flagged (or re-pointed) this row. A tombstone whose + /// outpoint is a foreign input of a swept incoming payment never + /// drains — no funding TXO ever arrives — so without a bound it is + /// permanent junk an attacker grows one row per input by repeatedly + /// double-spending payments at this wallet. The changeset-header + /// collector (`collectFinalizedSweptTombstones`) deletes tombstones + /// once `syncedHeight` clears this stamp by the sweep margin — the + /// storage mirror of key-wallet's `prune_finalized_observed_spends` + /// doctrine; a genuine claim drains into its TXO on funding arrival + /// and leaves the collectible set with the row. `nil` means "flagged + /// before this property existed, or with no synced height on record" + /// — the collector back-fills it with the current height rather than + /// guessing, so such rows wait a full margin from first sight. + /// Optional, so existing stores lightweight-migrate with the rows + /// reading unstamped. + public var heldSinceHeight: UInt32? + public init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 24044bdd11c..875741fb95a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -924,6 +924,29 @@ 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 synced + // height clears a swept tombstone's stamp by the margin, 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. + // Gated on a chainlock having been applied at some point, + // mirroring upstream's "no-op until a chainlock has been + // applied"; the chainlock's own height is bincode-opaque on + // this side of the FFI, so the boundary is the synced height + // alone — the winner's finality never depended on it (a sweep + // only fires for a chainlocked or InstantSend-locked winner), + // and synced height is the half of the upstream boundary that + // certifies filter coverage. + if cs.has_chain, cs.chain.has_synced_height, cs.chain.synced_height > 0, + wallet.lastAppliedChainLockBytes?.isEmpty == false { + collectFinalizedSweptTombstones( + walletId: walletId, + syncedHeight: 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 @@ -955,6 +978,61 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Blocks the synced height must clear past a swept tombstone's + /// `heldSinceHeight` stamp before `collectFinalizedSweptTombstones` + /// deletes it. Mirrors the SQLite store's `TOMBSTONE_COLLECT_MARGIN`: + /// the stamp is taken when the sweep is observed — on the InstantSend + /// path before the winner mines, customarily in the very next block — + /// so the margin's first block covers the winner's own confirmation + /// and the second is slack. Past the margin, convergence carries the + /// claim instead: BIP158 filters match input prevout scripts, so any + /// delivery path that ever classifies the funding output also delivers + /// the winner's spend. + private static let sweptTombstoneCollectMargin: UInt32 = 2 + + /// Delete this wallet's swept tombstones whose stamp `syncedHeight` + /// has cleared by [`sweptTombstoneCollectMargin`], back-filling + /// unstamped rows (written before `heldSinceHeight` existed, or while + /// no height was on record) with the current height so they wait a + /// full margin from first sight. See the property doc on + /// `PersistentPendingInput.heldSinceHeight` for why the bound exists. + /// + /// Housekeeping, not correctness: a pass that cannot run self-heals on + /// the next height-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, syncedHeight: 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 + } + let cut = syncedHeight > Self.sweptTombstoneCollectMargin + ? syncedHeight - Self.sweptTombstoneCollectMargin + : nil + for pending in rows where pending.isSweptTombstone && !pending.isDeleted { + guard let stamp = pending.heldSinceHeight else { + pending.heldSinceHeight = syncedHeight + continue + } + if let cut, stamp <= cut { + backgroundContext.delete(pending) + } + } + } + /// 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 @@ -989,9 +1067,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return false } - guard wallet != nil else { return true } + guard let wallet else { return true } guard count > 0, let sweepsPtr = sweeps else { return true } + // Creation stamp for any tombstone this round flags or + // re-points: the wallet's best-known synced height, `nil` + // while none has been recorded (the collector back-fills + // rather than guesses). `collectFinalizedSweptTombstones` + // measures the tombstone's bounded lifetime from this. + let tombstoneStamp: UInt32? = wallet.syncedHeight > 0 ? wallet.syncedHeight : nil + // 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 @@ -1099,7 +1184,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { released: released, coSwept: coSwept, row: row, - priorTombstones: tombstonesBySpender[txid] ?? [] + priorTombstones: tombstonesBySpender[txid] ?? [], + stamp: tombstoneStamp ) } } @@ -1283,7 +1369,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { released: Set, coSwept: Set, row: PersistentTransaction?, - priorTombstones: [PersistentPendingInput] + priorTombstones: [PersistentPendingInput], + stamp: UInt32? ) { if let row { // The global half, done every time this function runs regardless @@ -1353,6 +1440,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { pending.spendingTransaction = nil pending.spendingTxid = supersededBy pending.isSweptTombstone = true + pending.heldSinceHeight = stamp } // Whatever is still attached to `row` after the scoping above @@ -1411,7 +1499,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { { backgroundContext.delete(pending) } else { + // Re-pointed to a new winner ⇒ re-stamped: the claim now + // belongs to a winner whose confirmation is measured from + // this round, not the original sweep's. pending.spendingTxid = supersededBy + pending.heldSinceHeight = stamp ?? pending.heldSinceHeight } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index 19bd26a5e01..c8f9d6085ba 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -2026,4 +2026,223 @@ final class SweptTransactionPersistTests: XCTestCase { } _ = handler.endChangeset(walletId: walletId, success: true) } + + // MARK: - Bounded tombstone lifetime + + /// One committed round carrying chain progress: the synced height and + /// (unless the caller opts out) chainlock bytes, driving the + /// `collectFinalizedSweptTombstones` pass in `persistWalletChangeset`. + private func heightsRound( + _ handler: PlatformWalletPersistenceHandler, + synced: UInt32, + chainLock: Bool = true + ) { + 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) + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Record a loser spending `(fundingTxid, 0)` with the funding side + /// unobserved, then sweep it — leaving the pending tombstone the + /// collection tests reason about. + private func seedSweptTombstone( + _ handler: PlatformWalletPersistenceHandler, + _ container: ModelContainer + ) throws { + let context = ModelContext(container) + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + } + + private func pendingRows(_ container: ModelContainer) throws -> [PersistentPendingInput] { + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// 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 changeset-header collector deletes it once the + /// synced height clears its stamp by the margin — and not one block + /// sooner: the winner customarily mines at stamp + 1, and the margin + /// keeps the claim through that block. + func testASweptTombstoneIsCollectedAtFinalityAndNotBefore() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + heightsRound(handler, synced: 100) + try seedSweptTombstone(handler, container) + + let tombstone = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(tombstone.isSweptTombstone, "sanity: the sweep flagged the row") + XCTAssertEqual( + tombstone.heldSinceHeight, 100, + "the tombstone is stamped with the wallet's synced height" + ) + + heightsRound(handler, synced: 101) + XCTAssertEqual( + try pendingRows(container).count, 1, + "boundary 101 has not cleared stamp 100 by the margin — the hold stays" + ) + + heightsRound(handler, synced: 102) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "boundary 102 cleared stamp 100 by the margin — the junk row is gone" + ) + } + + /// Synced height alone is not finality: until a chainlock has been + /// applied the collector must not run, mirroring upstream's (and the + /// SQLite store's) "no-op until a chainlock has been applied". The + /// moment one lands, the aged stamp collects. + func testASweptTombstoneOutlivesSyncProgressWithoutAChainLock() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + heightsRound(handler, synced: 100, chainLock: false) + try seedSweptTombstone(handler, container) + + heightsRound(handler, synced: 500, chainLock: false) + XCTAssertEqual( + try pendingRows(container).count, 1, + "no chainlock has ever been applied — the hold outlasts any " + + "amount of synced-height progress" + ) + + heightsRound(handler, synced: 500) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the first applied chainlock 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 sync 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() + heightsRound(handler, synced: 100) + try seedSweptTombstone(handler, container) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, 100, + "sanity: held, undrained, stamped" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "sanity: the drain consumed the pending rows" + ) + + heightsRound(handler, synced: 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 tombstone flagged while no synced height was on record (or written + /// before `heldSinceHeight` existed — lightweight migration leaves + /// those `nil`) is back-filled with the current height on the + /// collector's first sight of it, never collected in that same round, + /// and then ages out like any other. + func testAnUnstampedTombstoneIsBackfilledBeforeItCanBeCollected() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container) + XCTAssertNil( + try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, + "sanity: no synced height existed, so the stamp is nil" + ) + + heightsRound(handler, synced: 1_000) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, 1_000, + "first collection pass back-fills the stamp instead of collecting" + ) + + heightsRound(handler, synced: 1_002) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the back-filled stamp ages out like any other" + ) + } + + /// A chained sweep that re-points a still-unfunded claim to a new + /// winner also re-stamps it: the claim now belongs to a winner whose + /// confirmation is measured from this round, not the original sweep's. + func testARepointedTombstoneIsRestampedToTheLaterSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + heightsRound(handler, synced: 100) + try seedSweptTombstone(handler, container) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, 100, + "sanity: stamped at the first sweep's height" + ) + + // One block of progress — within the collection margin, so the + // tombstone survives to be re-pointed rather than collected. + heightsRound(handler, synced: 101) + // The first winner is itself swept — 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)]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.heldSinceHeight, 101, + "re-pointed ⇒ re-stamped to the later sweep's height" + ) + } } From 16e88914c4e119b8b6cc5d0020faf20f3e2841ad Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:34:07 +0300 Subject: [PATCH 092/102] refactor(platform-wallet): extract the sweep's payment-flip coupling into its own PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the bit-11 behavioral block so the funds-critical sweep core — pin bump, producer arm, watermark gate, SQLite/Swift/Kotlin persisters — can converge on its own: SweptPaymentFlips and the evidence-classed resolver in payments.rs, the adapter's flip staging with its same-fold retraction, rollback ledger, cross-drain re-validation (commit_batch_with_payment_revalidation) and WalletBatch::payments_overlay, and the ROUND_COUPLED_PAYMENT_FLIPS composite. The block returns unchanged as a stacked follow-up PR that carries its review findings together with their regression tests. What stays is the seam the extracted PR plugs back into: capability bits 10 and 11 with their FFI derivations and gate tests, the commit_batch/commit_wallet factoring, and the payment_handler no-op arms with their pinning test — a sweep still must not drive the payment hooks, whichever PR the flip lands in. Co-Authored-By: Claude Opus 5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 16 +- .../src/changeset/core_bridge.rs | 1554 +---------------- .../src/changeset/persistence_capabilities.rs | 29 - .../src/wallet/identity/network/mod.rs | 5 +- .../identity/network/payment_handler.rs | 40 +- .../src/wallet/identity/network/payments.rs | 879 +--------- 6 files changed, 58 insertions(+), 2465 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index a43db2c1885..efb7e3d20df 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1131,13 +1131,6 @@ impl FFIPersister { // wallet-event adapter would couple the flip to a round that // silently drops it: the accepted-and-ignored shape the sweep // bit's own gating exists to prevent, reproduced one channel over. - // Unlike `CORE_SWEEP_REMOVAL` below, this bit does NOT fold in the - // begin/end pair: its contract is per-callback durability of the - // overlay rows, which holds on a non-atomic host too. The - // round-coupling the adapter's staging needs is expressed as the - // `ROUND_COUPLED_PAYMENT_FLIPS` composite (this bit plus - // `ATOMIC_CHANGESETS`), so atomicity stays attested once, by the - // bit that owns it. if self.callbacks.on_persist_dashpay_payments_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); } @@ -6359,13 +6352,8 @@ mod tests { /// vtable leaves `on_persist_dashpay_payments_fn` unset, so even a /// host blindly OR-ing the bit must read as payments-blind: the /// wallet-event adapter keys the sweep's Failed-flip staging on this - /// bit (composed with `ATOMIC_CHANGESETS` — the - /// `ROUND_COUPLED_PAYMENT_FLIPS` composite — since the staging also - /// needs the round to commit as one unit), and an accepted-and-dropped - /// overlay is exactly the shape the gating exists to prevent. The bit - /// itself deliberately stays atomicity-free: it attests per-callback - /// durability, and the positive case below is such a host — one the - /// adapter now refuses to stage round-coupled overlays for. + /// bit, and an accepted-and-dropped overlay is exactly the shape the + /// gating exists to prevent. #[test] fn dashpay_payments_requires_the_slot_and_the_declaration() { fn persister_with( diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 4df89b52211..72e7b62299b 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -32,7 +32,7 @@ //! manager's lifetime; on shutdown, fire the [`CancellationToken`] to //! make the task exit cleanly. -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -158,11 +158,6 @@ struct BatchDiagnostics { /// Wallets in this drain that are faulted — whether they entered faulted /// or were faulted by it. Each wallet counts at most once per drain. faulted: usize, - /// Wallets whose `store()` this drain REJECTED outright (the `Err` - /// arm) — not the nominal-success sweep-capability freeze, whose round - /// did store. The caller uses this to roll back the in-memory payment - /// flips whose durable half the rejection discarded. - rejected_wallets: std::collections::BTreeSet, } impl BatchDiagnostics { @@ -305,33 +300,6 @@ async fn run_wallet_event_adapter

( // once per session rather than once per faulted batch. let mut freeze_logged = false; - // Whether the backend can give a payment flip the round-coupled - // durability this staging exists to provide — which takes BOTH bits - // of `ROUND_COUPLED_PAYMENT_FLIPS`. `DASHPAY_PAYMENTS` proves the - // overlay rows are durably applied: a sweep never re-emits once its - // round is durable, so handing the overlay to a host that silently - // drops it (Android deliberately keeps payment recording - // in-memory-only, its payments slot unwired) would leave this - // adapter believing a flip persisted — the accepted-and-ignored - // shape the sweep capability's own gating exists to prevent, one - // channel over. `ATOMIC_CHANGESETS` proves the round the flip rides - // commits or rolls back as one unit: on a host whose callbacks - // commit independently, the Core record and watermark can land - // durably and the process stop before the payments write — and for - // a one-shot chainlocked reinstatement nothing ever re-emits, so - // the reinstatement would stay durably recorded beside a payment - // durably `Failed`. Payments durability without the atomic round - // therefore gives neither the coupling nor the fail-closed - // watermark backstop, and such a host is treated exactly like a - // payments-blind one here: it still gets the in-memory flip (the - // truthful session state; the transaction IS dead) with nothing - // round-coupled — funds-safe, since payment entries are display - // metadata, and consistent with every other payment write on such - // hosts. - let payments_attested = persister - .persistence_capabilities() - .contains(PersistenceCapabilities::ROUND_COUPLED_PAYMENT_FLIPS); - loop { // Block for the first event of a batch. Everything already sitting in // the channel behind it is folded in below without another await, so a @@ -352,14 +320,6 @@ async fn run_wallet_event_adapter

( }; let mut batch: BTreeMap = BTreeMap::new(); - // The undo half of every sweep-failed payment flip staged into - // `batch` — kept OUTSIDE the batch because `commit_batch` consumes - // it, and the rollback only runs for wallets whose store was - // rejected (see below). - let mut payment_rollbacks: BTreeMap< - WalletId, - Vec, - > = BTreeMap::new(); let mut closed = false; { let wallet_id = event.wallet_id(); @@ -369,32 +329,9 @@ async fn run_wallet_event_adapter

( // read lock on the manager. let core = build_core_changeset(&wallet_manager, &event).await; let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; - let flips = swept_payment_flips_for_event(&wallet_manager, &event).await; let entry = batch.entry(wallet_id).or_default(); - retract_reinstated_payment_flips( - &wallet_manager, - entry, - &mut payment_rollbacks, - wallet_id, - &core.records, - ) - .await; - // After the same-fold retraction, so a sweep undone by this - // very event reads `Pending` (the hooks' ordinary confirm owns - // it) and only a durably `Failed` entry is corrected here. - let reinstated_flips = - crate::wallet::identity::network::confirm_reinstated_sent_payments_for_store( - &wallet_manager, - &wallet_id, - &core.records, - ) - .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); - if payments_attested { - fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); - fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, reinstated_flips); - } } // Fold in whatever else is already buffered. `try_recv` never waits, @@ -408,36 +345,9 @@ async fn run_wallet_event_adapter

( let core = build_core_changeset(&wallet_manager, &event).await; let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; - let flips = swept_payment_flips_for_event(&wallet_manager, &event).await; let entry = batch.entry(wallet_id).or_default(); - retract_reinstated_payment_flips( - &wallet_manager, - entry, - &mut payment_rollbacks, - wallet_id, - &core.records, - ) - .await; - // See the first-fold site: after the retraction, only - // a durably `Failed` entry is corrected here. - let reinstated_flips = - crate::wallet::identity::network::confirm_reinstated_sent_payments_for_store( - &wallet_manager, - &wallet_id, - &core.records, - ) - .await; entry.core.merge(core); entry.asset_locks.merge(asset_locks); - if payments_attested { - fold_payment_flips(entry, &mut payment_rollbacks, wallet_id, flips); - fold_payment_flips( - entry, - &mut payment_rollbacks, - wallet_id, - reinstated_flips, - ); - } folded += 1; } Err(TryRecvError::Empty) => break, @@ -450,41 +360,15 @@ async fn run_wallet_event_adapter

( // Commit the folded batch. The channel is lossless, so the only way a // watermark is held back is a rejected `store()` (the fail-closed - // backstop inside `commit_batch`). When the batch stages payment - // overlay rows, the commit re-validates them against live memory - // under the manager lock — see - // [`commit_batch_with_payment_revalidation`] for why that lock is - // held across the store itself. - let diag = commit_batch_with_payment_revalidation( - &wallet_manager, + // backstop inside `commit_batch`). + let diag = commit_batch( &*persister, batch, - &mut payment_rollbacks, folded, &mut fault, &sync_fault, &mut freeze_logged, - ) - .await; - - // A rejected round leaves NOTHING durable — the loser's record and - // the payment flip alike — so memory must return to the durable - // state or the replayed sweep (re-emitted by the re-scan, since the - // rejection kept the loser's record) would find the entries already - // `Failed` in memory, skip them as ineligible, and the store would - // never learn. Only rejected wallets roll back: a stored round — - // including one that stored but froze the watermark for a - // non-attesting sweep backend — has the flip durably applied. - for wallet_id in &diag.rejected_wallets { - if let Some(rollback) = payment_rollbacks.remove(wallet_id) { - crate::wallet::identity::network::rollback_payment_flips( - &wallet_manager, - wallet_id, - rollback, - ) - .await; - } - } + ); // One structured line per drain via the `log` facade so a tester // logcat is unambiguous about whether the watermark is advancing. @@ -548,10 +432,7 @@ where } /// Commit one wallet's folded changeset — the per-wallet unit of -/// [`commit_batch`], split out so -/// [`commit_batch_with_payment_revalidation`] can scope its manager-lock -/// hold to exactly the store that lock orders instead of the whole -/// multi-wallet batch. +/// [`commit_batch`]. fn commit_wallet

( persister: &P, wallet_id: WalletId, @@ -566,7 +447,6 @@ fn commit_wallet

( let WalletBatch { mut core, asset_locks, - payments_overlay, } = wallet_batch; { // Hold this wallet's durable watermark at the last fully persisted @@ -586,10 +466,7 @@ fn commit_wallet

( diag.record_frozen(h); } } - if core.is_empty_no_records() - && Merge::is_empty(&asset_locks) - && payments_overlay.is_empty() - { + if core.is_empty_no_records() && Merge::is_empty(&asset_locks) { // 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. @@ -634,9 +511,6 @@ fn commit_wallet

( // same store round-trip so the row and the record that // implies it land atomically. asset_locks: (!Merge::is_empty(&asset_locks)).then_some(asset_locks), - // The sweep-failed payments ride the same atomic round as the - // sweep that proved them dead — see `WalletBatch::payments_overlay`. - dashpay_payments_overlay: (!payments_overlay.is_empty()).then_some(payments_overlay), ..PlatformWalletChangeSet::default() }; match persister.store(wallet_id, cs) { @@ -681,9 +555,6 @@ fn commit_wallet

( // 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. - // Reported to the caller so the in-memory payment flips whose - // durable half this rejection discarded are rolled back. - diag.rejected_wallets.insert(wallet_id); if fault_and_freeze( diag, offered_height, @@ -764,20 +635,6 @@ fn freeze_synced_height_if_faulted(core: &mut CoreChangeSet, persistence_faulted struct WalletBatch { core: CoreChangeSet, asset_locks: AssetLockChangeSet, - /// Sent DashPay payments a folded sweep failed, riding the SAME - /// `store()` as the sweep that proved them dead. This is the flip's - /// only durability: a sweep never re-emits once its round is durable, - /// so a separately persisted flip whose store failed was lost for - /// good — while here a rejection keeps the loser's record with it, - /// the wallet faults, the re-scan re-detects the conflict, and the - /// re-emitted sweep recomputes the flip (after - /// [`run_wallet_event_adapter`] rolls the in-memory half back). - /// Folded last-write-wins per `(owner, txid)`, matching - /// `PlatformWalletChangeSet::merge`'s overlay rule. - payments_overlay: std::collections::BTreeMap< - dpp::prelude::Identifier, - std::collections::BTreeMap, - >, } /// Rebuild missing tracked asset locks from the records an event @@ -868,357 +725,6 @@ async fn reconstruct_asset_locks_for_event( reconstruction::reconstruct_tracked_asset_locks(wallet_manager, &wallet_id, &candidates).await } -/// The payment half of a sweep: flip the losers' `Pending` sent DashPay -/// payments to `Failed` in memory and hand back the overlay + rollback the -/// drain loop stages into the sweep's own store round. Every other event -/// is a no-op. See [`WalletBatch::payments_overlay`] for why this rides -/// the round instead of the payment hooks' own store. -async fn swept_payment_flips_for_event( - wallet_manager: &Arc>>, - event: &WalletEvent, -) -> crate::wallet::identity::network::SweptPaymentFlips { - match event { - WalletEvent::TransactionsSwept { - wallet_id, txids, .. - } => { - crate::wallet::identity::network::flip_swept_sent_payments_for_store( - wallet_manager, - wallet_id, - txids, - ) - .await - } - _ => crate::wallet::identity::network::SweptPaymentFlips::default(), - } -} - -/// The batch-level half of the reinstatement invariant: **a merged -/// changeset must never carry a sweep-derived assertion about a txid the -/// same fold reinstates.** Each sweep-derived channel enforces it where -/// that channel folds: -/// -/// - `core.sweeps.txids` — `CoreChangeSet::merge` retracts reinstated -/// txids from folded batches; -/// - `core.sweeps.released_outpoints` — deliberately NOT retracted; every -/// backend withholds an outpoint a surviving record claims, so the -/// reinstated transaction's own entries are inert (documented at the -/// merge); -/// - `asset_locks.removed` — `AssetLockChangeSet::merge` cancels a folded -/// sweep tombstone when the reinstating reconstruction upsert lands; -/// - `payments_overlay` + its rollback ledger — live at BATCH level, not -/// inside any sub-changeset's `Merge`, so their retraction lives here. -/// Any future sweep-derived channel carried on [`WalletBatch`] must get -/// its retraction in this function too. -/// -/// `reinstated` is exactly `core.records` of the event being folded — the -/// same set `CoreChangeSet::merge` keys its own retraction on, taken from -/// the same projection, so the two can never diverge. Without this, a -/// buffered `[TransactionsSwept(X), BlockProcessed(chainlocked X)]` fold -/// would commit X's reinstated record beside a stale `Failed` overlay row -/// — and because the payment hooks confirm X on their own task, that row -/// could overwrite a `Confirmed` the hooks had already persisted. -/// -/// Three moves per reinstated txid, all before the overlay can reach a -/// store: drop the staged overlay row, drop its rollback-ledger entry -/// (a later rejection of this round must not replay the dead undo), and -/// undo the in-memory flip through the guarded -/// [`rollback_payment_flips`] — which leaves the entry alone if the -/// hooks already advanced it to `Confirmed`, the table's terminal. -/// -/// This function only sees records THIS drain captured. A reinstating -/// record queued after `try_recv` stopped folding — whose payment hooks -/// may confirm the entry on their own task before this batch stores — is -/// the commit stage's job: [`commit_batch_with_payment_revalidation`] -/// re-validates every staged row against live memory under the manager -/// lock, held across the store. -async fn retract_reinstated_payment_flips( - wallet_manager: &Arc>>, - entry: &mut WalletBatch, - payment_rollbacks: &mut BTreeMap< - WalletId, - Vec, - >, - wallet_id: WalletId, - records: &[TransactionRecord], -) { - if records.is_empty() { - return; - } - let ledger_live = payment_rollbacks - .get(&wallet_id) - .is_some_and(|ledger| !ledger.is_empty()); - if entry.payments_overlay.is_empty() && !ledger_live { - return; - } - use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - - let reinstated: std::collections::HashSet = records - .iter() - .map(|record| record.txid.to_string()) - .collect(); - - // Only sweep-derived `Failed` rows are retracted. A `Confirmed` - // reinstatement row staged earlier in this same fold asserts exactly - // what the reinstating record says and stays on the round. - for rows in entry.payments_overlay.values_mut() { - rows.retain(|txid, row| { - !(reinstated.contains(txid) && row.status == PaymentStatus::Failed) - }); - } - entry.payments_overlay.retain(|_, rows| !rows.is_empty()); - - if let Some(ledger) = payment_rollbacks.get_mut(&wallet_id) { - let mut undo = Vec::new(); - ledger.retain(|entry_undo| { - if reinstated.contains(&entry_undo.txid) && entry_undo.wrote == PaymentStatus::Failed { - undo.push(entry_undo.clone()); - false - } else { - true - } - }); - if !undo.is_empty() { - crate::wallet::identity::network::rollback_payment_flips( - wallet_manager, - &wallet_id, - undo, - ) - .await; - } - } -} - -/// Stage one event's sweep-payment flips: the overlay folds into the -/// wallet's batch entry (last-write-wins per `(owner, txid)`, matching -/// `PlatformWalletChangeSet::merge`), the rollback into the drain's ledger. -/// The inverse — a later event in the same fold reinstating a flipped -/// txid — is [`retract_reinstated_payment_flips`]' job, which the drain -/// runs for every record-bearing event before merging it. -fn fold_payment_flips( - entry: &mut WalletBatch, - payment_rollbacks: &mut BTreeMap< - WalletId, - Vec, - >, - wallet_id: WalletId, - flips: crate::wallet::identity::network::SweptPaymentFlips, -) { - if flips.is_empty() { - return; - } - for (owner, rows) in flips.overlay { - entry - .payments_overlay - .entry(owner) - .or_default() - .extend(rows); - } - payment_rollbacks - .entry(wallet_id) - .or_default() - .extend(flips.rollback); -} - -/// The cross-drain half of the reinstatement invariant: **a store round -/// must never carry a sweep's `Failed` overlay row that live memory has -/// already superseded.** [`retract_reinstated_payment_flips`] covers the -/// same-fold case, keyed on records this drain captured — but a -/// chainlocked reinstating record queued just after `try_recv` observed an -/// empty channel is invisible to it. The payment hooks process that record -/// on their own task and can advance the entry `Failed → Confirmed` in -/// memory and persist `Confirmed` on their own round BEFORE this sweep -/// batch reaches the persister; the staged `Failed` row would then land -/// after it and durably demote the terminal state, while the live -/// confirmation event has already been consumed. Atomicity within a store -/// round does not order separate rounds — so the staged failure is applied -/// conditionally instead. -/// -/// The manager READ lock is held from the re-validation of a wallet's -/// staged rows through THAT wallet's `store()`, and that hold is -/// load-bearing. The confirm path -/// ([`resolve_sent_payment_by_txid`](crate::wallet::identity::network) via -/// the payment hooks) advances memory and persists under one continuous -/// hold of the manager WRITE lock, so for every store that carries payment -/// rows the two critical sections are mutually exclusive and totally -/// ordered: -/// -/// - confirm first: this re-validation sees `Confirmed` and drops the -/// staged row (and its rollback-ledger entry — a later rejection of this -/// round must not replay an undo for a row the round never carried); -/// - this round first: the store lands `Failed` before the confirm can -/// run, and the confirm's own later round advances it — `Failed → -/// Confirmed` is exactly the transition the shared table permits. -/// -/// A check released before the store would reopen the race: the whole -/// confirm (memory advance + persist) could run inside the gap. -/// -/// The hold is exactly as wide as that argument requires and no wider — -/// the persistence trait permits inline I/O and calls made under the -/// manager lock are latency-sensitive, so a writer must never wait out a -/// synchronous store the lock is not ordering. Scoping per wallet keeps -/// the proof intact, because the ordering obligation is per store: each -/// overlay-carrying store runs inside a read hold that began before its -/// own rows were re-validated, which is all the mutual exclusion above -/// ever used — the guard that covered OTHER wallets' stores ordered -/// nothing. Concretely: -/// -/// - a wallet with no staged rows commits outside any guard (on a -/// payments-blind backend — bit 11 not attested — that is every wallet, -/// since the fold never stages the overlay there); -/// - a wallet whose re-validation drops EVERY staged row commits after -/// the guard is released: no payment row rides the round, so nothing -/// needs ordering, exactly as if it never staged; -/// - a wallet with surviving rows commits under the guard. -#[allow(clippy::too_many_arguments)] -async fn commit_batch_with_payment_revalidation

( - wallet_manager: &Arc>>, - persister: &P, - batch: BTreeMap, - payment_rollbacks: &mut BTreeMap< - WalletId, - Vec, - >, - folded: usize, - fault: &mut AdapterFaultState, - sync_fault: &AtomicBool, - freeze_logged: &mut bool, -) -> BatchDiagnostics -where - P: PlatformWalletPersistence + ?Sized, -{ - // The hot path: no wallet staged a payment row — every drain on a - // payments-blind backend, and every drain without a sweep — so the - // whole batch commits exactly as before, without a lock or the - // per-wallet branching below. - if batch - .values() - .all(|entry| entry.payments_overlay.is_empty()) - { - return commit_batch(persister, batch, folded, fault, sync_fault, freeze_logged); - } - let mut diag = BatchDiagnostics::new(folded, batch.len()); - for (wallet_id, mut wallet_batch) in batch { - if wallet_batch.payments_overlay.is_empty() { - commit_wallet( - persister, - wallet_id, - wallet_batch, - &mut diag, - fault, - sync_fault, - freeze_logged, - ); - continue; - } - let wm = wallet_manager.read().await; - retract_superseded_payment_flips(&wm, wallet_id, &mut wallet_batch, payment_rollbacks); - if wallet_batch.payments_overlay.is_empty() { - // Every staged row was superseded: nothing left on this round - // needs ordering against the confirm path, so release the - // writers before the store. - drop(wm); - commit_wallet( - persister, - wallet_id, - wallet_batch, - &mut diag, - fault, - sync_fault, - freeze_logged, - ); - } else { - // Deliberately still under `wm`: releasing the guard before - // this store is the race this function exists to close. - // `commit_wallet` is synchronous and takes no manager lock, so - // this cannot deadlock. - commit_wallet( - persister, - wallet_id, - wallet_batch, - &mut diag, - fault, - sync_fault, - freeze_logged, - ); - drop(wm); - } - } - diag -} - -/// Drop every staged payment overlay row whose in-memory entry no longer -/// holds the status the row asserts, together with its rollback-ledger -/// entry. Called only under the manager lock -/// [`commit_batch_with_payment_revalidation`] holds across the store. -/// -/// A staged row exists because a round-riding flip wrote it in this very -/// drain — the sweep's `Pending → Failed` -/// ([`flip_swept_sent_payments_for_store`](crate::wallet::identity::network)) -/// or the reinstatement's `Failed → Confirmed` -/// ([`confirm_reinstated_sent_payments_for_store`](crate::wallet::identity::network)) -/// — and the same-fold retraction removes both the row and the flip when -/// it undoes one. So at commit time the live entry either still holds the -/// written status (keep the row: the store must learn the verdict) or -/// another writer outran the batch — for a `Failed` row, the payment -/// hooks advancing the entry to `Confirmed`, whose own round carries the -/// truth (drop the row). Anything else — the entry or its wallet vanished -/// — is also dropped: the overlay asserts what memory holds, and memory -/// no longer holds it. -fn retract_superseded_payment_flips( - wm: &WalletManager, - wallet_id: WalletId, - entry: &mut WalletBatch, - payment_rollbacks: &mut BTreeMap< - WalletId, - Vec, - >, -) { - if entry.payments_overlay.is_empty() { - return; - } - let info = wm.get_wallet_info(&wallet_id); - // Owner-keyed index of the dropped rows, probed once per ledger - // entry below. A sweep event can carry many payment txids, and a - // linear rescan of the dropped set per ledger entry would be - // O(dropped × ledger) identifier-and-string comparisons on the - // commit path. - let mut superseded: BTreeMap> = BTreeMap::new(); - for (owner, rows) in entry.payments_overlay.iter_mut() { - rows.retain(|txid, row| { - // A row is kept while the live entry still holds the status - // the row asserts. A sweep's `Failed` row loses its standing - // when the hooks advanced the entry to `Confirmed`; a - // reinstatement's `Confirmed` row can lose it only if the - // entry vanished, since `Confirmed` is terminal. - let still_standing = info - .and_then(|info| info.identity_manager.managed_identity(owner)) - .and_then(|managed| managed.dashpay().payments.get(txid)) - .is_some_and(|live| live.status == row.status); - if !still_standing { - tracing::info!( - owner = %owner, - txid = %txid, - "Retracting a staged payment flip row superseded in memory before \ - its round stored" - ); - superseded.entry(*owner).or_default().insert(txid.clone()); - } - still_standing - }); - } - entry.payments_overlay.retain(|_, rows| !rows.is_empty()); - if superseded.is_empty() { - return; - } - if let Some(ledger) = payment_rollbacks.get_mut(&wallet_id) { - ledger.retain(|undo| { - !superseded - .get(&undo.owner) - .is_some_and(|txids| txids.contains(&undo.txid)) - }); - } -} - /// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable /// for atomic persistence. async fn build_core_changeset( @@ -2783,8 +2289,6 @@ mod tests { n_records: usize, n_asset_locks: usize, n_asset_locks_removed: usize, - n_payment_overlay_rows: usize, - n_payment_overlay_confirmed: usize, rejected: bool, } @@ -2850,24 +2354,6 @@ mod tests { .as_ref() .map(|a| a.removed.len()) .unwrap_or(0), - n_payment_overlay_rows: changeset - .dashpay_payments_overlay - .as_ref() - .map(|o| o.values().map(|rows| rows.len()).sum()) - .unwrap_or(0), - n_payment_overlay_confirmed: changeset - .dashpay_payments_overlay - .as_ref() - .map(|o| { - o.values() - .flat_map(|rows| rows.values()) - .filter(|entry| { - entry.status - == crate::wallet::identity::types::dashpay::payment::PaymentStatus::Confirmed - }) - .count() - }) - .unwrap_or(0), rejected, }); if rejected { @@ -3759,1032 +3245,6 @@ mod tests { handle.await.expect("adapter task joins"); } - /// A backend that never attested `DASHPAY_PAYMENTS` — Android, whose - /// payments slot is deliberately unwired — must not be handed the - /// sweep's Failed flip on the round at all: it would accept the round, - /// silently drop the overlay, and leave this adapter believing a flip - /// persisted that no store ever applied — the accepted-and-ignored - /// shape the sweep capability's own gating exists to prevent, one - /// channel over. The withhold keeps the in-memory flip (the truthful - /// session state) with nothing round-coupled. - #[tokio::test] - async fn a_payments_blind_backend_is_not_handed_the_sweeps_flip_on_the_round() { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - use key_wallet::account::account_type::StandardAccountType; - - use super::spawn_wallet_event_adapter; - use crate::test_support::{funded_wallet_manager, NoopTestPersister}; - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use crate::wallet::persister::WalletPersister; - - let (wallet_manager, wallet_id, _generation, _signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let txid = dashcore::Txid::from([0xB9; 32]); - let txid_key = txid.to_string(); - - let noop = WalletPersister::new( - wallet_id, - Arc::new(NoopTestPersister) as Arc, - ); - { - let mut wm = wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); - info.identity_manager - .add_identity( - Identity::V0(IdentityV0 { - id: owner, - public_keys: std::collections::BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &noop, - ) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, None), - &noop, - ) - .expect("record pending sent"); - } - - let (obs_tx, mut obs_rx) = unbounded_channel(); - // Sweep-capable but payments-blind: the exact Android shape. - let persister = Arc::new(ProbePersister::with_capabilities( - obs_tx, - crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, - )); - 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(), - ); - - event_tx - .send(WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid], - superseded_by: dashcore::Txid::from([0xBA; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: BTreeMap::new(), - }) - .expect("send sweep"); - - let observed = obs_rx.recv().await.expect("sweep store"); - assert!(!observed.rejected); - assert_eq!( - observed.n_payment_overlay_rows, 0, - "an overlay a payments-blind backend would silently drop must be withheld \ - from its round" - ); - - { - let wm = wallet_manager.read().await; - let status = wm - .get_wallet_info(&wallet_id) - .expect("info") - .identity_manager - .managed_identity(&owner) - .expect("managed") - .dashpay() - .payments - .get(&txid_key) - .expect("entry") - .status; - assert_eq!( - status, - PaymentStatus::Failed, - "the in-memory flip still happens — the truthful session state" - ); - } - - cancel.cancel(); - handle.await.expect("adapter task joins"); - } - - /// A backend that attests `DASHPAY_PAYMENTS` but NOT `ATOMIC_CHANGESETS` - /// must be treated exactly like a payments-blind one: the whole point - /// of staging a flip onto the triggering record's round is that the two - /// land or fail together, and a host whose callbacks commit - /// independently gives neither the coupling nor the fail-closed - /// watermark backstop. It can commit the Core record and watermark and - /// then stop before the payments write — and a one-shot chainlocked - /// reinstatement never re-emits, so its payment would stay durably - /// `Failed` beside a durably recorded reinstatement. Staging requires - /// the full `ROUND_COUPLED_PAYMENT_FLIPS` composite; this host keeps - /// the in-memory flip with nothing round-coupled. - #[tokio::test] - async fn an_atomicity_blind_backend_is_not_handed_payment_flips_on_the_round() { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - use key_wallet::account::account_type::StandardAccountType; - - use super::spawn_wallet_event_adapter; - use crate::test_support::{funded_wallet_manager, NoopTestPersister}; - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use crate::wallet::persister::WalletPersister; - - let (wallet_manager, wallet_id, _generation, _signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let txid = dashcore::Txid::from([0xC9; 32]); - let txid_key = txid.to_string(); - - let noop = WalletPersister::new( - wallet_id, - Arc::new(NoopTestPersister) as Arc, - ); - { - let mut wm = wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); - info.identity_manager - .add_identity( - Identity::V0(IdentityV0 { - id: owner, - public_keys: std::collections::BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &noop, - ) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, None), - &noop, - ) - .expect("record pending sent"); - } - - let (obs_tx, mut obs_rx) = unbounded_channel(); - // Sweep-capable and payments-durable, but with no atomic round: - // each callback commits on its own, so the flip and the record - // cannot be made to land or fail together. - let persister = Arc::new(ProbePersister::with_capabilities( - obs_tx, - crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL - .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS), - )); - 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(), - ); - - event_tx - .send(WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid], - superseded_by: dashcore::Txid::from([0xCA; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: BTreeMap::new(), - }) - .expect("send sweep"); - - let observed = obs_rx.recv().await.expect("sweep store"); - assert!(!observed.rejected); - assert_eq!( - observed.n_payment_overlay_rows, 0, - "an overlay that cannot ride an atomic round must be withheld from a host \ - whose callbacks commit independently" - ); - - { - let wm = wallet_manager.read().await; - let status = wm - .get_wallet_info(&wallet_id) - .expect("info") - .identity_manager - .managed_identity(&owner) - .expect("managed") - .dashpay() - .payments - .get(&txid_key) - .expect("entry") - .status; - assert_eq!( - status, - PaymentStatus::Failed, - "the in-memory flip still happens — the truthful session state" - ); - } - - cancel.cancel(); - handle.await.expect("adapter task joins"); - } - - /// The sweep's payment flip is durable BECAUSE it rides the sweep's own - /// atomic store round: a sweep never re-emits once its round is - /// durable, so a separately persisted flip whose store failed was lost - /// for good. End to end through the real adapter loop: the flip's - /// overlay lands in the SAME `store()` as the sweep; a rejected round - /// rolls the in-memory flip back to the durable state (`Pending`), so - /// the replayed sweep finds the entry eligible and recomputes it; and - /// the replay's round carries the overlay again. - #[tokio::test] - async fn swept_payment_flip_rides_the_sweeps_round_and_rolls_back_on_rejection() { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - use key_wallet::account::account_type::StandardAccountType; - - use super::spawn_wallet_event_adapter; - use crate::test_support::{funded_wallet_manager, NoopTestPersister}; - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use crate::wallet::persister::WalletPersister; - - let (wallet_manager, wallet_id, _generation, _signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let tx1 = dashcore::Txid::from([0xB1; 32]); - let tx2 = dashcore::Txid::from([0xB2; 32]); - - // Seed the identity and two Pending sent entries through a noop - // persister so the probe's observation stream carries ONLY the - // adapter's own stores. - let noop = WalletPersister::new( - wallet_id, - Arc::new(NoopTestPersister) as Arc, - ); - { - let mut wm = wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); - info.identity_manager - .add_identity( - Identity::V0(IdentityV0 { - id: owner, - public_keys: std::collections::BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &noop, - ) - .expect("add owner"); - let managed = info - .identity_manager - .managed_identity_mut(&owner) - .expect("managed"); - for (txid, amount) in [(tx1, 50_000u64), (tx2, 10_000u64)] { - managed - .record_dashpay_payment( - txid.to_string(), - PaymentEntry::new_sent(contact, amount, None), - &noop, - ) - .expect("record pending sent"); - } - } - - async fn status( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - owner: &dpp::prelude::Identifier, - txid: &str, - ) -> crate::wallet::identity::types::dashpay::payment::PaymentStatus { - let wm = wallet_manager.read().await; - wm.get_wallet_info(wallet_id) - .expect("info") - .identity_manager - .managed_identity(owner) - .expect("managed") - .dashpay() - .payments - .get(txid) - .expect("entry") - .status - } - - let (obs_tx, mut obs_rx) = unbounded_channel(); - 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(), - ); - - let swept = |txid: dashcore::Txid| WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid], - superseded_by: dashcore::Txid::from([0xC1; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: BTreeMap::new(), - }; - - // Leg 1: the flip's overlay rides the sweep's own store. - event_tx.send(swept(tx1)).expect("send sweep 1"); - let observed = obs_rx.recv().await.expect("sweep 1 store"); - assert!(!observed.rejected); - assert_eq!( - observed.n_payment_overlay_rows, 1, - "the Failed flip must ride the same store() as the sweep that proved it" - ); - assert_eq!( - status(&wallet_manager, &wallet_id, &owner, &tx1.to_string()).await, - PaymentStatus::Failed - ); - - // Leg 2: a rejected round rolls the in-memory flip back to the - // durable state, so the replayed sweep can recompute it. - persister.fail_next(wallet_id); - event_tx.send(swept(tx2)).expect("send sweep 2"); - let observed = obs_rx.recv().await.expect("sweep 2 store attempt"); - assert!(observed.rejected, "the probe rejects this round"); - assert_eq!( - observed.n_payment_overlay_rows, 1, - "the attempt carried the flip" - ); - // The rollback runs right after commit in the same drain iteration; - // bounded-poll memory rather than racing it. - let mut rolled_back = false; - for _ in 0..50 { - if status(&wallet_manager, &wallet_id, &owner, &tx2.to_string()).await - == PaymentStatus::Pending - { - rolled_back = true; - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - assert!( - rolled_back, - "a rejected round must roll the in-memory flip back to Pending — the \ - durable state, and the replayed sweep's eligibility" - ); - - // Leg 3: the replayed sweep (the re-scan re-emits it, because the - // rejected round kept the loser's record too) recomputes the flip - // and its round carries the overlay again. - event_tx.send(swept(tx2)).expect("send sweep 2 replay"); - let observed = obs_rx.recv().await.expect("replayed sweep store"); - assert!(!observed.rejected); - assert_eq!( - observed.n_payment_overlay_rows, 1, - "the replayed sweep must recompute the flip the rollback undid" - ); - assert_eq!( - status(&wallet_manager, &wallet_id, &owner, &tx2.to_string()).await, - PaymentStatus::Failed - ); - - cancel.cancel(); - handle.await.expect("adapter task joins"); - } - - /// The payment channel's half of the reinstatement invariant, end to - /// end: a buffered `[TransactionsSwept(X), BlockProcessed(chainlocked - /// X)]` pair folds into ONE store round, and that round must carry X's - /// reinstated record with NO sweep-derived `Failed` overlay row beside - /// it — `CoreChangeSet::merge` retracts the sweep, and - /// `retract_reinstated_payment_flips` must retract the payment flip - /// keyed on the very same record set. The in-memory flip is undone - /// with it, so the entry reads `Pending` for the confirm path the - /// reinstated record drives (the payment hooks run on their own task; - /// this harness runs only the adapter). Without the retraction the - /// fold committed a stale `Failed` row that could overwrite a - /// `Confirmed` the hooks had already persisted. - /// - /// Both events are queued BEFORE the adapter task spawns, which is - /// what makes the single-fold deterministic: the first `recv` takes - /// the sweep and the backlog `try_recv` folds the record. - #[tokio::test] - async fn a_reinstating_record_in_the_same_fold_retracts_the_payment_flip() { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - 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 super::spawn_wallet_event_adapter; - use crate::test_support::{funded_wallet_manager, NoopTestPersister}; - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use crate::wallet::persister::WalletPersister; - - let (wallet_manager, wallet_id, _generation, _signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - - // X: the transaction that is swept and then returns chainlocked in - // the same buffered fold. - let tx = dashcore::Transaction { - version: 1, - lock_time: 0, - input: vec![dashcore::TxIn { - previous_output: dashcore::OutPoint::new(dashcore::Txid::from([0xD0; 32]), 0), - ..Default::default() - }], - output: Vec::new(), - special_transaction_payload: None, - }; - let record = TransactionRecord::new( - tx, - AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - TransactionContext::InChainLockedBlock(BlockInfo::new( - 4321, - { - use dashcore::hashes::Hash as _; - dashcore::BlockHash::all_zeros() - }, - 1_650_000_000, - )), - TransactionType::Standard, - TransactionDirection::Outgoing, - Vec::new(), - Vec::new(), - 0, - ); - let txid = record.txid; - let txid_key = txid.to_string(); - - let noop = WalletPersister::new( - wallet_id, - Arc::new(NoopTestPersister) as Arc, - ); - { - let mut wm = wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); - info.identity_manager - .add_identity( - Identity::V0(IdentityV0 { - id: owner, - public_keys: std::collections::BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &noop, - ) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, None), - &noop, - ) - .expect("record pending sent"); - } - - let (obs_tx, mut obs_rx) = unbounded_channel(); - 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(); - - // Queue BOTH events before the adapter runs, so they land in one - // fold: the sweep of X, then the chainlocked record reinstating X. - event_tx - .send(WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid], - superseded_by: dashcore::Txid::from([0xD1; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: BTreeMap::new(), - }) - .expect("send sweep"); - 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 reinstating record"); - - 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(), - ); - - let observed = obs_rx.recv().await.expect("the folded store"); - assert!(!observed.rejected); - assert_eq!( - observed.n_records, 1, - "the reinstated record must ride the fold's store" - ); - assert_eq!( - observed.n_payment_overlay_rows, 0, - "a merged changeset must never carry a sweep-derived assertion about a \ - txid the same fold reinstates" - ); - - { - let wm = wallet_manager.read().await; - let status = wm - .get_wallet_info(&wallet_id) - .expect("info") - .identity_manager - .managed_identity(&owner) - .expect("managed") - .dashpay() - .payments - .get(&txid_key) - .expect("entry") - .status; - assert_eq!( - status, - PaymentStatus::Pending, - "the retraction must undo the in-memory flip so the reinstated \ - record's own confirm path decides the entry" - ); - } - - cancel.cancel(); - handle.await.expect("adapter task joins"); - } - - /// The CROSS-drain half of the reinstatement invariant: a chainlocked - /// reinstating record queued just after `try_recv` observed an empty - /// channel is invisible to the same-fold retraction, and the payment - /// hooks process it on their own task — they can advance the entry - /// `Failed → Confirmed` in memory and persist `Confirmed` on their own - /// round BEFORE the sweep batch reaches the persister. Storing the - /// batch's staged `Failed` row after that durably demotes the terminal - /// state (memory `Confirmed`, storage `Failed`) with the live - /// confirmation event already consumed. - /// - /// Drives the commit stage with the drain's exact staging sequence and - /// the confirm wedged into the cross-drain window — an interleaving the - /// live loop cannot be made to schedule deterministically from outside: - /// stage the sweep's flips exactly as the fold does, run the real - /// confirm path against one of them, then commit through - /// [`commit_batch_with_payment_revalidation`](super::commit_batch_with_payment_revalidation). - /// The round must carry only the row memory still stands behind, and - /// the superseded row's rollback-ledger entry must be gone with it. - #[tokio::test] - async fn a_confirmation_landing_before_the_sweeps_store_retracts_its_stale_failed_row() { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - use key_wallet::account::account_type::StandardAccountType; - - use crate::test_support::{funded_wallet_manager, NoopTestPersister}; - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use crate::wallet::persister::WalletPersister; - - let (wallet_manager, wallet_id, _generation, _signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let reinstated = dashcore::Txid::from([0xE1; 32]); - let still_dead = dashcore::Txid::from([0xE2; 32]); - - let noop = WalletPersister::new( - wallet_id, - Arc::new(NoopTestPersister) as Arc, - ); - { - let mut wm = wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); - info.identity_manager - .add_identity( - Identity::V0(IdentityV0 { - id: owner, - public_keys: std::collections::BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &noop, - ) - .expect("add owner"); - let managed = info - .identity_manager - .managed_identity_mut(&owner) - .expect("managed"); - for txid in [reinstated, still_dead] { - managed - .record_dashpay_payment( - txid.to_string(), - PaymentEntry::new_sent(contact, 50_000, None), - &noop, - ) - .expect("record pending sent"); - } - } - - // Stage the sweep's flips exactly as the drain's fold does: both - // entries flip to `Failed` in memory, the overlay and rollback - // ledger ride the batch. - let flips = crate::wallet::identity::network::flip_swept_sent_payments_for_store( - &wallet_manager, - &wallet_id, - &[reinstated, still_dead], - ) - .await; - let mut batch: BTreeMap = BTreeMap::new(); - let mut payment_rollbacks = BTreeMap::new(); - super::fold_payment_flips( - batch.entry(wallet_id).or_default(), - &mut payment_rollbacks, - wallet_id, - flips, - ); - - // The cross-drain window: the payment hooks confirm `reinstated` - // from the chainlocked record's live evidence — memory `Failed → - // Confirmed`, persisted on the hooks' own round — before the sweep - // batch stores. - crate::wallet::identity::network::confirm_sent_dashpay_payment_by_txid( - &wallet_manager, - &wallet_id, - &noop, - &reinstated, - ) - .await; - - // Commit the sweep batch through the drain's commit stage. - let (obs_tx, mut obs_rx) = unbounded_channel(); - let persister = ProbePersister::with_capabilities( - obs_tx, - crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL - .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS) - .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), - ); - let sync_fault = AtomicBool::new(false); - let mut fault = AdapterFaultState::default(); - let mut freeze_logged = false; - super::commit_batch_with_payment_revalidation( - &wallet_manager, - &persister, - batch, - &mut payment_rollbacks, - 1, - &mut fault, - &sync_fault, - &mut freeze_logged, - ) - .await; - - let observed = obs_rx.recv().await.expect("the sweep round's store"); - assert!(!observed.rejected); - assert_eq!( - observed.n_payment_overlay_rows, 1, - "the round must carry only the row memory still stands behind — \ - storing the superseded Failed row would durably demote a \ - Confirmed the hooks' round already persisted" - ); - - // The superseded row's undo left the ledger with it: a later - // rejection of this round must not replay an undo for a row the - // round never carried. - let ledger = payment_rollbacks.get(&wallet_id).expect("ledger"); - assert_eq!( - ledger.len(), - 1, - "only the retained row keeps its rollback entry" - ); - assert_eq!(ledger[0].txid, still_dead.to_string()); - - // The retraction reads memory, never writes it: the confirmed - // entry keeps its terminal state, the still-dead one keeps the - // verdict its row just stored. - { - let wm = wallet_manager.read().await; - let payments = &wm - .get_wallet_info(&wallet_id) - .expect("info") - .identity_manager - .managed_identity(&owner) - .expect("managed") - .dashpay() - .payments; - assert_eq!( - payments.get(&reinstated.to_string()).expect("entry").status, - PaymentStatus::Confirmed - ); - assert_eq!( - payments.get(&still_dead.to_string()).expect("entry").status, - PaymentStatus::Failed - ); - } - } - - /// The one-shot reinstatement gets the round's durability, end to end - /// through the real adapter loop: a chainlocked reinstating record - /// arriving in a LATER drain than the sweep finds the entry durably - /// `Failed`, and the record re-arrives already final, so no further - /// detection follows it and the reconcile pass (`Pending`-only by - /// construction) cannot cover it — the hooks' own store round was the - /// last chance, and a rejection there left a durable `Failed` for a - /// transaction that survived. The adapter therefore owns the - /// correction: `confirm_reinstated_sent_payments_for_store` flips the - /// entry and rides the `Confirmed` row on the SAME store round as the - /// reinstated record. - /// - /// Three legs mirror the sweep-flip test: the flip rides the record's - /// round; a rejected round rolls the in-memory `Confirmed` back to - /// `Failed` (the durable state) so the replay can recompute it; and - /// the replayed record's round carries the row again. - #[tokio::test] - async fn a_chainlocked_reinstatement_rides_the_records_round_and_survives_rejection() { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - 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 super::spawn_wallet_event_adapter; - use crate::test_support::{funded_wallet_manager, NoopTestPersister}; - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - use crate::wallet::persister::WalletPersister; - - let (wallet_manager, wallet_id, _generation, _signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - - // Two transactions, each later reinstated by its own chainlocked - // record: X drives the ride leg, Y the rejection-and-replay legs. - let chainlocked_record = |input_byte: u8| { - let tx = dashcore::Transaction { - version: 1, - lock_time: 0, - input: vec![dashcore::TxIn { - previous_output: dashcore::OutPoint::new( - dashcore::Txid::from([input_byte; 32]), - 0, - ), - ..Default::default() - }], - output: Vec::new(), - special_transaction_payload: None, - }; - TransactionRecord::new( - tx, - AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - TransactionContext::InChainLockedBlock(BlockInfo::new( - 4321, - { - use dashcore::hashes::Hash as _; - dashcore::BlockHash::all_zeros() - }, - 1_650_000_000, - )), - TransactionType::Standard, - TransactionDirection::Outgoing, - Vec::new(), - Vec::new(), - 0, - ) - }; - let record_x = chainlocked_record(0xD0); - let record_y = chainlocked_record(0xD1); - let (txid_x, txid_y) = (record_x.txid, record_y.txid); - - let noop = WalletPersister::new( - wallet_id, - Arc::new(NoopTestPersister) as Arc, - ); - { - let mut wm = wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); - info.identity_manager - .add_identity( - Identity::V0(IdentityV0 { - id: owner, - public_keys: std::collections::BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &noop, - ) - .expect("add owner"); - let managed = info - .identity_manager - .managed_identity_mut(&owner) - .expect("managed"); - for txid in [txid_x, txid_y] { - managed - .record_dashpay_payment( - txid.to_string(), - PaymentEntry::new_sent(contact, 50_000, None), - &noop, - ) - .expect("record pending sent"); - } - } - - async fn status( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - owner: &dpp::prelude::Identifier, - txid: &str, - ) -> crate::wallet::identity::types::dashpay::payment::PaymentStatus { - let wm = wallet_manager.read().await; - wm.get_wallet_info(wallet_id) - .expect("info") - .identity_manager - .managed_identity(owner) - .expect("managed") - .dashpay() - .payments - .get(txid) - .expect("entry") - .status - } - - let (obs_tx, mut obs_rx) = unbounded_channel(); - 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(), - ); - - // The sweep lands durably in its own drain: both entries flip to - // `Failed`, the overlay rides the sweep's round. - event_tx - .send(WalletEvent::TransactionsSwept { - wallet_id, - txids: vec![txid_x, txid_y], - superseded_by: dashcore::Txid::from([0xDD; 32]), - released_outpoints: vec![], - balance: WalletCoreBalance::default(), - account_balances: BTreeMap::new(), - }) - .expect("send sweep"); - let observed = obs_rx.recv().await.expect("sweep round"); - assert!(!observed.rejected); - assert_eq!(observed.n_payment_overlay_rows, 2); - assert_eq!(observed.n_payment_overlay_confirmed, 0); - - let reinstating = |record: TransactionRecord| 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![], - }; - - // Leg 1: X's reinstating record arrives in a LATER drain (the - // sweep's round above is already durable). Its round must carry - // the reinstated record AND the `Confirmed` correction — the flip - // rides the same atomic store as the record that justifies it. - event_tx - .send(reinstating(record_x.clone())) - .expect("send reinstating record for X"); - let observed = obs_rx.recv().await.expect("X's reinstating round"); - assert!(!observed.rejected); - assert_eq!(observed.n_records, 1, "the reinstated record rides"); - assert_eq!( - observed.n_payment_overlay_rows, 1, - "the reinstatement confirmation must ride the record's own round — \ - there is no later detection to retry from" - ); - assert_eq!( - observed.n_payment_overlay_confirmed, 1, - "and the row asserts Confirmed, not a stale Failed" - ); - assert_eq!( - status(&wallet_manager, &wallet_id, &owner, &txid_x.to_string()).await, - PaymentStatus::Confirmed - ); - - // Leg 2: a rejected reinstating round rolls the in-memory flip - // back to `Failed` — the durable state — so the replayed record - // can recompute it. Without the rollback, memory would read - // `Confirmed` ahead of the store and the replay's eligibility - // check would skip the entry. - persister.fail_next(wallet_id); - event_tx - .send(reinstating(record_y.clone())) - .expect("send reinstating record for Y"); - let observed = obs_rx.recv().await.expect("Y's rejected round"); - assert!(observed.rejected); - assert_eq!( - observed.n_payment_overlay_confirmed, 1, - "the attempt carried the correction" - ); - let mut rolled_back = false; - for _ in 0..50 { - if status(&wallet_manager, &wallet_id, &owner, &txid_y.to_string()).await - == PaymentStatus::Failed - { - rolled_back = true; - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - assert!( - rolled_back, - "a rejected round must roll the in-memory Confirmed back to Failed, \ - the durable state the rejection left in the store" - ); - - // Leg 3: the replayed record (the rejected round froze the - // watermark, so the re-scan re-emits the chainlocked block) - // recomputes the flip and its round carries the correction again. - event_tx - .send(reinstating(record_y)) - .expect("send replayed record for Y"); - let observed = obs_rx.recv().await.expect("Y's replayed round"); - assert!(!observed.rejected); - assert_eq!(observed.n_records, 1); - assert_eq!( - observed.n_payment_overlay_confirmed, 1, - "the replay must recompute the correction the rollback undid" - ); - assert_eq!( - status(&wallet_manager, &wallet_id, &owner, &txid_y.to_string()).await, - PaymentStatus::Confirmed - ); - - 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 @@ -5119,7 +3579,6 @@ mod tests { super::WalletBatch { core: CoreChangeSet::default(), asset_locks, - payments_overlay: BTreeMap::new(), }, ); commit_batch( @@ -5178,7 +3637,6 @@ mod tests { WalletBatch { core, asset_locks: AssetLockChangeSet::default(), - payments_overlay: BTreeMap::new(), }, ); batch diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 7d09af5d7c5..41839ecf66d 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -84,15 +84,6 @@ impl PersistenceCapabilities { /// `CORE_SWEEP_REMOVAL`. On the FFI surface Rust honours the /// declaration only when `on_persist_dashpay_payments_fn` is actually /// wired. - /// - /// This bit alone attests only per-callback durability. The adapter's - /// round-coupled staging additionally requires `ATOMIC_CHANGESETS` - /// (see [`Self::ROUND_COUPLED_PAYMENT_FLIPS`]): on a host whose - /// callbacks commit independently, the Core record and watermark can - /// become durable while the process stops before the payments - /// callback — and a one-shot chainlocked reinstatement never - /// re-emits, so its payment would stay durably `Failed` beside a - /// durably recorded reinstatement. pub const DASHPAY_PAYMENTS: Self = Self(1 << 11); /// Capabilities required before exporting and funding an invitation voucher. @@ -112,22 +103,6 @@ impl PersistenceCapabilities { pub const ASSET_LOCK_RECONCILIATION: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); - /// Capabilities required before the wallet-event adapter stages a - /// sweep's `Failed` flip or a reinstatement's `Confirmed` correction - /// onto the triggering record's own store round. The point of that - /// staging is that the flip and the record land or fail together — - /// `DASHPAY_PAYMENTS` proves the overlay rows are durably applied, - /// and `ATOMIC_CHANGESETS` proves the round commits or rolls back as - /// one unit. A payments-durable host without the atomic round gives - /// neither the coupling nor the fail-closed watermark backstop: it - /// can commit the Core record and watermark, then stop before the - /// payments write — and a one-shot reinstatement never re-emits to - /// retry the orphaned flip. Such a host is treated as payments-blind - /// for staging (the in-memory flip still happens; funds-safe, as - /// payment entries are display metadata). - pub const ROUND_COUPLED_PAYMENT_FLIPS: Self = - Self(Self::ATOMIC_CHANGESETS.0 | Self::DASHPAY_PAYMENTS.0); - pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) } @@ -233,10 +208,6 @@ mod tests { PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 ); - assert_eq!( - PersistenceCapabilities::ROUND_COUPLED_PAYMENT_FLIPS.bits(), - 0x801 - ); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 53c0fc8158c..752fee202ce 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -53,9 +53,8 @@ pub(crate) use payment_handler::DashPayPaymentHandler; pub(crate) use payment_handler::run_dashpay_payment_hooks; mod payments; pub(crate) use payments::{ - confirm_reinstated_sent_payments_for_store, confirm_sent_dashpay_payment, - confirm_sent_dashpay_payment_by_txid, flip_swept_sent_payments_for_store, - record_incoming_dashpay_payments, rollback_payment_flips, PaymentFlipUndo, SweptPaymentFlips, + confirm_sent_dashpay_payment, confirm_sent_dashpay_payment_by_txid, + record_incoming_dashpay_payments, }; mod profile; pub(crate) mod sdk_writer; 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 28f38c7221e..e5ec9441040 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 @@ -256,10 +256,9 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { // — 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, and the - // wallet-event adapter owns that (see - // `payments::SweptPaymentFlips`). Routing it here as well would - // race a second, separately persisted write against that round. + // 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 { .. } @@ -286,8 +285,8 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { } => !inserted.is_empty() || !updated.is_empty(), // 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 rides the wallet-event - // adapter's own store round instead — see `dashpay_payment_records`. + // 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, @@ -295,15 +294,14 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { } /// Run the DashPay payment hooks for `event`: record any incoming DashPay -/// payment, then advance a matching sent payment from `Pending` (or a -/// sweep-written `Failed` — the reinstatement correction) to `Confirmed` -/// once its transaction reaches finality (mined or InstantSend-locked). -/// The opposite terminal — `Failed`, when a sweep proves the transaction -/// never can confirm — is applied by the wallet-event adapter on the -/// sweep's own atomic store round, not here (see -/// `payments::SweptPaymentFlips`). All paths are idempotent per txid, so -/// re-detections and repeated block-processing rounds converge without -/// duplicating entries. +/// payment, then advance a matching sent payment from `Pending` to +/// `Confirmed` once its transaction reaches finality (mined or +/// 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, @@ -479,12 +477,12 @@ mod tests { } /// `TransactionsSwept` must NOT drive the payment hooks: its payment - /// consequence — failing the losers' `Pending` sent payments — rides - /// the wallet-event adapter's own atomic store round (see - /// `payments::SweptPaymentFlips`), 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. + /// 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 { 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 f3ff2545470..c084ea667a1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -719,25 +719,14 @@ impl DashPayView<'_, B> { if sent_payment_status_for_record(&record) != PaymentStatus::Confirmed { continue; } - // Flip in place via the shared confirm path, declaring what - // this sweep's evidence can speak for: the record was read - // AFTER a snapshot that saw the entry `Pending`, so it proves - // nothing about an entry that has since moved. In particular a - // sweep hook can flip the entry to `Failed` anywhere in the - // snapshot→confirm span (hooks are unordered spawned tasks, and - // the sweep deletes the record on a third task) — this pass's - // record read may predate that verdict, and confirming from it - // would land a dead payment terminally `Confirmed`. The - // resolver re-checks under its own write lock against exactly - // this evidence set, so an entry no longer `Pending` is left - // for a caller whose evidence postdates the flip (the live - // reinstatement hook). + // Flip in place via the shared confirm path (re-checks the + // entry is still a `Pending` `Sent` under its own write lock, + // so it stays correct if a live event raced this sweep). confirm_sent_payment_by_txid( &self.wallet_manager, &self.wallet_id, &self.persister, &txid_str, - RECONCILE_CONFIRM_EVIDENCE, ) .await; confirmed += 1; @@ -960,7 +949,6 @@ pub(crate) async fn confirm_sent_dashpay_payment( wallet_id, persister, &record.txid.to_string(), - LIVE_CONFIRM_EVIDENCE, ) .await; } @@ -978,466 +966,57 @@ pub(crate) async fn confirm_sent_dashpay_payment_by_txid( persister: &crate::wallet::persister::WalletPersister, txid: &dashcore::Txid, ) { - confirm_sent_payment_by_txid( - wallet_manager, - wallet_id, - persister, - &txid.to_string(), - LIVE_CONFIRM_EVIDENCE, - ) - .await; + confirm_sent_payment_by_txid(wallet_manager, wallet_id, persister, &txid.to_string()).await; } -/// What a confirm caller's evidence can speak for — the from-states it is -/// entitled to advance. The transition table -/// ([`sent_status_transition_allowed`]) says which moves the machine -/// permits; this says which of them a given caller's evidence actually -/// supports, and the resolver requires both. The distinction exists -/// because `(Failed, Confirmed)` is only ever correct when the evidence -/// POSTDATES the sweep's verdict: +/// Flip the `Pending` `Sent` [`PaymentEntry`] under `txid` (if any) to +/// `Confirmed`, in place, preserving amount/memo/counterparty. /// -/// - **Live evidence** — a wallet event carrying (or naming) the -/// transaction. Upstream never re-emits a record for a txid it still -/// considers dead, so a live record/IS-lock signal for a `Failed` entry -/// is authoritative reinstatement and may correct the verdict. -/// - **Reconcile evidence** — a persisted-record read made after a -/// snapshot that saw the entry `Pending`. If the entry has since moved -/// to `Failed`, the read raced the sweep (which deletes the record on -/// another task) and may predate it — confirming from it would land a -/// dead payment terminally `Confirmed`. `Pending`-only, by -/// construction. -const LIVE_CONFIRM_EVIDENCE: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus] = &[ - crate::wallet::identity::types::dashpay::payment::PaymentStatus::Pending, - crate::wallet::identity::types::dashpay::payment::PaymentStatus::Failed, -]; -/// See [`LIVE_CONFIRM_EVIDENCE`]. -const RECONCILE_CONFIRM_EVIDENCE: - &[crate::wallet::identity::types::dashpay::payment::PaymentStatus] = - &[crate::wallet::identity::types::dashpay::payment::PaymentStatus::Pending]; - -/// Flip the `Sent` [`PaymentEntry`] under `txid` (if any) to `Confirmed`, -/// in place, preserving amount/memo/counterparty. -/// -/// No-op when no entry exists for `txid`, it is not a `Sent` entry, it is -/// already `Confirmed` (so repeated confirmed re-detections are idempotent -/// and skip the persistence round), or its current state is outside what -/// `evidence` can speak for. A `Failed` entry advances only under -/// [`LIVE_CONFIRM_EVIDENCE`]: a swept transaction's one road back is a -/// chainlocked reinstatement, whose re-emitted record is hard evidence the -/// verdict reversed — while a reconcile pass's record read can predate the -/// verdict entirely (see the constants above). +/// No-op when no entry exists for `txid`, it is not a `Sent` entry, or it +/// is already past `Pending` (so repeated confirmed re-detections are +/// idempotent and skip the persistence round). Separated from the event +/// glue above so the state transition is unit-testable without +/// constructing a full `TransactionRecord`. async fn confirm_sent_payment_by_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, persister: &crate::wallet::persister::WalletPersister, txid: &str, - evidence: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus], ) { - use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - // Log-and-continue is sound for ordinary confirmations: the flip - // rolled back in memory with the failed store, and every later signal - // for the same transaction — a confirmed re-detection, the block - // round, the IS-lock event — re-drives this path against the - // still-`Pending` entry. The sweep path handles its persistence - // failures itself (it rides the sweep's own store round). The one - // case with no later signal — a `Failed → Confirmed` reinstatement - // whose record arrived already chainlocked, which the reconcile sweep - // cannot cover either (its snapshot evidence is `Pending`-only - // precisely because a persisted-record read can predate a racing - // sweep's verdict) — is owned by the adapter instead: - // `confirm_reinstated_sent_payments_for_store` rides the reinstating - // record's own store round with the round's fail-closed machinery, so - // a store failure HERE is only ever a lost fast-path write, never the - // last chance. - if let Err(e) = resolve_sent_payment_by_txid( - wallet_manager, - wallet_id, - persister, - txid, - PaymentStatus::Confirmed, - evidence, - ) - .await - { - tracing::warn!( - error = %e, - "Failed to persist sent-payment confirmation; will retry on next detection" - ); - } -} - -/// The in-memory `Failed` flips for a sweep's losers, packaged for the -/// wallet-event adapter to ride on the sweep's OWN store round. -/// -/// A swept transaction was provably beaten to one of its inputs, so it can -/// never confirm — exactly the "transaction was dropped" case -/// [`PaymentStatus::Failed`](crate::wallet::identity::types::dashpay::payment::PaymentStatus::Failed) -/// documents — and the sweep deletes the record that was the last thing -/// `reconcile_sent_payments_from_tx_history` could have resolved the entry -/// from. Durability is why this is a changeset payload rather than a hook -/// that persists on its own: a sweep never re-emits once its round is -/// durable, so a separately persisted flip that failed its store was lost -/// for good (a bounded retry only narrowed the window). Riding the same -/// atomic `store()` as the core sweep gives the flip the round's own -/// fail-closed machinery — a rejection keeps the loser's record too, the -/// wallet faults, and the re-scan re-detects the conflict and re-emits the -/// sweep, recomputing the flip. -/// -/// `Confirmed` entries are never demoted (the shared transition table), -/// and the one way the verdict reverses — a chainlocked reinstatement — -/// re-emits the record confirmed, which the confirm path accepts from -/// `Failed`. -#[derive(Debug, Default)] -pub(crate) struct SweptPaymentFlips { - /// `PlatformWalletChangeSet::dashpay_payments_overlay` payload — the - /// flipped rows, exactly as memory now holds them. - pub overlay: std::collections::BTreeMap< - Identifier, - std::collections::BTreeMap, - >, - /// What to restore if the round is rejected, applied by - /// [`rollback_payment_flips`] so memory returns to the durable - /// state and the replayed event finds the entries eligible again. - pub rollback: Vec, -} - -/// One staged payment flip's undo: what to restore if the store round the -/// flip rode is rejected, and the status the flip wrote — the undo applies -/// only while that write still stands (see [`rollback_payment_flips`]). -/// -/// Carried by both round-riding flips: the sweep's `Pending → Failed` -/// ([`flip_swept_sent_payments_for_store`], `wrote = Failed`) and the -/// reinstatement's `Failed → Confirmed` -/// ([`confirm_reinstated_sent_payments_for_store`], `wrote = Confirmed`). -#[derive(Debug, Clone)] -pub(crate) struct PaymentFlipUndo { - pub owner: Identifier, - pub txid: String, - /// The entry as it stood before the flip. - pub previous: crate::wallet::identity::PaymentEntry, - /// The status the flip wrote over it. - pub wrote: crate::wallet::identity::types::dashpay::payment::PaymentStatus, -} - -impl SweptPaymentFlips { - pub(crate) fn is_empty(&self) -> bool { - self.overlay.is_empty() - } -} - -/// Flip the `Pending` `Sent` entries under `txids` to `Failed` in memory -/// and return the store payload + rollback. Persists NOTHING itself — the -/// caller owns the store round (see [`SweptPaymentFlips`]). -pub(crate) async fn flip_swept_sent_payments_for_store( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - txids: &[dashcore::Txid], -) -> SweptPaymentFlips { - use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; - - let mut flips = SweptPaymentFlips::default(); - if txids.is_empty() { - return flips; - } - let mut wm = wallet_manager.write().await; - let Some(info) = wm.get_wallet_info_mut(wallet_id) else { - return flips; - }; - for txid in txids { - let key = txid.to_string(); - 'owners: for owner in info.identity_manager.identity_ids() { - let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { - continue; - }; - let previous = match managed.dashpay().payments.get(&key) { - Some(entry) - if entry.direction == PaymentDirection::Sent - && sent_status_transition_allowed(entry.status, PaymentStatus::Failed) => - { - entry.clone() - } - _ => continue, - }; - let mut updated = previous.clone(); - updated.status = PaymentStatus::Failed; - tracing::info!( - owner = %owner, - txid = %key, - "Failing sent DashPay payment on its sweep's own store round" - ); - managed - .dashpay_payments_mut() - .insert(key.clone(), updated.clone()); - flips - .overlay - .entry(owner) - .or_default() - .insert(key.clone(), updated); - flips.rollback.push(PaymentFlipUndo { - owner, - txid: key.clone(), - previous, - wrote: PaymentStatus::Failed, - }); - // txid is unique — only one identity can hold this entry. - break 'owners; - } - } - flips -} - -/// The adapter-owned reinstatement confirmation: flip `Failed` `Sent` -/// entries whose transaction a folded record reinstates as final to -/// `Confirmed` in memory, and return the overlay + rollback for the -/// wallet-event adapter to ride on the reinstating record's OWN store -/// round. Persists NOTHING itself — the caller owns the round. -/// -/// Exists because a chainlocked reinstatement can be a one-shot: the -/// record re-arrives already final, so no further detection follows it, -/// and `reconcile_sent_payments_from_tx_history` is `Pending`-only by -/// construction (its snapshot evidence can predate a racing sweep's -/// verdict). The hooks' live confirm persists on its own round, and a -/// rejection there had nothing left to retry against — the durable entry -/// stayed `Failed` for a transaction that survived and is final. Riding -/// the same atomic `store()` as the reinstated record gives the -/// correction the round's fail-closed machinery instead: a rejected round -/// keeps the watermark back, the re-scan re-emits the chainlocked record, -/// and this flip is recomputed — the same durability contract the sweep's -/// own `Failed` flip gets from [`SweptPaymentFlips`]. -/// -/// Composes with the hooks rather than replacing them: whichever writer -/// runs first flips memory (both under the manager write lock), the other -/// finds the entry outside its from-set and no-ops, and both rounds write -/// the same terminal row. Only records the shared finality gate accepts -/// ([`sent_payment_status_for_record`] == `Confirmed`) qualify, and only -/// entries currently `Failed` are flipped — a `Pending` entry's ordinary -/// confirmation stays the hooks' job, which has recovery of its own -/// (every later signal for the transaction, and the reconcile pass). -pub(crate) async fn confirm_reinstated_sent_payments_for_store( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - records: &[key_wallet::managed_account::transaction_record::TransactionRecord], -) -> SweptPaymentFlips { use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; - let mut flips = SweptPaymentFlips::default(); - if records.is_empty() { - return flips; - } - // Fast path under the READ lock: a `Failed` sent entry exists only - // after a sweep, so almost every record-bearing event has nothing to - // correct — don't make the drain take the write lock per event just - // to discover that. Missing a concurrent flip is not possible: the - // adapter task calling this is the only writer of `Failed` - // (`flip_swept_sent_payments_for_store` and the rejected-round undo - // both run on it), and the write-lock re-check below still gates the - // flip itself, so an entry the hooks confirm in the gap is skipped. - { - let wm = wallet_manager.read().await; - let Some(info) = wm.get_wallet_info(wallet_id) else { - return flips; - }; - let any_candidate = records.iter().any(|record| { - sent_payment_status_for_record(record) == PaymentStatus::Confirmed && { - let key = record.txid.to_string(); - info.identity_manager - .identity_ids() - .into_iter() - .any(|owner| { - info.identity_manager - .managed_identity(&owner) - .and_then(|managed| managed.dashpay().payments.get(&key)) - .is_some_and(|entry| { - entry.direction == PaymentDirection::Sent - && entry.status == PaymentStatus::Failed - }) - }) - } - }); - if !any_candidate { - return flips; - } - } - let mut wm = wallet_manager.write().await; - let Some(info) = wm.get_wallet_info_mut(wallet_id) else { - return flips; - }; - for record in records { - if sent_payment_status_for_record(record) != PaymentStatus::Confirmed { - continue; - } - let key = record.txid.to_string(); - 'owners: for owner in info.identity_manager.identity_ids() { - let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { - continue; - }; - let previous = match managed.dashpay().payments.get(&key) { - Some(entry) - if entry.direction == PaymentDirection::Sent - && entry.status == PaymentStatus::Failed - && sent_status_transition_allowed( - entry.status, - PaymentStatus::Confirmed, - ) => - { - entry.clone() - } - _ => continue, - }; - let mut updated = previous.clone(); - updated.status = PaymentStatus::Confirmed; - tracing::info!( - owner = %owner, - txid = %key, - "Confirming reinstated sent DashPay payment on its record's own store round" - ); - managed - .dashpay_payments_mut() - .insert(key.clone(), updated.clone()); - flips - .overlay - .entry(owner) - .or_default() - .insert(key.clone(), updated); - flips.rollback.push(PaymentFlipUndo { - owner, - txid: key.clone(), - previous, - wrote: PaymentStatus::Confirmed, - }); - // txid is unique — only one identity can hold this entry. - break 'owners; - } - } - flips -} - -/// Undo round-riding in-memory payment flips — after the round they rode -/// was rejected, or after the same fold reinstated their transaction and -/// the adapter retracted the staged overlay row. Memory returns to the -/// durable state (the entry the rejection left untouched in the store), -/// which is what lets the replayed event — re-emitted by the re-scan, -/// because the rejected round kept its rows too — find the entries -/// eligible and recompute the flip. Without this, memory would read ahead -/// of the store, the replay's eligibility check would skip the entries, -/// and the store would never learn. -/// -/// An undo is NOT a forward transition, so it does not go through -/// [`sent_status_transition_allowed`] — but it obeys the same authority: -/// it may only revert the flip's own still-standing write -/// ([`PaymentFlipUndo::wrote`]). An entry that moved on — the payment -/// hooks confirming a sweep-failed entry concurrently, which the table -/// permits from `Failed` — outranks the undo; restoring the captured -/// state over what another writer's round may already hold durably would -/// demote a verdict this undo has no authority over. -pub(crate) async fn rollback_payment_flips( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - rollback: Vec, -) { - if rollback.is_empty() { - return; - } let mut wm = wallet_manager.write().await; let Some(info) = wm.get_wallet_info_mut(wallet_id) else { return; }; - for undo in rollback { - let Some(managed) = info.identity_manager.managed_identity_mut(&undo.owner) else { - continue; - }; - let payments = managed.dashpay_payments_mut(); - match payments.get(&undo.txid) { - Some(current) if current.status == undo.wrote => { - payments.insert(undo.txid, undo.previous); - } - _ => {} - } - } -} - -/// The sent-payment state machine's one transition table, shared by every -/// writer so the confirm path and the sweep flip can never drift: -/// `Confirmed` is terminal, `Pending` advances to either verdict, and -/// `Failed` advances only to `Confirmed` — the chainlocked-reinstatement -/// correction, whose record re-arrives confirmed. -pub(crate) fn sent_status_transition_allowed( - from: crate::wallet::identity::types::dashpay::payment::PaymentStatus, - to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, -) -> bool { - use crate::wallet::identity::types::dashpay::payment::PaymentStatus; - matches!( - (from, to), - (PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed) - ) -} - -/// The confirm path's flip: move the `Sent` [`PaymentEntry`] under `txid` -/// to `to`, in place, preserving amount/memo/counterparty, and persist it -/// through its own store round. (The `Failed` flip does NOT come through -/// here — it rides the sweep's atomic round; see [`SweptPaymentFlips`].) -/// -/// Eligibility is [`sent_status_transition_allowed`] (shared with the -/// sweep flip so the state machine cannot drift) INTERSECTED with the -/// caller's declared `evidence_from` (see [`LIVE_CONFIRM_EVIDENCE`]); -/// every ineligible combination is a no-op, which is what keeps -/// re-detections idempotent, skipping the persistence round, and a stale -/// reconcile snapshot unable to overrule a sweep verdict it never saw. Separated from the event glue so -/// the transition is unit-testable without constructing a full -/// `TransactionRecord`. -/// -/// A no-op resolution (no entry, not `Sent`, not eligible) is `Ok(())`; -/// `Err` means the flip was found, attempted, and its store rejected — the -/// in-memory overwrite has already been rolled back -/// (`record_dashpay_payment`'s contract), so the caller may retry or -/// accept per its own signal model (a confirmation is re-driven by every -/// later signal for its transaction). -async fn resolve_sent_payment_by_txid( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - persister: &crate::wallet::persister::WalletPersister, - txid: &str, - to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, - evidence_from: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus], -) -> Result<(), crate::changeset::PersistenceError> { - use crate::wallet::identity::types::dashpay::payment::PaymentDirection; - - let mut wm = wallet_manager.write().await; - let Some(info) = wm.get_wallet_info_mut(wallet_id) else { - return Ok(()); - }; // The sent transaction belongs to one managed identity; find the - // eligible `Sent` entry under this txid and resolve it in place. + // `Pending` `Sent` entry under this txid and confirm it in place. for owner in info.identity_manager.identity_ids() { let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { continue; }; - let resolved = match managed.dashpay().payments.get(txid) { - Some(entry) if entry.direction == PaymentDirection::Sent => { - // Both gates, deliberately: the table says the machine - // permits the move, `evidence_from` says this caller's - // evidence supports it. The re-check under this write lock - // is what turns a caller's stale snapshot into a safe - // no-op — an entry that moved outside the declared set - // means the evidence predates another writer's verdict. - if !evidence_from.contains(&entry.status) - || !sent_status_transition_allowed(entry.status, to) - { - continue; - } + let confirmed = match managed.dashpay().payments.get(txid) { + Some(entry) + if entry.direction == PaymentDirection::Sent + && entry.status == PaymentStatus::Pending => + { let mut updated = entry.clone(); - updated.status = to; + updated.status = PaymentStatus::Confirmed; updated } _ => continue, }; - tracing::info!(owner = %owner, %txid, status = ?to, "Resolving sent DashPay payment"); - // txid is unique — only one identity can hold this entry, so the - // first eligible hit decides the call's result either way. - return managed.record_dashpay_payment(txid.to_string(), resolved, persister); + tracing::info!(owner = %owner, %txid, "Confirming sent DashPay payment"); + if let Err(e) = managed.record_dashpay_payment(txid.to_string(), confirmed, persister) { + tracing::warn!( + error = %e, + "Failed to persist sent-payment confirmation; will retry on next detection" + ); + } + // txid is unique — only one identity can hold this entry. + break; } - Ok(()) } // --------------------------------------------------------------------------- @@ -1940,9 +1519,6 @@ mod tests { #[derive(Default)] struct RecordingPersister { stores: Mutex>, - /// Fail the next N `store` calls with an injected backend error - /// before recording resumes — the shape of a transient rejection. - fail_next_stores: Mutex, } impl PlatformWalletPersistence for RecordingPersister { @@ -1951,13 +1527,6 @@ mod tests { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { - { - let mut budget = self.fail_next_stores.lock().unwrap(); - if *budget > 0 { - *budget -= 1; - return Err(PersistenceError::backend("injected store failure")); - } - } self.stores.lock().unwrap().push((wallet_id, changeset)); Ok(()) } @@ -3295,14 +2864,7 @@ mod tests { ); // A confirmed detection flips it to Confirmed, preserving fields. - super::confirm_sent_payment_by_txid( - &iw.wallet_manager, - &wallet_id, - &p, - &txid, - super::LIVE_CONFIRM_EVIDENCE, - ) - .await; + super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid).await; let entry = read_entry(iw, &wallet_id, &owner, &txid).await; assert_eq!( entry.status, @@ -3314,396 +2876,13 @@ mod tests { assert_eq!(entry.memo.as_deref(), Some("dinner"), "memo preserved"); // Idempotent: a second confirmed re-detection changes nothing. - super::confirm_sent_payment_by_txid( - &iw.wallet_manager, - &wallet_id, - &p, - &txid, - super::LIVE_CONFIRM_EVIDENCE, - ) - .await; + super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid).await; assert_eq!( read_entry(iw, &wallet_id, &owner, &txid).await.status, PaymentStatus::Confirmed ); } - /// A sweep naming a `Pending` sent payment's transaction must fail the - /// entry: the transaction was provably beaten to one of its inputs and - /// can never confirm, and the same sweep deletes the record that was - /// the last thing reconciliation could have resolved the entry from — - /// so without this transition the sender's payment sat `Pending` - /// forever with no terminal state. Driven through the flip the - /// wallet-event adapter stages onto the sweep's own store round. Also - /// pins the guard rails: a re-emitted sweep is an idempotent no-op, a - /// `Confirmed` entry is never demoted by a stale sweep, and the one - /// legitimate reversal — a chainlocked reinstatement re-emitting the - /// record confirmed — advances `Failed` to `Confirmed`. - #[tokio::test] - async fn swept_sent_payment_fails_and_a_reinstating_confirmation_recovers_it() { - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - - let (manager, persister, wallet_id) = make_wallet().await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let txid = dashcore::Txid::from([0xAB; 32]); - let txid_key = txid.to_string(); - - let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); - let iw = wallet.identity(); - let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); - { - let mut wm = iw.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - info.identity_manager - .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, Some("dinner".into())), - &p, - ) - .expect("record pending sent"); - } - - async fn status( - iw: &crate::wallet::identity::IdentityWallet, - wallet_id: &WalletId, - owner: &Identifier, - txid: &str, - ) -> PaymentStatus { - let wm = iw.wallet_manager.read().await; - let info = wm.get_wallet_info(wallet_id).expect("info"); - info.identity_manager - .managed_identity(owner) - .unwrap() - .dashpay() - .payments - .get(txid) - .expect("entry") - .status - } - - // The sweep's flip: memory moves to Failed and the overlay carries - // exactly the flipped row for the sweep's own store round. - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert_eq!( - flips.overlay[&owner][&txid_key].status, - PaymentStatus::Failed, - "the overlay must carry the Failed row for the sweep's own round" - ); - assert_eq!(flips.rollback.len(), 1); - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Failed, - "a swept transaction can never confirm — its sent payment must fail" - ); - - // Re-emitted sweep: idempotent no-op (nothing eligible, empty flip). - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert!( - flips.is_empty(), - "a re-emitted sweep must find nothing to flip" - ); - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Failed - ); - - // The chainlocked reinstatement re-emits the record confirmed; the - // hard evidence must be able to correct the Failed verdict. - super::confirm_sent_payment_by_txid( - &iw.wallet_manager, - &wallet_id, - &p, - &txid_key, - super::LIVE_CONFIRM_EVIDENCE, - ) - .await; - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Confirmed, - "a reinstated, confirmed transaction must recover the payment" - ); - - // And a stale sweep arriving after confirmation never demotes it. - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert!(flips.is_empty()); - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Confirmed, - "Confirmed is terminal — a stale sweep must not demote it" - ); - } - - /// The rejected-round contract: rolling the flip back returns memory to - /// the durable state (`Pending`), which is exactly what lets the - /// replayed sweep — re-emitted by the re-scan, because the rejected - /// round kept the loser's record too — find the entry eligible and - /// recompute the flip. Without the rollback, memory would read `Failed` - /// ahead of the store and the replay's eligibility check would skip the - /// entry forever. The adapter-level rejection wiring is pinned - /// end to end in `core_bridge`. - #[tokio::test] - async fn a_rolled_back_flip_is_recomputed_by_the_replayed_sweep() { - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - - let (manager, persister, wallet_id) = make_wallet().await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let txid = dashcore::Txid::from([0xAC; 32]); - let txid_key = txid.to_string(); - - let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); - let iw = wallet.identity(); - let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); - { - let mut wm = iw.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - info.identity_manager - .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 10_000, None), - &p, - ) - .expect("record pending sent"); - } - - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert!(!flips.is_empty()); - super::rollback_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; - - { - let wm = iw.wallet_manager.read().await; - let info = wm.get_wallet_info(&wallet_id).expect("info"); - let entry = info - .identity_manager - .managed_identity(&owner) - .unwrap() - .dashpay() - .payments - .get(&txid_key) - .expect("entry") - .clone(); - assert_eq!( - entry.status, - PaymentStatus::Pending, - "the rollback must return memory to the durable state" - ); - assert_eq!( - entry.amount_duffs, 10_000, - "the previous entry is restored whole" - ); - } - - // The replayed sweep finds the entry eligible again and recomputes - // the flip — the durability loop closes. - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert_eq!( - flips.overlay[&owner][&txid_key].status, - PaymentStatus::Failed, - "the replayed sweep must recompute the flip the rollback undid" - ); - } - - /// The stale-evidence race, frozen at its worst point: the reconcile - /// sweep snapshots an entry as `Pending` and reads its persisted - /// record, the sweep hook flips the entry to `Failed` mid-flight - /// (hooks are unordered spawned tasks, and the sweep deletes the - /// record on a third), and the reconciler then confirms from evidence - /// that predates the verdict — landing a dead payment terminally - /// `Confirmed`, durably, since nothing demotes `Confirmed` and - /// re-emitted sweeps are ineligible. The reconciler's declared - /// evidence (`RECONCILE_CONFIRM_EVIDENCE`, `Pending`-only) makes the - /// resolver's write-lock re-check turn exactly that into a no-op, - /// while live reinstatement evidence — which postdates any flip by - /// the event contract — still recovers the entry. - #[tokio::test] - async fn a_stale_reconcile_snapshot_cannot_confirm_a_swept_payment() { - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - - let (manager, persister, wallet_id) = make_wallet().await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let txid = dashcore::Txid::from([0xAF; 32]); - let txid_key = txid.to_string(); - - let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); - let iw = wallet.identity(); - let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); - { - let mut wm = iw.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - info.identity_manager - .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, None), - &p, - ) - .expect("record pending sent"); - } - - async fn status( - iw: &crate::wallet::identity::IdentityWallet, - wallet_id: &WalletId, - owner: &Identifier, - txid: &str, - ) -> PaymentStatus { - let wm = iw.wallet_manager.read().await; - let info = wm.get_wallet_info(wallet_id).expect("info"); - info.identity_manager - .managed_identity(owner) - .unwrap() - .dashpay() - .payments - .get(txid) - .expect("entry") - .status - } - - // The reconciler snapshotted the entry Pending; before it confirms, - // the sweep's verdict lands. - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert!(!flips.is_empty()); - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Failed - ); - - // The racing reconciler now confirms from its stale read, declaring - // exactly the evidence the production sweep declares. - super::confirm_sent_payment_by_txid( - &iw.wallet_manager, - &wallet_id, - &p, - &txid_key, - super::RECONCILE_CONFIRM_EVIDENCE, - ) - .await; - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Failed, - "evidence read before the sweep's verdict must not confirm the dead payment" - ); - - // Live reinstatement evidence — which postdates any flip by the - // event contract — still recovers the entry. - super::confirm_sent_payment_by_txid( - &iw.wallet_manager, - &wallet_id, - &p, - &txid_key, - super::LIVE_CONFIRM_EVIDENCE, - ) - .await; - assert_eq!( - status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Confirmed - ); - } - - /// The rollback may only revert the sweep flip's own still-standing - /// `Failed` write. The payment hooks run on their own task, so a - /// confirmation can land between the flip and its undo (a rejected - /// round, or a same-fold reinstatement) — and `Confirmed` is the - /// terminal `sent_status_transition_allowed` protects. An - /// unconditional restore would clobber it back to the captured - /// `Pending`, demoting a status the store may already hold. - #[tokio::test] - async fn rollback_does_not_clobber_a_concurrently_confirmed_entry() { - use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; - - let (manager, persister, wallet_id) = make_wallet().await; - let owner = Identifier::from([0xAA; 32]); - let contact = Identifier::from([0xBB; 32]); - let txid = dashcore::Txid::from([0xAE; 32]); - let txid_key = txid.to_string(); - - let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); - let iw = wallet.identity(); - let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); - { - let mut wm = iw.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - info.identity_manager - .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) - .expect("add owner"); - info.identity_manager - .managed_identity_mut(&owner) - .expect("managed") - .record_dashpay_payment( - txid_key.clone(), - PaymentEntry::new_sent(contact, 50_000, None), - &p, - ) - .expect("record pending sent"); - } - - let flips = - super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) - .await; - assert!(!flips.is_empty()); - - // The reinstated transaction's confirmation races in before the - // undo — Failed → Confirmed, the table's permitted correction. - super::confirm_sent_payment_by_txid( - &iw.wallet_manager, - &wallet_id, - &p, - &txid_key, - super::LIVE_CONFIRM_EVIDENCE, - ) - .await; - - // The undo arrives late (rejected round or same-fold retraction); - // it must find its own write gone and leave the terminal alone. - super::rollback_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; - - let wm = iw.wallet_manager.read().await; - let status = wm - .get_wallet_info(&wallet_id) - .expect("info") - .identity_manager - .managed_identity(&owner) - .expect("managed") - .dashpay() - .payments - .get(&txid_key) - .expect("entry") - .status; - assert_eq!( - status, - PaymentStatus::Confirmed, - "an undo may only revert the sweep's own still-standing Failed write — \ - never a concurrently confirmed terminal" - ); - } - /// A sent payment confirmed by a block must flip `Pending → Confirmed`. /// /// The wallet sees its *own* broadcast in the mempool first From 59d5023f1773b6a07fda2e48b9142d5c85711594 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:49:22 +0300 Subject: [PATCH 093/102] perf(unified-sdk-jni): allocate the sweep winner once per batch, not per loser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `superseded_by` is invariant for a batch, but the projection allocated a fresh 32-byte Java array for every loser. The loser count is network-influenced and this runs synchronously inside the atomic persistence callback, so it is work an attacker can scale. `new_object_array` fills every slot with its initial element, so the winner is now allocated once and the per-loser set disappears with it — the loop only projects txids. Sharing one array is safe because the Kotlin consumer only reads these values: `supersededBy[i]` feeds DAO arguments and entity fields, and nothing writes into the array. --- packages/rs-unified-sdk-jni/src/persistence.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 4a16a554479..8b7fceaf168 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -692,13 +692,21 @@ unsafe fn persist_changeset_sweep_batch( let txids = slice_or_empty(batch.txids, batch.txids_count); let txids_arr = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; - let winners = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; + // The winner is invariant for the whole batch, so it is allocated once + // and every slot is initialised to it — `new_object_array` fills the + // array with its initial element, so no per-loser set is needed either. + // The loser count is network-influenced and this projection runs + // synchronously inside the atomic persistence callback, so a per-loser + // allocation is work an attacker can scale. Sharing one array across + // the slots is safe because the Kotlin consumer only ever reads these + // values: `supersededBy[i]` feeds DAO arguments and entity fields, and + // nothing writes into the array. + let winner = env.byte_array_from_slice(&batch.superseded_by)?; + let winners = env.new_object_array(txids.len() as i32, &byte_array_cls, &winner)?; for (i, txid) in txids.iter().enumerate() { env.with_local_frame(8, |env| { let t = env.byte_array_from_slice(txid)?; - env.set_object_array_element(&txids_arr, i as i32, &t)?; - let w = env.byte_array_from_slice(&batch.superseded_by)?; - env.set_object_array_element(&winners, i as i32, &w) + env.set_object_array_element(&txids_arr, i as i32, &t) })?; } From 98bc011121e1f625fc29fce45f8618678f548a6c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:32:34 +0300 Subject: [PATCH 094/102] fix(platform-wallet): never drop balance snapshots under wallets-map writes BalanceUpdateHandler::on_wallet_event is synchronous and looked wallets up with try_read() on the tokio RwLock wallets map, dropping the event's balance snapshot whenever a manager lifecycle write (create / remove / load) was in flight. The bus neither retries nor coalesces, and TransactionsSwept can be the only event carrying the corrected lower balance - the winner that settled the inputs need not be wallet-relevant - so one lost sweep leaves removed funds on display until an unrelated balance-bearing event happens to arrive. Convert the map to arc_swap::ArcSwap (already this crate's idiom for rare-write / hot-read state): readers take a wait-free snapshot that can never fail or block, so the drop window no longer exists rather than being papered over; the rare lifecycle writers publish via rcu, preserving the generation-checked removal's check-and-remove atomicity. The sync-context accessors that used blocking_read() become wait-free loads as a side effect, removing their panic-inside-runtime hazard. The regression test parks a lifecycle writer mid-rcu and asserts a sweep snapshot delivered in that window is observable immediately. The pre-fix form of the same test - holding wallets.write() across the delivery - was run against the old code first and failed with the balance atomics stuck at the stale value. --- .../src/manager/accessors.rs | 24 ++-- .../src/manager/dashpay_sync.rs | 9 +- .../src/manager/dpns_sync.rs | 13 +- .../rs-platform-wallet/src/manager/load.rs | 19 +-- .../rs-platform-wallet/src/manager/mod.rs | 111 +++++++++++++++--- .../src/manager/platform_address_sync.rs | 13 +- .../src/manager/wallet_lifecycle.rs | 54 ++++++--- .../src/wallet/core/balance_handler.rs | 45 ++++--- 8 files changed, 195 insertions(+), 93 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7b9c6422826..29c5dcf09dc 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -368,13 +368,13 @@ impl PlatformWalletManager

{ /// Get a clone of a wallet by its ID. pub async fn get_wallet(&self, wallet_id: &WalletId) -> Option> { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets.get(wallet_id).cloned() } /// List all wallet IDs. pub async fn wallet_ids(&self) -> Vec { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets.keys().copied().collect() } @@ -439,10 +439,9 @@ impl PlatformWalletManager

{ // ----------------------------------------------------------------- /// Atomic snapshot of every wallet id currently registered on the - /// manager. Cheap (`Arc` read + `BTreeMap` key clone). + /// manager. Cheap (wait-free `ArcSwap` load + `BTreeMap` key clone). pub fn list_wallet_ids_blocking(&self) -> Vec { - let wallets = self.wallets.blocking_read(); - wallets.keys().copied().collect() + self.wallets.load().keys().copied().collect() } /// Network a registered wallet belongs to, or `None` when the id is @@ -463,9 +462,7 @@ impl PlatformWalletManager

{ /// registered wallet participates in each pass since the sync /// manager doesn't keep a separate watch list. pub fn platform_address_sync_config_blocking(&self) -> PlatformAddressSyncConfigSnapshot { - let wallets = self.wallets.blocking_read(); - let count = wallets.len(); - drop(wallets); + let count = self.wallets.load().len(); let interval = self.platform_address_sync_manager.interval(); let last = self .platform_address_sync_manager @@ -628,9 +625,7 @@ impl PlatformWalletManager

{ &self, wallet_id: &WalletId, ) -> Option { - let wallets = self.wallets.blocking_read(); - let wallet = wallets.get(wallet_id)?.clone(); - drop(wallets); + let wallet = self.wallets.load().get(wallet_id)?.clone(); let provider_lock = wallet.platform().provider_for_diagnostics(); let guard = provider_lock.blocking_read(); let Some(provider) = guard.as_ref() else { @@ -995,10 +990,9 @@ impl PlatformWalletManager

{ // byte strings for the same G1 point — no collision). let mut operator_index: std::collections::HashMap<[u8; 48], u32> = std::collections::HashMap::new(); - // Clone the `Arc` out and drop the `wallets` read - // guard before deriving (the derive calls take the wallet's own - // state lock — don't hold `wallets` across them). - let platform_wallet = self.wallets.blocking_read().get(wallet_id).cloned(); + // Clone the `Arc` out of the map snapshot before + // deriving (the derive calls take the wallet's own state lock). + let platform_wallet = self.wallets.load().get(wallet_id).cloned(); if let Some(platform_wallet) = platform_wallet { use crate::wallet::provider_key_at_index::ProviderKeyKind; for index in 0..operator_scan_max { diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 6f1f4340996..7c1b45e1d7e 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -52,8 +52,7 @@ use std::sync::{ }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::RwLock; - +use arc_swap::ArcSwap; use dash_async::{ThreadRegistry, WorkerConfig}; use crate::error::PlatformWalletError; @@ -132,7 +131,7 @@ impl DashPaySyncSummary { /// without any re-registration — and crucially without consulting the /// token registry, so DashPay-only identities are never skipped. pub struct DashPaySyncManager { - wallets: Arc>>>, + wallets: Arc>>>, /// Shared registry that owns this loop's lifecycle: it spawns the /// OS thread (with the deep-stack config below), owns its cancellation /// token, and joins it at shutdown. A generation-guarded slot handles a @@ -154,7 +153,7 @@ pub struct DashPaySyncManager { impl DashPaySyncManager { pub fn new( - wallets: Arc>>>, + wallets: Arc>>>, registry: Arc>, ) -> Self { Self { @@ -364,7 +363,7 @@ impl DashPaySyncManager { } let snapshot: Vec<(WalletId, Arc)> = { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; diff --git a/packages/rs-platform-wallet/src/manager/dpns_sync.rs b/packages/rs-platform-wallet/src/manager/dpns_sync.rs index ef376031921..6344c425c53 100644 --- a/packages/rs-platform-wallet/src/manager/dpns_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dpns_sync.rs @@ -9,8 +9,8 @@ //! //! **Wallet-driven, not registry-driven — by design.** A sibling of //! [`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager): it -//! holds the same `wallets` map, snapshots the wallet `Arc`s under a -//! read guard each sweep, and refreshes **every** wallet. It is a +//! holds the same `wallets` map, snapshots the wallet `Arc`s from its +//! wait-free map each sweep, and refreshes **every** wallet. It is a //! separate coordinator (not a seventh DashPay step) because the DashPay //! pass is contact/profile-scoped and runs at a 15s cadence, while //! marketplace state changes are rare — this loop defaults to 60s. @@ -43,8 +43,7 @@ use std::sync::{ }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::RwLock; - +use arc_swap::ArcSwap; use dash_async::{ThreadRegistry, WorkerConfig}; use crate::events::PlatformEventManager; @@ -129,7 +128,7 @@ impl DpnsSyncPassSummary { /// [`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager) /// verbatim. pub struct DpnsSyncManager { - wallets: Arc>>>, + wallets: Arc>>>, registry: Arc>, /// Dispatches `on_dpns_marketplace_sync_completed` after each pass. events: Arc, @@ -144,7 +143,7 @@ pub struct DpnsSyncManager { impl DpnsSyncManager { pub fn new( - wallets: Arc>>>, + wallets: Arc>>>, registry: Arc>, events: Arc, ) -> Self { @@ -289,7 +288,7 @@ impl DpnsSyncManager { } let snapshot: Vec<(WalletId, Arc)> = { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4a4d8a9d9ce..b8e536ec875 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -184,9 +184,11 @@ impl PlatformWalletManager

{ } let platform_wallet = Arc::new(platform_wallet); - let mut wallets_guard = self.wallets.write().await; - wallets_guard.insert(wallet_id, platform_wallet); - drop(wallets_guard); + self.wallets.rcu(|wallets| { + let mut wallets = std::collections::BTreeMap::clone(wallets); + wallets.insert(wallet_id, Arc::clone(&platform_wallet)); + wallets + }); inserted_in_wallets.push(wallet_id); } @@ -196,10 +198,13 @@ impl PlatformWalletManager

{ // remove from `self.wallets` first (UI surface), then // from the inner `wallet_manager`. if !inserted_in_wallets.is_empty() { - let mut wallets_guard = self.wallets.write().await; - for id in &inserted_in_wallets { - wallets_guard.remove(id); - } + self.wallets.rcu(|wallets| { + let mut wallets = std::collections::BTreeMap::clone(wallets); + for id in &inserted_in_wallets { + wallets.remove(id); + } + wallets + }); } if !inserted_in_manager.is_empty() { let mut wm = self.wallet_manager.write().await; diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 1e64401db2b..5b1fc54c8ed 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -332,7 +332,17 @@ pub struct PlatformWalletManager { /// update their lock-free balance atomics from event-handler /// context, without touching the SPV-contended `wallet_manager` /// lock. - pub(super) wallets: Arc>>>, + /// + /// An [`arc_swap::ArcSwap`] rather than a lock: readers take a + /// wait-free snapshot that can never fail or block, which the + /// balance handler depends on — the event bus neither retries nor + /// coalesces, so a snapshot dropped during a lifecycle write would + /// be lost for good (see `BalanceUpdateHandler`). Writers are the + /// rare manager lifecycle paths (create/remove/load) and publish + /// via `rcu`, whose closure must stay pure map manipulation — it + /// can run more than once under a concurrent-writer retry. + pub(super) wallets: + Arc>>>, /// Notified on InstantLock / ChainLock events for `AssetLockManager` waiters. pub(super) lock_notify: Arc, pub(super) spv_manager: Arc, @@ -437,7 +447,9 @@ impl PlatformWalletManager

{ .take_persistence_receiver() .expect("persistence receiver is available exactly once on a fresh WalletManager"); let wallet_manager = Arc::new(RwLock::new(wallet_manager_inner)); - let wallets = Arc::new(RwLock::new(std::collections::BTreeMap::new())); + let wallets = Arc::new(arc_swap::ArcSwap::from_pointee( + std::collections::BTreeMap::new(), + )); let lock_notify = Arc::new(Notify::new()); // Shared registry that owns the coordinators' loop-thread join // handles for a clean, panic-aware shutdown join. @@ -461,10 +473,11 @@ impl PlatformWalletManager

{ // Build handler list: app handler + internal handlers. // BalanceUpdateHandler holds a clone of the wallets map (a - // separate lock from wallet_manager) so it can look up - // PlatformWallets and write to their lock-free balance - // atomics from broadcast-handler context without contending - // with SPV's write lock. + // wait-free `ArcSwap`, separate from the wallet_manager lock) + // so it can look up PlatformWallets and write to their + // lock-free balance atomics from broadcast-handler context + // without contending with SPV's write lock — and without any + // window in which a lifecycle write could make the lookup fail. let lock_handler = Arc::new(LockNotifyHandler::new(Arc::clone(&lock_notify))); let balance_handler = Arc::new(BalanceUpdateHandler::new(Arc::clone(&wallets))); // DashPayPaymentHandler records incoming DashPay payments and @@ -778,14 +791,10 @@ impl PlatformWalletManager

{ )); }; - // Snapshot Arc clones under a short read lock; never hold the - // `wallets` read guard across the per-wallet `.await`s below — - // that would block registration and invite lock-ordering - // issues against each wallet's `wallet_manager` lock. - let wallets: Vec> = { - let guard = self.wallets.read().await; - guard.values().cloned().collect() - }; + // Snapshot Arc clones from the wait-free map; clone out rather + // than holding the `ArcSwap` guard across the per-wallet + // `.await`s below. + let wallets: Vec> = self.wallets.load().values().cloned().collect(); for wallet in wallets { wallet.platform().reset_sync_state().await; @@ -1219,4 +1228,78 @@ mod tests { "guard must clear the slot during unwind" ); } + + /// A `TransactionsSwept` snapshot delivered while a lifecycle write to + /// the `wallets` map is in flight must still land in the wallet's + /// lock-free balance atomics. A sweep can be the ONLY event carrying + /// the corrected (lower) balance — `superseded_by` need not be + /// wallet-relevant, so no later balance-bearing event is guaranteed — + /// and the event bus does not retry or coalesce, so dropping this one + /// snapshot leaves the removed funds on screen indefinitely. + /// + /// When the map was a `tokio::sync::RwLock` and the handler used + /// `try_read()`, this exact delivery-under-contention scenario dropped + /// the snapshot (the pre-fix form of this test held `wallets.write()` + /// across the delivery and failed). With the map an `ArcSwap`, the + /// closest reachable window is a lifecycle writer parked mid-`rcu`; + /// the handler's `load()` must observe a committed map and apply the + /// balance immediately, before that writer completes. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn swept_balance_survives_wallets_map_write_contention() { + use dashcore::hashes::Hash; + + use crate::test_support::test_platform_wallet_manager; + use crate::wallet::core::BalanceUpdateHandler; + use key_wallet::wallet::balance::WalletCoreBalance; + + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("fixture wallet is registered"); + + // The production unit under test, holding the same map the + // manager registers at construction. + let handler = BalanceUpdateHandler::new(Arc::clone(&manager.wallets)); + + // Park a lifecycle writer mid-publication: its `rcu` closure has + // read the current map but not yet committed the replacement. + // This pins open the window in which the old lock-based map + // made `try_read()` fail and lose the event. + let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let wallets_for_writer = Arc::clone(&manager.wallets); + let writer = std::thread::spawn(move || { + wallets_for_writer.rcu(|current| { + let _ = entered_tx.send(()); + let _ = release_rx.recv(); + Arc::clone(current) + }); + }); + entered_rx + .recv() + .expect("the writer must reach its rcu closure"); + + // Deliver the sweep while the write is in flight. + let corrected = WalletCoreBalance::new(1_234, 0, 0, 0); + handler.on_wallet_event(&crate::events::WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![dashcore::Txid::from_byte_array([0xAA; 32])], + superseded_by: dashcore::Txid::from_byte_array([0xBB; 32]), + released_outpoints: vec![], + balance: corrected, + account_balances: Default::default(), + }); + + // Observable immediately — before the lifecycle writer commits. + assert_eq!( + wallet.balance().confirmed(), + corrected.confirmed(), + "the swept balance snapshot was dropped: a lifecycle write to the wallets map \ + was in flight during delivery, and no later event will re-carry the correction" + ); + + release_tx.send(()).expect("writer still parked"); + writer.join().expect("writer thread completes"); + } } diff --git a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index f6e971d891e..f8e7cefb127 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -15,12 +15,11 @@ use std::sync::{ }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use arc_swap::ArcSwapOption; +use arc_swap::{ArcSwap, ArcSwapOption}; use dash_sdk::platform::address_sync::{AddressSyncConfig, AddressSyncResult}; use key_wallet::PlatformP2PKHAddress; use crate::wallet::PlatformAddressTag; -use tokio::sync::RwLock; use dash_async::ThreadRegistry; @@ -98,7 +97,7 @@ impl PlatformAddressSyncSummary { /// `sync_now` again returns an empty summary immediately (the caller can /// check `is_syncing()` to distinguish). pub struct PlatformAddressSyncManager { - wallets: Arc>>>, + wallets: Arc>>>, event_manager: Arc, /// Shared registry that owns this loop's lifecycle: it spawns the /// OS thread, owns its cancellation token, and joins it at shutdown. @@ -127,7 +126,7 @@ pub struct PlatformAddressSyncManager { impl PlatformAddressSyncManager { pub fn new( - wallets: Arc>>>, + wallets: Arc>>>, event_manager: Arc, registry: Arc>, ) -> Self { @@ -357,7 +356,7 @@ impl PlatformAddressSyncManager { } let snapshot: Vec<(WalletId, Arc)> = { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; @@ -454,7 +453,7 @@ impl PlatformAddressSyncManager { } let wallet = { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets.get(wallet_id).cloned() }; let wallet = @@ -514,7 +513,7 @@ mod tests { /// but still drives the full flag → gate → completion-event protocol /// we're testing here. fn make_manager() -> (Arc, Arc) { - let wallets = Arc::new(RwLock::new(BTreeMap::new())); + let wallets = Arc::new(ArcSwap::from_pointee(BTreeMap::new())); let counter = Arc::new(CompletionCounter::new()); let event_manager = Arc::new(PlatformEventManager::new(vec![ Arc::clone(&counter) as Arc diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index c9eafee286b..abd5bccb91a 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -58,11 +58,10 @@ fn parse_mnemonic_any_language(phrase: &str) -> Result { /// publish a NEW generation into both maps — the id is free in the inner /// manager from the moment the removal above completes, and nothing gates /// registration. Reproducing it deterministically from outside is not possible: -/// the window is bounded by two *different* locks, and the only lock a test -/// could hold to park the remover inside it (`self.wallets`) is the same lock -/// the registration must acquire to publish, so parking the remover would also -/// block the registration — and `tokio`'s `RwLock` hands the writer queue out -/// in FIFO order, which puts the remover first. A rendezvous is therefore the +/// the window is bounded by two *different* synchronization domains — the +/// inner manager's lock and the public map's `ArcSwap` publication — and a +/// test holds no lock that could park the remover between them without also +/// stalling the registration's own publish. A rendezvous is therefore the /// only way to pin this ordering without a sleep or a completion-order race. /// /// Compiled under `cfg(test)` only: neither this static nor its call site @@ -539,11 +538,14 @@ impl PlatformWalletManager

{ let platform_wallet = Arc::new(platform_wallet); - // Register the PlatformWallet handle. - { - let mut wallets = self.wallets.write().await; + // Register the PlatformWallet handle. `rcu` publishes a new map + // snapshot; the closure can rerun under a concurrent-writer + // retry, so it must stay pure map manipulation. + self.wallets.rcu(|wallets| { + let mut wallets = std::collections::BTreeMap::clone(wallets); wallets.insert(wallet_id, Arc::clone(&platform_wallet)); - } + wallets + }); // Re-seed the lock-free balance atomic from the wallet's inner // balance now that the wallet is in `self.wallets`. @@ -655,7 +657,7 @@ impl PlatformWalletManager

{ /// So once this method has removed generation G1 from the inner /// `wallet_manager`, the id is free and a concurrent registration can publish /// a *different* generation G2 into both maps before this method reaches its - /// own `self.wallets` removal — the two removals are separately locked, with + /// own `self.wallets` removal — the two removals are separately synchronized, with /// no happens-before edge between them and the registration. Removing by key /// there would take G2 out of the public map (leaving it registered in the /// inner manager, invisible and unremovable) and hand G2 to `tear_down`, @@ -696,7 +698,7 @@ impl PlatformWalletManager

{ // matched against. let (removed, _teardown) = loop { let candidate = { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); match wallets.get(wallet_id) { None => { return Err(PlatformWalletError::WalletNotFound(hex::encode(wallet_id))) @@ -706,7 +708,7 @@ impl PlatformWalletManager

{ }; let guard = candidate.generation().teardown_guard().await; let still_current = { - let wallets = self.wallets.read().await; + let wallets = self.wallets.load(); wallets .get(wallet_id) .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), candidate.generation())) @@ -837,13 +839,27 @@ impl PlatformWalletManager

{ // this method); removing by key would evict that live wallet and hand it // to `tear_down` under the wrong gate. { - let mut wallets = self.wallets.write().await; - let entry_is_ours = wallets - .get(wallet_id) - .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), &generation)); - if entry_is_ours { - wallets.remove(wallet_id); - } else { + // `rcu` keeps the generation check and the removal atomic: the + // closure sees the map the CAS will replace, and a concurrent + // publication retries the whole closure against the new map. + // The `Cell` therefore ends up holding the verdict of the + // attempt that actually committed. + let entry_is_ours = std::cell::Cell::new(false); + self.wallets.rcu(|wallets| { + let ours = wallets + .get(wallet_id) + .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), &generation)); + entry_is_ours.set(ours); + if ours { + let mut next = std::collections::BTreeMap::clone(wallets); + next.remove(wallet_id); + Arc::new(next) + } else { + // Not ours: publish the map unchanged. + Arc::clone(wallets) + } + }); + if !entry_is_ours.get() { tracing::warn!( wallet_id = %hex::encode(wallet_id), "remove_wallet: a new generation was registered under this id while the \ 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 2a498a180a9..c6b66165c9a 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -4,8 +4,8 @@ use std::collections::BTreeMap; use std::sync::Arc; +use arc_swap::ArcSwap; use dash_spv::EventHandler; -use tokio::sync::RwLock; use crate::events::{PlatformEventHandler, WalletEvent}; use crate::wallet::platform_wallet::WalletId; @@ -23,18 +23,29 @@ use crate::wallet::PlatformWallet; /// /// Registered in `PlatformWalletManager`'s handler list. The handler /// holds an `Arc` clone of the manager's `wallets` map (a *separate* -/// lock from the heavily-contended `wallet_manager` SPV write lock). -/// SPV holds the wallet-manager write lock for the entire duration of -/// block processing — looking the balance up through *that* lock would -/// silently lose every event during initial sync. The wallets map is -/// only written by manager lifecycle methods (`create_wallet_from_*`, -/// `remove_wallet`), so a `try_read()` here essentially never contends. +/// structure from the heavily-contended `wallet_manager` SPV write +/// lock). SPV holds the wallet-manager write lock for the entire +/// duration of block processing — looking the balance up through *that* +/// lock would silently lose every event during initial sync. +/// +/// The map is an [`ArcSwap`] so this lookup is wait-free and can never +/// fail: `load()` always returns the latest published map, even while a +/// 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 — 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>>>, + wallets: Arc>>>, } impl BalanceUpdateHandler { - pub fn new(wallets: Arc>>>) -> Self { + pub fn new(wallets: Arc>>>) -> Self { Self { wallets } } } @@ -69,16 +80,12 @@ impl EventHandler for BalanceUpdateHandler { WalletEvent::ChainLockProcessed { .. } => return, }; - // try_read on the wallets map (NOT the wallet_manager - // SPV-contended lock). The map is only written by manager - // lifecycle methods, so this almost never contends. - let Ok(wallets) = self.wallets.try_read() else { - tracing::debug!( - wallet = %hex::encode(wallet_id), - "Wallet balance update dropped: wallets-map lock contended" - ); - return; - }; + // Wait-free snapshot of the wallets map; cannot fail or block, + // so no balance-bearing event is ever dropped here. A wallet + // not in the snapshot is one registered concurrently with this + // event — its creation path re-seeds the balance atomics from + // the inner wallet after publishing it, covering that window. + let wallets = self.wallets.load(); if let Some(pw) = wallets.get(wallet_id) { pw.balance().set( balance.confirmed(), From 81f6acf12a32ef046e0d347451f2ce2a67a0a56c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:03:41 +0300 Subject: [PATCH 095/102] fix(platform-wallet): key sweep-tombstone lifetime on the winner's mined height Repin rust-dashcore to 090faea2 (#975), which adds winner_mined_height to WalletEvent::TransactionsSwept, and rework the sweep-tombstone lifetime rule on all three backends to mirror key-wallet's observed-spends doctrine exactly, closing the held_since_height review blocker: - a mempool-context sweep (IS-locked winner, unmined) creates no placeholder at all: upstream deliberately never records an unconfirmed spend ("an unconfirmed spend must not invalidate a coin"), the engine keeps no durable hold the mirror could be mirroring, and the placeholder population an attacker could grow by double-spending incoming payments dies at the source - a block-context placeholder stores the winner's own mined height and is collected exactly when min(chainlock_height, synced_height) reaches it - prune_finalized_observed_spends' condition verbatim; the two-block observation-age margin, the held_since_height stamp, and the back-fill machinery are removed, not bypassed - an IS-locked chained re-point keeps the earlier block-context stamp, as upstream never retracts an observed-spend entry for an unconfirmed conflict; a block-context re-point re-stamps to the new winner's height - SweepBatchFFI carries the winner's finality context, and the numeric chainlock height now crosses to mobile through a new size-negotiated extension slot, replacing the "chainlock bytes exist" gate that let Swift and Kotlin collect on synced height alone SQLite V006 and Room's v12->13 migration are amended in place under the pre-release policy (nothing shipped has applied either); a dev database that ran the old V006 fails refinery's divergence check and must be recreated. --- Cargo.lock | 46 +- Cargo.toml | 16 +- .../13.json | 17 +- .../persistence/DashDatabaseMigrationTest.kt | 32 +- .../dashsdk/ffi/NativePersistenceBridge.kt | 24 +- .../dashsdk/persistence/DashDatabase.kt | 35 +- .../PlatformWalletPersistenceHandler.kt | 201 ++++-- .../dashsdk/persistence/dao/DocumentDao.kt | 38 +- .../entities/PendingInputEntity.kt | 37 +- .../persistence/entities/WalletEntity.kt | 11 + .../PlatformWalletPersistenceHandlerTest.kt | 502 ++++++++++++--- .../src/core_wallet_types.rs | 18 + .../rs-platform-wallet-ffi/src/manager.rs | 64 +- .../rs-platform-wallet-ffi/src/persistence.rs | 238 ++++++- .../V006__utxo_sweep_winner_height.rs | 45 ++ .../migrations/V006__utxo_tombstone_stamp.rs | 35 -- .../src/sqlite/schema/core_state.rs | 203 +++--- .../tests/sqlite_transaction_sweeps.rs | 446 ++++++++++--- .../src/changeset/changeset.rs | 22 + .../src/changeset/core_bridge.rs | 44 ++ .../rs-platform-wallet/src/manager/mod.rs | 1 + .../identity/network/payment_handler.rs | 1 + .../rs-unified-sdk-jni/src/persistence.rs | 48 +- .../Persistence/DashModelContainer.swift | 10 + .../Models/PersistentPendingInput.swift | 41 +- .../Persistence/Models/PersistentWallet.swift | 13 + .../PlatformWalletPersistenceHandler.swift | 253 ++++++-- .../SweptTransactionPersistTests.swift | 586 ++++++++++++++---- 28 files changed, 2348 insertions(+), 679 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V006__utxo_sweep_winner_height.rs delete mode 100644 packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs diff --git a/Cargo.lock b/Cargo.lock index 76a29ce49f4..f8b0122484b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" 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=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" 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=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" 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=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" 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=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" 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=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" 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=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" dependencies = [ "bincode", "dashcore-private", @@ -2493,7 +2493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2554,7 +2554,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2923,7 +2923,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" [[package]] name = "glob" @@ -3858,7 +3858,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4114,7 +4114,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" dependencies = [ "aes", "async-trait", @@ -4143,7 +4143,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4159,7 +4159,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b66db390d71d2065b817ca8eba69f808a01ec37a#b66db390d71d2065b817ca8eba69f808a01ec37a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" dependencies = [ "async-trait", "bincode", @@ -4670,7 +4670,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5770,7 +5770,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6578,7 +6578,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6591,7 +6591,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6650,7 +6650,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7510,7 +7510,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8959,7 +8959,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a7f592a05c9..bcf2fd57918 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "b66db390d71d2065b817ca8eba69f808a01ec37a" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } tokio-metrics = "0.5" diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json index 527d0089451..218055f2930 100644 --- a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 13, - "identityHash": "ee7037cf7dec388532aab3174187d2e2", + "identityHash": "263002ef596467980280337c18e9c337", "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, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "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", @@ -58,6 +58,11 @@ "columnName": "lastAppliedChainLockBytes", "affinity": "BLOB" }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, { "fieldPath": "isImported", "columnName": "isImported", @@ -2799,7 +2804,7 @@ }, { "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, `heldSinceHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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", @@ -2850,8 +2855,8 @@ "defaultValue": "0" }, { - "fieldPath": "heldSinceHeight", - "columnName": "heldSinceHeight", + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", "affinity": "INTEGER" } ], @@ -4138,7 +4143,7 @@ ], "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, 'ee7037cf7dec388532aab3174187d2e2')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '263002ef596467980280337c18e9c337')" ] } } \ 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 1ad197b4357..e071928623f 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 @@ -478,35 +478,51 @@ class DashDatabaseMigrationTest { } /** - * v12 → v13 adds `pending_inputs.heldSinceHeight` (nullable, no - * default) — additive. Pre-existing tombstones must survive and read - * back unstamped (NULL — the collector back-fills them before it ever - * collects), and the column must accept an explicit stamp on write. + * v12 → v13 adds `pending_inputs.winnerMinedHeight` and + * `wallets.lastAppliedChainLockHeight` (both nullable, no default) — + * additive. Pre-existing rows must survive and read back NULL + * (an unstamped tombstone is never collected, and no chainlock height + * means no finality boundary), and both columns must accept an + * explicit value on write. */ @Test - fun migrate12To13AddsTombstoneStamp() { + fun migrate12To13AddsWinnerHeightAndChainLockHeight() { val legacy = helper.createDatabase(dbName, 12) legacy.execSQL( "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + "walletId, createdAt, isSweptTombstone) " + "VALUES (x'04', 0, x'05', x'06', 0, 1)", ) + legacy.execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'06', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) legacy.close() val db = helper.runMigrationsAndValidate(dbName, 13, true, DashDatabase.MIGRATION_12_13) - db.query("SELECT heldSinceHeight FROM pending_inputs WHERE outpoint = x'04'").use { c -> + db.query("SELECT winnerMinedHeight FROM pending_inputs WHERE outpoint = x'04'").use { c -> assertTrue(c.moveToFirst()) assertTrue("pre-migration tombstones read back unstamped", c.isNull(0)) } + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'06'").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, heldSinceHeight) " + + "walletId, createdAt, isSweptTombstone, winnerMinedHeight) " + "VALUES (x'07', 0, x'05', x'06', 0, 1, 1234)", ) - db.query("SELECT heldSinceHeight FROM pending_inputs WHERE outpoint = x'07'").use { c -> + db.query("SELECT winnerMinedHeight FROM pending_inputs WHERE outpoint = x'07'").use { c -> assertTrue(c.moveToFirst()) assertEquals(1234, c.getInt(0)) } + db.execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321 WHERE walletId = x'06'") + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'06'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(4321, c.getInt(0)) + } db.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 fb123f6608f..660e0164f53 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 @@ -312,7 +312,14 @@ abstract class NativePersistenceBridge { * each paired by index with the transaction that settled its inputs, * plus the outpoints the removals actually freed. Fired once after the * per-account decomposition, and only when the round swept something. - * Descriptor `([B[[B[[B[[B)I`. + * Descriptor `([B[[B[[B[[BI)I`. + * + * [winnerMinedHeight] is the winner's own mined block height for a + * block-context sweep, or -1 for an InstantSend-locked winner not yet + * mined (the sentinel is unambiguous — block heights are + * non-negative — and the handler maps it back to null). It keys the + * whole lifetime rule of a pending-input tombstone: no height, no + * tombstone. * * 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 @@ -354,9 +361,24 @@ abstract class NativePersistenceBridge { txids: Array, supersededBy: Array, releasedOutpoints: Array, + winnerMinedHeight: Int, ): Int = if (persistenceCapabilitiesBits() and CAPABILITY_CORE_SWEEP_REMOVAL != 0L) 1 else 0 + /** + * The round's numeric chainlock height, fired on chainlock-advancing + * persistence rounds after the header slot. 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 25ec14dac2e..9a7152bb6e5 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 @@ -143,14 +143,18 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * swept. * * Version 13 (bounded tombstone lifetime): adds - * `pending_inputs.heldSinceHeight`, the creation stamp the header - * callback's collector measures a swept tombstone's bounded lifetime - * from. A tombstone for a foreign input of a swept incoming payment never - * drains, and before this column existed it was permanent — junk an - * attacker could grow without limit by double-spending payments at the - * wallet. Nullable and additive: pre-migration rows read back unstamped, - * and the collector back-fills them with the current synced height before - * it ever collects them. + * `pending_inputs.winnerMinedHeight` — the sweep winner's own mined block + * height, the stamp the collector compares against the chainlock finality + * boundary `min(chainlockHeight, syncedHeight)` — and + * `wallets.lastAppliedChainLockHeight`, the numeric chainlock height + * delivered by `onWalletChangesetChainLockHeight` that supplies the + * chainlock half of that boundary (the bincode chainlock blob is opaque + * here). A tombstone for a foreign input of a swept incoming payment + * never drains, and before these columns existed it was permanent — junk + * an attacker could grow without limit by double-spending payments at the + * wallet. Both nullable and additive: pre-migration tombstones read back + * unstamped and are never collected (no proof of finality), and a wallet + * with no recorded chainlock height has no boundary at all. */ @Database( version = 13, @@ -617,15 +621,20 @@ abstract class DashDatabase : RoomDatabase() { } /** - * v12 → v13: adds `pending_inputs.heldSinceHeight` (additive, - * nullable — no default needed) — see the version-13 class doc - * above. Pre-migration tombstones read back unstamped and are - * back-filled by the collector before they can be collected. + * v12 → v13: adds `pending_inputs.winnerMinedHeight` and + * `wallets.lastAppliedChainLockHeight` (both additive, nullable — + * no default needed) — see the version-13 class doc above. + * Pre-migration tombstones read back unstamped and are never + * collected; the chainlock height starts NULL, so no boundary + * exists until `onWalletChangesetChainLockHeight` records one. */ val MIGRATION_12_13: Migration = object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( - "ALTER TABLE `pending_inputs` ADD COLUMN `heldSinceHeight` INTEGER", + "ALTER TABLE `pending_inputs` ADD COLUMN `winnerMinedHeight` INTEGER", + ) + db.execSQL( + "ALTER TABLE `wallets` ADD COLUMN `lastAppliedChainLockHeight` INTEGER", ) } } 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 e71bce4dac2..a8ed5745763 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 @@ -705,36 +705,86 @@ class PlatformWalletPersistenceHandler( lastUpdated = now(), ), ) - // Bounded tombstone lifetime (the Room mirror of the SQLite - // store's `collect_finalized_tombstones`): once the synced - // height clears a swept tombstone's stamp by the margin, the - // row has provably never drained — a genuine claim's row is - // deleted by the drain in `onWalletChangesetUtxoAdded` when - // its funding TXO lands — so what remains is junk from foreign - // inputs of swept incoming payments, previously permanent and - // attacker-growable. Unstamped rows are back-filled first so - // they wait a full margin from first sight. Gated on a - // chainlock having been applied at some point, mirroring - // upstream's "no-op until a chainlock has been applied"; the - // chainlock's own height is bincode-opaque on this side of the - // FFI, so the boundary is the synced height alone — the - // winner's finality never depended on it (a sweep only fires - // for a chainlocked or InstantSend-locked winner), and synced - // height is the half of the upstream boundary that certifies - // filter coverage. - val chainLockApplied = lastAppliedChainLockBytes.isNotEmpty() || - wallet.lastAppliedChainLockBytes?.isNotEmpty() == true - if (hasSyncedHeight && syncedHeight > 0 && chainLockApplied) { - db.documentDao().backfillSweptTombstoneStamps(walletId, syncedHeight) - val cut = syncedHeight - SWEPT_TOMBSTONE_COLLECT_MARGIN - if (cut > 0) { - db.documentDao().collectFinalizedSweptTombstones(walletId, cut) - } + // Bounded tombstone lifetime, synced-height half (the Room + // mirror of the SQLite store's `collect_finalized_tombstones`): + // once the chainlock finality boundary reaches a swept + // tombstone's winner height, the row has provably never + // drained — a genuine claim's row is deleted by the drain in + // `onWalletChangesetUtxoAdded` when its funding TXO lands — so + // what remains is junk from foreign inputs of swept incoming + // payments, previously permanent and attacker-growable. The + // chainlock half of the boundary is the NUMERIC height + // `onWalletChangesetChainLockHeight` stores on the wallet row; + // the mere presence of chainlock bytes proves nothing about + // WHICH block is final, so until a numeric height is on record + // there is no boundary and nothing collects — mirroring + // upstream's "no-op until a chainlock height has been + // persisted". + if (hasSyncedHeight && syncedHeight > 0) { + collectFinalizedSweptTombstones( + db, walletId, + chainLockHeight = wallet.lastAppliedChainLockHeight, + syncedHeight = syncedHeight, + ) } } 0 } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage + // Monotonic max — a stale round's chainlock never lowers the + // finality boundary, matching the SQLite store's + // `upsert_sync_state`. + val advanced = maxOf(height, wallet.lastAppliedChainLockHeight ?: Int.MIN_VALUE) + db.walletDao().upsert( + wallet.copy( + lastAppliedChainLockHeight = advanced, + lastUpdated = now(), + ), + ) + // Bounded tombstone lifetime, chainlock half: this call is + // what turns the boundary on at all (no numeric height, no + // collection), so a chainlock-advancing round must collect + // too — otherwise a wallet whose synced height stopped moving + // would hold finalized junk until the next header. + collectFinalizedSweptTombstones( + db, walletId, + chainLockHeight = advanced, + syncedHeight = wallet.syncedHeight, + ) + } + 0 + } + + /** + * 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, and the same boundary the SQLite store's + * `collect_finalized_tombstones` applies. 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. + * Called from both watermark writers — the header (synced height) and + * the chainlock-height slot — since either half advancing can + * complete the boundary. + */ + private suspend fun collectFinalizedSweptTombstones( + db: DashDatabase, + walletId: ByteArray, + chainLockHeight: Int?, + syncedHeight: Int, + ) { + if (chainLockHeight == null || syncedHeight <= 0) return + db.documentDao().collectFinalizedSweptTombstones( + walletId, + boundary = minOf(chainLockHeight, syncedHeight), + ) + } + override fun onWalletChangesetAccountBegin( walletId: ByteArray, accountIndex: Int, @@ -1203,6 +1253,26 @@ class PlatformWalletPersistenceHandler( * `onWalletChangesetUtxoAdded` knows to keep the coin spent — durably, * via `TxoEntity.supersededByTxid` — once the funding TXO materializes. * + * The tombstone is created only for a BLOCK-CONTEXT sweep + * ([winnerMinedHeight] non-null), stamped with the winner's own mined + * height — the projection of key-wallet's `observed_spent_outpoints`, + * which records nothing for a mempool/IS-lock spend ("an unconfirmed + * spend must not invalidate a coin"). An IS-locked, unmined winner + * leaves no tombstone: the wallet itself keeps no durable hold for an + * unconfirmed spend, so the mirror holding one would out-claim the + * thing it mirrors — and when the winner eventually mines, BIP158 + * delivery of its block re-marks the coin through the ordinary + * channels. This is also what kills the attacker-growable junk at the + * source: a swept INCOMING payment reaches this path too, and every + * sender-owned foreign input it names would land a tombstone nothing + * ever drains. Ownership cannot gate it (nothing can prove an input + * foreign; dashpay/rust-dashcore#968), so the mempool path creates + * none at all, and the collector evicts block-context tombstones once + * the chainlock finality boundary reaches their winner's height. + * Materialized `TxoEntity` rows still get spend-marked either way — + * they carry real funding data, so holding them costs nothing an + * attacker controls. + * * A tombstoned row can itself need to move again: [supersededBy] is a * winner in this round, but nothing stops it from losing a later round * to a further winner while [supersededBy]'s own funding TXO is still @@ -1243,16 +1313,18 @@ class PlatformWalletPersistenceHandler( txids: Array, supersededBy: Array, releasedOutpoints: Array, + winnerMinedHeight: Int, ): Int = guarded { stage(walletId) { db -> - val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage - // Creation stamp for any tombstone this round flags or - // re-points: the wallet's best-known synced height, `null` - // while none has been recorded (the collector back-fills - // rather than guesses). The collector in - // `onWalletChangesetHeader` measures the tombstone's bounded - // lifetime from this. - val tombstoneStamp = wallet.syncedHeight.takeIf { it > 0 } + if (db.walletDao().getByWalletId(walletId) == null) return@stage + // The winner's finality context: its own mined block height + // for a block-context sweep, null (JNI sentinel -1) for an + // InstantSend-locked winner not yet mined. This is the whole + // lifetime rule of any tombstone this round creates or + // re-points — the collector compares it against the chainlock + // finality boundary — and the null case creates no tombstone + // at all. + val winnerHeight = winnerMinedHeight.takeIf { it >= 0 } // Hold every input first, then free the ones upstream named: the // released set spans the whole round's removals, so it is // applied once rather than per transaction. @@ -1323,16 +1395,26 @@ class PlatformWalletPersistenceHandler( db.documentDao().deletePendingInputs(goneStaged) } if (heldStaged.isNotEmpty()) { - db.documentDao().updatePendingInputs( - heldStaged.map { - it.copy( - spendingTransactionTxid = null, - spendingTxid = supersededBy[i], - isSweptTombstone = true, - heldSinceHeight = tombstoneStamp, - ) - }, - ) + if (winnerHeight != null) { + db.documentDao().updatePendingInputs( + heldStaged.map { + it.copy( + spendingTransactionTxid = null, + spendingTxid = supersededBy[i], + isSweptTombstone = true, + winnerMinedHeight = winnerHeight, + ) + }, + ) + } else { + // An IS-locked, unmined winner creates no + // tombstone — see the doc comment above. The rows + // would cascade with the loser's delete anyway; + // deleting them here makes the end state explicit + // and independent of whether the shared loser row + // itself survives another wallet's claim below. + db.documentDao().deletePendingInputs(heldStaged) + } } // A pending input an EARLIER sweep already tombstoned to // txids[i] (that txid was itself a sweep's winner, and is @@ -1350,14 +1432,24 @@ class PlatformWalletPersistenceHandler( db.documentDao().deletePendingInputs(gonePrior) } if (stillHeld.isNotEmpty()) { - // Re-pointed to a new winner ⇒ re-stamped: the claim now - // belongs to a winner whose confirmation is measured from - // this round, not the original sweep's. + // Re-pointed to a new block-context winner ⇒ + // re-stamped 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. An + // IS-locked winner (null height) re-points the claim + // but keeps the existing stamp — upstream's + // observed-spend entry is never retracted by an + // unconfirmed conflict, and collection at the old + // height stays sound: the funding output of a spent + // outpoint is mined at or below the height of ANY + // block-context spender of it, so the boundary + // passing that height still proves the funding was + // delivered or never will be. db.documentDao().updatePendingInputs( stillHeld.map { it.copy( spendingTxid = supersededBy[i], - heldSinceHeight = tombstoneStamp ?: it.heldSinceHeight, + winnerMinedHeight = winnerHeight ?: it.winnerMinedHeight, ) }, ) @@ -3622,21 +3714,6 @@ class PlatformWalletPersistenceHandler( /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 - /** - * Blocks the synced height must clear past a swept tombstone's - * [PendingInputEntity.heldSinceHeight] stamp before the collector - * in [onWalletChangesetHeader] deletes it. Mirrors the SQLite - * store's `TOMBSTONE_COLLECT_MARGIN`: the stamp is taken when the - * sweep is observed — on the InstantSend path before the winner - * mines, customarily in the very next block — so the margin's - * first block covers the winner's own confirmation and the second - * is slack. Past the margin, convergence carries the claim - * instead: BIP158 filters match input prevout scripts, so any - * delivery path that ever classifies the funding output also - * delivers the winner's spend. - */ - private const val SWEPT_TOMBSTONE_COLLECT_MARGIN = 2 - /** `Network.testnet` rawValue — the Swift fallback network. */ private const val NETWORK_TESTNET = 1 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 0ec1e8b58a3..b9b695cbadf 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 @@ -237,34 +237,30 @@ interface DocumentDao { ) suspend fun hasOtherWalletPendingInput(txid: ByteArray, walletId: ByteArray): Boolean - /** - * Back-fill unstamped swept tombstones with the current synced - * height, so rows written before `heldSinceHeight` existed (or while - * no height was on record) wait a full collection margin from first - * sight instead of being guessed collectible. - */ - @Query( - "UPDATE pending_inputs SET heldSinceHeight = :stamp " + - "WHERE walletId = :walletId AND isSweptTombstone = 1 " + - "AND heldSinceHeight IS NULL", - ) - suspend fun backfillSweptTombstoneStamps(walletId: ByteArray, stamp: Int) - /** * Bounded tombstone lifetime: delete this wallet's swept tombstones - * whose stamp the synced height has cleared by the collection margin - * (`:cut` = synced − margin, computed by the caller). 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. + * whose winner's mined height the chainlock finality boundary has + * reached (`:boundary` = `min(chainlockHeight, syncedHeight)`, + * computed by the caller) — 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. Unstamped + * rows are never collected — no current writer produces one (a + * mempool-context sweep creates no tombstone, an IS-locked re-point + * keeps the existing stamp), so an unstamped row is legacy or foreign + * data and holding it forever is the safe reading. */ @Query( "DELETE FROM pending_inputs " + "WHERE walletId = :walletId AND isSweptTombstone = 1 " + - "AND heldSinceHeight IS NOT NULL AND heldSinceHeight <= :cut", + "AND winnerMinedHeight IS NOT NULL AND winnerMinedHeight <= :boundary", ) - suspend fun collectFinalizedSweptTombstones(walletId: ByteArray, cut: Int) + 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/entities/PendingInputEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt index 6969e58c78e..4c997ae1c20 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 @@ -77,21 +77,28 @@ data class PendingInputEntity( @ColumnInfo(defaultValue = "0") val isSweptTombstone: Boolean = false, /** - * Creation stamp of a swept tombstone: the wallet's `syncedHeight` at - * the round that flagged (or re-pointed) this row. A tombstone whose - * outpoint is a foreign input of a swept incoming payment never - * drains — no funding TXO ever arrives — so without a bound it is - * permanent junk an attacker grows one row per input by repeatedly - * double-spending payments at this wallet. The header callback's - * collector deletes tombstones once `syncedHeight` clears this stamp - * by the sweep margin (the storage mirror of key-wallet's - * `prune_finalized_observed_spends` doctrine); a genuine claim drains - * into its TXO on funding arrival and leaves the collectible set with - * the row. NULL means "flagged before this column existed, or with no - * synced height on record" — the collector back-fills it with the - * current height rather than guessing, so such rows wait a full - * margin from first sight. Nullable, so the ADD COLUMN migration + * 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. A tombstone whose outpoint is a + * foreign input of a swept incoming payment never drains — no funding + * TXO ever arrives — so without a bound it is permanent junk an + * attacker grows one row per input by repeatedly double-spending + * payments at this wallet. This stamp is the row's whole lifetime + * rule: the 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) creates no tombstone at all, and an IS-locked re-point + * keeps the existing stamp — so under the current writers an + * unstamped tombstone is legacy or foreign data, and holding it + * forever is the safe reading. Nullable, so the ADD COLUMN migration * needs no default and pre-migration rows read as unstamped. */ - val heldSinceHeight: Int? = null, + val winnerMinedHeight: Int? = null, ) 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..8766962dd03 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,17 @@ 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). Monotonic max — a stale + * round never lowers it. This is the chainlock half of the swept- + * tombstone collection boundary `min(chainlockHeight, syncedHeight)`; + * 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/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 5e4486e56ba..46b45efca60 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 @@ -102,7 +102,7 @@ class PlatformWalletPersistenceHandlerTest { assertTrue( "a hand-declared capability with the inherited no-op body must fail the round", declaringButNotOverriding.onWalletChangesetTransactionsSwept( - walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), + walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), 400, ) != 0, ) @@ -110,7 +110,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals( 0, nonAttesting.onWalletChangesetTransactionsSwept( - walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), + walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), 400, ), ) } @@ -2147,7 +2147,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) handler.onWalletChangesetTransactionsSwept( walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), - arrayOf(makeOutpoint(fundingTxid, 1)), + arrayOf(makeOutpoint(fundingTxid, 1)), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -2226,7 +2226,7 @@ class PlatformWalletPersistenceHandlerTest { walletId, arrayOf(sweptTxid), arrayOf(irrelevantWinner), // Upstream knows the winner took this coin even though it never // reports the winner itself, so nothing is released. - emptyArray(), + emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -2307,7 +2307,7 @@ class PlatformWalletPersistenceHandlerTest { // as a stamped hold. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) handler.onChangesetBegin(walletId) @@ -2472,7 +2472,7 @@ class PlatformWalletPersistenceHandlerTest { ) handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, reclaimerTxid) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), arrayOf(freedCoin), + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), arrayOf(freedCoin), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -2552,10 +2552,10 @@ class PlatformWalletPersistenceHandlerTest { // One round, two batches, in order. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(firstLoser), arrayOf(ByteArray(32) { 73 }), arrayOf(contested), + walletId, arrayOf(firstLoser), arrayOf(ByteArray(32) { 73 }), arrayOf(contested), 400, ) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(secondLoser), arrayOf(ByteArray(32) { 74 }), emptyArray(), + walletId, arrayOf(secondLoser), arrayOf(ByteArray(32) { 74 }), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -2666,7 +2666,7 @@ class PlatformWalletPersistenceHandlerTest { // (Q) is held rather than freed. handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletB, success = true) @@ -2684,7 +2684,7 @@ class PlatformWalletPersistenceHandlerTest { // Wallet A second: releases P. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -2716,7 +2716,7 @@ class PlatformWalletPersistenceHandlerTest { // Wallet A first: releases P. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -2734,7 +2734,7 @@ class PlatformWalletPersistenceHandlerTest { // Wallet B second: releases nothing. handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletB, success = true) @@ -2800,7 +2800,7 @@ class PlatformWalletPersistenceHandlerTest { // this test at all. handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletB, success = true) @@ -2892,12 +2892,12 @@ class PlatformWalletPersistenceHandlerTest { // only its own coin. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pA), + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pA), 400, ) handler.onChangesetEnd(walletId, success = true) handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pB), + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pB), 400, ) handler.onChangesetEnd(walletB, success = true) @@ -2938,7 +2938,7 @@ class PlatformWalletPersistenceHandlerTest { // the sweep already tombstoned it and deleted its phantom output. handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletB, success = true) @@ -3054,7 +3054,7 @@ class PlatformWalletPersistenceHandlerTest { walletId, arrayOf(loser), arrayOf(ByteArray(32) { 82 }), - released.toTypedArray(), + released.toTypedArray(), 400, ) val committed = handler.onChangesetEnd(walletId, success = true) @@ -3081,7 +3081,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(txid), arrayOf(ByteArray(32) { 44 }), emptyArray(), + walletId, arrayOf(txid), arrayOf(ByteArray(32) { 44 }), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = false) @@ -3210,7 +3210,7 @@ class PlatformWalletPersistenceHandlerTest { // with nothing on hand to update. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), emptyArray(), + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3300,7 +3300,7 @@ class PlatformWalletPersistenceHandlerTest { pOutpoint, 1, ) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3380,7 +3380,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( walletId, arrayOf(parentTxid, childTxid), - arrayOf(winnerTxid, winnerTxid), emptyArray(), + arrayOf(winnerTxid, winnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3455,7 +3455,7 @@ class PlatformWalletPersistenceHandlerTest { // First sweep: W beats L, holding P (still unfunded). handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), + walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3481,7 +3481,7 @@ class PlatformWalletPersistenceHandlerTest { // Second sweep: X beats W, releasing P this time. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), arrayOf(pOutpoint), + walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), arrayOf(pOutpoint), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3554,7 +3554,7 @@ class PlatformWalletPersistenceHandlerTest { // First sweep: W beats L, holding P. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loserTxid), arrayOf(intermediateWinner), emptyArray(), + walletId, arrayOf(loserTxid), arrayOf(intermediateWinner), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3574,7 +3574,7 @@ class PlatformWalletPersistenceHandlerTest { // Second sweep: X beats W, and this time upstream frees P. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(intermediateWinner), arrayOf(finalWinner), arrayOf(pOutpoint), + walletId, arrayOf(intermediateWinner), arrayOf(finalWinner), arrayOf(pOutpoint), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3626,7 +3626,7 @@ class PlatformWalletPersistenceHandlerTest { // First sweep: W beats L, holding P. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), + walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3642,7 +3642,7 @@ class PlatformWalletPersistenceHandlerTest { // Second sweep: X beats W, still holding the same input. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), emptyArray(), + walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) @@ -3744,12 +3744,12 @@ class PlatformWalletPersistenceHandlerTest { // beats L, holding everything (nothing funded, nothing released). handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), + walletId, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), 400, ) handler.onChangesetEnd(walletId, success = true) handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), + walletB, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), 400, ) handler.onChangesetEnd(walletB, success = true) assertNull("L is gone once both wallets ran", db.transactionDao().getByTxid(sharedLoser)) @@ -3771,7 +3771,7 @@ class PlatformWalletPersistenceHandlerTest { // wallet's — deletes the shared row. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(pA), + walletId, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(pA), 400, ) handler.onChangesetEnd(walletId, success = true) assertNull( @@ -3784,7 +3784,7 @@ class PlatformWalletPersistenceHandlerTest { // of its two coins and holding the other. handler.onChangesetBegin(walletB) handler.onWalletChangesetTransactionsSwept( - walletB, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(rB), + walletB, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(rB), 400, ) handler.onChangesetEnd(walletB, success = true) @@ -4736,8 +4736,28 @@ class PlatformWalletPersistenceHandlerTest { h.onChangesetEnd(walletId, success = true) } - /** Record a loser spending [outpoint] (funding unknown), then sweep it. */ - private fun seedSweptTombstone(outpoint: ByteArray, loser: ByteArray, winner: ByteArray) { + /** + * 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) must leave nothing. + */ + private fun seedSweptTombstone( + outpoint: ByteArray, + loser: ByteArray, + winner: ByteArray, + winnerMinedHeight: Int = 400, + ) { handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), @@ -4747,7 +4767,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetEnd(walletId, success = true) handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(loser), arrayOf(winner), emptyArray(), + walletId, arrayOf(loser), arrayOf(winner), emptyArray(), winnerMinedHeight, ) handler.onChangesetEnd(walletId, success = true) } @@ -4755,64 +4775,72 @@ class PlatformWalletPersistenceHandlerTest { @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, + // 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 header callback's collector deletes it once - // the synced height clears its stamp by the margin — and not one - // block sooner: the winner customarily mines at stamp + 1, and the - // margin keeps the claim through that block. + // 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) - headerRound(handler, 100) val fundingTxid = ByteArray(32) { 71 } val p = makeOutpoint(fundingTxid, 0) - seedSweptTombstone(p, ByteArray(32) { 72 }, ByteArray(32) { 73 }) + 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 wallet's synced height", - 100, tombstone.heldSinceHeight, + "the tombstone is stamped with the winner's own mined height, " + + "not any observation watermark", + 400, tombstone.winnerMinedHeight, ) - headerRound(handler, 101) + // 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 101 has not cleared stamp 100 by the margin — the hold stays", + "boundary min(10000, 399) = 399 is below the winner's height 400 — the hold stays", 1, db.documentDao().getPendingInputsByOutpoint(p).size, ) - headerRound(handler, 102) + headerRound(handler, 400) assertTrue( - "boundary 102 cleared stamp 100 by the margin — the junk row is gone", + "the boundary reaching the winner's height collects the row — no margin", db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), ) } @Test - fun aSweptTombstoneOutlivesAnySyncProgressWithoutAChainLock() = runTest { - // Synced height alone is not finality: until a chainlock has been - // applied the collector must not run, mirroring upstream's (and the - // SQLite store's) "no-op until a chainlock has been applied". The - // moment one lands, the aged stamp collects. + 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, chainLockBytes = ByteArray(0)) + headerRound(handler, 100) val fundingTxid = ByteArray(32) { 74 } val p = makeOutpoint(fundingTxid, 0) - seedSweptTombstone(p, ByteArray(32) { 75 }, ByteArray(32) { 76 }) + seedSweptTombstone(p, ByteArray(32) { 75 }, ByteArray(32) { 76 }, winnerMinedHeight = 400) - headerRound(handler, 500, chainLockBytes = ByteArray(0)) + headerRound(handler, 100_000) assertEquals( - "no chainlock has ever been applied — the hold outlasts any " + - "amount of synced-height progress", + "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, ) - headerRound(handler, 500) + chainLockHeightRound(handler, 100_000) assertTrue( - "the first applied chainlock supplies the boundary and the " + - "long-aged stamp collects", + "the first numeric chainlock height supplies the boundary and " + + "the finalized stamp collects", db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), ) } @@ -4843,10 +4871,10 @@ class PlatformWalletPersistenceHandlerTest { val fundingTxid = ByteArray(32) { 77 } val p = makeOutpoint(fundingTxid, 0) val winner = ByteArray(32) { 79 } - seedSweptTombstone(p, ByteArray(32) { 78 }, winner) + seedSweptTombstone(p, ByteArray(32) { 78 }, winner, winnerMinedHeight = 400) assertEquals( - "sanity: held, undrained, stamped", - 100, db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + "sanity: held, undrained, stamped with the winner's height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, ) handler.onChangesetBegin(walletId) @@ -4857,6 +4885,7 @@ class PlatformWalletPersistenceHandlerTest { 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) @@ -4865,58 +4894,66 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun anUnstampedTombstoneIsBackfilledBeforeItCanBeCollected() = runTest { - // A tombstone flagged while no synced height was on record (or - // written before `heldSinceHeight` existed — the v12 → v13 - // migration leaves those NULL) is back-filled with the current - // height on the collector's first sight of it, never collected in - // that same round, and then ages out like any other. + fun aTombstoneWithoutAWinnerHeightIsNeverCollected() = runTest { + // A tombstone with a NULL stamp is never collected. No current + // writer produces one — a mempool-context sweep creates no + // tombstone at all and an IS-locked re-point keeps the existing + // stamp — so an unstamped row is legacy data (the v12 → v13 + // migration leaves pre-existing tombstones NULL) or foreign, and + // 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) - seedSweptTombstone(p, ByteArray(32) { 81 }, ByteArray(32) { 82 }) - assertNull( - "sanity: no synced height existed, so the stamp is NULL", - db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, - ) - - headerRound(handler, 1_000) - assertEquals( - "first collection pass back-fills the stamp instead of collecting", - 1_000, db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + // Plant the shape directly — the current writers cannot produce it. + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = p, + inputIndex = 0, + spendingTxid = ByteArray(32) { 82 }, + walletId = walletId, + isSweptTombstone = true, + winnerMinedHeight = null, + ), ) - headerRound(handler, 1_002) - assertTrue( - "the back-filled stamp ages out like any other", - db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + // 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 - // winner also re-stamps it: the claim now belongs to a winner whose - // confirmation is measured from this round, not the original - // sweep's. + // 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) - headerRound(handler, 100) 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) + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) assertEquals( - "sanity: stamped at the first sweep's height", - 100, db.documentDao().getPendingInputsByOutpoint(p).single().heldSinceHeight, + "sanity: stamped with the first winner's mined height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, ) - headerRound(handler, 105) - // The first winner's own record, then its sweep — the carry-forward - // path that re-points the earlier tombstone. + // 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) handler.onWalletChangesetTransaction( walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), @@ -4926,7 +4963,7 @@ class PlatformWalletPersistenceHandlerTest { handler.onChangesetEnd(walletId, success = true) handler.onChangesetBegin(walletId) handler.onWalletChangesetTransactionsSwept( - walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), 450, ) handler.onChangesetEnd(walletId, success = true) @@ -4936,9 +4973,280 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(row.isSweptTombstone) assertTrue(finalWinner.contentEquals(row.spendingTxid)) assertEquals( - "re-pointed ⇒ re-stamped to the later sweep's height", - 105, row.heldSinceHeight, + "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 aMempoolContextSweepCreatesNoPlaceholderRows() = runTest { + // A mempool-context sweep — an InstantSend-locked winner that has + // not mined — creates NO tombstone for a held-but-unfunded input, + // however often it happens. This is upstream's own doctrine + // projected ("an unconfirmed spend must not invalidate a coin"), + // and it kills the attacker-growable population at the source: a + // swept incoming payment's foreign inputs land nothing, so + // repeated double-spends at this wallet grow nothing. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + // Repeated double-spent incoming payments: distinct losers, each + // claiming distinct still-unfunded inputs, each swept by an + // IS-locked winner. + 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, + ) + assertTrue( + "an unmined IS-locked winner must leave no pending row for input #$i", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + assertEquals( + "repeated mempool-path double-spends must leave zero pending rows", + 0L, 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. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 123 } + val p = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 124 } + val winner = ByteArray(32) { 125 } + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + 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 aFundingOutputArrivingAfterAMempoolSweepAndRestartLandsUnspent() = runTest { + // The reviewer's named regression, resolved by engine parity: 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. The wallet engine + // itself keeps no durable hold for an unconfirmed spend and would + // credit the coin — so this store must land it unspent too, with + // no stale tombstone in the way and no tombstone wrongly collected + // beforehand (none ever existed). Convergence is the winner's job: + // when it mines, BIP158 delivery of its block re-marks the coin + // through the ordinary channels. + 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 = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 126 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 127 }, ByteArray(32) { 0x7F }, winnerMinedHeight = -1) + assertTrue( + "sanity: the mempool-context sweep left no pending row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + + // Restart: a fresh handler bound to the same underlying store — + // this suite's restart idiom (see + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded). + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + // Arbitrary chainlock/height advancement while the winner stays + // unmined. + headerRound(restarted, 25_000) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetChainLockHeight(walletId, 25_000) + restarted.onChangesetEnd(walletId, success = true) + + // The funding output is finally delivered and classified. + 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) + assertFalse( + "the engine credits a coin whose only spender is unconfirmed — the " + + "mirror must agree after a restart, not hold a claim the wallet " + + "itself no longer remembers", + coin!!.isSpent, + ) + assertNull(coin.supersededByTxid) + assertTrue(db.documentDao().getPendingInputsByOutpoint(p).isEmpty()) + assertEquals(1, restarted.onLoadWalletList().single().utxos.size) + } + + @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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), -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 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-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index cf77bce0e05..6818bf5e53f 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -298,6 +298,22 @@ pub struct SweepBatchFFI { /// this is the only thing telling it which to hand back. pub released_outpoints: *const OutPointFFI, pub released_outpoints_count: usize, + /// Whether `winner_mined_height` is meaningful. `false` means the sweep + /// was triggered by an InstantSend-locked winner still waiting to be + /// mined (upstream's only other trigger — an unlocked mempool arrival + /// never sweeps), and the winner has NO finality horizon: a persister + /// must not create a durable placeholder for a held-but-unfunded input + /// on such a sweep, mirroring upstream's refusal to record an + /// unconfirmed spend, and must keep (not clear) the stamp of any + /// existing placeholder it re-points. + pub has_winner_mined_height: bool, + /// Mined height of `superseded_by` when `has_winner_mined_height` — + /// the winner's own block, carried from the sweep event because the + /// winner may never appear anywhere else in this wallet's stream. A + /// persister stamps it onto the placeholder it writes for a + /// held-but-unfunded input, and collects that placeholder exactly when + /// `min(chainlock_height, synced_height)` reaches the stamp. + pub winner_mined_height: u32, } // --------------------------------------------------------------------------- @@ -592,6 +608,8 @@ pub(crate) fn build_sweep_batches_for_callback( backing.released.as_ptr() }, released_outpoints_count: backing.released.len(), + has_winner_mined_height: batch.winner_mined_height.is_some(), + winner_mined_height: batch.winner_mined_height.unwrap_or(0), } }) .collect(); diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 11212cbf104..cff21aaff01 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,9 +8,9 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, PersistDpnsNameStatesFn, PersistWalletChangesetSweepsFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, - PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + FFIPersister, PersistDpnsNameStatesFn, PersistWalletChangesetChainLockHeightFn, + PersistWalletChangesetSweepsFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -78,6 +78,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create( None, None, None, + None, out_handle, ) } @@ -105,6 +106,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_capabil None, None, None, + None, out_handle, ) } @@ -132,6 +134,8 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_extensi let declaration = persistence_capabilities_declaration(&*persistence_capabilities); let dpns_callback = persistence_extension_dpns_callback(persistence_extension); let sweeps_callback = persistence_extension_sweeps_callback(persistence_extension); + let chain_lock_height_callback = + persistence_extension_chain_lock_height_callback(persistence_extension); platform_wallet_manager_create_impl( sdk_ptr, persistence, @@ -139,6 +143,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_extensi declaration, dpns_callback, sweeps_callback, + chain_lock_height_callback, None, out_handle, ) @@ -164,6 +169,8 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( let declaration = persistence_capabilities_declaration(&*persistence_capabilities); let dpns_persistence_callback = persistence_extension_dpns_callback(persistence_extension); let sweeps_persistence_callback = persistence_extension_sweeps_callback(persistence_extension); + let chain_lock_height_persistence_callback = + persistence_extension_chain_lock_height_callback(persistence_extension); let dpns_event_callback = event_extension_dpns_callback(event_extension); platform_wallet_manager_create_impl( sdk_ptr, @@ -172,6 +179,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( declaration, dpns_persistence_callback, sweeps_persistence_callback, + chain_lock_height_persistence_callback, dpns_event_callback, out_handle, ) @@ -232,6 +240,18 @@ unsafe fn persistence_extension_sweeps_callback( ) } +unsafe fn persistence_extension_chain_lock_height_callback( + extension: *const PersistenceCallbacksExtension, +) -> Option { + negotiated_extension_slot!( + extension, + PersistenceCallbacksExtension, + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + on_persist_wallet_changeset_chain_lock_height_fn, + PersistWalletChangesetChainLockHeightFn + ) +} + unsafe fn event_extension_dpns_callback( extension: *const EventHandlerCallbacksExtension, ) -> Option { @@ -255,6 +275,7 @@ unsafe fn platform_wallet_manager_create_impl( declared_capabilities: PersistenceCapabilities, dpns_name_states_callback: Option, wallet_changeset_sweeps_callback: Option, + wallet_changeset_chain_lock_height_callback: Option, dpns_event_callback: Option, out_handle: *mut Handle, ) -> PlatformWalletFFIResult { @@ -292,11 +313,12 @@ unsafe fn platform_wallet_manager_create_impl( let sdk = Arc::new((*(sdk_ptr as *const Sdk)).clone()); let persister = Arc::new( - FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + FFIPersister::new_with_persistence_capabilities_and_all_extension_callbacks( std::ptr::read(persistence), declared_capabilities, dpns_name_states_callback, wallet_changeset_sweeps_callback, + wallet_changeset_chain_lock_height_callback, ), ); let handler: Arc = Arc::new(FFIEventHandler::new( @@ -818,6 +840,14 @@ mod tests { 0 } + unsafe extern "C" fn persist_wallet_changeset_chain_lock_height( + _context: *mut c_void, + _wallet_id: *const u8, + _chain_lock_height: u32, + ) -> i32 { + 0 + } + fn persistence_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_changeset_begin_fn: Some(begin_changeset), @@ -1143,6 +1173,8 @@ mod tests { assert!(unsafe { persistence_extension_dpns_callback(&unknown) }.is_none()); assert!(unsafe { persistence_extension_sweeps_callback(&short) }.is_none()); assert!(unsafe { persistence_extension_sweeps_callback(&unknown) }.is_none()); + assert!(unsafe { persistence_extension_chain_lock_height_callback(&short) }.is_none()); + assert!(unsafe { persistence_extension_chain_lock_height_callback(&unknown) }.is_none()); } /// The exact cross-version pairing the sweep transport exists for: a @@ -1170,13 +1202,37 @@ mod tests { }; assert!(unsafe { persistence_extension_dpns_callback(&legacy) }.is_some()); assert!(unsafe { persistence_extension_sweeps_callback(&legacy) }.is_none()); + assert!(unsafe { persistence_extension_chain_lock_height_callback(&legacy) }.is_none()); + + // A host built when the extension ended at the sweeps slot: sweeps + // negotiate, the chainlock-height slot is refused, never read. + let sweeps_era_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ); + let sweeps_era = PersistenceCallbacksExtension { + struct_size: sweeps_era_size, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + persist_wallet_changeset_chain_lock_height, + ), + ..Default::default() + }; + assert!(unsafe { persistence_extension_dpns_callback(&sweeps_era) }.is_some()); + assert!(unsafe { persistence_extension_sweeps_callback(&sweeps_era) }.is_some()); + assert!(unsafe { persistence_extension_chain_lock_height_callback(&sweeps_era) }.is_none()); let current = PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + persist_wallet_changeset_chain_lock_height, + ), ..Default::default() }; assert!(unsafe { persistence_extension_sweeps_callback(¤t) }.is_some()); + assert!(unsafe { persistence_extension_chain_lock_height_callback(¤t) }.is_some()); } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index efb7e3d20df..561e6f0893b 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -152,6 +152,21 @@ pub type PersistWalletChangesetSweepsFn = unsafe extern "C" fn( sweeps_count: usize, ) -> i32; +/// Carries the NUMERIC block height of the round's applied chainlock — +/// the same watermark whose bincode blob rides +/// `WalletChangeSetFFI::last_applied_chain_lock_bytes`, which is opaque to +/// a non-Rust host. The height is one half of the sweep-tombstone +/// collection boundary `min(chainlock_height, synced_height)` (see +/// [`SweepBatchFFI::winner_mined_height`]); without it a host either +/// cannot collect at all or has to guess from the synced height alone, +/// which is not finality. Fired inside the round's begin/end bracket, +/// after `on_persist_wallet_changeset_fn`, only when the round advanced +/// the chainlock watermark. Monotonic-max semantics at the host: chain +/// locks only move forward, so store `max(stored, incoming)`. A non-zero +/// return fails the round like any other per-kind callback. +pub type PersistWalletChangesetChainLockHeightFn = + unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8, chain_lock_height: u32) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -202,6 +217,20 @@ pub struct PersistenceCallbacksExtension { sweeps_count: usize, ) -> i32, >, + /// The round's numeric chainlock height (see + /// [`PersistWalletChangesetChainLockHeightFn`]). Appended under the + /// same version for the same reason as the sweeps slot above: + /// `struct_size` proves whether a host allocated it, and a host that + /// did not simply never has it read. Purely additive — a host without + /// it keeps working, it just cannot compute the tombstone-collection + /// finality boundary and must hold its tombstones instead. + pub on_persist_wallet_changeset_chain_lock_height_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + chain_lock_height: u32, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -212,6 +241,7 @@ impl Default for PersistenceCallbacksExtension { reserved: 0, on_persist_dpns_name_states_fn: None, on_persist_wallet_changeset_sweeps_fn: None, + on_persist_wallet_changeset_chain_lock_height_fn: None, } } } @@ -997,6 +1027,12 @@ pub struct FFIPersister { /// attestation of `CORE_SWEEP_REMOVAL`, unlike the legacy changeset /// callback whose unchanged signature proves nothing. wallet_changeset_sweeps_callback: Option, + /// `Some` only when the host's extension `struct_size` proved the slot + /// was allocated. Carries the numeric chainlock height a non-Rust host + /// cannot read out of the bincode blob on the changeset struct; a host + /// without it simply never collects sweep tombstones (safe — held, not + /// leaked to the unspent set). + wallet_changeset_chain_lock_height_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1074,11 +1110,30 @@ impl FFIPersister { declared_capabilities: PersistenceCapabilities, dpns_name_states_callback: Option, wallet_changeset_sweeps_callback: Option, + ) -> Self { + Self::new_with_persistence_capabilities_and_all_extension_callbacks( + callbacks, + declared_capabilities, + dpns_name_states_callback, + wallet_changeset_sweeps_callback, + None, + ) + } + + pub fn new_with_persistence_capabilities_and_all_extension_callbacks( + callbacks: PersistenceCallbacks, + declared_capabilities: PersistenceCapabilities, + dpns_name_states_callback: Option, + wallet_changeset_sweeps_callback: Option, + wallet_changeset_chain_lock_height_callback: Option< + PersistWalletChangesetChainLockHeightFn, + >, ) -> Self { Self { callbacks, dpns_name_states_callback, wallet_changeset_sweeps_callback, + wallet_changeset_chain_lock_height_callback, declared_capabilities, pending: RwLock::new(BTreeMap::new()), round_lock: Mutex::new(RoundGuardState::default()), @@ -1492,6 +1547,28 @@ impl PlatformWalletPersistence for FFIPersister { } } + // The numeric chainlock height rides its own size-negotiated + // extension slot for the same layout reason the sweeps below do: + // the bincode blob on the changeset struct is opaque to a + // non-Rust host, and the frozen `WalletChangeSetFFI` cannot grow + // a numeric field. Fired before the sweeps so a round carrying + // both has the boundary stored before any tombstone the sweep + // writes could be measured against it. + if let Some(cl) = core_cs.last_applied_chain_lock.as_ref() { + if let Some(cb) = self.wallet_changeset_chain_lock_height_callback { + let result = + unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), cl.block_height) }; + if result != 0 { + eprintln!( + "Wallet changeset chainlock-height persistence callback returned \ + error code {}", + result + ); + round_success = false; + } + } + } + // The round's sweeps ride their own size-negotiated extension // callback rather than the changeset struct (see the layout note // on `WalletChangeSetFFI`), fired immediately after it — still @@ -6522,10 +6599,16 @@ mod tests { } else { slice::from_raw_parts(batch.released_outpoints, batch.released_outpoints_count) }; + let winner_height = if batch.has_winner_mined_height { + format!("Some({})", batch.winner_mined_height) + } else { + "None".to_string() + }; events.push(format!( - "sweep txids={:?} winner={} released={:?}", + "sweep txids={:?} winner={} height={} released={:?}", txids.iter().map(|t| t[0]).collect::>(), batch.superseded_by[0], + winner_height, released .iter() .map(|o| (o.txid[0], o.vout)) @@ -6539,20 +6622,25 @@ mod tests { PlatformWalletChangeSet { core: Some(CoreChangeSet { sweeps: vec![ + // Block-context: the winner's mined height crosses. SweepBatch { txids: vec![dashcore::Txid::from_byte_array([0x11; 32])], superseded_by: dashcore::Txid::from_byte_array([0x22; 32]), + winner_mined_height: Some(910), released_outpoints: vec![dashcore::OutPoint::new( dashcore::Txid::from_byte_array([0x33; 32]), 7, )], }, + // IS-locked winner: no height — the consumer must + // see the absence, not a fabricated zero. SweepBatch { txids: vec![ dashcore::Txid::from_byte_array([0x44; 32]), dashcore::Txid::from_byte_array([0x55; 32]), ], superseded_by: dashcore::Txid::from_byte_array([0x66; 32]), + winner_mined_height: None, released_outpoints: vec![], }, ], @@ -6581,8 +6669,8 @@ mod tests { sink.events.lock().unwrap().clone(), vec![ "changeset".to_string(), - "sweep txids=[17] winner=34 released=[(51, 7)]".to_string(), - "sweep txids=[68, 85] winner=102 released=[]".to_string(), + "sweep txids=[17] winner=34 height=Some(910) released=[(51, 7)]".to_string(), + "sweep txids=[68, 85] winner=102 height=None released=[]".to_string(), ], ); drop(persister); @@ -6611,6 +6699,132 @@ mod tests { drop(persister); } + /// The numeric chainlock height reaches the host through its own + /// size-negotiated extension slot: a chainlock-advancing round fires it + /// after the changeset callback with the height a non-Rust host cannot + /// read out of the bincode blob, a round with no chainlock never fires + /// it, and a host without the slot still succeeds — it just never + /// learns the finality boundary and must hold its sweep tombstones. + #[test] + fn store_delivers_the_chainlock_height_through_the_extension_slot() { + use platform_wallet::changeset::CoreChangeSet; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events.lock().unwrap().push("changeset".into()); + 0 + } + unsafe extern "C" fn record_chain_lock_height( + ctx: *mut c_void, + _wallet_id: *const u8, + chain_lock_height: u32, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events + .lock() + .unwrap() + .push(format!("chain_lock_height={chain_lock_height}")); + 0 + } + fn chain_lock_at(height: u32) -> dashcore::ephemerealdata::chain_lock::ChainLock { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::hashes::Hash as _; + use dashcore::BlockHash; + dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0xCC; 32]), + signature: BLSSignature::from([0u8; 96]), + } + } + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_all_extension_callbacks( + callbacks, + PersistenceCapabilities::NONE, + None, + None, + Some(record_chain_lock_height), + ); + // A round with no chainlock: the slot stays silent. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("chainlock-less round must succeed"); + // A chainlock-advancing round: the numeric height crosses, after + // the changeset callback. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(chain_lock_at(4_242)), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("chainlock round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "changeset".to_string(), + "chain_lock_height=4242".to_string(), + ], + ); + drop(persister); + + // Host without the slot: the same round still succeeds. + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(chain_lock_at(4_242)), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("slotless-host chainlock round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + drop(persister); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -6756,9 +6970,11 @@ mod tests { assert_eq!(PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, 1); // The extension is append-only under version 1: the DPNS slot's end // is exactly where the sweeps slot begins (a version-1 host that - // predates sweeps declared its struct_size at that boundary), and - // the sweeps slot is currently terminal. Reordering either would - // silently misread every extension already in the field. + // predates sweeps declared its struct_size at that boundary), the + // sweeps slot's end is where the chainlock-height slot begins (the + // sweeps-era boundary), and the chainlock-height slot is currently + // terminal. Reordering any of them would silently misread every + // extension already in the field. assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, @@ -6774,6 +6990,16 @@ mod tests { PersistenceCallbacksExtension, on_persist_wallet_changeset_sweeps_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet-storage/migrations/V006__utxo_sweep_winner_height.rs b/packages/rs-platform-wallet-storage/migrations/V006__utxo_sweep_winner_height.rs new file mode 100644 index 00000000000..6d930b973dc --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V006__utxo_sweep_winner_height.rs @@ -0,0 +1,45 @@ +//! Anchor sweep tombstones at their winner's mined height and pin the +//! chainlock finality boundary. +//! +//! `core_utxos.winner_mined_height` is the mined height of the +//! transaction that beat an unmaterialised sweep tombstone's outpoint — +//! the placeholder row `apply_sweep` writes for a held input whose +//! funding output has never classified (`height IS NULL AND spent = 1`; +//! no other writer leaves `height` NULL). The height is carried on the +//! sweep event itself (`TransactionsSwept::winner_mined_height`), and +//! only a block-context sweep — one whose winner actually mined — writes +//! a tombstone at all; an InstantSend-locked winner has no mining +//! deadline and leaves no placeholder, mirroring key-wallet's +//! `observed_spent_outpoints` doctrine ("an unconfirmed spend must not +//! invalidate a coin"). The collector in `core_state::apply` evicts a +//! tombstone exactly when `min(chainlock_height, synced_height)` reaches +//! its winner's height — `prune_finalized_observed_spends`' condition +//! verbatim, with no observation-age margin. NULL is never written by +//! current code; an unstamped held row is held forever rather than +//! guessed collectible. +//! +//! `core_sync_state.chainlock_height` is the monotonic-max height of +//! the last applied chainlock, mirrored from +//! `CoreChangeSet::last_applied_chain_lock` (previously dropped by +//! this store). It is one half of the collector's finality boundary; +//! rows are never collected before a chainlock has been persisted, +//! matching upstream's "no-op until a chainlock has been applied". +//! +//! The partial index covers exactly the unmaterialised rows — the +//! collector's scan set — so the per-round sweep touches tombstones +//! only, not the wallet's full spent history. +//! +//! Edited in place (formerly `V006__utxo_tombstone_stamp`, column +//! `held_since_height`) under the same pre-release policy V001's test +//! documents: nothing shipped has applied this migration, and a dev +//! database that did apply the old shape fails refinery's divergence +//! check and must be recreated. + +pub fn migration() -> String { + "ALTER TABLE core_utxos ADD COLUMN winner_mined_height INTEGER; + ALTER TABLE core_sync_state ADD COLUMN chainlock_height INTEGER; + CREATE INDEX idx_core_utxos_unmaterialized + ON core_utxos(wallet_id, winner_mined_height) + WHERE height IS NULL;" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs b/packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs deleted file mode 100644 index 187b9bbdf1e..00000000000 --- a/packages/rs-platform-wallet-storage/migrations/V006__utxo_tombstone_stamp.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Stamp sweep tombstones and pin the chainlock finality boundary. -//! -//! `core_utxos.held_since_height` is the creation stamp of an -//! unmaterialised sweep tombstone — the placeholder row `apply_sweep` -//! writes for a held input whose funding output has never classified -//! (`height IS NULL AND spent = 1`; no other writer leaves `height` -//! NULL). The stamp is the wallet's best-known processed height at the -//! round that created (or re-pointed) the claim, and exists so the -//! collector in `core_state::apply` can bound the row's lifetime the -//! way key-wallet's `prune_finalized_observed_spends` bounds the -//! equivalent in-memory entries: evict once -//! `min(chainlock_height, synced_height)` passes the stamp by the -//! sweep margin. NULL means "stamped before this column existed" — -//! the collector back-fills it with the current height rather than -//! guessing, so legacy rows wait a full margin from first sight. -//! -//! `core_sync_state.chainlock_height` is the monotonic-max height of -//! the last applied chainlock, mirrored from -//! `CoreChangeSet::last_applied_chain_lock` (previously dropped by -//! this store). It is one half of the collector's finality boundary; -//! rows are never collected before a chainlock has been persisted, -//! matching upstream's "no-op until a chainlock has been applied". -//! -//! The partial index covers exactly the unmaterialised rows — the -//! collector's scan set — so the per-round sweep touches tombstones -//! only, not the wallet's full spent history. - -pub fn migration() -> String { - "ALTER TABLE core_utxos ADD COLUMN held_since_height INTEGER; - ALTER TABLE core_sync_state ADD COLUMN chainlock_height INTEGER; - CREATE INDEX idx_core_utxos_unmaterialized - ON core_utxos(wallet_id, held_since_height) - WHERE height IS NULL;" - .to_string() -} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index d0591b78f44..5cf081f7f1c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -184,15 +184,6 @@ pub fn apply( .flat_map(|record| record.transaction.input.iter()) .map(|input| input.previous_output) .collect(); - // Best-known processed height at this round, read AFTER the sync-state - // upsert above so a height carried by this same changeset is included. - // Stamped onto every tombstone this round creates or re-points; the - // collector compares it against the chainlock finality boundary. A - // sweeps-only round carries no heights of its own, which is exactly why - // this reads the stored watermark instead of `cs` directly. `None` on a - // wallet that has never recorded a height — the collector back-fills - // such stamps before it ever collects. - let stamp = tombstone_stamp(tx, wallet_id)?; for batch in &cs.sweeps { // Only this stays per batch: a release is true of the wallet its own // sweep saw, which is what lets a later batch correct an earlier one. @@ -210,7 +201,7 @@ pub fn apply( &batch.superseded_by, &released, &swept_txids, - stamp, + batch.winner_mined_height, )?; } // Releases are outpoint-keyed facts, so they are applied by outpoint @@ -298,27 +289,32 @@ pub fn apply( /// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the /// claim survives the funding upsert instead of being upserted away by it. /// -/// The placeholder is created UNCONDITIONALLY, ownership unproven, and its -/// LIFETIME is what is bounded — not its creation. A swept INCOMING payment -/// reaches this loop too, and every sender-owned input it named lands a -/// placeholder no funding upsert will ever overwrite and no release will -/// ever name — zero-value junk, one row per foreign input, growable by -/// anyone willing to double-spend payments at this wallet. It cannot be -/// gated at creation because nothing anywhere can prove an input foreign: -/// `input_details` and `direction` are computed from the wallet's UTXO -/// snapshot AT RECORD TIME, so the held-but-unfunded claim this placeholder -/// exists to preserve — our own coin, spent before its funding output was -/// classified — is byte-identical to an attacker's fan-in; and the sweep -/// site upstream cannot attest ownership either, because recording the -/// loser already removed its inputs from `utxos` and the spent marks carry -/// no ownership (dashpay/rust-dashcore#968 — the once-proposed "held -/// outpoints attested ours" set is empty by construction). Instead -/// `collect_finalized_tombstones` evicts never-materialised placeholders -/// once the chainlock finality boundary passes their creation stamp, -/// mirroring key-wallet's `prune_finalized_observed_spends` doctrine for -/// the same shape — so attacker junk lives for the finality window -/// (minutes), while a genuine claim materialises via the funding upsert -/// (gaining a real `height`) and permanently leaves the collectible set. +/// The placeholder is created only for a BLOCK-CONTEXT sweep +/// (`winner_mined_height` is `Some`), and it stores that height — the +/// winner's own, not any observation watermark. This is the projection of +/// key-wallet's `observed_spent_outpoints`, which maps each outpoint +/// observed spent in a block to the height of the block that spent it and +/// deliberately records nothing for a mempool/IS-lock spend ("an +/// unconfirmed spend must not invalidate a coin" — `wallet_checker.rs`). +/// An IS-locked winner therefore leaves no placeholder here either: the +/// wallet itself keeps no durable hold for an unconfirmed spend, so the +/// mirror holding one would out-claim the thing it mirrors — and when the +/// winner eventually mines, BIP158 delivery of its block (the funding +/// output's script matches the winner's prevout) re-marks the coin through +/// the ordinary channels. This is also what bounds the junk an attacker can +/// grow by double-spending incoming payments at this wallet: a swept +/// INCOMING payment reaches this loop too, and every sender-owned foreign +/// input it names would land a placeholder nothing ever overwrites. It +/// cannot be gated by ownership because nothing anywhere can prove an +/// input foreign (`input_details` and `direction` are computed from the +/// wallet's UTXO snapshot AT RECORD TIME; dashpay/rust-dashcore#968 — the +/// once-proposed "held outpoints attested ours" set is empty by +/// construction). Instead the mempool path creates none at all, and +/// `collect_finalized_tombstones` evicts block-context placeholders once +/// the chainlock finality boundary reaches their winner's height — +/// key-wallet's `prune_finalized_observed_spends` condition verbatim — +/// while a genuine claim materialises via the funding upsert (gaining a +/// real `height`) and permanently leaves the collectible set. /// /// Idempotent: a txid this store never recorded is a successful no-op, not an /// error. A sweep can legitimately name a transaction this wallet dropped, or @@ -333,7 +329,7 @@ fn apply_sweep( superseded_by: &dashcore::Txid, released: &HashSet, swept_txids: &HashSet, - stamp: Option, + winner_mined_height: Option, ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx .query_row( @@ -379,28 +375,38 @@ fn apply_sweep( // NULL (nobody's claim), a held one is attributed to `superseded_by` so // the claim outlives this row's own deletion below. // A held, never-materialised claim (`height IS NULL`) is re-stamped - // with this round's height: the claim now belongs to this sweep's - // winner, whose own confirmation is what the collector's margin is - // measured from. Materialised rows (`height` set) keep their NULL stamp - // — they are outside the collector's reach either way. + // with the NEW winner's mined height when this sweep has one — the + // claim now belongs to that winner, and its height is what the + // collector compares against the finality boundary. An IS-locked + // winner (`?5` NULL) re-points the claim but keeps the existing stamp: + // the earlier block-context observation stands, exactly as upstream's + // `observed_spent_outpoints` entry is never retracted by an + // unconfirmed conflict, and collection at the old height stays sound — + // the funding output of a spent outpoint is mined at or below the + // height of ANY block-context spender of it, so the boundary passing + // that height still proves the funding was delivered or never will be. + // Materialised rows (`height` set) keep their NULL stamp — they are + // outside the collector's reach either way. let mut spend_stmt = tx.prepare_cached( "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4, \ - held_since_height = CASE \ - WHEN ?3 AND height IS NULL THEN COALESCE(?5, held_since_height) \ - ELSE held_since_height END \ + winner_mined_height = CASE \ + WHEN ?3 AND height IS NULL THEN COALESCE(?5, winner_mined_height) \ + ELSE winner_mined_height END \ WHERE wallet_id = ?1 AND outpoint = ?2", )?; - // Only reached for a held input with no existing row — see the - // doc comment above. `value`/`script`/`height`/`account_index` are - // placeholders; the funding UTXO's own upsert overwrites them (and, - // thanks to the `spent_in_txid` guard in `execute_upsert_utxo`, does - // not clear `spent` while doing it). `held_since_height` is the - // creation stamp `collect_finalized_tombstones` measures the row's - // bounded lifetime from. + // Only reached for a held input with no existing row AND a + // block-context winner — see the doc comment above; an IS-locked + // winner creates no placeholder at all, mirroring upstream's refusal + // to record an unconfirmed spend. `value`/`script`/`height`/ + // `account_index` are placeholders; the funding UTXO's own upsert + // overwrites them (and, thanks to the `spent_in_txid` guard in + // `execute_upsert_utxo`, does not clear `spent` while doing it). + // `winner_mined_height` is the winner's own block height, the row's + // whole lifetime rule for `collect_finalized_tombstones`. let mut tombstone_stmt = tx.prepare_cached( "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid, \ - held_since_height) \ + winner_mined_height) \ VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3, ?4)", )?; for input in &loser.transaction.input { @@ -449,14 +455,23 @@ fn apply_sweep( &key[..], !freed, spent_in_txid, - stamp.map(i64::from) + winner_mined_height.map(i64::from) ])?; if affected == 0 && !freed { + // No row to hold and nothing durable to key a hold on: an + // IS-locked winner (`winner_mined_height` None) gets no + // placeholder — upstream records nothing for an unconfirmed + // spend, and this is where the foreign-input junk an attacker + // could grow by double-spending incoming payments is killed at + // the source rather than aged out. + let Some(height) = winner_mined_height else { + continue; + }; tombstone_stmt.execute(params![ wallet_id.as_slice(), &key[..], AsRef::<[u8]>::as_ref(superseded_by), - stamp.map(i64::from) + i64::from(height) ])?; } } @@ -479,10 +494,10 @@ const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = // exactly the arrival that tombstone exists to survive, so it must not // double as the thing that erases it. `spent_in_txid` itself is left out of // the SET list entirely — untouched, it carries the claim forward. -// `held_since_height` DOES clear: this statement always binds a real +// `winner_mined_height` DOES clear: this statement always binds a real // funding `height`, so the row it lands on is materialised from here on — // permanently outside `collect_finalized_tombstones`'s reach — and a stale -// creation stamp would only mislead. +// stamp would only mislead. const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -491,7 +506,7 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ script = excluded.script, \ height = excluded.height, \ account_index = excluded.account_index, \ - held_since_height = NULL, \ + winner_mined_height = NULL, \ spent = CASE WHEN core_utxos.spent_in_txid IS NOT NULL \ THEN core_utxos.spent ELSE excluded.spent END"; @@ -603,55 +618,23 @@ fn read_sync_heights( )) } -/// Blocks the finality boundary must clear past a tombstone's stamp -/// before the row is collectible. -/// -/// The stamp is taken when the sweep is OBSERVED, which on the -/// InstantSend path is before the winner mines — customarily in the very -/// next block, so the margin's first block covers it and the second is -/// slack. Requiring `boundary >= stamp + 2` therefore means the winner's -/// typical block is itself chain-locked and filter-scanned before the -/// hold is dropped, matching upstream's eviction precondition of -/// "spend height at or below the boundary" without knowing the winner's -/// height (the winner need not be wallet-relevant, so no row records -/// it). A winner that mines later than the margin allows is covered by -/// convergence, not the tombstone: BIP158 filters match input prevout -/// scripts, so any delivery path that ever classifies the funding output -/// also delivers the winner's spend and re-marks the coin — and upstream -/// itself retains nothing in memory for an unmined or unrecorded winner, -/// so the bounded row is never less protection than the wallet it -/// mirrors. -const TOMBSTONE_COLLECT_MARGIN: u32 = 2; - -/// Best-known processed height for stamping a tombstone created this -/// round: the max of the stored watermarks (the caller upserts incoming -/// heights first, so a height carried by the same round is included). -fn tombstone_stamp( - tx: &Transaction<'_>, - wallet_id: &WalletId, -) -> Result, WalletStorageError> { - let (lp, sy, _) = read_sync_heights(tx, wallet_id)?; - Ok(match (lp, sy) { - (Some(a), Some(b)) => Some(a.max(b)), - (a, b) => a.or(b), - }) -} - /// Evict never-materialised sweep tombstones once the chainlock finality -/// boundary passes their creation stamp — the storage-side mirror of -/// key-wallet's `prune_finalized_observed_spends`, which applies the same -/// doctrine to the same shape in memory: an entry at or below +/// boundary reaches their winner's mined height — the storage-side mirror +/// of key-wallet's `prune_finalized_observed_spends`, same condition +/// verbatim: an entry whose spend height is at or below /// `min(chainlock_height, synced_height)` is safe to forget, because the -/// spend it guards is chain-locked and every filter below the boundary -/// has been matched, so no delivery path can hand the coin back -/// unobserved. This is what bounds the junk an attacker can grow by -/// double-spending incoming payments (see `apply_sweep`): a foreign-input -/// placeholder lives for the finality window instead of forever, while a -/// genuine claim materialises through the funding upsert — gaining a real -/// `height` — and permanently leaves the collectible set, so nothing this -/// function deletes can ever be a coin the wallet still holds data for. +/// spend at that height is chain-locked and every BIP158 filter below the +/// boundary has been matched with no false negatives, so the funding +/// transaction of the outpoint it guards — necessarily mined at or below +/// the spend's own height — has either been delivered (materialising the +/// row) or provably never will be. No observation-age margin: the stamp IS +/// the winner's height, carried on the sweep event itself, so nothing here +/// guesses when the winner mined. Rows with no stamp are never collected — +/// under the current writers none can exist (an IS-locked winner creates +/// no placeholder and never clears an existing stamp), so an unstamped row +/// is foreign or legacy data, and holding it forever is the safe reading. /// -/// Three passes, all narrowed to `height IS NULL` (only the tombstone +/// Two passes, both narrowed to `height IS NULL` (only the tombstone /// insert leaves `height` NULL, so the set is exactly the /// never-materialised rows, served by the partial index): /// @@ -659,12 +642,8 @@ fn tombstone_stamp( /// never-materialised claim holds nothing and would read as a /// zero-value phantom coin. The release path now deletes these /// in-line; this pass self-heals rows written before it did. -/// 2. Held rows with no stamp (written before `held_since_height` -/// existed, or on a wallet with no recorded heights) are back-filled -/// with the current best-known height, so they wait a full margin from -/// first sight rather than being guessed collectible. -/// 3. Held rows whose stamp sits at least [`TOMBSTONE_COLLECT_MARGIN`] -/// below the boundary are collected. +/// 2. Held rows whose winner height is at or below the boundary are +/// collected. /// /// Like upstream, a no-op until a chainlock height has been persisted — /// without a finality boundary nothing can be proven final. @@ -677,26 +656,16 @@ fn collect_finalized_tombstones( WHERE wallet_id = ?1 AND height IS NULL AND spent = 0", params![wallet_id.as_slice()], )?; - let (lp, sy, cl) = read_sync_heights(tx, wallet_id)?; + let (_, sy, cl) = read_sync_heights(tx, wallet_id)?; let (Some(sy), Some(cl)) = (sy, cl) else { return Ok(()); }; - let best = lp.map_or(sy, |lp| lp.max(sy)); - tx.execute( - "UPDATE core_utxos SET held_since_height = ?2 \ - WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ - AND held_since_height IS NULL", - params![wallet_id.as_slice(), i64::from(best)], - )?; let boundary = cl.min(sy); - let Some(cut) = boundary.checked_sub(TOMBSTONE_COLLECT_MARGIN) else { - return Ok(()); - }; tx.execute( "DELETE FROM core_utxos \ WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ - AND held_since_height <= ?2", - params![wallet_id.as_slice(), i64::from(cut)], + AND winner_mined_height <= ?2", + params![wallet_id.as_slice(), i64::from(boundary)], )?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index e1b3f35062c..b33ee0df823 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -24,6 +24,11 @@ use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet_storage::sqlite::schema::{blob, core_state}; use rusqlite::params; +/// Mined height carried by every block-context sweep in these tests +/// unless a test pins its own. High enough that the pre-seeded funding +/// heights (10) and default watermarks sit below it. +const WINNER_HEIGHT: u32 = 400; + fn p2pkh(byte: u8) -> Address { use dashcore::address::Payload; use dashcore::hashes::Hash; @@ -162,6 +167,7 @@ fn sweep_only_changeset_deletes_loser_row_and_its_outputs() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: Txid::from_byte_array([0x11; 32]), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -201,6 +207,7 @@ fn sweeping_an_unknown_txid_is_a_no_op() { sweeps: vec![SweepBatch { txids: vec![Txid::from_byte_array([0x20; 32])], superseded_by: Txid::from_byte_array([0x21; 32]), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -296,6 +303,7 @@ fn the_released_set_frees_exactly_the_inputs_it_names() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![exclusive_input], }], ..Default::default() @@ -379,6 +387,7 @@ fn an_absent_winner_still_keeps_its_own_input_spent() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: unrecorded_winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![loser_exclusive], }], ..Default::default() @@ -476,6 +485,7 @@ fn a_released_coin_a_surviving_record_reclaims_stays_spent() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![freed_coin], }], ..Default::default() @@ -544,6 +554,7 @@ fn sweeping_a_transaction_deletes_its_instant_lock() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: Txid::from_byte_array([0x61; 32]), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -636,6 +647,7 @@ fn a_later_sweep_keeping_a_coin_spent_overrides_an_earlier_release() { SweepBatch { txids: vec![first_loser], superseded_by: Txid::from_byte_array([0x7a; 32]), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![contested], }, // The second winner consumed the coin, so this sweep frees @@ -643,6 +655,7 @@ fn a_later_sweep_keeping_a_coin_spent_overrides_an_earlier_release() { SweepBatch { txids: vec![second_loser], superseded_by: Txid::from_byte_array([0x7b; 32]), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }, ], @@ -717,6 +730,7 @@ fn a_held_input_with_no_utxo_row_survives_restart_and_stays_spent_when_funded() sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -826,6 +840,7 @@ fn a_chained_sweep_before_funding_still_frees_an_earlier_tombstone_on_release() sweeps: vec![SweepBatch { txids: vec![first_loser], superseded_by: second_loser, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -857,6 +872,7 @@ fn a_chained_sweep_before_funding_still_frees_an_earlier_tombstone_on_release() sweeps: vec![SweepBatch { txids: vec![second_loser], superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![unfunded_input], }], ..Default::default() @@ -949,6 +965,7 @@ fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winne sweeps: vec![SweepBatch { txids: vec![first_loser], superseded_by: second_loser, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -971,6 +988,7 @@ fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winne sweeps: vec![SweepBatch { txids: vec![second_loser], superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1081,6 +1099,7 @@ fn a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombs sweeps: vec![SweepBatch { txids: vec![shared_loser], superseded_by: shared_winner, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1111,6 +1130,7 @@ fn a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombs sweeps: vec![SweepBatch { txids: vec![shared_winner], superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![p1], }], ..Default::default() @@ -1154,6 +1174,7 @@ fn a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombs sweeps: vec![SweepBatch { txids: vec![shared_winner], superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1263,6 +1284,7 @@ fn sweep_of_a_shared_loser_txid_is_independent_per_wallet() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![coin], }], ..Default::default() @@ -1309,6 +1331,7 @@ fn sweep_of_a_shared_loser_txid_is_independent_per_wallet() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1410,6 +1433,7 @@ fn sweep_deletion_is_durable_even_when_the_other_wallets_callback_never_arrives( sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1525,6 +1549,7 @@ fn a_record_reinstating_a_swept_txid_in_a_later_round_is_accepted_and_durable() sweeps: vec![SweepBatch { txids: vec![txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1647,6 +1672,7 @@ fn a_release_applies_even_when_the_swept_txid_has_no_row() { sweeps: vec![SweepBatch { txids: vec![loser_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1672,6 +1698,7 @@ fn a_release_applies_even_when_the_swept_txid_has_no_row() { sweeps: vec![SweepBatch { txids: vec![winner_txid], superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![p], }], ..Default::default() @@ -1761,6 +1788,7 @@ fn a_batch_sweeping_parent_and_child_leaves_no_placeholder_for_the_parents_outpu sweeps: vec![SweepBatch { txids: vec![parent_txid, child_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1856,6 +1884,7 @@ fn a_co_swept_parent_with_no_row_still_has_its_output_removed() { sweeps: vec![SweepBatch { txids: vec![parent_txid, child_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -1958,6 +1987,7 @@ fn a_co_swept_parent_known_only_through_the_childs_spend_is_still_removed() { sweeps: vec![SweepBatch { txids: vec![parent_txid, child_txid], superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }], ..Default::default() @@ -2003,11 +2033,12 @@ fn a_co_swept_parent_known_only_through_the_childs_spend_is_still_removed() { // ───────────────────────── tombstone collection ───────────────────────── // -// `collect_finalized_tombstones` bounds the LIFETIME of the unconditional -// held-but-absent placeholder: attacker-created foreign-input rows live -// until the chainlock finality boundary passes their creation stamp by -// `TOMBSTONE_COLLECT_MARGIN`, instead of forever. These tests drive the -// collector through ordinary `core_state::apply` rounds. +// A held-but-absent placeholder exists only for a block-context sweep and +// stores the winner's own mined height; `collect_finalized_tombstones` +// deletes it exactly when `min(chainlock_height, synced_height)` reaches +// that height — upstream's `prune_finalized_observed_spends` condition +// verbatim, no observation-age margin. These tests drive both the creation +// gate and the collector through ordinary `core_state::apply` rounds. fn chain_lock_at(height: u32) -> dashcore::ephemerealdata::chain_lock::ChainLock { use dashcore::bls_sig_utils::BLSSignature; @@ -2033,7 +2064,7 @@ fn apply_heights(conn: &mut rusqlite::Connection, w: &WalletId, height: u32) { tx.commit().unwrap(); } -/// `(spent, height, held_since_height)` of a `core_utxos` row, or `None` +/// `(spent, height, winner_mined_height)` of a `core_utxos` row, or `None` /// when absent. fn utxo_row_state( conn: &rusqlite::Connection, @@ -2042,7 +2073,7 @@ fn utxo_row_state( ) -> Option<(bool, Option, Option)> { let bytes = blob::encode_outpoint(op).unwrap(); conn.query_row( - "SELECT spent, height, held_since_height FROM core_utxos \ + "SELECT spent, height, winner_mined_height FROM core_utxos \ WHERE wallet_id = ?1 AND outpoint = ?2", params![w.as_slice(), &bytes[..]], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), @@ -2051,14 +2082,17 @@ fn utxo_row_state( .unwrap() } -/// Record a loser spending `input` (no funding row exists), then sweep it — -/// leaving the held-but-absent placeholder the collection tests reason about. +/// Record a loser spending `input` (no funding row exists), then sweep it +/// in the given winner context — `Some(height)` leaves the held-but-absent +/// placeholder the collection tests reason about, `None` (an IS-locked, +/// unmined winner) must leave nothing. fn seed_tombstone( conn: &mut rusqlite::Connection, w: &WalletId, input: OutPoint, loser: Txid, winner: Txid, + winner_mined_height: Option, ) { { let tx = conn.transaction().unwrap(); @@ -2074,6 +2108,7 @@ fn seed_tombstone( sweeps: vec![SweepBatch { txids: vec![loser], superseded_by: winner, + winner_mined_height, released_outpoints: vec![], }], ..Default::default() @@ -2082,12 +2117,13 @@ fn seed_tombstone( tx.commit().unwrap(); } -/// The attacker-shaped row: a swept incoming payment's foreign input lands -/// a placeholder, and the collector deletes it once -/// `min(chainlock_height, synced_height)` clears its stamp by the margin — -/// and not one block sooner. Bounded lifetime is the whole fix: without the -/// collector this row was permanent, growable without limit by anyone -/// repeatedly double-spending payments at this wallet. +/// A block-context placeholder stores the WINNER'S mined height and is +/// collected exactly when `min(chainlock_height, synced_height)` reaches +/// it — upstream's `prune_finalized_observed_spends` condition verbatim, +/// no observation-age margin. At that boundary the funding transaction of +/// the outpoint (necessarily mined at or below the winner's height) has +/// been filter-scanned with no false negatives, so an unmaterialised row +/// is provably not the wallet's coin. #[test] fn a_never_materialised_tombstone_is_collected_at_finality_and_not_before() { let (persister, _tmp, _path) = fresh_persister(); @@ -2100,29 +2136,226 @@ fn a_never_materialised_tombstone_is_collected_at_finality_and_not_before() { let mut conn = persister.lock_conn_for_test(); apply_heights(&mut conn, &w, 100); - seed_tombstone(&mut conn, &w, p, loser, winner); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(100))), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), "sanity: the sweep left a held, never-materialised row stamped with \ - the round's best-known height" + the winner's own mined height — not any observation watermark" + ); + + // Boundary one below the winner's height: the winner's block is not + // yet inside the finality boundary, so the hold must survive. + apply_heights(&mut conn, &w, WINNER_HEIGHT - 1); + assert!( + row_exists(&conn, &w, &p), + "boundary {} has not reached the winner's height {} — the hold stays", + WINNER_HEIGHT - 1, + WINNER_HEIGHT ); - // Boundary one short of stamp + margin: the hold must survive — the - // winner customarily mines at stamp + 1, and the margin keeps the claim - // through that block's own finality. - apply_heights(&mut conn, &w, 101); + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "the boundary reaching the winner's height collects the row" + ); +} + +/// The reviewer's unrelated-advancement scenario, block-context half: the +/// chainlock can run arbitrarily far ahead, but while `synced_height` sits +/// below the winner's mined height the boundary has not reached the spend +/// and the hold must survive — the funding output could still be delivered +/// by the unscanned range. It collects the moment the synced height +/// catches up. +#[test] +fn a_block_context_tombstone_outlives_unrelated_advancement_below_its_winners_height() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x62; 32]), 0); + let loser = Txid::from_byte_array([0x63; 32]); + let winner = Txid::from_byte_array([0x64; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + + // Chainlocks race ahead by thousands of blocks; the filter scan has + // only reached one block short of the winner. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(WINNER_HEIGHT - 1), + last_applied_chain_lock: Some(chain_lock_at(WINNER_HEIGHT + 10_000)), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } assert!( row_exists(&conn, &w, &p), - "boundary 101 has not cleared stamp 100 by the margin — the hold stays" + "min(chainlock, synced) = {} is below the winner's height {} — any \ + amount of unrelated chainlock progress must not collect the hold", + WINNER_HEIGHT - 1, + WINNER_HEIGHT ); - apply_heights(&mut conn, &w, 102); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(WINNER_HEIGHT), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } assert!( !row_exists(&conn, &w, &p), - "boundary 102 cleared stamp 100 by the margin — the junk row is gone" + "the scan reaching the winner's height completes the boundary and collects" + ); +} + +/// A mempool-context sweep — an InstantSend-locked winner that has not +/// mined — creates NO placeholder for a held-but-unfunded input, however +/// often it happens. This is upstream's own doctrine projected ("an +/// unconfirmed spend must not invalidate a coin": `record_observed_spends` +/// refuses mempool/IS contexts), and it kills the attacker-growable +/// population at the source — a swept incoming payment's foreign inputs +/// land nothing, so repeated double-spends at this wallet grow nothing. +#[test] +fn a_mempool_context_sweep_creates_no_placeholder_rows() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF8); + ensure_wallet_meta(&persister, &w); + + let mut conn = persister.lock_conn_for_test(); + // Repeated double-spent incoming payments: distinct losers, each + // claiming distinct foreign inputs, each swept by an IS-locked winner. + for i in 0u8..3 { + let p = OutPoint::new(Txid::from_byte_array([0x70 + i; 32]), 0); + let loser = Txid::from_byte_array([0x80 + i; 32]); + let winner = Txid::from_byte_array([0x90 + i; 32]); + seed_tombstone(&mut conn, &w, p, loser, winner, None); + assert!( + !row_exists(&conn, &w, &p), + "an unmined IS-locked winner must leave no placeholder for input #{i}" + ); + } + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + rows, 0, + "repeated mempool-path double-spends must leave zero placeholder rows" + ); +} + +/// The mempool-context sweep still spend-marks a coin that HAS +/// materialised: the row carries real funding data, so holding it costs +/// nothing an attacker controls, and the winner's own record (or its +/// eventual block delivery) is the durable evidence. Its stamp stays NULL +/// — a materialised row is outside the collector's reach anyway. +#[test] +fn a_mempool_context_sweep_still_spend_marks_a_materialised_coin() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x65); + let funding_txid = Txid::from_byte_array([0x66; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x67; 32]); + let winner = Txid::from_byte_array([0x68; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + seed_tombstone(&mut conn, &w, p, loser, winner, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "a materialised coin is spend-marked by the IS-locked winner, with \ + no stamp — its funding data is real and the collector never sees it" + ); +} + +/// The reviewer's named regression, resolved by engine parity: 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. The wallet engine itself keeps no +/// durable hold for an unconfirmed spend and would credit the coin — so +/// the store must land it unspent too, with no stale placeholder in the +/// way and no placeholder wrongly collected beforehand (none ever +/// existed). Convergence is the winner's job: when it mines, BIP158 +/// delivery of its block re-marks the coin through the ordinary channels. +#[test] +fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_unspent() { + let (persister, tmp, path) = fresh_persister(); + let w: WalletId = wid(0xFA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x69); + let funding_txid = Txid::from_byte_array([0x6A; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x6B; 32]); + let winner = Txid::from_byte_array([0x6C; 32]); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + seed_tombstone(&mut conn, &w, p, loser, winner, None); + } + // Restart. + drop(persister); + let cfg = SqlitePersisterConfig::new(&path); + let persister = SqlitePersister::open(cfg).expect("reopen"); + + let mut conn = persister.lock_conn_for_test(); + // Arbitrary chainlock/height advancement while the winner stays unmined. + apply_heights(&mut conn, &w, 25_000); + + // The funding output is finally delivered and classified. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + unspent(&conn, &w).contains(&p), + "the engine credits a coin whose only spender is unconfirmed — the \ + mirror must agree after a restart, not hold a claim the wallet \ + itself no longer remembers" + ); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + rows, 1, + "exactly the funding row itself — no leftover placeholder" ); + drop(conn); + drop(tmp); } /// Synced height alone is not finality: with no chainlock ever persisted @@ -2149,7 +2382,7 @@ fn a_tombstone_is_never_collected_without_a_persisted_chainlock() { core_state::apply(&tx, &w, &cs).unwrap(); tx.commit().unwrap(); } - seed_tombstone(&mut conn, &w, p, loser, winner); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); { let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { @@ -2166,13 +2399,13 @@ fn a_tombstone_is_never_collected_without_a_persisted_chainlock() { outlast any amount of synced-height progress" ); - // The moment a chainlock does land, the boundary exists and the aged - // stamp collects immediately. + // The moment a chainlock does land, the boundary exists and the + // winner's height sits inside it — the row collects immediately. apply_heights(&mut conn, &w, 500); assert!( !row_exists(&conn, &w, &p), "the first persisted chainlock supplies the boundary and the \ - long-aged stamp collects" + winner-height stamp collects" ); } @@ -2194,11 +2427,11 @@ fn a_materialised_claim_is_never_collected() { let mut conn = persister.lock_conn_for_test(); derive_address(&conn, &w, 0, &addr); apply_heights(&mut conn, &w, 100); - seed_tombstone(&mut conn, &w, p, loser, winner); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(100))), - "sanity: held, unmaterialised, stamped" + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "sanity: held, unmaterialised, stamped with the winner's height" ); // The funding output classifies: the valve keeps the coin spent, the @@ -2227,49 +2460,50 @@ fn a_materialised_claim_is_never_collected() { ); } -/// A tombstone written before `held_since_height` existed (or on a wallet -/// with no recorded heights) has a NULL stamp. The collector back-fills it -/// with the current best-known height on first sight rather than guessing, -/// so it waits a full margin from then — never collected in the same round -/// that first saw it. +/// A held, unmaterialised row with a NULL winner height is never +/// collected. No current writer produces one — the tombstone insert +/// requires a block context and an IS-locked re-point keeps the existing +/// stamp — so an unstamped row is foreign or legacy data, and the safe +/// reading is to hold it forever rather than guess it collectible. #[test] -fn an_unstamped_tombstone_is_backfilled_before_it_can_be_collected() { +fn a_tombstone_without_a_winner_height_is_never_collected() { let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xF4); ensure_wallet_meta(&persister, &w); let p = OutPoint::new(Txid::from_byte_array([0x59; 32]), 0); - let loser = Txid::from_byte_array([0x5A; 32]); let winner = Txid::from_byte_array([0x5B; 32]); let mut conn = persister.lock_conn_for_test(); - // No heights have ever been recorded: the sweep stamps NULL. - seed_tombstone(&mut conn, &w, p, loser, winner); - assert_eq!( - utxo_row_state(&conn, &w, &p), - Some((true, None, None)), - "sanity: no watermark existed, so the stamp is NULL" - ); + // Plant the shape directly — the current writers cannot produce it. + { + let bytes = blob::encode_outpoint(&p).unwrap(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3)", + params![w.as_slice(), &bytes[..], AsRef::<[u8]>::as_ref(&winner)], + ) + .unwrap(); + } - apply_heights(&mut conn, &w, 1_000); + // Two rounds, not one: a back-filling collector (the rejected design) + // would stamp the row on the first round and collect it on the second. + apply_heights(&mut conn, &w, 1_000_000); + apply_heights(&mut conn, &w, 1_000_010); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(1_000))), - "first collection pass back-fills the stamp instead of collecting" - ); - - apply_heights(&mut conn, &w, 1_002); - assert!( - !row_exists(&conn, &w, &p), - "the back-filled stamp ages out like any other" + Some((true, None, None)), + "no winner height, no proof of finality — the hold outlasts any boundary" ); } -/// A chained sweep that re-points a still-unfunded claim to a new winner -/// also re-stamps it: the claim now belongs to a winner whose confirmation -/// is measured from this round, not the original sweep's. +/// A chained sweep that re-points a still-unfunded claim to a new +/// block-context winner also re-stamps it with THAT winner's mined +/// height: the claim now belongs to a spend anchored at a later block, +/// and its collection horizon moves with it. #[test] -fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { +fn a_repointed_tombstone_is_restamped_to_the_later_winners_height() { let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xF5); ensure_wallet_meta(&persister, &w); @@ -2280,19 +2514,81 @@ fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { let final_winner = Txid::from_byte_array([0x5F; 32]); let mut conn = persister.lock_conn_for_test(); - apply_heights(&mut conn, &w, 100); - seed_tombstone(&mut conn, &w, p, first_loser, second_loser); + seed_tombstone( + &mut conn, + &w, + p, + first_loser, + second_loser, + Some(WINNER_HEIGHT), + ); assert_eq!( utxo_row_state(&conn, &w, &p).and_then(|(_, _, s)| s), - Some(100), - "sanity: stamped at the first sweep's height" + Some(i64::from(WINNER_HEIGHT)), + "sanity: stamped with the first winner's mined height" + ); + + // The first winner is itself swept — by a winner mined 50 blocks + // later — still holding the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT + 50), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT + 50)))), + "the re-pointed claim is re-stamped to the later winner's mined height" ); +} + +/// The IS-locked half of the chained case: an unmined winner re-points +/// the claim but must NOT disturb the earlier block-context stamp — +/// upstream's observed-spend entry is never retracted by an unconfirmed +/// conflict. Collection at the retained height stays sound (the funding +/// output is mined at or below the FIRST spender's height regardless of +/// who claims the coin now), so the row still collects at that boundary. +#[test] +fn a_mempool_repointed_tombstone_keeps_its_block_context_stamp() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFB); + ensure_wallet_meta(&persister, &w); - // One block of progress — within the collection margin, so the - // tombstone survives to be re-pointed (through the UPDATE's re-stamp - // CASE) rather than collected and re-created by the insert path. - apply_heights(&mut conn, &w, 101); - // The first winner is itself swept, still holding the unfunded input. + let p = OutPoint::new(Txid::from_byte_array([0x6D; 32]), 0); + let first_loser = Txid::from_byte_array([0x6E; 32]); + let second_loser = Txid::from_byte_array([0x6F; 32]); + let final_winner = Txid::from_byte_array([0x71; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone( + &mut conn, + &w, + p, + first_loser, + second_loser, + Some(WINNER_HEIGHT), + ); + + // The first winner is evicted by an IS-locked, unmined conflict that + // also claims the unfunded input. { let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { @@ -2308,6 +2604,7 @@ fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { sweeps: vec![SweepBatch { txids: vec![second_loser], superseded_by: final_winner, + winner_mined_height: None, released_outpoints: vec![], }], ..Default::default() @@ -2317,8 +2614,17 @@ fn a_repointed_tombstone_is_restamped_to_the_later_sweep() { } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(101))), - "the re-pointed claim is re-stamped to the later sweep's height" + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "an unmined winner re-points the claim without touching the earlier \ + block-context stamp" + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "the retained stamp still bounds the row: the funding output sits at \ + or below the first spender's height, so the boundary reaching it \ + proves delivery-or-never" ); } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 897decf0fb4..f886e5c51a4 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -239,6 +239,28 @@ pub struct SweepBatch { /// and still sweep, which is why it cannot be looked up to work out what /// it took. pub superseded_by: Txid, + /// Mined height of `superseded_by` when the sweep was triggered by its + /// arrival in a block; `None` when it was triggered by an + /// InstantSend-locked winner still waiting to be mined (upstream's only + /// two triggers — an unlocked mempool arrival never sweeps). + /// + /// This is the winner's finality context, straight from the event: the + /// winner need not be wallet-relevant, so no persister can look its + /// height up in its own records. A held-but-unfunded input is only + /// mirrored as a durable placeholder when this is `Some` — the exact + /// projection of upstream's `observed_spent_outpoints`, which records a + /// spend's own block height and deliberately records nothing for a + /// mempool/IS-lock spend ("an unconfirmed spend must not invalidate a + /// coin"). The stored height is then the placeholder's whole lifetime + /// rule: collectible once `min(chainlock_height, synced_height)` + /// reaches it, exactly upstream's `prune_finalized_observed_spends` + /// boundary. + /// + /// `serde(default)`: a journaled payload written before this field + /// existed reads back as `None` — the conservative reading (no new + /// placeholder, existing stamps kept). + #[cfg_attr(feature = "serde", serde(default))] + pub winner_mined_height: Option, /// Of the inputs those removed transactions claimed, the ones that came /// free — no surviving transaction spends them too. Everything else they /// claimed was taken by `superseded_by` and stays spent. diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 72e7b62299b..bf5fc7cf96b 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -842,6 +842,7 @@ async fn build_core_changeset( WalletEvent::TransactionsSwept { txids, superseded_by, + winner_mined_height, released_outpoints, .. } => { @@ -864,12 +865,21 @@ async fn build_core_changeset( 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) creates no placeholder at + // all, mirroring upstream's observed-spends doctrine. + winner_mined_height: *winner_mined_height, released_outpoints: released_outpoints.clone(), }], ..CoreChangeSet::default() @@ -1353,6 +1363,9 @@ mod swept_transaction_projection_tests { 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![]) } @@ -1362,6 +1375,7 @@ mod swept_transaction_projection_tests { wallet_id: WALLET_ID, txids, superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints, balance: WalletCoreBalance::default(), account_balances: BTreeMap::new(), @@ -1377,6 +1391,7 @@ mod swept_transaction_projection_tests { vec![SweepBatch { txids: vec![txid(1), txid(2)], superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), released_outpoints: vec![], }] ); @@ -1387,6 +1402,30 @@ mod swept_transaction_projection_tests { 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; @@ -2770,6 +2809,9 @@ 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 _; @@ -2777,6 +2819,7 @@ mod tests { 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(), @@ -3217,6 +3260,7 @@ mod tests { 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(), diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 5b1fc54c8ed..2f6a8a42677 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1286,6 +1286,7 @@ mod tests { wallet_id, txids: vec![dashcore::Txid::from_byte_array([0xAA; 32])], superseded_by: dashcore::Txid::from_byte_array([0xBB; 32]), + winner_mined_height: None, released_outpoints: vec![], balance: corrected, account_balances: Default::default(), 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 e5ec9441040..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 @@ -489,6 +489,7 @@ mod tests { 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(), diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 8b7fceaf168..e5977af4f49 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -199,6 +199,9 @@ pub(crate) fn build_extension() -> PersistenceCallbacksExtension { PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), on_persist_wallet_changeset_sweeps_fn: Some(tramp_persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + tramp_persist_wallet_changeset_chain_lock_height, + ), ..Default::default() } } @@ -722,20 +725,55 @@ unsafe fn persist_changeset_sweep_batch( })?; } + // The winner's finality context: its mined height for a block-context + // sweep, -1 for an InstantSend-locked winner still waiting to be mined. + // The sentinel is unambiguous — block heights are non-negative — and + // the Kotlin bridge maps it back to null. The handler keys the whole + // lifetime rule of a pending-input tombstone on it: no height, no + // tombstone. + let winner_mined_height: i32 = if batch.has_winner_mined_height { + batch.winner_mined_height as i32 + } else { + -1 + }; + env.call_method( bridge, "onWalletChangesetTransactionsSwept", - "([B[[B[[B[[B)I", + "([B[[B[[B[[BI)I", &[ wid.into(), (&txids_arr).into(), (&winners).into(), (&released_arr).into(), + JValue::Int(winner_mined_height), ], )? .i() } +/// Deliver the round's numeric chainlock height (see +/// `PersistWalletChangesetChainLockHeightFn`). 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(chain_lock_height as i32)], + )? + .i() + }) +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -4393,7 +4431,13 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ // right where a failed round freezes the wallet's watermark. Descriptor // must track the literal at the `call_method` site in // `persist_changeset_sweep_batch` above. - ("onWalletChangesetTransactionsSwept", "([B[[B[[B[[B)I"), + ("onWalletChangesetTransactionsSwept", "([B[[B[[B[[BI)I"), + // 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;\ diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 1c347fa0195..3b3f712fa6f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -227,6 +227,16 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// `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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index eb66cf3dabc..da3ee96f133 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -93,23 +93,30 @@ public final class PersistentPendingInput { /// existing rows migrate as ordinary pending entries. public var isSweptTombstone: Bool = false - /// Creation stamp of a swept tombstone: the wallet's `syncedHeight` at - /// the round that flagged (or re-pointed) this row. A tombstone whose - /// outpoint is a foreign input of a swept incoming payment never - /// drains — no funding TXO ever arrives — so without a bound it is - /// permanent junk an attacker grows one row per input by repeatedly - /// double-spending payments at this wallet. The changeset-header - /// collector (`collectFinalizedSweptTombstones`) deletes tombstones - /// once `syncedHeight` clears this stamp by the sweep margin — the - /// storage mirror of key-wallet's `prune_finalized_observed_spends` - /// doctrine; a genuine claim drains into its TXO on funding arrival - /// and leaves the collectible set with the row. `nil` means "flagged - /// before this property existed, or with no synced height on record" - /// — the collector back-fills it with the current height rather than - /// guessing, so such rows wait a full margin from first sight. - /// Optional, so existing stores lightweight-migrate with the rows - /// reading unstamped. - public var heldSinceHeight: UInt32? + /// 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 never stamps this: + /// it creates no tombstone at all, and re-pointing an existing + /// tombstone keeps the earlier block-context stamp untouched (upstream + /// never retracts an observed-spend entry for an unconfirmed conflict). + /// `nil` on a tombstone therefore means foreign or legacy data — the + /// collector holds it forever rather than guess it collectible. + /// Optional, so existing stores lightweight-migrate. + public var winnerMinedHeight: UInt32? public init( outpoint: Data, 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/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 875741fb95a..1b1980ab37f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -925,25 +925,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } // Bounded tombstone lifetime (the SwiftData mirror of the SQLite - // store's `collect_finalized_tombstones`): once the synced - // height clears a swept tombstone's stamp by the margin, the + // 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. - // Gated on a chainlock having been applied at some point, - // mirroring upstream's "no-op until a chainlock has been - // applied"; the chainlock's own height is bincode-opaque on - // this side of the FFI, so the boundary is the synced height - // alone — the winner's finality never depended on it (a sweep - // only fires for a chainlocked or InstantSend-locked winner), - // and synced height is the half of the upstream boundary that - // certifies filter coverage. + // 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, - wallet.lastAppliedChainLockBytes?.isEmpty == false { + let clHeight = wallet.lastAppliedChainLockHeight { collectFinalizedSweptTombstones( walletId: walletId, - syncedHeight: cs.chain.synced_height + boundary: min(clHeight, cs.chain.synced_height) ) } @@ -978,29 +980,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// Blocks the synced height must clear past a swept tombstone's - /// `heldSinceHeight` stamp before `collectFinalizedSweptTombstones` - /// deletes it. Mirrors the SQLite store's `TOMBSTONE_COLLECT_MARGIN`: - /// the stamp is taken when the sweep is observed — on the InstantSend - /// path before the winner mines, customarily in the very next block — - /// so the margin's first block covers the winner's own confirmation - /// and the second is slack. Past the margin, convergence carries the - /// claim instead: BIP158 filters match input prevout scripts, so any - /// delivery path that ever classifies the funding output also delivers - /// the winner's spend. - private static let sweptTombstoneCollectMargin: UInt32 = 2 - - /// Delete this wallet's swept tombstones whose stamp `syncedHeight` - /// has cleared by [`sweptTombstoneCollectMargin`], back-filling - /// unstamped rows (written before `heldSinceHeight` existed, or while - /// no height was on record) with the current height so they wait a - /// full margin from first sight. See the property doc on - /// `PersistentPendingInput.heldSinceHeight` for why the bound exists. + /// 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 — under the current writers none + /// can exist (a mempool-context sweep creates no tombstone and never + /// clears an existing stamp), so an unstamped row is foreign or legacy + /// data, and holding it forever is the safe reading. See the property + /// doc on `PersistentPendingInput.winnerMinedHeight`. /// /// Housekeeping, not correctness: a pass that cannot run self-heals on - /// the next height-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, syncedHeight: UInt32) { + /// 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 } ) @@ -1019,20 +1016,74 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return } - let cut = syncedHeight > Self.sweptTombstoneCollectMargin - ? syncedHeight - Self.sweptTombstoneCollectMargin - : nil for pending in rows where pending.isSweptTombstone && !pending.isDeleted { - guard let stamp = pending.heldSinceHeight else { - pending.heldSinceHeight = syncedHeight - continue - } - if let cut, stamp <= cut { + // A nil stamp is deliberately NOT back-filled: no current + // writer produces one, and stamping it 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 @@ -1067,16 +1118,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return false } - guard let wallet else { return true } + guard wallet != nil else { return true } guard count > 0, let sweepsPtr = sweeps else { return true } - // Creation stamp for any tombstone this round flags or - // re-points: the wallet's best-known synced height, `nil` - // while none has been recorded (the collector back-fills - // rather than guesses). `collectFinalizedSweptTombstones` - // measures the tombstone's bounded lifetime from this. - let tombstoneStamp: UInt32? = wallet.syncedHeight > 0 ? wallet.syncedHeight : nil - // 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 @@ -1121,6 +1165,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let supersededBy = Swift.withUnsafeBytes(of: batch.superseded_by) { Data($0) } + // The winner's finality context, carried on the batch + // itself: `nil` means the winner is InstantSend-locked and + // NOT yet mined (upstream's only other sweep trigger), and + // such a sweep must leave no durable placeholder — this is + // the projection of key-wallet's `observed_spent_outpoints`, + // which records nothing for an unconfirmed spend. A present + // height is the winner's OWN mined block, the stamp every + // tombstone this batch writes (or re-points) carries and + // the collector's whole lifetime rule. + let winnerMinedHeight: UInt32? = + batch.has_winner_mined_height ? batch.winner_mined_height : nil + if batch.txids_count > 0, let txidsPtr = batch.txids { // This wallet's detached tombstones, fetched ONCE per // batch and grouped by the live `spendingTxid` each @@ -1185,7 +1241,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { coSwept: coSwept, row: row, priorTombstones: tombstonesBySpender[txid] ?? [], - stamp: tombstoneStamp + winnerMinedHeight: winnerMinedHeight ) } } @@ -1326,6 +1382,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// left for the cascade, the same as a released materialized input needs /// no special handling beyond the loop above. /// + /// The tombstone is written only for a BLOCK-CONTEXT sweep + /// (`winnerMinedHeight` non-nil), stamped with that height — the + /// winner's own, not any observation watermark. This is the projection + /// of key-wallet's `observed_spent_outpoints`, which deliberately + /// records nothing for a mempool/IS-lock spend ("an unconfirmed spend + /// must not invalidate a coin"). A mempool-context sweep + /// (`winnerMinedHeight` nil — the winner is IS-locked and not yet + /// mined) DELETES the held pending row instead: the wallet itself + /// keeps no durable hold for an unconfirmed spend, so the mirror + /// holding one would out-claim the thing it mirrors — and when the + /// winner eventually mines, BIP158 delivery of its block (the funding + /// output's script matches the winner's prevout) re-marks the coin + /// through the ordinary channels. This is also what bounds the junk an + /// attacker can grow by double-spending incoming payments at this + /// wallet: a swept INCOMING payment reaches this loop too, and every + /// sender-owned foreign input it names would land a placeholder + /// nothing ever overwrites. It cannot be gated by ownership — nothing + /// anywhere can prove an input foreign (dashpay/rust-dashcore#968) — + /// so the mempool path creates none at all, and + /// `collectFinalizedSweptTombstones` evicts block-context tombstones + /// once the finality boundary reaches their winner's height, while a + /// genuine claim drains into its TXO on funding arrival and leaves the + /// collectible set with the rows. + /// /// 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 @@ -1370,7 +1450,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { coSwept: Set, row: PersistentTransaction?, priorTombstones: [PersistentPendingInput], - stamp: UInt32? + winnerMinedHeight: UInt32? ) { if let row { // The global half, done every time this function runs regardless @@ -1437,10 +1517,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(pending) continue } + guard let winnerMinedHeight else { + // Mempool context: the winner is IS-locked and not yet + // mined, and upstream records nothing durable for an + // unconfirmed spend — so the loser's claim dies with + // the loser instead of being repurposed into a + // tombstone. Deleted explicitly rather than left for + // the row's cascade for the same reason as the + // released branch above: still attached, it would read + // as this wallet's live claim in the ownership check + // below and stalemate another wallet's callback. No + // row is the correct end state — if the funding output + // ever classifies, its ordinary upsert lands it + // freshly unspent, exactly as the wallet engine itself + // would credit it. + backgroundContext.delete(pending) + continue + } pending.spendingTransaction = nil pending.spendingTxid = supersededBy pending.isSweptTombstone = true - pending.heldSinceHeight = stamp + pending.winnerMinedHeight = winnerMinedHeight } // Whatever is still attached to `row` after the scoping above @@ -1499,11 +1596,23 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { { backgroundContext.delete(pending) } else { - // Re-pointed to a new winner ⇒ re-stamped: the claim now - // belongs to a winner whose confirmation is measured from - // this round, not the original sweep's. + // 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 - pending.heldSinceHeight = stamp ?? pending.heldSinceHeight + if let winnerMinedHeight { + pending.winnerMinedHeight = winnerMinedHeight + } } } } @@ -2436,6 +2545,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 } @@ -7706,6 +7821,32 @@ private func persistWalletChangesetSweepsCallback( ) ? 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 /// `PlatformWalletPersistenceHandler.beginChangeset` so the handler /// can prep any wallet-scope batching it needs for the round. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index c8f9d6085ba..abbf7ed7cef 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -138,10 +138,19 @@ final class SweptTransactionPersistTests: XCTestCase { /// 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, and the coins it freed. + /// 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)] = [] } @@ -226,6 +235,12 @@ final class SweptTransactionPersistTests: XCTestCase { 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) } @@ -277,7 +292,7 @@ final class SweptTransactionPersistTests: XCTestCase { try seedSpend(in: container, winnerTakesA: true) sweep(handler, [ - Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 1)]) + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) ]) XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row is gone") @@ -292,7 +307,7 @@ final class SweptTransactionPersistTests: XCTestCase { try seedSpend(in: container, winnerTakesA: true) sweep(handler, [ - Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 1)]) + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) ]) let takenByWinner = txo(container, txid: fundingTxid, vout: 0) @@ -316,7 +331,7 @@ final class SweptTransactionPersistTests: XCTestCase { try seedSpend(in: container, winnerTakesA: false) sweep(handler, [ - Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 1)]) + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) ]) XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") @@ -352,7 +367,7 @@ final class SweptTransactionPersistTests: XCTestCase { func testWalletReDeliveringAStampedHeldCoinKeepsItSpent() throws { let (handler, container) = try makeHandler() try seedSpend(in: container, winnerTakesA: false) - sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) redeliverCoinB(handler) @@ -396,7 +411,7 @@ final class SweptTransactionPersistTests: XCTestCase { // The sweep holds the claim; the funding TXO then materializes it // as a stamped hold. - sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + 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) @@ -452,7 +467,7 @@ final class SweptTransactionPersistTests: XCTestCase { )) try context.save() - sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + 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) @@ -640,9 +655,9 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() sweep(handler, [ - Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 1)]), + 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)), + Batch(losers: [secondLoser], winner: Data(repeating: 0x56, count: 32), winnerMinedHeight: 400), ]) let contested = txo(container, txid: fundingTxid, vout: 1) @@ -769,7 +784,7 @@ final class SweptTransactionPersistTests: XCTestCase { // 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)], walletId: walletB) + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) XCTAssertNotNil( transaction(container, txid: loserTxid), @@ -781,7 +796,7 @@ final class SweptTransactionPersistTests: XCTestCase { // Wallet A second: its own released set names P. sweep(handler, [ - Batch(losers: [loserTxid], winner: winner, released: [(txid: fundingTxid, vout: 0)]) + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ], walletId: walletId) XCTAssertNil( @@ -812,7 +827,7 @@ final class SweptTransactionPersistTests: XCTestCase { // Wallet A first: releases P. sweep(handler, [ - Batch(losers: [loserTxid], winner: winner, released: [(txid: fundingTxid, vout: 0)]) + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ], walletId: walletId) XCTAssertNotNil( @@ -824,7 +839,7 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertNotNil(untouchedQ.spendingTransaction, "Q is still linked to the loser, untouched") // Wallet B second: releases nothing. - sweep(handler, [Batch(losers: [loserTxid], winner: winner)], walletId: walletB) + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) XCTAssertNil( transaction(container, txid: loserTxid), @@ -876,7 +891,7 @@ final class SweptTransactionPersistTests: XCTestCase { // 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)], walletId: walletB) + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) XCTAssertNotNil( transaction(container, txid: loserTxid), @@ -947,7 +962,7 @@ final class SweptTransactionPersistTests: XCTestCase { // 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)], walletId: walletB) + 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") @@ -1052,7 +1067,7 @@ final class SweptTransactionPersistTests: XCTestCase { } try context.save() - let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) XCTAssertTrue(applied, "a stale post-deletion callback is not a failure") XCTAssertNotNil( @@ -1092,7 +1107,7 @@ final class SweptTransactionPersistTests: XCTestCase { handle.truncateFile(atOffset: 16) try handle.close() - let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") } @@ -1142,11 +1157,11 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() sweep(handler, [ - Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 8)]) + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 8)]) ]) sweep( handler, - [Batch(losers: [sweptTxid], winner: winnerTxid, released: [(txid: fundingTxid, vout: 9)])], + [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 9)])], walletId: walletB ) @@ -1164,7 +1179,7 @@ final class SweptTransactionPersistTests: XCTestCase { try seedSpend(in: container, winnerTakesA: true) let applied = sweep(handler, [ - Batch(losers: [Data(repeating: 0x99, count: 32)], winner: winnerTxid) + 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") @@ -1214,7 +1229,7 @@ final class SweptTransactionPersistTests: XCTestCase { "sanity: the funding TXO has not arrived yet" ) - sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") } @@ -1294,7 +1309,7 @@ final class SweptTransactionPersistTests: XCTestCase { )) try context.save() - sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + 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. @@ -1355,7 +1370,7 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() // First sweep: W beats L, holding P (still unfunded). - sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser)]) + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) let tombstoneDescriptor = FetchDescriptor( @@ -1399,7 +1414,7 @@ final class SweptTransactionPersistTests: XCTestCase { // Second sweep: X beats W, this time releasing P. sweep(handler, [ - Batch(losers: [secondLoser], winner: finalWinner, released: [(txid: fundingTxid, vout: 0)]) + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ]) let survivingTombstones = try context.fetch(tombstoneDescriptor) @@ -1453,7 +1468,7 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() // First sweep: W beats L, holding P. - sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser)]) + 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. @@ -1468,7 +1483,7 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() // Second sweep: X beats W, still holding the same input. - sweep(handler, [Batch(losers: [secondLoser], winner: finalWinner)]) + sweep(handler, [Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400)]) let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) let tombstoneDescriptor = FetchDescriptor( @@ -1535,7 +1550,7 @@ final class SweptTransactionPersistTests: XCTestCase { // 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)]) + sweep(handler, [Batch(losers: [fundingTxid, childTxid], winner: winner, winnerMinedHeight: 400)]) let pendingDescriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == pOutpoint } @@ -1593,10 +1608,11 @@ final class SweptTransactionPersistTests: XCTestCase { // 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), + Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400), Batch( losers: [secondLoser], winner: finalWinner, + winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)] ), ]) @@ -1653,7 +1669,7 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() // First sweep: W beats L, holding the still-unfunded coin. - sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser)]) + 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. @@ -1675,7 +1691,7 @@ final class SweptTransactionPersistTests: XCTestCase { // Second sweep: X beats W, and this time upstream frees the coin. sweep(handler, [ - Batch(losers: [secondLoser], winner: finalWinner, released: [(txid: fundingTxid, vout: 0)]) + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ]) let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) @@ -1723,7 +1739,7 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() // First sweep: W beats L, holding the still-unfunded coin. - sweep(handler, [Batch(losers: [firstLoser], winner: unrecordedWinner)]) + 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. @@ -1739,6 +1755,7 @@ final class SweptTransactionPersistTests: XCTestCase { Batch( losers: [unrecordedWinner], winner: finalWinner, + winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)] ) ]) @@ -1799,8 +1816,8 @@ final class SweptTransactionPersistTests: XCTestCase { // First sweep, one independently committed callback per wallet: W // beats L, holding everything (nothing funded, nothing released). - sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner)], walletId: walletId) - sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner)], walletId: walletB) + 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 @@ -1827,7 +1844,7 @@ final class SweptTransactionPersistTests: XCTestCase { // its own coin, and — finding no attached claim of any other // wallet's — deletes the shared row. sweep(handler, [ - Batch(losers: [sharedWinner], winner: finalWinner, released: [(txid: fundingTxid, vout: 0)]) + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) ], walletId: walletId) XCTAssertNil( transaction(container, txid: sharedWinner), @@ -1838,7 +1855,7 @@ 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, released: [(txid: fundingTxid, vout: 2)]) + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 2)]) ], walletId: walletB) let heldOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) @@ -2029,13 +2046,24 @@ final class SweptTransactionPersistTests: XCTestCase { // MARK: - Bounded tombstone lifetime - /// One committed round carrying chain progress: the synced height and - /// (unless the caller opts out) chainlock bytes, driving the - /// `collectFinalizedSweptTombstones` pass in `persistWalletChangeset`. + /// 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 + chainLock: Bool = true, + chainLockHeight: UInt32? = nil ) { handler.beginChangeset(walletId: walletId) var cs = WalletChangeSetFFI() @@ -2052,19 +2080,32 @@ final class SweptTransactionPersistTests: XCTestCase { _ = 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 `(fundingTxid, 0)` with the funding side - /// unobserved, then sweep it — leaving the pending tombstone the - /// collection tests reason about. + /// 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 + _ 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: sweptTxid, + txid: loser, transactionData: Data(repeating: 0x05, count: 10), context: 0, blockHeight: 0, @@ -2072,101 +2113,139 @@ final class SweptTransactionPersistTests: XCTestCase { ) context.insert(swept) context.insert(PersistentPendingInput( - outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + outpoint: PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0), inputIndex: 0, - spendingTxid: sweptTxid, + spendingTxid: loser, spendingTransaction: swept, walletId: walletId )) try context.save() - sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid)]) + sweep(handler, [Batch( + losers: [loser], + winner: winner ?? winnerTxid, + winnerMinedHeight: winnerMinedHeight + )]) } - private func pendingRows(_ container: ModelContainer) throws -> [PersistentPendingInput] { - let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + 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) } - /// 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 changeset-header collector deletes it once the - /// synced height clears its stamp by the margin — and not one block - /// sooner: the winner customarily mines at stamp + 1, and the margin - /// keeps the claim through that block. + /// 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() - heightsRound(handler, synced: 100) - try seedSweptTombstone(handler, container) + 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.heldSinceHeight, 100, - "the tombstone is stamped with the wallet's synced height" + tombstone.winnerMinedHeight, Self.winnerHeight, + "the tombstone is stamped with the WINNER'S own mined height — " + + "not any observation watermark" ) - heightsRound(handler, synced: 101) + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight - 1 + ) XCTAssertEqual( try pendingRows(container).count, 1, - "boundary 101 has not cleared stamp 100 by the margin — the hold stays" + "boundary \(Self.winnerHeight - 1) has not reached the winner's " + + "height \(Self.winnerHeight) — the hold stays" ) - heightsRound(handler, synced: 102) + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) XCTAssertTrue( try pendingRows(container).isEmpty, - "boundary 102 cleared stamp 100 by the margin — the junk row is gone" + "the boundary reaching the winner's height collects the row — no margin" ) } - /// Synced height alone is not finality: until a chainlock has been - /// applied the collector must not run, mirroring upstream's (and the - /// SQLite store's) "no-op until a chainlock has been applied". The - /// moment one lands, the aged stamp collects. - func testASweptTombstoneOutlivesSyncProgressWithoutAChainLock() throws { + /// 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() - heightsRound(handler, synced: 100, chainLock: false) - try seedSweptTombstone(handler, container) + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) - heightsRound(handler, synced: 500, chainLock: false) + heightsRound(handler, synced: 10_000, chainLock: true) XCTAssertEqual( try pendingRows(container).count, 1, - "no chainlock has ever been applied — the hold outlasts any " - + "amount of synced-height progress" + "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: 500) + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) XCTAssertTrue( try pendingRows(container).isEmpty, - "the first applied chainlock supplies the boundary and the " - + "long-aged stamp collects" + "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 sync progress may - /// touch the materialised hold. + /// 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() - heightsRound(handler, synced: 100) - try seedSweptTombstone(handler, container) + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) XCTAssertEqual( - try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, 100, - "sanity: held, undrained, stamped" + 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) @@ -2175,7 +2254,7 @@ final class SweptTransactionPersistTests: XCTestCase { "sanity: the drain consumed the pending rows" ) - heightsRound(handler, synced: 10_000) + 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" @@ -2184,65 +2263,336 @@ final class SweptTransactionPersistTests: XCTestCase { XCTAssertEqual(coin.supersededByTxid, winnerTxid) } - /// A tombstone flagged while no synced height was on record (or written - /// before `heldSinceHeight` existed — lightweight migration leaves - /// those `nil`) is back-filled with the current height on the - /// collector's first sight of it, never collected in that same round, - /// and then ages out like any other. - func testAnUnstampedTombstoneIsBackfilledBeforeItCanBeCollected() throws { + /// A held tombstone with a nil winner-height stamp is never collected. + /// No current writer produces one — a mempool-context sweep creates no + /// tombstone at all and a mempool re-point keeps the existing stamp — + /// so an unstamped row is foreign or legacy data, and 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)) + // Plant the shape directly — the current writers cannot produce it. + let orphan = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: nil, + walletId: walletId + ) + orphan.isSweptTombstone = true + context.insert(orphan) try context.save() - try seedSweptTombstone(handler, container) + + // 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( - try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, - "sanity: no synced height existed, so the stamp is nil" + row.winnerMinedHeight, + "and the stamp is never back-filled — that would fabricate the horizon" ) + } - heightsRound(handler, synced: 1_000) + /// 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( - try XCTUnwrap(try pendingRows(container).first).heldSinceHeight, 1_000, - "first collection pass back-fills the stamp instead of collecting" + row.winnerMinedHeight, Self.winnerHeight + 50, + "re-pointed to a later block-context winner ⇒ re-stamped to " + + "THAT winner's mined height" ) - heightsRound(handler, synced: 1_002) + // 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 — creates NO placeholder for a held-but-unfunded input, + /// however often it happens. This is upstream's own doctrine projected + /// ("an unconfirmed spend must not invalidate a coin"), and it kills + /// the attacker-growable population at the source: a swept incoming + /// payment's foreign inputs land nothing, so repeated double-spends at + /// this wallet grow nothing. + func testAMempoolContextSweepCreatesNoPlaceholderRows() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + // Repeated double-spent incoming payments: distinct losers, each + // claiming a distinct unobserved input, each swept by an IS-locked + // winner. + 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) + ) + XCTAssertTrue( + try pendingRows(container, spentTxid: spent).isEmpty, + "an unmined IS-locked winner must leave no placeholder for input #\(i)" + ) + } XCTAssertTrue( - try pendingRows(container).isEmpty, - "the back-filled stamp ages out like any other" + try walletPendingRows(container).isEmpty, + "repeated mempool-path double-spends must leave zero pending-input rows" ) } - /// A chained sweep that re-points a still-unfunded claim to a new - /// winner also re-stamps it: the claim now belongs to a winner whose - /// confirmation is measured from this round, not the original sweep's. - func testARepointedTombstoneIsRestampedToTheLaterSweep() throws { + /// The mempool-context sweep still spend-marks a coin that HAS + /// materialised — that path is unchanged: 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-materialised claim (the pending row) dies with the loser. + 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) + XCTAssertTrue( + try pendingRows(container, spentTxid: unfundedTxid).isEmpty, + "while the never-materialised claim dies with the loser — no tombstone" + ) + } + + /// The reviewer's named regression, resolved by engine parity: 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. The wallet engine itself + /// keeps no durable hold for an unconfirmed spend and would credit the + /// coin — so the mirror must land it unspent too, with no stale + /// placeholder in the way and none wrongly collected beforehand (none + /// ever existed). Convergence is the winner's job: when it mines, + /// BIP158 delivery of its block re-marks the coin through the ordinary + /// channels. + func testAFundingOutputArrivingAfterAMempoolSweepAndRestartLandsUnspent() 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") + XCTAssertTrue( + try walletPendingRows(container).isEmpty, + "sanity: the mempool sweep left no pending rows behind" + ) + } + + // Restart: a fresh persister loading the same on-disk store, then + // arbitrary chainlock/height advancement while the winner stays + // unmined, and only then the funding delivery. + let (handler, container) = try makeHandler(url: storeURL) + heightsRound(handler, synced: 25_000, chainLockHeight: 25_000) + 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" + ) + XCTAssertFalse( + coin.isSpent, + "the engine credits a coin whose only spender is unconfirmed — " + + "the mirror must agree after a restart, not hold a claim " + + "the wallet itself no longer remembers" + ) + XCTAssertNil(coin.supersededByTxid) + XCTAssertTrue( + try walletPendingRows(container).isEmpty, + "and no leftover pending rows either" + ) + } + + /// 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() - heightsRound(handler, synced: 100) - try seedSweptTombstone(handler, container) + 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 XCTUnwrap(try pendingRows(container).first).heldSinceHeight, 100, - "sanity: stamped at the first sweep's height" + 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" ) - // One block of progress — within the collection margin, so the - // tombstone survives to be re-pointed rather than collected. - heightsRound(handler, synced: 101) - // The first winner is itself swept — the chained-sweep continuation - // that re-points the earlier tombstone (no row needed: the - // tombstone is found by the scalar `spendingTxid` it carries). + // 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 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)]) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: nil + )]) let row = try XCTUnwrap(try pendingRows(container).first) - XCTAssertTrue(row.isSweptTombstone) XCTAssertEqual(row.spendingTxid, finalWinner) XCTAssertEqual( - row.heldSinceHeight, 101, - "re-pointed ⇒ re-stamped to the later sweep's height" + 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 1505912fbc04929b0f64897b3ba483bd4b810a5f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:04:36 +0300 Subject: [PATCH 096/102] fix(platform-wallet): preserve an unstamped tombstone for mempool-context sweeps An IS-locked, unmined winner's sweep previously created no placeholder for a held-but-unfunded input, so the only durable spend claim vanished with the loser: after a restart, a late funding-output delivery landed freshly unspent although the network had provably consumed the coin (DIP-10 settles the input the moment the winner is locked). Upstream retains exactly this hold in the account's spent_outpoints after drop_conflicted_transactions - but that set is serde(skip_serializing) and 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 mirror's tombstone is the hold's only durable carrier, and CORE_SWEEP_REMOVAL's contract already required it: every non-released input retains a durable spend claim even before its funding TXO materializes. All three backends (SQLite, Swift, Kotlin) now write the tombstone in EVERY sweep context and key only its lifetime on the winner's finality: - block context stamps the winner's mined height and collects at the chainlock finality boundary, unchanged - mempool context leaves the stamp NULL, and the collector never touches an unstamped row - 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 can prove the funding delivered-or-never; collecting early is exactly the unsound deletion the previous review rejected - an unstamped hold resolves only through proof: the funding upsert materializes it (wallet-owned claims always eventually deliver 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, which no ownership signal can gate (dashpay/rust-dashcore#968: input_details is computed from the record-time UTXO snapshot and is empty by construction for an unmaterialized funding TXO; upstream's spent_outpoints holds foreign inputs too). It is bounded by attack cost rather than collection: masternodes lock first-seen, so each row requires a conflicting payment delivered straight to the victim while withheld from the network, plus a fee-paying network-accepted IS-locked double-spend. Unlike the rejected unconditional-placeholder design, block-context rows - the shape an ordinary double-spend reaches - still collect. The regression tests that asserted the post-restart credit encoded the wrong expectation and are inverted; a new test per backend covers the block-context re-stamp channel. No schema changes: the stamp columns were already nullable and every collector already skipped NULL. --- .../PlatformWalletPersistenceHandler.kt | 93 +++++---- .../entities/PendingInputEntity.kt | 36 ++-- .../PlatformWalletPersistenceHandlerTest.kt | 188 +++++++++++------ .../src/core_wallet_types.rs | 13 +- .../src/sqlite/schema/core_state.rs | 122 ++++++----- .../tests/sqlite_transaction_sweeps.rs | 191 ++++++++++++------ .../src/changeset/changeset.rs | 24 ++- .../src/changeset/core_bridge.rs | 6 +- .../Models/PersistentPendingInput.swift | 16 +- .../PlatformWalletPersistenceHandler.swift | 92 ++++----- .../SweptTransactionPersistTests.swift | 174 ++++++++++------ 11 files changed, 609 insertions(+), 346 deletions(-) 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 a8ed5745763..9b4231f75bd 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 @@ -1253,25 +1253,39 @@ class PlatformWalletPersistenceHandler( * `onWalletChangesetUtxoAdded` knows to keep the coin spent — durably, * via `TxoEntity.supersededByTxid` — once the funding TXO materializes. * - * The tombstone is created only for a BLOCK-CONTEXT sweep - * ([winnerMinedHeight] non-null), stamped with the winner's own mined - * height — the projection of key-wallet's `observed_spent_outpoints`, - * which records nothing for a mempool/IS-lock spend ("an unconfirmed - * spend must not invalidate a coin"). An IS-locked, unmined winner - * leaves no tombstone: the wallet itself keeps no durable hold for an - * unconfirmed spend, so the mirror holding one would out-claim the - * thing it mirrors — and when the winner eventually mines, BIP158 - * delivery of its block re-marks the coin through the ordinary - * channels. This is also what kills the attacker-growable junk at the - * source: a swept INCOMING payment reaches this path too, and every - * sender-owned foreign input it names would land a tombstone nothing - * ever drains. Ownership cannot gate it (nothing can prove an input - * foreign; dashpay/rust-dashcore#968), so the mempool path creates - * none at all, and the collector evicts block-context tombstones once - * the chainlock finality boundary reaches their winner's height. - * Materialized `TxoEntity` rows still get spend-marked either way — - * they carry real funding data, so holding them costs nothing an - * attacker controls. + * The tombstone is created for EVERY sweep context; only the stamp + * differs. A BLOCK-CONTEXT sweep ([winnerMinedHeight] non-null) + * stamps the winner's own mined height — the projection of + * key-wallet's `observed_spent_outpoints` — and the collector evicts + * the row once the chainlock finality boundary reaches it. An + * IS-locked, unmined winner leaves the SAME tombstone UNSTAMPED + * (null), 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's sender-owned inputs; ownership cannot gate it — nothing + * 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. Materialized `TxoEntity` rows still get + * spend-marked either way — they carry real funding data, so + * holding them costs nothing an attacker controls. * * A tombstoned row can itself need to move again: [supersededBy] is a * winner in this round, but nothing stops it from losing a later round @@ -1322,8 +1336,8 @@ class PlatformWalletPersistenceHandler( // InstantSend-locked winner not yet mined. This is the whole // lifetime rule of any tombstone this round creates or // re-points — the collector compares it against the chainlock - // finality boundary — and the null case creates no tombstone - // at all. + // finality boundary — and the null case leaves the tombstone + // unstamped, which the collector never touches. val winnerHeight = winnerMinedHeight.takeIf { it >= 0 } // Hold every input first, then free the ones upstream named: the // released set spans the whole round's removals, so it is @@ -1395,26 +1409,23 @@ class PlatformWalletPersistenceHandler( db.documentDao().deletePendingInputs(goneStaged) } if (heldStaged.isNotEmpty()) { - if (winnerHeight != null) { - db.documentDao().updatePendingInputs( - heldStaged.map { - it.copy( - spendingTransactionTxid = null, - spendingTxid = supersededBy[i], - isSweptTombstone = true, - winnerMinedHeight = winnerHeight, - ) - }, - ) - } else { - // An IS-locked, unmined winner creates no - // tombstone — see the doc comment above. The rows - // would cascade with the loser's delete anyway; - // deleting them here makes the end state explicit - // and independent of whether the shared loser row - // itself survives another wallet's claim below. - db.documentDao().deletePendingInputs(heldStaged) - } + // 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 null and + // the collector never touches the row — see the doc + // comment above for what resolves an unstamped hold. + db.documentDao().updatePendingInputs( + heldStaged.map { + it.copy( + spendingTransactionTxid = null, + spendingTxid = supersededBy[i], + isSweptTombstone = true, + winnerMinedHeight = winnerHeight, + ) + }, + ) } // A pending input an EARLIER sweep already tombstoned to // txids[i] (that txid was itself a sweep's winner, and is 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 4c997ae1c20..9203a8f9ee5 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 @@ -79,26 +79,26 @@ data class PendingInputEntity( /** * 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. A tombstone whose outpoint is a - * foreign input of a swept incoming payment never drains — no funding - * TXO ever arrives — so without a bound it is permanent junk an - * attacker grows one row per input by repeatedly double-spending - * payments at this wallet. This stamp is the row's whole lifetime - * rule: the 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. + * not any observation watermark. This stamp is the row's whole + * lifetime rule: the 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) creates no tombstone at all, and an IS-locked re-point - * keeps the existing stamp — so under the current writers an - * unstamped tombstone is legacy or foreign data, and holding it - * forever is the safe reading. Nullable, so the ADD COLUMN migration - * needs no default and pre-migration rows read as unstamped. + * 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/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 2c0b29232ed..2042882537a 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 @@ -4750,7 +4750,8 @@ class PlatformWalletPersistenceHandlerTest { * 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) must leave nothing. + * (an IS-locked, unmined winner) leaves the same tombstone unstamped, + * which the collector never touches. */ private fun seedSweptTombstone( outpoint: ByteArray, @@ -4895,28 +4896,20 @@ class PlatformWalletPersistenceHandlerTest { @Test fun aTombstoneWithoutAWinnerHeightIsNeverCollected() = runTest { - // A tombstone with a NULL stamp is never collected. No current - // writer produces one — a mempool-context sweep creates no - // tombstone at all and an IS-locked re-point keeps the existing - // stamp — so an unstamped row is legacy data (the v12 → v13 - // migration leaves pre-existing tombstones NULL) or foreign, and - // with no proof of finality the safe reading is to hold it - // forever rather than guess it collectible. + // 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 v12 → v13 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) - // Plant the shape directly — the current writers cannot produce it. - db.documentDao().upsertPendingInput( - PendingInputEntity( - outpoint = p, - inputIndex = 0, - spendingTxid = ByteArray(32) { 82 }, - walletId = walletId, - isSweptTombstone = true, - winnerMinedHeight = null, - ), - ) + // 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 @@ -5011,19 +5004,20 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun aMempoolContextSweepCreatesNoPlaceholderRows() = runTest { + fun aMempoolContextSweepPreservesAnUnstampedTombstone() = runTest { // A mempool-context sweep — an InstantSend-locked winner that has - // not mined — creates NO tombstone for a held-but-unfunded input, - // however often it happens. This is upstream's own doctrine - // projected ("an unconfirmed spend must not invalidate a coin"), - // and it kills the attacker-growable population at the source: a - // swept incoming payment's foreign inputs land nothing, so - // repeated double-spends at this wallet grow nothing. + // 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) - // Repeated double-spent incoming payments: distinct losers, each - // claiming distinct still-unfunded inputs, each swept by an - // IS-locked winner. for (i in 0 until 3) { val p = makeOutpoint(ByteArray(32) { (114 + i).toByte() }, 0) seedSweptTombstone( @@ -5032,14 +5026,23 @@ class PlatformWalletPersistenceHandlerTest { ByteArray(32) { (120 + i).toByte() }, winnerMinedHeight = -1, ) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() assertTrue( - "an unmined IS-locked winner must leave no pending row for input #$i", - db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + "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( - "repeated mempool-path double-spends must leave zero pending rows", - 0L, db.documentDao().countPendingInputs().first(), + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release resolves one", + 3L, db.documentDao().countPendingInputs().first(), ) } @@ -5099,17 +5102,18 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun aFundingOutputArrivingAfterAMempoolSweepAndRestartLandsUnspent() = runTest { - // The reviewer's named regression, resolved by engine parity: 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. The wallet engine - // itself keeps no durable hold for an unconfirmed spend and would - // credit the coin — so this store must land it unspent too, with - // no stale tombstone in the way and no tombstone wrongly collected - // beforehand (none ever existed). Convergence is the winner's job: - // when it mines, BIP158 delivery of its block re-marks the coin - // through the ordinary channels. + 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. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } handler.onPersistAccountRegistration( @@ -5128,11 +5132,11 @@ class PlatformWalletPersistenceHandlerTest { val fundingTxid = ByteArray(32) { 126 } val p = makeOutpoint(fundingTxid, 0) - seedSweptTombstone(p, ByteArray(32) { 127 }, ByteArray(32) { 0x7F }, winnerMinedHeight = -1) - assertTrue( - "sanity: the mempool-context sweep left no pending row", - db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), - ) + 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 @@ -5140,13 +5144,18 @@ class PlatformWalletPersistenceHandlerTest { val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) // Arbitrary chainlock/height advancement while the winner stays - // unmined. + // 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. + // 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 }, @@ -5156,15 +5165,23 @@ class PlatformWalletPersistenceHandlerTest { val coin = db.txoDao().getByOutpoint(p) assertNotNull(coin) - assertFalse( - "the engine credits a coin whose only spender is unconfirmed — the " + - "mirror must agree after a restart, not hold a claim the wallet " + - "itself no longer remembers", + assertTrue( + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart", coin!!.isSpent, ) - assertNull(coin.supersededByTxid) - assertTrue(db.documentDao().getPendingInputsByOutpoint(p).isEmpty()) - assertEquals(1, restarted.onLoadWalletList().single().utxos.size) + 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 @@ -5225,6 +5242,61 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), 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 diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 8b0c2982bbd..ce3e52845bf 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -303,10 +303,15 @@ pub struct SweepBatchFFI { /// was triggered by an InstantSend-locked winner still waiting to be /// mined (upstream's only other trigger — an unlocked mempool arrival /// never sweeps), and the winner has NO finality horizon: a persister - /// must not create a durable placeholder for a held-but-unfunded input - /// on such a sweep, mirroring upstream's refusal to record an - /// unconfirmed spend, and must keep (not clear) the stamp of any - /// existing placeholder it re-points. + /// must still create a durable placeholder for a held-but-unfunded + /// input — under DIP-10 the lock alone settles it, and the placeholder + /// is the only claim that survives a restart — but must leave it + /// UNSTAMPED and never collect an unstamped placeholder (the winner has + /// no mining deadline, so no watermark proves its funding output + /// delivered-or-never; only funding materialisation, a later + /// block-context re-stamp, or a release resolves it). Re-pointing an + /// existing placeholder on such a sweep must keep (not clear) any + /// stamp it already carries. pub has_winner_mined_height: bool, /// Mined height of `superseded_by` when `has_winner_mined_height` — /// the winner's own block, carried from the sweep event because the diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 5cf081f7f1c..4c9785348e7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -289,32 +289,54 @@ pub fn apply( /// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the /// claim survives the funding upsert instead of being upserted away by it. /// -/// The placeholder is created only for a BLOCK-CONTEXT sweep -/// (`winner_mined_height` is `Some`), and it stores that height — the -/// winner's own, not any observation watermark. This is the projection of -/// key-wallet's `observed_spent_outpoints`, which maps each outpoint -/// observed spent in a block to the height of the block that spent it and -/// deliberately records nothing for a mempool/IS-lock spend ("an -/// unconfirmed spend must not invalidate a coin" — `wallet_checker.rs`). -/// An IS-locked winner therefore leaves no placeholder here either: the -/// wallet itself keeps no durable hold for an unconfirmed spend, so the -/// mirror holding one would out-claim the thing it mirrors — and when the -/// winner eventually mines, BIP158 delivery of its block (the funding -/// output's script matches the winner's prevout) re-marks the coin through -/// the ordinary channels. This is also what bounds the junk an attacker can -/// grow by double-spending incoming payments at this wallet: a swept -/// INCOMING payment reaches this loop too, and every sender-owned foreign -/// input it names would land a placeholder nothing ever overwrites. It -/// cannot be gated by ownership because nothing anywhere can prove an -/// input foreign (`input_details` and `direction` are computed from the -/// wallet's UTXO snapshot AT RECORD TIME; dashpay/rust-dashcore#968 — the -/// once-proposed "held outpoints attested ours" set is empty by -/// construction). Instead the mempool path creates none at all, and -/// `collect_finalized_tombstones` evicts block-context placeholders once -/// the chainlock finality boundary reaches their winner's height — -/// key-wallet's `prune_finalized_observed_spends` condition verbatim — -/// while a genuine claim materialises via the funding upsert (gaining a -/// real `height`) and permanently leaves the collectible set. +/// The placeholder is created for EVERY sweep context; only the stamp +/// differs. A BLOCK-CONTEXT sweep (`winner_mined_height` is `Some`) +/// stamps the winner's own mined height — the projection of key-wallet's +/// `observed_spent_outpoints`, which maps each outpoint observed spent in +/// a block to the height of the block that spent it — and +/// `collect_finalized_tombstones` evicts the row once the chainlock +/// finality boundary reaches that height, key-wallet's +/// `prune_finalized_observed_spends` condition verbatim. A +/// MEMPOOL-CONTEXT sweep (IS-locked winner, unmined) writes the same row +/// UNSTAMPED (`winner_mined_height` NULL), and the collector never takes +/// an unstamped row. The in-memory model an unstamped row mirrors is not +/// `observed_spent_outpoints` (which indeed records nothing for an +/// unconfirmed spend) but the account's `spent_outpoints`: +/// `drop_conflicted_transactions` deletes the loser and RETAINS the +/// winner's shared inputs there — a hold that carries no height, because +/// under DIP-10 the IS lock alone settles the input. That set is +/// `serde(skip_serializing)` upstream and rebuilt from live records on +/// load, so after the sweep no record can reconstruct it; this row is the +/// hold's only durable carrier, and dropping it lets a post-restart +/// funding delivery credit a coin the network has already consumed. +/// +/// Nothing may collect an unstamped row, ever: an IS-locked winner has no +/// mining deadline, and the funding transaction of an input it spends may +/// itself be IS-locked and unmined (DIP-10 eligibility allows chained +/// locks), so no height watermark can prove the funding output "delivered +/// or never will be". An unstamped row instead leaves the set only +/// through proof: the funding upsert materialises it (a wallet-owned +/// claim — DIP-10 eligibility means the funding tx is mined or will mine, +/// and BIP158 matches its block by our script, so delivery is guaranteed; +/// the row gains a real `height` and becomes an ordinary spent coin), a +/// later block-context sweep re-points it and stamps it into the +/// collectible set, or a release deletes it. +/// +/// The residue is foreign inputs — a swept INCOMING payment reaches this +/// loop too, and a sender-owned input's funding output never delivers, so +/// its unstamped row is permanent. It cannot be gated by ownership +/// because nothing anywhere can prove an input foreign (`input_details` +/// and `direction` are computed from the wallet's UTXO snapshot AT RECORD +/// TIME; dashpay/rust-dashcore#968 — the once-proposed "held outpoints +/// attested ours" set is empty by construction). What bounds the residue +/// is attack cost, not collection: masternodes lock first-seen, so for +/// the winner to earn the IS lock this sweep requires, the conflicting +/// loser must have been delivered straight to this wallet while withheld +/// from the network, and every batch of rows costs the attacker a +/// fee-paying, network-accepted double-spend. The unconditional-placeholder +/// shape this narrows (every context leaking rows with no collector at +/// all) does not return: block-context rows still collect at the finality +/// boundary, and only the IS-context shared-input residue is permanent. /// /// Idempotent: a txid this store never recorded is a successful no-op, not an /// error. A sweep can legitimately name a transaction this wallet dropped, or @@ -394,15 +416,16 @@ fn apply_sweep( ELSE winner_mined_height END \ WHERE wallet_id = ?1 AND outpoint = ?2", )?; - // Only reached for a held input with no existing row AND a - // block-context winner — see the doc comment above; an IS-locked - // winner creates no placeholder at all, mirroring upstream's refusal - // to record an unconfirmed spend. `value`/`script`/`height`/ - // `account_index` are placeholders; the funding UTXO's own upsert - // overwrites them (and, thanks to the `spent_in_txid` guard in - // `execute_upsert_utxo`, does not clear `spent` while doing it). - // `winner_mined_height` is the winner's own block height, the row's - // whole lifetime rule for `collect_finalized_tombstones`. + // Only reached for a held input with no existing row — see the doc + // comment above. `value`/`script`/`height`/`account_index` are + // placeholders; the funding UTXO's own upsert overwrites them (and, + // thanks to the `spent_in_txid` guard in `execute_upsert_utxo`, does + // not clear `spent` while doing it). `winner_mined_height` is the + // winner's own block height when the sweep has one — the row's whole + // lifetime rule for `collect_finalized_tombstones` — and NULL for an + // IS-locked, unmined winner, which the collector never touches: the + // hold then lasts until the funding upsert materialises it, a later + // block-context sweep stamps it, or a release deletes it. let mut tombstone_stmt = tx.prepare_cached( "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid, \ @@ -458,20 +481,18 @@ fn apply_sweep( winner_mined_height.map(i64::from) ])?; if affected == 0 && !freed { - // No row to hold and nothing durable to key a hold on: an - // IS-locked winner (`winner_mined_height` None) gets no - // placeholder — upstream records nothing for an unconfirmed - // spend, and this is where the foreign-input junk an attacker - // could grow by double-spending incoming payments is killed at - // the source rather than aged out. - let Some(height) = winner_mined_height else { - continue; - }; + // A held input with no row gets a placeholder in EVERY sweep + // context — `CORE_SWEEP_REMOVAL`'s contract: each non-released + // input retains a durable spend claim even when its funding + // TXO has not materialised yet. An IS-locked, unmined winner + // just leaves the stamp NULL, which the collector never + // touches — see the doc comment above for what resolves (and + // what bounds) an unstamped row. tombstone_stmt.execute(params![ wallet_id.as_slice(), &key[..], AsRef::<[u8]>::as_ref(superseded_by), - i64::from(height) + winner_mined_height.map(i64::from) ])?; } } @@ -629,10 +650,13 @@ fn read_sync_heights( /// the spend's own height — has either been delivered (materialising the /// row) or provably never will be. No observation-age margin: the stamp IS /// the winner's height, carried on the sweep event itself, so nothing here -/// guesses when the winner mined. Rows with no stamp are never collected — -/// under the current writers none can exist (an IS-locked winner creates -/// no placeholder and never clears an existing stamp), so an unstamped row -/// is foreign or legacy data, and holding it forever is the safe reading. +/// guesses when the winner mined. Rows with no stamp are never collected: +/// a mempool-context sweep (IS-locked winner, unmined) deliberately +/// writes its placeholder unstamped, because such a winner has no mining +/// deadline and no watermark can prove its inputs' funding "delivered or +/// never will be" — an unstamped row is a live hold, resolved only by the +/// funding upsert materialising it, a later block-context sweep stamping +/// it, or a release deleting it (see `apply_sweep`). /// /// Two passes, both narrowed to `height IS NULL` (only the tombstone /// insert leaves `height` NULL, so the set is exactly the diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index b33ee0df823..17fc5e088ca 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -2084,8 +2084,9 @@ fn utxo_row_state( /// Record a loser spending `input` (no funding row exists), then sweep it /// in the given winner context — `Some(height)` leaves the held-but-absent -/// placeholder the collection tests reason about, `None` (an IS-locked, -/// unmined winner) must leave nothing. +/// placeholder stamped with the winner's mined height, `None` (an +/// IS-locked, unmined winner) leaves the same placeholder unstamped, which +/// the collector never touches. fn seed_tombstone( conn: &mut rusqlite::Connection, w: &WalletId, @@ -2217,41 +2218,54 @@ fn a_block_context_tombstone_outlives_unrelated_advancement_below_its_winners_he } /// A mempool-context sweep — an InstantSend-locked winner that has not -/// mined — creates NO placeholder for a held-but-unfunded input, however -/// often it happens. This is upstream's own doctrine projected ("an -/// unconfirmed spend must not invalidate a coin": `record_observed_spends` -/// refuses mempool/IS contexts), and it kills the attacker-growable -/// population at the source — a swept incoming payment's foreign inputs -/// land nothing, so repeated double-spends at this wallet grow nothing. +/// mined — preserves an UNSTAMPED tombstone for every held-but-unfunded +/// input. Under DIP-10 the IS lock alone settles those inputs: upstream's +/// `drop_conflicted_transactions` deletes the loser and retains them in +/// the account's `spent_outpoints`, a hold that carries no height and +/// that nothing can reconstruct from records once the loser is gone (the +/// winner need not be wallet-relevant). The row is that hold's only +/// durable carrier — `CORE_SWEEP_REMOVAL` requires every non-released +/// input to keep a durable spend claim before its funding TXO +/// materialises — and it is unstamped because an IS-locked winner has no +/// mining deadline, so no boundary may ever collect it; resolution is the +/// funding upsert, a later block-context re-stamp, or a release. #[test] -fn a_mempool_context_sweep_creates_no_placeholder_rows() { +fn a_mempool_context_sweep_preserves_an_unstamped_tombstone() { let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xF8); ensure_wallet_meta(&persister, &w); let mut conn = persister.lock_conn_for_test(); - // Repeated double-spent incoming payments: distinct losers, each - // claiming distinct foreign inputs, each swept by an IS-locked winner. + // Several IS-context sweeps in a row, each with a distinct + // held-but-unfunded input. for i in 0u8..3 { let p = OutPoint::new(Txid::from_byte_array([0x70 + i; 32]), 0); let loser = Txid::from_byte_array([0x80 + i; 32]); let winner = Txid::from_byte_array([0x90 + i; 32]); seed_tombstone(&mut conn, &w, p, loser, winner, None); - assert!( - !row_exists(&conn, &w, &p), - "an unmined IS-locked winner must leave no placeholder for input #{i}" + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "an unmined IS-locked winner must leave a held, unstamped \ + placeholder for input #{i}" ); } + // Arbitrary chainlock/height advancement never collects an unstamped + // hold — two rounds, so a back-filling collector would be caught too. + apply_heights(&mut conn, &w, 1_000_000); + apply_heights(&mut conn, &w, 1_000_010); let rows: i64 = conn .query_row( - "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1 \ + AND spent = 1 AND winner_mined_height IS NULL", params![w.as_slice()], |row| row.get(0), ) .unwrap(); assert_eq!( - rows, 0, - "repeated mempool-path double-spends must leave zero placeholder rows" + rows, 3, + "every unstamped hold outlasts any boundary — only funding \ + materialisation, a block-context re-stamp, or a release resolves one" ); } @@ -2292,17 +2306,21 @@ fn a_mempool_context_sweep_still_spend_marks_a_materialised_coin() { ); } -/// The reviewer's named regression, resolved by engine parity: 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. The wallet engine itself keeps no -/// durable hold for an unconfirmed spend and would credit the coin — so -/// the store must land it unspent too, with no stale placeholder in the -/// way and no placeholder wrongly collected beforehand (none ever -/// existed). Convergence is the winner's job: when it mines, BIP158 -/// delivery of its block re-marks the coin through the ordinary channels. +/// The reviewer's named regression: an IS-locked winner sweeps on the +/// mempool path and never mines, the app restarts, chainlocks and heights +/// advance arbitrarily, and only then is the funding output delivered. +/// Under DIP-10 the IS lock already settled that input — upstream deleted +/// the loser and retained the hold in the account's `spent_outpoints`, a +/// set rebuilt from records on load that no surviving record can +/// reconstruct (the winner need not be wallet-relevant). The unstamped +/// tombstone is therefore the claim's only durable carrier, and the +/// funding upsert must land ON it and stay spent: crediting the coin +/// would hand coin selection an outpoint the network has provably +/// consumed. This is `CORE_SWEEP_REMOVAL`'s contract verbatim — every +/// non-released input retains a durable spend claim even before its +/// funding TXO materialises. #[test] -fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_unspent() { +fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_spent() { let (persister, tmp, path) = fresh_persister(); let w: WalletId = wid(0xFA); ensure_wallet_meta(&persister, &w); @@ -2324,10 +2342,18 @@ fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_unspent() { let persister = SqlitePersister::open(cfg).expect("reopen"); let mut conn = persister.lock_conn_for_test(); - // Arbitrary chainlock/height advancement while the winner stays unmined. + // Arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold. apply_heights(&mut conn, &w, 25_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "the unstamped hold survives the restart and every boundary" + ); - // The funding output is finally delivered and classified. + // The funding output is finally delivered and classified: the upsert + // materialises the row (real height, stamp stays clear) and the + // `spent_in_txid` valve keeps the coin spent. { let tx = conn.transaction().unwrap(); let cs = CoreChangeSet { @@ -2338,21 +2364,15 @@ fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_unspent() { tx.commit().unwrap(); } assert!( - unspent(&conn, &w).contains(&p), - "the engine credits a coin whose only spender is unconfirmed — the \ - mirror must agree after a restart, not hold a claim the wallet \ - itself no longer remembers" + !unspent(&conn, &w).contains(&p), + "an input the IS-locked winner consumed must never come back \ + spendable — the sweep's claim outlives the restart" ); - let rows: i64 = conn - .query_row( - "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", - params![w.as_slice()], - |row| row.get(0), - ) - .unwrap(); assert_eq!( - rows, 1, - "exactly the funding row itself — no leftover placeholder" + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "materialised on the tombstone: real funding height, still spent, \ + permanently outside the collector's reach" ); drop(conn); drop(tmp); @@ -2461,10 +2481,10 @@ fn a_materialised_claim_is_never_collected() { } /// A held, unmaterialised row with a NULL winner height is never -/// collected. No current writer produces one — the tombstone insert -/// requires a block context and an IS-locked re-point keeps the existing -/// stamp — so an unstamped row is foreign or legacy data, and the safe -/// reading is to hold it forever rather than guess it collectible. +/// collected. The mempool-context sweep path writes exactly this shape +/// (an IS-locked, unmined winner has no finality horizon to stamp), and +/// legacy rows read identically — either way the safe reading is to hold +/// it forever rather than guess it collectible. #[test] fn a_tombstone_without_a_winner_height_is_never_collected() { let (persister, _tmp, _path) = fresh_persister(); @@ -2472,20 +2492,13 @@ fn a_tombstone_without_a_winner_height_is_never_collected() { ensure_wallet_meta(&persister, &w); let p = OutPoint::new(Txid::from_byte_array([0x59; 32]), 0); + let loser = Txid::from_byte_array([0x5A; 32]); let winner = Txid::from_byte_array([0x5B; 32]); let mut conn = persister.lock_conn_for_test(); - // Plant the shape directly — the current writers cannot produce it. - { - let bytes = blob::encode_outpoint(&p).unwrap(); - conn.execute( - "INSERT INTO core_utxos \ - (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ - VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3)", - params![w.as_slice(), &bytes[..], AsRef::<[u8]>::as_ref(&winner)], - ) - .unwrap(); - } + // The real writer: an IS-context sweep of a loser whose funding row + // never arrived. + seed_tombstone(&mut conn, &w, p, loser, winner, None); // Two rounds, not one: a back-filling collector (the rejected design) // would stamp the row on the first round and collect it on the second. @@ -2628,6 +2641,70 @@ fn a_mempool_repointed_tombstone_keeps_its_block_context_stamp() { ); } +/// The other direction of the chained case: an UNSTAMPED hold (IS-context +/// sweep) re-pointed by a later BLOCK-context sweep gains that winner's +/// stamp — the claim now belongs to a spend anchored in a real block, so +/// it enters the collectible set and the boundary reaching the new +/// winner's height collects it. This is one of the three resolution +/// channels that bound the unstamped population. +#[test] +fn an_unstamped_tombstone_restamped_by_a_block_context_sweep_becomes_collectible() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFC); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x72; 32]), 0); + let first_loser = Txid::from_byte_array([0x73; 32]); + let second_loser = Txid::from_byte_array([0x74; 32]); + let final_winner = Txid::from_byte_array([0x75; 32]); + + let mut conn = persister.lock_conn_for_test(); + // IS-context sweep: the hold lands unstamped. + seed_tombstone(&mut conn, &w, p, first_loser, second_loser, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "sanity: held and unstamped" + ); + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "the block-context re-point stamps the previously unstamped hold" + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "once stamped, the ordinary finality boundary collects the row" + ); +} + /// Legacy shape self-heal: a zero-value released placeholder written /// before the release path deleted them (`height` NULL, `spent = 0`) holds /// no claim and is swept up by the collector's first pass — chainlock or diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index f886e5c51a4..d980cf34299 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -246,15 +246,21 @@ pub struct SweepBatch { /// /// This is the winner's finality context, straight from the event: the /// winner need not be wallet-relevant, so no persister can look its - /// height up in its own records. A held-but-unfunded input is only - /// mirrored as a durable placeholder when this is `Some` — the exact - /// projection of upstream's `observed_spent_outpoints`, which records a - /// spend's own block height and deliberately records nothing for a - /// mempool/IS-lock spend ("an unconfirmed spend must not invalidate a - /// coin"). The stored height is then the placeholder's whole lifetime - /// rule: collectible once `min(chainlock_height, synced_height)` - /// reaches it, exactly upstream's `prune_finalized_observed_spends` - /// boundary. + /// height up in its own records. A held-but-unfunded input is mirrored + /// as a durable placeholder in EITHER case; this field decides the + /// placeholder's lifetime. `Some` stamps the winner's own block height + /// — the projection of upstream's `observed_spent_outpoints` — and the + /// placeholder is collectible once `min(chainlock_height, + /// synced_height)` reaches it, exactly upstream's + /// `prune_finalized_observed_spends` boundary. `None` (IS-locked + /// winner, unmined) leaves the placeholder UNSTAMPED and never + /// collectible: under DIP-10 the lock alone settles the input — + /// upstream retains it in the account's `spent_outpoints`, a hold with + /// no height that no record survives to rebuild — and an IS-locked + /// winner has no mining deadline, so no watermark can ever prove the + /// funding output delivered-or-never. An unstamped placeholder + /// resolves only through proof: funding materialisation, a later + /// block-context sweep's re-stamp, or a release. /// /// `serde(default)`: a journaled payload written before this field /// existed reads back as `None` — the conservative reading (no new diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index bf5fc7cf96b..6ea98037b93 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -877,8 +877,10 @@ async fn build_core_changeset( // 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) creates no placeholder at - // all, mirroring upstream's observed-spends doctrine. + // `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(), }], diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index da3ee96f133..f340cf34b79 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -109,12 +109,16 @@ public final class PersistentPendingInput { /// 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 never stamps this: - /// it creates no tombstone at all, and re-pointing an existing - /// tombstone keeps the earlier block-context stamp untouched (upstream - /// never retracts an observed-spend entry for an unconfirmed conflict). - /// `nil` on a tombstone therefore means foreign or legacy data — the - /// collector holds it forever rather than guess it collectible. + /// 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? diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index aa9f4dfdf7e..ea1989a22d4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -988,11 +988,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// 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 — under the current writers none - /// can exist (a mempool-context sweep creates no tombstone and never - /// clears an existing stamp), so an unstamped row is foreign or legacy - /// data, and holding it forever is the safe reading. See the property - /// doc on `PersistentPendingInput.winnerMinedHeight`. + /// 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 @@ -1382,29 +1385,38 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// left for the cascade, the same as a released materialized input needs /// no special handling beyond the loop above. /// - /// The tombstone is written only for a BLOCK-CONTEXT sweep - /// (`winnerMinedHeight` non-nil), stamped with that height — the - /// winner's own, not any observation watermark. This is the projection - /// of key-wallet's `observed_spent_outpoints`, which deliberately - /// records nothing for a mempool/IS-lock spend ("an unconfirmed spend - /// must not invalidate a coin"). A mempool-context sweep - /// (`winnerMinedHeight` nil — the winner is IS-locked and not yet - /// mined) DELETES the held pending row instead: the wallet itself - /// keeps no durable hold for an unconfirmed spend, so the mirror - /// holding one would out-claim the thing it mirrors — and when the - /// winner eventually mines, BIP158 delivery of its block (the funding - /// output's script matches the winner's prevout) re-marks the coin - /// through the ordinary channels. This is also what bounds the junk an - /// attacker can grow by double-spending incoming payments at this - /// wallet: a swept INCOMING payment reaches this loop too, and every - /// sender-owned foreign input it names would land a placeholder - /// nothing ever overwrites. It cannot be gated by ownership — nothing - /// anywhere can prove an input foreign (dashpay/rust-dashcore#968) — - /// so the mempool path creates none at all, and - /// `collectFinalizedSweptTombstones` evicts block-context tombstones - /// once the finality boundary reaches their winner's height, while a - /// genuine claim drains into its TXO on funding arrival and leaves the - /// collectible set with the rows. + /// 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 @@ -1517,23 +1529,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(pending) continue } - guard let winnerMinedHeight else { - // Mempool context: the winner is IS-locked and not yet - // mined, and upstream records nothing durable for an - // unconfirmed spend — so the loser's claim dies with - // the loser instead of being repurposed into a - // tombstone. Deleted explicitly rather than left for - // the row's cascade for the same reason as the - // released branch above: still attached, it would read - // as this wallet's live claim in the ownership check - // below and stalemate another wallet's callback. No - // row is the correct end state — if the funding output - // ever classifies, its ordinary upsert lands it - // freshly unspent, exactly as the wallet engine itself - // would credit 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index abbf7ed7cef..d83f77db157 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -2264,27 +2264,20 @@ final class SweptTransactionPersistTests: XCTestCase { } /// A held tombstone with a nil winner-height stamp is never collected. - /// No current writer produces one — a mempool-context sweep creates no - /// tombstone at all and a mempool re-point keeps the existing stamp — - /// so an unstamped row is foreign or legacy data, and with no proof of - /// finality the safe reading is to hold it forever rather than guess. + /// 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)) - // Plant the shape directly — the current writers cannot produce it. - let orphan = PersistentPendingInput( - outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), - inputIndex: 0, - spendingTxid: winnerTxid, - spendingTransaction: nil, - walletId: walletId - ) - orphan.isSweptTombstone = true - context.insert(orphan) 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 @@ -2360,21 +2353,21 @@ final class SweptTransactionPersistTests: XCTestCase { } /// A mempool-context sweep — an InstantSend-locked winner that has not - /// mined — creates NO placeholder for a held-but-unfunded input, - /// however often it happens. This is upstream's own doctrine projected - /// ("an unconfirmed spend must not invalidate a coin"), and it kills - /// the attacker-growable population at the source: a swept incoming - /// payment's foreign inputs land nothing, so repeated double-spends at - /// this wallet grow nothing. - func testAMempoolContextSweepCreatesNoPlaceholderRows() throws { + /// 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() - // Repeated double-spent incoming payments: distinct losers, each - // claiming a distinct unobserved input, each swept by an IS-locked - // winner. for i in 0..<3 { let spent = Data(repeating: UInt8(0x70 + i), count: 32) try seedSweptTombstone( @@ -2385,22 +2378,33 @@ final class SweptTransactionPersistTests: XCTestCase { loser: Data(repeating: UInt8(0x80 + i), count: 32), winner: Data(repeating: UInt8(0x90 + i), count: 32) ) - XCTAssertTrue( - try pendingRows(container, spentTxid: spent).isEmpty, - "an unmined IS-locked winner must leave no placeholder for input #\(i)" + 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") } - XCTAssertTrue( - try walletPendingRows(container).isEmpty, - "repeated mempool-path double-spends must leave zero pending-input rows" + // 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, so holding it costs nothing an attacker controls, and the - /// winner's eventual block delivery is the durable evidence. Only the - /// never-materialised claim (the pending row) dies with the loser. + /// 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) @@ -2431,23 +2435,26 @@ final class SweptTransactionPersistTests: XCTestCase { "a materialised coin is spend-marked by the IS-locked winner exactly as before" ) XCTAssertEqual(coinB.supersededByTxid, winnerTxid) - XCTAssertTrue( - try pendingRows(container, spentTxid: unfundedTxid).isEmpty, - "while the never-materialised claim dies with the loser — no tombstone" + 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, resolved by engine parity: 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. The wallet engine itself - /// keeps no durable hold for an unconfirmed spend and would credit the - /// coin — so the mirror must land it unspent too, with no stale - /// placeholder in the way and none wrongly collected beforehand (none - /// ever existed). Convergence is the winner's job: when it mines, - /// BIP158 delivery of its block re-marks the coin through the ordinary - /// channels. - func testAFundingOutputArrivingAfterAMempoolSweepAndRestartLandsUnspent() throws { + /// 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) } @@ -2459,33 +2466,39 @@ final class SweptTransactionPersistTests: XCTestCase { try context.save() try seedSweptTombstone(handler, container, winnerMinedHeight: nil) XCTAssertNil(transaction(container, txid: sweptTxid), "sanity: the loser is gone") - XCTAssertTrue( - try walletPendingRows(container).isEmpty, - "sanity: the mempool sweep left no pending rows behind" + 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, and only then the funding delivery. + // 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" ) - XCTAssertFalse( + XCTAssertTrue( coin.isSpent, - "the engine credits a coin whose only spender is unconfirmed — " - + "the mirror must agree after a restart, not hold a claim " - + "the wallet itself no longer remembers" + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart" ) - XCTAssertNil(coin.supersededByTxid) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "held by the winner the sweep named") XCTAssertTrue( try walletPendingRows(container).isEmpty, - "and no leftover pending rows either" + "the claim drained into the TXO row" ) } @@ -2525,6 +2538,53 @@ final class SweptTransactionPersistTests: XCTestCase { ) } + /// 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 From 1c02faf1cdb0dc44d194c63d5bba6529df5a1636 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:04:51 +0300 Subject: [PATCH 097/102] docs(platform-wallet): state the ordered, non-commutative changeset merge contract The Merge module doc and the adapter's batching comment both described CoreChangeSet merging as commutative and safe to reorder. It is neither: merging is an ordered left fold in production order, associative but not commutative. A record in the later operand retracts an earlier sweep of the same txid (reinstatement) while the reverse order keeps the sweep for apply time to honor, sweep batches append in emission order so a later batch's spend decision replays over an earlier release, and the IS-lock map's last-write-wins plus the chain-lock equal-height tie-break also take the later operand. Reversing or parallelizing the fold can therefore persist a different spend decision, not just a differently-arranged changeset. Regrouping the fold remains safe - associativity is what the batching adapter actually relies on. Comments only; no behavior change. --- .../src/changeset/core_bridge.rs | 22 +++++++++++++++---- .../rs-platform-wallet/src/changeset/merge.rs | 18 ++++++++++----- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 6ea98037b93..e42251d8d87 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -79,10 +79,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 diff --git a/packages/rs-platform-wallet/src/changeset/merge.rs b/packages/rs-platform-wallet/src/changeset/merge.rs index 9c0d97fad27..79892407fa1 100644 --- a/packages/rs-platform-wallet/src/changeset/merge.rs +++ b/packages/rs-platform-wallet/src/changeset/merge.rs @@ -1,14 +1,22 @@ //! The `Merge` trait for composing changeset deltas. //! -//! Changesets are commutative and associative so that multiple deltas can be -//! batched and reordered without affecting the final result. +//! Changeset merging is an ORDERED, associative operation: a stream of +//! deltas may be folded together in any grouping, but only in the order +//! the deltas were produced. It is NOT commutative — `CoreChangeSet` is +//! the load-bearing example: a record in the later operand retracts an +//! earlier sweep of the same txid (reinstatement), while the reverse +//! order keeps the sweep, and sweep batches append in emission order so +//! a later batch's spend decision replays over an earlier one's release. +//! Reordering or parallelizing a fold can therefore persist a different +//! spend decision, not just a differently-arranged changeset. use std::collections::{BTreeMap, BTreeSet}; -/// Combine two changesets. Changesets are commutative and associative -/// for safe batching and reordering. +/// Combine two changesets: `self` is the earlier delta, `other` the later +/// one. Associative (safe to regroup a fold) but NOT commutative — see +/// the module doc; callers must keep operands in production order. pub trait Merge: Default { - /// Merge another changeset into `self`. + /// Merge `other`, the LATER delta, into `self`. fn merge(&mut self, other: Self); /// Returns `true` if this changeset contains no changes. From a76bc797880e6a7f31eafab1950ca556a45cf93a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:14:47 +0300 Subject: [PATCH 098/102] docs(platform-wallet-storage): align the V007 contract with unstamped mempool tombstones The migration's module doc still described the pre-1505912fbc design: placeholders only from block-context sweeps, no placeholder for an IS-locked winner, and "NULL is never written by current code". The current apply_sweep does the opposite - every non-released held input gets a placeholder in every sweep context, and a mempool-context sweep leaves winner_mined_height NULL on purpose, an unstamped row the collector never takes. As the long-lived explanation for the nullable column, the doc was pointing maintainers straight at the restart bug 1505912fbc fixed. --- .../V007__utxo_sweep_winner_height.rs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs b/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs index f324f8e6e0c..a2de745804f 100644 --- a/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs +++ b/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs @@ -7,16 +7,22 @@ //! funding output has never classified (`height IS NULL AND spent = 1`; //! no other writer leaves `height` NULL). The height is carried on the //! sweep event itself (`TransactionsSwept::winner_mined_height`), and -//! only a block-context sweep — one whose winner actually mined — writes -//! a tombstone at all; an InstantSend-locked winner has no mining -//! deadline and leaves no placeholder, mirroring key-wallet's -//! `observed_spent_outpoints` doctrine ("an unconfirmed spend must not -//! invalidate a coin"). The collector in `core_state::apply` evicts a -//! tombstone exactly when `min(chainlock_height, synced_height)` reaches -//! its winner's height — `prune_finalized_observed_spends`' condition -//! verbatim, with no observation-age margin. NULL is never written by -//! current code; an unstamped held row is held forever rather than -//! guessed collectible. +//! `apply_sweep` writes the placeholder for EVERY non-released held +//! input in EVERY sweep context — only the stamp differs. A +//! block-context sweep (winner actually mined) stamps the winner's +//! height, and the collector in `core_state::apply` evicts the row +//! exactly when `min(chainlock_height, synced_height)` reaches that +//! height — `prune_finalized_observed_spends`' condition verbatim, with +//! no observation-age margin. An InstantSend-locked, unmined winner +//! writes the same row with the stamp NULL — under DIP-10 its lock +//! alone settles the input, but it carries no height to key a lifetime +//! on — and the collector never takes an unstamped row: it resolves +//! only through proof, when the funding upsert materialises it, a later +//! block-context sweep re-stamps it into the collectible set, or a +//! release deletes it. See `CORE_SWEEP_REMOVAL` and the `apply_sweep` +//! doc in `core_state.rs` for why an unstamped hold must survive (it is +//! the only durable carrier of upstream's in-memory `spent_outpoints` +//! hold across a restart) and what bounds the foreign-input residue. //! //! `core_sync_state.chainlock_height` is the monotonic-max height of //! the last applied chainlock, mirrored from @@ -26,8 +32,9 @@ //! matching upstream's "no-op until a chainlock has been applied". //! //! The partial index covers exactly the unmaterialised rows — the -//! collector's scan set — so the per-round sweep touches tombstones -//! only, not the wallet's full spent history. +//! collector's scan set is the stamped subset of these — so the +//! per-round sweep touches tombstones only, not the wallet's full +//! spent history. //! //! Edited in place (formerly `V006__utxo_tombstone_stamp`, column //! `held_since_height`) under the same pre-release policy V001's test From 4a02bf85e6c84390aec44383b0b62988333e2c21 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:14:57 +0300 Subject: [PATCH 099/102] docs(kotlin-sdk): define CORE_SWEEP_REMOVAL as ordered exclusion, not row deletion The KDoc still promised physical deletion of the loser's row and its pending-input tombstone. The Rust capability contract requires observable exclusion from every restore and enumeration path, tolerates an inert globally-swept row until wallet-scoped cleanup lands, and deliberately retains a detached non-released spend claim whose funding TXO has not materialized - that tombstone is the only durable cross-restart hold, and an out-of-tree Android persister following the old wording would delete it and later credit a coin the network already consumed. Rewritten in the Rust doc's terms; the Swift mirror already states the contract correctly. --- .../dashsdk/wallet/PlatformWalletManager.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 e870a7247eb..04bbcc2a314 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 @@ -69,9 +69,18 @@ data class PlatformWalletPersistenceCapabilities( */ const val TRACKED_MASTERNODES: Long = 1L shl 10 /** - * A stored core changeset's swept transactions are durably removed: - * the loser's row (and any tombstoned pending-input claim standing - * in for a not-yet-materialized UTXO) actually leaves Room. Mirrors + * A stored core changeset's non-empty sweeps are durably applied + * batch by batch and in order: each swept transaction and its + * outputs are excluded from every restore and enumeration path + * (physical deletion or a durable marker alike — an inert + * globally-swept row may remain until every wallet's scoped + * cleanup lands), each released outpoint is freed unless a later + * surviving claim supersedes that release, and each non-released + * input RETAINS a durable spend claim even when its funding TXO + * has not materialized yet — a detached claim must outlive its + * loser, or a post-restart funding delivery credits a coin the + * network already consumed. Physical row deletion is an + * implementation detail, not the contract. Mirrors * `PersistenceCapabilities::CORE_SWEEP_REMOVAL`. */ const val CORE_SWEEP_REMOVAL: Long = 1L shl 11 From 5088fa45b9309cdcbc217d1fd5e424b41f20d568 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:00:03 +0300 Subject: [PATCH 100/102] fix(platform-wallet): refuse a sweep release for a coin a stored settled record still claims Upstream computes TransactionsSwept::released_outpoints from its live records, and under the default keep-finalized-transactions=off a chainlocked spender is pruned to a bare txid - the pinned event doc records the limitation outright: the inputs of a pruned record survive nowhere else, so it cannot be resolved at that layer. A wallet-relevant loser recorded after the pruning can reuse the pruned spender's input alongside an attacker-owned one; when a final winner beats it on the attacker input alone, the sweep names the settled coin released, every backend flips its materialized UTXO back to spendable, and the next load hands coin selection a guaranteed double spend. After a restart the same amnesia covers records of every context, since hydration rebuilds the in-memory wallet without transaction history. It CAN be resolved one layer down: all three stores durably retain what upstream forgets, so each now re-evaluates upstream's own retain_unclaimed predicate - drop outpoints some surviving record still spends - against its unpruned history before honouring a release: - SQLite keeps every core_transactions record_blob past finalization. The release filter now subtracts the union of surviving rows' inputs, built lazily at most once per round and only when a batch's released set survives the in-round claimed_by_survivors filter; the common full-resend sweep releases nothing and pays nothing. - Swift and Kotlin already carry the attribution their release passes trust: the TXO's spender link, which the loser walk detaches and the by-outpoint release requires detached. The defect there was last-writer-wins linking - the loser's record pass stole the link from the settled spender, making the coin releasable. The link is now guarded: a network-final spender (IS-locked or better, not globally swept) keeps it, with the one DIP-10-sanctioned takeover - a chainlocked arrival over a spender that was only IS-locked, the same precedence upstream's sweep gate applies. The utxos_spent channel gets the same guard; unreachable for this defect today (upstream only classifies inputs against a still-present funding UTXO), but the invariant should not depend on that detail. In-session the in-memory wallet stays safe on its own - a released mark is never re-credited to utxos, and redelivery short-circuits on the known txid - so refusing the release at the store is what closes the cross-restart window, and it does so by construction: the guard's data lives in the store, not in rehydrated memory. The regression per backend is the reviewer scenario end to end: finalized F pruned upstream, loser L reusing F's input plus an attacker input, final W beating L on the attacker input, the release wrongly naming F's coin - asserting the settled coin stays spent across a restart while the coin only the loser claimed still comes free in the same batch. Each new test was run red against its backend's pre-guard code. This closes the release-trust half of the finding locally. The event surface itself still reports the wrong release to every other key-wallet consumer; that remains an upstream defect to file separately and does not gate this branch. --- .../PlatformWalletPersistenceHandler.kt | 86 +++++++++- .../PlatformWalletPersistenceHandlerTest.kt | 113 +++++++++++++ .../src/sqlite/schema/core_state.rs | 95 ++++++++++- .../tests/sqlite_transaction_sweeps.rs | 140 ++++++++++++++++ .../PlatformWalletPersistenceHandler.swift | 85 +++++++++- .../SweptTransactionPersistTests.swift | 157 ++++++++++++++++++ 6 files changed, 662 insertions(+), 14 deletions(-) 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 9b4231f75bd..f75bb977531 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 @@ -972,11 +972,29 @@ class PlatformWalletPersistenceHandler( // 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). + // + // The LINK is guarded, not last-writer-wins. `spendingTxid` + // is this store's spend attribution, and the sweep release + // pass trusts it: `holdSpentWithoutSpender` detaches rows + // by it and `releaseByOutpoint` frees only detached rows. + // 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 would name the coin released. + // With the link intact the hold pass never detaches the + // row and the release is refused; with it stolen, the + // provably consumed coin reads unspent after the next + // restart — a guaranteed double spend. The one legitimate + // theft is DIP-10 precedence: a chainlocked arrival may + // take a coin from a spender that was only IS-locked. + val keepExistingLink = keepSettledSpenderLink(db, txo, txid, context) db.txoDao().upsert( txo.copy( isSpent = txo.isSpent || context >= CONTEXT_IN_BLOCK, - spendingTxid = txid, - spendingInputIndex = i, + spendingTxid = if (keepExistingLink) txo.spendingTxid else txid, + spendingInputIndex = if (keepExistingLink) txo.spendingInputIndex else i, lastUpdated = now(), ), ) @@ -1171,10 +1189,27 @@ class PlatformWalletPersistenceHandler( // until the winner reaches a block. val spending = db.transactionDao().getByTxid(spendingTxid) val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + // Same link guard as `onWalletChangesetTransaction`: a + // network-final spender's attribution is what the sweep release + // pass trusts, so it is never stolen by a conflicting later + // spend. Unreachable on this channel today — upstream only + // emits utxos_spent for inputs it classified against a + // still-present funding UTXO, which a settled spend has already + // consumed — but the invariant is cheap to hold here and does + // not depend on that classification detail staying true. + val keepExistingLink = + keepSettledSpenderLink(db, txo, spendingTxid, spending?.context ?: 0) db.txoDao().upsert( txo.copy( - spendingTxid = if (spending != null) spendingTxid else txo.spendingTxid, - isSpent = if (spending != null) { + spendingTxid = if (spending != null && !keepExistingLink) { + spendingTxid + } else { + txo.spendingTxid + }, + // When the settled link is kept, the `isSpent` gate is + // not re-answered from the usurper either — the whole + // claim is dropped, not just the link write. + isSpent = if (spending != null && !keepExistingLink) { spentInBlock || txo.supersededByTxid != null } else { txo.isSpent @@ -1483,6 +1518,43 @@ class PlatformWalletPersistenceHandler( 0 } + /** + * Whether [txo]'s existing `spendingTxid` link must survive an arriving + * record ([newSpendingTxid], at [newContext]) that also claims the + * outpoint. See the guard's call site in `onWalletChangesetTransaction` + * for why a network-final spender's link is load-bearing: it is the only + * attribution the sweep release pass consults, 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, has not been + * globally swept (a swept spender's claims were resolved by its own + * sweep — its link is dead weight a new spend may legitimately replace), + * and is network-final: IS-locked, in-block, or chainlocked + * (context >= [CONTEXT_INSTANT_SEND]). Two mempool spenders keep + * last-writer-wins, as before — 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. + */ + 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.isGloballySwept) return false + if (existing.context < CONTEXT_INSTANT_SEND) return false + val chainlockOverIsLock = + newContext >= CONTEXT_CHAIN_LOCKED && existing.context == CONTEXT_INSTANT_SEND + return !chainlockOverIsLock + } + /** * Whether some wallet other than [walletId] still has a TXO or pending * input pointing at [txid] as its spender, after this call's own @@ -3722,9 +3794,15 @@ class PlatformWalletPersistenceHandler( private const val TAG = "DashPersistence" + /** `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 + /** `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 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 2042882537a..600c0168626 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 @@ -2343,6 +2343,119 @@ class PlatformWalletPersistenceHandlerTest { 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 hold pass never detaches F's coin and + // `releaseByOutpoint` 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. + 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, + ), + ) + + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransaction( + 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) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), + arrayOf(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 backstop for rows written before holds named their winner: a diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 4c9785348e7..ac5f630c56f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -184,15 +184,50 @@ pub fn apply( .flat_map(|record| record.transaction.input.iter()) .map(|input| input.previous_output) .collect(); + // The changeset is not the whole answer, though. Upstream computes + // `released_outpoints` from its *live* records, and under the default + // `keep-finalized-transactions = off` a chainlocked record is pruned to + // its bare txid — the pinned `TransactionsSwept::released_outpoints` + // doc records this exact limitation ("the inputs of a pruned record + // survive nowhere else, so this cannot be resolved at this layer"). + // It CAN be resolved at this layer: this store never prunes a + // `core_transactions` row on finalization, so the full input set of + // every settled spend the wallet has forgotten is still on disk. A + // release naming a coin such a record still claims is upstream + // reporting its own amnesia — honouring it flips the materialized UTXO + // to `spent = 0` and hands a provably consumed coin back as spendable + // after the next load, a guaranteed double spend. The same applies + // after a restart for records of ANY context: hydration rebuilds the + // in-memory wallet without its transaction history, so every claim the + // store holds is one upstream can no longer see. + // + // `stored_input_claims` is therefore upstream's own `retain_unclaimed` + // predicate — "drop outpoints some surviving record still spends" — + // re-evaluated against the unpruned history. Built lazily and at most + // once per round: only a batch whose released set survives the + // in-round filter above pays for it, and the common sweep (a resend + // whose winner spends every input its loser did) releases nothing. + let mut stored_claims: Option> = None; for batch in &cs.sweeps { // Only this stays per batch: a release is true of the wallet its own // sweep saw, which is what lets a later batch correct an earlier one. - let released: HashSet = batch + let mut released: HashSet = batch .released_outpoints .iter() .filter(|outpoint| !claimed_by_survivors.contains(outpoint)) .copied() .collect(); + if !released.is_empty() { + let claims = match stored_claims.as_ref() { + Some(claims) => claims, + None => { + stored_claims = + Some(surviving_stored_input_claims(tx, wallet_id, &swept_txids)?); + stored_claims.as_ref().expect("just assigned") + } + }; + released.retain(|outpoint| !claims.contains(outpoint)); + } for loser_txid in &batch.txids { apply_sweep( tx, @@ -253,6 +288,64 @@ pub fn apply( Ok(()) } +/// The union of every input outpoint claimed by a surviving +/// `core_transactions` row — every row except this round's swept losers, +/// whose deletion the round itself performs. +/// +/// This is the durable mirror of upstream's `retain_unclaimed` claimed-set, +/// with one decisive difference: it includes records the in-memory wallet +/// has pruned (chainlocked, under the default +/// `keep-finalized-transactions = off`) or lost across a restart. Rows of +/// EVERY context count as claimants on purpose. A mined or chainlocked +/// record's spend is settled and may never be re-freed; an InstantSend- +/// locked record's inputs are settled under DIP-10 the moment the lock +/// lands; and a plain mempool record is a claim upstream itself would have +/// retained had it still held the record — refusing matches what +/// `retain_unclaimed` computes from an unpruned, unrestarted wallet, no +/// more and no less. In-session a live claimant never appears in a released +/// set anyway (upstream filters it), so any hit here is by construction a +/// claim upstream forgot. +/// +/// One pass over the wallet's rows, decoding each blob once — the same +/// build-the-set-then-probe shape (and rationale) as upstream's +/// `retain_unclaimed`: released sets follow the input count of a +/// transaction a remote peer picks, so probing per candidate would be +/// `O(released × history)` instead. The pass itself is `O(history)` blob +/// decodes, paid only by a round whose sweep actually frees candidate +/// coins — rare organically, and an attacker can only force one per +/// on-chain final transaction they pay for. +fn surviving_stored_input_claims( + tx: &Transaction<'_>, + wallet_id: &WalletId, + swept_txids: &HashSet, +) -> Result, WalletStorageError> { + use dashcore::hashes::Hash; + + let mut stmt = + tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1")?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut claims: HashSet = HashSet::new(); + while let Some(row) = rows.next()? { + let txid_bytes: Vec = row.get(0)?; + let Ok(txid_array) = <[u8; 32]>::try_from(txid_bytes.as_slice()) else { + continue; + }; + if swept_txids.contains(&dashcore::Txid::from_byte_array(txid_array)) { + continue; + } + let blob_bytes: Vec = row.get(1)?; + let record: TransactionRecord = blob::decode(&blob_bytes)?; + claims.extend( + record + .transaction + .input + .iter() + .map(|input| input.previous_output), + ); + } + Ok(claims) +} + /// Delete a swept transaction's row and outputs, then resolve the coins it /// claimed to spend. /// diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 17fc5e088ca..284a2a909f7 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -500,6 +500,146 @@ fn a_released_coin_a_surviving_record_reclaims_stays_spent() { ); } +/// The reviewer scenario for the pruned-finalized-release defect: upstream +/// computes `released_outpoints` from its live records, and a chainlocked +/// spender F is pruned to a bare txid under the default +/// `keep-finalized-transactions = off` — so a loser L that arrived after the +/// pruning, reusing F's input alongside an attacker-owned one, reports F's +/// input as released when a final W beats L on the attacker input. The +/// pinned `TransactionsSwept` doc calls this unresolvable at its layer; this +/// store is the layer that CAN resolve it, because F's full record survives +/// in `core_transactions`. The release must be refused for F's coin — and +/// still honoured for a coin only the swept loser claimed, in the same +/// batch, or the guard would strand legitimately freed money. +#[test] +fn a_release_naming_a_coin_a_stored_finalized_record_claims_is_refused() { + use key_wallet::transaction_checking::transaction_context::BlockInfo; + + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x09); + let funding_txid = Txid::from_byte_array([0x90; 32]); + // F's coin — consumed on chain, must never come back. + let settled_coin = OutPoint::new(funding_txid, 0); + // Claimed only by the loser — must come free. + let losers_own_coin = OutPoint::new(funding_txid, 1); + // Attacker-owned input shared by L and W — never ours, no row. + let attacker_input = OutPoint::new(Txid::from_byte_array([0x9A; 32]), 0); + + let finalized_txid = Txid::from_byte_array([0x91; 32]); + let loser_txid = Txid::from_byte_array([0x92; 32]); + let winner_txid = Txid::from_byte_array([0x93; 32]); + + // F: chainlocked spender of `settled_coin`. Upstream keeps only its + // txid from here on; this row keeps everything. + let mut finalized = tx_record( + finalized_txid, + vec![settled_coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + finalized.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([0x9B; 32]), + 1_735_689_600, + )); + + // L: arrives after F's pruning, pays this wallet, reuses F's input and + // the attacker's. + let loser = tx_record( + loser_txid, + vec![settled_coin, attacker_input, losers_own_coin], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 400), + make_utxo(&addr, funding_txid, 1, 600), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![finalized], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 600)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // W (final) conflicts with L on the attacker input alone. Upstream's + // release set — computed from live records that no longer include F — + // wrongly names F's coin alongside the loser's own. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![settled_coin, losers_own_coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "a released coin a stored finalized record still claims must stay spent" + ); + assert!( + visible.contains(&losers_own_coin), + "a coin only the swept loser claimed must still come free" + ); + + drop(conn); + drop(persister); + + // Restart: the guard's verdict must be what a relaunch loads — this is + // exactly where the unguarded release manufactured the double spend. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "the refused release must hold across a restart" + ); + assert!( + visible.contains(&losers_own_coin), + "the honoured release must hold across a restart" + ); +} + /// A chainlocked winner may evict an InstantSend-locked loser, so a swept /// transaction can own a row in `core_instant_locks`. Nothing ties that table /// to `core_transactions`, so the lock has to be deleted explicitly or it diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index ea1989a22d4..4c56b11d06b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2128,6 +2128,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 @@ -2160,19 +2198,31 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // restore set until the winner reaches a block. Flips to // false stay with the paths that own them: the sweep // release pass and `upsertUtxo`'s recovery clear. + // The LINK is guarded, not last-writer-wins — see + // `settledSpenderLinkIsKept` for why a network-final + // spender's attribution must survive a conflicting later + // record. `isSpent` stays monotonic either way. + let keepExistingLink = Self.settledSpenderLinkIsKept( + existing: txo.spendingTransaction, + newTxid: spendingTxid, + newContext: spendingTransaction.context + ) let expectedIsSpent = txo.isSpent || Self.spendIsInBlock(spendingTransaction) let linkageChanged = txo.isSpent != expectedIsSpent - || txo.spendingTransaction?.txid != spendingTxid - || txo.spendingInputIndex != inputIndex + || (!keepExistingLink + && (txo.spendingTransaction?.txid != spendingTxid + || txo.spendingInputIndex != inputIndex)) if linkageChanged { txo.isSpent = expectedIsSpent - if txo.spendingTransaction?.txid != spendingTxid { - txo.spendingTransaction = spendingTransaction + if !keepExistingLink { + 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 } - // Capture the canonical vin index so the detail - // view can render inputs in serialized order. - txo.spendingInputIndex = inputIndex txo.lastUpdated = Date() } // A pending entry from an earlier write is now stale — @@ -2459,9 +2509,26 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if txo.spendingTransaction?.txid == spendingTxid { spendingTx = txo.spendingTransaction } else { - spendingTx = fetchTransactionRow(txid: spendingTxid) - if let spending = spendingTx { + // Same link guard as `resolveInputOutpoint`: a + // network-final spender's attribution is what the sweep + // release pass trusts, so it is never stolen by a + // conflicting later spend — and the `isSpent` gate below + // must not be re-answered from the usurper either, so the + // whole claim is dropped, not just the link write. + // Unreachable on this channel today — upstream only emits + // `utxos_spent` for inputs it classified against a + // still-present funding UTXO, which a settled spend has + // already consumed — but the invariant is cheap to hold + // here and does not depend on that classification detail + // staying true. + if let spending = fetchTransactionRow(txid: spendingTxid), + !Self.settledSpenderLinkIsKept( + existing: txo.spendingTransaction, + newTxid: spendingTxid, + newContext: spending.context + ) { txo.spendingTransaction = spending + spendingTx = spending } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift index d83f77db157..8993144e0bc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -549,6 +549,163 @@ final class SweptTransactionPersistTests: XCTestCase { _ = 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 From 612d1d93c090e99c8e371604b760834a334f0b5c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:44:50 +0300 Subject: [PATCH 101/102] fix(platform-wallet-storage): harden the stored-claims veto - settled spenders only, fail closed Two corrections to surviving_stored_input_claims, the release guard introduced by the previous commit. The veto is now settled-evidence only: a claimant row counts when its record is network-final (IS-locked, in-block, or chainlocked), never when it is a bare mempool record. A mempool row is the one context that can go stale forever - an evicted or abandoned mempool transaction has no removal path in this store other than a later sweep (upstream's abandon path emits no events, dashpay/rust-dashcore#976), and restoration deliberately does not repopulate ordinary history - so a stale claimant surviving a restart would veto an authoritative release, attribute the coin to an unrelated winner, and strand it durably spent: the mirror image of the wrong-release bug the veto exists to stop. This is also the rule the mobile stores already implement (their link guard protects a network-final spender and lets a mempool link be replaced), so the three backends now agree. A live mempool claim loses nothing: in-session upstream holds the record and never names its inputs released, and within the round claimed_by_survivors carries the changeset's own records of every context. The accepted trade: after a restart a still-alive mempool claimant no longer vetoes, so the coin may be transiently re-offered while that pending spend races - self-resolving, and strictly better than a permanent strand. The scan also fails CLOSED now. It is the final guard against re-crediting a consumed coin, so a corrupt claimant row must fail the round rather than silently losing its veto: a core_transactions.txid of the wrong length and a record blob whose decoded txid disagrees with its typed key (the key is what excludes a row as a swept loser) are both BlobDecode errors, matching the other typed-column readers. Both behaviors carry regressions run red against the pre-fix code: a stale mempool claimant followed by a restart and an authoritative release (the coin must come free), and a corrupt claimant key in a sweep round with release candidates (the round must roll back). --- .../src/sqlite/schema/core_state.rs | 66 ++++-- .../tests/sqlite_transaction_sweeps.rs | 203 ++++++++++++++++++ 2 files changed, 254 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index ac5f630c56f..a1ab1a15696 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -197,9 +198,12 @@ pub fn apply( // reporting its own amnesia — honouring it flips the materialized UTXO // to `spent = 0` and hands a provably consumed coin back as spendable // after the next load, a guaranteed double spend. The same applies - // after a restart for records of ANY context: hydration rebuilds the - // in-memory wallet without its transaction history, so every claim the - // store holds is one upstream can no longer see. + // after a restart for every NETWORK-FINAL record (IS-locked, in-block, + // chainlocked): hydration rebuilds the in-memory wallet without its + // transaction history, so every settled claim the store holds is one + // upstream can no longer see. Bare mempool rows are deliberately not + // part of the veto — see `surviving_stored_input_claims` for why a + // stale one must not strand a legitimately released coin. // // `stored_input_claims` is therefore upstream's own `retain_unclaimed` // predicate — "drop outpoints some surviving record still spends" — @@ -295,16 +299,36 @@ pub fn apply( /// This is the durable mirror of upstream's `retain_unclaimed` claimed-set, /// with one decisive difference: it includes records the in-memory wallet /// has pruned (chainlocked, under the default -/// `keep-finalized-transactions = off`) or lost across a restart. Rows of -/// EVERY context count as claimants on purpose. A mined or chainlocked -/// record's spend is settled and may never be re-freed; an InstantSend- -/// locked record's inputs are settled under DIP-10 the moment the lock -/// lands; and a plain mempool record is a claim upstream itself would have -/// retained had it still held the record — refusing matches what -/// `retain_unclaimed` computes from an unpruned, unrestarted wallet, no -/// more and no less. In-session a live claimant never appears in a released -/// set anyway (upstream filters it), so any hit here is by construction a -/// claim upstream forgot. +/// `keep-finalized-transactions = off`) or lost across a restart. +/// +/// Only NETWORK-FINAL claimants count: InstantSend-locked (settled under +/// DIP-10 the moment the lock lands), in-block, or chainlocked. A bare +/// `Mempool` row is deliberately not settled-spend evidence, because it is +/// the one context that can go stale forever: an evicted or abandoned +/// mempool transaction has no removal path in this store other than a later +/// sweep (upstream's abandon path emits no events — +/// dashpay/rust-dashcore#976), and restoration deliberately does not +/// repopulate ordinary transaction history, so nothing ever re-asserts or +/// retracts the row. Letting it veto an authoritative release would leave +/// the coin attributed to an unrelated winner and durably spent — the +/// mirror image of the wrong-release bug this guard exists to stop. This is +/// also exactly the mobile stores' rule: their link guard protects a +/// network-final spender's link and lets a mempool link be replaced. A LIVE +/// mempool claim loses nothing here: in-session upstream holds the record +/// and never names its inputs released, and within the round +/// `claimed_by_survivors` carries the changeset's own mempool records. The +/// one accepted trade: after a restart a still-alive mempool claimant on +/// disk no longer vetoes, so the release wins and the coin may be +/// transiently re-offered while that pending spend races — self-resolving +/// when the pending spend confirms or dies, and strictly better than a +/// permanent strand. +/// +/// Fails CLOSED. This scan is the final guard against re-crediting a +/// consumed coin, so a malformed stored key must fail the round rather than +/// silently drop that row's veto: a `txid` column of the wrong length and a +/// record blob whose decoded `TransactionRecord::txid` disagrees with the +/// typed key (the key is what excludes a row as a swept loser) are both +/// `BlobDecode` errors, matching the other typed-column readers. /// /// One pass over the wallet's rows, decoding each blob once — the same /// build-the-set-then-probe shape (and rationale) as upstream's @@ -328,13 +352,25 @@ fn surviving_stored_input_claims( while let Some(row) = rows.next()? { let txid_bytes: Vec = row.get(0)?; let Ok(txid_array) = <[u8; 32]>::try_from(txid_bytes.as_slice()) else { - continue; + return Err(WalletStorageError::blob_decode( + "core_transactions.txid must be exactly 32 bytes", + )); }; - if swept_txids.contains(&dashcore::Txid::from_byte_array(txid_array)) { + let key_txid = dashcore::Txid::from_byte_array(txid_array); + if swept_txids.contains(&key_txid) { continue; } let blob_bytes: Vec = row.get(1)?; let record: TransactionRecord = blob::decode(&blob_bytes)?; + if record.txid != key_txid { + return Err(WalletStorageError::blob_decode( + "core_transactions.txid disagrees with the decoded record's txid", + )); + } + + if matches!(record.context, TransactionContext::Mempool) { + continue; + } claims.extend( record .transaction diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index 284a2a909f7..5df50fd43d2 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -640,6 +640,209 @@ fn a_release_naming_a_coin_a_stored_finalized_record_claims_is_refused() { ); } +/// The stored-claims veto is settled-evidence only: a bare mempool row must +/// not outrank an authoritative release. A mempool record is the one context +/// that can go stale forever — an evicted or abandoned mempool transaction +/// has no removal path in this store other than a later sweep, and +/// restoration does not repopulate ordinary history — so a stale claimant +/// surviving a restart must not veto the release of a coin a later loser +/// claimed, or the coin is attributed to an unrelated winner and stranded +/// durably spent: the mirror image of the wrong-release bug the veto exists +/// to stop. +#[test] +fn a_stale_mempool_claimant_does_not_veto_an_authoritative_release() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0A); + let funding_txid = Txid::from_byte_array([0xA0; 32]); + let coin = OutPoint::new(funding_txid, 0); + + let stale_txid = Txid::from_byte_array([0xA1; 32]); + let loser_txid = Txid::from_byte_array([0xA2; 32]); + let winner_txid = Txid::from_byte_array([0xA3; 32]); + + // M: a mempool spend of the coin, marked spent when recorded. It is + // then evicted from the network's mempool without the wallet ever + // hearing — its row simply goes stale. + let stale = tx_record( + stale_txid, + vec![coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![stale], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Restart: upstream's memory of M is gone for good; only the stale row + // remains. + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let mut conn = persister.lock_conn_for_test(); + + // A fresh loser claims the same coin, and an authoritative sweep later + // frees it — upstream's word, computed from the wallet it actually + // holds. + let loser = tx_record( + loser_txid, + vec![coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + unspent(&conn, &w).contains(&coin), + "a stale mempool claimant must not veto an authoritative release" + ); +} + +/// The stored-claims scan is the final guard against re-crediting a +/// consumed coin, so corrupt claimant rows fail the round instead of +/// silently losing their veto: a wrong-length `txid` key and a record blob +/// whose decoded txid disagrees with its typed key are both `BlobDecode` +/// errors. +#[test] +fn a_corrupt_stored_claimant_fails_the_sweep_round_closed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xEB); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0B); + let funding_txid = Txid::from_byte_array([0xB0; 32]); + let coin = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0xB1; 32]); + let winner_txid = Txid::from_byte_array([0xB2; 32]); + + let loser = tx_record( + loser_txid, + vec![coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // A claimant row whose typed key is not 32 bytes. The blob is a real, + // decodable record so the failure is attributable to the key alone. + let honest = tx_record(Txid::from_byte_array([0xB3; 32]), vec![coin], Vec::new()); + let honest_blob = blob::encode(&honest).unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![w.as_slice(), &[0xB3u8; 31][..], &honest_blob[..]], + ) + .unwrap(); + + let sweep_cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + }], + ..Default::default() + }; + { + let tx = conn.transaction().unwrap(); + let err = core_state::apply(&tx, &w, &sweep_cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a wrong-length claimant key must fail the round closed, got {err:?}" + ); + } + + // Repair the key length but leave it disagreeing with the record's own + // txid — the typed key decides swept-loser exclusion, so the mismatch + // must fail too. + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND length(txid) = 31", + params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![w.as_slice(), &[0xB4u8; 32][..], &honest_blob[..]], + ) + .unwrap(); + { + let tx = conn.transaction().unwrap(); + let err = core_state::apply(&tx, &w, &sweep_cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a key/record txid mismatch must fail the round closed, got {err:?}" + ); + } +} + /// A chainlocked winner may evict an InstantSend-locked loser, so a swept /// transaction can own a row in `core_instant_locks`. Nothing ties that table /// to `core_transactions`, so the lock has to be deleted explicitly or it From 4fee0e8cca6bde6f3aca34e64ba26518151421e5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:45:19 +0300 Subject: [PATCH 102/102] docs(platform-wallet): align the sweep-batch contract with ordered, unstamped-hold semantics Four comments still described the pre-1505912fbc design and, on the public JNI surface, misstated the invocation shape. The NativePersistenceBridge KDoc said the sweeps callback fires once for the round and that winnerMinedHeight == -1 means no height, no tombstone; JNI actually invokes it once per sweep batch in emission order - non-commutative, a later batch can keep spent a coin an earlier one freed - and a -1 winner retains every unresolved non-released input as a durable UNSTAMPED tombstone that no collector removes. An out-of-tree backend following the old words could collapse batches or drop the only cross-restart hold while still attesting CORE_SWEEP_REMOVAL. The matching stale comments in the JNI emitter (rs-unified-sdk-jni), the Swift handler's winner-context note, and DocumentDao's collector doc (which still called unstamped rows legacy data no current writer produces) now state the same contract: the stamp keys a hold's lifetime, never its existence, and an unstamped hold resolves only through proof - funding arrival, a later block-context re-stamp, or a release. --- .../dashsdk/ffi/NativePersistenceBridge.kt | 28 +++++++++++++++---- .../dashsdk/persistence/dao/DocumentDao.kt | 13 ++++++--- .../rs-unified-sdk-jni/src/persistence.rs | 9 ++++-- .../PlatformWalletPersistenceHandler.swift | 18 +++++++----- 4 files changed, 48 insertions(+), 20 deletions(-) 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 ba7c5b60329..28076a6871c 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 @@ -308,18 +308,34 @@ abstract class NativePersistenceBridge { open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 /** - * Transactions the wallet removed this round, as raw 32-byte txids, - * each paired by index with the transaction that settled its inputs, - * plus the outpoints the removals actually freed. Fired once after the - * per-account decomposition, and only when the round swept something. + * Transactions the wallet removed in one sweep batch, as raw 32-byte + * txids, each paired by index with the transaction that settled its + * inputs, plus the outpoints this batch actually freed. Invoked once + * PER BATCH, in the round's emission order, after the per-account + * decomposition and only when the round swept something. The order is + * load-bearing, not cosmetic: 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 never fold calls together or reorder them. * Descriptor `([B[[B[[B[[BI)I`. * * [winnerMinedHeight] is the winner's own mined block height for a * block-context sweep, or -1 for an InstantSend-locked winner not yet * mined (the sentinel is unambiguous — block heights are * non-negative — and the handler maps it back to null). It keys the - * whole lifetime rule of a pending-input tombstone: no height, no - * tombstone. + * 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 null 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 a -1 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 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 b9b695cbadf..2c8293ae2ee 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 @@ -250,10 +250,15 @@ interface DocumentDao { * 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. Unstamped - * rows are never collected — no current writer produces one (a - * mempool-context sweep creates no tombstone, an IS-locked re-point - * keeps the existing stamp), so an unstamped row is legacy or foreign - * data and holding it forever is the safe reading. + * 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 " + diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index e5977af4f49..83af9f86d03 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -728,9 +728,12 @@ unsafe fn persist_changeset_sweep_batch( // The winner's finality context: its mined height for a block-context // sweep, -1 for an InstantSend-locked winner still waiting to be mined. // The sentinel is unambiguous — block heights are non-negative — and - // the Kotlin bridge maps it back to null. The handler keys the whole - // lifetime rule of a pending-input tombstone on it: no height, no - // tombstone. + // the Kotlin bridge maps it back to null. 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. let winner_mined_height: i32 = if batch.has_winner_mined_height { batch.winner_mined_height as i32 } else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c56b11d06b..960fbd6e11e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1170,13 +1170,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // The winner's finality context, carried on the batch // itself: `nil` means the winner is InstantSend-locked and - // NOT yet mined (upstream's only other sweep trigger), and - // such a sweep must leave no durable placeholder — this is - // the projection of key-wallet's `observed_spent_outpoints`, - // which records nothing for an unconfirmed spend. A present - // height is the winner's OWN mined block, the stamp every - // tombstone this batch writes (or re-points) carries and - // the collector's whole lifetime rule. + // NOT yet mined (upstream's only other sweep trigger). It + // keys a tombstone's LIFETIME, never its existence — every + // non-released input keeps a durable claim in either + // context. A present height is the winner's OWN mined + // block, the stamp that makes the hold collectible at the + // chainlock finality boundary; `nil` leaves the SAME hold + // unstamped, which no collector may remove — an IS-locked + // winner has no mining deadline, so an unstamped hold + // resolves only through proof: the funding TXO drains it, + // a later block-context sweep re-stamps it, or a release + // deletes it. let winnerMinedHeight: UInt32? = batch.has_winner_mined_height ? batch.winner_mined_height : nil