From 0d61df8cb06ee5a177b4c5b8f98ef77a2ede82b3 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 19:12:18 -0700 Subject: [PATCH 1/3] fix(platform-wallet): make asset-lock spends visible to every balance reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An SDK-built asset lock left both persistence mirrors and the per-account balance aggregates believing its funding TXOs were still spendable, long after the lock chain-locked and its top-up was credited — observed live as an Android wallet displaying 0.21 DASH with 0.1 spendable, and a MAX button offering the phantom total. Two independent gaps, one cause: an asset-lock tx burns its value into the special-tx PAYLOAD and often has no wallet-owned standard output, so SPV block matching can miss it and the spender's transaction record never leaves mempool context. Gap 1 — the persistence mirrors never flipped isSpent. Both handlers flip a TXO's spent flag only when the SPENDING tx reaches in-block context; for a block-matching-missed asset lock that context advance never arrives, so the TXO kept isSpent=false (spendingTxid set) forever. The lock's own STATUS is a signal that provably does arrive — the proof wait drives Built → Broadcast → InstantSendLocked/ChainLocked → Consumed upserts. From InstantSendLocked on, the network has locked the inputs, so the asset-lock upsert now flips the TXOs already linked to the lock's funding txid. Kotlin (onPersistAssetLockUpsert) and Swift (persistAssetLocks) get the same reconcile; a terminal Consumed upsert heals rows an earlier missed IS/CL left stale. Gap 2 — account_balances_blocking served the cached per-account balance field, which refreshes only when transaction processing runs update_balance(); the miss above leaves it stale indefinitely, while the coin selection reads the live UTXO set (and was always right). The accessor now computes the balance read-only from account.utxos with the exact bucket rules of ManagedCoreFundsAccount::update_balance, so the per-account snapshot derives from the same source selection uses and cannot disagree with it. Deeper engine follow-up (out of scope here): promote a self-authored transaction's record context by txid when its block is processed, so records do not depend on script matching — that would make the reconciles above redundant rather than load-bearing. Tests: Rust — a dirtied UTXO set with a deliberately stale cache, the accessor must report the live truth (571+1 pass). Kotlin/Robolectric — Broadcast must NOT flip, InstantSendLocked flips, terminal Consumed heals a stale row. clippy clean; kotlin-sdk unit suite green. Swift mirror is compile-unverified here (no iOS toolchain run) — flagged for CI. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 26 ++++++ .../PlatformWalletPersistenceHandlerTest.kt | 77 ++++++++++++++++ .../src/manager/accessors.rs | 88 ++++++++++++++++++- .../PlatformWalletPersistenceHandler.swift | 27 ++++++ 4 files changed, 217 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 abc115a0726..16dbc08d766 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 @@ -1523,6 +1523,23 @@ class PlatformWalletPersistenceHandler( updatedAt = now(), ), ) + // Spend-visibility reconcile: an asset-lock tx burns its value + // into the special-tx PAYLOAD and often has no wallet-owned + // standard output, so SPV block matching can miss it entirely — + // the spender's transaction row then never leaves mempool + // context and onWalletChangesetTransaction's in-block flip never + // runs, leaving the funding TXOs isSpent=0 (spendingTxid set) + // FOREVER. The lock's own STATUS is a signal that provably + // does arrive (the proof wait drives it): once it reaches + // InstantSendLocked (2) the network has locked the inputs, so + // flip the linked TXOs here. Monotonic, and keyed strictly to + // TXOs already linked to THIS lock's funding txid. + if ((status.toInt() and 0xFF) >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { + val fundingTxid = outPoint.copyOfRange(0, 32) + for (txo in db.txoDao().getUnspentBySpendingTxid(fundingTxid)) { + db.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now())) + } + } } 0 } @@ -3090,6 +3107,15 @@ class PlatformWalletPersistenceHandler( /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 + /** + * Rust `AssetLockStatus` wire bytes (asset_lock_persistence.rs): + * Built 0, Broadcast 1, InstantSendLocked 2, ChainLocked 3, + * Consumed 4. At InstantSendLocked the network has locked the + * funding inputs — the spend-visibility reconcile treats the + * linked TXOs as spent from there. + */ + private const val ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED = 2 + /** `Network.testnet` rawValue — the Swift fallback network. */ private const val NETWORK_TESTNET = 1 diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index c3d16f7c511..fa36f5dc23d 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 @@ -9,6 +9,8 @@ import org.dashfoundation.dashsdk.errors.DashSdkError 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.TransactionEntity +import org.dashfoundation.dashsdk.persistence.entities.TxoEntity import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity @@ -2762,4 +2764,79 @@ class PlatformWalletPersistenceHandlerTest { // Unchanged pre-invitation behavior: no account row conjured. assertTrue(db.accountDao().observeByWallet(walletId).first().isEmpty()) } + + // ── Asset-lock spend visibility ──────────────────────────────────── + + /** + * An asset-lock tx burns its value into the special-tx payload and often + * has no wallet-owned standard output, so SPV block matching can miss it: + * the spender's transaction row never advances past mempool context and + * the in-block flip in onWalletChangesetTransaction never runs — the + * funding TXO sits at isSpent=false (spendingTxid set) FOREVER, and every + * isSpent-based balance read overstates the wallet. The lock's own status + * DOES keep arriving; from InstantSendLocked on, the upsert must flip + * linked TXOs. + */ + @Test + fun assetLockStatusAdvanceFlipsItsFundingTxos() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val lockTxid = ByteArray(32) { 7 } + db.transactionDao().upsert( + TransactionEntity(txid = lockTxid, transactionData = ByteArray(4), context = 0), + ) + val outpoint = ByteArray(36) { 9 } + db.txoDao().upsert( + TxoEntity( + outpoint = outpoint, + vout = 0, + amount = 1_000_000, + address = "yTest", + walletId = walletId, + spendingTxid = lockTxid, + spendingInputIndex = 0, + isSpent = false, + ), + ) + + // Broadcast (1) must NOT flip — the network holds no lock yet and a + // pre-broadcast abort could still release the inputs. + handler.onPersistAssetLockUpsert( + walletId, lockTxid + ByteArray(4), ByteArray(4), 0, 1, 0, 999_545, 1, null, + ) + assertFalse(db.txoDao().getByOutpoint(outpoint)!!.isSpent) + + // InstantSendLocked (2): the network has locked the inputs — flip. + handler.onPersistAssetLockUpsert( + walletId, lockTxid + ByteArray(4), ByteArray(4), 0, 1, 0, 999_545, 2, null, + ) + assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent) + } + + /** The Consumed (4) terminal upsert heals rows a missed IS/CL never flipped. */ + @Test + fun assetLockConsumedHealsAStaleUnspentRow() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val lockTxid = ByteArray(32) { 8 } + db.transactionDao().upsert( + TransactionEntity(txid = lockTxid, transactionData = ByteArray(4), context = 0), + ) + val outpoint = ByteArray(36) { 10 } + db.txoDao().upsert( + TxoEntity( + outpoint = outpoint, + vout = 0, + amount = 9_999_545, + address = "yTest2", + walletId = walletId, + spendingTxid = lockTxid, + spendingInputIndex = 0, + isSpent = false, + ), + ) + + handler.onPersistAssetLockUpsert( + walletId, lockTxid + ByteArray(4), ByteArray(4), 0, 1, 0, 9_999_545, 4, null, + ) + assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent) + } } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ef0c780e3eb..30c962c22fd 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -385,6 +385,7 @@ impl PlatformWalletManager

