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 58d487d00b1..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 @@ -1667,6 +1667,21 @@ 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 (incomingStatus >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { + val fundingTxid = outPoint.copyOfRange(0, 32) + db.txoDao().markSpentBySpendingTxid(fundingTxid, now()) + } } 0 } @@ -2414,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 @@ -2459,6 +2504,40 @@ 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. + 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 + } + // 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) } ?: accountByAddress.getOrPut(txo.address) { @@ -3284,6 +3363,17 @@ class PlatformWalletPersistenceHandler( /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 + /** + * 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 + /** `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/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 d48cd2c81a5..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,7 +17,10 @@ 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 import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity @@ -2881,6 +2893,335 @@ class PlatformWalletPersistenceHandlerTest { 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) + } + + /** + * 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` @@ -3239,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 af4c76dfc5f..1ab412e522f 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -410,6 +410,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() @@ -418,7 +419,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. @@ -1197,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( @@ -1217,7 +1229,7 @@ mod spv_rescan_tests { } } - struct NoopEventHandler; + pub(super) struct NoopEventHandler; impl EventHandler for NoopEventHandler {} impl PlatformEventHandler for NoopEventHandler {} @@ -1276,3 +1288,266 @@ 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::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" + ); + + // 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_core_balance_matches_update_balance_bucket_for_bucket() { + let (wallet_manager, wallet_id, _balance, _signer) = + 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"); + 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); + 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!( + 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 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 6aae1f9cd0c..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( @@ -302,6 +375,34 @@ 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 >= Self.assetLockStatusInstantSendLocked, + let wireTxid = Self.assetLockFundingTxid(outPointHex: entry.outPointHex) { + let staleDescriptor = FetchDescriptor( + predicate: #Predicate { + $0.spendingTransaction?.txid == wireTxid && $0.isSpent == false + } + ) + 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 + } + } } for outPointHex in removed { @@ -322,6 +423,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(existing) } } + return allPersisted } } @@ -4726,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 { @@ -4748,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 @@ -4786,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, @@ -4795,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: %@", @@ -4803,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 { @@ -7621,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?, @@ -7683,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) + } +}