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 01/95] 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 02/95] 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 03/95] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20kee?= =?UTF-8?q?p=20the=20winner's=20coin=20spent,=20fail=20a=20sweep=20that=20?= =?UTF-8?q?throws?= 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 04/95] 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 05/95] 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 06/95] 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 07/95] 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 08/95] 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 09/95] 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 10/95] 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 11/95] 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 12/95] 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 13/95] 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 14/95] 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 15/95] 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 16/95] 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 17/95] 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 18/95] 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 19/95] 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 20/95] 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 21/95] 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 22/95] 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 23/95] 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 24/95] 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 25/95] 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 26/95] 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 27/95] 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 28/95] 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 29/95] 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 30/95] 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 31/95] 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 32/95] 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 33/95] 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 34/95] 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 35/95] 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 36/95] 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 37/95] 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 38/95] 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 39/95] 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 40/95] 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 41/95] 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 42/95] 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 43/95] 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 44/95] 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 45/95] 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 46/95] 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 47/95] 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 48/95] 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 49/95] 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 50/95] 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 51/95] 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 52/95] 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 53/95] 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 54/95] 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 55/95] 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 56/95] 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 57/95] 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 58/95] 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 59/95] 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 60/95] 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 61/95] 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 62/95] 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 63/95] 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 64/95] 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 65/95] 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 66/95] 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 67/95] 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 68/95] 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 69/95] 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 70/95] 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 71/95] 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 72/95] 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 73/95] 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 74/95] 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 75/95] 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 76/95] 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 77/95] 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 78/95] 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 79/95] 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 80/95] 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 81/95] 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 82/95] 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 83/95] 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 84/95] 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 85/95] 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 86/95] 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 87/95] 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 88/95] 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 89/95] 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 90/95] 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 91/95] 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 92/95] 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 741e4b48e9d4ff13af8b632f33cdc52fa95700cb Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:34:22 +0300 Subject: [PATCH 93/95] fix(platform-wallet): couple a sweep's payment flips to their own persistence round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reattach the bit-11 behavioral block extracted from the sweep core PR, byte-identical to how it was reviewed there: SweptPaymentFlips / PaymentFlipUndo and the evidence-classed resolve_sent_payment_by_txid with the shared sent-status transition table in payments.rs; the wallet-event adapter's flip staging with same-fold retraction (retract_reinstated_payment_flips), the rollback ledger and rejected-wallet replay, cross-drain re-validation under the manager read lock (commit_batch_with_payment_revalidation / retract_superseded_payment_flips) and WalletBatch::payments_overlay; the adapter-owned reinstatement confirmation riding the reinstating record's round; and the ROUND_COUPLED_PAYMENT_FLIPS composite (DASHPAY_PAYMENTS | ATOMIC_CHANGESETS) that gates all staging. A backend failing the composite degrades to a payments-blind host: the in-memory flip still happens with nothing round-coupled — funds-safe, since payment entries are display metadata and the funds-critical half gates on CORE_SWEEP_REMOVAL in the base PR. 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, 2465 insertions(+), 58 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 72e7b62299b..4df89b52211 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; @@ -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 { @@ -300,6 +305,33 @@ 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 @@ -320,6 +352,14 @@ 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(); @@ -329,9 +369,32 @@ 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, @@ -345,9 +408,36 @@ 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, @@ -360,15 +450,41 @@ 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 + // 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. @@ -432,7 +548,10 @@ where } /// Commit one wallet's folded changeset — the per-wallet unit of -/// [`commit_batch`]. +/// [`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, @@ -447,6 +566,7 @@ fn commit_wallet

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

( 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. @@ -511,6 +634,9 @@ 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) { @@ -555,6 +681,9 @@ 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, @@ -635,6 +764,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 @@ -725,6 +868,357 @@ 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( @@ -2289,6 +2783,8 @@ 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, } @@ -2354,6 +2850,24 @@ 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 { @@ -3245,6 +3759,1032 @@ 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 @@ -3579,6 +5119,7 @@ mod tests { super::WalletBatch { core: CoreChangeSet::default(), asset_locks, + payments_overlay: BTreeMap::new(), }, ); commit_batch( @@ -3637,6 +5178,7 @@ 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 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] 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..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,8 +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, - record_incoming_dashpay_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, }; 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 e5ec9441040..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 @@ -256,9 +256,10 @@ 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, 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. + // 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 { .. } @@ -285,8 +286,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 belongs on the - // wallet-event adapter's own store round — see `dashpay_payment_records`. + // 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, @@ -294,14 +295,15 @@ 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` 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. +/// 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>>, wallet_id: &WalletId, @@ -477,12 +479,12 @@ mod tests { } /// `TransactionsSwept` must NOT drive the payment hooks: its payment - /// consequence — failing the losers' `Pending` sent payments — belongs - /// on the wallet-event adapter's own atomic store round, because a - /// sweep never re-emits once its round is durable and a separately - /// persisted flip that failed its store would be lost for good. - /// Spawning a hook task here would race a second write against that - /// round. + /// 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_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 c084ea667a1..f3ff2545470 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,57 +978,466 @@ 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: /// -/// 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`. +/// - **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). 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 - // `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 => { + // 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(); - 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::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; + 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); } + Ok(()) } // --------------------------------------------------------------------------- @@ -1519,6 +1940,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 { @@ -1527,6 +1951,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(()) } @@ -2864,7 +3295,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, @@ -2876,13 +3314,396 @@ 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 ); } + /// 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 51b9bb92e76629a749c8531ad750f60be615245d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:29:48 +0300 Subject: [PATCH 94/95] fix(platform-wallet): apply sent-payment verdicts in emission order on the wallet-event adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DashPay payment hooks confirmed sent payments from independently spawned tasks off dash-spv's bounded, lossy event broadcast, so execution order did not preserve emission order. Upstream permits a chainlocked transaction to evict an IS-locked conflict, so a pre-sweep confirmation task delayed past the newer sweep flipped the durable Failed verdict back to Confirmed (LIVE_CONFIRM_EVIDENCE admitted Failed), and one that ran just before the sweep staged made the terminal-state check skip the failure — either way a dead payment ended durably Confirmed with no later sweep re-emission to repair it. Route every sent-payment verdict through the adapter's single ordered drain of the lossless persistence channel instead: - confirm_final_sent_payments_for_store generalizes the reinstatement-only confirm to ALL finality evidence (final records, and TransactionInstantLocked by txid), staging Pending/Failed -> Confirmed on the event's own store round; the hooks now only record incoming payments (idempotent inserts with no state machine to race). - flip_swept_sent_payments_for_store gains Confirmed -> Failed: with writers ordered, every Confirmed visible at sweep-fold time was written from evidence the sweep postdates, and upstream never sweeps a currently final record, so the newer sweep verdict must win. - sent_status_transition_allowed enumerates its legal edges explicitly (review suggestion): the (Pending, _) wildcard admitted Pending -> Pending and would silently admit any future PaymentStatus variant. - The commit-stage re-validation (commit_batch_with_payment_revalidation / retract_superseded_payment_flips) is removed as redundant: its only purpose was dropping staged rows an unordered hook had outrun, and no such writer remains — the reconcile pass, the one off-adapter confirmer, persists memory-and-store atomically under the manager write lock with Pending-only evidence, so staged Failed/Confirmed rows cannot be superseded between fold and store. Regressions cover both reviewed interleavings by parking a pre-sweep IS-lock confirmation hook until the newer sweep is durable and then releasing it, plus the same-fold [IS-lock, Swept] case committing only the newer verdict, and an exhaustive transition-table test. --- .../src/changeset/core_bridge.rs | 822 +++++++++--------- .../rs-platform-wallet/src/manager/mod.rs | 8 +- .../src/wallet/identity/network/mod.rs | 7 +- .../identity/network/payment_handler.rs | 167 ++-- .../src/wallet/identity/network/payments.rs | 755 +++++++++------- 5 files changed, 928 insertions(+), 831 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 4df89b52211..181e13f2cfb 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; @@ -380,20 +380,20 @@ async fn run_wallet_event_adapter

( ) .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( + // very event reads `Pending` and the ordered confirm below + // takes it straight to `Confirmed` on this event's round. + let confirm_flips = + crate::wallet::identity::network::confirm_final_sent_payments_for_store( &wallet_manager, &wallet_id, - &core.records, + &event, ) .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_payment_flips(entry, &mut payment_rollbacks, wallet_id, confirm_flips); } } @@ -418,25 +418,21 @@ 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( + // See the first-fold site: after the retraction, so a + // same-fold-swept entry reads `Pending` for the + // ordered confirm. + let confirm_flips = + crate::wallet::identity::network::confirm_final_sent_payments_for_store( &wallet_manager, &wallet_id, - &core.records, + &event, ) .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_payment_flips(entry, &mut payment_rollbacks, wallet_id, confirm_flips); } folded += 1; } @@ -450,22 +446,35 @@ 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`). + // + // No commit-time re-validation of the staged payment rows is + // needed, and none is done: every sent-payment verdict writer + // either IS this task (the sweep flip and the ordered confirm + // above, applied in emission order and staged into this very + // batch, where a later flip's row overwrites an earlier one per + // `(owner, txid)`), or persists memory-and-store atomically under + // a continuous hold of the manager WRITE lock with `Pending`-only + // evidence (the reconcile pass via `resolve_sent_payment_by_txid` + // → `record_dashpay_payment`). A staged row always asserts + // `Failed` or `Confirmed`, so between this drain's fold and its + // store no other writer can move the entry the row describes — + // the reconcile pass's evidence excludes both states — and its + // write-lock hold across its own store means the store order of + // the two writers matches their memory order. When verdicts also + // ran on the EventHandler broadcast (unordered spawned tasks + // persisting on their own rounds), a commit-time re-validation + // under a held read lock was required here to drop staged rows a + // hook had outrun; routing every verdict through this drain is + // what retired it. + 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 @@ -548,10 +557,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, @@ -764,16 +770,19 @@ 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. + /// Sent DashPay payment verdicts this fold's events produced — a + /// sweep's `Failed` rows and the ordered confirm's `Confirmed` rows — + /// riding the SAME `store()` as the events that proved them. This is + /// a flip's only durability: a sweep never re-emits once its round is + /// durable, and a chainlocked reinstatement can be a one-shot, so a + /// separately persisted flip whose store failed was lost for good — + /// while here a rejection keeps the proving event's rows with it, the + /// wallet faults, the re-scan re-emits the event, and the replay + /// 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 — which + /// is also what makes a fold containing both a confirmation and a + /// later eviction commit only the newer verdict. payments_overlay: std::collections::BTreeMap< dpp::prelude::Identifier, std::collections::BTreeMap, @@ -868,11 +877,12 @@ 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. +/// The payment half of a sweep: flip the losers' sent DashPay payments +/// (`Pending` — or `Confirmed`, when this sweep postdates the entry's +/// confirmation) 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 rather than persisting on its own. async fn swept_payment_flips_for_event( wallet_manager: &Arc>>, event: &WalletEvent, @@ -914,23 +924,26 @@ async fn swept_payment_flips_for_event( /// 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. +/// would commit X's reinstated record beside a stale `Failed` overlay +/// row. /// /// 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. +/// [`rollback_payment_flips`], returning the entry to `Pending` so the +/// ordered confirm running right after this retraction +/// ([`confirm_final_sent_payments_for_store`](crate::wallet::identity::network)) +/// takes it straight to `Confirmed` on this same round. /// -/// 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. +/// This function only sees records THIS drain captured, and that is +/// enough: a reinstating record queued after `try_recv` stopped folding +/// is simply the NEXT drain's event — the adapter is the only +/// sent-payment verdict writer that stages rows, so nothing can supersede +/// this batch's staged rows between its fold and its store (see the +/// commit-site comment in [`run_wallet_event_adapter`]), and the later +/// drain's `Failed → Confirmed` is exactly the transition the shared +/// table permits. async fn retract_reinstated_payment_flips( wallet_manager: &Arc>>, entry: &mut WalletBatch, @@ -1019,206 +1032,6 @@ 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 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( @@ -4179,11 +3992,10 @@ mod tests { /// 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. + /// with it, so the entry reads `Pending` for the ordered confirm the + /// reinstated record drives on this same fold. Without the retraction + /// the fold would commit a stale `Failed` assertion beside the record + /// that disproves it. /// /// Both events are queued BEFORE the adapter task spawns, which is /// what makes the single-fold deterministic: the first `recv` takes @@ -4329,9 +4141,14 @@ mod tests { "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" + observed.n_payment_overlay_rows, 1, + "the fold must carry exactly the reinstating record's own verdict" + ); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "a merged changeset must never carry a sweep-derived Failed about a \ + txid the same fold reinstates — the retraction drops it and the \ + ordered confirm stages Confirmed in its place" ); { @@ -4349,9 +4166,9 @@ mod tests { .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" + PaymentStatus::Confirmed, + "the retraction undoes the in-memory flip and the reinstating \ + record's ordered confirm decides the entry on the same fold" ); } @@ -4359,172 +4176,375 @@ 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() { + /// Seed `owner` with one `Pending` sent payment under `txid`, through + /// a noop persister so a probe's observation stream carries only the + /// adapter's own stores. Shared by the ordering regressions below. + async fn seed_pending_sent_payment( + wallet_manager: &Arc>>, + wallet_id: WalletId, + owner: dpp::prelude::Identifier, + contact: dpp::prelude::Identifier, + txid: dashcore::Txid, + ) -> crate::wallet::persister::WalletPersister { 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::test_support::NoopTestPersister; + use crate::wallet::identity::types::dashpay::payment::PaymentEntry; use crate::wallet::persister::WalletPersister; + 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.to_string(), + PaymentEntry::new_sent(contact, 50_000, None), + &noop, + ) + .expect("record pending sent"); + noop + } + + async fn sent_payment_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 + } + + /// THE regression from the dashpay/platform#4442 review — interleaving + /// A: a pre-sweep confirmation hook is parked until the newer sweep + /// has been emitted (and durably applied), then released. + /// + /// Upstream emits `TransactionInstantLocked(X)` and later — the + /// chainlocked-conflict eviction upstream explicitly permits — + /// `TransactionsSwept(X)`. The adapter applies both in emission + /// order: `Confirmed` rides the IS-lock's round, the newer sweep + /// demotes to `Failed` on its own round. The EventHandler hook task + /// for the OLD IS-lock event — parked past both rounds, which the + /// lossy, unordered broadcast path genuinely allows — is then + /// released, running exactly the code the handler's spawned task runs + /// (`run_dashpay_payment_hooks`). The hook no longer writes + /// sent-payment verdicts, so the stale pre-sweep evidence cannot + /// resurrect the dead payment. Before the fix, the released hook + /// confirmed from `Failed` (`LIVE_CONFIRM_EVIDENCE` admitted it) and + /// durably landed `Confirmed` for a transaction upstream had proven + /// dead — with no later sweep re-emission to repair it. + #[tokio::test] + async fn a_parked_pre_sweep_confirmation_hook_cannot_resurrect_the_swept_payment() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::funded_wallet_manager; + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + 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 owner = dpp::prelude::Identifier::from([0xAA; 32]); + let contact = dpp::prelude::Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xF1; 32]); + let noop = + seed_pending_sent_payment(&wallet_manager, wallet_id, owner, contact, txid).await; - let noop = WalletPersister::new( + 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 pre-sweep confirmation event. Its HOOK task is parked (not + // run) until after the sweep below is durable. + let is_lock_event = WalletEvent::TransactionInstantLocked { wallet_id, - Arc::new(NoopTestPersister) as Arc, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + + // The adapter applies the IS-lock in emission order: the ordered + // confirm stages `Confirmed` on the event's own round. + event_tx + .send(is_lock_event.clone()) + .expect("send IS-lock event"); + let observed = obs_rx.recv().await.expect("IS-lock round"); + assert!(!observed.rejected); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "the ordered confirm must ride the IS-lock event's own round" + ); + assert_eq!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Confirmed ); - { - 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( + // The newer sweep — upstream evicted X for a chainlocked conflict + // — demotes the confirmation on its own round. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xF2; 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, 1); + assert_eq!( + observed.n_payment_overlay_confirmed, 0, + "the newer sweep's Failed row must overrule the older confirmation" + ); + assert_eq!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Failed + ); + + // Release the parked hook — the delayed task for the pre-sweep + // IS-lock event finally runs, after the newer sweep is durable. + crate::wallet::identity::network::run_dashpay_payment_hooks( &wallet_manager, &wallet_id, - &[reinstated, still_dead], + &noop, + &is_lock_event, ) .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, + assert_eq!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Failed, + "a parked pre-sweep confirmation hook must not resurrect the swept payment" ); - // 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( + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// The same review finding's interleaving B: the pre-sweep + /// confirmation RUNS just before the sweep is staged. Pre-fix, the + /// hook confirmed `Pending → Confirmed` on its own round and the + /// sweep's eligibility check then skipped the entry (`Confirmed` was + /// terminal), leaving the dead payment durably `Confirmed`. Post-fix + /// the released hook writes nothing, the confirmation is the + /// adapter's own ordered write on the IS-lock round, and the sweep — + /// the newer verdict — demotes it on its round. + #[tokio::test] + async fn a_sweep_staged_after_a_confirmation_still_fails_the_dead_payment() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::funded_wallet_manager; + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = dpp::prelude::Identifier::from([0xAA; 32]); + let contact = dpp::prelude::Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xF3; 32]); + let noop = + seed_pending_sent_payment(&wallet_manager, wallet_id, owner, contact, txid).await; + + let is_lock_event = WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + + // The hook task runs FIRST — just before the adapter stages + // anything. Post-fix it writes no sent-payment verdict. + crate::wallet::identity::network::run_dashpay_payment_hooks( &wallet_manager, &wallet_id, &noop, - &reinstated, + &is_lock_event, ) .await; - // Commit the sweep batch through the drain's commit stage. let (obs_tx, mut obs_rx) = unbounded_channel(); - let persister = ProbePersister::with_capabilities( + 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 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); + // The adapter's ordered confirm owns the flip instead. + event_tx + .send(is_lock_event.clone()) + .expect("send IS-lock event"); + let observed = obs_rx.recv().await.expect("IS-lock round"); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "the confirmation must be the adapter's ordered write, not the hook's" + ); + + // The newer sweep must still fail the dead payment — pre-fix the + // hook's earlier Confirmed made this a skipped, durable wrong + // terminal. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xF4; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + let observed = obs_rx.recv().await.expect("sweep round"); 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 sweep must demote the earlier confirmation on its own round" ); + assert_eq!(observed.n_payment_overlay_confirmed, 0); + assert_eq!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Failed, + "the newer sweep's verdict must win over the pre-sweep confirmation" + ); + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// The two events of the race folded into ONE drain: `[IS-lock(X), + /// Swept(X)]` buffered together must commit a single round whose + /// payment row is the LAST verdict in emission order — `Failed` — + /// because the fold overwrites the staged `Confirmed` row per + /// `(owner, txid)` when the later sweep demotes it in memory. + #[tokio::test] + async fn a_confirmation_and_its_eviction_in_one_fold_commit_the_newer_verdict() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::funded_wallet_manager; + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = dpp::prelude::Identifier::from([0xAA; 32]); + let contact = dpp::prelude::Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xF5; 32]); + seed_pending_sent_payment(&wallet_manager, wallet_id, owner, contact, txid).await; - // 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"); + 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 task spawns, which is what + // makes the single-fold deterministic: the first `recv` takes the + // IS-lock and the backlog `try_recv` folds the sweep. + event_tx + .send(WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send IS-lock event"); + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xF6; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + + 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 round"); + assert!(!observed.rejected); assert_eq!( - ledger.len(), - 1, - "only the retained row keeps its rollback entry" + observed.n_payment_overlay_rows, 1, + "one row per (owner, txid): the later flip overwrites the earlier" + ); + assert_eq!( + observed.n_payment_overlay_confirmed, 0, + "the fold must commit the newer verdict — Failed, not the folded-over Confirmed" + ); + assert_eq!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Failed ); - 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 - ); - } + cancel.cancel(); + handle.await.expect("adapter task joins"); } /// The one-shot reinstatement gets the round's durability, end to end @@ -4532,10 +4552,10 @@ mod tests { /// 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 + /// construction) cannot cover it — a separately persisted correction + /// whose store was rejected would have left a durable `Failed` for a /// transaction that survived. The adapter therefore owns the - /// correction: `confirm_reinstated_sent_payments_for_store` flips the + /// correction: `confirm_final_sent_payments_for_store` flips the /// entry and rides the `Confirmed` row on the SAME store round as the /// reinstated record. /// diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 1e64401db2b..85b8f06bb2e 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -467,9 +467,11 @@ impl PlatformWalletManager

{ // with SPV's write lock. 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 - // confirms sent ones off the wallet-event fan-out, keeping that - // domain logic out of the generic core-changeset bridge. It holds + // DashPayPaymentHandler records incoming DashPay payments off the + // wallet-event fan-out, keeping that domain logic out of the + // generic core-changeset bridge. (Sent-payment verdicts do NOT + // run here: they are the wallet-event adapter's to apply in + // emission order — see `payment_handler`'s module docs.) It holds // the wallet-manager (for the in-memory payment state it mutates) // and the persister (to write the resulting payment rows). let dashpay_payment_handler = Arc::new(DashPayPaymentHandler::new( 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..b45a2e4649c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -47,14 +47,13 @@ pub use invitation::{ }; mod payment_handler; pub(crate) use payment_handler::DashPayPaymentHandler; -// Re-exported for the payments unit tests, which drive the hooks -// directly; the handler itself calls it module-locally. +// Re-exported for the core-bridge ordering regressions, which release a +// "parked" hook task directly; the handler itself calls it module-locally. #[cfg(test)] 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, + confirm_final_sent_payments_for_store, flip_swept_sent_payments_for_store, record_incoming_dashpay_payments, rollback_payment_flips, PaymentFlipUndo, SweptPaymentFlips, }; mod profile; 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..343253f159a 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 @@ -1,4 +1,4 @@ -//! Event handler that drives the DashPay payment hooks off upstream +//! Event handler that records incoming DashPay payments off upstream //! `WalletEvent`s. //! //! Registered as one of the [`PlatformEventHandler`]s in @@ -6,22 +6,35 @@ //! keeps the DashPay-payment domain logic out of the generic //! core-changeset bridge ([`spawn_wallet_event_adapter`]): the bridge //! projects every event into a `CoreChangeSet` and persists it, while -//! this handler independently records incoming payments and confirms -//! sent ones. +//! this handler independently records incoming payments. +//! +//! # No sent-payment verdicts here +//! +//! Sent-payment status writes (`Confirmed` on finality, `Failed` on a +//! sweep) live EXCLUSIVELY on the wallet-event adapter's single ordered +//! drain (`confirm_final_sent_payments_for_store`, +//! `payments::SweptPaymentFlips`), never on this handler. This handler +//! rides dash-spv's bounded, lossy broadcast and spawns one independent +//! task per event, so execution order does not preserve emission order — +//! a pre-sweep confirmation task delayed past a newer sweep would +//! resurrect a dead payment as durably `Confirmed`, and a task that ran +//! just before the sweep staged would make the sweep skip the entry. +//! Verdicts are only safe where they apply in emission order. Incoming +//! recording is different in kind: it is an idempotent insert keyed by +//! txid with no state machine to race, and the recurring reconcile sweep +//! backfills anything the lossy broadcast dropped. //! //! # Why it spawns //! //! [`PlatformEventHandler::on_wallet_event`] is synchronous and is //! dispatched from dash-spv's wallet-event broadcast monitor, which can -//! fire while SPV holds the wallet-manager write lock. The payment hooks -//! are async and take that same write lock, so they cannot run inline. -//! The handler therefore captures an owned copy of the event and spawns -//! a task that queues on the write lock and runs once SPV releases it. -//! Every hook path is idempotent per txid (re-detections converge and -//! the recurring reconcile sweep backfills anything a lagged broadcast -//! dropped), so running off the core-store bridge's ordering is safe — -//! a payment row's only foreign key is to its `identities` parent, never -//! to a core transaction row. +//! fire while SPV holds the wallet-manager write lock. The recording +//! hook is async and takes that same write lock, so it cannot run +//! inline. The handler therefore captures an owned copy of the event and +//! spawns a task that queues on the write lock and runs once SPV +//! releases it. Recording is idempotent per txid, so running off the +//! core-store bridge's ordering is safe — a payment row's only foreign +//! key is to its `identities` parent, never to a core transaction row. use std::sync::Arc; use std::{future::Future, sync::Mutex}; @@ -35,8 +48,9 @@ use crate::changeset::traits::PlatformWalletPersistence; use crate::events::PlatformEventHandler; use crate::wallet::platform_wallet::PlatformWalletInfo; -/// Records incoming DashPay payments and confirms sent ones in response -/// to upstream `WalletEvent`s. +/// Records incoming DashPay payments in response to upstream +/// `WalletEvent`s. (Sent-payment verdicts live on the wallet-event +/// adapter's ordered drain — see the module docs.) /// /// Holds the manager's `wallet_manager` (for the in-memory identity / /// payment state the hooks mutate) and an `Arc` @@ -227,25 +241,20 @@ impl EventHandler for DashPayPaymentHandler { impl PlatformEventHandler for DashPayPaymentHandler {} /// Transaction records carried by `event` that should drive the DashPay -/// payment hooks (live incoming-record recording + sent-payment confirm). +/// incoming-payment recording. /// /// [`WalletEvent::TransactionDetected`] is the first off-chain sighting of -/// a transaction — mempool, or a direct InstantSend lock — so its -/// `record.context` is not yet block-confirmed. +/// a transaction — mempool, or a direct InstantSend lock. /// [`WalletEvent::BlockProcessed`] carries the records a block changed: -/// `inserted` (first stored in this block) and `updated` -/// (previously-known records that this block confirmed). A wallet sees its -/// *own* broadcast in the mempool first, so that transaction reaches a -/// confirmed context only via `BlockProcessed.updated` — routing solely -/// `TransactionDetected` is the gap that left sent payments stuck -/// `Pending`: the confirm hook early-returns on the unconfirmed mempool -/// sighting and never sees the confirming block. `matured` is +/// `inserted` (first stored in this block — how a payment first seen in a +/// block lands) and `updated` (previously-known records the block +/// confirmed — a second chance to record a payment whose first-sighting +/// broadcast was dropped by the lossy bus). `matured` is /// coinbase-maturity only — never a DashPay payment — so it is excluded. fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { // Exhaustive on purpose (no `_` arm): a new upstream `WalletEvent` // variant that carries transaction records must fail to compile here - // rather than be silently dropped — routing only `TransactionDetected` - // is exactly the gap that left sent payments stuck `Pending`. + // rather than be silently dropped. match event { WalletEvent::TransactionDetected { record, .. } => vec![record.as_ref()], WalletEvent::BlockProcessed { @@ -253,13 +262,16 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { } => inserted.iter().chain(updated.iter()).collect(), // `TransactionsSwept` carries txids, not records: the wallet has // already dropped the records these name. Its payment consequence - // — failing the matching `Pending` sent payments, since a swept - // transaction can never confirm — is NOT this handler's to apply: - // a sweep never re-emits once its round is durable, so the flip - // must ride the sweep's own atomic store round, 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. + // — failing the matching sent payments, since a swept transaction + // can never confirm — is NOT this handler's to apply: sent-payment + // verdicts must apply in emission order, so the wallet-event + // adapter owns them (see `payments::SweptPaymentFlips` and the + // module docs). `TransactionInstantLocked` likewise: it is + // sent-payment finality evidence (no record, only a txid), owned + // by the adapter's ordered confirm + // (`confirm_final_sent_payments_for_store`) — a confirmation run + // from this handler's unordered task could land after a newer + // sweep and durably resurrect a dead payment. WalletEvent::TransactionInstantLocked { .. } | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } @@ -269,60 +281,49 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { /// Whether `event` is worth spawning a payment-hook task for. /// -/// Covers the record-bearing events ([`dashpay_payment_records`]) plus -/// [`WalletEvent::TransactionInstantLocked`], which drives the sent-payment -/// confirm by txid alone (no record). A `BlockProcessed` that changed no -/// records — the common case while syncing past empty blocks — has no -/// payment work, so it is skipped rather than spawning a task that would -/// only take and release the wallet-manager write lock for nothing. +/// Exactly the record-bearing events ([`dashpay_payment_records`]): only +/// they can carry an incoming payment to record. A `BlockProcessed` that +/// changed no records — the common case while syncing past empty blocks — +/// has no payment work, so it is skipped rather than spawning a task that +/// would only take and release the wallet-manager write lock for nothing. /// Allocation-free. fn drives_payment_hooks(event: &WalletEvent) -> bool { match event { - WalletEvent::TransactionDetected { .. } | WalletEvent::TransactionInstantLocked { .. } => { - true - } + WalletEvent::TransactionDetected { .. } => true, WalletEvent::BlockProcessed { inserted, updated, .. } => !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`. - WalletEvent::TransactionsSwept { .. } + // nothing. Sent-payment verdicts — the sweep's `Failed` AND every + // finality confirmation, including `TransactionInstantLocked`'s — + // ride the wallet-event adapter's ordered drain instead. + WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => false, } } /// Run the DashPay payment hooks for `event`: record any incoming DashPay -/// payment, then advance a matching sent payment from `Pending` (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 +/// payment the event's records carry. Idempotent per txid, so /// re-detections and repeated block-processing rounds converge without /// duplicating entries. +/// +/// Deliberately does NOT touch sent-payment status. Both verdicts — a +/// sweep's `Failed` and a finality `Confirmed` (including the +/// reinstatement correction) — are applied by the wallet-event adapter in +/// emission order on the events' own store rounds (see +/// `payments::SweptPaymentFlips` and +/// `confirm_final_sent_payments_for_store`). This function runs on an +/// unordered spawned task off a lossy broadcast; a verdict written here +/// could overrule a newer one it never saw. pub(crate) async fn run_dashpay_payment_hooks( wallet_manager: &Arc>>, wallet_id: &WalletId, persister: &crate::wallet::persister::WalletPersister, event: &WalletEvent, ) { - // An InstantSend lock applied to a previously-seen transaction carries - // no record — only a txid — and is final for DashPay display, so - // confirm the matching sent payment directly. - if let WalletEvent::TransactionInstantLocked { txid, .. } = event { - crate::wallet::identity::network::confirm_sent_dashpay_payment_by_txid( - wallet_manager, - wallet_id, - persister, - txid, - ) - .await; - return; - } for record in dashpay_payment_records(event) { crate::wallet::identity::network::record_incoming_dashpay_payments( wallet_manager, @@ -331,13 +332,6 @@ pub(crate) async fn run_dashpay_payment_hooks( record, ) .await; - crate::wallet::identity::network::confirm_sent_dashpay_payment( - wallet_manager, - wallet_id, - persister, - record, - ) - .await; } } @@ -400,13 +394,12 @@ mod tests { } } - /// `BlockProcessed` is the path by which a wallet's own broadcast - /// confirms (`updated`), and the path by which a payment first seen in a - /// block lands (`inserted`); both must drive the DashPay payment hooks. - /// `matured` is coinbase-maturity only and carries no DashPay payment, so - /// it is excluded. A regression that re-narrows routing to - /// `TransactionDetected` — the original sent-payment-stuck-`Pending` bug — - /// drops the `updated` record and fails this test. + /// `BlockProcessed` is the path by which a payment first seen in a + /// block lands (`inserted`), and `updated` is the second chance to + /// record a payment whose first-sighting broadcast the lossy bus + /// dropped; both must drive the incoming-recording hook. `matured` is + /// coinbase-maturity only and carries no DashPay payment, so it is + /// excluded. #[test] fn dashpay_payment_records_covers_block_processed_inserted_and_updated() { let event = block_processed(vec![record(0x01)], vec![record(0x02)], vec![record(0x03)]); @@ -421,7 +414,7 @@ mod tests { assert!( txids.contains(&record(0x02).txid), "updated (just-confirmed) record must drive the payment hooks — \ - this is how a sent payment flips Pending → Confirmed" + the backfill chance for a dropped first sighting" ); assert!( !txids.contains(&record(0x03).txid), @@ -460,11 +453,14 @@ mod tests { assert!(!drives_payment_hooks(&event)); } - /// `TransactionInstantLocked` carries no record but DOES drive the - /// payment hooks — it confirms a sent payment by txid alone (an - /// InstantSend lock is final for DashPay display). + /// `TransactionInstantLocked` must NOT drive the payment hooks: it is + /// sent-payment finality evidence, and every sent-payment verdict is + /// the wallet-event adapter's to apply in emission order + /// (`confirm_final_sent_payments_for_store`). A hook task here would + /// confirm on an unordered, lossy path — the exact shape that let a + /// stale pre-sweep confirmation durably resurrect a dead payment. #[test] - fn instant_locked_drives_payment_hooks_without_a_record() { + fn instant_locked_does_not_drive_payment_hooks() { use dashcore::ephemerealdata::instant_lock::InstantLock; let event = WalletEvent::TransactionInstantLocked { wallet_id: [0u8; 32], @@ -473,9 +469,8 @@ mod tests { balance: WalletCoreBalance::default(), account_balances: std::collections::BTreeMap::new(), }; - // 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(&event)); } /// `TransactionsSwept` must NOT drive the payment hooks: its payment 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..84c002d86a4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -654,15 +654,16 @@ impl DashPayView<'_, B> { /// persisted core transaction record reports the transaction final. /// /// Recovery path for sent-payment confirmation. The live confirm path - /// ([`confirm_sent_dashpay_payment`](super::confirm_sent_dashpay_payment)) - /// flips a sent payment the moment its block / InstantSend-lock event - /// arrives, but that is a single live event: if it is missed — a lagged - /// wallet-event broadcast, or a relaunch after the transaction confirmed - /// but before the flip was captured — the entry would otherwise stay - /// `Pending` forever (received payments self-heal from receival-account - /// UTXOs; sent payments have no such ground truth). This sweep consults - /// the persisted core tx record (txid + context) and flips any `Pending` - /// `Sent` entry whose transaction is mined or InstantSend-locked. + /// ([`confirm_final_sent_payments_for_store`](super::confirm_final_sent_payments_for_store), + /// on the wallet-event adapter's ordered drain) flips a sent payment + /// the moment its block / InstantSend-lock event is applied, but that + /// is a single live event: if it is missed — a relaunch after the + /// transaction confirmed but before the flip was captured — the entry + /// would otherwise stay `Pending` forever (received payments + /// self-heal from receival-account UTXOs; sent payments have no such + /// ground truth). This sweep consults the persisted core tx record + /// (txid + context) and flips any `Pending` `Sent` entry whose + /// transaction is mined or InstantSend-locked. /// /// Runs as a local-only step of `dashpay_sync()` — one persister read /// per pending sent payment, no network round-trips. Idempotent: a @@ -722,16 +723,16 @@ impl DashPayView<'_, B> { // 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 + // nothing about an entry that has since moved. In particular + // the wallet-event adapter can flip the entry to `Failed` + // anywhere in the snapshot→confirm span (this pass runs on its + // own task, off the adapter's ordered drain, and the sweep + // deletes the record on the adapter task) — this pass's record + // read may predate that verdict, and confirming from it would + // land a dead payment `Confirmed` against a newer sweep. 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). + // for the adapter, whose verdicts apply in emission order. confirm_sent_payment_by_txid( &self.wallet_manager, &self.wallet_id, @@ -927,105 +928,39 @@ fn sent_payment_status_for_record( } } -/// Advance a sender's `Sent` [`PaymentEntry`] from `Pending` to -/// `Confirmed` once its broadcast transaction reaches finality. -/// -/// [`IdentityWallet::send_payment`] records the outgoing entry as -/// `Pending` at broadcast time and nothing else advances it. The wallet -/// re-emits the sender's own transaction as it moves through mempool → -/// InstantSend → in-block → chain-locked, so when a re-detection reports -/// the transaction final the matching entry is flipped in place. -/// -/// An **InstantSend lock counts as final** for DashPay display: it is -/// effectively irreversible, so the user sees `Confirmed` without waiting -/// for the surrounding block. A bare mempool re-detection (no IS lock, not -/// yet mined) leaves the entry `Pending` — which it genuinely still is. -/// Idempotent: once `Confirmed`, later re-detections find nothing to -/// change and skip the persistence round. -pub(crate) async fn confirm_sent_dashpay_payment( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - persister: &crate::wallet::persister::WalletPersister, - record: &key_wallet::managed_account::transaction_record::TransactionRecord, -) { - use key_wallet::transaction_checking::TransactionContext; - // Mined (InBlock / InChainLockedBlock) OR InstantSend-locked advances - // the entry. A plain mempool sighting does not. - let is_instant_send = matches!(record.context, TransactionContext::InstantSend(_)); - if !record.is_confirmed() && !is_instant_send { - return; - } - confirm_sent_payment_by_txid( - wallet_manager, - wallet_id, - persister, - &record.txid.to_string(), - LIVE_CONFIRM_EVIDENCE, - ) - .await; -} - -/// Confirm a sender's `Sent` [`PaymentEntry`] by txid alone, for a -/// [`WalletEvent::TransactionInstantLocked`](key_wallet_manager::WalletEvent::TransactionInstantLocked) -/// that applies an InstantSend lock to a previously-seen transaction. -/// That event carries no [`TransactionRecord`](key_wallet::managed_account::transaction_record::TransactionRecord), -/// only the txid; an IS lock is treated as final for DashPay display, so -/// this flips a matching `Pending` `Sent` entry to `Confirmed`. Idempotent -/// (the underlying flip skips entries already past `Pending`). -pub(crate) async fn confirm_sent_dashpay_payment_by_txid( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - 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; -} - -/// What a confirm caller's evidence can speak for — the from-states it is -/// entitled to advance. The transition table +/// What the reconcile confirm'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: +/// permits; this says which of them this caller's evidence actually +/// supports, and the resolver requires both. /// -/// - **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`]. +/// `Pending`-only, by construction: reconcile evidence is a +/// persisted-record read made after a snapshot that saw the entry +/// `Pending`, on a task with no ordering against the wallet-event +/// adapter. If the entry has since moved to `Failed`, the read raced the +/// adapter's sweep (which deletes the record on its own ordered drain) +/// and may predate the verdict — confirming from it would land a dead +/// payment `Confirmed` against a newer sweep. Every OTHER sent-payment +/// verdict is applied by the adapter itself in emission order +/// ([`confirm_final_sent_payments_for_store`], +/// [`flip_swept_sent_payments_for_store`]) and needs no evidence class: +/// on the ordered drain, the event being applied postdates every verdict +/// already applied. 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. +/// in place, preserving amount/memo/counterparty. The reconcile sweep's +/// recovery flip — every live/ordered confirmation is the wallet-event +/// adapter's job ([`confirm_final_sent_payments_for_store`]). /// /// 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). +/// already `Confirmed` (so repeated reconcile passes are idempotent and +/// skip the persistence round), or its current state is outside what +/// `evidence` can speak for — a `Failed` entry never advances here, +/// because a reconcile pass's record read can predate the sweep verdict +/// entirely (see [`RECONCILE_CONFIRM_EVIDENCE`]). async fn confirm_sent_payment_by_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -1034,21 +969,15 @@ async fn confirm_sent_payment_by_txid( 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. + // Log-and-continue is sound here: the flip rolled back in memory with + // the failed store, and the entry is still `Pending`, so the next + // reconcile pass — and any adapter-applied signal for the same + // transaction — re-drives the confirmation. The one-shot cases with + // no later signal (a sweep's `Failed`, a reinstating record's + // `Failed → Confirmed`) never come through here: they ride their + // event's own store round on the adapter with the round's fail-closed + // machinery, so a store failure HERE is only ever a lost recovery + // write, never the last chance. if let Err(e) = resolve_sent_payment_by_txid( wallet_manager, wallet_id, @@ -1083,10 +1012,19 @@ async fn confirm_sent_payment_by_txid( /// 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`. +/// A `Confirmed` entry IS demoted here (the shared transition table's +/// `(Confirmed, Failed)` edge): the only confirmations that can exist +/// when a sweep is applied were written from evidence the sweep +/// postdates — every verdict writer is either this same ordered drain or +/// re-validates under the manager lock against `Pending`-only evidence — +/// and upstream never emits a sweep for a transaction that is currently +/// final (a confirmed record is never a sweep loser). The one way the +/// verdict reverses — a reinstatement — re-emits the record final, which +/// the adapter's ordered confirm accepts from `Failed` on a later event. +/// +/// Despite the name, this is the carrier for BOTH round-riding verdict +/// flips: the sweep's `Failed` rows and the ordered confirm's +/// `Confirmed` rows ([`confirm_final_sent_payments_for_store`]). #[derive(Debug, Default)] pub(crate) struct SweptPaymentFlips { /// `PlatformWalletChangeSet::dashpay_payments_overlay` payload — the @@ -1105,10 +1043,10 @@ pub(crate) struct SweptPaymentFlips { /// 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` +/// Carried by both round-riding flips: the sweep's `→ Failed` /// ([`flip_swept_sent_payments_for_store`], `wrote = Failed`) and the -/// reinstatement's `Failed → Confirmed` -/// ([`confirm_reinstated_sent_payments_for_store`], `wrote = Confirmed`). +/// ordered confirm's `→ Confirmed` +/// ([`confirm_final_sent_payments_for_store`], `wrote = Confirmed`). #[derive(Debug, Clone)] pub(crate) struct PaymentFlipUndo { pub owner: Identifier, @@ -1125,9 +1063,19 @@ impl SweptPaymentFlips { } } -/// 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`]). +/// Flip the `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`]). +/// +/// Eligibility is the shared transition table: `Pending` AND `Confirmed` +/// entries flip. Demoting `Confirmed` is what makes a sweep that +/// postdates a confirmation win — upstream permits a chainlocked +/// transaction to evict an IS-locked conflict, so a payment confirmed on +/// IS-lock evidence can genuinely die afterwards — and it is sound only +/// because this runs on the adapter's ordered drain, where every already +/// applied confirmation predates the sweep being applied (see the table's +/// `(Confirmed, Failed)` edge for the full argument). `Failed` entries +/// are skipped, which keeps a replayed sweep idempotent. pub(crate) async fn flip_swept_sent_payments_for_store( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -1186,73 +1134,112 @@ pub(crate) async fn flip_swept_sent_payments_for_store( 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. +/// The adapter-owned sent-payment confirmation: flip the `Sent` entries +/// whose transaction `event` proves final to `Confirmed` in memory, and +/// return the overlay + rollback for the wallet-event adapter to ride on +/// the event'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`]. +/// This is the ONLY live confirmation path. It runs on the adapter's +/// single ordered drain of the lossless persistence channel, which is +/// what makes each verdict safe against every other: the event being +/// applied postdates every verdict already applied, so a confirmation +/// here can never overrule a newer sweep — and a sweep folded later +/// ([`flip_swept_sent_payments_for_store`]) rightly overrules this +/// confirmation. The confirmation must NOT also run off the EventHandler +/// broadcast (the old payment-hook path): that bus is bounded, lossy, and +/// drained by a detached task with no ordering against this drain, so a +/// stale pre-sweep confirmation could execute after the sweep and durably +/// resurrect a dead payment. /// -/// 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( +/// Finality is the shared gate ([`sent_payment_status_for_record`]: +/// mined or IS-locked context), plus +/// [`TransactionInstantLocked`](key_wallet_manager::WalletEvent::TransactionInstantLocked), +/// which carries no record — an IS lock is final for DashPay display, so +/// the txid alone confirms. Both `Pending` and `Failed` entries advance: +/// `Failed → Confirmed` is the reinstatement correction, and final +/// evidence delivered on this ordered drain necessarily postdates the +/// sweep that failed the entry (upstream never re-emits final evidence +/// for a txid it still considers dead). Riding the event's own atomic +/// `store()` gives the flip the round's fail-closed machinery — needed +/// because a chainlocked reinstatement can be a one-shot with no later +/// signal to retry from — and keeps store order equal to emission order, +/// so a round carrying this `Confirmed` row can never land after a newer +/// sweep's `Failed` row. The `Pending`-only reconcile pass +/// ([`RECONCILE_CONFIRM_EVIDENCE`]) remains as recovery for confirmations +/// this drain missed (relaunch, lossy start-up). +pub(crate) async fn confirm_final_sent_payments_for_store( wallet_manager: &Arc>>, wallet_id: &WalletId, - records: &[key_wallet::managed_account::transaction_record::TransactionRecord], + event: &key_wallet_manager::WalletEvent, ) -> SweptPaymentFlips { use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; + use key_wallet_manager::WalletEvent; + + // The txids this event proves final. Exhaustive on purpose (no `_` + // arm): a new upstream `WalletEvent` variant that can carry finality + // evidence must fail to compile here rather than be silently dropped. + let final_txids: Vec = match event { + // No record, only a txid — final for DashPay display. + WalletEvent::TransactionInstantLocked { txid, .. } => vec![txid.to_string()], + // First off-chain sighting: final only when the record already + // carries IS-lock context (a plain mempool sighting is not). + WalletEvent::TransactionDetected { record, .. } => std::iter::once(record.as_ref()) + .filter(|r| sent_payment_status_for_record(r) == PaymentStatus::Confirmed) + .map(|r| r.txid.to_string()) + .collect(), + // `inserted` (first stored in this block — including a swept + // transaction's reinstating re-detection) and `updated` + // (previously-known records this block confirmed). `matured` is + // coinbase maturity — never a DashPay payment. + WalletEvent::BlockProcessed { + inserted, updated, .. + } => inserted + .iter() + .chain(updated.iter()) + .filter(|r| sent_payment_status_for_record(r) == PaymentStatus::Confirmed) + .map(|r| r.txid.to_string()) + .collect(), + // The sweep's verdict is the opposite flip + // ([`flip_swept_sent_payments_for_store`]); the rest carry no + // finality evidence for a sent payment. + WalletEvent::TransactionsSwept { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => Vec::new(), + }; let mut flips = SweptPaymentFlips::default(); - if records.is_empty() { + if final_txids.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. + // Fast path under the READ lock: almost every final record names no + // sent payment at all — don't make the drain take the write lock per + // event just to discover that. Missing a concurrent flip is not + // possible: the sweep flip and the rejected-round undo both run on + // this same adapter task, and the write-lock re-check below still + // gates the flip itself, so an entry the reconcile pass confirms in + // the gap is simply found already `Confirmed` and 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 - }) - }) - } + let any_candidate = final_txids.iter().any(|key| { + 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 + && matches!( + entry.status, + PaymentStatus::Pending | PaymentStatus::Failed + ) + }) + }) }); if !any_candidate { return flips; @@ -1262,19 +1249,14 @@ pub(crate) async fn confirm_reinstated_sent_payments_for_store( 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(); + for key in &final_txids { '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) { + 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, @@ -1289,7 +1271,8 @@ pub(crate) async fn confirm_reinstated_sent_payments_for_store( tracing::info!( owner = %owner, txid = %key, - "Confirming reinstated sent DashPay payment on its record's own store round" + previous_status = ?previous.status, + "Confirming sent DashPay payment on its event's own store round" ); managed .dashpay_payments_mut() @@ -1325,11 +1308,11 @@ pub(crate) async fn confirm_reinstated_sent_payments_for_store( /// 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. +/// ([`PaymentFlipUndo::wrote`]). An entry that moved on — a later flip in +/// the same fold overwriting this one, or the reconcile pass confirming a +/// still-`Pending` entry concurrently — 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, @@ -1357,10 +1340,32 @@ pub(crate) async fn rollback_payment_flips( } /// 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. +/// writer so the confirm path and the sweep flip can never drift. Every +/// legal edge is enumerated explicitly — no wildcard — so adding a +/// `PaymentStatus` variant forces a review of this machine instead of +/// silently admitting new transitions, and a same-state "move" is never a +/// transition (that no-op is what lets re-delivered evidence skip the +/// persistence round). +/// +/// - `Pending → Confirmed`: the transaction reached finality (mined or +/// IS-locked). +/// - `Pending → Failed`: a sweep proved the transaction can never confirm +/// (beaten to one of its inputs). +/// - `Failed → Confirmed`: the reinstatement correction — the swept +/// transaction re-arrived final. +/// - `Confirmed → Failed`: the eviction correction — upstream permits a +/// chainlocked transaction to evict an IS-locked conflict, so a payment +/// confirmed on IS-lock (or provisional-block) evidence can genuinely +/// die afterwards. This edge is sound ONLY because sent-payment +/// verdicts are applied in emission order on the wallet-event adapter's +/// single drain: when the sweep is applied, every `Confirmed` it can +/// see was written from evidence the sweep postdates (the adapter's own +/// earlier events, or a reconcile pass's read of a record state the +/// sweep is about to delete), and no sweep can follow the evidence that +/// would make `Confirmed` truly final, because upstream never sweeps a +/// confirmed record. Its only legitimate writer is the adapter's sweep +/// flip ([`flip_swept_sent_payments_for_store`]); any new writer with +/// `to = Failed` must run on that same ordered drain. pub(crate) fn sent_status_transition_allowed( from: crate::wallet::identity::types::dashpay::payment::PaymentStatus, to: crate::wallet::identity::types::dashpay::payment::PaymentStatus, @@ -1368,22 +1373,30 @@ pub(crate) fn sent_status_transition_allowed( use crate::wallet::identity::types::dashpay::payment::PaymentStatus; matches!( (from, to), - (PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed) + (PaymentStatus::Pending, PaymentStatus::Confirmed) + | (PaymentStatus::Pending, PaymentStatus::Failed) + | (PaymentStatus::Failed, PaymentStatus::Confirmed) + | (PaymentStatus::Confirmed, PaymentStatus::Failed) ) } -/// 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`].) +/// The reconcile pass's flip: move the `Sent` [`PaymentEntry`] under +/// `txid` to `to`, in place, preserving amount/memo/counterparty, and +/// persist it through its own store round — memory advance and store +/// under ONE continuous hold of the manager write lock, which is what +/// orders this writer against the adapter's staged rounds (see the +/// commit-site comment in the wallet-event adapter). (Adapter-applied +/// verdicts do NOT come through here — they ride their event'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 +/// adapter's flips so the state machine cannot drift) INTERSECTED with +/// the caller's declared `evidence_from` (see +/// [`RECONCILE_CONFIRM_EVIDENCE`]); every ineligible combination is a +/// no-op, which is what keeps repeated passes idempotent, skipping the +/// persistence round, and a stale reconcile snapshot unable to overrule +/// a sweep verdict it never saw. Separated from the sweep glue so the +/// transition is unit-testable without constructing a full /// `TransactionRecord`. /// /// A no-op resolution (no entry, not `Sent`, not eligible) is `Ok(())`; @@ -3232,13 +3245,19 @@ mod tests { } /// A `Sent` payment must advance `Pending → Confirmed` once its - /// transaction confirms on-chain. `send_payment` records it `Pending` - /// and nothing else moved it, so before the confirm path was wired the - /// entry was stuck `Pending` forever (sent payments never showed - /// confirmed). Pins the flip, idempotency on re-detection, and that - /// amount/memo are preserved. + /// transaction reaches finality. `send_payment` records it `Pending` + /// and only the wallet-event adapter's ordered confirm + /// ([`confirm_final_sent_payments_for_store`]) moves it live, so + /// before that path was wired the entry was stuck `Pending` forever + /// (sent payments never showed confirmed). Pins the flip riding the + /// event's own round (overlay + rollback), idempotency on + /// re-delivery, and that amount/memo are preserved. #[tokio::test] async fn confirm_flips_sent_payment_pending_to_confirmed() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + use crate::wallet::identity::types::dashpay::payment::{ PaymentDirection, PaymentEntry, PaymentStatus, }; @@ -3246,7 +3265,8 @@ mod tests { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); - let txid = "a".repeat(64); + let txid = dashcore::Txid::from([0x5a; 32]); + let txid_key = txid.to_string(); let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = wallet.identity(); @@ -3262,7 +3282,7 @@ mod tests { .managed_identity_mut(&owner) .expect("managed") .record_dashpay_payment( - txid.clone(), + txid_key.clone(), PaymentEntry::new_sent(contact, 50_000, Some("dinner".into())), &p, ) @@ -3289,41 +3309,55 @@ mod tests { } assert_eq!( - read_entry(iw, &wallet_id, &owner, &txid).await.status, + read_entry(iw, &wallet_id, &owner, &txid_key).await.status, PaymentStatus::Pending, "precondition: entry starts Pending" ); - // 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; - let entry = read_entry(iw, &wallet_id, &owner, &txid).await; + // The IS-lock event (final for DashPay display) flips it to + // Confirmed, staging the row for the event's own store round. + let event = WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + let flips = + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event) + .await; + assert_eq!( + flips.overlay[&owner][&txid_key].status, + PaymentStatus::Confirmed, + "the Confirmed row must ride the event's own round" + ); + assert_eq!(flips.rollback.len(), 1); + assert_eq!( + flips.rollback[0].wrote, + PaymentStatus::Confirmed, + "the undo may only revert this flip's own write" + ); + let entry = read_entry(iw, &wallet_id, &owner, &txid_key).await; assert_eq!( entry.status, PaymentStatus::Confirmed, - "a confirmed tx must flip the Sent entry to Confirmed" + "a final tx must flip the Sent entry to Confirmed" ); assert_eq!(entry.direction, PaymentDirection::Sent); assert_eq!(entry.amount_duffs, 50_000); 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; + // Idempotent: re-delivered finality evidence changes nothing and + // stages nothing (the same-state move is not a transition). + let flips = + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event) + .await; + assert!( + flips.is_empty(), + "re-delivered evidence must skip the persistence round" + ); assert_eq!( - read_entry(iw, &wallet_id, &owner, &txid).await.status, + read_entry(iw, &wallet_id, &owner, &txid_key).await.status, PaymentStatus::Confirmed ); } @@ -3335,10 +3369,14 @@ mod tests { /// 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`. + /// pins the guard rails: a re-emitted sweep on an already-`Failed` + /// entry is an idempotent no-op; the reinstatement reversal — the + /// swept transaction re-arriving final — advances `Failed` to + /// `Confirmed` on the reinstating event's round; and a sweep + /// delivered AFTER a confirmation demotes it (`Confirmed → Failed`): + /// on the adapter's ordered drain a delivered sweep always postdates + /// every applied confirmation — upstream never sweeps a currently + /// final record — so the newer verdict must win. #[tokio::test] async fn swept_sent_payment_fails_and_a_reinstating_confirmation_recovers_it() { use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; @@ -3417,34 +3455,92 @@ mod tests { 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( + // The reinstatement: the swept transaction re-arrives final (here + // as its IS-lock event); the ordered confirm must correct the + // `Failed` verdict on the reinstating event's own round. + let reinstating = { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + } + }; + let flips = super::confirm_final_sent_payments_for_store( &iw.wallet_manager, &wallet_id, - &p, - &txid_key, - super::LIVE_CONFIRM_EVIDENCE, + &reinstating, ) .await; + assert_eq!( + flips.overlay[&owner][&txid_key].status, + PaymentStatus::Confirmed, + "the reinstatement correction must ride the event's own round" + ); assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, PaymentStatus::Confirmed, - "a reinstated, confirmed transaction must recover the payment" + "a reinstated, final transaction must recover the payment" ); - // And a stale sweep arriving after confirmation never demotes it. + // A sweep delivered AFTER the confirmation demotes it: on the + // ordered drain the sweep postdates the applied confirmation, and + // upstream never sweeps a currently final record — so this sweep + // is genuinely newer truth (the reinstated transaction lost a new + // conflict) and must win. let flips = super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) .await; - assert!(flips.is_empty()); + assert_eq!( + flips.overlay[&owner][&txid_key].status, + PaymentStatus::Failed, + "a newer sweep must demote a Confirmed entry — its Failed row rides its round" + ); assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, - PaymentStatus::Confirmed, - "Confirmed is terminal — a stale sweep must not demote it" + PaymentStatus::Failed, + "the newer sweep's verdict wins over the older confirmation" ); } + /// The transition table enumerates its legal edges explicitly — every + /// same-state pair is a no-op (that skip is what keeps re-delivered + /// evidence off the persistence round), `Failed → Pending` and + /// `Confirmed → Pending` do not exist, and the four legal edges are + /// exactly the machine's verdicts. A wildcard `(Pending, _)` arm — the + /// shape this replaces — silently admits `Pending → Pending` and any + /// future `PaymentStatus` variant without a state-machine review. + #[test] + fn sent_status_transition_table_enumerates_exactly_the_legal_edges() { + use crate::wallet::identity::types::dashpay::payment::PaymentStatus::*; + + let allowed = [ + (Pending, Confirmed), + (Pending, Failed), + (Failed, Confirmed), + (Confirmed, Failed), + ]; + for from in [Pending, Confirmed, Failed] { + for to in [Pending, Confirmed, Failed] { + assert_eq!( + super::sent_status_transition_allowed(from, to), + allowed.contains(&(from, to)), + "transition {from:?} -> {to:?} must be {}", + if allowed.contains(&(from, to)) { + "allowed" + } else { + "rejected — a same-state move is not a transition, and \ + nothing returns to Pending" + } + ); + } + } + } + /// 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 @@ -3526,16 +3622,16 @@ 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 + /// record, the adapter's sweep flip lands `Failed` mid-flight (the + /// reconcile pass runs on its own task, off the adapter's ordered + /// drain, and the sweep deletes the record on the adapter), and the + /// reconciler then confirms from evidence that predates the verdict — + /// landing a dead payment `Confirmed` against a newer sweep, with no + /// later sweep re-emission to repair it. 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. + /// while reinstatement evidence applied on the ordered drain — which + /// postdates the sweep by delivery order — 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}; @@ -3611,16 +3707,23 @@ mod tests { "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; + // Reinstatement evidence applied on the ordered drain — which + // postdates the sweep by delivery order — still recovers the + // entry. + let reinstating = { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + } + }; + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &reinstating) + .await; assert_eq!( status(iw, &wallet_id, &owner, &txid_key).await, PaymentStatus::Confirmed @@ -3628,12 +3731,12 @@ 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. + /// `Failed` write. A later flip can land between the sweep flip and + /// its undo — the same-fold reinstatement's ordered confirm advancing + /// the entry to `Confirmed` before a rejected round replays the + /// ledger. An unconditional restore would clobber it back to the + /// captured `Pending`, demoting a verdict the undo has no authority + /// over. #[tokio::test] async fn rollback_does_not_clobber_a_concurrently_confirmed_entry() { use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; @@ -3669,16 +3772,22 @@ mod tests { .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 reinstated transaction's ordered confirmation lands before + // the undo — Failed → Confirmed, the table's permitted correction. + let reinstating = { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + } + }; + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &reinstating) + .await; // The undo arrives late (rejected round or same-fold retraction); // it must find its own write gone and leave the terminal alone. @@ -3707,20 +3816,17 @@ mod tests { /// A sent payment confirmed by a block must flip `Pending → Confirmed`. /// /// The wallet sees its *own* broadcast in the mempool first - /// (`TransactionDetected`, context `Mempool`), where the confirm hook - /// early-returns because the transaction is not yet confirmed. The - /// transaction reaches a confirmed context only when a block mines it — - /// delivered as [`key_wallet_manager::WalletEvent::BlockProcessed`] with - /// the record in `updated` (a previously-known record that just - /// confirmed). Routing the payment hooks only for `TransactionDetected` - /// would leave the entry `Pending` forever. This drives the real adapter - /// dispatch - /// ([`run_dashpay_payment_hooks`](crate::wallet::identity::network::run_dashpay_payment_hooks)) - /// with a `BlockProcessed` event and pins the flip end-to-end, so a - /// regression that re-narrows the routing to `TransactionDetected` is - /// caught here. Also pins idempotency across a repeated block-processing - /// round and that the `matured` bucket (coinbase maturity) never - /// confirms a payment. + /// (`TransactionDetected`, context `Mempool`), which carries no + /// finality. The transaction reaches a confirmed context only when a + /// block mines it — delivered as + /// [`key_wallet_manager::WalletEvent::BlockProcessed`] with the record + /// in `updated` (a previously-known record that just confirmed). + /// Routing the ordered confirm + /// ([`confirm_final_sent_payments_for_store`]) only for + /// `TransactionDetected` would leave the entry `Pending` forever; + /// this pins the `BlockProcessed.updated` routing, idempotency across + /// a repeated block-processing round, and that the `matured` bucket + /// (coinbase maturity) never confirms a payment. #[tokio::test] async fn block_processed_confirms_sent_payment() { use dashcore::blockdata::transaction::Transaction; @@ -3812,13 +3918,7 @@ mod tests { addresses_derived: Vec::new(), }; - crate::wallet::identity::network::run_dashpay_payment_hooks( - &iw.wallet_manager, - &wallet_id, - &p, - &event, - ) - .await; + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event).await; // Read the entry under a short-lived read lock so the re-fire below // can take the write lock. @@ -3850,13 +3950,7 @@ mod tests { // Idempotent: a repeated block-processing round for the same txid // changes nothing (the confirm path skips entries past `Pending`). - crate::wallet::identity::network::run_dashpay_payment_hooks( - &iw.wallet_manager, - &wallet_id, - &p, - &event, - ) - .await; + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event).await; assert_eq!( read_status(iw, &wallet_id, &owner, &txid.to_string()) .await @@ -3867,7 +3961,7 @@ mod tests { // A confirmed record arriving only in the `matured` bucket (coinbase // maturity) must NOT confirm a payment — `matured` is never a DashPay - // payment, so it is excluded from the payment hooks. + // payment, so the ordered confirm excludes it. let matured_tx = Transaction { version: 2, lock_time: 0, @@ -3923,10 +4017,9 @@ mod tests { account_balances: std::collections::BTreeMap::new(), addresses_derived: Vec::new(), }; - crate::wallet::identity::network::run_dashpay_payment_hooks( + super::confirm_final_sent_payments_for_store( &iw.wallet_manager, &wallet_id, - &p, &matured_event, ) .await; @@ -3942,8 +4035,8 @@ mod tests { /// An InstantSend lock applied to a previously-seen sent payment /// confirms it without waiting for a block. The lock arrives as /// `WalletEvent::TransactionInstantLocked` (no record, just a txid); an - /// IS lock is final for DashPay display, so the entry flips - /// `Pending → Confirmed`. Drives the real adapter dispatch. + /// IS lock is final for DashPay display, so the ordered confirm flips + /// the entry `Pending → Confirmed`. #[tokio::test] async fn instant_send_lock_confirms_sent_payment() { use dashcore::ephemerealdata::instant_lock::InstantLock; @@ -3984,13 +4077,7 @@ mod tests { balance: WalletCoreBalance::default(), account_balances: std::collections::BTreeMap::new(), }; - crate::wallet::identity::network::run_dashpay_payment_hooks( - &iw.wallet_manager, - &wallet_id, - &p, - &event, - ) - .await; + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event).await; let wm = iw.wallet_manager.read().await; let info = wm.get_wallet_info(&wallet_id).expect("info"); @@ -4090,13 +4177,7 @@ mod tests { account_balances: std::collections::BTreeMap::new(), addresses_derived: Vec::new(), }; - crate::wallet::identity::network::run_dashpay_payment_hooks( - &iw.wallet_manager, - &wallet_id, - &p, - &event, - ) - .await; + super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event).await; let wm = iw.wallet_manager.read().await; let info = wm.get_wallet_info(&wallet_id).expect("info"); From 2d5c3f19441d4dfcd2cdf6a568c2af80e10edfab Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:33:23 +0300 Subject: [PATCH 95/95] fix(platform-wallet): coalesce a round's payment flips into one journal entry per (owner, txid) The payments overlay folded repeated verdicts last-write-wins while the rollback ledger appended every intermediate undo, so a round that flipped the same entry twice (Pending -> Confirmed -> Failed) kept two undos for one staged row. A rejected round's forward guarded replay then skipped the overwritten Confirmed undo and restored only Confirmed -- never the durable Pending -- and a same-fold reinstatement retracted only the sweep's undo, returning memory to the in-round Confirmed, where the ordered confirm no-opped and the reinstated record committed with no payment correction at all. Make the two structurally incapable of disagreeing: each flip is now a single previous/updated record, coalesced into a drain-scoped round journal keyed per (owner, txid) exactly like the overlay -- the first flip pins the durable pre-round entry, the last flip sets the staged one, and there is no intermediate history to unwind. The store overlay is materialized from the journal just before commit, the rejected-round rollback restores previous while the staged status stands, and the same-fold retraction drops the journal entry whole and restores the pre-round state so the reinstating record's confirm genuinely re-stages Confirmed. Regressions cover the rejected [IS-lock(X), Swept(X)] round and the three-event [IS-lock(X), Swept(X), reinstating record(X)] fold, both through the real adapter loop. --- .../src/changeset/core_bridge.rs | 547 ++++++++++++++---- .../src/wallet/identity/network/payments.rs | 116 ++-- 2 files changed, 519 insertions(+), 144 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 181e13f2cfb..e306449cabc 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -352,14 +352,17 @@ 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(); + // This drain's sent-payment round journal: per wallet, per + // `(owner, txid)`, the durable pre-round entry and the entry the + // round currently intends to leave staged. The store overlay + // (materialized just before commit), the rejected-round rollback, + // and the same-fold retraction are all projections of this ONE + // structure — see [`fold_payment_flips`] — so a staged row and + // its undo cannot describe different histories. Kept OUTSIDE the + // batch because `commit_batch` consumes the batch, and the + // rollback only runs for wallets whose store was rejected (see + // below). + let mut payment_journal: BTreeMap = BTreeMap::new(); let mut closed = false; { let wallet_id = event.wallet_id(); @@ -373,15 +376,15 @@ async fn run_wallet_event_adapter

( let entry = batch.entry(wallet_id).or_default(); retract_reinstated_payment_flips( &wallet_manager, - entry, - &mut payment_rollbacks, + &mut payment_journal, wallet_id, &core.records, ) .await; // After the same-fold retraction, so a sweep undone by this - // very event reads `Pending` and the ordered confirm below - // takes it straight to `Confirmed` on this event's round. + // very event reads the durable pre-round state and the + // ordered confirm below takes it straight to `Confirmed` on + // this event's round. let confirm_flips = crate::wallet::identity::network::confirm_final_sent_payments_for_store( &wallet_manager, @@ -392,8 +395,8 @@ async fn run_wallet_event_adapter

( 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, confirm_flips); + fold_payment_flips(&mut payment_journal, wallet_id, flips); + fold_payment_flips(&mut payment_journal, wallet_id, confirm_flips); } } @@ -412,15 +415,14 @@ async fn run_wallet_event_adapter

( let entry = batch.entry(wallet_id).or_default(); retract_reinstated_payment_flips( &wallet_manager, - entry, - &mut payment_rollbacks, + &mut payment_journal, wallet_id, &core.records, ) .await; // See the first-fold site: after the retraction, so a - // same-fold-swept entry reads `Pending` for the - // ordered confirm. + // same-fold-swept entry reads the durable pre-round + // state for the ordered confirm. let confirm_flips = crate::wallet::identity::network::confirm_final_sent_payments_for_store( &wallet_manager, @@ -431,8 +433,8 @@ async fn run_wallet_event_adapter

( 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, confirm_flips); + fold_payment_flips(&mut payment_journal, wallet_id, flips); + fold_payment_flips(&mut payment_journal, wallet_id, confirm_flips); } folded += 1; } @@ -444,6 +446,25 @@ async fn run_wallet_event_adapter

( } } + // Materialize each wallet's payment overlay FROM the journal — + // the overlay's only writer. One row per `(owner, txid)`: the + // round's final staged entry. Because the store payload and the + // rollback below are projections of the same journal entry, the + // undo always matches what this round actually offers the store. + for (wallet_id, ledger) in &payment_journal { + if ledger.is_empty() { + continue; + } + let entry = batch.entry(*wallet_id).or_default(); + for ((owner, txid), round) in ledger { + entry + .payments_overlay + .entry(*owner) + .or_default() + .insert(txid.clone(), round.staged.clone()); + } + } + // 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`). @@ -451,9 +472,10 @@ async fn run_wallet_event_adapter

( // No commit-time re-validation of the staged payment rows is // needed, and none is done: every sent-payment verdict writer // either IS this task (the sweep flip and the ordered confirm - // above, applied in emission order and staged into this very - // batch, where a later flip's row overwrites an earlier one per - // `(owner, txid)`), or persists memory-and-store atomically under + // above, applied in emission order and coalesced into this + // drain's round journal, where a later flip updates the staged + // entry per `(owner, txid)`), or persists memory-and-store + // atomically under // a continuous hold of the manager WRITE lock with `Pending`-only // evidence (the reconcile pass via `resolve_sent_payment_by_txid` // → `record_dashpay_payment`). A staged row always asserts @@ -485,7 +507,22 @@ async fn run_wallet_event_adapter

( // 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) { + if let Some(ledger) = payment_journal.remove(wallet_id) { + // One guarded undo per `(owner, txid)`: restore the + // durable pre-round entry while the round's final staged + // status still stands in memory — never an intermediate + // flip's snapshot. + let rollback: Vec = ledger + .into_iter() + .map(|((owner, txid), round)| { + crate::wallet::identity::network::PaymentFlipUndo { + owner, + txid, + wrote: round.staged.status, + previous: round.previous, + } + }) + .collect(); crate::wallet::identity::network::rollback_payment_flips( &wallet_manager, wallet_id, @@ -779,16 +816,49 @@ struct WalletBatch { /// while here a rejection keeps the proving event's rows with it, the /// wallet faults, the re-scan re-emits the event, and the replay /// 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 — which - /// is also what makes a fold containing both a confirmation and a - /// later eviction commit only the newer verdict. + /// in-memory half back). NOT written during the fold: it is + /// materialized from the drain's round journal (see + /// [`fold_payment_flips`]) just before commit — one row per + /// `(owner, txid)`, the round's final staged verdict, matching + /// `PlatformWalletChangeSet::merge`'s last-write-wins overlay rule — + /// which is also what makes a fold containing both a confirmation + /// and a later eviction commit only the newer verdict. payments_overlay: std::collections::BTreeMap< dpp::prelude::Identifier, std::collections::BTreeMap, >, } +/// One wallet's coalesced sent-payment journal for a single drain round, +/// keyed — like the store overlay — per `(owner, txid)`. See +/// [`fold_payment_flips`]. +type PaymentRoundLedger = BTreeMap<(dpp::prelude::Identifier, String), PaymentRoundEntry>; + +/// What one drain round did to one sent payment — exactly what any +/// consumer of the round needs, and the ONLY record of it: +/// +/// - the store overlay row is `staged` (materialized before commit); +/// - the rejected-round undo is "restore `previous` while `staged.status` +/// still stands"; +/// - the same-fold retraction drops the whole entry and applies that +/// same undo. +/// +/// `previous` is pinned by the round's FIRST flip and never overwritten, +/// so it is always the durable pre-round entry (at fold start memory +/// equals the durable state: the adapter is the only staging writer, a +/// stored round made its flips durable, a rejected one was rolled back, +/// and the reconcile pass persists memory-and-store atomically). +/// `staged` follows the round's LAST flip. Intermediate flips leave no +/// trace — there is no history to unwind, which is what makes rollback +/// and retraction structurally unable to disagree with the staged row. +#[derive(Debug, Clone)] +struct PaymentRoundEntry { + /// The entry as durably stored before this round's first flip. + previous: crate::wallet::identity::PaymentEntry, + /// The entry this round currently intends to leave staged. + staged: crate::wallet::identity::PaymentEntry, +} + /// Rebuild missing tracked asset locks from the records an event /// carries (see [`crate::wallet::asset_lock::sync::reconstruction`]). /// @@ -879,8 +949,8 @@ async fn reconstruct_asset_locks_for_event( /// The payment half of a sweep: flip the losers' sent DashPay payments /// (`Pending` — or `Confirmed`, when this sweep postdates the entry's -/// confirmation) to `Failed` in memory and hand back the overlay + -/// rollback the drain loop stages into the sweep's own store round. +/// confirmation) to `Failed` in memory and hand back the flips the drain +/// loop journals into the sweep's own store round. /// Every other event is a no-op. See [`WalletBatch::payments_overlay`] /// for why this rides the round rather than persisting on its own. async fn swept_payment_flips_for_event( @@ -915,10 +985,11 @@ async fn swept_payment_flips_for_event( /// 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. +/// - the payment round journal (from which `payments_overlay` is +/// materialized at commit) — lives at DRAIN level, not inside any +/// sub-changeset's `Merge`, so its 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 @@ -927,14 +998,22 @@ async fn swept_payment_flips_for_event( /// would commit X's reinstated record beside a stale `Failed` overlay /// row. /// -/// 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`], returning the entry to `Pending` so the -/// ordered confirm running right after this retraction +/// Two moves per reinstated txid whose round-final staged status is +/// `Failed`, both before the journal can be materialized into a store: +/// drop the journal entry whole (its staged row and its undo are the same +/// record — a later rejection of this round must not replay a dead undo), +/// and restore the DURABLE pre-round entry to memory through the guarded +/// [`rollback_payment_flips`], so the ordered confirm running right after +/// this retraction /// ([`confirm_final_sent_payments_for_store`](crate::wallet::identity::network)) -/// takes it straight to `Confirmed` on this same round. +/// re-derives `Confirmed` from the reinstating record on this same round +/// and journals a fresh `previous → Confirmed` entry. Restoring the +/// pre-round entry — not any intermediate flip's snapshot — is what keeps +/// a `[finality(X), sweep(X), reinstating record(X)]` fold sound: the +/// entry returns to `Pending`, the confirm genuinely transitions, and the +/// reinstated record commits WITH its payment correction. An entry whose +/// staged status is `Confirmed` asserts exactly what the reinstating +/// record says and stays on the round. /// /// This function only sees records THIS drain captured, and that is /// enough: a reinstating record queued after `try_recv` stopped folding @@ -946,21 +1025,17 @@ async fn swept_payment_flips_for_event( /// table permits. async fn retract_reinstated_payment_flips( wallet_manager: &Arc>>, - entry: &mut WalletBatch, - payment_rollbacks: &mut BTreeMap< - WalletId, - Vec, - >, + payment_journal: &mut BTreeMap, 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 { + let Some(ledger) = payment_journal.get_mut(&wallet_id) else { + return; + }; + if ledger.is_empty() { return; } use crate::wallet::identity::types::dashpay::payment::PaymentStatus; @@ -970,66 +1045,65 @@ async fn retract_reinstated_payment_flips( .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; + // Only rounds whose FINAL staged verdict is the sweep-derived + // `Failed` are retracted; a staged `Confirmed` stays on the round. + let mut undo = Vec::new(); + ledger.retain(|(owner, txid), round| { + if reinstated.contains(txid) && round.staged.status == PaymentStatus::Failed { + undo.push(crate::wallet::identity::network::PaymentFlipUndo { + owner: *owner, + txid: txid.clone(), + wrote: round.staged.status, + previous: round.previous.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. +/// Fold one event's sent-payment flips into the drain's round journal — +/// the ONE structure the round's store overlay, rejected-round rollback, +/// and same-fold retraction are all projections of. Per `(owner, txid)`: +/// the first flip of the round pins `previous` (the durable pre-round +/// entry — the fold never overwrites it), and every flip updates `staged` +/// (last write wins, matching `PlatformWalletChangeSet::merge`'s overlay +/// rule). A repeated flip therefore coalesces instead of appending +/// history: whatever a rejected round must restore is always `previous`, +/// guarded by the `staged` status the round actually offers the store — +/// there is no intermediate undo for a rollback to skip or a retraction +/// to half-apply, and a future writer folding more flips through here +/// cannot reintroduce that divergence. 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, - >, + payment_journal: &mut BTreeMap, 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); + let ledger = payment_journal.entry(wallet_id).or_default(); + for flip in flips.flips { + match ledger.entry((flip.owner, flip.txid)) { + std::collections::btree_map::Entry::Occupied(mut occupied) => { + occupied.get_mut().staged = flip.updated; + } + std::collections::btree_map::Entry::Vacant(vacant) => { + vacant.insert(PaymentRoundEntry { + previous: flip.previous, + staged: flip.updated, + }); + } + } } - payment_rollbacks - .entry(wallet_id) - .or_default() - .extend(flips.rollback); } /// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable @@ -4547,6 +4621,283 @@ mod tests { handle.await.expect("adapter task joins"); } + /// REGRESSION (dashpay/platform#4442 review, rejected-store leg): a + /// buffered `[IS-lock(X), Swept(X)]` round from durably `Pending` + /// flips the entry twice (`Pending → Confirmed → Failed`) but must + /// journal ONE coalesced verdict — so when the store rejects the + /// round, the rollback restores the durable pre-round state, + /// `Pending`, and the replayed sweep finds the entry eligible again. + /// + /// Before the fix the rollback ledger appended every intermediate + /// undo: the forward guarded replay skipped the first (`wrote = + /// Confirmed` no longer stood) and applied only the sweep's, leaving + /// memory at the in-round `Confirmed` — a state the store never held + /// — where the replayed sweep's `Confirmed → Failed` flip would + /// commit a demotion of a confirmation that never existed durably. + #[tokio::test] + async fn a_rejected_flip_round_restores_the_pre_round_durable_state() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::account::account_type::StandardAccountType; + + use super::spawn_wallet_event_adapter; + use crate::test_support::funded_wallet_manager; + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = dpp::prelude::Identifier::from([0xAA; 32]); + let contact = dpp::prelude::Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0xF7; 32]); + seed_pending_sent_payment(&wallet_manager, wallet_id, owner, contact, txid).await; + + 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), + )); + persister.fail_next(wallet_id); + let (event_tx, event_rx) = unbounded_channel(); + + // Queue BOTH events before the adapter task spawns, which is what + // makes the single-fold deterministic: the first `recv` takes the + // IS-lock and the backlog `try_recv` folds the sweep. + event_tx + .send(WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send IS-lock event"); + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xF8; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep"); + + 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 rejected fold"); + assert!(observed.rejected, "the probe rejects this round"); + assert_eq!( + observed.n_payment_overlay_rows, 1, + "the round coalesces to one staged verdict per (owner, txid)" + ); + assert_eq!(observed.n_payment_overlay_confirmed, 0); + + // 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 sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.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 restore the durable PRE-ROUND state (Pending) — \ + not any intermediate flip's snapshot" + ); + + // The replayed sweep (the re-scan re-emits it, because the + // rejected round kept the loser's record too) finds the entry + // eligible and recomputes the flip on a clean round. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xF8; 32]), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep 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!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Failed + ); + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// REGRESSION (dashpay/platform#4442 review, three-event leg): + /// `[IS-lock(X), Swept(X), BlockProcessed(chainlocked X)]` in ONE + /// fold. The reinstating record's retraction must return the entry to + /// the durable pre-round `Pending` — dropping the coalesced journal + /// entry whole — so the ordered confirm genuinely transitions + /// `Pending → Confirmed` and the fold commits the reinstated record + /// WITH its payment correction. + /// + /// Before the fix the retraction unwound only the sweep's own undo, + /// whose captured `previous` was the same round's in-memory + /// `Confirmed`: memory came back `Confirmed`, the confirm no-opped + /// (same-state moves are not transitions), and — the earlier + /// `Confirmed` overlay row having been overwritten by the sweep's, + /// then retracted — the round committed the reinstated record with NO + /// payment row at all, leaving the payment durably `Pending` beside a + /// chainlocked record while memory claimed `Confirmed`. + #[tokio::test] + async fn a_three_event_reinstatement_fold_commits_the_confirmed_row() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + 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; + use crate::wallet::identity::types::dashpay::payment::PaymentStatus; + + let (wallet_manager, wallet_id, _generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let owner = dpp::prelude::Identifier::from([0xAA; 32]); + let contact = dpp::prelude::Identifier::from([0xBB; 32]); + + // X: IS-locked, swept, and returned chainlocked — all in one + // buffered fold. + let tx = dashcore::Transaction { + version: 1, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint::new(dashcore::Txid::from([0xD2; 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; + seed_pending_sent_payment(&wallet_manager, wallet_id, owner, contact, txid).await; + + 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 all THREE events before the adapter task spawns: the + // first `recv` takes the IS-lock and the backlog `try_recv` folds + // the sweep, then the reinstating chainlocked record. + event_tx + .send(WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send IS-lock event"); + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![txid], + superseded_by: dashcore::Txid::from([0xD3; 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, 1, + "the fold must carry the reinstating record's payment correction" + ); + assert_eq!( + observed.n_payment_overlay_confirmed, 1, + "the retraction restores the durable pre-round Pending, so the \ + ordered confirm re-stages Confirmed — the reinstated record must \ + never commit without its payment correction" + ); + assert_eq!( + sent_payment_status(&wallet_manager, &wallet_id, &owner, &txid.to_string()).await, + PaymentStatus::Confirmed + ); + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + /// 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 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 84c002d86a4..025d5fba47a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1027,26 +1027,37 @@ async fn confirm_sent_payment_by_txid( /// `Confirmed` rows ([`confirm_final_sent_payments_for_store`]). #[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, + /// The flips applied to memory, in application order. Each is the + /// SINGLE record of its flip — the store overlay row, the + /// rejected-round undo, and the same-fold retraction are all derived + /// from it by the wallet-event adapter's round journal (see + /// `fold_payment_flips` in the core bridge), never bookkept apart. + pub flips: Vec, +} + +/// One applied in-memory sent-payment flip: the entry as it stood before +/// (`previous`) and as memory now holds it (`updated`). Everything a +/// round needs is a projection of this pair: the staged overlay row is +/// `updated`, and the guarded undo is "restore `previous` while +/// `updated.status` still stands" ([`PaymentFlipUndo`]). +#[derive(Debug, Clone)] +pub(crate) struct PaymentFlip { + pub owner: Identifier, + pub txid: String, + /// The entry as it stood before this flip. + pub previous: crate::wallet::identity::PaymentEntry, + /// The entry as memory holds it after the flip. + pub updated: crate::wallet::identity::PaymentEntry, } /// 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 `→ Failed` -/// ([`flip_swept_sent_payments_for_store`], `wrote = Failed`) and the -/// ordered confirm's `→ Confirmed` -/// ([`confirm_final_sent_payments_for_store`], `wrote = Confirmed`). +/// Derived, never bookkept: the adapter projects it out of its coalesced +/// round journal (`previous` = the durable pre-round entry, `wrote` = the +/// status the round last staged), so an undo can never describe a +/// different history than the staged row it guards. #[derive(Debug, Clone)] pub(crate) struct PaymentFlipUndo { pub owner: Identifier, @@ -1059,7 +1070,25 @@ pub(crate) struct PaymentFlipUndo { impl SweptPaymentFlips { pub(crate) fn is_empty(&self) -> bool { - self.overlay.is_empty() + self.flips.is_empty() + } + + /// Project every flip into its guarded undo, in application order — + /// what [`rollback_payment_flips`] takes. Production rollbacks go + /// through the adapter's coalesced round journal instead (one undo + /// per `(owner, txid)`); this uncoalesced projection exists for the + /// single-flip unit tests. + #[cfg(test)] + pub(crate) fn into_undos(self) -> Vec { + self.flips + .into_iter() + .map(|flip| PaymentFlipUndo { + owner: flip.owner, + txid: flip.txid, + wrote: flip.updated.status, + previous: flip.previous, + }) + .collect() } } @@ -1116,16 +1145,11 @@ pub(crate) async fn flip_swept_sent_payments_for_store( managed .dashpay_payments_mut() .insert(key.clone(), updated.clone()); - flips - .overlay - .entry(owner) - .or_default() - .insert(key.clone(), updated); - flips.rollback.push(PaymentFlipUndo { + flips.flips.push(PaymentFlip { owner, txid: key.clone(), previous, - wrote: PaymentStatus::Failed, + updated, }); // txid is unique — only one identity can hold this entry. break 'owners; @@ -1136,8 +1160,8 @@ pub(crate) async fn flip_swept_sent_payments_for_store( /// The adapter-owned sent-payment confirmation: flip the `Sent` entries /// whose transaction `event` proves final to `Confirmed` in memory, and -/// return the overlay + rollback for the wallet-event adapter to ride on -/// the event's OWN store round. Persists NOTHING itself — the caller owns +/// return the flips for the wallet-event adapter to journal onto the +/// event's OWN store round. Persists NOTHING itself — the caller owns /// the round. /// /// This is the ONLY live confirmation path. It runs on the adapter's @@ -1277,16 +1301,11 @@ pub(crate) async fn confirm_final_sent_payments_for_store( managed .dashpay_payments_mut() .insert(key.clone(), updated.clone()); - flips - .overlay - .entry(owner) - .or_default() - .insert(key.clone(), updated); - flips.rollback.push(PaymentFlipUndo { + flips.flips.push(PaymentFlip { owner, txid: key.clone(), previous, - wrote: PaymentStatus::Confirmed, + updated, }); // txid is unique — only one identity can hold this entry. break 'owners; @@ -3250,8 +3269,9 @@ mod tests { /// ([`confirm_final_sent_payments_for_store`]) moves it live, so /// before that path was wired the entry was stuck `Pending` forever /// (sent payments never showed confirmed). Pins the flip riding the - /// event's own round (overlay + rollback), idempotency on - /// re-delivery, and that amount/memo are preserved. + /// event's own round (the `previous`/`updated` pair the adapter + /// journals), idempotency on re-delivery, and that amount/memo are + /// preserved. #[tokio::test] async fn confirm_flips_sent_payment_pending_to_confirmed() { use dashcore::ephemerealdata::instant_lock::InstantLock; @@ -3326,16 +3346,18 @@ mod tests { let flips = super::confirm_final_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &event) .await; + assert_eq!(flips.flips.len(), 1); + assert_eq!(flips.flips[0].owner, owner); + assert_eq!(flips.flips[0].txid, txid_key); assert_eq!( - flips.overlay[&owner][&txid_key].status, + flips.flips[0].updated.status, PaymentStatus::Confirmed, "the Confirmed row must ride the event's own round" ); - assert_eq!(flips.rollback.len(), 1); assert_eq!( - flips.rollback[0].wrote, - PaymentStatus::Confirmed, - "the undo may only revert this flip's own write" + flips.flips[0].previous.status, + PaymentStatus::Pending, + "the flip records the pre-flip entry its undo may restore" ); let entry = read_entry(iw, &wallet_id, &owner, &txid_key).await; assert_eq!( @@ -3430,12 +3452,14 @@ mod tests { let flips = super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) .await; + assert_eq!(flips.flips.len(), 1); + assert_eq!(flips.flips[0].owner, owner); + assert_eq!(flips.flips[0].txid, txid_key); assert_eq!( - flips.overlay[&owner][&txid_key].status, + flips.flips[0].updated.status, PaymentStatus::Failed, - "the overlay must carry the Failed row for the sweep's own round" + "the flip 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, @@ -3477,7 +3501,7 @@ mod tests { ) .await; assert_eq!( - flips.overlay[&owner][&txid_key].status, + flips.flips[0].updated.status, PaymentStatus::Confirmed, "the reinstatement correction must ride the event's own round" ); @@ -3496,7 +3520,7 @@ mod tests { super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) .await; assert_eq!( - flips.overlay[&owner][&txid_key].status, + flips.flips[0].updated.status, PaymentStatus::Failed, "a newer sweep must demote a Confirmed entry — its Failed row rides its round" ); @@ -3583,7 +3607,7 @@ mod tests { 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; + super::rollback_payment_flips(&iw.wallet_manager, &wallet_id, flips.into_undos()).await; { let wm = iw.wallet_manager.read().await; @@ -3614,7 +3638,7 @@ mod tests { super::flip_swept_sent_payments_for_store(&iw.wallet_manager, &wallet_id, &[txid]) .await; assert_eq!( - flips.overlay[&owner][&txid_key].status, + flips.flips[0].updated.status, PaymentStatus::Failed, "the replayed sweep must recompute the flip the rollback undid" ); @@ -3791,7 +3815,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_payment_flips(&iw.wallet_manager, &wallet_id, flips.rollback).await; + super::rollback_payment_flips(&iw.wallet_manager, &wallet_id, flips.into_undos()).await; let wm = iw.wallet_manager.read().await; let status = wm