{ let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; + let last_processed_height = info.core_wallet.metadata.last_processed_height; info.core_wallet .accounts .all_accounts() @@ -393,7 +394,18 @@ impl PlatformWalletManager

{ // Balance lives on the funds-bearing variant only; // keys-only accounts (identity, asset-lock, provider) // never carry UTXOs. - let balance = account.as_funds().map(|a| a.balance).unwrap_or_default(); + // + // Computed FRESH from the account's UTXO set — NOT the cached + // `a.balance` field. The cache refreshes only when transaction + // processing runs `update_balance()`, and a self-authored + // asset-lock spend can leave it stale long after the UTXO set + // (which coin selection reads) has moved on. Deriving from the + // same source selection uses makes disagreement impossible; + // the fold is bounded by the account's UTXO count. + let balance = account + .as_funds() + .map(|a| computed_core_balance(a, last_processed_height)) + .unwrap_or_default(); // Walk every pool on the account, sum // `used` + total entries. Cheap — pools are bounded by // the gap limit. @@ -1237,3 +1249,77 @@ mod spv_rescan_tests { .expect("blocking accessor task"); } } + +/// Read-only [`WalletCoreBalance`] over an account's live UTXO set, with the +/// exact bucket rules of `ManagedCoreFundsAccount::update_balance` (which +/// requires `&mut self` and mutates the cache, so it cannot serve a +/// read-path): locked, else immature, else confirmed when in a block / +/// InstantSend-locked / trusted change, else unconfirmed. +fn computed_core_balance( + account: &key_wallet::managed_account::ManagedCoreFundsAccount, + last_processed_height: u32, +) -> key_wallet::wallet::balance::WalletCoreBalance { + let mut confirmed = 0u64; + let mut unconfirmed = 0u64; + let mut immature = 0u64; + let mut locked = 0u64; + for utxo in account.utxos.values() { + let value = utxo.txout.value; + if utxo.is_locked { + locked += value; + } else if !utxo.is_mature(last_processed_height) { + immature += value; + } else if utxo.is_confirmed || utxo.is_instantlocked || utxo.is_trusted { + confirmed += value; + } else { + unconfirmed += value; + } + } + key_wallet::wallet::balance::WalletCoreBalance::new(confirmed, unconfirmed, immature, locked) +} + +#[cfg(test)] +mod computed_balance_tests { + use super::*; + use key_wallet::account::StandardAccountType; + + /// The accessor's per-account figure must come from the LIVE UTXO set, + /// not the cached balance field: a self-authored asset-lock spend can + /// leave the cache stale long after selection has moved on. Dirty the + /// UTXO set + /// without running update_balance and assert the fold reports the fresh + /// truth while the cache still holds the stale figure. + #[tokio::test] + async fn computed_balance_ignores_the_stale_cache() { + let (wallet_manager, wallet_id, _balance, _signer) = + crate::test_support::funded_wallet_manager(StandardAccountType::BIP44Account).await; + + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + let height = info.core_wallet.metadata.last_processed_height; + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account 0"); + + // Freshen the cache, then remove every UTXO WITHOUT refreshing it — + // the shape an unprocessed self-spend leaves behind. + account.update_balance(height); + let cached_before = account.balance; + assert!(cached_before.total() > 0, "fixture must be funded"); + account.utxos.clear(); + + assert_eq!( + account.balance.total(), + cached_before.total(), + "precondition: the cache must still hold the stale figure" + ); + assert_eq!( + computed_core_balance(account, height).total(), + 0, + "the accessor's fold must see the live (empty) UTXO set" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a665f6d1e9f..e288496eb81 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -280,6 +280,33 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) backgroundContext.insert(record) } + + // Spend-visibility reconcile (mirror of the Kotlin handler's + // onPersistAssetLockUpsert): an asset-lock tx burns its value + // into the special-tx payload and often has no wallet-owned + // standard output, so SPV block matching can miss it — the + // spender's transaction row then never leaves mempool context + // and resolveInputOutpoint's in-block flip never runs, leaving + // the funding TXOs isSpent=false forever. The lock's + // own STATUS keeps arriving via this callback; from + // InstantSendLocked (2) the network has locked the inputs, so + // flip the TXOs already linked to this lock's funding tx. + if entry.statusRaw >= 2, + let displayTxidHex = entry.outPointHex.split(separator: ":").first, + let displayTxid = Data(hexString: String(displayTxidHex)) { + let wireTxid = Data(displayTxid.reversed()) + let staleDescriptor = FetchDescriptor( + predicate: #Predicate { + $0.spendingTransaction?.txid == wireTxid && $0.isSpent == false + } + ) + if let stale = try? backgroundContext.fetch(staleDescriptor) { + for txo in stale { + txo.isSpent = true + txo.lastUpdated = Date() + } + } + } } for outPointHex in removed { From 4deaf3bc905180b0d1afd8e5c007c094bff2c180 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 19:13:05 -0700 Subject: [PATCH 2/3] fix(kotlin-sdk): stop the UTXO restore resurrecting asset-lock-consumed outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the spend-visibility fix for the RESTART path. The restore guard skipped a spent-linked TXO only when its spending tx had reached in-block context — the very context a block-matching-missed asset lock never reaches — so every relaunch handed the consumed outputs back to Rust as spendable and the engine's lock-free balance re-inflated (observed live: a 0.1 wallet re-showing 0.22 after each restart, and persisting the phantom as the next launch's holding figure). The guard now also consults the tracked lock's own status — the finality signal that provably arrives — via the spending txid's outpoint key (credit outpoints are always vout 0): from InstantSendLocked on, the output is skipped AND its isSpent flag is healed in place, so already-poisoned wallets converge on their next launch without waiting for a status re-upsert that terminal Consumed rows will never send. kotlin-sdk unit suite green. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 16dbc08d766..fe25142c4bb 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 @@ -2284,6 +2284,23 @@ class PlatformWalletPersistenceHandler( if (spendingTxid != null) { val spending = database.transactionDao().getByTxid(spendingTxid) if (spending != null && spending.context >= CONTEXT_IN_BLOCK) continue + // Asset-lock spender: the lock tx burns its value into the + // special-tx payload and often has no wallet-owned standard + // output, so SPV block matching can miss it and its row sits + // at mempool context FOREVER — the guard above never fires, + // and every relaunch resurrects the consumed output into the + // engine's balance. The tracked lock's own status is the + // finality signal that provably arrives; from + // InstantSendLocked on this output is gone. Skip it, and + // heal the flag so isSpent-based readers stop counting it. + val lockKey = encodeOutPointHex(spendingTxid + ByteArray(4)) + val lock = database.assetLockDao().getByOutPointHex(lockKey) + if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { + if (!txo.isSpent) { + database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now())) + } + continue + } } val account = txo.accountId?.let { database.accountDao().getById(it) } ?: accountByAddress.getOrPut(txo.address) { From 34aa2a0b7cacb816ede86b7468fe451bcc4250ab Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 14:04:22 +0700 Subject: [PATCH 3/3] fix(swift-sdk): heal asset-lock-consumed UTXOs at load; harden the Kotlin heals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #4336. Swift load-path heal (blocking finding). The callback-time reconcile in `persistAssetLocks` only fires when a lock upserts again, and `Consumed` is terminal — it never upserts again. A wallet whose lock finalized before that reconcile existed therefore has no future callback to repair its funding TXOs, and `loadWalletList`'s `isSpent == false` fetch handed them back to Rust as spendable on every launch. `loadWalletList` now collects the funding txids of every lock at `InstantSendLocked` or beyond, drops the TXOs those locks consumed from the restore set, and saves the repaired flags opportunistically (log + rollback on failure, the `healIdentityIsLocalFlags` pattern). Exclusion happens before the save, so it does not depend on the repair becoming durable. Non-clobbering, non-fatal heal writes. Both Kotlin heals were read-then-upsert round trips that wrote the whole row back from an in-memory copy. They are now column-scoped `UPDATE … SET isSpent = 1 … WHERE isSpent = 0` DAO queries: they cannot regress an already-spent row and cannot write back stale copies of columns they do not own. Vout-independent Kotlin finality lookup. The load guard probed the synthetic `:0` outpoint, but DIP-0027 lets one funding transaction carry several credit outputs and `wallet/asset_lock/sync/reconstruction.rs` persists each tracked lock under its own credit-output index — a valid lock can live at `:1` with no `:0` row at all, and the guard missed it. Replaced with `AssetLockDao.maxStatusForTxid`, the same guarded 64-hex `:` prefix match `fundingTypeForTxid` already uses, reduced with `MAX`: finality belongs to the transaction, so any of its locks reaching `InstantSendLocked` means the inputs are gone. One explicit failure policy for that lookup. It is the only new read in `buildUtxoRestoreData`, it runs under `guardedLoad(emptyArray())`, and the Android load surface is array-only — an escaping failure returned a SUCCESSFUL EMPTY restore, which Rust reads as a fresh coinless device for every wallet. `spendByFinalizedAssetLock` now answers `true` / `false` / `null`, where `null` means "could not read": the caller drops that one candidate and never heals it (nothing was proven), and every unrelated wallet, account and TXO still restores. Under-reporting one output for a launch is recoverable and non-durable; handing a consumed output back as spendable is what the guard exists to stop. Swift error propagation. Two load-bearing `try?`s failed OPEN, each in the direction that defeats its own guard: - the callback-time reconcile treated a stale-TXO fetch failure as "no rows to heal" and the shim returned 0, so a terminal `Consumed` status could commit while its funding TXOs stayed `isSpent == false` with no future upsert to repair them. `persistAssetLocks` is now result-bearing and `persistAssetLocksCallback` returns nonzero, which fails the Rust round and rolls the changeset back. - `finalizedAssetLockFundingTxids` mapped a thrown fetch to an empty set, and the caller reads an empty set as the positive claim "no lock has finalized" — restoring exactly the phantom inputs the guard withholds. It now throws, and `loadWalletList` returns `(nil, 0, true)` like the adjacent unspent fetch. Rust regression drives the production accessor. The previous version asserted on `computed_core_balance` directly, so it never called `account_balances_blocking` and stayed green against the pre-fix cached read. `account_balances_blocking_ignores_the_stale_cache` now builds a `PlatformWalletManager` over the funded fixture and asserts the returned `AccountBalanceRow` buckets in three steps: reproduce a freshly updated non-empty balance, track a live confirmed→locked reclassification the cache has not seen, then track the drain to zero. The direct-fold assertions remain as `computed_core_balance_matches_update_balance_bucket_for_bucket`, a bucket-policy companion (the fold duplicates `ManagedCoreFundsAccount::update_balance` and nothing in the type system keeps the two in step). Regression coverage, red before / green after. - `account_balances_blocking_ignores_the_stale_cache` (Rust): FAIL with line 432 reverted to `.map(|a| a.balance)` — `(10000000,0,0,0)` vs the expected `(3000000,0,0,7000000)` — and FAIL against a `WalletCoreBalance::default()` helper stub; PASS as written. - `loadSkipsATxoConsumedByAFinalizedAssetLockPersistedAtANonZeroVout` (Kotlin): FAIL with the vout-0 `getByOutPointHex` probe restored; PASS with the txid-prefix lookup. - `aFailingFinalizedLockLookupDropsOnlyItsOwnCandidate` (Kotlin): a Room open-helper factory faults the single `SELECT MAX(statusRaw) FROM asset_locks` statement — not the table, which two other restore builders also read — and asserts an unrelated TXO on the same wallet and an unrelated wallet's TXO both still restore, and that the unanswered candidate is dropped unhealed. FAIL with the failure allowed to escape (the whole restore comes back empty); PASS as written. - `AssetLockSpendVisibilityTests` (Swift, new): the SwiftData coverage both heals were missing — load excludes and heals a TXO consumed by a finalized lock, does so for a lock persisted at a non-zero vout, and the callback reconcile flips the linked TXO and reports success. Each FAILs with its guard disabled; all PASS as written. - `testPersistAssetLocksFailsTheRoundWhenTheStaleTxoFetchThrows` (Swift): faults only the `PersistentTxo` read, then asserts `persistAssetLocks` reports failure, the round closed as failed, and no `PersistentAssetLock` row committed. FAIL with the catch reverted to log-and-continue — the round reports success, commits, and lands the terminal status over funding TXOs still `isSpent == false`; PASS as written. - `testLoadReportsFailureWhenTheFinalizedLockFetchThrows` (Swift): faults only the `PersistentAssetLock` read, asserts the seam observed `PersistentWallet, PersistentTxo, PersistentAssetLock` (the earlier reads were served, so the failure is the lock read alone), then `errored == true` with nil entries and count 0. FAIL with the fetch reverted to `try? … ?? []` — the load returns a successful one-entry restore carrying the phantom UTXO; PASS as written. Also names the Swift `InstantSendLocked` threshold instead of a bare `2`, mirroring Kotlin, and corrects both status docs to list `RecoveredFromChain` (5) — added by #4347 after this branch forked. Fault-injection seam for the Swift fail-closed reads. Both Swift guards only take their failure branch when a `fetch` throws, which a live store never does on demand, and store-level corruption is no substitute: it trips `loadWalletList`'s earlier wallet/TXO fetches too, so a load test built on it would pass for the wrong reason. The reads whose failure must reject the round therefore go through `ModelFetching`, a handler-owned one-method protocol whose production implementation is `ModelContext.fetch` verbatim. A test injects a fetcher that faults one model type and serves every other read live, so each regression pins its own fetch — the load test asserts the wallet and unspent-TXO reads were served before the lock read threw. Verified: `cargo nextest run -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-features --locked -E 'not test(~shield)'` 1580 passed; `cargo test -p platform-wallet --lib` 890 passed; `cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi -p rs-unified-sdk-jni --all-features --all-targets` clean for these crates; `cargo fmt --all --check` clean; `:sdk:testDebugUnitTest :sdk:testReleaseUnitTest :sdk:compileDebugAndroidTestKotlin` green; `xcodebuild test -scheme SwiftDashSDK -only-testing:SwiftDashSDKTests` on an iOS 26 simulator, 391 tests, only the two pre-existing `KeychainSignerAdditionalSigningKeys` keychain-environment failures (identical with this branch's Swift changes reverted). `rs-drive`'s unused `DocumentPropertyType` import (a7ccac2449) makes a workspace-wide `-D warnings` clippy fail on unmodified code — pre-existing, not from here. Co-Authored-By: Claude Opus 5 --- .../PlatformWalletPersistenceHandler.kt | 77 +++- .../dashsdk/persistence/dao/AssetLockDao.kt | 28 ++ .../dashsdk/persistence/dao/TxoDao.kt | 26 ++ .../PlatformWalletPersistenceHandlerTest.kt | 313 ++++++++++++++++ .../src/manager/accessors.rs | 227 +++++++++++- .../PlatformWalletPersistenceHandler.swift | 224 ++++++++++- .../AssetLockSpendVisibilityTests.swift | 349 ++++++++++++++++++ 7 files changed, 1196 insertions(+), 48 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift 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 d02cc5929b5..fa807e83c00 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 @@ -1678,11 +1678,9 @@ class PlatformWalletPersistenceHandler( // InstantSendLocked (2) the network has locked the inputs, so // flip the linked TXOs here. Monotonic, and keyed strictly to // TXOs already linked to THIS lock's funding txid. - if ((status.toInt() and 0xFF) >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { + if (incomingStatus >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { val fundingTxid = outPoint.copyOfRange(0, 32) - for (txo in db.txoDao().getUnspentBySpendingTxid(fundingTxid)) { - db.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now())) - } + db.txoDao().markSpentBySpendingTxid(fundingTxid, now()) } } 0 @@ -2431,6 +2429,36 @@ class PlatformWalletPersistenceHandler( return out.toTypedArray() } + /** + * Whether the transaction [spendingTxid] funds an asset lock the + * network has already locked (`InstantSendLocked` or beyond), or + * `null` when the asset-lock table could not be read. + * + * Keyed on the funding TXID alone, never on a single outpoint: + * DIP-0027 lets one funding transaction carry several credit + * outputs, and Rust persists each tracked lock under its own + * credit-output index, so the lock a given spend produced can sit at + * any vout. Finality belongs to the transaction, so any of its locks + * reaching InstantSendLocked means the inputs are gone. + * + * `null` is a deliberate third answer, not a swallowed error. This + * runs inside `guardedLoad(emptyArray())` and the Android load + * surface carries no error channel, so an escaping read failure would + * hand Rust a SUCCESSFUL EMPTY restore for every wallet — the + * strongest possible "this device has no coins". The fault is + * therefore contained to the single candidate it concerns and every + * unrelated wallet, account and TXO still restores. + */ + private suspend fun spendByFinalizedAssetLock(spendingTxid: ByteArray): Boolean? = + try { + val status = database.assetLockDao() + .maxStatusForTxid(spendingTxid.reversedArray().toHex()) + status != null && status >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED + } catch (t: Throwable) { + Log.w(TAG, "load: asset-lock finality lookup failed; dropping the candidate UTXO", t) + null + } + /** * Assemble the [UtxoRestoreData] rows for one wallet: every unspent * `txos` row, routed to its owning account for the leading @@ -2485,13 +2513,30 @@ class PlatformWalletPersistenceHandler( // finality signal that provably arrives; from // InstantSendLocked on this output is gone. Skip it, and // heal the flag so isSpent-based readers stop counting it. - val lockKey = encodeOutPointHex(spendingTxid + ByteArray(4)) - val lock = database.assetLockDao().getByOutPointHex(lockKey) - if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { - if (!txo.isSpent) { - database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now())) + when (spendByFinalizedAssetLock(spendingTxid)) { + // Provably final. Heal opportunistically: excluding + // the row from THIS restore does not depend on the + // repair becoming durable, and the whole body of + // `onLoadWalletList` runs under + // `guardedLoad(emptyArray())` — an escaping write + // failure would discard every wallet's restore set + // over one unhealed row. Log and carry on instead, + // the way `scrubAliases` treats its cleanup. + true -> { + try { + database.txoDao().markSpentByOutpoint(txo.outpoint, now()) + } catch (t: Throwable) { + Log.w(TAG, "load: failed to heal asset-lock-consumed TXO", t) + } + continue } - continue + // Unreadable (see the helper): drop this one candidate + // and never heal it. Under-reporting one output for a + // launch is recoverable; handing a consumed output back + // as spendable is what this guard exists to stop. + null -> continue + // Demonstrably not final — keep it in the restore set. + false -> Unit } } val account = txo.accountId?.let { database.accountDao().getById(it) } @@ -3319,11 +3364,13 @@ class PlatformWalletPersistenceHandler( private const val CONTEXT_IN_BLOCK = 2 /** - * Rust `AssetLockStatus` wire bytes (asset_lock_persistence.rs): - * Built 0, Broadcast 1, InstantSendLocked 2, ChainLocked 3, - * Consumed 4. At InstantSendLocked the network has locked the - * funding inputs — the spend-visibility reconcile treats the - * linked TXOs as spent from there. + * Rust `AssetLockStatus` wire bytes + * (`wallet::asset_lock::tracked`): Built 0, Broadcast 1, + * InstantSendLocked 2, ChainLocked 3, Consumed 4, + * RecoveredFromChain 5. At InstantSendLocked the network has + * locked the funding inputs, and every status above it is a + * strictly stronger finality claim — so the spend-visibility + * reconcile treats the linked TXOs as spent from there on. */ private const val ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED = 2 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 339f2b36bb9..92924b2ebc0 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 @@ -110,6 +110,34 @@ interface AssetLockDao { @Query("SELECT * FROM asset_locks WHERE outPointHex = :outPointHex") suspend fun getByOutPointHex(outPointHex: String): AssetLockEntity? + /** + * Strongest lifecycle status any asset lock funded by [txidHex] has + * reached, or null when the transaction funds no tracked lock. + * + * [txidHex] is the explorer DISPLAY txid hex (64 chars, wire order + * reversed) — the prefix of the `outPointHex` PK + * (`:`). Deliberately keyed on the txid alone + * and NOT on a whole outpoint: DIP-0027 lets one funding transaction + * carry several credit outputs, and `sync/reconstruction.rs` persists + * each of them under its own credit-output index, so the lock a given + * funding transaction produced can live at any vout. Finality is a + * property of the transaction, so `MAX` over the whole prefix is the + * right reduction — any output of it reaching InstantSendLocked means + * the transaction's inputs are gone. + * + * Same 64-hex input contract as [fundingTypeForTxid], enforced in SQL + * and compared against the exact 65-char `:` prefix rather than + * a LIKE pattern, so `%`/`_` in malformed input can never match + * arbitrary rows. + */ + @Query( + "SELECT MAX(statusRaw) FROM asset_locks " + + "WHERE length(:txidHex) = 64 " + + "AND lower(:txidHex) NOT GLOB '*[^0-9a-f]*' " + + "AND substr(outPointHex, 1, 65) = lower(:txidHex) || ':'" + ) + suspend fun maxStatusForTxid(txidHex: String): Int? + /** * Transaction-label resolver probe: the `fundingTypeRaw` of the asset * lock whose outpoint belongs to [txidHex]. [txidHex] is the explorer 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..361c8e37b98 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 @@ -6,6 +6,7 @@ import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import org.dashfoundation.dashsdk.persistence.entities.TxoEntity +import java.util.Date /** * Queries over [TxoEntity], mirroring the Swift call sites: @@ -43,6 +44,31 @@ interface TxoDao { @Query("SELECT * FROM txos WHERE spendingTxid = :spendingTxid AND isSpent = 0") suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List + /** + * Flip `isSpent` on every still-unspent TXO consumed by + * [spendingTxid] — the heal a finalized asset lock drives when SPV + * block matching missed its spender and the ordinary in-block flip + * never ran (see `onPersistAssetLockUpsert`). + * + * Column-scoped and conditioned on `isSpent = 0`: it cannot regress + * an already-spent row, and unlike a read-then-[upsert] round trip it + * never writes back a stale copy of the columns it does not own. + * Promote-only and idempotent — a second run matches no rows. Returns + * the number of rows healed. + */ + @Query( + "UPDATE txos SET isSpent = 1, lastUpdated = :now " + + "WHERE spendingTxid = :spendingTxid AND isSpent = 0", + ) + suspend fun markSpentBySpendingTxid(spendingTxid: ByteArray, now: Date): Int + + /** Single-row [markSpentBySpendingTxid], keyed by the TXO's own outpoint. */ + @Query( + "UPDATE txos SET isSpent = 1, lastUpdated = :now " + + "WHERE outpoint = :outpoint AND isSpent = 0", + ) + suspend fun markSpentByOutpoint(outpoint: ByteArray, now: Date): Int + @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 5d9c0016744..5008ade780b 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 @@ -1,5 +1,14 @@ package org.dashfoundation.dashsdk.persistence +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteException +import android.os.CancellationSignal +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -8,6 +17,7 @@ import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.wallet.PlatformWalletPersistenceCapabilities +import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity import org.dashfoundation.dashsdk.persistence.entities.TransactionEntity import org.dashfoundation.dashsdk.persistence.entities.TxoEntity @@ -2958,6 +2968,260 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent) } + /** + * Registers [wallet] with one BIP44 account, one owned [address], and + * one plain unspent output on `:0` — the ordinary restorable + * shape, with no spender linked. + */ + private suspend fun seedRestorableWallet( + wallet: ByteArray, + address: String, + txid: ByteArray, + xpubFill: Byte, + ) { + handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) + handler.onPersistAccountRegistration( + wallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { xpubFill }, + ) + val account = db.accountDao().observeByWallet(wallet).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = address, + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + handler.onChangesetBegin(wallet) + handler.onWalletChangesetUtxoAdded( + wallet, txid, 0, 999_545, address, ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(wallet, success = true) + } + + /** + * Seeds the state an OLDER build left behind on [wallet]: a funding + * TXO whose spender is the asset-lock transaction [lockTxid], stuck + * at MEMPOOL context because SPV block matching never matched the + * lock, so `onWalletChangesetTransaction`'s in-block flip never ran + * and the row stays `isSpent = false` with `spendingTxid` linked. + */ + private suspend fun seedFundingTxoSpentByAMempoolAssetLock( + wallet: ByteArray, + address: String, + fundingTxid: ByteArray, + lockTxid: ByteArray, + xpubFill: Byte, + ) { + seedRestorableWallet(wallet, address, fundingTxid, xpubFill) + + handler.onChangesetBegin(wallet) + handler.onWalletChangesetTransaction( + wallet, lockTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "AssetLock", 0, -999_545, 0, false, "", 1_700_000_100, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onChangesetEnd(wallet, success = true) + + val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertFalse("precondition: the missed flip leaves the row unspent", txo.isSpent) + assertTrue(lockTxid.contentEquals(txo.spendingTxid!!)) + } + + /** + * A directly-written asset-lock row, the way a build predating the + * callback-time reconcile would have left it: terminal `Consumed`, + * no further upsert coming. + */ + private suspend fun seedConsumedAssetLockRow( + wallet: ByteArray, + lockTxid: ByteArray, + vout: Int, + ) { + db.assetLockDao().upsert( + AssetLockEntity( + outPointHex = encodeOutPointHex(makeOutpoint(lockTxid, vout)), + walletId = wallet, + transactionBytes = ByteArray(10) { 5 }, + fundingTypeRaw = 0, + identityIndexRaw = 0, + amountDuffs = 999_545, + statusRaw = 4, + ), + ) + } + + private fun restoredUtxoCount(wallet: ByteArray): Int = restoredUtxoTxids(wallet).size + + /** Hex prev-txids the restore hands back for [wallet], sorted. */ + private fun restoredUtxoTxids(wallet: ByteArray): List { + val entry = handler.onLoadWalletList().firstOrNull { it.walletId.contentEquals(wallet) } + // `guardedLoad` degrades to an empty array, which Rust reads as a + // fresh coinless device — name that failure rather than letting it + // surface as a NoSuchElementException. + assertNotNull("the restore must still carry this wallet", entry) + return entry!!.utxos.map { it.prevTxid.toHex() }.sorted() + } + + /** + * The restore-time half of the same defect, on the state an OLDER + * build left behind: the funding TXO is linked to a spending tx + * stuck at MEMPOOL context (SPV block matching never matched the + * lock, so the in-block flip never ran) while the lock row itself + * already reads `Consumed`. + * + * `Consumed` is terminal — the lock never upserts again — so the + * callback-time reconcile has no future event to repair this with. + * Every relaunch would hand the consumed output back to Rust as + * spendable and re-inflate the balance. The restore guard must both + * exclude it and heal the row in place. + */ + @Test + fun loadSkipsAndHealsATxoConsumedByAFinalizedAssetLock() = runTest { + val fundingTxid = ByteArray(32) { 51 } + val lockTxid = ByteArray(32) { 52 } + seedFundingTxoSpentByAMempoolAssetLock( + walletId, "yLockFunder", fundingTxid, lockTxid, 30, + ) + + // Without a finalized lock row this IS the phantom UTXO: the + // restore hands the consumed output straight back to Rust. + assertEquals(1, restoredUtxoCount(walletId)) + + seedConsumedAssetLockRow(walletId, lockTxid, vout = 0) + + assertEquals( + "the finalized lock's funding output must not rehydrate as spendable", + 0, + restoredUtxoCount(walletId), + ) + assertTrue( + "and the stale flag must be healed in place", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) + } + + /** + * Same defect, on the outpoint shape DIP-0027 actually permits: one + * funding transaction may carry several credit outputs, and Rust + * persists each tracked lock under its own credit-output index + * (`wallet/asset_lock/sync/reconstruction.rs`), so a perfectly valid + * lock row can be keyed `:1` with no `:0` row anywhere. + * + * A guard that probes the synthetic vout-0 outpoint misses it and + * hands the consumed output straight back as spendable — the finality + * signal belongs to the transaction, not to one of its outputs. + */ + @Test + fun loadSkipsATxoConsumedByAFinalizedAssetLockPersistedAtANonZeroVout() = runTest { + val fundingTxid = ByteArray(32) { 61 } + val lockTxid = ByteArray(32) { 62 } + seedFundingTxoSpentByAMempoolAssetLock( + walletId, "yLockFunderVout1", fundingTxid, lockTxid, 31, + ) + assertEquals(1, restoredUtxoCount(walletId)) + + // ONLY the second credit output is persisted — no `:0` row exists. + seedConsumedAssetLockRow(walletId, lockTxid, vout = 1) + assertNull( + "the fixture must not leave a vout-0 row for the guard to find", + db.assetLockDao().getByOutPointHex(encodeOutPointHex(makeOutpoint(lockTxid, 0))), + ) + + assertEquals( + "finality belongs to the funding transaction, not to credit output 0", + 0, + restoredUtxoCount(walletId), + ) + assertTrue( + "and the stale flag must be healed in place", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) + } + + /** + * Failure policy for the finality lookup the guard depends on. + * + * `onLoadWalletList` runs under `guardedLoad(emptyArray())` and the + * Android load surface is array-only — there is no error result — so + * an escaping read failure returns a SUCCESSFUL EMPTY restore, which + * Rust reads as a fresh, coinless device for EVERY wallet. The lookup + * must therefore contain its own failure: drop the one candidate it + * could not answer for (never healing it, since nothing was proven) + * and leave every unrelated wallet and TXO restoring normally. + * + * The fault is injected at the single prepared statement, not at the + * table, because the table is read by two other restore builders + * whose own failure modes are out of this guard's hands. + */ + @Test + fun aFailingFinalizedLockLookupDropsOnlyItsOwnCandidate() = runTest { + // The helper factory is fixed when the database is built, so the + // shared fixture is replaced with one that can be faulted. + val faults = SingleStatementFaultInjector("SELECT MAX(statusRaw) FROM asset_locks") + db.close() + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + DashDatabase::class.java, + ) + .allowMainThreadQueries() + .openHelperFactory(faults) + .build() + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + val fundingTxid = ByteArray(32) { 71 } + val lockTxid = ByteArray(32) { 72 } + seedFundingTxoSpentByAMempoolAssetLock( + walletId, "yLockFunderThrow", fundingTxid, lockTxid, 32, + ) + seedConsumedAssetLockRow(walletId, lockTxid, vout = 0) + // Sentinel 1: an ordinary unspent output on the SAME wallet, with + // no spender at all, so it never reaches the lookup. + val sentinelTxid = ByteArray(32) { 73 } + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, sentinelTxid, 0, 500_000, "yLockFunderThrow", + ByteArray(25) { 6 }, 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // Sentinel 2: an unrelated wallet with its own restorable output. + val otherWallet = ByteArray(32) { 74 } + val otherTxid = ByteArray(32) { 75 } + seedRestorableWallet(otherWallet, "yOtherFunder", otherTxid, 33) + + // Readable lookup: the guard excludes the consumed output and + // keeps both sentinels. + assertEquals(listOf(sentinelTxid.toHex()), restoredUtxoTxids(walletId)) + assertEquals(listOf(otherTxid.toHex()), restoredUtxoTxids(otherWallet)) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + + // Re-stale the healed row so the unreadable pass faces the same + // decision the readable one just made. + db.txoDao().upsert( + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.copy(isSpent = false), + ) + faults.armed = true + + assertEquals( + "only the unanswerable candidate is dropped; the unrelated output survives", + listOf(sentinelTxid.toHex()), + restoredUtxoTxids(walletId), + ) + assertEquals( + "and so does the unrelated wallet's — one bad lookup cannot empty the restore", + listOf(otherTxid.toHex()), + restoredUtxoTxids(otherWallet), + ) + assertFalse( + "an unanswerable lookup proves nothing, so it must not heal the flag", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) + } + // ── Asset locks: Consumed is terminal ───────────────────────────── // // Swift parity with `persistAssetLocks` @@ -3316,3 +3580,52 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0, db.identityDao().healIsLocalFlags()) } } + +/** + * Room open-helper factory that fails exactly one prepared statement — + * the one whose SQL starts with [failingSqlPrefix] — once [armed], and + * delegates every other read and write to real SQLite. + * + * Faults ONE query rather than dropping its table: `asset_locks` is read + * by three independent restore builders, so a table-level fault proves + * nothing about the isolation of any single one of them. + */ +private class SingleStatementFaultInjector( + private val failingSqlPrefix: String, +) : SupportSQLiteOpenHelper.Factory { + private val real = FrameworkSQLiteOpenHelperFactory() + + /** Off while the fixture is seeded, then flipped on by the test. */ + var armed: Boolean = false + + override fun create( + configuration: SupportSQLiteOpenHelper.Configuration, + ): SupportSQLiteOpenHelper = Helper(real.create(configuration)) + + private inner class Helper( + private val delegate: SupportSQLiteOpenHelper, + ) : SupportSQLiteOpenHelper by delegate { + override val writableDatabase: SupportSQLiteDatabase + get() = Db(delegate.writableDatabase) + override val readableDatabase: SupportSQLiteDatabase + get() = Db(delegate.readableDatabase) + } + + private inner class Db( + private val delegate: SupportSQLiteDatabase, + ) : SupportSQLiteDatabase by delegate { + private fun shouldFail(query: SupportSQLiteQuery) = + armed && query.sql.startsWith(failingSqlPrefix) + + override fun query(query: SupportSQLiteQuery): Cursor = + if (shouldFail(query)) throw SQLiteException("injected read failure: ${query.sql}") + else delegate.query(query) + + override fun query( + query: SupportSQLiteQuery, + cancellationSignal: CancellationSignal?, + ): Cursor = + if (shouldFail(query)) throw SQLiteException("injected read failure: ${query.sql}") + else delegate.query(query, cancellationSignal) + } +} diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 5606f00d207..1ab412e522f 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1209,7 +1209,7 @@ mod spv_rescan_tests { const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon about"; - struct NoopPersister; + pub(super) struct NoopPersister; impl PlatformWalletPersistence for NoopPersister { fn store( @@ -1229,7 +1229,7 @@ mod spv_rescan_tests { } } - struct NoopEventHandler; + pub(super) struct NoopEventHandler; impl EventHandler for NoopEventHandler {} impl PlatformEventHandler for NoopEventHandler {} @@ -1319,19 +1319,187 @@ fn computed_core_balance( #[cfg(test)] mod computed_balance_tests { + use super::spv_rescan_tests::{NoopEventHandler, NoopPersister}; use super::*; use key_wallet::account::StandardAccountType; + use key_wallet_manager::WalletManager; + use tokio::sync::RwLock; + + use crate::events::PlatformEventHandler; + use crate::wallet::platform_wallet::PlatformWalletInfo; + + /// Buckets of every account row the accessor returns, folded into one + /// `(confirmed, unconfirmed, immature, locked)` tuple. Only the funded + /// account carries UTXOs, so the fold IS that account's figure — and + /// it stays meaningful once the account is drained to nothing. + fn folded_buckets(rows: &[AccountBalanceRow]) -> (u64, u64, u64, u64) { + rows.iter().fold((0, 0, 0, 0), |(c, u, i, l), row| { + ( + c + row.balance.confirmed(), + u + row.balance.unconfirmed(), + i + row.balance.immature(), + l + row.balance.locked(), + ) + }) + } + + /// A manager whose wallet-manager IS the funded fixture's, so the + /// production accessor reads the very account the test mutates. + /// `account_balances_blocking` takes the manager, not a bare + /// `WalletManager`, and there is no constructor that adopts one — so + /// the fixture's value is moved into the freshly built manager's slot. + async fn manager_over_funded_fixture( + funded: Arc>>, + ) -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(NoopPersister), + event_handler, + )); + let adopted = std::mem::replace( + &mut *funded.write().await, + WalletManager::::new(key_wallet::Network::Testnet), + ); + *manager.wallet_manager.write().await = adopted; + manager + } + + /// `account_balances_blocking` uses `blocking_read`, so it may only be + /// called off the async runtime's worker. + async fn account_buckets( + manager: &Arc>, + wallet_id: WalletId, + ) -> (u64, u64, u64, u64) { + let manager = Arc::clone(manager); + tokio::task::spawn_blocking(move || { + folded_buckets(&manager.account_balances_blocking(&wallet_id)) + }) + .await + .expect("blocking accessor task") + } + + /// The per-account figure the explorer/FFI reads must come from the + /// LIVE UTXO set, not the cached `balance` field: a self-authored + /// asset-lock spend can leave the cache stale long after selection — + /// which reads the UTXO set — has moved on. + /// + /// Driven through `account_balances_blocking`, the accessor production + /// actually calls, and in three steps because "reports the live truth" + /// is more than "reports zero": it must first REPRODUCE a freshly + /// updated non-empty balance bucket for bucket (an implementation + /// returning `WalletCoreBalance::default()` passes an empty-set-only + /// test), then track a live re-classification the cache has not seen, + /// then track removal. + #[tokio::test] + async fn account_balances_blocking_ignores_the_stale_cache() { + let (funded, wallet_id, _balance, _signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[7_000_000, 3_000_000], + ) + .await; + let manager = manager_over_funded_fixture(funded).await; + + // 1. Agreement on a funded account. The accessor's fold and the + // cache are two implementations of the same bucket rules; if + // they disagree here, every later assertion is meaningless. + let cached = { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + let height = info.core_wallet.metadata.last_processed_height; + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account 0"); + account.update_balance(height); + account.balance + }; + assert_eq!(cached.total(), 10_000_000, "fixture must be funded"); + assert_eq!( + account_buckets(&manager, wallet_id).await, + ( + cached.confirmed(), + cached.unconfirmed(), + cached.immature(), + cached.locked() + ), + "the accessor must reproduce a freshly updated non-empty balance, bucket for bucket" + ); - /// The accessor's per-account figure must come from the LIVE UTXO set, - /// not the cached balance field: a self-authored asset-lock spend can - /// leave the cache stale long after selection has moved on. Dirty the - /// UTXO set - /// without running update_balance and assert the fold reports the fresh - /// truth while the cache still holds the stale figure. + // 2. Re-classify one UTXO WITHOUT refreshing the cache. The + // accessor must move its value confirmed → locked live; a + // read of the cached `balance` field cannot. + let locked_value = { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account 0"); + let first = *account.utxos.keys().next().expect("funded utxo"); + let utxo = account.utxos.get_mut(&first).expect("funded utxo"); + utxo.is_locked = true; + assert_eq!( + account.balance, cached, + "precondition: the cache must still hold the pre-lock figure" + ); + utxo.txout.value + }; + assert_eq!( + account_buckets(&manager, wallet_id).await, + ( + cached.confirmed() - locked_value, + cached.unconfirmed(), + cached.immature(), + locked_value + ), + "the accessor must see the live lock: value out of confirmed, into locked" + ); + + // 3. Remove every UTXO — the shape an unprocessed self-spend + // (the asset-lock drain) leaves behind. + { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account 0"); + account.utxos.clear(); + assert_eq!( + account.balance.total(), + cached.total(), + "precondition: the cache must still hold the stale figure" + ); + } + assert_eq!( + account_buckets(&manager, wallet_id).await, + (0, 0, 0, 0), + "the accessor must see the live (empty) UTXO set" + ); + } + + /// Bucket-policy companion to + /// [`account_balances_blocking_ignores_the_stale_cache`], asserted + /// directly on the fold: `computed_core_balance` duplicates + /// `ManagedCoreFundsAccount::update_balance`'s classification rules, + /// and nothing in the type system keeps the two in step. #[tokio::test] - async fn computed_balance_ignores_the_stale_cache() { + async fn computed_core_balance_matches_update_balance_bucket_for_bucket() { let (wallet_manager, wallet_id, _balance, _signer) = - crate::test_support::funded_wallet_manager(StandardAccountType::BIP44Account).await; + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[7_000_000, 3_000_000], + ) + .await; let mut wm = wallet_manager.write().await; let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); @@ -1343,22 +1511,43 @@ mod computed_balance_tests { .get_mut(&0) .expect("bip44 account 0"); - // Freshen the cache, then remove every UTXO WITHOUT refreshing it — - // the shape an unprocessed self-spend leaves behind. account.update_balance(height); - let cached_before = account.balance; - assert!(cached_before.total() > 0, "fixture must be funded"); - account.utxos.clear(); + let funded = account.balance; + assert_eq!(funded.total(), 10_000_000, "fixture must be funded"); + assert_eq!( + computed_core_balance(account, height), + funded, + "the fold must reproduce a freshly updated non-empty balance, bucket for bucket" + ); + let first = *account.utxos.keys().next().expect("funded utxo"); + let locked_value = { + let utxo = account.utxos.get_mut(&first).expect("funded utxo"); + utxo.is_locked = true; + utxo.txout.value + }; + let live = computed_core_balance(account, height); + assert_eq!( + live.locked(), + locked_value, + "the locked UTXO must be bucketed as locked" + ); assert_eq!( - account.balance.total(), - cached_before.total(), - "precondition: the cache must still hold the stale figure" + live.confirmed(), + funded.confirmed() - locked_value, + "and must have left the confirmed bucket" ); + assert_eq!( + live.total(), + funded.total(), + "locking moves value between buckets, it does not destroy it" + ); + + account.utxos.clear(); assert_eq!( computed_core_balance(account, height).total(), 0, - "the accessor's fold must see the live (empty) UTXO set" + "the fold must see the live (empty) UTXO set" ); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 97093ab902d..4a4f535ee55 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2,6 +2,31 @@ import Foundation import SwiftData import DashSDKFFI +/// Read seam for the persistence reads whose failure must reject the round. +/// +/// The asset-lock guards withhold outputs a finalized lock has already +/// consumed, so each of them treats an unreadable table as a failure +/// rather than as "nothing to withhold". Those branches only run when a +/// `fetch` throws, which a live store never does on demand, so the reads +/// they protect are taken through a fetcher the handler owns instead of +/// calling the context directly. Production passes `LiveModelFetcher` — +/// `ModelContext.fetch` verbatim. +protocol ModelFetching: Sendable { + func fetch( + _ descriptor: FetchDescriptor, + in context: ModelContext + ) throws -> [T] +} + +struct LiveModelFetcher: ModelFetching { + func fetch( + _ descriptor: FetchDescriptor, + in context: ModelContext + ) throws -> [T] { + try context.fetch(descriptor) + } +} + /// Bridges FFI persistence callbacks to SwiftData storage. /// /// Allocated as a class so its pointer can be passed as the opaque `context` @@ -94,6 +119,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `markUtxoSpent`, …) assume they are already on the queue. private let backgroundContext: ModelContext + /// Taken instead of `backgroundContext.fetch` by the reads whose + /// failure must reject the round (see `ModelFetching`). + private let modelFetcher: ModelFetching + /// Context dedicated to tracked-masternode whole-set writes. Those writes /// are not part of a wallet changeset and must become durable before their /// synchronous FFI callback reports success. Keeping them off @@ -146,9 +175,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// like all other mutable handler state. private var deferredPaymentUpserts: [(ownerIdentityId: Data, payments: [DashPayPayment])] = [] - public init(modelContainer: ModelContainer, network: Network? = nil) { + public convenience init(modelContainer: ModelContainer, network: Network? = nil) { + self.init( + modelContainer: modelContainer, + network: network, + modelFetcher: LiveModelFetcher() + ) + } + + init( + modelContainer: ModelContainer, + network: Network?, + modelFetcher: ModelFetching + ) { self.modelContainer = modelContainer self.network = network + self.modelFetcher = modelFetcher self.backgroundContext = ModelContext(modelContainer) self.backgroundContext.autosaveEnabled = true self.trackedMasternodeContext = ModelContext(modelContainer) @@ -240,6 +282,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // MARK: - Asset locks + /// `AssetLockStatus` wire value for `InstantSendLocked` + /// (`wallet::asset_lock::tracked`: Built 0, Broadcast 1, + /// InstantSendLocked 2, ChainLocked 3, Consumed 4, + /// RecoveredFromChain 5). At this value and above the network has + /// locked — or the chain has buried — the lock's funding inputs, so + /// every TXO the lock spends is gone for good. Mirrors the Kotlin + /// handler's `ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED`. + private static let assetLockStatusInstantSendLocked = 2 + + /// Wire-order (little-endian) funding txid of the asset lock whose + /// outpoint is stored as `:` — the encoding + /// `PersistentAssetLock.encodeOutPoint` writes. `PersistentTransaction` + /// stores its `txid` in wire order, so the display hex is decoded and + /// reversed before it can be matched against one. Returns `nil` for a + /// row whose outpoint string is not decodable. + private static func assetLockFundingTxid(outPointHex: String) -> Data? { + guard let displayHex = outPointHex.split(separator: ":").first, + let displayTxid = Data(hexString: String(displayHex)) else { + return nil + } + return Data(displayTxid.reversed()) + } + /// Apply an `AssetLockChangeSet` projection to SwiftData. /// /// The Rust-side asset-lock manager emits a changeset on every @@ -254,12 +319,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// /// No `save()` here — bracketed by `beginChangeset` / /// `endChangeset` from the Rust `store()` round. + /// + /// Returns `false` when the spend-visibility reconcile below could not + /// read the TXOs a now-final lock consumed. That failure is not + /// skippable: `Consumed` is terminal, so committing the status while + /// its funding TXOs stay `isSpent == false` leaves a phantom UTXO with + /// no future callback to repair it. A `false` return fails the Rust + /// round, which rolls the changeset back and re-emits the status. func persistAssetLocks( walletId: Data, upserts: [AssetLockEntrySnapshot], removed: [Data] - ) { + ) -> Bool { onQueue { + var allPersisted = true for entry in upserts { let outPointHex = entry.outPointHex let descriptor = FetchDescriptor( @@ -313,20 +386,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // own STATUS keeps arriving via this callback; from // InstantSendLocked (2) the network has locked the inputs, so // flip the TXOs already linked to this lock's funding tx. - if entry.statusRaw >= 2, - let displayTxidHex = entry.outPointHex.split(separator: ":").first, - let displayTxid = Data(hexString: String(displayTxidHex)) { - let wireTxid = Data(displayTxid.reversed()) + if entry.statusRaw >= Self.assetLockStatusInstantSendLocked, + let wireTxid = Self.assetLockFundingTxid(outPointHex: entry.outPointHex) { let staleDescriptor = FetchDescriptor( predicate: #Predicate { $0.spendingTransaction?.txid == wireTxid && $0.isSpent == false } ) - if let stale = try? backgroundContext.fetch(staleDescriptor) { - for txo in stale { + do { + for txo in try modelFetcher.fetch(staleDescriptor, in: backgroundContext) { txo.isSpent = true txo.lastUpdated = Date() } + } catch { + print("⚠️ persistAssetLocks: stale-TXO fetch failed for \(entry.outPointHex) — failing the round so the lock status does not commit ahead of its spend flags: \(error)") + allPersisted = false } } } @@ -349,6 +423,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(existing) } } + return allPersisted } } @@ -4753,6 +4828,37 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Wire-order funding txids of every persisted asset lock whose + /// status is `InstantSendLocked` or beyond — the locks whose + /// funding TXOs are provably gone. + /// + /// Not scoped to a wallet: InstantSend / chain finality is a + /// property of the spending transaction, and that transaction can + /// consume inputs tracked by more than one wallet. Scoping would + /// leave a sibling wallet's input of the same lock stale. + /// + /// Throws rather than returning an empty set on a fetch failure. An + /// empty set is a positive claim — "no lock has finalized" — and the + /// caller acts on it by restoring every `isSpent == false` row, + /// exactly the phantom UTXOs this guard exists to withhold. A read + /// fault must reject the snapshot instead. + private func finalizedAssetLockFundingTxids() throws -> Set { + let finalized = Self.assetLockStatusInstantSendLocked + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.statusRaw >= finalized } + ) + let rows = try modelFetcher.fetch(descriptor, in: backgroundContext) + var txids = Set() + txids.reserveCapacity(rows.count) + for row in rows { + guard let txid = Self.assetLockFundingTxid(outPointHex: row.outPointHex) else { + continue + } + txids.insert(txid) + } + return txids + } + /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { onQueue { @@ -4775,7 +4881,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } let wallets: [PersistentWallet] do { - wallets = try backgroundContext.fetch(walletDescriptor) + wallets = try modelFetcher.fetch(walletDescriptor, in: backgroundContext) } catch { // Surfacing the SwiftData failure to Rust is critical — // returning success-with-empty here would let restore @@ -4813,7 +4919,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var unspentDescriptor = FetchDescriptor( predicate: #Predicate { $0.isSpent == false } ) - unspentDescriptor.relationshipKeyPathsForPrefetching = [\.account] + unspentDescriptor.relationshipKeyPathsForPrefetching = [ + \.account, + // Read once per row by the asset-lock spend guard below. + \.spendingTransaction, + ] // Bail with `errored = true` on a SwiftData failure rather // than degrading to an empty bucket map. Without this, Rust // would see `entry.utxos_count == 0` for every wallet, @@ -4822,7 +4932,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // the failure mode this code path was added to eliminate. let unspent: [PersistentTxo] do { - unspent = try backgroundContext.fetch(unspentDescriptor) + unspent = try modelFetcher.fetch(unspentDescriptor, in: backgroundContext) } catch { NSLog( "[persistor-load:swift] PersistentTxo unspent fetch failed: %@", @@ -4830,8 +4940,84 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } + // Finalized-asset-lock guard, and the one-shot heal for + // rows a missed callback left behind (mirror of Kotlin + // `buildUtxoRestoreData`). An asset-lock tx burns its value + // into the special-tx payload and often has no wallet-owned + // standard output, so SPV block matching can miss it: the + // spender's transaction row never leaves mempool context and + // the in-block flip in `resolveInputOutpoint` never runs, + // leaving its funding TXOs at `isSpent == false` — which this + // fetch hands straight back to Rust as spendable, on every + // launch. The lock's own status is the finality signal that + // did arrive; from `InstantSendLocked` on, the output is gone. + // + // The callback-time reconcile in `persistAssetLocks` cannot + // cover these: `Consumed` is terminal and never re-upserts, so + // a wallet whose lock finalized before that reconcile existed + // has no future callback to repair it. Load is the one + // guaranteed per-launch pass, so it repairs them here. + // + // Exclusion does not depend on the repair being durable: rows + // are dropped from the restore set first, and the heal is + // saved opportunistically afterwards. + let finalizedLockTxids: Set + do { + finalizedLockTxids = try finalizedAssetLockFundingTxids() + } catch { + // Same contract as the unspent fetch above: bail with + // `errored = true` rather than degrade. Treating an + // unreadable lock table as "no lock has finalized" + // rehydrates the phantom inputs the guard exists to + // withhold — the exact inverse of its safety claim. + NSLog( + "[persistor-load:swift] PersistentAssetLock finalized fetch failed: %@", + String(describing: error) + ) + return (nil, 0, true) + } + var liveUnspent = unspent + if !finalizedLockTxids.isEmpty { + var kept: [PersistentTxo] = [] + kept.reserveCapacity(unspent.count) + var healed = 0 + for row in unspent { + if let spendingTxid = row.spendingTransaction?.txid, + finalizedLockTxids.contains(spendingTxid) { + if !row.isSpent { + row.isSpent = true + row.lastUpdated = Date() + healed += 1 + } + continue + } + kept.append(row) + } + liveUnspent = kept + // Skip the write mid-round: saving inside a changeset + // bracket would commit the round's staged writes early. + // The exclusion above already holds for this launch. + if healed > 0 && !self.inChangeset { + do { + try backgroundContext.save() + NSLog( + "[persistor-load:swift] healed %d asset-lock-consumed UTXO row(s)", + healed + ) + } catch { + // Non-fatal: the next launch retries. Roll back so + // the failed heal can't bleed into the restore + // marshalling below. + backgroundContext.rollback() + NSLog( + "[persistor-load:swift] asset-lock UTXO heal save failed: %@", + String(describing: error) + ) + } + } + } unspentBuckets.reserveCapacity(restorable.count) - for row in unspent { + for row in liveUnspent { guard row.account != nil else { continue } let key: Data if !row.walletId.isEmpty { @@ -7648,6 +7834,13 @@ private func persistDpnsNameStatesCallback( /// Swift-owned `Data` snapshots before invoking the handler so the /// Rust-side `_storage` Vec can release the byte buffers as soon as /// this trampoline returns. +/// +/// Returns 0 when every asset-lock mutation was staged. A nonzero return +/// fails the Rust persistence round and rolls the changeset back, and is +/// reserved for a spend-visibility reconcile that could not read the TXOs +/// a now-final lock consumed — see `persistAssetLocks`. Acknowledging that +/// one would commit a terminal `Consumed` status over funding TXOs still +/// marked spendable, with no later upsert to repair them. private func persistAssetLocksCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, @@ -7710,8 +7903,11 @@ private func persistAssetLocksCallback( } } - handler.persistAssetLocks(walletId: walletId, upserts: upserts, removed: removed) - return 0 + return handler.persistAssetLocks( + walletId: walletId, + upserts: upserts, + removed: removed + ) ? 0 : 1 } /// C shim for `on_persist_contacts_fn`. Same snapshot + cast pattern diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift new file mode 100644 index 00000000000..c24f81295da --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -0,0 +1,349 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +// MARK: - Asset-lock spend visibility +// +// An asset-lock transaction burns its value into the special-tx payload +// and often has no wallet-owned standard output, so SPV block matching +// can miss it: the spender's transaction row never leaves mempool +// context, `resolveInputOutpoint`'s in-block flip never runs, and the +// funding TXOs it consumed stay `isSpent == false` forever. Every +// relaunch then hands those consumed outputs back to Rust as spendable. +// +// Two repairs cover it — the callback-time reconcile in +// `persistAssetLocks`, and the load-time guard in `loadWalletList` for +// wallets whose lock reached the terminal `Consumed` status before that +// reconcile existed and so has no future callback to fire. Both depend +// on a SwiftData read, and both must fail CLOSED: the whole point is to +// withhold outputs that are gone, so an unreadable table may never be +// read as "nothing to withhold." +// +// Both directions are pinned here. The readable behaviour goes through a +// live store; the fail-closed behaviour goes through the handler's +// `ModelFetching` seam, which faults exactly one model type's read and +// serves every other read live. That isolation is the point: a load-path +// regression cannot pass because the wallet or unspent-TXO fetch failed +// first, and the reconcile regression cannot pass because the whole store +// was unreadable. + +/// Serves every read live except the one model type it is told to fault, +/// and records the reads it saw so a test can prove which fetch failed. +private final class FetchFaultInjector: ModelFetching, @unchecked Sendable { + struct ReadFault: Error {} + + private let live = LiveModelFetcher() + private let faulted: ObjectIdentifier + private let lock = NSLock() + private var reads: [String] = [] + + init(faulting model: any PersistentModel.Type) { + faulted = ObjectIdentifier(model) + } + + /// Model names in the order they were read, the faulted one included. + var observedReads: [String] { + lock.lock() + defer { lock.unlock() } + return reads + } + + func fetch( + _ descriptor: FetchDescriptor, + in context: ModelContext + ) throws -> [T] { + lock.lock() + reads.append(String(describing: T.self)) + lock.unlock() + guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() } + return try live.fetch(descriptor, in: context) + } +} + +final class AssetLockSpendVisibilityTests: XCTestCase { + + private var container: ModelContainer! + private var handler: PlatformWalletPersistenceHandler! + + private let walletId = Data(repeating: 0xAA, count: 32) + private let fundingTxid = Data(repeating: 0x51, count: 32) + private let lockTxid = Data(repeating: 0x52, count: 32) + + override func setUpWithError() throws { + try super.setUpWithError() + container = try DashModelContainer.createInMemory() + handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + } + + override func tearDown() { + handler = nil + container = nil + super.tearDown() + } + + // MARK: Fixtures + + /// Seeds the state an older build left behind: a restorable wallet + /// holding one funding TXO whose spender is an asset-lock + /// transaction stuck at mempool context, so the row is still + /// `isSpent == false` with `spendingTransaction` linked. + @discardableResult + private func seedFundingTxoSpentByAMempoolAssetLock( + into container: ModelContainer + ) throws -> Data { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, + accountType: 0, + accountIndex: 0, + accountTypeName: "standard" + ) + account.accountExtendedPubKeyBytes = Data(repeating: 0xEE, count: 78) + context.insert(account) + + let fundingTx = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100 + ) + context.insert(fundingTx) + let txo = PersistentTxo( + transaction: fundingTx, + vout: 0, + amount: 999_545, + address: "yLockFunder", + scriptPubKey: Data(repeating: 0x06, count: 25), + height: 100 + ) + txo.walletId = walletId + txo.account = account + txo.isConfirmed = true + context.insert(txo) + + // Mempool context (0): the lock's spend is linked but the + // in-block flip never ran. + let lockTx = PersistentTransaction( + txid: lockTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + transactionType: "AssetLock", + netAmount: -999_545 + ) + context.insert(lockTx) + txo.spendingTransaction = lockTx + try context.save() + return txo.outpoint + } + + /// A directly-written terminal `Consumed` lock row, keyed at + /// [vout] of the lock's own funding transaction. + private func insertConsumedAssetLock(into container: ModelContainer, vout: UInt32) throws { + let context = ModelContext(container) + let outPoint = PersistentTxo.makeOutpoint(txid: lockTxid, vout: vout) + context.insert(PersistentAssetLock( + outPointHex: PersistentAssetLock.encodeOutPoint(rawBytes: outPoint), + walletId: walletId, + transactionBytes: Data(repeating: 0x05, count: 10), + fundingTypeRaw: 0, + identityIndexRaw: 0, + amountDuffs: 999_545, + statusRaw: 4 + )) + try context.save() + } + + /// Drives the FFI load path and returns the single wallet entry's + /// UTXO count, releasing the buffers before returning. + private func restoredUtxoCount() throws -> Int { + let (entries, count, errored) = handler.loadWalletList() + XCTAssertFalse(errored, "the restore must not report a failure here") + XCTAssertEqual(count, 1) + let entriesPtr = try XCTUnwrap(entries) + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entriesPtr)) } + return Int(entriesPtr[0].utxos_count) + } + + /// Committed `PersistentAssetLock` rows, read on a context of its own + /// so a round staged but never saved does not count. + private func persistedAssetLockCount() throws -> Int { + try ModelContext(container).fetchCount(FetchDescriptor()) + } + + private func txoIsSpent(outpoint: Data) throws -> Bool { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try XCTUnwrap(try context.fetch(descriptor).first).isSpent + } + + // MARK: Load-time guard + + /// `Consumed` is terminal — the lock never upserts again — so the + /// callback-time reconcile has no future event to repair this with, + /// and every relaunch would re-inflate the balance with an output + /// that is provably gone. The load guard must both exclude it from + /// the restore and heal the row in place. + func testLoadExcludesAndHealsATxoConsumedByAFinalizedAssetLock() throws { + let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) + + // Without a finalized lock row this IS the phantom UTXO. + XCTAssertEqual(try restoredUtxoCount(), 1) + XCTAssertFalse(try txoIsSpent(outpoint: outpoint)) + + try insertConsumedAssetLock(into: container, vout: 0) + + XCTAssertEqual( + try restoredUtxoCount(), 0, + "the finalized lock's funding output must not rehydrate as spendable" + ) + XCTAssertTrue( + try txoIsSpent(outpoint: outpoint), + "and the stale flag must be healed in place" + ) + } + + /// DIP-0027 lets one funding transaction carry several credit + /// outputs, and Rust persists each tracked lock under its own + /// credit-output index, so a valid lock row can be keyed `:1` + /// with no `:0` row anywhere. Finality belongs to the + /// transaction, so the guard keys on the funding txid alone. + func testLoadExcludesATxoConsumedByALockPersistedAtANonZeroVout() throws { + let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) + XCTAssertEqual(try restoredUtxoCount(), 1) + + try insertConsumedAssetLock(into: container, vout: 1) + + XCTAssertEqual( + try restoredUtxoCount(), 0, + "finality belongs to the funding transaction, not to credit output 0" + ) + XCTAssertTrue(try txoIsSpent(outpoint: outpoint)) + } + + // MARK: Callback-time reconcile + + /// The reconcile that runs while the lock is still upserting: + /// from `InstantSendLocked` the network has locked the inputs, so + /// every TXO already linked to the lock's funding tx is spent. + func testPersistAssetLocksFlipsLinkedTxosAndReportsSuccess() throws { + let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) + let outPointRaw = PersistentTxo.makeOutpoint(txid: lockTxid, vout: 0) + + handler.beginChangeset(walletId: walletId) + let staged = handler.persistAssetLocks( + walletId: walletId, + upserts: [.init( + outPointHex: PersistentAssetLock.encodeOutPoint(rawBytes: outPointRaw), + transactionBytes: Data(repeating: 0x05, count: 10), + fundingTypeRaw: 0, + identityIndexRaw: 0, + accountIndexRaw: 0, + amountDuffs: 999_545, + statusRaw: 2, + proofBytes: nil + )], + removed: [] + ) + XCTAssertTrue(staged, "a readable round must report success") + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + + XCTAssertTrue( + try txoIsSpent(outpoint: outpoint), + "the linked funding TXO must be flipped by the reconcile" + ) + } + + /// The reconcile's fetch is the only thing that can find the TXOs a + /// now-final lock consumed, so an unreadable read must not be spent as + /// "nothing to heal". `Consumed` is terminal — it never upserts again — + /// so a status that commits over an unread TXO table leaves a phantom + /// UTXO with no future callback to repair it. Fail the round instead, + /// which rolls the status back and lets Rust re-emit it. + func testPersistAssetLocksFailsTheRoundWhenTheStaleTxoFetchThrows() throws { + let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) + let injector = FetchFaultInjector(faulting: PersistentTxo.self) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet, + modelFetcher: injector + ) + let outPointRaw = PersistentTxo.makeOutpoint(txid: lockTxid, vout: 0) + + handler.beginChangeset(walletId: walletId) + let staged = handler.persistAssetLocks( + walletId: walletId, + upserts: [.init( + outPointHex: PersistentAssetLock.encodeOutPoint(rawBytes: outPointRaw), + transactionBytes: Data(repeating: 0x05, count: 10), + fundingTypeRaw: 0, + identityIndexRaw: 0, + accountIndexRaw: 0, + amountDuffs: 999_545, + statusRaw: 4, + proofBytes: nil + )], + removed: [] + ) + + XCTAssertEqual( + injector.observedReads, ["PersistentTxo"], + "the faulted read must be the reconcile's stale-TXO fetch" + ) + XCTAssertFalse( + staged, + "an unreadable stale-TXO fetch must fail the round, not report success" + ) + // What Rust does with a non-zero callback: close the round as failed. + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: staged)) + + XCTAssertEqual( + try persistedAssetLockCount(), 0, + "the terminal status must not commit ahead of the spend flags it could not read" + ) + XCTAssertFalse( + try txoIsSpent(outpoint: outpoint), + "and nothing may be left half-applied by the rolled-back round" + ) + } + + /// An unreadable lock table must not be read as the positive claim + /// "no lock has finalized": the caller acts on that by restoring every + /// `isSpent == false` row, which is exactly the consumed output this + /// guard exists to withhold. The load must reject the snapshot. + func testLoadReportsFailureWhenTheFinalizedLockFetchThrows() throws { + try seedFundingTxoSpentByAMempoolAssetLock(into: container) + try insertConsumedAssetLock(into: container, vout: 0) + + let injector = FetchFaultInjector(faulting: PersistentAssetLock.self) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet, + modelFetcher: injector + ) + + let (entries, count, errored) = handler.loadWalletList() + if let entries { + handler.loadWalletListFree(entries: UnsafeRawPointer(entries)) + } + + XCTAssertEqual( + injector.observedReads, + ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], + "the wallet and unspent-TXO reads must have been served — only the lock read failed" + ) + XCTAssertTrue( + errored, + "an unreadable lock table may not restore as 'no lock has finalized'" + ) + XCTAssertNil(entries) + XCTAssertEqual(count, 0) + } +}