diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ad7caf27b..4de13225974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,8 +63,18 @@ [rust-dashcore#1183](https://github.com/dashpay/rust-dashcore/pull/1183) runs the storage writer only between start and stop. - **platform-wallet:** a chain reorganization reported by key-wallet - (`WalletEvent::ChainTruncated`) updates the in-memory balance but is not yet - mirrored to persisted wallet state + (`WalletEvent::ChainTruncated`) now reaches persisted wallet state as + `CoreChangeSet::truncations`. The SQLite backend and the Swift and Kotlin + persistence handlers remove the transactions of the dropped blocks and their + coins, keep InstantSend-locked ones as unconfirmed, restore the coins the + removed ones spent and lower the stored sync heights to the fork, so a + restart after a reorg loads the same wallet as no restart. FFI hosts receive + truncations through the size-negotiated + `on_persist_wallet_changeset_truncations_fn` extension callback, Kotlin + bridges through `onWalletChangesetChainTruncated`. A persister attests + support with the new + `PersistenceCapabilities::CORE_CHAIN_TRUNCATION` bit; one that does not is + not handed the truncation and keeps the replaced branch until it rescans ([#5380](https://github.com/dashpay/platform/issues/5380)). - **platform-wallet:** the SPV broadcast-readiness wait (`SpvRuntime::wait_until_ready`) wakes when the client starts or dash-spv reports a peer update, instead of diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index f662cfae2fe..9b235fb193c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -99,6 +99,13 @@ abstract class NativePersistenceBridge { * it, so the declaration and the mirror can never drift apart. */ const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800 + + /** + * `PersistenceCapabilities::CORE_CHAIN_TRUNCATION` (bit 15, + * `0x8000`; bit 14 is reserved for Core history restore). The one Kotlin + * home of this bit, like [CAPABILITY_CORE_SWEEP_REMOVAL]. + */ + const val CAPABILITY_CORE_CHAIN_TRUNCATION: Long = 0x8000 } /** @@ -421,6 +428,47 @@ abstract class NativePersistenceBridge { winnerMinedHeight: Int, ): Int = 0 + /** + * One chain truncation: a fork replaced the chain above [height] and + * the wallet dropped what it recorded from the blocks above it. + * Descriptor `([BI[BI[BI[BI)I`. + * + * [txids] packs [txidCount] raw 32-byte txids of removed transactions, + * [unconfirmedTxids] packs [unconfirmedTxidCount] txids of + * InstantSend-locked transactions kept as unconfirmed, and + * [restoredOutpoints] packs [restoredOutpointCount] 36-byte outpoint + * keys (raw txid followed by a little-endian vout) of coins the removed + * transactions spent. + * + * Fired before every other core callback of the round, once per + * truncation and in order: the rest of the round describes the wallet + * after the last one, so a removed transaction can be stored again, in + * its new block, by the callbacks that follow. Applying one means + * deleting every UTXO a removed transaction created for this wallet, + * and the transaction itself unless another wallet of the same store + * still has a part in it, keeping each unconfirmed one in the + * InstantSend context with no block and this wallet's outputs of it + * unconfirmed and InstantSend-locked, marking each restored outpoint + * unspent, and lowering a stored sync height above [height] to it. A + * transaction or outpoint never stored is a no-op. + * + * The JNI layer wires the slot only when the concrete bridge OVERRIDES + * this method, and Rust attests the capability only for slot present + * AND [CAPABILITY_CORE_CHAIN_TRUNCATION] declared through + * [persistenceCapabilitiesBits]. Without that, native never sends a + * truncation, so this default is never reached for a wired slot. + */ + open fun onWalletChangesetChainTruncated( + walletId: ByteArray, + height: Int, + txids: ByteArray, + txidCount: Int, + unconfirmedTxids: ByteArray, + unconfirmedTxidCount: Int, + restoredOutpoints: ByteArray, + restoredOutpointCount: Int, + ): Int = 0 + /** * The round's numeric chainlock height, fired on every round whose * changeset carries a chainlock, after the changeset callback and 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 62dfdf82093..4e3790195b1 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 @@ -154,7 +154,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_WALLET_RESTORE or CAPABILITY_DPNS_NAME_STATES or CAPABILITY_TRACKED_ASSET_LOCKS or - CAPABILITY_CORE_SWEEP_REMOVAL + CAPABILITY_CORE_SWEEP_REMOVAL or + CAPABILITY_CORE_CHAIN_TRUNCATION /** * The single-thread executor created when no [dispatcher] is injected. @@ -1282,6 +1283,85 @@ class PlatformWalletPersistenceHandler( return !chainlockOverIsLock } + // ── Chain truncations ───────────────────────────────────────────── + + /** One chain truncation, unpacked from the JNI trampoline's flat arrays at the callback. */ + private class ChainTruncation( + val height: Int, + val removedTxids: List, + val unconfirmedTxids: List, + val restoredOutpoints: List, + ) + + /** + * Stage one chain truncation. Native fires this before the round's + * other core callbacks and the round replays its ops in order, so the + * truncation lands first and a transaction it removes can be stored + * again, in its new block, by the slices that follow. Outside a round + * it runs in its own transaction. + */ + override fun onWalletChangesetChainTruncated( + walletId: ByteArray, + height: Int, + txids: ByteArray, + txidCount: Int, + unconfirmedTxids: ByteArray, + unconfirmedTxidCount: Int, + restoredOutpoints: ByteArray, + restoredOutpointCount: Int, + ): Int = guarded { + require(height >= 0) { "truncation height must not be negative" } + val truncation = ChainTruncation( + height = height, + removedTxids = unpackFixed(txids, txidCount, 32, "truncated txids"), + unconfirmedTxids = unpackFixed(unconfirmedTxids, unconfirmedTxidCount, 32, "unconfirmed txids"), + restoredOutpoints = unpackFixed(restoredOutpoints, restoredOutpointCount, 36, "restored outpoints"), + ) + stage(walletId) { db -> applyChainTruncation(db, walletId, truncation) } + 0 + } + + /** + * Cut the store back the way the wallet cut itself back when a fork + * replaced the chain above [ChainTruncation.height] — the Room mirror + * of the SQLite store's `apply_truncation`. + * + * Everything but the transaction row is this wallet's own. The row is + * shared across wallets and each wallet's round commits on its own, so + * another wallet may already have stored a removed transaction again, + * in its new block, by the time this truncation arrives: its coins are + * not this truncation's to take, and the row goes only once nothing + * references it. The row's block columns are the one thing wallets + * share; between two wallets' rounds they can show the branch the other + * wallet last wrote. + * + * A restored coin needs no veto from stored claims, unlike a sweep's + * release: its spender was confirmed, and no block the fork keeps can + * hold a second spend of it. + */ + private suspend fun applyChainTruncation(db: DashDatabase, walletId: ByteArray, truncation: ChainTruncation) { + // Drop stale post-deletion callbacks (can't resurrect a wallet). + if (db.walletDao().getByWalletId(walletId) == null) return + // Removed: this wallet's coins, links, claims and involvement + // first, then the rows nothing references any more. + chunked(truncation.removedTxids) { db.txoDao().deleteWalletTxosByTxids(it, walletId) } + chunked(truncation.removedTxids) { db.txoDao().detachWalletSpenders(it, walletId) } + chunked(truncation.removedTxids) { db.documentDao().deleteWalletClaimsBySpenders(walletId, it) } + chunked(truncation.removedTxids) { db.transactionDao().deleteWalletInvolvements(it, walletId) } + chunked(truncation.removedTxids) { db.transactionDao().deleteUnreferencedByTxids(it) } + // Kept: InstantSend-locked, so mined again. Stored until then the + // way a locked transaction no block holds yet is; the coins they + // spent stay spent. + chunked(truncation.unconfirmedTxids) { db.transactionDao().unconfirmByTxids(it) } + chunked(truncation.unconfirmedTxids) { db.txoDao().unconfirmInstantLockedByTxids(it, walletId) } + // Restored: this wallet's coins the removed transactions spent. + chunked(truncation.restoredOutpoints) { db.txoDao().releaseByOutpoints(it, walletId) } + chunked(truncation.restoredOutpoints) { + db.documentDao().deleteWalletSweptTombstonesByOutpoints(walletId, it) + } + db.walletDao().lowerSyncedHeight(walletId, truncation.height, System.currentTimeMillis()) + } + // ── Sweeps ──────────────────────────────────────────────────────── /** One sweep batch, unpacked from the JNI trampoline's flat arrays at the callback. */ @@ -4120,6 +4200,8 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + internal const val CAPABILITY_CORE_CHAIN_TRUNCATION: Long = + NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION private const val TAG = "DashPersistence" diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt index de86eea2e0b..759035dc243 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt @@ -254,6 +254,18 @@ interface DocumentDao { ) suspend fun deleteWalletSweptTombstonesByOutpoints(walletId: ByteArray, outpoints: List) + /** + * Delete [walletId]'s ordinary claims made by one of [spendingTxids] — + * its spend-before-funding rows for transactions a chain truncation + * removed. Tombstones are not claims of the removed transaction and + * stay. Chunked by the caller. + */ + @Query( + "DELETE FROM pending_inputs WHERE walletId = :walletId AND isSweptTombstone = 0 " + + "AND spendingTransactionTxid IN (:spendingTxids)", + ) + suspend fun deleteWalletClaimsBySpenders(walletId: ByteArray, spendingTxids: List) + /** Bulk insert of freshly minted tombstones; Room binds one row at a time. */ @Insert suspend fun insertPendingInputs(rows: List) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index c72a5569832..d368a4e8e9d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -111,6 +111,46 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE txid IN (:txids)") suspend fun deleteByTxids(txids: List) + /** + * Drop [walletId]'s accounts' involvement in [txids] — its part in + * transactions a chain truncation removed. Chunked by the caller. + */ + @Query( + "DELETE FROM transaction_account_involvements WHERE transactionTxid IN (:txids) " + + "AND accountId IN (SELECT id FROM accounts WHERE walletId = :walletId)" + ) + suspend fun deleteWalletInvolvements(txids: List, walletId: ByteArray) + + /** + * Delete those of [txids] nothing references any more — the same test + * as [deleteOrphanTransactions], limited to the transactions one chain + * truncation removed. A row another wallet still has a coin, a spend, + * a claim or an involvement in stays. Chunked by the caller. + */ + @Query( + "DELETE FROM transactions WHERE txid IN (:txids) " + + "AND txid NOT IN (SELECT txid FROM txos WHERE txid IS NOT NULL) " + + "AND txid NOT IN (SELECT spendingTxid FROM txos WHERE spendingTxid IS NOT NULL) " + + "AND txid NOT IN " + + "(SELECT spendingTransactionTxid FROM pending_inputs " + + "WHERE spendingTransactionTxid IS NOT NULL) " + + "AND txid NOT IN (SELECT transactionTxid FROM transaction_account_involvements)" + ) + suspend fun deleteUnreferencedByTxids(txids: List) + + /** + * Keep [txids] as unconfirmed and InstantSend-locked: a chain + * truncation dropped their block, and it keeps no other kind. Sets the + * InstantSend context, clears the block columns and touches nothing + * else. Chunked by the caller. + */ + @Query( + "UPDATE transactions SET context = 1, blockHeight = 0, blockHash = NULL, " + + "blockTimestamp = 0, blockPosition = 0, hasBlockPosition = 0 " + + "WHERE txid IN (:txids)" + ) + suspend fun unconfirmByTxids(txids: List) + /** * Orphan sweep run after a wallet wipe (Swift `deleteWalletData`'s * post-delete pass): drop transactions no longer referenced by any diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index ac484917836..ccc105d1a1c 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 @@ -166,6 +166,39 @@ interface TxoDao { @Query("DELETE FROM txos WHERE txid IN (:txids)") suspend fun deleteByTxids(txids: List) + /** + * Delete [walletId]'s TXOs created by one of [txids] — its own coins + * from transactions a chain truncation removed. Another wallet's coins + * from the same transactions are not this wallet's truncation to take: + * that wallet's round may already have stored them again, in their new + * block. Chunked by the caller. + */ + @Query("DELETE FROM txos WHERE txid IN (:txids) AND walletId = :walletId") + suspend fun deleteWalletTxosByTxids(txids: List, walletId: ByteArray) + + /** + * Drop [walletId]'s links to one of [spendingTxids] — the wallet-scoped + * form of [detachSpenders], for a chain truncation. Chunked by the + * caller. + */ + @Query( + "UPDATE txos SET spendingTxid = NULL, spendingInputIndex = NULL " + + "WHERE spendingTxid IN (:spendingTxids) AND walletId = :walletId", + ) + suspend fun detachWalletSpenders(spendingTxids: List, walletId: ByteArray) + + /** + * Mark [walletId]'s TXOs created by one of [txids] unconfirmed and + * InstantSend-locked — its outputs of transactions a chain truncation + * kept after dropping their block. Spend state is not touched, nor are + * another wallet's outputs. Chunked by the caller. + */ + @Query( + "UPDATE txos SET isConfirmed = 0, isInstantLocked = 1, height = 0 " + + "WHERE txid IN (:txids) AND walletId = :walletId", + ) + suspend fun unconfirmInstantLockedByTxids(txids: List, walletId: ByteArray) + /** * Delete the rows at [outpoints] outright — outputs of a transaction * swept in this round that some loser claimed or some release named. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt index 31bf8e81ba5..b7467daa6ed 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt @@ -78,6 +78,21 @@ interface WalletDao { ) suspend fun advanceChainLockHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int + /** + * Lower the stored synced height to [height] when it is above it — a + * chain truncation at [height]. Never raises it, and a narrow column + * write for the same reason as [advanceChainLockHeight]. + * + * @param nowMillis epoch millis for the `lastUpdated` stamp. + * @return number of rows updated (0 when the wallet row is gone or its + * height is already at or below [height]). + */ + @Query( + "UPDATE wallets SET syncedHeight = :height, lastUpdated = :nowMillis " + + "WHERE walletId = :walletId AND syncedHeight > :height" + ) + suspend fun lowerSyncedHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int + @Delete suspend fun delete(wallet: WalletEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1fe2572e8fc..b3f8bd878b3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -77,6 +77,13 @@ data class PlatformWalletPersistenceCapabilities( * is deliberately absent: Android never attests it). */ const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + /** + * A stored core changeset's chain truncations are durably applied + * before the rest of their round. Mirrors + * `PersistenceCapabilities::CORE_CHAIN_TRUNCATION`; aliased to the + * bridge's declaration like [CORE_SWEEP_REMOVAL]. + */ + const val CORE_CHAIN_TRUNCATION: Long = NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION } } 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 cc6dede2141..5044317ba6f 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 @@ -220,7 +220,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) + assertEquals(0x8bbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -234,6 +234,11 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_CHAIN_TRUNCATION)) + assertEquals( + NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION, + PlatformWalletPersistenceCapabilities.CORE_CHAIN_TRUNCATION, + ) } @Test @@ -6144,6 +6149,274 @@ class PlatformWalletPersistenceHandlerTest { ) } + // ── Chain truncations ───────────────────────────────────────────── + + /** + * The truncation slot as the JNI trampoline packs it: each list as one + * flat array plus its count. + */ + private fun truncate( + h: PlatformWalletPersistenceHandler, + height: Int, + removed: List = emptyList(), + unconfirmed: List = emptyList(), + restored: List = emptyList(), + wallet: ByteArray = walletId, + ): Int = h.onWalletChangesetChainTruncated( + wallet, + height, + removed.fold(ByteArray(0)) { acc, txid -> acc + txid }, + removed.size, + unconfirmed.fold(ByteArray(0)) { acc, txid -> acc + txid }, + unconfirmed.size, + restored.fold(ByteArray(0)) { acc, outpoint -> acc + outpoint }, + restored.size, + ) + + /** + * Stage [txid] mined at [height], spending [inputs] and paying output 0 + * back to the wallet's address. The caller brackets the round. + */ + private fun stageMinedSpend(txid: ByteArray, height: Int, blockHash: ByteArray, vararg inputs: ByteArray) { + recordTransaction( + handler, + walletId, txid, ByteArray(10) { 5 }, 2, height, blockHash, + 1_700_000_100, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + inputs.fold(ByteArray(0)) { acc, op -> acc + op }, inputs.size, + ) + handler.onWalletChangesetUtxoAdded( + walletId, txid, 0, 50_000, "yUtxoAddr", ByteArray(25) { 6 }, + height, false, true, false, false, + ) + } + + /** + * A funded coin at height 100 and a spend of it mined at 101 that pays + * change back, with the wallet synced to 101. Returns the coin's and + * the change's outpoints. + */ + private suspend fun seedSpendMinedAboveTheFork(spend: ByteArray): Pair { + val coin = seedFundedCoin(ByteArray(32) { 0x50 }) + handler.onChangesetBegin(walletId) + stageMinedSpend(spend, 101, ByteArray(32) { 8 }, coin) + handler.onChangesetEnd(walletId, success = true) + headerRound(handler, synced = 101) + assertTrue(db.txoDao().getByOutpoint(coin)!!.isSpent) + return coin to makeOutpoint(spend, 0) + } + + @Test + fun shouldRemoveATransactionOfADroppedBlockAndRestoreTheCoinItSpent() = runTest { + val spend = ByteArray(32) { 0x51 } + val (coin, change) = seedSpendMinedAboveTheFork(spend) + + handler.onChangesetBegin(walletId) + assertEquals(0, truncate(handler, height = 100, removed = listOf(spend), restored = listOf(coin))) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + assertNull(db.transactionDao().getByTxid(spend)) + assertNull("the removed transaction's own output is gone", db.txoDao().getByOutpoint(change)) + val restored = db.txoDao().getByOutpoint(coin)!! + assertFalse(restored.isSpent) + assertNull(restored.spendingTxid) + assertEquals(100, db.walletDao().getByWalletId(walletId)!!.syncedHeight) + val reloaded = newHandler().onLoadWalletList().single() + assertEquals("the restored coin is spendable after a restart", 1, reloaded.utxos.size) + assertEquals(100, reloaded.syncedHeight) + } + + @Test + fun shouldKeepAnInstantLockedTransactionUnconfirmedWhenItsBlockIsDropped() = runTest { + val spend = ByteArray(32) { 0x52 } + val (coin, change) = seedSpendMinedAboveTheFork(spend) + + handler.onChangesetBegin(walletId) + assertEquals(0, truncate(handler, height = 100, unconfirmed = listOf(spend))) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + val kept = db.transactionDao().getByTxid(spend)!! + assertEquals("unconfirmed, and still InstantSend-locked", 1, kept.context) + assertEquals(0, kept.blockHeight) + assertNull(kept.blockHash) + val output = db.txoDao().getByOutpoint(change)!! + assertFalse(output.isConfirmed) + assertTrue(output.isInstantLocked) + assertEquals(0, output.height) + assertTrue("what it spent stays spent", db.txoDao().getByOutpoint(coin)!!.isSpent) + assertEquals(100, db.walletDao().getByWalletId(walletId)!!.syncedHeight) + } + + @Test + fun shouldStoreATransactionOnceInItsNewBlockWhenTheSameRoundMinesItAgain() = runTest { + val spend = ByteArray(32) { 0x53 } + val (coin, change) = seedSpendMinedAboveTheFork(spend) + val newBlock = ByteArray(32) { 9 } + + // Native order: the truncation first, then the round as the wallet + // holds it after the fork. Applied in that order the removal cannot + // take the rows the new block wrote. + handler.onChangesetBegin(walletId) + truncate(handler, height = 100, removed = listOf(spend), restored = listOf(coin)) + stageMinedSpend(spend, 101, newBlock, coin) + handler.onWalletChangesetHeader(walletId, true, 101, false, 0, 0, 0, 0, ByteArray(0)) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + val mined = db.transactionDao().getByTxid(spend)!! + assertEquals(101, mined.blockHeight) + assertTrue(newBlock.contentEquals(mined.blockHash)) + assertNotNull(db.txoDao().getByOutpoint(change)) + val spent = db.txoDao().getByOutpoint(coin)!! + assertTrue("spent again, by the transaction in its new block", spent.isSpent) + assertTrue(spend.contentEquals(spent.spendingTxid)) + assertEquals(101, db.walletDao().getByWalletId(walletId)!!.syncedHeight) + } + + @Test + fun shouldLeaveASyncedHeightBelowTheForkAlone() = runTest { + seedFundedCoin(ByteArray(32) { 0x54 }) + headerRound(handler, synced = 90) + + handler.onChangesetBegin(walletId) + truncate(handler, height = 100) + handler.onChangesetEnd(walletId, success = true) + + assertEquals(90, db.walletDao().getByWalletId(walletId)!!.syncedHeight) + } + + @Test + fun shouldRollATruncationBackWithItsRound() = runTest { + val spend = ByteArray(32) { 0x55 } + val (coin, change) = seedSpendMinedAboveTheFork(spend) + + handler.onChangesetBegin(walletId) + truncate(handler, height = 100, removed = listOf(spend), restored = listOf(coin)) + handler.onChangesetEnd(walletId, success = false) + + assertNotNull(db.transactionDao().getByTxid(spend)) + assertNotNull(db.txoDao().getByOutpoint(change)) + assertTrue(db.txoDao().getByOutpoint(coin)!!.isSpent) + assertEquals(101, db.walletDao().getByWalletId(walletId)!!.syncedHeight) + } + + @Test + fun shouldFailTheRoundOnATruncationWhoseArraysDisagreeWithTheirCounts() = runTest { + seedFundedCoin(ByteArray(32) { 0x56 }) + + handler.onChangesetBegin(walletId) + val code = handler.onWalletChangesetChainTruncated( + walletId, 100, ByteArray(31), 1, ByteArray(0), 0, ByteArray(0), 0, + ) + + assertTrue("a packing drift must not be read as an empty truncation", code != 0) + handler.onChangesetEnd(walletId, success = false) + } + + // A transaction two wallets share: one row, a coin for each. + + private val sharingWalletA = ByteArray(32) { 0x61 } + private val sharingWalletB = ByteArray(32) { 0x62 } + private val sharedTxid = ByteArray(32) { 0x63 } + + /** Stage [sharedTxid] mined at [height] with [wallet]'s own output [vout]. The caller brackets the round. */ + private fun stageSharedOutput(wallet: ByteArray, vout: Int, address: String, height: Int, blockHash: ByteArray) { + recordTransaction( + handler, + wallet, sharedTxid, ByteArray(10) { 5 }, 2, height, blockHash, + 1_700_000_100, 0, "Standard", 0, 50_000, 0, false, "", 1_700_000_050, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + wallet, sharedTxid, vout, 50_000, address, ByteArray(25) { 6 }, + height, false, true, false, false, + ) + } + + /** Two wallets, and [sharedTxid] mined at 101 paying output 0 to A and output 1 to B. */ + private suspend fun seedTransactionSharedByTwoWallets() { + seedWalletWithAddress(sharingWalletA, "yWalletA", xpubFill = 30) + seedWalletWithAddress(sharingWalletB, "yWalletB", xpubFill = 31) + for ((wallet, vout, address) in sharedOutputs) { + handler.onChangesetBegin(wallet) + stageSharedOutput(wallet, vout, address, 101, ByteArray(32) { 8 }) + assertEquals(0, handler.onChangesetEnd(wallet, success = true)) + } + } + + private val sharedOutputs = listOf(Triple(sharingWalletA, 0, "yWalletA"), Triple(sharingWalletB, 1, "yWalletB")) + + /** + * Each wallet's whole backlog in a round of its own, A's first: the + * truncation, then the transaction in its new block with that wallet's + * output. + */ + private fun truncateAndMineAgainForEachWallet(removed: List, unconfirmed: List) { + for ((wallet, vout, address) in sharedOutputs) { + handler.onChangesetBegin(wallet) + assertEquals(0, truncate(handler, 100, removed, unconfirmed, wallet = wallet)) + stageSharedOutput(wallet, vout, address, 102, ByteArray(32) { 9 }) + assertEquals(0, handler.onChangesetEnd(wallet, success = true)) + } + } + + @Test + fun shouldKeepAnotherWalletsReMinedOutputWhenItsOwnTruncationArrivesLater() = runTest { + seedTransactionSharedByTwoWallets() + + truncateAndMineAgainForEachWallet(removed = listOf(sharedTxid), unconfirmed = emptyList()) + + val first = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 0)) + assertNotNull("wallet A's coin went missing when wallet B's truncation arrived", first) + assertEquals("wallet A's coin lost its new block", 102, first!!.height) + val second = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1)) + assertNotNull("wallet B's coin went missing", second) + assertEquals("wallet B's coin lost its new block", 102, second!!.height) + assertEquals(102, db.transactionDao().getByTxid(sharedTxid)!!.blockHeight) + } + + @Test + fun shouldKeepAnotherWalletsReMinedOutputConfirmedWhenItsOwnTruncationKeepsTheTransaction() = runTest { + seedTransactionSharedByTwoWallets() + + truncateAndMineAgainForEachWallet(removed = emptyList(), unconfirmed = listOf(sharedTxid)) + + val first = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 0)) + assertNotNull("wallet A's coin went missing", first) + assertTrue( + "wallet A's coin was returned to unconfirmed by wallet B's truncation", + first!!.isConfirmed, + ) + assertEquals("wallet A's coin lost its new block", 102, first.height) + val second = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1)) + assertNotNull("wallet B's coin went missing", second) + assertTrue("wallet B's coin stayed unconfirmed after its own round mined it", second!!.isConfirmed) + val row = db.transactionDao().getByTxid(sharedTxid)!! + assertEquals(2, row.context) + assertEquals(102, row.blockHeight) + } + + @Test + fun shouldLeaveASharedTransactionWhileAnotherWalletStillHoldsACoinFromIt() = runTest { + seedTransactionSharedByTwoWallets() + + handler.onChangesetBegin(sharingWalletA) + assertEquals(0, truncate(handler, 100, removed = listOf(sharedTxid), wallet = sharingWalletA)) + assertEquals(0, handler.onChangesetEnd(sharingWalletA, success = true)) + + assertNull("the truncating wallet's own coin is gone", db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 0))) + assertNotNull( + "wallet B's coin went missing before its own truncation arrived", + db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1)), + ) + assertNotNull("the row stays while a wallet has a part in it", db.transactionDao().getByTxid(sharedTxid)) + + handler.onChangesetBegin(sharingWalletB) + assertEquals(0, truncate(handler, 100, removed = listOf(sharedTxid), wallet = sharingWalletB)) + assertEquals(0, handler.onChangesetEnd(sharingWalletB, success = true)) + + assertNull(db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1))) + assertNull("and goes once no wallet does", db.transactionDao().getByTxid(sharedTxid)) + } + // ── Bounded tombstone lifetime ──────────────────────────────────── /** One committed round: synced height + (optionally) chainlock bytes. */ diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index dd56522df75..05583280b98 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -464,6 +464,52 @@ pub struct SweepBatchFFI { pub winner_mined_height: u32, } +/// One chain truncation: a fork replaced the chain above `height` and the +/// wallet dropped what it recorded from the blocks above it. +/// +/// Delivered through `PersistenceCallbacksExtension`'s +/// `on_persist_wallet_changeset_truncations_fn`, for the same layout reason +/// as [`SweepBatchFFI`]. The entries arrive in the order the wallet emitted +/// them and **a persister applies them, in sequence, before everything else +/// the round carries**: the rest of the round describes the wallet after the +/// last truncation, so a transaction named in `txids` can be stored again, +/// in its new block, by the changeset callback that follows. +/// +/// Applying one entry means: +/// - delete every UTXO a transaction in `txids` created for this wallet, +/// and the transaction itself unless another wallet of the same store +/// still has a part in it. Rounds are per wallet, so that wallet may +/// already have stored the transaction again, in its new block; +/// - keep each transaction in `unconfirmed_txids` as unconfirmed and +/// InstantSend-locked, which is the only kind a truncation keeps: the +/// InstantSend context, no block, outputs unconfirmed and +/// InstantSend-locked, the coins it spent still spent. The entry is all a +/// host needs for that; the lock itself does not cross this surface; +/// - mark each outpoint in `restored_outpoints` unspent again; +/// - lower a stored sync height above `height` to `height`. +/// +/// A transaction or outpoint the persister never stored is a no-op. +/// +/// # Null at count 0 +/// +/// Each pointer is null when its count is zero. A consumer must check the +/// pointer before forming a slice from it, as with [`SweepBatchFFI`]. +#[repr(C)] +pub struct ChainTruncationFFI { + /// Height of the last block the fork keeps. + pub height: u32, + /// Removed transactions, raw 32-byte txids. + pub txids: *const [u8; 32], + pub txids_count: usize, + /// InstantSend-locked transactions kept as unconfirmed, raw 32-byte + /// txids. + pub unconfirmed_txids: *const [u8; 32], + pub unconfirmed_txids_count: usize, + /// Coins the removed transactions spent, unspent again. + pub restored_outpoints: *const OutPointFFI, + pub restored_outpoints_count: usize, +} + // --------------------------------------------------------------------------- // Conversion // --------------------------------------------------------------------------- @@ -645,14 +691,21 @@ impl WalletChangeSetFFI { let mut utxos_added: Vec = Vec::new(); let mut utxos_spent: Vec = Vec::new(); for rec in &utxo_slices { - utxos_added.extend(record_new_utxos_ffi(rec)); + // The lock state is the changeset's to say: a record's + // context alone does not carry it. + utxos_added.extend(record_new_utxos_ffi( + rec, + cs.is_unconfirmed_instant_send_locked(rec), + )); utxos_spent.extend(record_spent_outpoints_ffi(rec)); } // Transaction rows for this account (wallet-level, // folded — see the bucketing comment above). - let transactions: Vec = - tx_rows.into_iter().map(tx_record_to_ffi).collect(); + let transactions: Vec = tx_rows + .into_iter() + .map(|rec| tx_record_to_ffi(rec, cs.is_unconfirmed_instant_send_locked(rec))) + .collect(); let utxos_added_count = utxos_added.len(); let utxos_spent_count = utxos_spent.len(); @@ -821,6 +874,71 @@ pub(crate) fn build_sweep_batches_for_callback( (batches, storage) } +/// Backing storage for one [`ChainTruncationFFI`]'s nested buffers, kept +/// alive by the caller for the callback window like [`SweepBatchStorage`]. +pub(crate) struct ChainTruncationStorage { + txids: Vec<[u8; 32]>, + unconfirmed_txids: Vec<[u8; 32]>, + restored: Vec, +} + +/// Build the C mirrors of a changeset's truncations for the extension +/// truncation callback (`on_persist_wallet_changeset_truncations_fn`), in +/// the wallet's emission order. +pub(crate) fn build_chain_truncations_for_callback( + cs: &platform_wallet::changeset::CoreChangeSet, +) -> (Vec, Vec) { + fn raw_txids(txids: &[dashcore::Txid]) -> Vec<[u8; 32]> { + txids + .iter() + .map(|txid| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(txid.as_ref()); + raw + }) + .collect() + } + // Null at count 0: see `ChainTruncationFFI`. + fn ptr_or_null(items: &[T]) -> *const T { + if items.is_empty() { + std::ptr::null() + } else { + items.as_ptr() + } + } + + let storage: Vec = cs + .truncations + .iter() + .map(|truncation| ChainTruncationStorage { + txids: raw_txids(&truncation.txids), + unconfirmed_txids: raw_txids(&truncation.unconfirmed_txids), + restored: truncation + .restored_outpoints + .iter() + .map(OutPointFFI::from) + .collect(), + }) + .collect(); + + let truncations: Vec = cs + .truncations + .iter() + .zip(storage.iter()) + .map(|(truncation, backing)| ChainTruncationFFI { + height: truncation.height, + txids: ptr_or_null(&backing.txids), + txids_count: backing.txids.len(), + unconfirmed_txids: ptr_or_null(&backing.unconfirmed_txids), + unconfirmed_txids_count: backing.unconfirmed_txids.len(), + restored_outpoints: ptr_or_null(&backing.restored), + restored_outpoints_count: backing.restored.len(), + }) + .collect(); + + (truncations, storage) +} + /// Returns the account "index" the FFI surfaces in `account_index`. /// /// For variants with a natural index field (`Standard`, `CoinJoin`, @@ -1162,8 +1280,11 @@ pub fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountCha /// entries. Mirrors `derive_new_utxos` in /// `platform_wallet::changeset::core_bridge` but stops one layer /// further down the stack so the FFI conversion stays self-contained. +/// `is_instant` is the record's lock state as the changeset reports it +/// (`CoreChangeSet::is_unconfirmed_instant_send_locked`). fn record_new_utxos_ffi( rec: &key_wallet::managed_account::transaction_record::TransactionRecord, + is_instant: bool, ) -> Vec { use key_wallet::managed_account::transaction_record::OutputRole; use key_wallet::transaction_checking::TransactionContext; @@ -1174,7 +1295,6 @@ fn record_new_utxos_ffi( rec.context, TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) ); - let is_instant = matches!(rec.context, TransactionContext::InstantSend(_)); let is_coinbase = rec.transaction.is_coin_base(); rec.output_details @@ -1527,6 +1647,7 @@ pub(crate) fn masternode_entry_v2_ffi( fn tx_record_to_ffi( tr: &key_wallet::managed_account::transaction_record::TransactionRecord, + is_instant: bool, ) -> TransactionRecordFFI { use key_wallet::transaction_checking::TransactionContext; use std::ffi::CString; @@ -1540,10 +1661,11 @@ fn tx_record_to_ffi( let (ctx_val, blk_height, blk_hash, blk_ts, blk_position, has_blk_position) = match &tr.context { - TransactionContext::Mempool => (0u32, 0u32, [0u8; 32], 0u32, 0u32, false), - TransactionContext::InstantSend(_is_lock) => { - // InstantSend has no block info — treat as mempool-level with flag - (1u32, 0u32, [0u8; 32], 0u32, 0u32, false) + // Unconfirmed: whether it is locked is the caller's, read from the + // changeset (`CoreChangeSet::is_unconfirmed_instant_send_locked`). + TransactionContext::Mempool | TransactionContext::InstantSend(_) => { + // InstantSend has no block info — mempool-level with the flag. + (u32::from(is_instant), 0u32, [0u8; 32], 0u32, 0u32, false) } TransactionContext::InBlock(bi) => { let mut h = [0u8; 32]; @@ -2026,64 +2148,119 @@ mod tests { unsafe { free_wallet_changeset_ffi(&ffi) }; } - /// The conversion of a send's record that carries an InstantSend context - /// and is in no block: its change coin goes to the owning account's - /// bucket as locked and unconfirmed. An InstantSend lock's changeset - /// names no coins of its own, so this row is where a persister learns - /// the change is locked. The record here is built by hand; that the - /// lock re-emits one of this shape is the bridge's test - /// (`should_re_emit_the_change_of_an_unmined_send_with_its_lock`). + /// One round sees a transaction mined and then its block dropped. The + /// truncation keeps it, which only an InstantSend lock earns, and the + /// host has already stored it that way when the truncation callback ran + /// first. The row and the coin that follow must say the same, or they + /// overwrite the lock with a plain mempool sighting. #[test] - fn should_file_the_change_of_a_locked_unmined_send_as_instant_locked() { - use dashcore::ephemerealdata::instant_lock::InstantLock; - use dashcore::OutPoint; + fn should_project_a_transaction_a_merged_truncation_keeps_as_instant_send_locked() { + use dashcore::hashes::Hash; + use dashcore::{Address, BlockHash, Network, OutPoint, ScriptBuf, TxIn, TxOut, Witness}; use key_wallet::managed_account::transaction_record::{ - TransactionDirection, TransactionRecord, + OutputDetail, OutputRole, TransactionDirection, TransactionRecord, }; use key_wallet::transaction_checking::transaction_router::TransactionType; - use key_wallet::transaction_checking::TransactionContext; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use platform_wallet::changeset::changeset::ChainTruncation; + use platform_wallet::changeset::Merge; let bip44 = AccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, }; - let SendWithChange { - tx, - our_input, - sent, - change, - } = send_with_change(); - let txid = tx.txid(); - let locked_send = TransactionRecord::new( + let ours = Address::dummy(Network::Testnet, 1); + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::default(), + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + }], + output: vec![TxOut { + value: 50_000, + script_pubkey: ours.script_pubkey(), + }], + special_transaction_payload: None, + }; + let mined = TransactionRecord::new( tx, bip44, - TransactionContext::InstantSend(InstantLock::default()), + TransactionContext::InBlock(BlockInfo::new( + 101, + BlockHash::from_byte_array([7; 32]), + 1_700_000_000, + )), TransactionType::Standard, - TransactionDirection::Outgoing, - vec![our_input], - vec![sent, change], - -901_000, + TransactionDirection::Incoming, + vec![], + vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(ours), + value: 50_000, + }], + 50_000, ); - - let cs = CoreChangeSet { - records: vec![locked_send.clone()], - account_records: vec![locked_send], + let txid = mined.txid; + // A plain mempool sighting no truncation names, to show the + // projection changes nothing for it. + let mut unrelated = mined.clone(); + unrelated.transaction.lock_time = 1; + unrelated.txid = unrelated.transaction.txid(); + unrelated.update_context(TransactionContext::Mempool); + let unrelated_txid = unrelated.txid; + + let mut round = CoreChangeSet { + records: vec![mined.clone(), unrelated.clone()], + account_records: vec![mined, unrelated], ..CoreChangeSet::default() }; - let ffi = WalletChangeSetFFI::from_changeset(&cs); + round.merge(CoreChangeSet { + truncations: vec![ChainTruncation { + height: 100, + txids: vec![], + unconfirmed_txids: vec![txid], + restored_outpoints: vec![], + }], + ..CoreChangeSet::default() + }); + + let ffi = WalletChangeSetFFI::from_changeset(&round); assert_eq!(ffi.accounts_count, 1); let bucket = unsafe { &*ffi.accounts }; - let tags = account_type_to_tags(&bip44); - assert_eq!(bucket.type_tag, tags.type_tag); - assert_eq!(bucket.standard_tag, tags.standard_tag); - assert_eq!(bucket.account_index, tags.index); - assert_eq!(bucket.utxos_added_count, 1, "the change, not the payment"); - let change = unsafe { &*bucket.utxos_added }; - assert_eq!(OutPoint::from(&change.outpoint), OutPoint { txid, vout: 1 }); - assert_eq!(change.amount, 99_000); - assert!(change.is_instantlocked); - assert!(!change.is_confirmed); - assert_eq!(change.height, 0); + let rows = + unsafe { std::slice::from_raw_parts(bucket.transactions, bucket.transactions_count) }; + let coins = + unsafe { std::slice::from_raw_parts(bucket.utxos_added, bucket.utxos_added_count) }; + let row_of = |txid: &dashcore::Txid| { + rows.iter() + .find(|row| row.txid == txid.to_byte_array()) + .expect("the transaction row is projected") + }; + let coin_of = |txid: &dashcore::Txid| { + coins + .iter() + .find(|coin| coin.outpoint.txid == txid.to_byte_array()) + .expect("the coin is projected") + }; + + let row = row_of(&txid); + assert_eq!(row.context, 1, "InstantSend, not a plain mempool sighting"); + assert_eq!(row.block_height, 0, "and in no block"); + let coin = coin_of(&txid); + assert!(coin.is_instantlocked); + assert!(!coin.is_confirmed); + assert_eq!(coin.height, 0); + + assert_eq!( + row_of(&unrelated_txid).context, + 0, + "a mempool record no truncation names stays one" + ); + assert!(!coin_of(&unrelated_txid).is_instantlocked); unsafe { free_wallet_changeset_ffi(&ffi) }; } @@ -2296,6 +2473,59 @@ mod tests { ); unsafe { crate::wallet::platform_wallet_manager_free_masternodes_v2(v2, 2) }; } + #[test] + fn should_file_the_change_of_a_locked_unmined_send_as_instant_locked() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::OutPoint; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::TransactionContext; + + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let SendWithChange { + tx, + our_input, + sent, + change, + } = send_with_change(); + let txid = tx.txid(); + let locked_send = TransactionRecord::new( + tx, + bip44, + TransactionContext::InstantSend(InstantLock::default()), + TransactionType::Standard, + TransactionDirection::Outgoing, + vec![our_input], + vec![sent, change], + -901_000, + ); + + let cs = CoreChangeSet { + records: vec![locked_send.clone()], + account_records: vec![locked_send], + ..CoreChangeSet::default() + }; + let ffi = WalletChangeSetFFI::from_changeset(&cs); + assert_eq!(ffi.accounts_count, 1); + let bucket = unsafe { &*ffi.accounts }; + let tags = account_type_to_tags(&bip44); + assert_eq!(bucket.type_tag, tags.type_tag); + assert_eq!(bucket.standard_tag, tags.standard_tag); + assert_eq!(bucket.account_index, tags.index); + assert_eq!(bucket.utxos_added_count, 1, "the change, not the payment"); + let change = unsafe { &*bucket.utxos_added }; + assert_eq!(OutPoint::from(&change.outpoint), OutPoint { txid, vout: 1 }); + assert_eq!(change.amount, 99_000); + assert!(change.is_instantlocked); + assert!(!change.is_confirmed); + assert_eq!(change.height, 0); + unsafe { free_wallet_changeset_ffi(&ffi) }; + } } // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4d1adad6356..091d806fd14 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -12,8 +12,9 @@ use crate::persistence::{ LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, - PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + PersistWalletChangesetTruncationsFn, PersistWalletChangesetUtxoVerdictsFn, + PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -290,6 +291,10 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), + wallet_changeset_truncations: slot!( + on_persist_wallet_changeset_truncations_fn, + PersistWalletChangesetTruncationsFn + ), } } @@ -1369,47 +1374,34 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + /// A host whose `struct_size` stops before the truncations slot keeps + /// its earlier slots and never has this one read. #[test] - fn should_size_gate_asset_lock_operation_id_slots_independently() { - unsafe extern "C" fn persist( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *const u8, - ) -> i32 { - 0 - } - unsafe extern "C" fn load( + fn should_read_the_truncations_slot_only_from_an_extension_that_covers_it() { + unsafe extern "C" fn persist_truncations( _: *mut c_void, _: *const u8, - _: *const u8, - _: *mut bool, - _: *mut u8, + _: *const crate::core_wallet_types::ChainTruncationFFI, + _: usize, ) -> i32 { 0 } + let slot_offset = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_truncations_fn + ); let mut ext = PersistenceCallbacksExtension { - on_persist_asset_lock_operation_id_fn: Some(persist), - on_load_asset_lock_operation_id_fn: Some(load), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_truncations_fn: Some(persist_truncations), ..Default::default() }; let parsed = unsafe { persistence_extension_callbacks(&ext) }; - assert!(parsed.persist_asset_lock_operation_id.is_some()); - assert!(parsed.load_asset_lock_operation_id.is_some()); - ext.struct_size = std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_asset_lock_operation_id_fn - ); - let parsed = unsafe { persistence_extension_callbacks(&ext) }; - assert!(parsed.persist_asset_lock_operation_id.is_some()); - assert!(parsed.load_asset_lock_operation_id.is_none()); - ext.struct_size = std::mem::offset_of!( - PersistenceCallbacksExtension, - on_persist_asset_lock_operation_id_fn - ); + assert!(parsed.wallet_changeset_truncations.is_some()); + + ext.struct_size = slot_offset; let parsed = unsafe { persistence_extension_callbacks(&ext) }; - assert!(parsed.persist_asset_lock_operation_id.is_none()); - assert!(parsed.load_asset_lock_operation_id.is_none()); + assert!(parsed.wallet_changeset_sweeps.is_some()); + assert!(parsed.wallet_changeset_truncations.is_none()); } /// A host whose `struct_size` stops right after the chainlock-height @@ -2220,4 +2212,46 @@ mod remove_wallet_lifecycle_tests { drop(in_flight_a); }); } + #[test] + fn should_size_gate_asset_lock_operation_id_slots_independently() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut bool, + _: *mut u8, + ) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_persist_asset_lock_operation_id_fn: Some(persist), + on_load_asset_lock_operation_id_fn: Some(load), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_asset_lock_operation_id.is_some()); + assert!(parsed.load_asset_lock_operation_id.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_asset_lock_operation_id_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_asset_lock_operation_id.is_some()); + assert!(parsed.load_asset_lock_operation_id.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_asset_lock_operation_id_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_asset_lock_operation_id.is_none()); + assert!(parsed.load_asset_lock_operation_id.is_none()); + } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 09e06b1fe14..da3815344a1 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -55,9 +55,10 @@ use crate::contact_persistence::{ }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ - build_dashpay_backfill_covered_for_callback, build_sweep_batches_for_callback, - build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, - DashPayBackfillCoveredContactFFI, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, + build_chain_truncations_for_callback, build_dashpay_backfill_covered_for_callback, + build_sweep_batches_for_callback, build_utxo_credit_verdicts_for_callback, + free_wallet_changeset_ffi, ChainTruncationFFI, DashPayBackfillCoveredContactFFI, SweepBatchFFI, + UtxoCreditVerdictFFI, WalletChangeSetFFI, }; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ @@ -157,6 +158,11 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = 1 << 13; +/// Chain truncations are applied before the rest of their round and +/// atomically with it. Requires the extension slot +/// `on_persist_wallet_changeset_truncations_fn`, the changeset callback, the +/// begin/end pair with `ATOMIC_CHANGESETS`, and the host declaring the bit. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_CHAIN_TRUNCATION: u64 = 1 << 15; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -260,6 +266,19 @@ pub type PersistWalletChangesetUtxoVerdictsFn = unsafe extern "C" fn( verdicts_count: usize, ) -> i32; +/// Carries a round's chain truncations (see [`ChainTruncationFFI`]). Fired +/// inside the round's begin/end bracket BEFORE every other callback that +/// carries core wallet state, because the rest of the round describes the +/// wallet after the last truncation. Fired only on rounds that carry one. A +/// non-zero return fails the round like any other per-kind callback: a +/// truncation silently skipped leaves the replaced branch in the store. +pub type PersistWalletChangesetTruncationsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + truncations: *const ChainTruncationFFI, + truncations_count: usize, +) -> i32; + /// Persist one identity's optional balance freshness stamp in the same /// transaction as its legacy identity row. A null stamp means no watermark. pub type PersistIdentityBalanceBlockTimeFn = unsafe extern "C" fn( @@ -485,6 +504,19 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, + /// The round's chain truncations (see + /// [`PersistWalletChangesetTruncationsFn`]). Appended under the same + /// version like the slots above: `struct_size` proves whether a host + /// allocated it. A host without it is never handed a truncation and + /// cannot attest `CORE_CHAIN_TRUNCATION`. + pub on_persist_wallet_changeset_truncations_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + truncations: *const ChainTruncationFFI, + truncations_count: usize, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -505,6 +537,7 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_dashpay_backfill_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, + on_persist_wallet_changeset_truncations_fn: None, } } } @@ -526,6 +559,7 @@ pub struct PersistenceExtensionCallbacks { pub wallet_dashpay_backfill: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, + pub wallet_changeset_truncations: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1462,6 +1496,8 @@ pub struct FFIPersister { /// host without it re-runs the backfill on every launch, as every host /// did before the slot existed (dashpay/platform#4302). wallet_dashpay_backfill_callback: Option, + /// The round's truncations, applied atomically before its other rows. + wallet_changeset_truncations_callback: Option, /// Additive tracked-masternode persistence trio (persist / load / /// free), likewise extension-negotiated. tracked_masternodes_callbacks: PersistenceExtensionCallbacks, @@ -1588,6 +1624,7 @@ impl FFIPersister { .wallet_changeset_chain_lock_height, wallet_changeset_utxo_verdicts_callback: extensions.wallet_changeset_utxo_verdicts, wallet_dashpay_backfill_callback: extensions.wallet_dashpay_backfill, + wallet_changeset_truncations_callback: extensions.wallet_changeset_truncations, tracked_masternodes_callbacks: extensions, persist_identity_balance_block_time_callback: extensions .persist_identity_balance_block_time, @@ -1723,6 +1760,20 @@ impl FFIPersister { { capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); } + // Same structural proof and same split-transport reasoning as the + // sweep bit above: the truncation arrives on its own call, ahead of + // the changeset callback whose rows it cuts back, so only a round + // that commits or rolls back whole can apply the two together. + if self.wallet_changeset_truncations_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self + .declared_capabilities + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) + { + capabilities = capabilities.union(PersistenceCapabilities::CORE_CHAIN_TRUNCATION); + } #[cfg(feature = "shielded")] if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() @@ -2137,6 +2188,34 @@ impl PlatformWalletPersistence for FFIPersister { // Send core wallet changeset (accounts, transactions, UTXOs). if let Some(ref core_cs) = changeset.core { + // Truncations first: every other callback of this block carries + // the wallet as it is after the last of them. A host without + // the slot never sees them and cannot attest + // `CORE_CHAIN_TRUNCATION`, so the wallet-event adapter withholds + // them one layer up. + if !core_cs.truncations.is_empty() { + if let Some(cb) = self.wallet_changeset_truncations_callback { + let (truncations, _truncation_storage) = + build_chain_truncations_for_callback(core_cs); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + truncations.as_ptr(), + truncations.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet changeset chain-truncation persistence callback returned \ + error code {}", + result + ); + outcome.record(result); + } + } + } + // Fan out gap-limit-extension addresses BEFORE the wallet // changeset itself: the changeset's UTXOs reference these // addresses, and the Swift-side `upsertUtxo`'s @@ -8433,6 +8512,260 @@ mod tests { .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); } + /// The truncation bit mirrors the sweep bit: the slot is the structural + /// proof, and because the truncation travels on its own call it also + /// needs the changeset callback it cuts back and a round that commits + /// or rolls back whole. + #[test] + fn should_attest_chain_truncation_only_with_the_slot_the_atomic_round_and_the_declaration() { + unsafe extern "C" fn noop_truncations( + _ctx: *mut c_void, + _wallet_id: *const u8, + _truncations: *const ChainTruncationFFI, + _truncations_count: usize, + ) -> i32 { + 0 + } + fn attests( + callbacks: PersistenceCallbacks, + declared: PersistenceCapabilities, + truncations: Option, + ) -> bool { + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + declared, + PersistenceExtensionCallbacks { + wallet_changeset_truncations: truncations, + ..Default::default() + }, + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_CHAIN_TRUNCATION) + } + fn wired_callbacks() -> PersistenceCallbacks { + PersistenceCallbacks { + on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + ..Default::default() + } + } + let declared = PersistenceCapabilities::CORE_CHAIN_TRUNCATION + .union(PersistenceCapabilities::ATOMIC_CHANGESETS); + + assert!( + !attests(wired_callbacks(), declared, None), + "declared without the slot" + ); + assert!( + !attests( + PersistenceCallbacks::default(), + declared, + Some(noop_truncations) + ), + "no changeset callback to cut back" + ); + assert!( + !attests( + wired_callbacks(), + PersistenceCapabilities::ATOMIC_CHANGESETS, + Some(noop_truncations) + ), + "wired but never declared" + ); + assert!( + !attests( + wired_callbacks(), + PersistenceCapabilities::CORE_CHAIN_TRUNCATION, + Some(noop_truncations) + ), + "no atomic round to bracket the two calls" + ); + assert!( + !attests( + PersistenceCallbacks { + on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + ..Default::default() + }, + declared, + Some(noop_truncations) + ), + "atomic declared but begin/end not wired" + ); + assert!(attests(wired_callbacks(), declared, Some(noop_truncations))); + } + + /// A round's truncations reach the host before anything else it carries + /// about core state, in emission order and with payloads intact: the + /// changeset that follows describes the wallet after them. + #[test] + fn should_deliver_truncations_before_the_rest_of_the_round() { + use dashcore::hashes::Hash as _; + use platform_wallet::changeset::changeset::{ChainTruncation, SweepBatch}; + use platform_wallet::changeset::CoreChangeSet; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + fail_truncations: bool, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let changeset = &*changeset; + sink.events.lock().unwrap().push(format!( + "changeset synced={:?}", + changeset.has_chain.then_some(changeset.chain.synced_height) + )); + 0 + } + unsafe extern "C" fn record_sweeps( + ctx: *mut c_void, + _wallet_id: *const u8, + _sweeps: *const SweepBatchFFI, + sweeps_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events + .lock() + .unwrap() + .push(format!("sweeps x{sweeps_count}")); + 0 + } + unsafe extern "C" fn record_truncations( + ctx: *mut c_void, + _wallet_id: *const u8, + truncations: *const ChainTruncationFFI, + truncations_count: usize, + ) -> i32 { + // Null at count 0, as the struct documents. + unsafe fn first_bytes(txids: *const [u8; 32], count: usize) -> Vec { + if txids.is_null() { + return Vec::new(); + } + slice::from_raw_parts(txids, count) + .iter() + .map(|txid| txid[0]) + .collect() + } + let sink = &*(ctx as *const Sink); + if sink.fail_truncations { + return 1; + } + let mut events = sink.events.lock().unwrap(); + for truncation in slice::from_raw_parts(truncations, truncations_count) { + let restored = if truncation.restored_outpoints.is_null() { + &[][..] + } else { + slice::from_raw_parts( + truncation.restored_outpoints, + truncation.restored_outpoints_count, + ) + }; + events.push(format!( + "truncation height={} removed={:?} unconfirmed={:?} restored={:?}", + truncation.height, + first_bytes(truncation.txids, truncation.txids_count), + first_bytes( + truncation.unconfirmed_txids, + truncation.unconfirmed_txids_count + ), + restored + .iter() + .map(|o| (o.txid[0], o.vout)) + .collect::>(), + )); + } + 0 + } + + fn txid(byte: u8) -> dashcore::Txid { + dashcore::Txid::from_byte_array([byte; 32]) + } + fn round() -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + truncations: vec![ + ChainTruncation { + height: 900, + txids: vec![txid(0x11), txid(0x12)], + unconfirmed_txids: vec![txid(0x21)], + restored_outpoints: vec![dashcore::OutPoint::new(txid(0x31), 4)], + }, + ChainTruncation { + height: 898, + txids: Vec::new(), + unconfirmed_txids: Vec::new(), + restored_outpoints: Vec::new(), + }, + ], + synced_height: Some(899), + sweeps: vec![SweepBatch { + txids: vec![txid(0x41)], + superseded_by: txid(0x42), + winner_mined_height: None, + released_outpoints: Vec::new(), + }], + ..Default::default() + }), + ..Default::default() + } + } + fn persister(sink: &Sink, with_slot: bool) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + wallet_changeset_sweeps: Some(record_sweeps), + wallet_changeset_truncations: with_slot.then_some(record_truncations), + ..Default::default() + }, + ) + } + + let sink = Sink::default(); + persister(&sink, true) + .store([1u8; 32], round()) + .expect("the round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "truncation height=900 removed=[17, 18] unconfirmed=[33] restored=[(49, 4)]" + .to_string(), + "truncation height=898 removed=[] unconfirmed=[] restored=[]".to_string(), + "changeset synced=Some(899)".to_string(), + "sweeps x1".to_string(), + ], + ); + + // A host whose extension never proved the slot is not handed them. + let sink = Sink::default(); + persister(&sink, false) + .store([1u8; 32], round()) + .expect("the round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset synced=Some(899)".to_string(), + "sweeps x1".to_string() + ], + ); + + // A host that could not apply them fails the round. + let sink = Sink { + fail_truncations: true, + ..Sink::default() + }; + assert!(persister(&sink, true).store([1u8; 32], round()).is_err()); + } + /// The delivery contract of the extension transport itself: a /// sweep-carrying round hands its batches to the extension slot AFTER /// the changeset callback, within the same round, in emission order and @@ -10256,6 +10589,16 @@ mod tests { PersistenceCallbacksExtension, on_load_asset_lock_operation_id_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_truncations_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_truncations_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -10318,6 +10661,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS, PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_CHAIN_TRUNCATION, + PersistenceCapabilities::CORE_CHAIN_TRUNCATION.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index cdfb33f9a25..d9028070e80 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,9 +7,9 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension, Transaction}; use dpp::prelude::Identifier; -use platform_wallet::changeset::changeset::SweepBatch; +use platform_wallet::changeset::changeset::{ChainTruncation, SweepBatch}; use platform_wallet::changeset::{ - ClientStartState, IdentityChangeSet, PersistenceCapabilities, PersistenceError, + ClientStartState, CoreChangeSet, IdentityChangeSet, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::wallet::identity::ManagedIdentity; @@ -1112,12 +1112,12 @@ impl SqlitePersister { } /// Classify the failure: transient errors restore the buffer and - /// surface as `FlushRetryable`; everything else drops the changeset - /// and returns the original variant. + /// surface as `FlushRetryable`; everything else drops the changeset, + /// except its chain truncations, and returns the original variant. fn handle_flush_error( &self, wallet_id: &WalletId, - cs: PendingWrites, + mut cs: PendingWrites, err: WalletStorageError, ) -> Result<(), PersistenceError> { let field_count = populated_field_count(&cs); @@ -1161,13 +1161,55 @@ impl SqlitePersister { source, })) } else { + // A truncation is reported once, and a round restored after a + // transient failure is this store's alone to retry: its caller + // no longer holds the truncation in it. So the truncations stay + // buffered for the next write, which applies them ahead of + // whatever it brings. One that arrives a second time from a + // caller that kept its own copy lands right behind the first, + // where applying it again changes nothing. Nothing but + // truncations is kept here, and they only ever take away, so a + // repeat is dropped: a truncation that can never be applied + // would otherwise add a copy with every round it fails. + let mut truncations: Vec = Vec::new(); + for truncation in cs + .segments + .iter_mut() + .filter_map(|segment| segment.core.as_mut()) + .flat_map(|core| std::mem::take(&mut core.truncations)) + { + if !truncations.contains(&truncation) { + truncations.push(truncation); + } + } + let kept_truncation_count = truncations.len(); tracing::error!( wallet_id = %hex::encode(wallet_id), error_kind = kind, dropped_field_count = field_count, + kept_truncation_count, "flush failed fatally — buffer wiped" ); drop(cs); + if !truncations.is_empty() { + let owed = PendingWrites { + segments: vec![PlatformWalletChangeSet { + core: Some(CoreChangeSet { + truncations, + ..CoreChangeSet::default() + }), + ..PlatformWalletChangeSet::default() + }], + }; + if let Err(restore_err) = self.buffer.restore(*wallet_id, owed) { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error_kind = restore_err.error_kind_str(), + lost_truncation_count = kept_truncation_count, + "chain truncations could not be kept after a fatal flush error — the store keeps the replaced branch" + ); + } + } Err(PersistenceError::from(err)) } } @@ -1327,7 +1369,8 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) - .union(PersistenceCapabilities::DASHPAY_PAYMENTS); + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(PersistenceCapabilities::CORE_CHAIN_TRUNCATION); #[cfg(feature = "shielded")] { capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) @@ -2447,6 +2490,60 @@ fn current_schema_version(conn: &Connection) -> Result, WalletStorag mod tests { use super::*; + /// A caller that kept its own copy of a rejected truncation offers it + /// again with every round. While that truncation cannot be applied each + /// round fails, and what the buffer keeps must not grow with them. + #[test] + fn should_keep_one_copy_of_a_truncation_offered_again_after_a_fatal_flush() { + let dir = tempfile::tempdir().expect("temp dir"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)) + .expect("secure tempdir permissions"); + } + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(dir.path().join("wallet.db"))) + .expect("open persister"); + let wallet_id: WalletId = [7; 32]; + let at = |height| ChainTruncation { + height, + txids: Vec::new(), + unconfirmed_txids: Vec::new(), + restored_outpoints: Vec::new(), + }; + // The second fork is reported while the first is still owed. + let offers = [vec![at(100)], vec![at(100), at(90)], vec![at(100), at(90)]]; + + for truncations in offers { + persister.force_next_flush_to_fail(WalletStorageError::Sqlite( + rusqlite::Error::InvalidColumnIndex(0), + )); + let rejected = persister.store( + wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + truncations, + ..CoreChangeSet::default() + }), + ..PlatformWalletChangeSet::default() + }, + ); + assert!(rejected.is_err(), "the fatal failure rejects the round"); + } + + let kept: Vec = persister + .buffer + .take_for_flush(&wallet_id) + .expect("buffer lock") + .into_iter() + .flat_map(|pending| pending.segments) + .filter_map(|segment| segment.core) + .flat_map(|core| core.truncations) + .collect(); + assert_eq!(kept, vec![at(100), at(90)]); + } + /// Every table in the migrated schema is accounted for: rehydrated by /// `load()`, reachable through a dedicated production API, listed as /// unimplemented, or schema infrastructure. A table in none of those diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 19570322b88..6b0ee0f6d3a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -11,7 +11,7 @@ use dashcore::{hashes::Hash, OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; -use platform_wallet::changeset::changeset::{SweepBatch, UtxoCreditVerdict}; +use platform_wallet::changeset::changeset::{ChainTruncation, SweepBatch, UtxoCreditVerdict}; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -83,6 +83,11 @@ pub fn apply( wallet_id: &WalletId, cs: &CoreChangeSet, ) -> Result<(), WalletStorageError> { + // First: the rest of the changeset is the wallet after its last + // truncation, so its rows must land on a store already cut back. + for truncation in &cs.truncations { + apply_truncation(tx, wallet_id, truncation)?; + } if !cs.records.is_empty() { let mut stmt = tx.prepare_cached( "INSERT INTO core_transactions \ @@ -435,6 +440,95 @@ pub fn apply( Ok(()) } +/// Cut the store back the way the wallet cut itself back when a fork replaced +/// the chain above `truncation.height`. +/// +/// Removed transactions lose their row, every output they created and their +/// InstantSend lock. Kept ones become unconfirmed: the confirmation columns +/// are cleared and the stored record's context is rewritten to match, so a +/// reload replays it as the wallet holds it. Restored coins are unspent, and +/// both stored heights come down to the fork. +/// +/// Idempotent, and a transaction or coin this store never recorded is a +/// no-op. Unlike a sweep's release, `restored_outpoints` needs no veto from +/// stored claims: the removed spender was confirmed, and no block the fork +/// keeps can hold a second spend of the same coin. +/// +/// # Errors +/// +/// A kept transaction whose stored record cannot be decoded, or names another +/// transaction, fails the round: clearing its columns alone would leave a +/// record that still claims the dropped block. +fn apply_truncation( + tx: &Transaction<'_>, + wallet_id: &WalletId, + truncation: &ChainTruncation, +) -> Result<(), WalletStorageError> { + for txid in &truncation.txids { + let key = params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)]; + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + key, + )?; + // `encode_outpoint_txid_occupies_bytes_two_to_thirty_three` pins this prefix. + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2", + key, + )?; + tx.execute( + "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + key, + )?; + } + for txid in &truncation.unconfirmed_txids { + // Strict: read before the columns change, so the record is checked + // against the block it was stored with. + let record = get_tx_record(tx, wallet_id, txid, &LoadCtx::strict())?; + tx.execute( + "UPDATE core_transactions \ + SET height = NULL, block_hash = NULL, block_time = NULL, finalized = 0 \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)], + )?; + if let Some(mut record) = record { + record.update_context(TransactionContext::Mempool); + tx.execute( + "UPDATE core_transactions SET record_blob = ?3 WHERE wallet_id = ?1 AND txid = ?2", + params![ + wallet_id.as_slice(), + AsRef::<[u8]>::as_ref(txid), + blob::encode(&record)? + ], + )?; + } + } + for outpoint in &truncation.restored_outpoints { + let key = blob::encode_outpoint(outpoint)?; + // A claim that never materialised has no coin to hand back. + let dropped = tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 1", + params![wallet_id.as_slice(), &key[..]], + )?; + if dropped == 0 { + tx.execute( + "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..]], + )?; + } + } + // `MIN` leaves a height that was never stored as it is: NULL. + tx.execute( + "UPDATE core_sync_state \ + SET last_processed_height = MIN(last_processed_height, ?2), \ + synced_height = MIN(synced_height, ?2) \ + WHERE wallet_id = ?1", + params![wallet_id.as_slice(), i64::from(truncation.height)], + )?; + Ok(()) +} + /// The union of every input outpoint claimed by a surviving /// `core_transactions` row — every row except this round's swept losers, /// whose deletion the round itself performs. diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 51e5fcf4fe5..81e8d7e4034 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -25,6 +25,7 @@ mod sqlite_backup_restore; mod sqlite_blob_roundtrip_coverage; mod sqlite_blob_size_gate_on_load; mod sqlite_buffer_semantics; +mod sqlite_chain_truncation; mod sqlite_check_constraints; mod sqlite_clean_restart; mod sqlite_cli_smoke; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_chain_truncation.rs new file mode 100644 index 00000000000..fb196711c1c --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_chain_truncation.rs @@ -0,0 +1,1079 @@ +#![cfg(feature = "sqlite")] + +//! A chain reorganization must reach the store: after a restart the wallet +//! holds the balances, history and scan position it held without one. +//! +//! Every case drives a real `WalletManager` through the real wallet-event +//! adapter into this crate's SQLite persister, then reopens the database and +//! compares what `load()` hands back with the wallet that never restarted. + +use super::common; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use dashcore::{ + address::Payload, + block::{Header, Version}, + ephemerealdata::instant_lock::InstantLock, + hashes::Hash, + Address, Block, BlockHash, CompactTarget, Network, OutPoint, PubkeyHash, Transaction, TxIn, + TxMerkleNode, TxOut, Txid, +}; +use key_wallet::{ + wallet::{ + initialization::WalletAccountCreationOptions, + managed_wallet_info::wallet_info_interface::WalletInfoInterface, ManagedWalletInfo, + }, + WalletCoreBalance, +}; +use platform_wallet::{ + changeset::{ + changeset::ChainTruncation, spawn_wallet_event_adapter, AccountRegistrationEntry, + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, + }, + key_wallet_manager::{WalletEvent, WalletInterface, WalletManager}, + wallet::platform_wallet::{PlatformWalletInfo, WalletId}, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; +use proptest::prelude::*; +use tokio::sync::{mpsc, RwLock}; +use tokio_util::sync::CancellationToken; + +type Manager = Arc>>; + +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +/// Height of the block that funds the wallet; no case forks below it. +const BASE: u32 = 100; +const FUNDING: u64 = 10_000_000; +const FEE: u64 = 1_000; + +/// What a user can tell apart: balances, coins, history and scan position. +#[derive(Debug, Clone, PartialEq)] +struct WalletView { + balance: WalletCoreBalance, + synced_height: u32, + /// Compared with `<=`, see [`WalletView::follows`]. + last_processed_height: u32, + /// `(value, is_confirmed, is_instantlocked, height)` per unspent coin. + coins: BTreeMap, + /// Block height of every recorded transaction, `None` when unconfirmed. + history: BTreeMap>, +} + +impl WalletView { + /// Whether this restored wallet is the `memory` one. + /// + /// Everything must be equal except `last_processed_height`, which may + /// trail: the wallet reports a block only when it holds something of + /// its own, so a store never hears of the blocks in between. + fn follows(&self, memory: &WalletView) -> Result<(), String> { + let mut restored = self.clone(); + if restored.last_processed_height <= memory.last_processed_height { + restored.last_processed_height = memory.last_processed_height; + } + if restored == *memory { + Ok(()) + } else { + Err(format!( + "restored wallet differs from the one that never restarted\n restored: {self:?}\n memory: {memory:?}" + )) + } + } +} + +fn view(info: &ManagedWalletInfo) -> WalletView { + WalletView { + balance: info.balance, + synced_height: info.metadata.synced_height, + last_processed_height: info.metadata.last_processed_height, + coins: info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| account.utxos.values()) + .map(|coin| { + ( + coin.outpoint, + ( + coin.value(), + coin.is_confirmed, + coin.is_instantlocked, + coin.height, + ), + ) + }) + .collect(), + history: info + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values()) + .map(|record| (record.txid, record.height())) + .collect(), + } +} + +fn foreign() -> Address { + Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0xF0; 20])), + ) +} + +fn transaction(inputs: &[OutPoint], outputs: &[(&Address, u64)]) -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: inputs + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: outputs + .iter() + .map(|(address, value)| TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + } +} + +fn instant_lock(tx: &Transaction) -> InstantLock { + InstantLock { + txid: tx.txid(), + inputs: tx.input.iter().map(|input| input.previous_output).collect(), + ..Default::default() + } +} + +/// One database fed by the wallet-event adapter. +struct Store { + persister: Option>, + path: PathBuf, + _dir: tempfile::TempDir, +} + +impl Store { + fn seeded(wallet_id: WalletId, registrations: &[AccountRegistrationEntry]) -> Self { + let (persister, dir, path) = common::fresh_persister(); + persister + .store( + wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: registrations.to_vec(), + ..Default::default() + }, + ) + .unwrap(); + Self { + persister: Some(Arc::new(persister)), + path, + _dir: dir, + } + } + + fn persister(&self) -> &Arc { + self.persister.as_ref().expect("store is open") + } + + /// One adapter drain over `events`: a single `store()` round. + async fn persist(&self, manager: &Manager, events: Vec) { + if events.is_empty() { + return; + } + let (sender, receiver) = mpsc::unbounded_channel(); + for event in events { + sender.send(event).unwrap(); + } + drop(sender); + let fault = Arc::new(AtomicBool::new(false)); + spawn_wallet_event_adapter( + Arc::clone(manager), + Arc::downgrade(self.persister()), + receiver, + Arc::clone(&fault), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!fault.load(Ordering::Relaxed), "the store rejected a round"); + } + + /// Close the database and read it back the way a relaunching client does. + fn restart(&mut self, wallet_id: WalletId) -> ManagedWalletInfo { + // The open-path registry refuses a second live persister on one file. + drop(self.persister.take()); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&self.path)).unwrap(); + let info = reopened + .load() + .unwrap() + .wallets + .remove(&wallet_id) + .expect("the wallet is restored") + .wallet_info; + self.persister = Some(Arc::new(reopened)); + info + } + + fn transaction_rows(&self, txid: &Txid) -> Vec> { + let conn = self.persister().lock_conn_for_test(); + let mut statement = conn + .prepare("SELECT height FROM core_transactions WHERE txid = ?1") + .unwrap(); + let rows = statement + .query_map([txid.as_byte_array().as_slice()], |row| row.get(0)) + .unwrap(); + rows.map(Result::unwrap).collect() + } + + /// Stored `(synced_height, last_processed_height)`. + fn heights(&self, wallet_id: WalletId) -> (Option, Option) { + self.persister() + .lock_conn_for_test() + .query_row( + "SELECT synced_height, last_processed_height FROM core_sync_state \ + WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + } +} + +/// A wallet funded at [`BASE`], its event stream, and a simulated chain tip. +struct Chain { + manager: Manager, + wallet_id: WalletId, + events: mpsc::UnboundedReceiver, + registrations: Vec, + ours: Address, + funding: OutPoint, + tip: u32, + /// Distinguishes the blocks of one branch from the ones they replace. + branch: u8, + /// Seeds the foreign inputs of received payments. + nonce: u8, +} + +impl Chain { + async fn funded() -> Self { + let mut manager = WalletManager::::new(Network::Testnet); + let events = manager.take_persistence_receiver().unwrap(); + let wallet_id = manager + .create_wallet_from_mnemonic(MNEMONIC, 0, WalletAccountCreationOptions::Default) + .unwrap(); + let wallet = manager.get_wallet(&wallet_id).unwrap(); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let registrations = wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(); + let ours = manager + .get_wallet_info_mut(&wallet_id) + .unwrap() + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = transaction( + &[OutPoint::new(Txid::from_byte_array([13; 32]), 0)], + &[(&ours, FUNDING)], + ); + let mut chain = Self { + manager: Arc::new(RwLock::new(manager)), + wallet_id, + events, + registrations, + ours, + funding: OutPoint::new(funding.txid(), 0), + tip: BASE - 1, + branch: 0, + nonce: 0, + }; + chain.mine(vec![funding]).await; + chain.sync().await; + chain + } + + fn store(&self) -> Store { + Store::seeded(self.wallet_id, &self.registrations) + } + + /// Mine `transactions` in the next block of the current branch. + async fn mine(&mut self, transactions: Vec) { + self.tip += 1; + let block = Block { + header: Header { + version: Version::default(), + prev_blockhash: BlockHash::from_byte_array([self.branch; 32]), + merkle_root: TxMerkleNode::all_zeros(), + time: self.tip, + bits: CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata: transactions, + }; + self.manager + .write() + .await + .process_block_for_wallets( + &block, + block.block_hash(), + self.tip, + &BTreeSet::from([self.wallet_id]), + ) + .await; + } + + /// Commit the scan checkpoint at the tip. + async fn sync(&mut self) { + self.manager + .write() + .await + .update_wallet_synced_height(&self.wallet_id, self.tip); + } + + /// Replace every block above `height` with a new, still empty branch. + async fn fork_at(&mut self, height: u32) { + self.manager + .write() + .await + .truncate_above(height) + .expect("no chain lock is applied in these cases"); + self.tip = height; + self.branch += 1; + } + + async fn relay(&mut self, tx: &Transaction, lock: Option) { + self.manager + .write() + .await + .process_mempool_transaction(tx, lock) + .await; + } + + /// A payment from `coin` that returns the change to the wallet. + fn spend(&self, coin: OutPoint, value: u64) -> Transaction { + transaction( + &[coin], + &[(&self.ours, value / 2), (&foreign(), value / 2 - FEE)], + ) + } + + /// A payment to the wallet from somebody else. + fn receive(&mut self, value: u64) -> Transaction { + self.nonce += 1; + transaction( + &[OutPoint::new(Txid::from_byte_array([self.nonce; 32]), 7)], + &[(&self.ours, value)], + ) + } + + fn drain(&mut self) -> Vec { + std::iter::from_fn(|| self.events.try_recv().ok()).collect() + } + + async fn memory(&self) -> WalletView { + let manager = self.manager.read().await; + view( + &manager + .get_wallet_info(&self.wallet_id) + .unwrap() + .core_wallet, + ) + } + + async fn largest_coin(&self) -> Option<(OutPoint, u64)> { + self.memory() + .await + .coins + .into_iter() + .map(|(outpoint, (value, ..))| (outpoint, value)) + .max_by_key(|(outpoint, value)| (*value, *outpoint)) + } +} + +/// How the rounds of one scenario reach the store. +#[derive(Debug, Clone, Copy)] +enum Rounds { + /// One `store()` per step, as when the adapter keeps up with the wallet. + PerStep, + /// Every event of the scenario folded into one `store()`. + Merged, +} + +/// A scenario's store plus the events it has not persisted yet. +struct Session { + chain: Chain, + store: Store, + rounds: Rounds, + pending: Vec, +} + +impl Session { + async fn open(rounds: Rounds) -> Self { + let mut chain = Chain::funded().await; + let store = chain.store(); + // The funding block is history every case starts from. + let funded = chain.drain(); + store.persist(&chain.manager, funded).await; + Self { + chain, + store, + rounds, + pending: Vec::new(), + } + } + + /// End of one step: persist it now, or keep it for the single merged round. + async fn step(&mut self) { + self.pending.extend(self.chain.drain()); + if matches!(self.rounds, Rounds::PerStep) { + self.flush().await; + } + } + + async fn flush(&mut self) { + let events = std::mem::take(&mut self.pending); + self.store.persist(&self.chain.manager, events).await; + } + + /// Persist what is left, restart, and require the restored wallet to be + /// the one that never restarted. + async fn assert_restart_matches_memory(&mut self) -> WalletView { + self.step().await; + self.flush().await; + let restored = view(&self.store.restart(self.chain.wallet_id)); + if let Err(difference) = restored.follows(&self.chain.memory().await) { + panic!("{:?}: {difference}", self.rounds); + } + restored + } +} + +#[tokio::test] +async fn should_restore_a_spent_coin_when_its_spender_is_not_mined_again() { + for rounds in [Rounds::PerStep, Rounds::Merged] { + let mut session = Session::open(rounds).await; + let funding = session.chain.funding; + let spend = session.chain.spend(funding, FUNDING); + session.chain.mine(vec![spend.clone()]).await; + session.chain.sync().await; + session.step().await; + + session.chain.fork_at(BASE).await; + + let restored = session.assert_restart_matches_memory().await; + assert!(restored.coins.contains_key(&funding), "{rounds:?}"); + assert!(!restored.history.contains_key(&spend.txid()), "{rounds:?}"); + assert!( + session.store.transaction_rows(&spend.txid()).is_empty(), + "{rounds:?}: the dropped transaction must leave the store" + ); + } +} + +#[tokio::test] +async fn should_store_a_transaction_once_in_its_new_block_when_mined_again() { + for rounds in [Rounds::PerStep, Rounds::Merged] { + let mut session = Session::open(rounds).await; + let funding = session.chain.funding; + let spend = session.chain.spend(funding, FUNDING); + session.chain.mine(vec![]).await; + session.chain.mine(vec![spend.clone()]).await; + session.chain.mine(vec![]).await; + session.chain.sync().await; + session.step().await; + + // The new branch mines it one block earlier, and the restart comes + // before the scan is back at the old height. + session.chain.fork_at(BASE).await; + session.step().await; + session.chain.mine(vec![spend.clone()]).await; + session.chain.sync().await; + + let restored = session.assert_restart_matches_memory().await; + assert_eq!( + restored.history.get(&spend.txid()), + Some(&Some(BASE + 1)), + "{rounds:?}" + ); + assert_eq!(restored.synced_height, BASE + 1, "{rounds:?}"); + assert_eq!( + session.store.transaction_rows(&spend.txid()), + vec![Some(BASE + 1)], + "{rounds:?}: stored once, in the new block" + ); + } +} + +#[tokio::test] +async fn should_lower_stored_heights_to_the_fork_before_the_scan_resumes() { + for rounds in [Rounds::PerStep, Rounds::Merged] { + let mut session = Session::open(rounds).await; + for _ in 0..3 { + session.chain.mine(vec![]).await; + } + session.chain.sync().await; + session.step().await; + + session.chain.fork_at(BASE).await; + + let restored = session.assert_restart_matches_memory().await; + assert_eq!(restored.synced_height, BASE, "{rounds:?}"); + assert!(restored.last_processed_height <= BASE, "{rounds:?}"); + } +} + +#[tokio::test] +async fn should_keep_an_instant_locked_transaction_unconfirmed_when_its_block_is_dropped() { + for rounds in [Rounds::PerStep, Rounds::Merged] { + let mut session = Session::open(rounds).await; + let funding = session.chain.funding; + let spend = session.chain.spend(funding, FUNDING); + session + .chain + .relay(&spend, Some(instant_lock(&spend))) + .await; + session.step().await; + session.chain.mine(vec![spend.clone()]).await; + session.chain.sync().await; + session.step().await; + + session.chain.fork_at(BASE).await; + + let restored = session.assert_restart_matches_memory().await; + let change = OutPoint::new(spend.txid(), 0); + assert_eq!( + restored.coins.get(&change), + Some(&(FUNDING / 2, false, true, 0)), + "{rounds:?}: its change stays, unconfirmed and InstantSend-locked" + ); + assert!(!restored.coins.contains_key(&funding), "{rounds:?}"); + assert_eq!( + restored.history.get(&spend.txid()), + Some(&None), + "{rounds:?}" + ); + } +} + +/// A double spend sweeps the wallet's own unconfirmed payment, then a fork +/// drops the block that held the winner: the store holds what the wallet +/// holds, and neither transaction comes back. +/// +/// The coin both spent is not asserted on its own: whether the wallet takes +/// it back is key-wallet's decision, and the store follows it. +// TODO(sweep-of-unstored-loser): run this merged too, and generate the +// double spend in the property test below, once a payment detected and swept +// within one `store()` round leaves its coin spent in the store. Today that +// round stores no spend of the coin, fork or no fork. +#[tokio::test] +async fn should_keep_a_swept_payment_out_when_the_block_of_its_double_spend_is_dropped() { + let mut session = Session::open(Rounds::PerStep).await; + let funding = session.chain.funding; + let payment = session.chain.spend(funding, FUNDING); + session.chain.relay(&payment, None).await; + session.step().await; + let winner = transaction(&[funding], &[(&foreign(), FUNDING - FEE)]); + session.chain.mine(vec![winner.clone()]).await; + session.chain.sync().await; + session.step().await; + + session.chain.fork_at(BASE).await; + + let restored = session.assert_restart_matches_memory().await; + for gone in [payment.txid(), winner.txid()] { + assert!(!restored.history.contains_key(&gone)); + } + assert!( + !restored + .coins + .contains_key(&OutPoint::new(payment.txid(), 0)), + "the swept payment's change is not a coin" + ); +} + +/// The lock is all that tells a fork to keep a transaction, and the block +/// that mines the transaction replaces the context it arrived in. Stored on +/// its own it outlives both the block and a restart, so the relaunched +/// wallet keeps what the one that never restarted keeps. +#[tokio::test] +async fn should_keep_an_instant_locked_transaction_when_the_fork_follows_a_restart() { + for rounds in [Rounds::PerStep, Rounds::Merged] { + let mut session = Session::open(rounds).await; + let funding = session.chain.funding; + let spend = session.chain.spend(funding, FUNDING); + session + .chain + .relay(&spend, Some(instant_lock(&spend))) + .await; + session.step().await; + session.chain.mine(vec![spend.clone()]).await; + session.chain.sync().await; + session.step().await; + session.flush().await; + + let mut relaunched = session.store.restart(session.chain.wallet_id); + relaunched + .truncate_above(BASE) + .expect("no chain lock is applied in these cases"); + session.chain.fork_at(BASE).await; + + let relaunched = view(&relaunched); + if let Err(difference) = relaunched.follows(&session.chain.memory().await) { + panic!("{rounds:?}: {difference}"); + } + assert_eq!( + relaunched.history.get(&spend.txid()), + Some(&None), + "{rounds:?}: kept, as unconfirmed" + ); + } +} + +/// The truncation among `events`, as the changeset carries it. +fn reported_truncation(events: Vec) -> ChainTruncation { + events + .into_iter() + .find_map(|event| match event { + WalletEvent::ChainTruncated { + height, + txids, + unconfirmed_txids, + restored_outpoints, + .. + } => Some(ChainTruncation { + height, + txids, + unconfirmed_txids, + restored_outpoints, + }), + _ => None, + }) + .expect("the fork is reported") +} + +fn core_round(core: CoreChangeSet) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + } +} + +/// A payment row needs its identity in the store. One whose identity is +/// gone is refused for good, and the round it rides in rolls back whole, the +/// truncation included — which is why the wallet-event adapter gives such a +/// row up instead of offering it with every round. +#[tokio::test] +async fn should_reject_the_whole_round_for_a_payment_row_of_an_absent_identity() { + use platform_wallet::changeset::PersistenceErrorKind; + use platform_wallet::wallet::identity::PaymentEntry; + + let mut session = Session::open(Rounds::PerStep).await; + let wallet_id = session.chain.wallet_id; + let spend = session.chain.spend(session.chain.funding, FUNDING); + session.chain.mine(vec![spend.clone()]).await; + session.chain.sync().await; + session.step().await; + session.chain.fork_at(BASE).await; + let truncation = reported_truncation(session.chain.drain()); + let absent = dpp::prelude::Identifier::from([0xAB; 32]); + let reversal = PaymentEntry::new_sent(dpp::prelude::Identifier::from([0xCD; 32]), 1, None); + + let mut round = core_round(CoreChangeSet { + truncations: vec![truncation], + synced_height: Some(BASE), + ..Default::default() + }); + round.dashpay_payments_overlay = Some(std::collections::BTreeMap::from([( + absent, + std::collections::BTreeMap::from([(spend.txid().to_string(), reversal)]), + )])); + let refused = session + .store + .persister() + .store(wallet_id, round) + .expect_err("the store refuses the round"); + + assert_eq!(refused.kind(), Some(PersistenceErrorKind::Constraint)); + assert_eq!( + session.store.transaction_rows(&spend.txid()).len(), + 1, + "the truncation rolled back with the row" + ); +} + +/// A store that keeps a round after a busy database owns the truncation in +/// it: nobody else will offer it again. When a later round fails for good +/// and takes the buffer with it, the truncation must still be applied by +/// the next round that lands. +#[tokio::test] +async fn should_apply_a_truncation_it_kept_after_dropping_a_later_round() { + let mut session = Session::open(Rounds::PerStep).await; + let wallet_id = session.chain.wallet_id; + let spend = session.chain.spend(session.chain.funding, FUNDING); + session.chain.mine(vec![spend.clone()]).await; + session.chain.sync().await; + session.step().await; + session.chain.fork_at(BASE).await; + let truncation = reported_truncation(session.chain.drain()); + assert_eq!(truncation.txids, [spend.txid()]); + let persister = session.store.persister(); + + persister.force_next_flush_to_fail(WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY), + None, + ))); + let kept = persister.store( + wallet_id, + core_round(CoreChangeSet { + truncations: vec![truncation.clone()], + ..Default::default() + }), + ); + assert!(kept.is_err(), "the busy database rejects the round"); + persister.force_next_flush_to_fail(WalletStorageError::Sqlite( + rusqlite::Error::InvalidColumnIndex(0), + )); + let dropped = persister.store( + wallet_id, + core_round(CoreChangeSet { + last_processed_height: Some(BASE), + ..Default::default() + }), + ); + assert!(dropped.is_err(), "the fatal failure rejects the round"); + persister + .store( + wallet_id, + core_round(CoreChangeSet { + synced_height: Some(BASE), + ..Default::default() + }), + ) + .expect("the next round lands"); + + let follows_the_fork = |when: &str| { + assert_eq!( + session.store.transaction_rows(&spend.txid()), + Vec::>::new(), + "{when}: the transaction of the dropped block is gone" + ); + assert_eq!( + session.store.heights(wallet_id), + (Some(BASE), Some(BASE)), + "{when}" + ); + }; + follows_the_fork("after the round that landed"); + + // A caller that kept its own copy offers the truncation once more. + persister + .store( + wallet_id, + core_round(CoreChangeSet { + truncations: vec![truncation], + ..Default::default() + }), + ) + .expect("a second delivery is accepted"); + follows_the_fork("after a second delivery"); + let restored = view(&session.store.restart(wallet_id)); + if let Err(difference) = restored.follows(&session.chain.memory().await) { + panic!("{difference}"); + } +} + +/// A second spend of the wallet's own sweeps a payment the store already +/// holds; then the winner's block and the block that funded the coin are +/// both dropped, and only the funding transaction is mined again. The +/// wallet holds the coin once more. Round by round the store follows it. +/// With the sweep and the fork folded into one round the sweep still holds +/// the coin for a winner that is gone, so the store may withhold it — and +/// must never hold a coin the wallet does not. +#[tokio::test] +async fn should_hand_out_no_coin_the_wallet_lacks_when_a_sweep_and_a_fork_share_a_round() { + for rounds in [Rounds::PerStep, Rounds::Merged] { + let mut session = Session::open(rounds).await; + let funding = session.chain.receive(250_000); + let coin = OutPoint::new(funding.txid(), 0); + session.chain.mine(vec![funding.clone()]).await; + let payment = session.chain.spend(coin, 250_000); + session.chain.relay(&payment, None).await; + session.step().await; + session.flush().await; + + let winner = transaction( + &[coin], + &[(&session.chain.ours, 100_000), (&foreign(), 100_000)], + ); + session.chain.mine(vec![winner]).await; + session.step().await; + session.chain.fork_at(BASE).await; + session.step().await; + session.chain.mine(vec![funding.clone()]).await; + session.chain.sync().await; + session.step().await; + session.flush().await; + + let memory = session.chain.memory().await; + assert!( + memory.coins.contains_key(&coin), + "{rounds:?}: the wallet holds the coin again" + ); + let restored = view(&session.store.restart(session.chain.wallet_id)); + for (outpoint, held) in &restored.coins { + assert_eq!( + memory.coins.get(outpoint), + Some(held), + "{rounds:?}: the store holds a coin the wallet does not" + ); + } + if matches!(rounds, Rounds::PerStep) { + if let Err(difference) = restored.follows(&memory) { + panic!("{rounds:?}: {difference}"); + } + } + } +} + +/// A truncation is several writes: rows deleted, records rewritten, coins +/// released, heights lowered. One that fails part-way must leave none behind. +#[tokio::test] +async fn should_leave_the_store_untouched_when_a_truncation_cannot_be_applied() { + let mut session = Session::open(Rounds::PerStep).await; + let wallet_id = session.chain.wallet_id; + let funding = session.chain.funding; + let kept = session.chain.spend(funding, FUNDING); + let removed = session.chain.receive(250_000); + session.chain.relay(&kept, Some(instant_lock(&kept))).await; + session + .chain + .mine(vec![kept.clone(), removed.clone()]) + .await; + session.chain.sync().await; + session.step().await; + // The kept transaction's record can no longer be rewritten as unconfirmed. + session + .store + .persister() + .lock_conn_for_test() + .execute( + "UPDATE core_transactions SET record_blob = X'00' WHERE txid = ?1", + [kept.txid().as_byte_array().as_slice()], + ) + .unwrap(); + + session.chain.fork_at(BASE).await; + let truncation = reported_truncation(session.chain.drain()); + assert_eq!(truncation.txids, [removed.txid()]); + assert_eq!(truncation.unconfirmed_txids, [kept.txid()]); + + let rejected = session.store.persister().store( + wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + truncations: vec![truncation], + ..Default::default() + }), + ..Default::default() + }, + ); + + assert!( + rejected.is_err(), + "an unreadable record must fail the round" + ); + assert_eq!( + session.store.transaction_rows(&removed.txid()), + vec![Some(BASE + 1)], + "the removal made before the failure is rolled back" + ); + assert_eq!( + session.store.transaction_rows(&kept.txid()), + vec![Some(BASE + 1)] + ); + assert_eq!( + session.store.heights(wallet_id), + (Some(BASE + 1), Some(BASE + 1)) + ); +} + +/// One step of a generated history. +#[derive(Debug, Clone)] +enum Step { + /// Mine a block holding whatever is waiting in the mempool. + Block, + /// Mine a payment to the wallet. + Receive, + /// Spend the wallet's largest coin, returning change to it. + Spend { relayed_first: bool, locked: bool }, + /// Commit the scan checkpoint at the tip. + Sync, + /// Replace the last `depth` blocks. What they held goes back to the + /// mempool; `remine` mines it in the first block of the new branch. + Reorg { depth: u32, remine: bool }, +} + +fn step() -> impl Strategy { + prop_oneof![ + 2 => Just(Step::Block), + 2 => Just(Step::Receive), + 4 => (any::(), any::()) + .prop_map(|(relayed_first, locked)| Step::Spend { relayed_first, locked }), + 2 => Just(Step::Sync), + 3 => (1u32..=3, any::()).prop_map(|(depth, remine)| Step::Reorg { depth, remine }), + ] +} + +/// The chain as a miner sees it: what each block above [`BASE`] holds and +/// what waits in the mempool, parents ahead of their children. +#[derive(Default)] +struct Miner { + blocks: Vec>, + mempool: Vec, +} + +impl Miner { + async fn mine(&mut self, chain: &mut Chain, mut extra: Vec) { + let mut block = std::mem::take(&mut self.mempool); + block.append(&mut extra); + chain.mine(block.clone()).await; + self.blocks.push(block); + } + + async fn apply(&mut self, chain: &mut Chain, step: &Step) { + match step { + Step::Block => self.mine(chain, vec![]).await, + Step::Receive => { + let payment = chain.receive(250_000); + self.mine(chain, vec![payment]).await; + } + Step::Spend { + relayed_first, + locked, + } => { + let Some((coin, value)) = chain.largest_coin().await else { + return; + }; + if value < 4 * FEE { + return; + } + let spend = chain.spend(coin, value); + let lock = locked.then(|| instant_lock(&spend)); + if *relayed_first || lock.is_some() { + chain.relay(&spend, lock).await; + self.mempool.push(spend); + } else { + self.mine(chain, vec![spend]).await; + } + } + Step::Sync => chain.sync().await, + Step::Reorg { depth, remine } => { + let depth = (*depth as usize).min(self.blocks.len()); + if depth == 0 { + return; + } + let mut returned: Vec = self + .blocks + .split_off(self.blocks.len() - depth) + .into_iter() + .flatten() + .collect(); + chain.fork_at(chain.tip - depth as u32).await; + returned.append(&mut self.mempool); + self.mempool = returned; + if *remine { + self.mine(chain, vec![]).await; + } + } + } + } +} + +/// The differential: one wallet, two stores. One store takes a `store()` +/// round per step, the other a single round holding every event, so the +/// second exercises the changeset merge the first never needs. +async fn merged_and_stepwise_stores_agree(steps: Vec) -> Result<(), TestCaseError> { + let mut chain = Chain::funded().await; + let mut stepwise = chain.store(); + let mut merged = chain.store(); + let funded = chain.drain(); + stepwise.persist(&chain.manager, funded.clone()).await; + merged.persist(&chain.manager, funded).await; + + let mut miner = Miner::default(); + let mut log = Vec::new(); + for step in &steps { + miner.apply(&mut chain, step).await; + let events = chain.drain(); + log.extend(events.iter().cloned()); + stepwise.persist(&chain.manager, events).await; + } + merged.persist(&chain.manager, log).await; + + let memory = chain.memory().await; + let stepwise = view(&stepwise.restart(chain.wallet_id)); + let merged = view(&merged.restart(chain.wallet_id)); + for (rounds, restored) in [ + ("one round per step", stepwise), + ("one merged round", merged), + ] { + if let Err(difference) = restored.follows(&memory) { + return Err(TestCaseError::fail(format!("{rounds}: {difference}"))); + } + } + Ok(()) +} + +#[tokio::test] +async fn should_keep_the_scanned_fork_checkpoint_when_an_older_advance_is_queued() { + merged_and_stepwise_stores_agree(vec![ + Step::Block, + Step::Block, + Step::Sync, + Step::Reorg { + depth: 1, + remine: false, + }, + ]) + .await + .unwrap(); +} + +#[test] +fn should_store_the_same_wallet_from_one_merged_round_as_from_a_round_per_step() { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let config = ProptestConfig { + cases: 48, + failure_persistence: None, + ..ProptestConfig::default() + }; + proptest!(config, |(steps in proptest::collection::vec(step(), 1..12))| { + runtime.block_on(merged_and_stepwise_stores_agree(steps))?; + }); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index b53cbd60883..c89db1ea9f0 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -19,7 +19,7 @@ //! changeset's `core` slot — so every domain, core included, shares one //! merge / apply shape downstream consumers can rely on. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use dashcore::blockdata::transaction::{OutPoint, Transaction}; use dashcore::ephemerealdata::chain_lock::ChainLock; @@ -32,6 +32,7 @@ use key_wallet::account::AccountType; use key_wallet::bip32::ExtendedPubKey; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::transaction_checking::TransactionContext; use key_wallet::{AddressInfo, Network, PlatformP2PKHAddress, Utxo}; use crate::changeset::dashpay_backfill::DashPayBackfillRecord; @@ -65,7 +66,11 @@ use crate::wallet::identity::{ /// /// Built by the platform-wallet event adapter from `WalletEvent` variants /// emitted by `WalletManager`. Every field is additive except -/// [`Self::sweeps`]. The merge implementation coalesces the record vecs +/// [`Self::sweeps`] and [`Self::truncations`]. A persister applies the +/// truncations first, then the additive fields, then the sweeps; everything +/// but the truncations describes the wallet after the last of them. The +/// merge implementation keeps that true (see [`Self::truncations`]), +/// coalesces the record vecs /// newest-wins (by txid for the wallet-level `records`, by /// `(txid, account)` for `account_records` — see /// [`fold_same_txid_records`]), uses monotonic-max for the height @@ -320,6 +325,64 @@ pub struct CoreChangeSet { /// [`Self::sweeps`]. #[cfg_attr(feature = "serde", serde(default))] pub utxo_credit_verdicts: BTreeMap, + + /// Chain truncations this batch carries, in the order the wallet + /// emitted them — the other subtractive part of this type, and the only + /// one that lowers a height. + /// + /// A persister applies these BEFORE every other field, in order. The + /// rest of the changeset is therefore what the wallet recorded after the + /// last truncation: a transaction a truncation removed and a block of + /// the new branch mined again is deleted and then stored once, in its + /// new block, and a height recorded on the new branch is raised from the + /// lowered one. + /// + /// Merge keeps that reading true when a truncation arrives after content + /// this changeset already holds: that content is rewritten as the + /// truncation left it — removed transactions and their coins dropped, + /// kept ones demoted to unconfirmed, spends of restored coins dropped, + /// both heights capped at the fork — before the truncation is appended. + /// + /// One case is not exact. A sweep already in the changeset gets the + /// restored coins added to what it releases, but it still holds, for its + /// winner, an input funded by a transaction the truncation removes. When + /// winner and funding transaction were both in a dropped block, a store + /// applying the merged changeset keeps that coin spent if the funding + /// transaction is mined again and neither the winner nor any other + /// spender of it is. Applied as separate rounds the store follows the + /// wallet, which holds the coin again when the winner was a transaction + /// it had recorded; a winner it never recorded leaves the coin spent in + /// the wallet too, and the two agree. The error is on the safe side: the + /// coin is withheld, never handed out twice. + /// + /// `serde(default)` for the same backward-compatible reading as + /// [`Self::sweeps`]. + #[cfg_attr(feature = "serde", serde(default))] + pub truncations: Vec, +} + +/// One `ChainTruncated` event: a fork replaced the chain above `height` and +/// the wallet dropped what it recorded from the blocks above it. +/// +/// A persister applies it before the rest of the changeset that carries it +/// (see [`CoreChangeSet::truncations`]) and atomically with it. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ChainTruncation { + /// Height of the last block the fork keeps. Stored `synced_height` and + /// `last_processed_height` above it are lowered to it. + pub height: u32, + /// Transactions removed: recorded in blocks above `height` and not + /// InstantSend-locked. Their rows and every UTXO they created go. + pub txids: Vec, + /// InstantSend-locked transactions recorded in blocks above `height`. + /// The network mines them again, so they are kept as unconfirmed: the + /// coins they spent stay spent and their outputs stay InstantSend-locked. + /// Their records read `Mempool`; see + /// [`CoreChangeSet::is_unconfirmed_instant_send_locked`]. + pub unconfirmed_txids: Vec, + /// Coins the removed transactions spent, unspent again. + pub restored_outpoints: Vec, } /// Why the engine did not credit a `Received` / `Change` output of a @@ -680,8 +743,90 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext) } } +impl CoreChangeSet { + /// Whether `record`, one of this changeset's records, is unconfirmed + /// and InstantSend-locked in the wallet this changeset describes. + /// + /// A record's own `InstantSend` context says so. So does a `Mempool` + /// record that a truncation in this changeset kept: a truncation keeps + /// only InstantSend-locked transactions, and the wallet holds the lock + /// of a transaction it has seen mined only as a txid, with no lock to + /// put back into the record's context (see [`Self::rewind_to`]). A + /// projection that writes the lock state of a record and its outputs + /// reads it here, not from the context alone. + pub fn is_unconfirmed_instant_send_locked(&self, record: &TransactionRecord) -> bool { + match record.context { + TransactionContext::InstantSend(_) => true, + TransactionContext::Mempool => self + .truncations + .iter() + .any(|truncation| truncation.unconfirmed_txids.contains(&record.txid)), + TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) => false, + } + } + + /// Rewrite this changeset as `truncation` left the wallet, so that what + /// it holds can be applied after that truncation instead of before it — + /// see [`Self::truncations`]. + /// + /// A kept record is demoted to `Mempool`, as the wallet demotes its + /// own: its lock payload is not held anywhere, and a made-up one would + /// be stored and later read back as a real lock. Its lock state is read + /// through [`Self::is_unconfirmed_instant_send_locked`]. + fn rewind_to(&mut self, truncation: &ChainTruncation) { + let removed: HashSet<&Txid> = truncation.txids.iter().collect(); + let demoted: HashSet<&Txid> = truncation.unconfirmed_txids.iter().collect(); + let restored: HashSet<&OutPoint> = truncation.restored_outpoints.iter().collect(); + + for records in [&mut self.records, &mut self.account_records] { + records.retain(|record| !removed.contains(&record.txid)); + for record in records.iter_mut() { + if demoted.contains(&record.txid) { + record.update_context(TransactionContext::Mempool); + } + } + } + self.new_utxos + .retain(|coin| !removed.contains(&coin.outpoint.txid)); + for coin in &mut self.new_utxos { + if demoted.contains(&coin.outpoint.txid) { + coin.is_confirmed = false; + coin.is_instantlocked = true; + coin.height = 0; + } + } + // A spend is undone when its coin is restored, and moot when the + // transaction that created the coin is gone. + let undone = + |outpoint: &OutPoint| restored.contains(outpoint) || removed.contains(&outpoint.txid); + self.spent_utxos.retain(|coin| !undone(&coin.outpoint)); + self.utxo_credit_verdicts + .retain(|outpoint, _| !undone(outpoint)); + self.instant_locks_for_non_final_records + .retain(|txid, _| !removed.contains(txid)); + self.last_processed_height = self + .last_processed_height + .map(|height| height.min(truncation.height)); + self.synced_height = self + .synced_height + .map(|height| height.min(truncation.height)); + // Sweeps are applied last, so a coin an earlier sweep left with its + // winner would be written spent again after the truncation freed it. + for batch in &mut self.sweeps { + for outpoint in &truncation.restored_outpoints { + if !batch.released_outpoints.contains(outpoint) { + batch.released_outpoints.push(*outpoint); + } + } + } + } +} + impl Merge for CoreChangeSet { fn merge(&mut self, other: Self) { + for truncation in &other.truncations { + self.rewind_to(truncation); + } // A record arriving after a sweep that removed the same transaction // reinstates it, and every persister writes records before replaying // sweeps — so without this the sweep would delete a row the wallet @@ -882,6 +1027,7 @@ impl Merge for CoreChangeSet { // batch's decision to free it, and only replaying them in sequence // preserves that. self.sweeps.extend(other.sweeps); + self.truncations.extend(other.truncations); // Credit verdicts, part 2: newest wins per outpoint; the stale // entries of records the newer changeset re-projected were dropped @@ -893,6 +1039,7 @@ impl Merge for CoreChangeSet { self.records.is_empty() && self.account_records.is_empty() && self.sweeps.is_empty() + && self.truncations.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -3910,3 +4057,297 @@ mod coalesce_account_records_tests { } } } + +#[cfg(test)] +mod chain_truncation_merge_tests { + //! A truncation folded into a changeset that already holds content must + //! leave what an in-order store would hold: persisters apply the + //! truncations first, so the merge has to undo, in the earlier content, + //! what each later truncation undid in the wallet. + + use super::*; + use dashcore::hashes::Hash; + use dashcore::{Address, BlockHash, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + /// Height of the last block every fork in these tests keeps. + const FORK: u32 = 100; + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn outpoint(byte: u8) -> OutPoint { + OutPoint { + txid: txid(byte), + vout: 0, + } + } + + fn in_block(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new(height, BlockHash::all_zeros(), 0)) + } + + fn record(txid_byte: u8, context: TransactionContext) -> TransactionRecord { + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + context, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid(txid_byte); + record + } + + /// A confirmed coin created by `txid_byte` in the block at `height`. + fn coin(txid_byte: u8, height: u32) -> Utxo { + let address = Address::dummy(Network::Testnet, 1); + let txout = TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }; + let mut coin = Utxo::new(outpoint(txid_byte), txout, address, height, false); + coin.is_confirmed = true; + coin + } + + /// What a block at `height` holding `txid_byte`, which spends + /// `spent_byte`'s coin, adds to a changeset. + fn block_with(txid_byte: u8, height: u32, spent_byte: u8) -> CoreChangeSet { + CoreChangeSet { + records: vec![record(txid_byte, in_block(height))], + account_records: vec![record(txid_byte, in_block(height))], + new_utxos: vec![coin(txid_byte, height)], + spent_utxos: vec![coin(spent_byte, FORK)], + last_processed_height: Some(height), + synced_height: Some(height), + ..CoreChangeSet::default() + } + } + + fn truncation( + height: u32, + removed: &[u8], + unconfirmed: &[u8], + restored: &[u8], + ) -> CoreChangeSet { + CoreChangeSet { + truncations: vec![ChainTruncation { + height, + txids: removed.iter().copied().map(txid).collect(), + unconfirmed_txids: unconfirmed.iter().copied().map(txid).collect(), + restored_outpoints: restored.iter().copied().map(outpoint).collect(), + }], + ..CoreChangeSet::default() + } + } + + fn spent(cs: &CoreChangeSet) -> Vec { + cs.spent_utxos.iter().map(|coin| coin.outpoint).collect() + } + + #[test] + fn should_report_a_changeset_carrying_only_a_truncation_as_not_empty() { + assert!(!truncation(FORK, &[1], &[], &[]).is_empty()); + } + + #[test] + fn should_drop_a_removed_transaction_recorded_before_its_truncation() { + let mut cs = block_with(1, FORK + 1, 9); + cs.utxo_credit_verdicts + .insert(outpoint(1), UtxoCreditVerdict::Uncredited); + + cs.merge(truncation(FORK, &[1], &[], &[9])); + + assert!(cs.records.is_empty()); + assert!(cs.account_records.is_empty()); + assert!(cs.new_utxos.is_empty(), "its coin never existed"); + assert!( + cs.spent_utxos.is_empty(), + "the coin it spent is unspent again" + ); + assert!(cs.utxo_credit_verdicts.is_empty()); + assert_eq!(cs.truncations.len(), 1, "the store still has to be told"); + } + + #[test] + fn should_store_a_transaction_mined_again_once_in_its_new_block() { + let mut cs = block_with(1, FORK + 2, 9); + cs.merge(truncation(FORK, &[1], &[], &[9])); + cs.merge(block_with(1, FORK + 1, 9)); + + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].height(), Some(FORK + 1)); + assert_eq!(cs.account_records.len(), 1); + assert_eq!(cs.new_utxos.len(), 1); + assert_eq!(cs.new_utxos[0].height, FORK + 1); + assert_eq!(spent(&cs), [outpoint(9)], "spent once, by the new block"); + assert_eq!( + cs.truncations[0].txids, + [txid(1)], + "the old row is deleted before the new one is stored" + ); + } + + #[test] + fn should_not_let_a_height_from_before_a_truncation_outlive_it() { + let heights = |synced, processed| CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(processed), + ..CoreChangeSet::default() + }; + + let mut cs = heights(FORK + 5, FORK + 6); + cs.merge(truncation(FORK, &[], &[], &[])); + assert_eq!(cs.synced_height, Some(FORK)); + assert_eq!(cs.last_processed_height, Some(FORK)); + + cs.merge(heights(FORK + 2, FORK + 1)); + assert_eq!(cs.synced_height, Some(FORK + 2)); + assert_eq!(cs.last_processed_height, Some(FORK + 1)); + } + + #[test] + fn should_not_invent_a_height_for_a_changeset_that_carried_none() { + let mut cs = CoreChangeSet::default(); + cs.merge(truncation(FORK, &[], &[], &[])); + + assert_eq!(cs.synced_height, None); + assert_eq!(cs.last_processed_height, None); + } + + #[test] + fn should_demote_a_kept_transaction_recorded_before_its_truncation() { + let mut cs = block_with(2, FORK + 1, 9); + cs.merge(truncation(FORK, &[], &[2], &[])); + + assert!(matches!(cs.records[0].context, TransactionContext::Mempool)); + assert!(matches!( + cs.account_records[0].context, + TransactionContext::Mempool + )); + let coin = &cs.new_utxos[0]; + assert_eq!( + (coin.is_confirmed, coin.is_instantlocked, coin.height), + (false, true, 0) + ); + assert_eq!(spent(&cs), [outpoint(9)], "what it spent stays spent"); + + cs.merge(block_with(2, FORK + 1, 9)); + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].height(), Some(FORK + 1), "mined again"); + } + + #[test] + fn should_apply_two_truncations_in_the_order_they_happened() { + let mut cs = block_with(1, FORK + 3, 8); + cs.merge(truncation(FORK + 1, &[1], &[], &[8])); + cs.merge(block_with(2, FORK + 2, 9)); + cs.merge(truncation(FORK, &[2], &[], &[9])); + cs.merge(CoreChangeSet { + synced_height: Some(FORK + 1), + ..CoreChangeSet::default() + }); + + assert!(cs.records.is_empty()); + assert!(cs.new_utxos.is_empty()); + assert!(cs.spent_utxos.is_empty()); + let heights: Vec = cs.truncations.iter().map(|t| t.height).collect(); + assert_eq!(heights, [FORK + 1, FORK]); + assert_eq!(cs.synced_height, Some(FORK + 1)); + assert_eq!(cs.last_processed_height, Some(FORK)); + } + + #[test] + fn should_release_a_restored_coin_from_a_sweep_recorded_before_the_truncation() { + let mut cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![txid(5)], + superseded_by: txid(6), + winner_mined_height: Some(FORK + 1), + released_outpoints: vec![outpoint(7)], + }], + ..CoreChangeSet::default() + }; + + cs.merge(truncation(FORK, &[6], &[], &[7, 9])); + + assert_eq!( + cs.sweeps[0].released_outpoints, + [outpoint(7), outpoint(9)], + "the removed winner no longer holds the coin, and nothing is listed twice" + ); + assert_eq!(cs.sweeps[0].txids, [txid(5)], "the loser stays removed"); + } + + #[test] + fn should_keep_what_a_truncation_does_not_name() { + let mut cs = block_with(3, FORK, 9); + cs.records.push(record(4, TransactionContext::Mempool)); + cs.instant_locks_for_non_final_records + .insert(txid(4), InstantLock::default()); + + cs.merge(truncation(FORK, &[1], &[2], &[8])); + + assert_eq!(cs.records.len(), 2); + assert_eq!(cs.records[0].height(), Some(FORK)); + assert_eq!(cs.new_utxos.len(), 1); + assert!(cs.new_utxos[0].is_confirmed); + assert_eq!(spent(&cs), [outpoint(9)]); + assert_eq!(cs.instant_locks_for_non_final_records.len(), 1); + assert_eq!(cs.synced_height, Some(FORK)); + } + + #[test] + fn should_read_a_transaction_a_merged_truncation_keeps_as_instant_send_locked() { + let mut cs = block_with(2, FORK + 1, 9); + cs.records.push(record(4, TransactionContext::Mempool)); + cs.merge(truncation(FORK, &[], &[2], &[])); + + let kept = cs.records.iter().find(|r| r.txid == txid(2)).unwrap(); + assert!( + cs.is_unconfirmed_instant_send_locked(kept), + "a truncation keeps only InstantSend-locked transactions" + ); + let unrelated = cs.records.iter().find(|r| r.txid == txid(4)).unwrap(); + assert!( + !cs.is_unconfirmed_instant_send_locked(unrelated), + "a mempool record no truncation names is not locked" + ); + } + + #[test] + fn should_read_an_instant_send_context_as_locked_and_a_block_as_not() { + let cs = CoreChangeSet::default(); + + assert!(cs.is_unconfirmed_instant_send_locked(&record( + 1, + TransactionContext::InstantSend(InstantLock::default()) + ))); + assert!(!cs.is_unconfirmed_instant_send_locked(&record(1, in_block(FORK)))); + assert!(!cs.is_unconfirmed_instant_send_locked(&record(1, TransactionContext::Mempool))); + } + + #[test] + fn should_not_read_a_kept_transaction_mined_again_as_unconfirmed() { + let mut cs = block_with(2, FORK + 1, 9); + cs.merge(truncation(FORK, &[], &[2], &[])); + cs.merge(block_with(2, FORK + 1, 9)); + + assert!(!cs.is_unconfirmed_instant_send_locked(&cs.records[0])); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 8d588b64817..4246236dd17 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -62,8 +62,8 @@ use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ coalesce_account_records, fold_same_txid_records, merge_payment_overlays, AssetLockChangeSet, - CoreChangeSet, HighestUsedIndexes, PaymentOverlay, PlatformWalletChangeSet, SweepBatch, - UtxoCreditVerdict, + ChainTruncation, CoreChangeSet, HighestUsedIndexes, PaymentOverlay, PlatformWalletChangeSet, + SweepBatch, UtxoCreditVerdict, }; use crate::changeset::merge::Merge; use crate::changeset::persistence_capabilities::PersistenceCapabilities; @@ -139,6 +139,17 @@ struct AdapterFaultState { /// Set by a `store()` rejection, or by the panic-recovery branch for a /// wallet whose commit outcome is unknown: freezes only the named wallets. per_wallet: HashMap, + /// Truncations a `store()` rejected and did not keep, per wallet. The + /// fork is reported once and no rescan repeats it, so they lead that + /// wallet's next round until a store accepts them. Session-scoped: a + /// restart before that round loses them. + unapplied_truncations: HashMap>, + /// Sent-payment rows a truncation returned to pending in a round a + /// `store()` rejected, per wallet. That verdict is decided once, with + /// the fork, so the rows are laid under that wallet's next round until + /// a store accepts them or refuses them for good on a retry. + /// Session-scoped, like the truncations. + unstored_reversals: HashMap, } impl AdapterFaultState { @@ -564,6 +575,11 @@ async fn project_event_into_batch( } } }), + WalletEvent::ChainTruncated { .. } => Some({ + let wm = wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .map(|info| info.rewind_barrier.epoch()) + }), _ => None, }; let mut core = build_core_changeset(wallet_manager, event).await; @@ -1171,10 +1187,45 @@ fn commit_wallet

( let WalletBatch { mut core, asset_locks, - payments, + mut payments, advance_epoch: _, } = wallet_batch; { + // Under this round's own rows: a verdict reached since is the newer. + let fresh_reversals = returned_to_pending(Some(&payments)); + if let Some(mut owed) = fault.unstored_reversals.remove(&wallet_id) { + merge_payment_overlays(&mut owed, payments); + payments = owed; + } + // A truncation this wallet's store rejected goes first: everything + // folded since describes the wallet after it. + if let Some(unapplied) = fault.unapplied_truncations.remove(&wallet_id) { + let mut owed = CoreChangeSet { + truncations: unapplied, + ..CoreChangeSet::default() + }; + owed.merge(core); + core = owed; + } + // Withheld, not faulted: see `CORE_CHAIN_TRUNCATION` for why freezing + // the watermark, as the sweep gate below does, would recover nothing. + // The merge has already rewritten the rest of the round as the + // truncation left it, so that part is still correct to store. + if !core.truncations.is_empty() + && !persister + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_CHAIN_TRUNCATION) + { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + truncations = core.truncations.len(), + fork_height = core.truncations.iter().map(|t| t.height).min(), + "Persister does not advertise CORE_CHAIN_TRUNCATION; withholding this round's \ + chain truncation. Stored wallet state keeps the replaced branch above the fork \ + height until the host rescans." + ); + core.truncations.clear(); + } // Sent-payment verdicts reach a host only through the payment-overlay // slot, which a persister advertises with `DASHPAY_PAYMENTS`. A host // without it would take the round, return `Ok`, and drop the verdict @@ -1291,7 +1342,49 @@ fn commit_wallet

( dashpay_payments_overlay: payments, ..PlatformWalletChangeSet::default() }; + let offered_truncations = cs + .core + .as_ref() + .map(|core| core.truncations.clone()) + .unwrap_or_default(); + let offered_reversals = returned_to_pending(cs.dashpay_payments_overlay.as_ref()); let store_result = persister.store(wallet_id, cs); + if let Err(error) = &store_result { + // A backend that kept the failed round for its own retry still + // holds the truncation. A second copy would land after what that + // round stores behind it, and cut that away. + // + // Any other failure sets the truncation aside, a round whose + // outcome the backend could not vouch for included. Applied over + // a round that did land in part, it takes away only what that + // round stored above the fork — rows this rejection already + // counts as unstored — and it lowers the stored height to the + // fork, where the frozen watermark keeps it, so the next start + // scans them back in. Withholding it would leave the replaced + // branch in the store for good. + let kept_by_backend = + error.is_transient() && !persister.store_transient_is_reissuable(); + if !offered_truncations.is_empty() && !kept_by_backend { + fault + .unapplied_truncations + .insert(wallet_id, offered_truncations); + } + // Whole rows, so a copy the backend also kept changes nothing: + // these are set aside on every failure. One that is not transient + // keeps only the rows this round reported itself: a row carried + // from an earlier round may never be taken — its identity + // removed, say — and would fail every later round of the wallet. + if !offered_reversals.is_empty() { + let owed = if error.is_transient() { + offered_reversals + } else { + fresh_reversals + }; + if !owed.is_empty() { + fault.unstored_reversals.insert(wallet_id, owed); + } + } + } // Recorded only once the store has RETURNED, and whether it accepted // or rejected — both are answers. A wallet whose store panicked never // reaches this line, and one the loop never got to is never pushed at @@ -1706,7 +1799,20 @@ async fn build_core_changeset( collect_usage_deltas(wallet_manager, wallet_id, vec![&**record]).await; let mut folded = owned.clone(); fold_same_txid_records(&mut folded); + // A lock that arrives with the transaction lives only in the + // record's context, which mining replaces. Stored on its own it + // survives that, so a truncation returning the transaction to + // the mempool leaves its outputs InstantSend-locked in the store + // as it does in the wallet. Read from the event, not the + // snapshot above, which may already be past that truncation. + let instant_locks_for_non_final_records = match &record.context { + TransactionContext::InstantSend(lock) => { + BTreeMap::from([(record.txid, lock.clone())]) + } + _ => BTreeMap::new(), + }; CoreChangeSet { + instant_locks_for_non_final_records, // New UTXOs from the owned slices only (a watch-only // chain's outputs are the contact's coins); spends // from ALL slices, so a contact spending an output a @@ -1863,24 +1969,33 @@ async fn build_core_changeset( restored_outpoints, .. } => { - // TODO(#5380): mirror the truncation to the persister. The wallet - // has already dropped these records in memory, but `CoreChangeSet` - // cannot express it: the heights are monotonic-max on merge, a - // `SweepBatch` needs a winning transaction, and nothing demotes a - // confirmed record to unconfirmed. Until it can, the stored rows - // keep the replaced branch, and this arm does not lower the stored - // sync height: `PlatformWalletInfo::truncate_above` notes the - // truncation as an in-memory rewind instead, which the next - // DashPay backfill record round carries to the host unless a - // new-branch advance reaches it first. - tracing::warn!( + // The wallet has already dropped these records. Every other arm + // only appends or raises, so without this one the store keeps + // the replaced branch and a height above the fork. + tracing::info!( height, removed = txids.len(), unconfirmed = unconfirmed_txids.len(), restored = restored_outpoints.len(), - "Chain truncation is not mirrored to the persister; stored wallet state keeps the replaced branch" + "Mirroring a chain truncation to the persister" ); - CoreChangeSet::default() + // The queued advance from the old branch may be discarded. Keep + // its scanned prefix, bounded by both the live cursor and fork. + let synced_height = { + let wm = wallet_manager.read().await; + wm.get_wallet_info(&event.wallet_id()) + .map(|info| info.core_wallet.synced_height().min(*height)) + }; + CoreChangeSet { + synced_height, + truncations: vec![ChainTruncation { + height: *height, + txids: txids.clone(), + unconfirmed_txids: unconfirmed_txids.clone(), + restored_outpoints: restored_outpoints.clone(), + }], + ..CoreChangeSet::default() + } } WalletEvent::SyncHeightAdvanced { wallet_id, height } => { // A watermark queued before a rewind must not land after it. @@ -1963,6 +2078,9 @@ enum SentPaymentEvidence { /// The transaction reached a context that is final for DashPay display — /// mined, or InstantSend-locked. Final, + /// A fork dropped the block that held the transaction and no InstantSend + /// lock covers it: unconfirmed again, and free to be mined again. + Unmined, } /// The complete set of legal `Sent` payment transitions, and the only place a @@ -1974,10 +2092,13 @@ enum SentPaymentEvidence { /// | `Confirmed` | `Swept` | `Failed` | An IS-locked payment evicted by a chainlocked winner is a dead payment reported as good — the worst of the two. | /// | `Pending` | `Final` | `Confirmed` | The ordinary confirm: mempool → mined / IS-locked. | /// | `Failed` | `Final` | `Confirmed` | A chainlock can reinstate a transaction a sweep removed; without this edge the repair is unreachable. | +/// | `Confirmed` | `Unmined`| `Pending` | The block that confirmed it is gone; the block that mines it again takes the ordinary confirm. | /// -/// The two remaining pairs — `Failed` + `Swept` and `Confirmed` + `Final` — -/// return `None`: the entry already carries that verdict, so re-emitting it -/// would put an unchanged row on the store round for every re-detection. +/// `Failed` + `Swept` and `Confirmed` + `Final` return `None`: the entry +/// already carries that verdict, so re-emitting it would put an unchanged row +/// on the store round for every re-detection. So does `Pending` + `Unmined`. +/// `Failed` + `Unmined` returns `None` because a truncation does not bring a +/// swept transaction back. /// /// Exhaustive on purpose (no `_` arm): a future [`PaymentStatus`] variant must /// fail to compile here and be given an explicit edge, rather than be silently @@ -1986,7 +2107,7 @@ fn next_sent_payment_status( current: PaymentStatus, evidence: SentPaymentEvidence, ) -> Option { - use SentPaymentEvidence::{Final, Swept}; + use SentPaymentEvidence::{Final, Swept, Unmined}; match (current, evidence) { (PaymentStatus::Pending, Swept) => Some(PaymentStatus::Failed), (PaymentStatus::Confirmed, Swept) => Some(PaymentStatus::Failed), @@ -1994,6 +2115,9 @@ fn next_sent_payment_status( (PaymentStatus::Pending, Final) => Some(PaymentStatus::Confirmed), (PaymentStatus::Failed, Final) => Some(PaymentStatus::Confirmed), (PaymentStatus::Confirmed, Final) => None, + (PaymentStatus::Confirmed, Unmined) => Some(PaymentStatus::Pending), + (PaymentStatus::Pending, Unmined) => None, + (PaymentStatus::Failed, Unmined) => None, } } @@ -2037,11 +2161,15 @@ fn sent_payment_evidence(event: &WalletEvent) -> Vec<(String, SentPaymentEvidenc WalletEvent::BlockProcessed { inserted, updated, .. } => finality(inserted.iter().chain(updated.iter())), - // TODO(#5380): a payment whose confirmation a truncation undid keeps - // the verdict it already has. - WalletEvent::SyncHeightAdvanced { .. } - | WalletEvent::ChainLockProcessed { .. } - | WalletEvent::ChainTruncated { .. } => Vec::new(), + // `unconfirmed_txids` are left out: they stay InstantSend-locked, + // which is still final for DashPay display. + WalletEvent::ChainTruncated { txids, .. } => txids + .iter() + .map(|txid| (txid.to_string(), SentPaymentEvidence::Unmined)) + .collect(), + WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => { + Vec::new() + } } } @@ -2083,6 +2211,13 @@ fn sent_payment_evidence(event: &WalletEvent) -> Vec<(String, SentPaymentEvidenc /// the sweep from the frozen watermark, which re-derives the verdict. A /// rollback ledger would only defend a divergence that cannot outlive the /// session that caused it. +/// +/// The exception is a payment a truncation returned to pending: no scan +/// reports the fork again, so nothing re-derives that verdict. Those rows are +/// offered again with the wallet's next round, as the truncation is (see +/// [`returned_to_pending`]). The list is held in memory only; a restart +/// before a store accepts them leaves the stored payment confirmed, and so +/// does a second refusal that is not transient. pub(crate) async fn sent_payment_verdicts( wallet_manager: &Arc>>, event: &WalletEvent, @@ -2163,6 +2298,23 @@ pub(crate) async fn sent_payment_verdicts( overlay } +/// The rows of `overlay` a truncation returned to pending. `Pending` is the +/// status only that verdict writes (see [`next_sent_payment_status`]), so the +/// status alone picks them out. +fn returned_to_pending(overlay: Option<&PaymentOverlay>) -> PaymentOverlay { + let mut rows = PaymentOverlay::default(); + for (owner, payments) in overlay.into_iter().flatten() { + for (txid, entry) in payments { + if entry.status == PaymentStatus::Pending { + rows.entry(*owner) + .or_default() + .insert(txid.clone(), entry.clone()); + } + } + } + rows +} + /// The status `entry` moves to under `evidence`, or `None` if it does not /// move. Received entries never move: their status is settled at the moment /// they are recorded from an on-chain sighting, and a sweep of an unrelated @@ -2781,6 +2933,7 @@ impl CoreChangeSet { self.records.is_empty() && self.account_records.is_empty() && self.sweeps.is_empty() + && self.truncations.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -2874,24 +3027,36 @@ mod swept_transaction_projection_tests { } } - /// Pins the gap tracked in #5380: a truncation reaches the bridge but - /// produces no persistable change, least of all a sweep (it has no - /// winning transaction) or a height (heights only move up on merge). + /// A truncation carries txids and outpoints but no records, so like a + /// sweep it has to reach the persister on the strength of those alone. #[tokio::test] - async fn should_persist_nothing_for_a_chain_truncation() { + async fn should_mirror_a_chain_truncation_to_the_persister() { let event = WalletEvent::ChainTruncated { wallet_id: WALLET_ID, height: WINNER_HEIGHT, txids: vec![txid(1)], unconfirmed_txids: vec![txid(2)], - restored_outpoints: Vec::new(), + restored_outpoints: vec![outpoint(3, 0)], balance: WalletCoreBalance::default(), account_balances: BTreeMap::new(), }; let cs = build_core_changeset(&test_manager(), &event).await; - assert!(cs.is_empty(), "a truncation is not mirrored yet"); + assert_eq!( + cs.truncations, + vec![ChainTruncation { + height: WINNER_HEIGHT, + txids: vec![txid(1)], + unconfirmed_txids: vec![txid(2)], + restored_outpoints: vec![outpoint(3, 0)], + }] + ); + assert!(cs.sweeps.is_empty(), "a truncation has no winner to name"); + assert!( + !cs.is_empty_no_records(), + "the adapter must not skip the round" + ); } #[tokio::test] @@ -3445,12 +3610,90 @@ mod sent_payment_verdict_tests { ); } + /// A truncation dropping the block of `removed`, keeping `unconfirmed` + /// as InstantSend-locked mempool transactions. + fn truncation_of( + wallet_id: WalletId, + removed: Vec, + unconfirmed: Vec, + ) -> WalletEvent { + WalletEvent::ChainTruncated { + wallet_id, + height: 1_499_000, + txids: removed, + unconfirmed_txids: unconfirmed, + restored_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + /// A payment shown `Confirmed` on the strength of a block the chain no + /// longer has is not confirmed. It is not dead either — the transaction + /// can be mined again — so it goes back to `Pending`, and the block that + /// mines it again confirms it through the ordinary edge. + #[tokio::test] + async fn should_return_a_confirmed_sent_payment_to_pending_when_its_block_is_dropped() { + let (wallet_manager, wallet_id, txid) = + wallet_with_payment(PaymentDirection::Sent, PaymentStatus::Confirmed).await; + let event = truncation_of(wallet_id, vec![sent_transaction().txid()], Vec::new()); + + let overlay = sent_payment_verdicts(&wallet_manager, &event).await; + + assert_eq!(only_row(&overlay, &txid).status, PaymentStatus::Pending); + assert_eq!( + stored_status(&wallet_manager, &wallet_id, &txid).await, + PaymentStatus::Pending + ); + + let mined_again = + sent_payment_verdicts(&wallet_manager, &chainlocked_reinstatement(wallet_id)).await; + assert_eq!( + only_row(&mined_again, &txid).status, + PaymentStatus::Confirmed + ); + } + + /// An InstantSend lock outlives the block: a kept transaction is still + /// final for DashPay display, so its payment does not move. + #[tokio::test] + async fn should_keep_a_sent_payment_confirmed_when_its_dropped_block_left_it_instant_locked() { + let (wallet_manager, wallet_id, txid) = + wallet_with_payment(PaymentDirection::Sent, PaymentStatus::Confirmed).await; + let event = truncation_of(wallet_id, Vec::new(), vec![sent_transaction().txid()]); + + let overlay = sent_payment_verdicts(&wallet_manager, &event).await; + + assert!(overlay.is_empty()); + assert_eq!( + stored_status(&wallet_manager, &wallet_id, &txid).await, + PaymentStatus::Confirmed + ); + } + + /// A sweep's verdict stands through a truncation: the wallet does not + /// bring a swept transaction back when the block of its winner goes. + #[tokio::test] + async fn should_keep_a_failed_sent_payment_failed_when_a_block_is_dropped() { + let (wallet_manager, wallet_id, txid) = + wallet_with_payment(PaymentDirection::Sent, PaymentStatus::Failed).await; + let event = truncation_of(wallet_id, vec![sent_transaction().txid()], Vec::new()); + + let overlay = sent_payment_verdicts(&wallet_manager, &event).await; + + assert!(overlay.is_empty()); + assert_eq!( + stored_status(&wallet_manager, &wallet_id, &txid).await, + PaymentStatus::Failed + ); + } + /// The transition table, enumerated. Written out rather than derived so /// that adding an edge means editing this list — the point of the table /// is that every edge was chosen, not inferred. #[test] - fn the_transition_table_admits_exactly_the_four_intended_edges() { - use SentPaymentEvidence::{Final, Swept}; + fn the_transition_table_admits_exactly_the_five_intended_edges() { + use SentPaymentEvidence::{Final, Swept, Unmined}; let expected = [ ((PaymentStatus::Pending, Swept), Some(PaymentStatus::Failed)), ( @@ -3467,6 +3710,12 @@ mod sent_payment_verdict_tests { Some(PaymentStatus::Confirmed), ), ((PaymentStatus::Confirmed, Final), None), + ( + (PaymentStatus::Confirmed, Unmined), + Some(PaymentStatus::Pending), + ), + ((PaymentStatus::Pending, Unmined), None), + ((PaymentStatus::Failed, Unmined), None), ]; for ((from, evidence), to) in expected { assert_eq!( @@ -3477,8 +3726,8 @@ mod sent_payment_verdict_tests { } assert_eq!( expected.iter().filter(|(_, to)| to.is_some()).count(), - 4, - "four edges move an entry; the other two are no-ops" + 5, + "five edges move an entry; the other four are no-ops" ); } @@ -6073,6 +6322,8 @@ mod tests { /// must be coupled to, since the sweep never re-emits once its round /// is durable. n_sweeps: usize, + /// Chain truncations the round carries, verbatim. + truncations: Vec, /// The round's `dashpay_payments_overlay` verbatim — `None` when the /// round carried none, which is also what a payments-blind persister /// must see after the capability gate has withheld one. @@ -6095,6 +6346,10 @@ mod tests { struct ProbePersister { obs: UnboundedSender, fail_once: Mutex>, + /// Wallets whose NEXT `store()` fails as `Transient`. The probe does + /// not attest `store_transient_is_reissuable`, so this is a backend + /// that kept the failed round for its own retry. + fail_transiently_once: Mutex>, /// Wallets whose NEXT `store()` panics instead of returning. Models a /// backend that dies mid-write — which surfaces as a `JoinError` /// instead of unwinding the whole adapter task. @@ -6116,6 +6371,7 @@ mod tests { Self { obs, fail_once: Mutex::new(HashSet::new()), + fail_transiently_once: Mutex::new(HashSet::new()), panic_once: Mutex::new(HashSet::new()), block_until: Mutex::new(None), blocked: Arc::new(AtomicBool::new(false)), @@ -6145,6 +6401,9 @@ mod tests { fn fail_next(&self, wallet_id: WalletId) { self.fail_once.lock().unwrap().insert(wallet_id); } + fn fail_next_transiently(&self, wallet_id: WalletId) { + self.fail_transiently_once.lock().unwrap().insert(wallet_id); + } fn panic_next(&self, wallet_id: WalletId) { self.panic_once.lock().unwrap().insert(wallet_id); } @@ -6176,7 +6435,12 @@ mod tests { if self.panic_once.lock().unwrap().remove(&wallet_id) { panic!("probe persister: store panicked for {wallet_id:?}"); } - let rejected = self.fail_once.lock().unwrap().remove(&wallet_id); + let kept_for_retry = self + .fail_transiently_once + .lock() + .unwrap() + .remove(&wallet_id); + let rejected = self.fail_once.lock().unwrap().remove(&wallet_id) || kept_for_retry; let _ = self.obs.send(StoreObserved { wallet_id, synced_height: core.and_then(|c| c.synced_height), @@ -6193,6 +6457,7 @@ mod tests { .map(|a| a.removed.len()) .unwrap_or(0), n_sweeps: core.map(|c| c.sweeps.len()).unwrap_or(0), + truncations: core.map(|c| c.truncations.clone()).unwrap_or_default(), dashpay_payments: changeset.dashpay_payments_overlay.clone(), dashpay_identity_payments: changeset.identities.as_ref().map(|ids| { ids.identities @@ -6202,7 +6467,12 @@ mod tests { }), rejected, }); - if rejected { + if kept_for_retry { + Err(PersistenceError::backend_with_kind( + crate::changeset::traits::PersistenceErrorKind::Transient, + "probe: forced transient store failure", + )) + } else if rejected { Err(PersistenceError::backend("probe: forced store rejection")) } else { Ok(()) @@ -7872,6 +8142,238 @@ mod tests { handle.await.unwrap(); } + /// Height of the last block the truncations in this module keep. + const FORK_HEIGHT: u32 = 30; + + fn truncated_event(wallet_id: WalletId) -> WalletEvent { + use dashcore::hashes::Hash as _; + WalletEvent::ChainTruncated { + wallet_id, + height: FORK_HEIGHT, + txids: vec![dashcore::Txid::from_byte_array([0x61; 32])], + unconfirmed_txids: Vec::new(), + restored_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + /// The next round the probe sees, or a failed test instead of a hang. + async fn next_store( + obs_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ) -> StoreObserved { + tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("a round must reach store() within the timeout") + .expect("a round must reach store()") + } + + /// A persister that never attested `CORE_CHAIN_TRUNCATION` is not handed + /// one, and — unlike an unattested sweep — is not faulted for it: nothing + /// replays a truncation, so freezing its watermark would recover nothing. + #[tokio::test] + async fn should_withhold_a_truncation_from_a_persister_that_cannot_apply_it() { + let manager = test_manager(); + let wallet_id = insert_test_wallet(&manager).await; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::new(obs_tx)); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + Arc::clone(&manager), + Arc::downgrade(&persister), + rx, + Arc::clone(&sync_fault), + Arc::default(), + cancel.clone(), + )); + + tx.send(truncated_event(wallet_id)).unwrap(); + emit_advance(&manager, &tx, wallet_id, FORK_HEIGHT + 5).await; + let mut seen = Vec::new(); + while seen + .iter() + .all(|round: &StoreObserved| round.synced_height.is_none()) + { + seen.push(next_store(&mut obs_rx).await); + } + + assert!( + seen.iter().all(|round| round.truncations.is_empty()), + "a persister that cannot apply a truncation must not be handed one" + ); + assert_eq!( + seen.last().and_then(|round| round.synced_height), + Some(FORK_HEIGHT + 5), + "the rest of the stream keeps persisting, watermark included" + ); + assert!(!sync_fault.load(Ordering::Relaxed)); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + #[tokio::test] + async fn should_keep_a_truncation_checkpoint_at_or_below_the_live_cursor() { + for live in [10, FORK_HEIGHT + 20] { + let (manager, _, wallet_id) = engine_wallet(live).await; + let mut batch = BTreeMap::new(); + super::project_event_into_batch( + &manager, + &Arc::default(), + &truncated_event(wallet_id), + &mut batch, + ) + .await; + assert_eq!( + batch[&wallet_id].core.synced_height, + Some(live.min(FORK_HEIGHT)) + ); + assert!(!batch[&wallet_id].core.synced_height_is_rewind); + } + } + + #[tokio::test] + async fn should_drop_a_truncation_checkpoint_after_a_later_contact_rewind() { + let (manager, _, wallet_id) = engine_wallet(FORK_HEIGHT + 20).await; + let mut batch = BTreeMap::new(); + super::project_event_into_batch( + &manager, + &Arc::default(), + &truncated_event(wallet_id), + &mut batch, + ) + .await; + assert_eq!(batch[&wallet_id].core.synced_height, Some(FORK_HEIGHT)); + rewind(&manager, wallet_id, 10).await; + engine_advance(&manager, wallet_id, FORK_HEIGHT + 20).await; + super::drop_watermarks_above_the_live_cursor(&manager, &mut batch).await; + assert_eq!(batch[&wallet_id].core.synced_height, None); + assert_eq!(batch[&wallet_id].core.truncations.len(), 1); + } + + #[tokio::test] + async fn should_hand_a_truncation_to_a_persister_that_attests_it() { + let wallet_id = [32u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_CHAIN_TRUNCATION, + )); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::downgrade(&persister), + rx, + Arc::new(AtomicBool::new(false)), + Arc::default(), + cancel.clone(), + )); + + tx.send(truncated_event(wallet_id)).unwrap(); + let round = next_store(&mut obs_rx).await; + + assert_eq!(round.truncations.len(), 1); + assert_eq!(round.truncations[0].height, FORK_HEIGHT); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// A rejected round leaves the store on the replaced branch, and no scan + /// reports a truncation twice. So the adapter offers it again, ahead of + /// whatever the wallet's next round carries, until a store accepts it. + #[tokio::test] + async fn should_offer_a_rejected_truncation_again_with_the_next_round() { + let wallet_id = [33u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_CHAIN_TRUNCATION, + )); + persister.fail_next(wallet_id); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::downgrade(&persister), + rx, + Arc::new(AtomicBool::new(false)), + Arc::default(), + cancel.clone(), + )); + + tx.send(truncated_event(wallet_id)).unwrap(); + let rejected = next_store(&mut obs_rx).await; + assert!(rejected.rejected); + assert_eq!(rejected.truncations.len(), 1); + + tx.send(block_processed_event(wallet_id, FORK_HEIGHT + 1)) + .unwrap(); + let retried = next_store(&mut obs_rx).await; + assert!(!retried.rejected); + assert_eq!( + retried.truncations, rejected.truncations, + "the unapplied truncation rides the wallet's next round" + ); + assert_eq!(retried.last_processed_height, Some(FORK_HEIGHT + 1)); + + tx.send(block_processed_event(wallet_id, FORK_HEIGHT + 2)) + .unwrap(); + let after = next_store(&mut obs_rx).await; + assert!( + after.truncations.is_empty(), + "an accepted truncation is not offered a third time" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// A backend that fails `Transient` without attesting + /// `store_transient_is_reissuable` kept the round and retries it itself. + /// A second copy from the adapter would land after what that round + /// stored behind the truncation, and cut it away. + #[tokio::test] + async fn should_not_offer_a_truncation_again_to_a_persister_that_kept_the_failed_round() { + let wallet_id = [34u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_CHAIN_TRUNCATION, + )); + persister.fail_next_transiently(wallet_id); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::downgrade(&persister), + rx, + Arc::new(AtomicBool::new(false)), + Arc::default(), + cancel.clone(), + )); + + tx.send(truncated_event(wallet_id)).unwrap(); + let kept = next_store(&mut obs_rx).await; + assert!(kept.rejected); + assert_eq!(kept.truncations.len(), 1); + + tx.send(block_processed_event(wallet_id, FORK_HEIGHT + 1)) + .unwrap(); + let next = next_store(&mut obs_rx).await; + assert!(next.truncations.is_empty()); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + /// The positive case for the same gate: a persister that attests /// `CORE_SWEEP_REMOVAL` is trusted normally, and the watermark keeps /// advancing through a sweep-bearing round exactly as it would through @@ -8679,6 +9181,341 @@ mod tests { assert_eq!(diag.faulted, 0, "a payments-capable host is not a fault"); } + /// A fork is reported once, so the payment it returns to pending is + /// decided once. When the store rejects that round the truncation is + /// offered again; the payment row has to come with it, or the store + /// keeps a confirmed payment for a transaction no block holds. + #[test] + fn should_offer_the_payment_a_rejected_truncation_returned_to_pending_again() { + use crate::changeset::PersistenceCapabilities; + use crate::wallet::identity::PaymentStatus; + let wallet_id = [0x35u8; 32]; + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = ProbePersister::with_capabilities( + obs_tx, + PersistenceCapabilities::CORE_CHAIN_TRUNCATION + .union(PersistenceCapabilities::DASHPAY_PAYMENTS), + ); + persister.fail_next(wallet_id); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let freeze_logged = AtomicBool::new(false); + let truncation = super::ChainTruncation { + height: 600, + txids: Vec::new(), + unconfirmed_txids: Vec::new(), + restored_outpoints: Vec::new(), + }; + + let mut rejected_round = BTreeMap::new(); + rejected_round.insert( + wallet_id, + WalletBatch { + core: CoreChangeSet { + truncations: vec![truncation.clone()], + ..CoreChangeSet::default() + }, + asset_locks: AssetLockChangeSet::default(), + payments: one_verdict_overlay(PaymentStatus::Pending), + advance_epoch: None, + }, + ); + commit_batch( + &persister, + rejected_round, + 1, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + assert!( + obs_rx + .try_recv() + .expect("the round reaches store()") + .rejected + ); + + commit_batch( + &persister, + one_wallet_batch(wallet_id, watermark_with_rows(700, 700)), + 2, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + let retried = obs_rx.try_recv().expect("the next round reaches store()"); + assert_eq!(retried.truncations, vec![truncation]); + assert_eq!( + retried.dashpay_payments, + Some(one_verdict_overlay(PaymentStatus::Pending)), + "the payment the truncation returned to pending rides the retry" + ); + + commit_batch( + &persister, + one_wallet_batch(wallet_id, watermark_with_rows(701, 701)), + 3, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + let after = obs_rx.try_recv().expect("the third round reaches store()"); + assert_eq!( + after.dashpay_payments, None, + "a row a store accepted is not offered again" + ); + } + + /// What the store sees on the round after one it rejected, when the + /// rejected round carried `rejected` and the next one carries `next`. + fn payments_stored_after_a_rejected_round( + rejected: super::PaymentOverlay, + next: super::PaymentOverlay, + ) -> Option { + let wallet_id = [0x36u8; 32]; + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS, + ); + persister.fail_next(wallet_id); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let freeze_logged = AtomicBool::new(false); + + for (seq, payments) in [(1, rejected), (2, next)] { + let mut batch = BTreeMap::new(); + batch.insert( + wallet_id, + WalletBatch { + core: watermark_with_rows(700 + seq as u32, 700 + seq as u32), + asset_locks: AssetLockChangeSet::default(), + advance_epoch: None, + payments, + }, + ); + commit_batch( + &persister, + batch, + seq, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + } + assert!(obs_rx.try_recv().expect("the first round").rejected); + obs_rx + .try_recv() + .expect("the second round") + .dashpay_payments + } + + /// What the store sees over three rounds when the first is refused for + /// good and carries a truncation with the payment it returned to + /// pending, and the second is refused the way `second` says. + fn rounds_after_a_refused_reversal( + second: fn(&ProbePersister, WalletId), + ) -> Vec { + use crate::changeset::PersistenceCapabilities; + use crate::wallet::identity::PaymentStatus; + let wallet_id = [0x37u8; 32]; + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = ProbePersister::with_capabilities( + obs_tx, + PersistenceCapabilities::CORE_CHAIN_TRUNCATION + .union(PersistenceCapabilities::DASHPAY_PAYMENTS), + ); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let freeze_logged = AtomicBool::new(false); + + let mut stored = Vec::new(); + for seq in 0..3usize { + let mut core = watermark_with_rows(700 + seq as u32, 700 + seq as u32); + let mut payments = super::PaymentOverlay::default(); + match seq { + 0 => { + core.truncations = vec![super::ChainTruncation { + height: 600, + txids: Vec::new(), + unconfirmed_txids: Vec::new(), + restored_outpoints: Vec::new(), + }]; + payments = one_verdict_overlay(PaymentStatus::Pending); + persister.fail_next(wallet_id); + } + 1 => second(&persister, wallet_id), + _ => {} + } + let mut batch = BTreeMap::new(); + batch.insert( + wallet_id, + WalletBatch { + core, + asset_locks: AssetLockChangeSet::default(), + advance_epoch: None, + payments, + }, + ); + commit_batch( + &persister, + batch, + seq, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + stored.push(obs_rx.try_recv().expect("the round reaches store()")); + } + assert!(stored[0].rejected && stored[1].rejected); + assert_eq!( + stored[1].dashpay_payments, + Some(one_verdict_overlay(PaymentStatus::Pending)), + "the second round carries the row the first one could not store" + ); + stored + } + + /// A store can refuse a carried row for good — its identity was removed + /// while the row waited. Offered again with every round, the row would + /// take each of them down, the truncation with it. + #[test] + fn should_stop_offering_a_carried_reversal_the_store_refuses_for_good() { + let stored = rounds_after_a_refused_reversal(|persister, wallet_id| { + persister.fail_next(wallet_id); + }); + + let third = &stored[2]; + assert!(!third.rejected, "the round after the refusal goes through"); + assert_eq!( + third.dashpay_payments, None, + "a row refused for good is not offered a third time" + ); + assert_eq!( + third.truncations, stored[0].truncations, + "and the truncation it rode with is applied" + ); + } + + /// The round that gives a carried row up can hold a reversal of its + /// own, from a second fork. That one has been refused once, not twice, + /// and nothing reports its fork again either. + #[test] + fn should_keep_a_fresh_reversal_when_a_carried_one_is_given_up() { + use crate::wallet::identity::{PaymentEntry, PaymentStatus}; + let wallet_id = [0x38u8; 32]; + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS, + ); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let freeze_logged = AtomicBool::new(false); + let fresh = super::PaymentOverlay::from([( + Identifier::from([0xCC; 32]), + BTreeMap::from([( + "cafe".to_string(), + PaymentEntry::new_sent(Identifier::from([0xDD; 32]), 70_000, None), + )]), + )]); + + let rounds = [ + (one_verdict_overlay(PaymentStatus::Pending), true), + (fresh.clone(), true), + (super::PaymentOverlay::default(), false), + ]; + let mut stored = Vec::new(); + for (seq, (payments, refused)) in rounds.into_iter().enumerate() { + if refused { + persister.fail_next(wallet_id); + } + let mut batch = BTreeMap::new(); + batch.insert( + wallet_id, + WalletBatch { + core: watermark_with_rows(700 + seq as u32, 700 + seq as u32), + asset_locks: AssetLockChangeSet::default(), + advance_epoch: None, + payments, + }, + ); + commit_batch( + &persister, + batch, + seq, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + stored.push(obs_rx.try_recv().expect("the round reaches store()")); + } + + assert_eq!( + stored[1].dashpay_payments.as_ref().map(BTreeMap::len), + Some(2), + "the refused round held the carried row and its own" + ); + assert_eq!( + stored[2].dashpay_payments, + Some(fresh), + "its own row is offered again, the carried one is not" + ); + } + + /// A store that is merely busy has said nothing about the row, so it is + /// offered until a store answers. + #[test] + fn should_keep_offering_a_carried_reversal_through_a_transient_failure() { + use crate::wallet::identity::PaymentStatus; + let stored = rounds_after_a_refused_reversal(|persister, wallet_id| { + persister.fail_next_transiently(wallet_id); + }); + + assert_eq!( + stored[2].dashpay_payments, + Some(one_verdict_overlay(PaymentStatus::Pending)), + ); + } + + /// A swept or confirmed payment of a rejected round is re-derived by the + /// scan the frozen watermark forces at the next start, so it is not + /// carried: only the verdict nothing reports twice is. + #[test] + fn should_not_offer_a_verdict_the_next_scan_re_derives_again() { + use crate::wallet::identity::PaymentStatus; + for status in [PaymentStatus::Failed, PaymentStatus::Confirmed] { + assert_eq!( + payments_stored_after_a_rejected_round( + one_verdict_overlay(status), + super::PaymentOverlay::default(), + ), + None, + "{status:?}" + ); + } + } + + /// The payment was mined again before the retry: the round that says so + /// is the newer word on the row. + #[test] + fn should_store_the_newer_verdict_over_a_carried_return_to_pending() { + use crate::wallet::identity::PaymentStatus; + assert_eq!( + payments_stored_after_a_rejected_round( + one_verdict_overlay(PaymentStatus::Pending), + one_verdict_overlay(PaymentStatus::Confirmed), + ), + Some(one_verdict_overlay(PaymentStatus::Confirmed)), + ); + } + /// A persister that never attested `DASHPAY_PAYMENTS` cannot apply the /// overlay, so handing it one would let the round return `Ok` while the /// verdict was silently dropped. Withhold it instead — and unlike a diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 6c17b550b1e..4b8bac16621 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -105,6 +105,45 @@ impl PersistenceCapabilities { /// declaration only when `on_persist_dashpay_payments_fn` is actually /// wired. pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); + /// A stored `CoreChangeSet` whose `truncations` are non-empty is durably + /// applied, before the rest of the same changeset and atomically with + /// it: each removed transaction and every output it created are gone, + /// each kept transaction is stored as unconfirmed with its outputs + /// InstantSend-locked, each restored outpoint is unspent, and stored + /// `synced_height` / `last_processed_height` above the fork height are + /// lowered to it. On the FFI surface truncations travel through their + /// own size-negotiated extension callback, like sweeps. + /// + /// # Unlike `CORE_SWEEP_REMOVAL`, absence does not fault the wallet + /// + /// A persister without this bit is simply not handed the truncation; the + /// rest of the round, its watermark included, is stored as usual. The + /// sweep gate freezes the watermark because that makes the next launch + /// re-scan and re-emit the sweep. Nothing re-emits a truncation: the + /// fork is detected once, against headers that are already replaced, so + /// a frozen watermark would cost the host its durable progress and + /// recover nothing. Such a store keeps the replaced branch until the + /// host rescans. + /// + /// # A truncation is owed until it is applied + /// + /// For the same reason a truncation a `store` rejected is offered again + /// ahead of the wallet's next round, and so is never skipped: while it + /// cannot be applied, every later round of that wallet fails with it. + /// That includes a rejection whose outcome the backend could not vouch + /// for. Over a round that landed in part, the truncation takes away only + /// what that round stored above the fork, which the rejection already + /// counts as unstored, and the next start scans it back in from the + /// height the truncation lowered. + /// + /// A backend whose + /// [`store_transient_is_reissuable`](super::PlatformWalletPersistence::store_transient_is_reissuable) + /// is `false` is not offered a truncation again after a transient + /// failure, since it may hold the round for its own retry. Attesting + /// this bit therefore obliges such a backend to keep that truncation + /// until it has applied it, including when it discards the rest of what + /// it held. + pub const CORE_CHAIN_TRUNCATION: Self = Self(1 << 15); /// Asset-lock operation UUIDs survive store/load, including consumed tombstones. /// Missing or malformed persistence data fails load rather than becoming an @@ -117,7 +156,7 @@ impl PersistenceCapabilities { /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 13; + const HIGHEST_DECLARED_BIT: u32 = 15; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -221,6 +260,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS, "asset_lock_operation_ids", ), + ( + PersistenceCapabilities::CORE_CHAIN_TRUNCATION, + "core_chain_truncation", + ), ]; KNOWN @@ -238,7 +281,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() + PersistenceCapabilities::CORE_CHAIN_TRUNCATION.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -271,6 +314,10 @@ mod tests { 0x2000 ); assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0x2285); + assert_eq!( + PersistenceCapabilities::CORE_CHAIN_TRUNCATION.bits(), + 0x8000 + ); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 @@ -304,6 +351,11 @@ mod tests { // to prevent. Walking the whole width cannot go stale. for shift in 0..=PersistenceCapabilities::HIGHEST_DECLARED_BIT { let bit = PersistenceCapabilities::from_bits_retain(1u64 << shift); + // Reserved for CORE_HISTORY_RESTORE (dashpay/platform#5220). + if shift == 14 { + assert!(bit.names().is_empty(), "bit 14 is declared; drop this skip"); + continue; + } assert_eq!( bit.names().len(), 1, diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 6ea8ba9b5fd..e6ddba0e193 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1439,6 +1439,85 @@ mod tests { ); } + /// A truncation hands back a coin whose spend was mined once and may be + /// mined again, so the coin has to be fenced until the spend is seen a + /// second time — and from the moment the wallet lock is released, not + /// from the delivery of an event: a fork recovery truncates between two + /// runs of the SPV client, when nothing listens for one. + #[tokio::test] + async fn should_fence_a_coin_again_when_a_truncation_undoes_its_spend() { + use dashcore::hashes::Hash as _; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + use key_wallet_manager::WalletInterface; + + const FORK_HEIGHT: u32 = 4; + let (wallet_manager, wallet_id, generation, _signer) = + crate::test_support::funded_wallet_manager( + key_wallet::account::account_type::StandardAccountType::BIP44Account, + ) + .await; + let coin = { + let wm = wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("the funded wallet"); + let utxos = info.core_wallet.utxos(); + utxos.first().expect("the funded coin").outpoint + }; + let spend = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: coin, + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let block = dashcore::Block { + header: dashcore::block::Header { + version: dashcore::block::Version::default(), + prev_blockhash: dashcore::BlockHash::all_zeros(), + merkle_root: dashcore::TxMerkleNode::all_zeros(), + time: 0, + bits: dashcore::CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata: vec![spend.clone()], + }; + wallet_manager + .write() + .await + .process_block_for_wallets( + &block, + block.block_hash(), + FORK_HEIGHT + 1, + &std::collections::BTreeSet::from([wallet_id]), + ) + .await; + assert!( + generation.in_broadcast_conflict(&spend).is_none(), + "a coin the wallet holds as spent needs no fence" + ); + + // No event is delivered to any handler from here on. + wallet_manager + .write() + .await + .truncate_above(FORK_HEIGHT) + .expect("no chain lock is applied above the fork"); + + assert_eq!( + generation.in_broadcast_conflict(&spend), + Some(coin), + "the undone spend may be mined again; the coin must not be re-selected" + ); + + generation.observe_spent([coin]); + assert!( + generation.in_broadcast_conflict(&spend).is_none(), + "seeing the spend again is what ends the fence" + ); + } + /// `shutdown()` joins every started coordinator through the shared /// [`ThreadRegistry`], reports each as cleanly joined, and is /// idempotent — a second call finds nothing running and still reports diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index 5224985f838..0121f341c13 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -598,6 +598,28 @@ impl WalletGeneration { } } + /// Fence every coin in `outpoints` that a chain truncation handed back. + /// + /// The transaction that spent such a coin was mined once. The wallet has + /// dropped its record, but the network still relays it and will normally + /// mine it again, so a new spend of the coin would conflict with a + /// transaction on the wire. The pending-spend phase therefore opens again + /// and ends the way it always does: when the spend is observed. This + /// holds for coins this generation never dispatched too — a spend made + /// by another instance of the same wallet comes back identically. + /// + /// The cost: when the undone spend is never mined again, nothing reports + /// the coin spent, and a send that selects it fails with the in-broadcast + /// conflict until the wallet is restarted. + pub(crate) fn fence_restored(&self, outpoints: impl IntoIterator) { + let mut pinned = self.in_broadcast_lock(); + for outpoint in outpoints { + let fence = pinned.entry(outpoint).or_default(); + fence.observed_spent = false; + fence.pending = true; + } + } + /// End one dispatch's hold on `outpoints` — the [`InBroadcastPin`] release /// half of [`pin_in_broadcast`](Self::pin_in_broadcast). /// diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs index db509798de1..fc8d50aa973 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -185,9 +185,9 @@ pub(crate) fn observed_spends(event: &WalletEvent) -> Vec { // without the loser's inputs travelling on the event. WalletEvent::TransactionsSwept { .. } => Vec::new(), // A truncation reports coins that became unspent again - // (`restored_outpoints`), the opposite of a spend. - // TODO(#5380): a fence already retired by a spend the truncation - // undid is not re-armed. + // (`restored_outpoints`), the opposite of a spend. Those are fenced + // where the wallet is truncated — see + // `WalletGeneration::fence_restored`. WalletEvent::ChainTruncated { .. } => Vec::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index a8414df417c..17562b5f1c7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -192,13 +192,20 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.update_synced_height(current_height); } - /// Forwarded, then noted like any other in-memory rewind (see - /// `note_in_memory_rewind`) when it lowered the scan cursor: the persister - /// does not mirror `ChainTruncated`, so the host still holds the replaced - /// branch's cursor. A refused truncation changes nothing and notes nothing. + /// Fence restored coins and note a lowered cursor's rewind epoch before + /// the truncation and its checkpoint reach the persister. + /// A refused truncation changes nothing and notes nothing. fn truncate_above(&mut self, height: CoreBlockHeight) -> Result { let before = self.core_wallet.metadata.synced_height; let truncation = self.core_wallet.truncate_above(height)?; + // Fenced here, not on the `ChainTruncated` event: the caller holds + // the wallet-manager write lock, so no build can select a restored + // coin before its fence stands, and a fork recovery truncates + // between two runs of the SPV client, when no handler is listening. + // Lock order: wallet manager, then the fence mutex — a leaf, which + // nothing holds while it takes another lock. + self.generation + .fence_restored(truncation.restored_outpoints.iter().copied()); self.note_in_memory_rewind(before); Ok(truncation) } @@ -404,13 +411,10 @@ impl ManagedAccountOperations for PlatformWalletInfo { impl PlatformWalletInfo { /// key-wallet lowers the scan cursor on its own: an account add rewinds /// it to just below wallet birth (the new account has no filter - /// coverage), a chain truncation to the fork height. Neither lowering - /// reaches the persister (an account add emits nothing, and - /// `ChainTruncated` projects to an empty changeset), so treat it like any - /// other in-memory rewind: advances emitted before it must not be stored - /// after it, and the next DashPay backfill record round owes the host the - /// lowered cursor — otherwise a round could store coverage beside a host - /// cursor that never came down. + /// coverage), a chain truncation to the fork height. Advances emitted + /// before either rewind must not be stored afterwards. The DashPay + /// backfill record also owes the host the lowered cursor, so coverage + /// cannot be stored beside a cursor that never came down. fn note_in_memory_rewind(&mut self, before: u32) { let after = self.core_wallet.metadata.synced_height; if after < before { diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index da5d40e8f04..5c6de79e390 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -53,11 +53,11 @@ use jni::sys::jstring; use jni::JNIEnv; use platform_wallet_ffi::{ AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI, - AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, - ContactRequestRemovalFFI, CoreAddressEntryFFI, DashPayBackfillCoveredContactFFI, - DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, - IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, OutPointFFI, - PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, + AssetLockEntryFFI, ChainTruncationFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, + ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, + DashPayBackfillCoveredContactFFI, DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, + IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, + OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, @@ -226,6 +226,12 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { /// publication fails closed. pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> PersistenceCallbacksExtension { extension_for(BridgeOverrides { + truncations: bridge_overrides( + env, + bridge, + "onWalletChangesetChainTruncated", + WALLET_CHANGESET_TRUNCATION_DESCRIPTOR, + ), sweeps: bridge_overrides( env, bridge, @@ -294,6 +300,8 @@ fn gate_account_writers(vtable: &mut PersistenceCallbacks, registrations: bool, /// supplies its own body for (see [`bridge_overrides`]). #[derive(Debug, Clone, Copy)] struct BridgeOverrides { + /// `onWalletChangesetChainTruncated`. + truncations: bool, /// `onWalletChangesetTransactionsSwept`. sweeps: bool, /// `onWalletChangesetHeader`, which carries the scan cursor. @@ -306,6 +314,11 @@ struct BridgeOverrides { /// [`build_extension`]). fn extension_for(overrides: BridgeOverrides) -> PersistenceCallbacksExtension { PersistenceCallbacksExtension { + on_persist_wallet_changeset_truncations_fn: if overrides.truncations { + Some(tramp_persist_wallet_changeset_truncations) + } else { + None + }, on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), on_persist_wallet_changeset_sweeps_fn: if overrides.sweeps { Some(tramp_persist_wallet_changeset_sweeps) @@ -1004,6 +1017,77 @@ unsafe fn persist_changeset_sweep_batch( .i() } +/// Extension-callback trampoline for the round's chain truncations, fired +/// before every other core callback of the round (see +/// `PersistWalletChangesetTruncationsFn`). One bridge call per truncation, +/// in order, each in its own local frame like the sweep batches. +unsafe extern "C" fn tramp_persist_wallet_changeset_truncations( + context: *mut c_void, + wallet_id: *const u8, + truncations: *const ChainTruncationFFI, + truncations_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + for truncation in slice_or_empty(truncations, truncations_count) { + let code = env.with_local_frame(8, |env| { + persist_changeset_truncation(env, bridge, &wid, truncation) + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) + }) +} + +/// Descriptor of `NativePersistenceBridge.onWalletChangesetChainTruncated`: +/// `(walletId, height, txids, txidCount, unconfirmedTxids, +/// unconfirmedTxidCount, restoredOutpoints, restoredOutpointCount)`. Txids +/// and outpoints ride as one flat `byte[]` each (32·N and 36·N bytes) plus +/// a count, the packing the sweep descriptor uses. +const WALLET_CHANGESET_TRUNCATION_DESCRIPTOR: &str = "([BI[BI[BI[BI)I"; + +unsafe fn persist_changeset_truncation( + env: &mut JNIEnv, + bridge: &JObject, + wid: &JByteArray, + truncation: &ChainTruncationFFI, +) -> Result { + let txids = slice_or_empty(truncation.txids, truncation.txids_count); + let txids_arr = env.byte_array_from_slice(txids.as_flattened())?; + let unconfirmed = slice_or_empty( + truncation.unconfirmed_txids, + truncation.unconfirmed_txids_count, + ); + let unconfirmed_arr = env.byte_array_from_slice(unconfirmed.as_flattened())?; + let restored = slice_or_empty( + truncation.restored_outpoints, + truncation.restored_outpoints_count, + ); + let mut packed_restored = Vec::with_capacity(restored.len() * 36); + for outpoint in restored { + packed_restored.extend_from_slice(&pack_outpoint_key(outpoint)); + } + let restored_arr = env.byte_array_from_slice(&packed_restored)?; + env.call_method( + bridge, + "onWalletChangesetChainTruncated", + WALLET_CHANGESET_TRUNCATION_DESCRIPTOR, + &[ + wid.into(), + JValue::Int(jint_height(truncation.height)?), + (&txids_arr).into(), + JValue::Int(txids.len() as i32), + (&unconfirmed_arr).into(), + JValue::Int(unconfirmed.len() as i32), + (&restored_arr).into(), + JValue::Int(restored.len() as i32), + ], + )? + .i() +} + /// Deliver the round's numeric chainlock height (see /// `PersistWalletChangesetChainLockHeightFn`), between the changeset callback /// and the sweep batches. One scalar, one call — the bincode chainlock blob @@ -4896,6 +4980,13 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onWalletChangesetTransactionsSwept", WALLET_CHANGESET_SWEEPS_DESCRIPTOR, ), + // Fires only on a reorg, so a drifted descriptor would otherwise + // surface on the first real one. Bound to the same constant as the + // `call_method` site in `persist_changeset_truncation`. + ( + "onWalletChangesetChainTruncated", + WALLET_CHANGESET_TRUNCATION_DESCRIPTOR, + ), // Same drift risk as the sweeps descriptor above: this slot fires on // chainlock-advancing rounds only, so a stale descriptor would surface // exactly when the first real chainlock crossed. Must track the @@ -5126,6 +5217,7 @@ mod tests { (true, true, true), ] { let extension = extension_for(BridgeOverrides { + truncations: false, sweeps: false, header, backfill, @@ -5137,6 +5229,7 @@ mod tests { ); } let sweeps_only = extension_for(BridgeOverrides { + truncations: false, sweeps: true, header: false, backfill: false, @@ -5145,6 +5238,26 @@ mod tests { assert!(sweeps_only.on_persist_wallet_dashpay_backfill_fn.is_none()); } + #[test] + fn should_wire_truncations_only_for_a_bridge_that_overrides_the_callback() { + for truncations in [false, true] { + let extension = extension_for(BridgeOverrides { + truncations, + sweeps: false, + header: false, + backfill: false, + }); + assert_eq!( + extension + .on_persist_wallet_changeset_truncations_fn + .is_some(), + truncations + ); + assert!(extension.on_persist_wallet_changeset_sweeps_fn.is_none()); + assert!(extension.on_persist_wallet_dashpay_backfill_fn.is_none()); + } + } + /// A bridge inheriting the no-op account writers gets neither account /// slot, so `FFIPersister` cannot derive `ACCOUNT_ADDRESS_POOLS` and does /// not keep outbound-account markers on disk; one implementing both keeps @@ -5199,6 +5312,18 @@ mod tests { assert_eq!(WALLET_CHANGESET_SWEEPS_DESCRIPTOR, "([B[BI[B[BIZI)I"); } + #[test] + fn should_ship_a_truncation_as_a_height_and_three_counted_flat_arrays() { + // walletId, height, then (packed bytes, count) for removed txids, + // kept-unconfirmed txids and restored outpoints — must match + // `NativePersistenceBridge.onWalletChangesetChainTruncated`. + assert_eq!(WALLET_CHANGESET_TRUNCATION_DESCRIPTOR, "([BI[BI[BI[BI)I"); + assert!(BRIDGE_METHOD_TABLE.contains(&( + "onWalletChangesetChainTruncated", + WALLET_CHANGESET_TRUNCATION_DESCRIPTOR + ))); + } + #[test] fn vtable_layout_remains_independent_of_capability_declaration() { let callbacks = build_vtable(ptr::null_mut()); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 5c86e8bce11..ea7bb95fe63 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -109,6 +109,13 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { public static let dashpayPayments: UInt64 = 1 << 12 /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 + /// A round's chain truncations — delivered through the persistence + /// extension's size-negotiated truncations callback — are durably + /// applied before the rest of their round. Bit 14 is reserved for + /// `CORE_HISTORY_RESTORE`. Mirrors + /// `PersistenceCapabilities::CORE_CHAIN_TRUNCATION`; Rust only honours + /// the declaration when the extension actually carries the callback. + public static let coreChainTruncation: UInt64 = 1 << 15 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 8089d826022..ca0a4727a55 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -289,6 +289,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// counts, by `endChangeset`. Confined to `serialQueue`. private var roundUtxoCreditTally = UtxoCreditVerdictTally() + /// Rows a chain truncation removed in the open round, keyed by `txid` + /// and by outpoint, and held back from deletion until `endChangeset`. + private struct VacatedRows { + var transactions: [Data: PersistentTransaction] = [:] + var txos: [Data: PersistentTxo] = [:] + } + + /// The round that removes a transaction can record it again in its new + /// block, and a delete followed by an insert under one unique key in a + /// single save is not a sequence this store is relied on to resolve. A + /// removed row is therefore hidden from the keyed lookups, handed to + /// the writer that would otherwise insert its key, and deleted by + /// `endChangeset` only if nothing took it. Cleared by `beginChangeset` + /// and `endChangeset`. Confined to `serialQueue`. + private var roundVacated = VacatedRows() + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -1566,6 +1582,209 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Cut the mirror back the way the wallet cut itself back when a fork + /// replaced the chain above each truncation's height — the SwiftData + /// counterpart of the SQLite store's `apply_truncation`. + /// + /// Rust fires this before the round's other core callbacks, so the rest + /// of the round describes the wallet after the last truncation. Per + /// truncation, in order: this wallet's part in a removed transaction + /// goes — the coins it created for it, its accounts' involvement, its + /// claims and links — and the row itself once no wallet has a part in + /// it; a kept transaction — InstantSend-locked, so mined again — is + /// stored the way a locked transaction no block holds yet is: + /// InstantSend context, no block, this wallet's outputs unconfirmed and + /// locked, and what it spent stays spent; this wallet's restored coins + /// are spendable again; the synced height drops to the fork and never + /// rises. A restored coin needs no veto from stored claims, unlike a + /// sweep's release: its spender was confirmed, and no block the fork + /// keeps can hold a second spend of it. + /// + /// **Everything but the row is this wallet's own.** A transaction row + /// is shared across wallets and each wallet's round commits on its own, + /// so another wallet may already have stored the transaction again, in + /// its new block, by the time this truncation arrives. Its coins are + /// not this truncation's to take. The row's block fields are the one + /// thing wallets share: between two wallets' rounds they can show the + /// branch the other wallet last wrote. + /// + /// Same fail-the-round contract as the sweep callback: a lookup that + /// throws returns `false`, so Rust does not treat a truncation that + /// never landed as durable. + @discardableResult + func persistWalletChangesetTruncations( + walletId: Data, + truncations: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + let wallet: PersistentWallet + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_truncations") { + case .failed: return false + case .absent: return true + case .found(let row): wallet = row + } + guard count > 0, let truncationsPtr = truncations else { return true } + + for index in 0.. 0, let txidsPtr = truncation.txids { + for i in 0.. 0, + let txidsPtr = truncation.unconfirmed_txids { + for i in 0.. 0, + let outpointsPtr = truncation.restored_outpoints { + for i in 0.. truncation.height { + wallet.syncedHeight = truncation.height + wallet.lastUpdated = Date() + } + } + + // Bracketed by changesetBegin/End, which deletes what the round + // did not take back; with no round open nothing can. + if !inChangeset { deleteVacatedRows() } + return true + } + } + + /// Take `walletId`'s part in a transaction a chain truncation removed + /// out of the round — see `roundVacated` — and the row with it when no + /// other wallet has a part in it. The wallet's links and pending claims + /// go at once, so nothing the rest of the round reads still counts the + /// transaction as a spender of its coins. + /// + /// Every row a relationship hands over is registered before it is + /// written, like any other first touch of a key (see `roundIndex`). + private func vacateTransaction(txid: Data, walletId: Data) throws { + guard let row = try fetchTransactionRow( + txid: txid, + prefetching: [\.outputs, \.inputs, \.pendingInputs] + ) else { return } + + // Read through an Optional cast, as `resolvedWalletId(of:)` does. + let walletOf: (PersistentAccount) -> Data? = { account in + let wallet: PersistentWallet? = account.wallet + return wallet?.walletId + } + // Decided before anything is detached: a coin whose wallet cannot + // be resolved counts as somebody else's. + let sharedWithAnotherWallet = + row.outputs.contains { Self.resolvedWalletId(of: $0) != walletId } + || row.inputs.contains { Self.resolvedWalletId(of: $0) != walletId } + || row.pendingInputs.contains { $0.walletId != walletId } + || row.involvedAccounts.contains { walletOf($0) != walletId } + + for output in row.outputs where Self.resolvedWalletId(of: output) == walletId { + roundIndex?.txosByOutpoint[output.outpoint] = output + output.isSpent = false + output.spendingTransaction = nil + output.spendingInputIndex = nil + output.supersededByTxid = nil + roundVacated.txos[output.outpoint] = output + } + for input in row.inputs where Self.resolvedWalletId(of: input) == walletId { + roundIndex?.txosByOutpoint[input.outpoint] = input + input.spendingTransaction = nil + input.spendingInputIndex = nil + input.lastUpdated = Date() + } + for pending in row.pendingInputs where pending.walletId == walletId { + backgroundContext.delete(pending) + } + row.involvedAccounts.removeAll { walletOf($0) == walletId } + + guard !sharedWithAnotherWallet else { return } + Self.unconfirm(row, as: .mempool) + roundVacated.transactions[txid] = row + } + + /// Keep a transaction whose block a chain truncation dropped as + /// unconfirmed and InstantSend-locked — a truncation keeps no other + /// kind: InstantSend context, no block, and `walletId`'s outputs + /// unconfirmed and locked. Spend state is not touched. + private func unconfirmTransaction(txid: Data, walletId: Data) throws { + guard let row = try fetchTransactionRow(txid: txid, prefetching: [\.outputs]) else { return } + Self.unconfirm(row, as: .instantSend) + for output in row.outputs where Self.resolvedWalletId(of: output) == walletId { + roundIndex?.txosByOutpoint[output.outpoint] = output + output.isConfirmed = false + output.isInstantLocked = true + output.height = 0 + output.lastUpdated = Date() + } + } + + /// Take `row` out of its block, leaving it unconfirmed in `context`. + private static func unconfirm(_ row: PersistentTransaction, as context: TransactionContextType) { + row.context = context.rawValue + row.blockHeight = 0 + row.blockHash = nil + row.blockTimestamp = 0 + row.blockPosition = 0 + row.hasBlockPosition = false + row.lastUpdated = Date() + } + + /// Hand back one of this wallet's coins that a removed transaction had + /// spent. The link to that transaction is cleared by + /// `vacateTransaction`. + private func restoreTruncatedCoin(outpoint: Data, walletId: Data) throws { + if let txo = try fetchTxoRowChecked(outpoint: outpoint), + Self.resolvedWalletId(of: txo) == walletId { + txo.isSpent = false + txo.supersededByTxid = nil + txo.lastUpdated = Date() + } + for pending in pendingInputRows(outpoint: outpoint) + where pending.walletId == walletId && pending.isSweptTombstone { + backgroundContext.delete(pending) + } + } + + /// Delete the rows a truncation removed that the round did not record + /// again — see `roundVacated`. + private func deleteVacatedRows() { + for txo in roundVacated.txos.values where !txo.isDeleted { + backgroundContext.delete(txo) + } + for row in roundVacated.transactions.values where !row.isDeleted { + backgroundContext.delete(row) + } + roundVacated = VacatedRows() + } + func persistWalletChangesetSweeps( walletId: Data, sweeps: UnsafePointer?, @@ -2442,6 +2661,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txid: Data, prefetching: [PartialKeyPath] ) throws -> PersistentTransaction? { + if roundVacated.transactions[txid] != nil { return nil } if let known = roundIndex?.transactionsByTxid[txid] { return known.isDeleted ? nil : known } @@ -2469,6 +2689,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// read both mean "write the row"); the reconcile passes must not, /// because for them a miss is an insert and a failed read is a stop. private func fetchTxoRowChecked(outpoint: Data) throws -> PersistentTxo? { + if roundVacated.txos[outpoint] != nil { return nil } if let known = roundIndex?.txosByOutpoint[outpoint] { return known.isDeleted ? nil : known } @@ -2612,6 +2833,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let record: PersistentTransaction if let existing { record = existing + } else if let vacated = roundVacated.transactions.removeValue(forKey: txidData) { + // Removed by a truncation earlier in this round and recorded + // again: the row is taken back and rewritten below. + record = vacated } else { record = PersistentTransaction( txid: txidData, @@ -2876,6 +3101,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let parentTx: PersistentTransaction if let existingTx = fetchTransactionRow(txid: txidData) { parentTx = existingTx + } else if let vacated = roundVacated.transactions.removeValue(forKey: txidData) { + // Removed by a truncation earlier in this round: taken + // back as the stub the branch below would insert. + vacated.transactionData = Data() + parentTx = vacated } else { // Stub row — `transactionData` is left as empty // `Data()` on purpose. The real upsert (which has the @@ -2894,18 +3124,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return Data(bytes: p, count: Int(utxo.script_pubkey_len)) }() let addressStr = utxo.address.map { String(cString: $0) } ?? "" - record = PersistentTxo( - transaction: parentTx, - vout: utxo.outpoint.vout, - amount: utxo.amount, - address: addressStr, - scriptPubKey: script, - height: utxo.height - ) + if let vacated = roundVacated.txos.removeValue(forKey: outpoint) { + // A coin a truncation removed earlier in this round, created + // again: `vacateTransaction` cleared its spend state, and + // the rest is rewritten here and below as for a new row. + record = vacated + record.transaction = parentTx + record.address = addressStr + record.scriptPubKey = script + } else { + record = PersistentTxo( + transaction: parentTx, + vout: utxo.outpoint.vout, + amount: utxo.amount, + address: addressStr, + scriptPubKey: script, + height: utxo.height + ) + backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record + } record.account = account record.walletId = resolvedWalletId - backgroundContext.insert(record) - roundIndex?.txosByOutpoint[outpoint] = record redelivered = false } @@ -3257,6 +3497,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds + | PlatformWalletPersistenceCapabilities.coreChainTruncation ) } @@ -3296,6 +3537,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback + // Chain truncations: fired first among a round's core callbacks, + // and size-negotiated like the sweeps slot. + extensionCallbacks.on_persist_wallet_changeset_truncations_fn = + persistWalletChangesetTruncationsCallback return extensionCallbacks } @@ -3379,6 +3624,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() + self.roundVacated = VacatedRows() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -3464,6 +3710,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() + self.roundVacated = VacatedRows() self.roundIndex = nil self.roundAdvancedFinalityBoundary = false self.inChangeset = false @@ -3500,6 +3747,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.rollback() return false } + // Whatever a truncation removed and the round did not + // record again goes now, ahead of every pass that reads + // the rows the round leaves behind. + deleteVacatedRows() // The round's one collector pass: after every account slice // and every sweep, on the boundary the round's own writes // left on the wallet row — see @@ -9709,6 +9960,34 @@ private func persistWalletChangesetSweepsCallback( ) ? 0 : 1 } +/// C shim for the extension's `on_persist_wallet_changeset_truncations_fn` +/// — the round's chain truncations, fired inside the begin/end bracket +/// before the round's other core callbacks. Same non-zero-fails-the-round +/// contract as its siblings: a truncation Rust believes durable but that +/// never landed would leave the replaced branch in the store. +private func persistWalletChangesetTruncationsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + truncationsPtr: UnsafePointer?, + truncationsCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetTruncations( + walletId: walletId, + truncations: truncationsPtr, + count: truncationsCount + ) ? 0 : 1 +} + /// C shim for the extension's /// `on_persist_wallet_changeset_chain_lock_height_fn` — the round's /// NUMERIC chainlock height, fired inside the same begin/end bracket diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift new file mode 100644 index 00000000000..f668f875a5c --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift @@ -0,0 +1,515 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the chain truncations delivered through the persistence +/// extension's `on_persist_wallet_changeset_truncations_fn`. +/// +/// A fork that replaces the chain above a height takes back what the +/// wallet recorded there: transactions of the dropped blocks are removed +/// or, when InstantSend-locked, kept as unconfirmed; the coins the removed +/// ones spent are spendable again; the scan resumes from the fork. A +/// mirror that ignores this hands the replaced branch back at the next +/// load. +/// +/// Every fixture starts from the same shape: a coin funded at height 100, +/// and a spend of it mined at 101 that pays change back, with the wallet +/// synced to 101. +@MainActor +final class ChainTruncationPersistTests: XCTestCase { + + private let walletId = Data(repeating: 0x01, count: 32) + private let fundingTxid = Data(repeating: 0x51, count: 32) + private let spendTxid = Data(repeating: 0x52, count: 32) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// File-backed variant: a restart is a second container over the same + /// store, which an in-memory store cannot outlive. + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// The coin at `fundingTxid:0` (height 100), spent by `spendTxid` mined + /// at 101, which created change at vout 0 and a second output at + /// vout 1. The wallet is synced to 101. + private func seedSpendMinedAboveTheFork(in container: ModelContainer) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet, syncedHeight: 101)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 100_000 + ) + let spend = PersistentTransaction( + txid: spendTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 2, + blockHeight: 101, + netAmount: -40_000 + ) + spend.blockHash = Data(repeating: 0x08, count: 32) + context.insert(funding) + context.insert(spend) + + let coin = PersistentTxo( + transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100 + ) + coin.walletId = walletId + coin.isConfirmed = true + coin.isSpent = true + coin.spendingTransaction = spend + coin.spendingInputIndex = 0 + context.insert(coin) + + for vout: UInt32 in [0, 1] { + let output = PersistentTxo( + transaction: spend, vout: vout, amount: 30_000, address: "yChangeAddr", height: 101 + ) + output.walletId = walletId + output.isConfirmed = true + context.insert(output) + } + + try context.save() + } + + private struct Truncation { + var height: UInt32 + var removed: [Data] = [] + var unconfirmed: [Data] = [] + var restored: [(txid: Data, vout: UInt32)] = [] + } + + private func rawTxids(_ txids: [Data]) -> UnsafeMutablePointer { + let buffer = UnsafeMutablePointer.allocate(capacity: max(txids.count, 1)) + for (i, txid) in txids.enumerated() { + var tuple: FFIByteTuple32 = (0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0) + Swift.withUnsafeMutableBytes(of: &tuple) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + buffer.advanced(by: i).initialize(to: tuple) + } + return buffer + } + + /// The truncations callback alone, inside whatever bracket the caller + /// opened. The nested buffers are allocated explicitly and freed after + /// the call, as the sweep tests do. + @discardableResult + private func stageTruncation( + _ handler: PlatformWalletPersistenceHandler, + _ truncation: Truncation, + wallet: Data? = nil + ) -> Bool { + let walletId = wallet ?? self.walletId + let removed = rawTxids(truncation.removed) + let unconfirmed = rawTxids(truncation.unconfirmed) + let restored = UnsafeMutablePointer.allocate( + capacity: max(truncation.restored.count, 1) + ) + for (i, outpoint) in truncation.restored.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + restored.advanced(by: i).initialize(to: entry) + } + defer { + removed.deinitialize(count: truncation.removed.count) + removed.deallocate() + unconfirmed.deinitialize(count: truncation.unconfirmed.count) + unconfirmed.deallocate() + restored.deinitialize(count: truncation.restored.count) + restored.deallocate() + } + + var entry = ChainTruncationFFI() + entry.height = truncation.height + entry.txids = UnsafePointer(removed) + entry.txids_count = UInt(truncation.removed.count) + entry.unconfirmed_txids = UnsafePointer(unconfirmed) + entry.unconfirmed_txids_count = UInt(truncation.unconfirmed.count) + entry.restored_outpoints = UnsafePointer(restored) + entry.restored_outpoints_count = UInt(truncation.restored.count) + return withUnsafePointer(to: &entry) { entryPtr in + handler.persistWalletChangesetTruncations( + walletId: walletId, + truncations: entryPtr, + count: 1 + ) + } + } + + /// One committed round carrying a single truncation. + @discardableResult + private func truncationRound( + _ handler: PlatformWalletPersistenceHandler, + _ truncation: Truncation, + success: Bool = true + ) -> Bool { + handler.beginChangeset(walletId: walletId) + let applied = stageTruncation(handler, truncation) + _ = handler.endChangeset(walletId: walletId, success: success && applied) + return applied + } + + /// Stage `spendTxid` mined at `blockHeight`, spending the funded coin + /// and creating output `vout` again, together with the synced height — the + /// part of a round that follows its truncation. The caller brackets it. + private func stageSpendMinedAgain( + _ handler: PlatformWalletPersistenceHandler, + blockHeight: UInt32, + blockHash: Data, + wallet: Data? = nil, + vout: UInt32 = 0 + ) { + let walletId = wallet ?? self.walletId + let name = strdup("Standard { index: 0 }") + let address = strdup("yChangeAddr") + defer { + free(name) + free(address) + } + + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = 0 + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + spendTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + Swift.withUnsafeMutableBytes(of: &record.block_hash) { dst in + blockHash.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = 2 + record.block_height = blockHeight + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + spendTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = vout + utxo.amount = 30_000 + utxo.address = address + utxo.height = blockHeight + utxo.is_confirmed = true + + withUnsafeMutablePointer(to: &input) { inputPtr in + record.input_outpoints = inputPtr + record.input_outpoints_count = 1 + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = blockHeight + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + _ = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + } + + private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + return try? context.fetch(descriptor).first + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try? context.fetch(descriptor).first + } + + private func syncedHeight(_ container: ModelContainer) throws -> UInt32 { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try XCTUnwrap(try ModelContext(container).fetch(descriptor).first).syncedHeight + } + + func testShouldRemoveATransactionOfADroppedBlockAndRestoreTheCoinItSpent() throws { + let (handler, container) = try makeHandler() + try seedSpendMinedAboveTheFork(in: container) + + let applied = truncationRound( + handler, + Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)]) + ) + + XCTAssertTrue(applied) + XCTAssertNil(transaction(container, txid: spendTxid)) + XCTAssertNil(txo(container, txid: spendTxid, vout: 0), "the removed transaction's own output is gone") + XCTAssertNil(txo(container, txid: spendTxid, vout: 1)) + let restored = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(restored.isSpent, "the coin the removed transaction spent is spendable again") + XCTAssertNil(restored.spendingTransaction) + XCTAssertNotNil(transaction(container, txid: fundingTxid), "a transaction the fork keeps stays") + XCTAssertEqual(try syncedHeight(container), 100) + } + + func testShouldKeepAnInstantLockedTransactionUnconfirmedWhenItsBlockIsDropped() throws { + let (handler, container) = try makeHandler() + try seedSpendMinedAboveTheFork(in: container) + + truncationRound(handler, Truncation(height: 100, unconfirmed: [spendTxid])) + + let kept = try XCTUnwrap(transaction(container, txid: spendTxid)) + XCTAssertEqual( + kept.context, + TransactionContextType.instantSend.rawValue, + "unconfirmed, and still InstantSend-locked" + ) + XCTAssertEqual(kept.blockHeight, 0) + XCTAssertNil(kept.blockHash) + let output = try XCTUnwrap(txo(container, txid: spendTxid, vout: 0)) + XCTAssertFalse(output.isConfirmed) + XCTAssertTrue(output.isInstantLocked) + XCTAssertEqual(output.height, 0) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent, "what it spent stays spent") + XCTAssertEqual(coin.spendingTransaction?.txid, spendTxid) + XCTAssertEqual(try syncedHeight(container), 100) + } + + /// The round that removes a transaction also records it in its new + /// block: one row per key, carrying the new block, before and after a + /// restart. + func testShouldStoreATransactionOnceInItsNewBlockWhenTheSameRoundMinesItAgain() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("truncation-mined-again-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let newBlock = Data(repeating: 0x09, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSpendMinedAboveTheFork(in: container) + + // Rust's order: the truncation first, then the round as the + // wallet holds it after the fork. + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(stageTruncation( + handler, + Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)]) + )) + stageSpendMinedAgain(handler, blockHeight: 102, blockHash: newBlock) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + let (_, container) = try makeHandler(url: storeURL) + + let context = ModelContext(container) + let spendTxid = self.spendTxid + let rows = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.txid == spendTxid }) + ) + XCTAssertEqual(rows.count, 1, "the transaction is stored once") + let mined = try XCTUnwrap(rows.first) + XCTAssertEqual(mined.blockHeight, 102) + XCTAssertEqual(mined.blockHash, newBlock) + + let output = try XCTUnwrap( + txo(container, txid: spendTxid, vout: 0), + "the output the new block's record brought back is stored" + ) + XCTAssertEqual(output.height, 102) + XCTAssertFalse(output.isSpent) + XCTAssertNil( + txo(container, txid: spendTxid, vout: 1), + "an output the round did not create again is gone" + ) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent, "spent again, by the transaction in its new block") + XCTAssertEqual(coin.spendingTransaction?.txid, spendTxid) + XCTAssertEqual(try syncedHeight(container), 102) + } + + func testShouldLeaveASyncedHeightBelowTheForkAlone() throws { + let (handler, container) = try makeHandler() + try seedSpendMinedAboveTheFork(in: container) + + truncationRound(handler, Truncation(height: 150)) + + XCTAssertEqual(try syncedHeight(container), 101) + } + + func testShouldRollATruncationBackWithItsRound() throws { + let (handler, container) = try makeHandler() + try seedSpendMinedAboveTheFork(in: container) + + truncationRound( + handler, + Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)]), + success: false + ) + + XCTAssertNotNil(transaction(container, txid: spendTxid)) + XCTAssertNotNil(txo(container, txid: spendTxid, vout: 0)) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.spendingTransaction?.txid, spendTxid) + XCTAssertEqual(try syncedHeight(container), 101) + } + + func testShouldTreatATruncationForAMissingWalletAsASuccessfulNoOp() throws { + let (handler, _) = try makeHandler() + + XCTAssertTrue(truncationRound(handler, Truncation(height: 100, removed: [spendTxid]))) + } + + // MARK: - A transaction two wallets share + + private let otherWalletId = Data(repeating: 0x02, count: 32) + + /// `seedSpendMinedAboveTheFork` with output 1 handed to a second + /// wallet: one transaction row, a coin for each wallet. + private func seedSpendSharedWithAnotherWallet(in container: ModelContainer) throws { + try seedSpendMinedAboveTheFork(in: container) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: otherWalletId, network: .testnet, syncedHeight: 101)) + let outpoint = PersistentTxo.makeOutpoint(txid: spendTxid, vout: 1) + let theirs = try XCTUnwrap( + try context.fetch(FetchDescriptor(predicate: #Predicate { $0.outpoint == outpoint })).first + ) + theirs.walletId = otherWalletId + try context.save() + } + + /// One wallet's whole backlog in one round: the truncation, then the + /// transaction in its new block with that wallet's own output. + private func truncateAndMineAgain( + _ handler: PlatformWalletPersistenceHandler, + wallet: Data, + vout: UInt32, + _ truncation: Truncation, + blockHash: Data + ) { + handler.beginChangeset(walletId: wallet) + XCTAssertTrue(stageTruncation(handler, truncation, wallet: wallet)) + stageSpendMinedAgain(handler, blockHeight: 102, blockHash: blockHash, wallet: wallet, vout: vout) + XCTAssertTrue(handler.endChangeset(walletId: wallet, success: true)) + } + + /// Each wallet's round commits on its own, so the second wallet's + /// truncation arrives after the first wallet has stored the transaction + /// again. It must take only its own part of the old branch. + func testShouldKeepAnotherWalletsReMinedOutputWhenItsOwnTruncationArrivesLater() throws { + let (handler, container) = try makeHandler() + try seedSpendSharedWithAnotherWallet(in: container) + let newBlock = Data(repeating: 0x09, count: 32) + let removal = Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)]) + + truncateAndMineAgain(handler, wallet: walletId, vout: 0, removal, blockHash: newBlock) + truncateAndMineAgain( + handler, wallet: otherWalletId, vout: 1, Truncation(height: 100, removed: [spendTxid]), + blockHash: newBlock + ) + + let first = try XCTUnwrap( + txo(container, txid: spendTxid, vout: 0), + "the first wallet's coin went missing when the second wallet's truncation arrived" + ) + XCTAssertEqual(first.walletId, walletId) + XCTAssertEqual(first.height, 102, "the first wallet's coin lost its new block") + let second = try XCTUnwrap( + txo(container, txid: spendTxid, vout: 1), + "the second wallet's coin went missing" + ) + XCTAssertEqual(second.walletId, otherWalletId) + XCTAssertEqual(second.height, 102, "the second wallet's coin lost its new block") + XCTAssertEqual(try XCTUnwrap(transaction(container, txid: spendTxid)).blockHeight, 102) + } + + /// The same backlog for a transaction the fork keeps: the second + /// wallet's truncation must not return the first wallet's coin, already + /// confirmed in the new block, to unconfirmed. + func testShouldKeepAnotherWalletsReMinedOutputConfirmedWhenItsOwnTruncationKeepsTheTransaction() throws { + let (handler, container) = try makeHandler() + try seedSpendSharedWithAnotherWallet(in: container) + let newBlock = Data(repeating: 0x09, count: 32) + let kept = Truncation(height: 100, unconfirmed: [spendTxid]) + + truncateAndMineAgain(handler, wallet: walletId, vout: 0, kept, blockHash: newBlock) + truncateAndMineAgain(handler, wallet: otherWalletId, vout: 1, kept, blockHash: newBlock) + + let first = try XCTUnwrap(txo(container, txid: spendTxid, vout: 0), "the first wallet's coin went missing") + XCTAssertTrue( + first.isConfirmed, + "the first wallet's coin was returned to unconfirmed by the second wallet's truncation" + ) + XCTAssertEqual(first.height, 102, "the first wallet's coin lost its new block") + let second = try XCTUnwrap(txo(container, txid: spendTxid, vout: 1), "the second wallet's coin went missing") + XCTAssertTrue(second.isConfirmed, "the second wallet's coin stayed unconfirmed after its own round mined it") + XCTAssertEqual(second.height, 102, "the second wallet's coin lost its new block") + let row = try XCTUnwrap(transaction(container, txid: spendTxid)) + XCTAssertEqual(row.context, TransactionContextType.inBlock.rawValue) + XCTAssertEqual(row.blockHeight, 102) + } + + /// A wallet's truncation removes its own part of a shared transaction + /// and leaves the row while another wallet still has a coin from it. + func testShouldLeaveASharedTransactionWhileAnotherWalletStillHoldsACoinFromIt() throws { + let (handler, container) = try makeHandler() + try seedSpendSharedWithAnotherWallet(in: container) + + truncationRound( + handler, + Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)]) + ) + + XCTAssertNil(txo(container, txid: spendTxid, vout: 0), "the truncating wallet's own coin is gone") + XCTAssertNotNil( + txo(container, txid: spendTxid, vout: 1), + "the other wallet's coin went missing before its own truncation arrived" + ) + XCTAssertNotNil(transaction(container, txid: spendTxid), "the row stays while a wallet has a part in it") + + handler.beginChangeset(walletId: otherWalletId) + XCTAssertTrue(stageTruncation(handler, Truncation(height: 100, removed: [spendTxid]), wallet: otherWalletId)) + XCTAssertTrue(handler.endChangeset(walletId: otherWalletId, success: true)) + + XCTAssertNil(txo(container, txid: spendTxid, vout: 1)) + XCTAssertNil(transaction(container, txid: spendTxid), "and goes once no wallet does") + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 7b51fce43d9..7fb86a3bd89 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -68,6 +68,10 @@ final class InvitationPersistenceTests: XCTestCase { // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds + // Chain truncations: the handler wires + // `on_persist_wallet_changeset_truncations_fn` and cuts the + // mirror back in the round that carries them. + | PlatformWalletPersistenceCapabilities.coreChainTruncation XCTAssertEqual( capabilities.version,