From b3904eebd99df72a8fa1ca1487e1d6d0d7b3ff3c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:04:32 +0000 Subject: [PATCH 01/39] fix(wallet): use upstream late transaction accounting corrections Build on rust-dashcore PR #979 and account-local correction/event fixes at ed4c02e119898f1bb510cf79abc8a125a9bc9bea. Cargo metadata --locked validates the pin; wallet integration checks follow with the storage changes. --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7f35d1b352..ec1f52cdde6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1654,7 +1654,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "dash-network", ] @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "async-trait", "chrono", @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "anyhow", "base64-compat", @@ -1815,12 +1815,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "dashcore-rpc-json", "hex", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "dashcore", "grovedb-bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "dashcore-private", "grovedb-bincode", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" [[package]] name = "glob" @@ -4154,7 +4154,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "aes", "async-trait", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index e8727211b7b..cd1abb44ccf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,14 +65,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which From a72737a046edbf1d68292d4826d5df5ae0d00933 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:04:11 +0000 Subject: [PATCH 02/39] fix(swift-sdk): reconcile persisted Core transaction accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repair fully resolved history from durable TXOs at round commit and wallet load, preserve funded asset-lock accounting during context-only recovery, and scope history presentation to the selected wallet. Swift tests require macOS tooling and were not executable on this host. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../Models/PersistentTransaction.swift | 62 ++++- .../PlatformWalletPersistenceHandler.swift | 95 ++++++- .../Core/Views/TransactionDetailView.swift | 45 ++-- .../Core/Views/TransactionListView.swift | 36 +-- .../TransactionAccountingTests.swift | 232 ++++++++++++++++++ 5 files changed, 416 insertions(+), 54 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..780917b101a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -84,7 +84,7 @@ public final class PersistentTransaction { /// replaces it with the real discriminant on touch. Accessors /// treat the sentinel as unknown (no branch fires). public var transactionTypeKind: UInt8 = 0xFF - /// Net amount in duffs (signed: positive=received, negative=sent). + /// Net Core amount in duffs across locally owned TXOs (positive=received, negative=sent). public var netAmount: Int64 /// Fee in duffs (nil if unknown). public var fee: UInt64? @@ -228,6 +228,66 @@ public final class PersistentTransaction { } } + /// Core value movement for one wallet; the stored scalar spans all locally owned TXOs. + public func netAmount(for walletId: Data) -> Int64? { + func owned(_ rows: [PersistentTxo]) -> [PersistentTxo] { + var seen = Set() + return rows.filter { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + && seen.insert($0.outpoint).inserted + } + } + let wallets = Set((inputs + outputs).compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + if wallets.count == 1, wallets.contains(walletId) { return netAmount } + guard pendingInputs.isEmpty else { return nil } + let walletInputs = owned(inputs) + let walletOutputs = owned(outputs) + guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } + return Self.reconciledAccounting( + inputs: walletInputs, ownedOutputAmounts: walletOutputs.map(\.amount), + allOutputsOwned: false, previousDirection: direction, isAssetLock: isAssetLock + )?.netAmount + } + + /// Direction relative to one wallet for transactions shared by multiple local wallets. + public func direction(for walletId: Data) -> UInt32 { + let wallets = Set((inputs + outputs).compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + guard wallets.count > 1, direction != 3, transactionTypeKind != 1, !isAssetLock else { return direction } + let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId } + return spendsOurs ? 1 : 0 + } + + /// Format the wallet's Core value movement in DASH. + public func formattedAmount(for walletId: Data) -> String { + guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } + return String(format: "%@%.8f DASH", amount >= 0 ? "+" : "-", Double(amount.magnitude) / 100_000_000) + } + + static func reconciledAccounting( + inputs: [PersistentTxo], ownedOutputAmounts: [UInt64], + allOutputsOwned: Bool, previousDirection: UInt32, isAssetLock: Bool + ) -> (netAmount: Int64, direction: UInt32)? { + func total(_ amounts: [UInt64]) -> Int64? { + var sum: Int64 = 0 + for amount in amounts { + guard let value = Int64(exactly: amount) else { return nil } + let addition = sum.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + sum = addition.partialValue + } + return sum + } + guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { + return nil + } + let direction: UInt32 + if previousDirection == 3 { direction = 3 } + else if inputs.isEmpty { direction = 0 } + else if isAssetLock || allOutputsOwned { direction = 2 } + else { direction = 1 } + return (received - spent, direction) + } + public var directionName: String { switch direction { case 0: return "Incoming" diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d9ffdea6697..5486d446b23 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -246,6 +246,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] } private var roundIndex: ChangesetRoundIndex? + private var accountingDirty: [Data: PersistentTransaction] = [:] /// Number of persistence rounds committed by this handler, read and /// compared on `serialQueue`. The store reconcile classifies rows off /// this queue and applies the verdicts on it; a round committed in @@ -2538,7 +2539,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.hasBlockPosition = tx.has_block_position let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes - record.direction = tx.direction + // A context-only recovery record has zero accounting; a funded asset lock burns Core value. + let preserveLockAccounting = tx.transaction_type_kind == 6 && tx.net_amount == 0 && !tx.has_fee + && record.netAmount != 0 && !record.inputs.isEmpty + if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) } @@ -2560,8 +2564,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.providerVotingKeyHash = tx.has_provider_voting_key_hash ? withUnsafeBytes(of: tx.provider_voting_key_hash) { Data($0) } : nil - record.netAmount = tx.net_amount - record.fee = tx.has_fee ? tx.fee : nil + if !preserveLockAccounting { + record.netAmount = tx.net_amount + record.fee = tx.has_fee ? tx.fee : nil + } + accountingDirty[record.txid] = record if let labelPtr = tx.label { record.label = String(cString: labelPtr) } @@ -2905,6 +2912,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// (`reconcileHealMissingTxos`), so both writers honour the same /// tombstone precedence and spender-adoption rules. private func drainPendingInputs(into record: PersistentTxo, resolvedWalletId: Data) { + if let parent = record.transaction { accountingDirty[parent.txid] = parent } + if let spender = record.spendingTransaction { accountingDirty[spender.txid] = spender } let pendingRows = pendingInputRows(outpoint: record.outpoint) if !pendingRows.isEmpty { // A tombstone is not an observation — it is a sweep's settled @@ -3037,7 +3046,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spender: PersistentTransaction, inputIndex: UInt32? ) { + accountingDirty[spender.txid] = spender let currentSpender = txo.spendingTransaction + if let currentSpender { accountingDirty[currentSpender.txid] = currentSpender } let verdict = Self.reconcileSpendObservation( currentSpenderTxid: currentSpender?.txid, currentSpenderContext: currentSpender?.context, @@ -3366,6 +3377,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() self.roundIndex = nil + self.accountingDirty.removeAll() self.roundAdvancedFinalityBoundary = false self.inChangeset = false self.drainDeferredBackfills() @@ -3409,6 +3421,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { collectFinalizedSweptTombstones(walletId: walletId) } do { + try reconcileTransactionAccounting(Array(accountingDirty.values)) try backgroundContext.save() committedRoundGeneration &+= 1 SDKLogger.event( @@ -6788,6 +6801,62 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return txids } + /// Repair only fully resolved spends; missing prevouts are not evidence of external ownership. + func reconcileTransactionAccounting( + _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil + ) throws { + for transaction in transactions where !transaction.isDeleted { + guard let transactionNetwork = network + ?? transaction.involvedAccounts.first?.wallet.network + ?? transaction.inputs.first?.account?.wallet.network + ?? transaction.outputs.first?.account?.wallet.network, + let decoded = try? TransactionDecoder.decode( + transaction.transactionData, network: transactionNetwork + ), !decoded.inputs.isEmpty else { continue } + var inputs: [PersistentTxo] = [] + var complete = true + for input in decoded.inputs { + let key = PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) + let row: PersistentTxo? + if let txos { row = txos[key] } + else { row = try fetchTxoRowChecked(outpoint: key) } + guard let row, !row.isDeleted, Self.resolvedWalletId(of: row) != nil else { + complete = false + break + } + inputs.append(row) + } + guard complete else { continue } + var amounts: [UInt64] = [] + var allOutputsOwned = true + let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted }.map(\.vout)) + for (index, output) in decoded.outputs.enumerated() { + // OP_RETURN burns (including asset locks) are not spendable Core outputs. + if output.scriptPubkey.first == 0x6a { continue } + var belongs = ownedVouts.contains(UInt32(index)) + if !belongs, let address = output.address { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) + let owner: PersistentCoreAddress? + if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } + else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } + if let account = owner?.account, account.accountType != 13 { + belongs = true + } + } + if belongs { amounts.append(output.valueDuffs) } + else { allOutputsOwned = false } + } + if let accounting = PersistentTransaction.reconciledAccounting( + inputs: inputs, ownedOutputAmounts: amounts, allOutputsOwned: allOutputsOwned, + previousDirection: transaction.transactionTypeKind == 1 ? 3 : transaction.direction, + isAssetLock: transaction.isAssetLock + ) { + transaction.netAmount = accounting.netAmount + transaction.direction = accounting.direction + } + } + } + /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { SDKLogger.event( @@ -6833,6 +6902,26 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } + do { + let walletIds = Set(wallets.map(\.walletId)) + let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + .filter { row in + row.involvedAccounts.contains { walletIds.contains($0.wallet.walletId) } + || (row.inputs + row.outputs).contains { + Self.resolvedWalletId(of: $0).map { walletIds.contains($0) } == true + } + } + let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + try reconcileTransactionAccounting(transactions, txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) })) + try backgroundContext.save() + } catch { + backgroundContext.rollback() + SDKLogger.event( + "persistence_wallet_load_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("transaction_accounting")], error: error + ) + return (nil, 0, true) + } let restorable = wallets.filter { wallet in wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index c222a532b70..37bd50aedad 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -3,24 +3,15 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction - /// Override amount for asset-lock txs. The wallet's `netAmount` - /// shows ~0 for these (credit output is structurally self-owned), - /// so the list view passes the linked - /// `PersistentAssetLock.amountDuffs`. `nil` for non-asset-lock - /// rows OR consumed asset locks whose tracking row was cleaned - /// up after successful identity registration. + var walletId: Data? = nil + private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } + private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @State private var showCopiedAlert = false - /// Amount label rendered prominently at the top of the sheet. - /// Same precedence rule as the row: asset-lock duffs when we - /// have them, else an explicit "amount unknown" label for the - /// historical-asset-lock case (rather than the misleading - /// `+0.00000000 DASH` from `transaction.formattedAmount`). - /// `nil` for a payload-only provider special tx — a ProRegTx - /// observed via the owner/voting keys moves no wallet balance, - /// and `+0.00000000 DASH` reads as a broken zero-value receive. + /// Show the lock's funding amount, or the wallet's Core value movement for ordinary transactions. private var displayAmount: String? { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { @@ -29,10 +20,10 @@ struct TransactionDetailView: View { } return "Asset Lock (amount unknown)" } - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return nil } - return transaction.formattedAmount + return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount } private var typeDescription: String { @@ -42,11 +33,13 @@ struct TransactionDetailView: View { || transaction.isProviderSpecial { return transaction.displayDirection } - switch transaction.netAmount { - case let amount where amount > 0: + switch direction { + case 0: return "Received" - case let amount where amount < 0: + case 1: return "Sent" + case 3: + return "CoinJoin" default: return "Self-Transfer" } @@ -56,10 +49,10 @@ struct TransactionDetailView: View { if transaction.isAssetLock { return "lock.fill" } if transaction.isAssetUnlock { return "lock.open.fill" } if transaction.isProviderSpecial { return "server.rack" } - switch transaction.netAmount { - case let amount where amount > 0: + switch direction { + case 0: return "arrow.down.circle.fill" - case let amount where amount < 0: + case 1: return "arrow.up.circle.fill" default: return "arrow.triangle.2.circlepath" @@ -73,10 +66,10 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.netAmount { - case let amount where amount > 0: + switch direction { + case 0: return .green - case let amount where amount < 0: + case 1: return .red default: return .blue @@ -208,7 +201,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, transaction.netAmount < 0 { + if let fee = formattedFee, netAmount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index f185f78c20f..4e0033ff220 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -169,6 +169,7 @@ struct TransactionListView: View { .sheet(item: $selectedTransaction) { transaction in TransactionDetailView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmountByTxid[transaction.txidHex] ) } @@ -202,6 +203,7 @@ struct TransactionListView: View { } label: { TransactionRowView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmounts[transaction.txidHex], dashpayPayment: payment, dashpayCounterpartyName: payment.map { @@ -219,12 +221,10 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction - /// Override amount displayed for asset-lock rows. The wallet's - /// `netAmount` shows ~0 for these (credit output is structurally - /// self-owned), so the list view passes the linked - /// `PersistentAssetLock.amountDuffs` — the actual L1 DASH burned - /// to mint platform credits. `nil` for non-asset-lock rows or - /// when no matching row was found. + var walletId: Data? = nil + private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } + private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in /// `TransactionListView`. When set, the row shows the contact context @@ -254,7 +254,7 @@ struct TransactionRowView: View { // keys in the payload — so the self-transfer arrows would lie. if transaction.isProviderSpecial { return "server.rack" } // direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin - switch transaction.direction { + switch direction { case 0: return "arrow.down.circle.fill" case 1: return "arrow.up.circle.fill" case 2: return "arrow.triangle.2.circlepath" @@ -278,7 +278,7 @@ struct TransactionRowView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.direction { + switch direction { case 0: return .green case 1, 2: return .red case 3: return .blue @@ -400,7 +400,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, transaction.netAmount < 0 { + if let fee = transaction.fee, netAmount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) @@ -417,19 +417,7 @@ struct TransactionRowView: View { return String(format: "%.8f DASH", dash) } - /// Amount label for the row. For asset-lock txs we substitute - /// the linked `PersistentAssetLock.amountDuffs` (the L1 DASH - /// actually burned to mint platform credits); the wallet's - /// `netAmount` is ~0 for these because the credit output is a - /// self-owned address. Rendered as a negative (DASH leaving L1). - /// - /// If we know the row is an asset lock but the linked - /// `PersistentAssetLock` is missing (e.g. a historical record - /// from before the `Consumed`-status retention change shipped), - /// we render "Asset Lock (amount unknown)" instead of falling - /// through to `transaction.formattedAmount` — that would say - /// `+0.00000000 DASH`, which is misleading for a row the user - /// can see was a funding tx. + /// Keep asset-lock funding amounts distinct from the Core debit, which includes fees. private var displayAmount: String { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { @@ -443,9 +431,9 @@ struct TransactionRowView: View { // put the tx kind in the amount slot instead. A provider tx // that DOES move value (e.g. this wallet funded the collateral) // falls through and shows the real signed amount. - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return transaction.providerSpecialName ?? transaction.transactionType } - return transaction.formattedAmount + return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift new file mode 100644 index 00000000000..0adad2d548f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -0,0 +1,232 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +@MainActor +final class TransactionAccountingTests: XCTestCase { + private func input(_ value: UInt64, wallet: UInt8 = 1) -> PersistentTxo { + let parent = PersistentTransaction(txid: Data(repeating: wallet, count: 32), transactionData: Data()) + let txo = PersistentTxo(transaction: parent, vout: 0, amount: value, address: "", height: 1) + txo.walletId = Data(repeating: wallet, count: 32) + return txo + } + + func testShouldRepairSpentInputsWithoutChangingBalanceOrConsumption() { + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let spent = input(100) + spent.isSpent = true + let result = PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [40], allOutputsOwned: false, previousDirection: 0, isAssetLock: false + ) + XCTAssertEqual(result?.netAmount, -60) + XCTAssertEqual(result?.direction, 1) + XCTAssertTrue(spent.isSpent) + XCTAssertEqual(tx.netAmount, 40) // Computing accounting has no storage side effects. + } + + func testShouldKeepInternalAndCoinJoinSemanticsWithNegativeNet() { + let spent = input(100) + XCTAssertEqual(PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: true, previousDirection: 0, isAssetLock: false + )?.direction, 2) + let lock = PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 2, isAssetLock: true + ) + XCTAssertEqual(lock?.netAmount, -100) + XCTAssertEqual(lock?.direction, 2) + XCTAssertEqual(PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: false, previousDirection: 3, isAssetLock: false + )?.direction, 3) + } + + func testShouldRejectOverflowInsteadOfWrappingHistory() { + XCTAssertNil(PersistentTransaction.reconciledAccounting( + inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false + )) + } + + func testShouldScopeNetToWalletAndDeduplicateOutpoints() { + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) + let first = input(100) + let other = input(200, wallet: 2) + tx.inputs = [first, first, other] + XCTAssertEqual(tx.netAmount(for: first.walletId), -100) + XCTAssertEqual(tx.netAmount(for: other.walletId), -200) + } + private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40) -> Data { + var bytes = Data([2, 0, 0, 0, UInt8(inputs.count)]) + for txid in inputs { + bytes.append(txid) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255]) + } + bytes.append(1) + withUnsafeBytes(of: outputValue.littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: [0, 0, 0, 0, 0]) + return bytes + } + + func testShouldBackfillExistingHistoryOnLoadAndRemainIdempotent() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction(txid: walletId, transactionData: Data()) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: 0, netAmount: 40 + ) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) + coin.walletId = walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(funding) + context.insert(spender) + context.insert(coin) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + XCTAssertFalse(handler.loadWalletList().errored) + let fresh = ModelContext(container) + let repaired = try XCTUnwrap(fresh.fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(repaired.netAmount, -100) + XCTAssertEqual(repaired.direction, 1) + XCTAssertTrue(try XCTUnwrap(fresh.fetch(FetchDescriptor()).first).isSpent) + } + + func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let coin = input(100) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), + transactionData: serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)]), + direction: 1, netAmount: -200 + ) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.first { $0.txid == spender.txid }?.netAmount, -200) + } + + private func persist( + _ handler: PlatformWalletPersistenceHandler, walletId: Data, txid: Data, + bytes: Data? = nil, net: Int64 = 0, kind: UInt8 = 0, + inputTxids: [Data] = [], outputs: [(Data, UInt64)] = [] + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("") + defer { free(name); free(address) } + var record = TransactionRecordFFI() + withUnsafeMutableBytes(of: &record.txid) { $0.copyBytes(from: txid) } + record.context = 2 + record.net_amount = net + record.transaction_type_kind = kind + var inputs = inputTxids.map { txid -> OutPointFFI in + var result = OutPointFFI() + withUnsafeMutableBytes(of: &result.txid) { $0.copyBytes(from: txid) } + return result + } + var txos = outputs.map { txid, amount -> UtxoEntryFFI in + var result = UtxoEntryFFI() + withUnsafeMutableBytes(of: &result.outpoint.txid) { $0.copyBytes(from: txid) } + result.amount = amount + result.address = address + return result + } + var raw = Array(bytes ?? Data()) + handler.beginChangeset(walletId: walletId) + raw.withUnsafeMutableBufferPointer { rawPtr in + inputs.withUnsafeMutableBufferPointer { inputPtr in + txos.withUnsafeMutableBufferPointer { txoPtr in + record.tx_data = rawPtr.baseAddress + record.tx_data_len = UInt(rawPtr.count) + record.input_outpoints = inputPtr.baseAddress + record.input_outpoints_count = UInt(inputPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = bytes == nil ? 0 : 1 + account.utxos_added = txoPtr.baseAddress + account.utxos_added_count = UInt(txoPtr.count) + withUnsafeMutablePointer(to: &account) { accountPtr in + var changeset = WalletChangeSetFFI() + changeset.accounts = accountPtr + changeset.accounts_count = 1 + withUnsafePointer(to: &changeset) { ptr in + XCTAssertTrue(handler.persistWalletChangeset(walletId: walletId, changeset: ptr)) + } + } + } + } + } + } + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + func testShouldRepairLateInputAndLateOutputInSeparateAtomicRounds() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let spenderId = Data(repeating: 3, count: 32) + persist(handler, walletId: walletId, txid: spenderId, + bytes: serializedSpend(inputs: [walletId]), net: 40, inputTxids: [walletId]) + persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) + persist(handler, walletId: walletId, txid: spenderId, outputs: [(spenderId, 40)]) + let context = ModelContext(container) + let row = try XCTUnwrap(context.fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -60) + XCTAssertEqual(row.direction, 2) + XCTAssertEqual(row.inputs.count, 1) + XCTAssertEqual(row.outputs.count, 1) + XCTAssertTrue(try XCTUnwrap(row.inputs.first).isSpent) + } + + func testShouldPreserveFundedAssetLockAccountingDuringSyntheticReplayWithMissingPrevout() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0) + let spender = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) + spender.transactionTypeKind = 6 + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6, inputTxids: [walletId]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200) + XCTAssertEqual(row.direction, 2) + XCTAssertEqual(row.context, 2) + } + + func testShouldRepairNoChangeAssetLockToFullCoreDebit() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let spenderId = Data(repeating: 3, count: 32) + persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) + persist(handler, walletId: walletId, txid: spenderId, + bytes: serializedSpend(inputs: [walletId], outputValue: 0), kind: 6, inputTxids: [walletId]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -100) + XCTAssertEqual(row.direction, 2) + XCTAssertTrue(row.isAssetLock) + } + +} From c4a3be44fbdd0e8e4cbd36980e26970adf9770a3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:12:48 +0000 Subject: [PATCH 03/39] fix(swift-sdk): exclude contact TXOs from history accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use one ownership predicate for persisted inputs, outputs, and scoped history amounts, including legacy watch-only TXOs. Preserve accountless rows with known wallet ownership. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../Models/PersistentTransaction.swift | 17 +++-- .../PlatformWalletPersistenceHandler.swift | 15 +++-- .../TransactionAccountingTests.swift | 65 +++++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 780917b101a..0fe3efe8f2e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -233,12 +233,15 @@ public final class PersistentTransaction { func owned(_ rows: [PersistentTxo]) -> [PersistentTxo] { var seen = Set() return rows.filter { - PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) + && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId && seen.insert($0.outpoint).inserted } } - let wallets = Set((inputs + outputs).compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) - if wallets.count == 1, wallets.contains(walletId) { return netAmount } + let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } + if wallets.count == 1, wallets.contains(walletId), !hasUnownedTxos { return netAmount } guard pendingInputs.isEmpty else { return nil } let walletInputs = owned(inputs) let walletOutputs = owned(outputs) @@ -251,9 +254,13 @@ public final class PersistentTransaction { /// Direction relative to one wallet for transactions shared by multiple local wallets. public func direction(for walletId: Data) -> UInt32 { - let wallets = Set((inputs + outputs).compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) guard wallets.count > 1, direction != 3, transactionTypeKind != 1, !isAssetLock else { return direction } - let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId } + let spendsOurs = inputs.contains { + PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) + && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + } return spendsOurs ? 1 : 0 } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 5486d446b23..0410f6ef794 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -105,6 +105,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return wallet?.walletId } + /// Contact watch-only TXOs do not belong to the wallet tracking their addresses. + static func isWalletOwnedTxo(_ txo: PersistentTxo) -> Bool { + resolvedWalletId(of: txo) != nil + && txo.account?.accountType != dashpayExternalAccountTypeTag + && txo.coreAddress?.account?.accountType != dashpayExternalAccountTypeTag + } + static func walletOwnsTransaction( walletId: Data, transaction: PersistentTransaction @@ -2541,7 +2548,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes // A context-only recovery record has zero accounting; a funded asset lock burns Core value. let preserveLockAccounting = tx.transaction_type_kind == 6 && tx.net_amount == 0 && !tx.has_fee - && record.netAmount != 0 && !record.inputs.isEmpty + && record.netAmount != 0 && record.inputs.contains(where: Self.isWalletOwnedTxo) if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) @@ -6820,7 +6827,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let row: PersistentTxo? if let txos { row = txos[key] } else { row = try fetchTxoRowChecked(outpoint: key) } - guard let row, !row.isDeleted, Self.resolvedWalletId(of: row) != nil else { + guard let row, !row.isDeleted, Self.isWalletOwnedTxo(row) else { complete = false break } @@ -6829,7 +6836,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard complete else { continue } var amounts: [UInt64] = [] var allOutputsOwned = true - let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted }.map(\.vout)) + let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted && Self.isWalletOwnedTxo($0) }.map(\.vout)) for (index, output) in decoded.outputs.enumerated() { // OP_RETURN burns (including asset locks) are not spendable Core outputs. if output.scriptPubkey.first == 0x6a { continue } @@ -6839,7 +6846,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let owner: PersistentCoreAddress? if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } - if let account = owner?.account, account.accountType != 13 { + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { belongs = true } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 0adad2d548f..ee419f93954 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -229,4 +229,69 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertTrue(row.isAssetLock) } + func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let coin = input(100) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: 2, netAmount: -60 + ) + coin.spendingTransaction = spender + let contactOutput = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) + contactOutput.walletId = walletId + contactOutput.account = contactAccount + context.insert(coin) + context.insert(spender) + context.insert(contactOutput) + try context.save() + XCTAssertEqual(spender.netAmount(for: walletId), -100) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100) + XCTAssertEqual(row.direction, 1) + XCTAssertEqual(row.netAmount(for: walletId), -100) + } + + func testShouldNotTreatPersistedContactInputAsOurFunding() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let coin = input(100) + coin.account = contactAccount + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: 0, netAmount: 40 + ) + coin.spendingTransaction = spender + let received = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) + received.walletId = walletId + context.insert(coin) + context.insert(spender) + context.insert(received) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, 40) + XCTAssertEqual(row.direction, 0) + XCTAssertEqual(row.netAmount(for: walletId), 40) + } + } From 60e1f6decb63fc08f6cb3a64c84b46ac9a4c422a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:15:35 +0000 Subject: [PATCH 04/39] fix(wallet): reconcile persisted Core transaction accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index historical inputs to repair late funding and existing SQLite history, preserve known ownership across partial replay, and project corrected bridge topology without double-counting account snapshots. Keep unknown credit verdicts from inventing spends or resurrecting coins. Co-Authored-By: Codex GPT-6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../V019__core_transaction_accounting.rs | 14 + .../src/sqlite/migrations.rs | 4 + .../src/sqlite/persister.rs | 21 +- .../src/sqlite/schema/core_history.rs | 313 +++++++++++++ .../src/sqlite/schema/core_state.rs | 425 +++++++++++++++++- .../src/sqlite/schema/mod.rs | 1 + .../tests/sqlite_migrations.rs | 32 +- .../tests/sqlite_schema_pinning.rs | 4 +- .../src/changeset/changeset.rs | 18 + .../src/changeset/core_bridge.rs | 49 +- 10 files changed, 852 insertions(+), 29 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs diff --git a/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs new file mode 100644 index 00000000000..b92003e8545 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs @@ -0,0 +1,14 @@ +//! Index raw inputs so late output ownership can repair the spending history. + +pub fn migration() -> String { + "CREATE TABLE core_transaction_inputs ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + outpoint BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid, outpoint), + FOREIGN KEY (wallet_id, txid) REFERENCES core_transactions(wallet_id, txid) ON DELETE CASCADE + ); + CREATE INDEX idx_core_transaction_inputs_outpoint + ON core_transaction_inputs(wallet_id, outpoint);" + .to_owned() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index b009d6ddeae..e152a68a97a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -45,6 +45,7 @@ fn run_with_runner( tx, registration_sql: hook_sql(8), pool_sql: hook_sql(11), + history_sql: hook_sql(19), }; // Grouped reports never claim that rolled-back migrations were applied. let report = runner.set_grouped(true).run(&mut driver)?; @@ -59,6 +60,7 @@ struct MigrationTransaction<'conn> { tx: rusqlite::Transaction<'conn>, registration_sql: String, pool_sql: String, + history_sql: String, } impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { @@ -75,6 +77,8 @@ impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { legacy_v008::backfill_registrations(&self.tx)?; } else if query == self.pool_sql { legacy_v008::convert_pools(&self.tx)?; + } else if query == self.history_sql { + super::schema::core_history::migrate(&self.tx)?; } count += 1; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index fd865405121..c08a5a0ee52 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -2402,16 +2402,17 @@ mod tests { // Not rehydrated by `load()`, but read on demand by a production // entry point, so the state is reachable rather than abandoned. const READ_BY_A_DEDICATED_API: &[&str] = &[ - "dpns_name_states", // get_dpns_name_state - "meta_contact", // the kv object store - "meta_data_versions", // schema::versions - "meta_global", // the kv object store - "meta_identity", // the kv object store - "meta_platform_address", // the kv object store - "meta_store_generation", // schema::versions - "meta_token", // the kv object store - "meta_wallet", // the kv object store - "tracked_masternodes", // load_tracked_masternodes + "core_transaction_inputs", // core_history::apply repairs indexed consumers + "dpns_name_states", // get_dpns_name_state + "meta_contact", // the kv object store + "meta_data_versions", // schema::versions + "meta_global", // the kv object store + "meta_identity", // the kv object store + "meta_platform_address", // the kv object store + "meta_store_generation", // schema::versions + "meta_token", // the kv object store + "meta_wallet", // the kv object store + "tracked_masternodes", // load_tracked_masternodes ]; const INFRASTRUCTURE: &[&str] = &["refinery_schema_history"]; // `load()` rehydrates these only with the `shielded` feature on, so diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs new file mode 100644 index 00000000000..c710ada2228 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -0,0 +1,313 @@ +//! Wallet accounting repaired from historical owned outputs, independent of live UTXOs. + +use std::collections::{BTreeMap, HashSet}; + +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, ScriptBuf, Txid}; +use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Transaction}; + +use super::{blob, core_state, wallets}; +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::LoadCtx; +use crate::sqlite::util::safe_cast::i64_to_u64; + +/// Preserve proven ownership when a partial account snapshot replaces the wallet record. +pub(super) fn preserve_known_details( + tx: &Transaction<'_>, + wallet_id: &WalletId, + incoming: &TransactionRecord, +) -> Result { + let mut merged = incoming.clone(); + let Some(previous) = + core_state::get_tx_record(tx, wallet_id, &incoming.txid, &LoadCtx::strict())? + else { + return Ok(merged); + }; + if previous.transaction != incoming.transaction { + return Err(WalletStorageError::blob_decode( + "same transaction id has different raw transaction bodies", + )); + } + let mut inputs: BTreeMap<_, _> = previous + .input_details + .into_iter() + .map(|d| (d.index, d)) + .collect(); + for detail in &incoming.input_details { + inputs.insert(detail.index, detail.clone()); + } + let mut outputs: BTreeMap<_, _> = previous + .output_details + .into_iter() + .map(|d| (d.index, d)) + .collect(); + for detail in &incoming.output_details { + let keep_previous = outputs + .get(&detail.index) + .is_some_and(|old| matches!(old.role, OutputRole::Received | OutputRole::Change)) + && !matches!(detail.role, OutputRole::Received | OutputRole::Change); + if !keep_previous { + outputs.insert(detail.index, detail.clone()); + } + } + merged.input_details = inputs.into_values().collect(); + merged.output_details = outputs.into_values().collect(); + Ok(merged) +} + +/// Index raw inputs independently of when their ownership becomes known. +pub(super) fn index_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) VALUES (?1, ?2, ?3)", + )?; + for input in &record.transaction.input { + stmt.execute(params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode_outpoint(&input.previous_output)?, + ])?; + } + Ok(()) +} + +/// Repair changed records and consumers of newly materialized historical outputs. +pub(super) fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + let mut affected: HashSet = cs.records.iter().map(|r| r.txid).collect(); + let mut consumers = tx.prepare_cached( + "SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + for utxo in cs.new_utxos.iter().chain(&cs.spent_utxos) { + affected.insert(utxo.outpoint.txid); + let mut rows = consumers.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(&utxo.outpoint)? + ])?; + while let Some(row) = rows.next()? { + let bytes: Vec = row.get(0)?; + affected.insert(Txid::from_slice(&bytes)?); + } + } + if affected.is_empty() { + return Ok(()); + } + let network = network(tx, wallet_id)?; + for txid in affected { + repair_record(tx, wallet_id, &txid, network)?; + } + Ok(()) +} + +fn network( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let label: String = tx.query_row( + "SELECT network FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |r| r.get(0), + )?; + wallets::parse_network(&label) + .ok_or_else(|| WalletStorageError::blob_decode("wallets.network is unknown")) +} + +fn owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let mut stmt = tx.prepare_cached("SELECT value, length(script), script FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0")?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(outpoint)? + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let value = i64_to_u64("core_utxos.value", row.get(0)?)?; + blob::check_size(row.get(1)?)?; + let script: Vec = row.get(2)?; + if contact_only_script(tx, wallet_id, &script)? { + return Ok(None); + } + let address = Address::from_script(&ScriptBuf::from_bytes(script), network)?; + Ok(Some((value, address))) +} + +fn contact_only_script( + tx: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result { + Ok(tx.query_row( + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) AND NOT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", + params![wallet_id.as_slice(), script], |r| r.get(0))?) +} + +fn repair_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, + network: dashcore::Network, +) -> Result<(), WalletStorageError> { + let Some(mut record) = core_state::get_tx_record(tx, wallet_id, txid, &LoadCtx::strict())? + else { + return Ok(()); + }; + let original = blob::encode(&record)?; + let mut inputs = BTreeMap::new(); + for detail in record.input_details.drain(..) { + if !contact_only_script(tx, wallet_id, detail.address.script_pubkey().as_bytes())? { + inputs.insert(detail.index, detail); + } + } + for (index, input) in record.transaction.input.iter().enumerate() { + if let Some((value, address)) = + owned_output(tx, wallet_id, &input.previous_output, network)? + { + inputs.insert( + index as u32, + InputDetail { + index: index as u32, + value, + address, + }, + ); + // Stale mempool rows cannot overrule a later sweep's release. + if !matches!(record.context, TransactionContext::Mempool) { + tx.execute( + "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)? + ], + )?; + } + } + } + let mut outputs = BTreeMap::new(); + for mut detail in record.output_details.drain(..) { + if let Some(address) = &detail.address { + if contact_only_script(tx, wallet_id, address.script_pubkey().as_bytes())? { + detail.role = OutputRole::Sent; + } + } + outputs.insert(detail.index, detail); + } + for (index, output) in record.transaction.output.iter().enumerate() { + let index = index as u32; + if let Some((_, address)) = owned_output( + tx, + wallet_id, + &OutPoint { + txid: *txid, + vout: index, + }, + network, + )? { + let role = outputs.get(&index).map_or(OutputRole::Received, |d| { + if d.role == OutputRole::Change { + OutputRole::Change + } else { + OutputRole::Received + } + }); + outputs.insert( + index, + OutputDetail { + index, + role, + address: Some(address), + value: output.value, + }, + ); + } + } + // Empty metadata is not accounting evidence (e.g. confirmation-only placeholders). + if inputs.is_empty() && outputs.is_empty() { + return Ok(()); + } + let received: i128 = outputs + .values() + .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) + .map(|d| i128::from(d.value)) + .sum(); + let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); + record.net_amount = i64::try_from(received - spent).map_err(|_| { + WalletStorageError::blob_decode("wallet transaction net amount exceeds i64") + })?; + let has_ours = outputs + .values() + .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); + let has_external = record + .transaction + .output + .iter() + .enumerate() + .any(|(i, output)| { + !output.script_pubkey.is_op_return() + && !outputs.get(&(i as u32)).is_some_and(|d| { + matches!( + d.role, + OutputRole::Received | OutputRole::Change | OutputRole::Unspendable + ) + }) + }); + record.direction = if record.transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if inputs.is_empty() { + TransactionDirection::Incoming + } else if !has_external && (has_ours || record.transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + }; + record.input_details = inputs.into_values().collect(); + record.output_details = outputs.into_values().collect(); + let repaired = blob::encode(&record)?; + if repaired != original { + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + repaired, + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ], + )?; + } + Ok(()) +} + +/// Backfill the input index and correct existing history in the migration transaction. +pub(crate) fn migrate(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; + let wallet_id: Vec = row.get(1)?; + let wallet_id = super::id32("core_transactions.wallet_id", &wallet_id)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid: Vec = row.get(3)?; + let txid = Txid::from_slice(&txid)?; + if let Some(record) = core_state::get_tx_record(tx, &wallet_id, &txid, &LoadCtx::strict())? + { + index_record(tx, &wallet_id, &record)?; + repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?)?; + } + } + Ok(()) +} 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 77d4808ed75..79d2cc1bbeb 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 @@ -10,6 +10,7 @@ use dashcore::ephemerealdata::chain_lock::ChainLock; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; +use platform_wallet::changeset::changeset::UtxoCreditVerdict; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -92,13 +93,14 @@ pub fn apply( finalized = excluded.finalized, \ record_blob = excluded.record_blob", )?; - for record in &cs.records { + for incoming in &cs.records { + let record = super::core_history::preserve_known_details(tx, wallet_id, incoming)?; let block_info = record.block_info(); let height = block_info.map(|b| i64::from(b.height())); let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); let block_time = block_info.map(|b| i64::from(b.timestamp())); let finalized = block_info.is_some(); - let payload = blob::encode(record)?; + let payload = blob::encode(&record)?; stmt.execute(params![ wallet_id.as_slice(), AsRef::<[u8]>::as_ref(&record.txid), @@ -108,6 +110,7 @@ pub fn apply( finalized, payload, ])?; + super::core_history::index_record(tx, wallet_id, &record)?; } } // `addresses_derived` is intentionally NOT persisted here — the pool @@ -144,7 +147,24 @@ pub fn apply( refresh the record itself to update its confirmation height" ); } - execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, false)?; + let spent = match cs.utxo_credit_verdicts.get(&utxo.outpoint) { + Some(UtxoCreditVerdict::ObservedSpent { .. } | UtxoCreditVerdict::Doomed) => true, + Some(UtxoCreditVerdict::Uncredited) => { + let prior_spent: Option = tx + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), blob::encode_outpoint(&utxo.outpoint)?], + |row| row.get(0), + ) + .optional()?; + let Some(prior_spent) = prior_spent else { + continue; + }; + prior_spent + } + None => false, + }; + execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, spent)?; } } if !cs.spent_utxos.is_empty() { @@ -227,6 +247,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } + super::core_history::apply(tx, wallet_id, cs)?; return Ok(()); } @@ -390,6 +411,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } + super::core_history::apply(tx, wallet_id, cs)?; Ok(()) } @@ -1393,6 +1415,403 @@ mod tests { } } + #[test] + fn should_repair_history_when_spent_funding_arrives_late() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAB; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let funding = sample_utxo(Txid::from_byte_array([0x31; 32]), 100, true); + let mut spending = transaction_record( + Txid::from_byte_array([0x32; 32]), + TransactionContext::InBlock(BlockInfo::new(101, BlockHash::all_zeros(), 123)), + ); + spending.transaction.input.push(dashcore::TxIn { + previous_output: funding.outpoint, + script_sig: dashcore::ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::new(), + }); + spending.transaction.output.push(TxOut { + value: 90_000, + script_pubkey: funding.txout.script_pubkey.clone(), + }); + spending.txid = spending.transaction.txid(); + spending.net_amount = 90_000; + let mut change = sample_utxo(spending.txid, 101, true); + change.txout.value = 90_000; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spending.clone()], + new_utxos: vec![change], + ..Default::default() + }, + ) + .unwrap(); + let other_wallet = [0xCD; 32]; + tx.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&other_wallet[..]], + ) + .unwrap(); + apply( + &tx, + &other_wallet, + &CoreChangeSet { + new_utxos: vec![funding.clone()], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + get_tx_record(&tx, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .net_amount, + 90_000, + "another wallet's funding must not affect this history" + ); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![funding.clone()], + ..Default::default() + }, + ) + .unwrap(); + let repaired = get_tx_record(&tx, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, -60_000); + assert_eq!(repaired.direction, TransactionDirection::Internal); + assert_eq!(repaired.input_details.len(), 1); + assert_eq!(repaired.block_info().unwrap().height(), 101); + let spent: bool = tx + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert!(spent, "late funding must not resurrect the spent coin"); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spending.clone()], + new_utxos: vec![funding], + ..Default::default() + }, + ) + .unwrap(); + let replayed = get_tx_record(&tx, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(replayed.net_amount, -60_000); + assert_eq!(replayed.input_details.len(), 1); + } + + #[test] + fn should_repair_existing_history_during_migration() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAC; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let funding = sample_utxo(Txid::from_byte_array([0x41; 32]), 100, true); + let mut spending = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + spending.transaction.input.push(dashcore::TxIn { + previous_output: funding.outpoint, + script_sig: dashcore::ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::new(), + }); + spending.txid = spending.transaction.txid(); + spending.net_amount = 0; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spending.clone()], + new_utxos: vec![funding.clone()], + ..Default::default() + }, + ) + .unwrap(); + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + blob::encode(&spending).unwrap(), + &wallet_id[..], + AsRef::<[u8]>::as_ref(&spending.txid) + ], + ) + .unwrap(); + tx.execute_batch("DROP TABLE IF EXISTS core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + tx.commit().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let repaired = get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, -150_000); + assert_eq!(repaired.input_details.len(), 1); + let spent: bool = conn + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert!( + !spent, + "a stale mempool attempt cannot undo a released coin during migration" + ); + crate::sqlite::migrations::run(&mut conn).unwrap(); + assert_eq!( + get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .net_amount, + -150_000 + ); + } + + #[test] + fn should_roll_back_history_migration_on_corrupt_record() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute_batch("DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + let wallet_id = [0xADu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![&wallet_id[..], &[0u8;32][..], &[0xffu8][..]]).unwrap(); + assert!(crate::sqlite::migrations::run(&mut conn).is_err()); + let tables: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE name = 'core_transaction_inputs'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tables, 0); + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(version, 18); + } + + #[test] + fn should_keep_uncredited_outputs_spent() { + use platform_wallet::changeset::changeset::UtxoCreditVerdict; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAEu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + for (marker, verdict) in [ + (1, UtxoCreditVerdict::ObservedSpent { height: 101 }), + (2, UtxoCreditVerdict::Doomed), + ] { + let utxo = sample_utxo(Txid::from_byte_array([marker; 32]), 100, true); + let outpoint = utxo.outpoint; + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo], + utxo_credit_verdicts: [(outpoint, verdict)].into(), + ..Default::default() + }, + ) + .unwrap(); + let spent: bool = tx + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(&outpoint).unwrap()], + |row| row.get(0), + ) + .unwrap(); + assert!(spent, "engine did not credit {verdict:?}"); + } + } + + #[test] + fn should_exclude_historical_contact_outputs_from_accounting() { + use key_wallet::managed_account::transaction_record::{OutputDetail, OutputRole}; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAFu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let mut record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + let mut output = sample_utxo(record.txid, 0, false); + record.transaction.output = vec![output.txout.clone()]; + record.txid = record.transaction.txid(); + record.output_details = vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(output.address.clone()), + value: output.value(), + }]; + record.net_amount = output.value() as i64; + output.outpoint.txid = record.txid; + conn.execute("INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", params![&wallet_id[..], output.txout.script_pubkey.as_bytes()]).unwrap(); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + new_utxos: vec![output], + ..Default::default() + }, + ) + .unwrap(); + let repaired = get_tx_record(&tx, &wallet_id, &record.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, 0); + assert_eq!(repaired.output_details[0].role, OutputRole::Sent); + } + + #[test] + fn should_preserve_known_inputs_when_replay_has_no_historical_txo() { + use key_wallet::managed_account::transaction_record::InputDetail; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB0u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let funding = sample_utxo(Txid::from_byte_array([0x42; 32]), 100, true); + let mut record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + record.transaction.input.push(dashcore::TxIn { + previous_output: funding.outpoint, + script_sig: dashcore::ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::new(), + }); + record.txid = record.transaction.txid(); + record.input_details = vec![InputDetail { + index: 0, + value: funding.value(), + address: funding.address, + }]; + record.net_amount = -150_000; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + ..Default::default() + }, + ) + .unwrap(); + record.input_details.clear(); + record.net_amount = 0; + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + ..Default::default() + }, + ) + .unwrap(); + let repaired = get_tx_record(&tx, &wallet_id, &record.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, -150_000); + assert_eq!(repaired.input_details.len(), 1); + } + + #[test] + fn should_preserve_spendability_for_unknown_credit_verdicts() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB1u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let utxo = sample_utxo(Txid::from_byte_array([0x43; 32]), 100, true); + let cs = CoreChangeSet { + new_utxos: vec![utxo.clone()], + utxo_credit_verdicts: [(utxo.outpoint, UtxoCreditVerdict::Uncredited)].into(), + ..Default::default() + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + let count: i64 = tx + .query_row("SELECT count(*) FROM core_utxos", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "unknown credit cannot create a spendable coin"); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + let spent: bool = tx + .query_row("SELECT spent FROM core_utxos", [], |r| r.get(0)) + .unwrap(); + assert!(!spent, "unknown credit cannot invent a spend"); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + spent_utxos: vec![utxo], + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + let spent: bool = tx + .query_row("SELECT spent FROM core_utxos", [], |r| r.get(0)) + .unwrap(); + assert!(spent, "unknown credit cannot clear a prior spend"); + } + fn sample_chain_lock(height: u32) -> ChainLock { ChainLock { block_height: height, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 88bff87e377..ad4bdbb387b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -10,6 +10,7 @@ pub mod accounts; pub mod asset_locks; pub mod blob; pub mod contacts; +pub(crate) mod core_history; pub mod core_pool; pub mod core_state; pub mod dashpay; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index e341aa0df0d..7622036fc04 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -555,6 +555,13 @@ fn tc046_v014_purges_legacy_empty_script_spent_utxos() { /// must survive untouched. #[test] fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { + use dashcore::hashes::Hash; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use platform_wallet_storage::sqlite::schema::blob::encode; use rusqlite::params; let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); @@ -572,11 +579,28 @@ fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { params![wallet_id.as_slice()], ) .expect("insert wallet"); - let live_txid = [7u8; 32]; + let record = TransactionRecord::new( + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::IdentityRegistration, + TransactionContext::InBlock(BlockInfo::new(100, dashcore::BlockHash::all_zeros(), 123)), + TransactionType::Standard, + TransactionDirection::Incoming, + vec![], + vec![], + 0, + ); + let live_txid = record.txid.to_byte_array(); + let live_blob = encode(&record).expect("encode valid historical record"); conn.execute( "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, \ - finalized, record_blob) VALUES (?1, ?2, 100, NULL, NULL, 1, X'AA')", - params![wallet_id.as_slice(), live_txid.as_slice()], + finalized, record_blob) VALUES (?1, ?2, 100, NULL, NULL, 1, ?3)", + params![wallet_id.as_slice(), live_txid.as_slice(), &live_blob], ) .expect("insert live transaction"); let live_outpoint = [0x20u8; 37]; @@ -630,7 +654,7 @@ fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .expect("live transaction survives"); - assert_eq!((height, finalized, blob), (100, 1, vec![0xAA])); + assert_eq!((height, finalized, blob), (100, 1, live_blob)); // 7. The live UTXO survived, and its confirmation height was backfilled // onto a height-only `core_transactions` row. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 282a2f5abf1..511f1b612d0 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "91f2fab573900a41066b8d237a29b83ca94b2fc730702389ab9c088271da522f"; + "edecaf720a714fb2b689e3a3a416a2300a15a9c846138f417e6fb6affb745b1b"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "0dbcfb2ab8d8362a206c0ab948051028c7eafc640861a823c4c50f13b0602be8"; + "50cbaacf66de2622f65f60699115a7a154345c250659b546a1d9a0658b575a97"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 1fe4d1eb514..0c9a8988d75 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -625,6 +625,24 @@ fn coalesce_newest_wins( } } +/// Keep the last correction per account before folding account contributions. +pub(crate) fn coalesce_account_records(records: &mut Vec) { + let mut positions = BTreeMap::new(); + for mut record in std::mem::take(records) { + let key = (record.txid, record.account_type); + if let Some(&position) = positions.get(&key) { + let previous: &TransactionRecord = &records[position]; + if context_rank(&previous.context) > context_rank(&record.context) { + record.context = previous.context.clone(); + } + records[position] = record; + } else { + positions.insert(key, records.len()); + records.push(record); + } + } +} + /// Rank a [`TransactionContext`](key_wallet::transaction_checking::TransactionContext) /// by how far along the confirmation lifecycle the observation is. /// Used by [`fold_same_txid_records`] so a fold never regresses a diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 574715eed6b..3fdf87bb6e0 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -51,8 +51,8 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ - merge_payment_overlays, AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PaymentOverlay, - PlatformWalletChangeSet, SweepBatch, UtxoCreditVerdict, + coalesce_account_records, merge_payment_overlays, AssetLockChangeSet, CoreChangeSet, + HighestUsedIndexes, PaymentOverlay, PlatformWalletChangeSet, SweepBatch, UtxoCreditVerdict, }; use crate::changeset::merge::Merge; use crate::changeset::persistence_capabilities::PersistenceCapabilities; @@ -1226,17 +1226,11 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. - for r in inserted { + // Corrections can discover owned inputs or outputs after the first observation. + for r in inserted.iter().chain(updated.iter()) { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - // Updated records (re-confirmation, IS-lock applied to a known - // mempool tx, etc.) don't usually change UTXO topology — the - // record's content does change though, so re-emit it. - // Matured coinbase records likewise: no UTXO topology change, - // just a status update for the persister. - // // Contact watch-only records are filtered out of all three // lists: re-emitting one on confirmation would re-clobber the // funding account's row with an incoming/positive @@ -1257,6 +1251,7 @@ async fn build_core_changeset( .filter(|r| !is_contact_watch_only(r)) .cloned(), ); + coalesce_account_records(&mut cs.account_records); cs.records = cs.account_records.clone(); crate::changeset::changeset::fold_same_txid_records(&mut cs.records); cs.last_processed_height = Some(*height); @@ -3430,6 +3425,40 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records.len(), 1); assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); + assert_eq!( + cs.spent_utxos.len(), + 1, + "updated records must project recognized inputs" + ); + assert_eq!( + cs.new_utxos.len(), + 1, + "updated records must project owned change" + ); + } + + #[tokio::test] + async fn should_coalesce_repeated_account_corrections_within_one_block() { + let (_, corrected, _) = contact_payment_records(); + let mut stale = corrected.clone(); + stale.input_details.clear(); + stale.net_amount = CHANGE as i64; + stale.direction = TransactionDirection::Incoming; + let event = WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_001, + chain_lock: None, + inserted: vec![stale], + updated: vec![corrected.clone()], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&test_manager(), &event).await; + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].net_amount, corrected.net_amount); + assert_eq!(cs.account_records.len(), 1); } /// A cross-account spend (CoinJoin-funded send with BIP44 change) From 82cec940dcd0f128c9069aa88462b6b539bc388d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:39:02 +0000 Subject: [PATCH 05/39] refactor(wallet): pin minimal Core transaction accounting fix Replace the PR 979-based dependency with the isolated accounting correction backported onto the original e4208c90 base. The identical upstream patch is available on current rust-dashcore dev without SPV or address-pool changes. Validated 24 Core storage and 132 changeset tests with the new pin; scoped Clippy with CI flags and formatting checks pass. Co-Authored-By: Codex --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec1f52cdde6..324364168c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1654,7 +1654,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "dash-network", ] @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "async-trait", "chrono", @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "anyhow", "base64-compat", @@ -1815,12 +1815,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "dashcore-rpc-json", "hex", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "dashcore", "grovedb-bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "dashcore-private", "grovedb-bincode", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" [[package]] name = "glob" @@ -4154,7 +4154,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "aes", "async-trait", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=ed4c02e119898f1bb510cf79abc8a125a9bc9bea#ed4c02e119898f1bb510cf79abc8a125a9bc9bea" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index cd1abb44ccf..d0d38670ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,14 +65,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "ed4c02e119898f1bb510cf79abc8a125a9bc9bea" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which From 85deb4134375696513d519a50072bffb809a5e6d Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 28 Sep 2026 14:27:14 -0500 Subject: [PATCH 06/39] test(drive): track latest protocol version in grovedb structure snapshot #5043 introduced PLATFORM_V15 and made it latest, but the committed grovedb-structure.json and one structure test still expected protocol 14, so `structure::tests` has failed on v4.3-dev since it merged: - should_match_committed_grovedb_structure_json: regenerate the snapshot. Only the origin labels and latest_protocol_version change (14 -> 15); v15 has the same state structure as v14. - should_record_a_contract_layer_with_its_documents_on_top: read the expected origin from PlatformVersion::latest() instead of a hard-coded 14, so the next protocol bump does not break it again. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-drive/grovedb-structure.json | 70 ++++++++++++------------ packages/rs-drive/src/structure/tests.rs | 8 ++- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 343665de9b8..16521adbf8f 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_protocol_version": 14, + "latest_protocol_version": 15, "element_kinds": [ { "name": "Item", @@ -5096,7 +5096,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "00", "right": { @@ -5105,7 +5105,7 @@ } }, "contract_groups.groups.group": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "02", "left": { @@ -5120,7 +5120,7 @@ } }, "contract_groups.members.contract": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5132,7 +5132,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@15", "tree": { "hex": "01", "left": { @@ -5144,7 +5144,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@15", "tree": { "hex": "80", "left": { @@ -5162,7 +5162,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "4d", "left": { @@ -5174,7 +5174,7 @@ } }, "group_actions.contract.group.active.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5183,7 +5183,7 @@ } }, "group_actions.contract.group.closed.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5192,7 +5192,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5217,7 +5217,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5236,7 +5236,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5258,7 +5258,7 @@ }, { "state": "budgeted_key_and_contract", - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5284,7 +5284,7 @@ ] }, "identities.identity.contract_info.bound": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5293,7 +5293,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "03", "left": { @@ -5305,7 +5305,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "02", "left": { @@ -5320,7 +5320,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "54", "left": { @@ -5329,7 +5329,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5357,14 +5357,14 @@ "states": [ { "state": "future", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "73" } }, { "state": "running", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5392,7 +5392,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@15", "tree": { "hex": "74", "left": { @@ -5415,7 +5415,7 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5427,7 +5427,7 @@ } }, "root": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "40", "left": { @@ -5484,7 +5484,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "65", "left": { @@ -5496,7 +5496,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5514,7 +5514,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5535,7 +5535,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5550,7 +5550,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@15", "tree": { "hex": "c0", "left": { @@ -5559,7 +5559,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5571,7 +5571,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "01", "left": { @@ -5580,7 +5580,7 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "64", "left": { @@ -5592,7 +5592,7 @@ } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "70", "left": { @@ -5601,7 +5601,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5610,7 +5610,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5619,7 +5619,7 @@ } }, "withdrawals": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "03", "left": { diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 6a1889e289c..9640fbded9f 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -119,7 +119,13 @@ fn should_record_a_contract_layer_with_its_documents_on_top() { // fixture. Documents are read most and sit at the root of the layer; the // contract itself and everything else hang below. let contract = &json["layer_shapes"]["contracts.contract"]; - assert_eq!(contract["origin"], "fixture contracts_with_documents@14"); + assert_eq!( + contract["origin"], + format!( + "fixture contracts_with_documents@{}", + PlatformVersion::latest().protocol_version + ) + ); assert_eq!(contract["tree"]["hex"], "01"); assert_eq!(contract["tree"]["left"]["hex"], "00"); assert_eq!(contract["tree"]["right"]["hex"], "02"); From c0425f7bdcc29776f6d5f0f35a56cde7eb368c7f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:38:34 +0000 Subject: [PATCH 07/39] fix(platform-wallet-storage): restore confirmed spends before wallet sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replay persisted confirmed transactions through the wallet checker on load, retain only persisted unspent outputs not consumed by confirmed history, and restore finality before sync checkpoint pruning. Use dash-async for synchronous persistence loading. Cover restart, stale projections, funding redelivery, finality, same-block spends, reserved inputs, and synchronous/current-thread/multi-thread callers. Co-authored-by: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 6 + Cargo.lock | 1 + .../rs-platform-wallet-storage/Cargo.toml | 2 + .../src/sqlite/error.rs | 9 +- .../src/sqlite/persister.rs | 3 + .../src/sqlite/rehydrate.rs | 74 ++++- .../tests/sqlite_error_classification.rs | 2 + .../tests/sqlite_spent_rehydration.rs | 298 ++++++++++++++++++ 8 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ae8423857..198da619b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Fixed + +- **platform-wallet-storage:** Restore confirmed Core spend and finality state on SQLite load so old funding transactions cannot make already-spent outputs selectable again. + ## [4.2.0-beta.4](https://github.com/dashpay/platform/compare/v4.2.0-beta.3...v4.2.0-beta.4) (2026-09-24) diff --git a/Cargo.lock b/Cargo.lock index 324364168c1..a0b69124ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5350,6 +5350,7 @@ dependencies = [ "chacha20poly1305", "chrono", "clap", + "dash-async", "dash-sdk", "dashcore", "dbus-secret-service-keyring-store", diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index fe491fcc76b..d506d239da1 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -41,6 +41,7 @@ platform-wallet = { path = "../rs-platform-wallet", features = [ "eddsa", ], optional = true } serde = { version = "1", features = ["derive"], optional = true } +dash-async = { path = "../rs-dash-async", optional = true } key-wallet = { workspace = true, optional = true } dashcore = { workspace = true, optional = true } dpp = { path = "../rs-dpp", optional = true } @@ -225,6 +226,7 @@ sqlite = [ "dep:platform-wallet", "dep:serde", "dep:key-wallet", + "dep:dash-async", "dep:dashcore", "dep:dpp", "dep:dash-sdk", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 9f843edf7bb..2a7a30414b9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -34,6 +34,10 @@ pub enum AutoBackupOperation { /// Errors produced by the wallet-storage SQLite backend. #[derive(Debug, thiserror::Error)] pub enum WalletStorageError { + /// Confirmed Core history could not be replayed into the restored wallet. + #[error("could not restore confirmed Core history: {0}")] + CoreHistoryReplay(#[source] dash_async::AsyncError), + /// File-system I/O error reaching the database or backup files. #[error("io error")] Io(#[from] std::io::Error), @@ -706,7 +710,8 @@ impl WalletStorageError { // `ToSqlConversionFailure`, `InvalidColumnIndex`) — is a // logic bug, not a contention failure. Self::Sqlite(_) => false, - Self::Io(_) + Self::CoreHistoryReplay(_) + | Self::Io(_) | Self::Migration(_) | Self::IntegrityCheckFailed { .. } | Self::IntegrityCheckRunFailed { .. } @@ -871,6 +876,7 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } + | Self::CoreHistoryReplay(_) | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, } } @@ -891,6 +897,7 @@ impl WalletStorageError { }, Self::Sqlite(_) => "sqlite_other", Self::FlushRetryable { .. } => "flush_retryable", + Self::CoreHistoryReplay(_) => "core_history_replay", Self::Io(_) => "io", Self::Migration(_) => "migration", Self::IntegrityCheckFailed { .. } => "integrity_check_failed", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index c08a5a0ee52..28623c535e2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1849,6 +1849,9 @@ fn load_one_wallet( )) })?; } + let (wallet, wallet_info) = + super::rehydrate::restore_confirmed_transactions(wallet_info, wallet, core_state.records) + .map_err(PersistenceError::from)?; Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 270e0a436d0..7fa4fd220cb 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,9 +4,14 @@ //! the manager consumes the carried snapshot directly, so no wrong-seed check //! runs here; that gate lives in the resolver-backed signing entrypoints. +use std::collections::HashSet; + use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::transaction_checking::WalletTransactionChecker; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -258,8 +263,8 @@ pub(crate) fn restore_provider_platform_node_pool( /// Coinbase-maturity nuance re-warms on sync. `is_instantlocked` is NOT /// among them: it is rebuilt from `core_instant_locks` above, for every /// UTXO a replayed lock covers. -/// - **Transaction-record history**: rebuilt by the next scan; not a -/// balance input. +/// - **Transaction records**: the SQLite loader replays confirmed history +/// through the wallet checker after this projection. /// /// # Errors /// @@ -300,12 +305,6 @@ pub fn apply_persisted_core_state( wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); } - // INTENTIONAL(tx-record-rehydration-gap): `core` also carries transaction - // records, but they cannot be replayed here — injecting one needs the raw - // `dashcore::Transaction`, and this crate persists only the abstracted - // `TransactionRecord` blob. History re-warms on the next scan and is not a - // balance input. - // Restore the UTXO set, routing each unspent outpoint to its true owning // funds account via `utxo_accounts` (matched on the same account identity // the writer keyed the pool row on). Two miss cases share the first-account @@ -420,6 +419,65 @@ pub fn apply_persisted_core_state( Ok(()) } +/// Restore spend and finality guards without re-crediting outputs excluded by persistence. +pub(crate) fn restore_confirmed_transactions( + mut wallet_info: ManagedWalletInfo, + mut wallet: Wallet, + records: Vec, +) -> Result<(Wallet, ManagedWalletInfo), WalletStorageError> { + let unspent: HashSet<_> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| account.utxos.keys().copied()) + .collect(); + let mut confirmed: Vec<_> = records + .into_iter() + .filter(|record| record.block_info().is_some()) + .collect(); + if confirmed.is_empty() { + return Ok((wallet, wallet_info)); + } + confirmed.sort_by_key(|record| { + record + .block_info() + .map(|block| (block.height(), block.position())) + }); + + // The checker only mutates in-memory state; no network requests or persistence. + dash_async::block_on(async move { + for record in confirmed { + wallet_info + .check_core_transaction( + &record.transaction, + record.context, + &mut wallet, + true, + false, + ) + .await; + } + + let spent: HashSet<_> = wallet_info + .observed_spent_outpoints() + .keys() + .copied() + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| unspent.contains(outpoint) && !spent.contains(outpoint)); + } + // Finalize replayed records before a sync checkpoint can prune their spend guards. + if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { + wallet_info.apply_chain_lock(chain_lock); + } + wallet_info.update_balance(); + (wallet, wallet_info) + }) + .map_err(WalletStorageError::CoreHistoryReplay) +} + /// Resolve an owning account to its position among `account_keys`, or fall /// back to the first funds account. A `None` owner (no attribution available) /// falls back silently; an owner not present in `account_keys` (store drift) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 36d46cb6073..935b204a2bc 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -351,6 +351,7 @@ fn samples() -> Vec { highest_used: Some(u32::MAX - 5), gap_limit: 20, }, + WalletStorageError::CoreHistoryReplay(dash_async::AsyncError::Generic("test".into())), WalletStorageError::DatabasePathIsSymlink { path: PathBuf::from("/tmp/wallet.db"), }, @@ -493,6 +494,7 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::EmptyPoolAddressScript { .. } => { (false, "empty_pool_address_script") } + WalletStorageError::CoreHistoryReplay(_) => (false, "core_history_replay"), WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs new file mode 100644 index 00000000000..bb9427c1b77 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -0,0 +1,298 @@ +#![cfg(feature = "sqlite")] + +mod common; + +use dashcore::{ + bls_sig_utils::BLSSignature, ephemerealdata::chain_lock::ChainLock, hashes::Hash, BlockHash, + Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, +}; +use key_wallet::{ + transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}, + wallet::{ + initialization::WalletAccountCreationOptions, + managed_wallet_info::{ + coin_selection::{CoinSelector, SelectionStrategy}, + fee::FeeRate, + wallet_info_interface::WalletInfoInterface, + }, + ManagedWalletInfo, Wallet, + }, +}; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn block(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) +} + +struct Fixture { + persister: SqlitePersister, + _dir: tempfile::TempDir, + wallet_id: [u8; 32], + funding: Transaction, + spent: OutPoint, + available: OutPoint, +} + +impl Fixture { + async fn new(spend_context: TransactionContext) -> Self { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([13; 32]), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }, + TxOut { + value: 20_000, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let spent = OutPoint::new(funding.txid(), 0); + let available = OutPoint::new(funding.txid(), 1); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let funding_result = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + let spent_coin = info.accounts.standard_bip44_accounts[&0].utxos[&spent].clone(); + let spending_result = info + .check_core_transaction(&spending, spend_context, &mut wallet, true, true) + .await; + let mut records = funding_result.new_records; + records.extend(spending_result.new_records); + assert_eq!(records.len(), 2); + let (persister, dir, path) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records, + new_utxos: coins, + spent_utxos: vec![spent_coin], + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + Self { + persister, + _dir: dir, + wallet_id: wallet.wallet_id, + funding, + spent, + available, + } + } + + fn load(&self) -> (Wallet, ManagedWalletInfo) { + let mut state = self.persister.load().unwrap(); + let restored = state.wallets.remove(&self.wallet_id).unwrap(); + (restored.wallet, restored.wallet_info) + } + + fn assert_spent_excluded(&self, info: &ManagedWalletInfo) { + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&self.spent)); + assert!(coins.contains_key(&self.available)); + assert_eq!(info.balance.total(), 20_000); + let selector = CoinSelector::new(SelectionStrategy::LargestFirst); + assert!(selector + .select_coins(coins.values(), 50_000, FeeRate::default(), 300) + .is_err()); + let selection = selector + .select_coins(coins.values(), 5_000, FeeRate::default(), 300) + .unwrap(); + assert_eq!(selection.selected.len(), 1); + assert_eq!(selection.selected[0].outpoint, self.available); + } + + async fn redeliver(&self, wallet: &mut Wallet, info: &mut ManagedWalletInfo) { + let result = info + .check_core_transaction(&self.funding, block(100), wallet, true, true) + .await; + self.assert_spent_excluded(info); + self.persister + .store( + self.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let (_, reloaded) = self.load(); + self.assert_spent_excluded(&reloaded); + } +} + +#[tokio::test] +async fn should_reject_spent_output_after_reload_and_funding_redelivery() { + let fixture = Fixture::new(block(200)).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_keep_spent_output_excluded_after_finality_pruning() { + let fixture = Fixture::new(block(200)).await; + let (mut wallet, mut info) = fixture.load(); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty()); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { + let fixture = Fixture::new(block(200)).await; + fixture + .persister + .lock_conn_for_test() + .execute("UPDATE core_utxos SET spent = 0", []) + .unwrap(); + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_not_release_inputs_reserved_by_unconfirmed_spend() { + let fixture = Fixture::new(TransactionContext::Mempool).await; + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); + assert!(!info.observed_spent_outpoints().contains_key(&fixture.spent)); +} + +#[tokio::test] +async fn should_handle_funding_and_spend_in_the_same_block() { + let fixture = Fixture::new(block(100)).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[test] +fn should_restore_without_an_async_runtime() { + let fixture = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(Fixture::new(block(200))); + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_on_managers_blocking_pool() { + let fixture = Fixture::new(block(200)).await; + tokio::task::spawn_blocking(move || { + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn should_restore_persisted_finality_before_advancing_sync_checkpoint() { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let fixture = Fixture::new(block(100)).await; + fixture + .persister + .store( + fixture.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let (mut wallet, mut info) = fixture.load(); + assert!(info.accounts.standard_bip44_accounts[&0] + .keys() + .transaction_is_finalized(&fixture.funding.txid())); + info.update_synced_height(300); + fixture.redeliver(&mut wallet, &mut info).await; +} From 9c8dff2229f2c04625bf6c495e5fc18f864afe6f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:21:17 +0000 Subject: [PATCH 08/39] fix(platform-wallet-storage): keep each replayed UTXO in one funds account The load projection parks an unattributable UTXO in the first funds account; replaying confirmed history then credits the same outpoint to its real owner. The post-replay filter only checked a wallet-wide unspent set, so both copies survived and update_balance double counted. Drop the load-time fallback copy whenever replay credits the outpoint to a different funds account, while still refusing to re-credit anything persistence excluded. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/rehydrate.rs | 47 ++++++++-- .../tests/sqlite_spent_rehydration.rs | 92 +++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 7fa4fd220cb..db27b6106ce 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,8 +4,9 @@ //! the manager consumes the carried snapshot directly, so no wrong-seed check //! runs here; that gate lives in the resolver-backed signing entrypoints. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use dashcore::OutPoint; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; @@ -425,11 +426,16 @@ pub(crate) fn restore_confirmed_transactions( mut wallet: Wallet, records: Vec, ) -> Result<(Wallet, ManagedWalletInfo), WalletStorageError> { - let unspent: HashSet<_> = wallet_info + // Where the load projection parked each unspent outpoint; its keys are + // the outputs persistence still considers unspent. + let placed: HashMap = wallet_info .accounts .all_funding_accounts() .into_iter() - .flat_map(|account| account.utxos.keys().copied()) + .flat_map(|account| { + let owner = funds_account_type(account); + account.utxos.keys().map(move |outpoint| (*outpoint, owner)) + }) .collect(); let mut confirmed: Vec<_> = records .into_iter() @@ -463,10 +469,31 @@ pub(crate) fn restore_confirmed_transactions( .keys() .copied() .collect(); + // Replay credits an output to the account whose pool derives it. When + // that differs from the load-time fallback, the fallback copy is a + // duplicate: drop it so each outpoint lives in exactly one account. + let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + let placed = &placed; + account.utxos.keys().filter_map(move |outpoint| { + placed + .get(outpoint) + .filter(|parked| **parked != owner) + .map(|parked| (*outpoint, *parked)) + }) + }) + .collect(); for account in wallet_info.accounts.all_funding_accounts_mut() { - account - .utxos - .retain(|outpoint, _| unspent.contains(outpoint) && !spent.contains(outpoint)); + let owner = funds_account_type(account); + account.utxos.retain(|outpoint, _| { + placed.contains_key(outpoint) + && !spent.contains(outpoint) + && !misplaced.contains(&(*outpoint, owner)) + }); } // Finalize replayed records before a sync checkpoint can prune their spend guards. if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { @@ -478,6 +505,14 @@ pub(crate) fn restore_confirmed_transactions( .map_err(WalletStorageError::CoreHistoryReplay) } +/// Account identity of a funds account, stable across replay mutations. +fn funds_account_type( + account: &key_wallet::managed_account::ManagedCoreFundsAccount, +) -> AccountType { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + account.managed_account_type().to_account_type() +} + /// Resolve an owning account to its position among `account_keys`, or fall /// back to the first funds account. A `None` owner (no attribution available) /// falls back silently; an owner not present in `account_keys` (store drift) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index bb9427c1b77..e2de2722b02 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -296,3 +296,95 @@ async fn should_restore_persisted_finality_before_advancing_sync_checkpoint() { info.update_synced_height(300); fixture.redeliver(&mut wallet, &mut info).await; } + +#[tokio::test] +async fn should_keep_replayed_output_only_in_its_owning_account() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip32_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip32_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([14; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 70_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let coin = OutPoint::new(funding.txid(), 0); + let result = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + let coins: Vec<_> = info.accounts.standard_bip32_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + assert_eq!(coins.len(), 1); + let (persister, _dir, _) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: coins, + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + // Without its pool row the loader cannot attribute the coin and parks it + // in the first funds account; replay then finds the real owner. + persister + .lock_conn_for_test() + .execute( + "DELETE FROM core_address_pool WHERE script = ?1", + [address.script_pubkey().as_bytes()], + ) + .unwrap(); + + let mut state = persister.load().unwrap(); + let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + let holders = info + .accounts + .all_funding_accounts() + .into_iter() + .filter(|account| account.utxos.contains_key(&coin)) + .count(); + assert_eq!(holders, 1, "one outpoint must live in exactly one account"); + assert!(info.accounts.standard_bip32_accounts[&0] + .utxos + .contains_key(&coin)); + assert_eq!(info.balance.total(), 70_000); + assert_eq!(info.get_spendable_utxos().len(), 1); +} From 4b805134392911a6d91719ac2a1bb1c600f70e03 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:24:43 +0000 Subject: [PATCH 09/39] fix(platform-wallet-storage): never load contact-only outputs as spendable Pre-fix builds persisted DashPay contact (watch-only) outputs as unspent core_utxos rows. V019 re-labels their history as Sent but left the rows unspent, so load routed them to the first funds account: the balance included coins the wallet cannot sign and coin selection could pick one. load_state now skips unspent contact-only rows. The rows are kept in SQLite (no destructive migration step), so the exclusion is reversible. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/core_history.rs | 9 +- .../src/sqlite/schema/core_state.rs | 99 ++++++++++++++++ .../tests/sqlite_spent_rehydration.rs | 112 ++++++++++++++---- 3 files changed, 195 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index c710ada2228..dd7081c4668 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -10,7 +10,7 @@ use key_wallet::managed_account::transaction_record::{ use key_wallet::transaction_checking::{TransactionContext, TransactionType}; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; -use rusqlite::{params, Transaction}; +use rusqlite::{params, Connection, Transaction}; use super::{blob, core_state, wallets}; use crate::sqlite::error::WalletStorageError; @@ -148,12 +148,13 @@ fn owned_output( Ok(Some((value, address))) } -fn contact_only_script( - tx: &Transaction<'_>, +/// Whether `script` is tracked only by a contact's watch-only (DashPay external) chain. +pub(crate) fn contact_only_script( + conn: &Connection, wallet_id: &WalletId, script: &[u8], ) -> Result { - Ok(tx.query_row( + Ok(conn.query_row( "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) AND NOT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", params![wallet_id.as_slice(), script], |r| r.get(0))?) } 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 79d2cc1bbeb..a6b1c32c6a2 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 @@ -1007,6 +1007,11 @@ pub fn load_state( let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; let height = transaction_heights.get(&outpoint.txid).copied().flatten(); let script = dashcore::ScriptBuf::from_bytes(script_bytes); + // A contact's watch-only output is never ours to spend, whatever + // an older build recorded; the fallback would make it spendable. + if super::core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { + continue; + } if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { utxo_accounts.insert(outpoint, owner); } @@ -1706,6 +1711,100 @@ mod tests { assert_eq!(repaired.output_details[0].role, OutputRole::Sent); } + /// Store one contact-only unspent row (a pre-fix build persisted the + /// contact's coins) next to one of our own. Returns both outpoints. + fn stage_contact_only_utxo(conn: &Connection, wallet_id: &[u8; 32]) -> (OutPoint, OutPoint) { + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let contact = sample_utxo(Txid::from_byte_array([0x51; 32]), 100, true); + let mut own = sample_utxo(Txid::from_byte_array([0x52; 32]), 100, true); + own.address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0x24u8; 20])), + ); + own.txout.script_pubkey = own.address.script_pubkey(); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], contact.txout.script_pubkey.as_bytes()], + ) + .unwrap(); + let (contact_outpoint, own_outpoint) = (contact.outpoint, own.outpoint); + let tx = conn.unchecked_transaction().unwrap(); + apply( + &tx, + wallet_id, + &CoreChangeSet { + new_utxos: vec![contact, own], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + (contact_outpoint, own_outpoint) + } + + #[test] + fn should_not_load_contact_only_outputs_as_spendable() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB1u8; 32]; + let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); + let (cs, owners) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded: Vec<_> = cs.new_utxos.iter().map(|u| u.outpoint).collect(); + assert_eq!(loaded, vec![own], "a contact's coin is not ours to spend"); + assert!(!owners.contains_key(&contact)); + } + + #[test] + fn should_keep_contact_only_rows_but_exclude_them_after_history_migration() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB2u8; 32]; + let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let unspent_rows = |outpoint: &OutPoint| -> i64 { + conn.query_row( + "SELECT count(*) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 0", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + unspent_rows(&contact), + 1, + "V019 must not destroy stored rows" + ); + assert_eq!(unspent_rows(&own), 1); + let (cs, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded: Vec<_> = cs.new_utxos.iter().map(|u| u.outpoint).collect(); + assert_eq!( + loaded, + vec![own], + "the kept row still never becomes spendable" + ); + } + #[test] fn should_preserve_known_inputs_when_replay_has_no_historical_txo() { use key_wallet::managed_account::transaction_record::InputDetail; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index e2de2722b02..46076a9bb99 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -3,8 +3,9 @@ mod common; use dashcore::{ - bls_sig_utils::BLSSignature, ephemerealdata::chain_lock::ChainLock, hashes::Hash, BlockHash, - Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, + address::Payload, bls_sig_utils::BLSSignature, ephemerealdata::chain_lock::ChainLock, + hashes::Hash, Address, BlockHash, Network, OutPoint, PubkeyHash, ScriptBuf, Transaction, TxIn, + TxOut, Txid, }; use key_wallet::{ transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}, @@ -17,6 +18,7 @@ use key_wallet::{ }, ManagedWalletInfo, Wallet, }, + Utxo, }; use platform_wallet::changeset::{ AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, @@ -297,6 +299,29 @@ async fn should_restore_persisted_finality_before_advancing_sync_checkpoint() { fixture.redeliver(&mut wallet, &mut info).await; } +/// A foreign P2PKH address no account of the test wallet derives. +fn foreign_address(marker: u8) -> Address { + Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([marker; 20])), + ) +} + +/// The persisted UTXO for `funding`'s output `vout`, as a sync would store it. +fn stored_utxo(funding: &Transaction, vout: u32, address: Address) -> Utxo { + Utxo { + outpoint: OutPoint::new(funding.txid(), vout), + txout: funding.output[vout as usize].clone(), + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + #[tokio::test] async fn should_keep_replayed_output_only_in_its_owning_account() { let mut wallet = @@ -310,6 +335,9 @@ async fn should_keep_replayed_output_only_in_its_owning_account() { .unwrap() .next_receive_address(Some(&xpub), true) .unwrap(); + // vout 1: no pool row and no replay owner, so the first-account fallback + // must hold. vout 2: tracked only by a contact's watch-only chain. + let (unowned, contact) = (foreign_address(0x61), foreign_address(0x62)); let funding = Transaction { version: 1, lock_time: 0, @@ -317,22 +345,36 @@ async fn should_keep_replayed_output_only_in_its_owning_account() { previous_output: OutPoint::new(Txid::from_byte_array([14; 32]), 0), ..Default::default() }], - output: vec![TxOut { - value: 70_000, - script_pubkey: address.script_pubkey(), - }], + output: vec![ + TxOut { + value: 70_000, + script_pubkey: address.script_pubkey(), + }, + TxOut { + value: 5_000, + script_pubkey: unowned.script_pubkey(), + }, + TxOut { + value: 9_000, + script_pubkey: contact.script_pubkey(), + }, + ], special_transaction_payload: None, }; let coin = OutPoint::new(funding.txid(), 0); + let fallback = OutPoint::new(funding.txid(), 1); + let contact_coin = OutPoint::new(funding.txid(), 2); let result = info .check_core_transaction(&funding, block(100), &mut wallet, true, true) .await; - let coins: Vec<_> = info.accounts.standard_bip32_accounts[&0] + let mut coins: Vec<_> = info.accounts.standard_bip32_accounts[&0] .utxos .values() .cloned() .collect(); assert_eq!(coins.len(), 1); + coins.push(stored_utxo(&funding, 1, unowned)); + coins.push(stored_utxo(&funding, 2, contact.clone())); let (persister, _dir, _) = common::fresh_persister(); persister .store( @@ -363,28 +405,56 @@ async fn should_keep_replayed_output_only_in_its_owning_account() { }, ) .unwrap(); - // Without its pool row the loader cannot attribute the coin and parks it - // in the first funds account; replay then finds the real owner. - persister - .lock_conn_for_test() - .execute( + { + let conn = persister.lock_conn_for_test(); + // Without its pool row the loader cannot attribute the coin and parks + // it in the first funds account; replay then finds the real owner. + conn.execute( "DELETE FROM core_address_pool WHERE script = ?1", [address.script_pubkey().as_bytes()], ) .unwrap(); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + rusqlite::params![&wallet.wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + } let mut state = persister.load().unwrap(); let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; - let holders = info - .accounts - .all_funding_accounts() - .into_iter() - .filter(|account| account.utxos.contains_key(&coin)) - .count(); - assert_eq!(holders, 1, "one outpoint must live in exactly one account"); + let holders = |outpoint: &OutPoint| { + info.accounts + .all_funding_accounts() + .into_iter() + .filter(|account| account.utxos.contains_key(outpoint)) + .count() + }; + assert_eq!( + holders(&coin), + 1, + "one outpoint must live in exactly one account" + ); assert!(info.accounts.standard_bip32_accounts[&0] .utxos .contains_key(&coin)); - assert_eq!(info.balance.total(), 70_000); - assert_eq!(info.get_spendable_utxos().len(), 1); + assert!( + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fallback), + "an output replay cannot attribute keeps the first-account fallback" + ); + assert_eq!(holders(&contact_coin), 0, "a contact's coin is not ours"); + assert_eq!(info.balance.total(), 75_000); + assert_eq!(info.get_spendable_utxos().len(), 2); + let stored: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT count(*) FROM core_utxos WHERE spent = 0", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, 3, "load must not delete stored rows"); } From 3d04bb0257399736331ab8f5e9ce61fd89ad05ae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:21:17 +0000 Subject: [PATCH 10/39] fix(swift-sdk): skip load-time accounting repair inside an open changeset loadWalletList saved or rolled back the background context unconditionally after reconciling transaction accounting. Inside an open begin/endChangeset round that committed half of the round or silently discarded its staged writes while the round still reported success. Guard the pass with !inChangeset like every other load-time writer; the round reconciles its own dirty rows and the next load runs the full pass. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 43 ++++++++++-------- .../TransactionAccountingTests.swift | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 0410f6ef794..354e2cf32e8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6909,25 +6909,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } - do { - let walletIds = Set(wallets.map(\.walletId)) - let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - .filter { row in - row.involvedAccounts.contains { walletIds.contains($0.wallet.walletId) } - || (row.inputs + row.outputs).contains { - Self.resolvedWalletId(of: $0).map { walletIds.contains($0) } == true - } - } - let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - try reconcileTransactionAccounting(transactions, txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) })) - try backgroundContext.save() - } catch { - backgroundContext.rollback() - SDKLogger.event( - "persistence_wallet_load_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("transaction_accounting")], error: error - ) - return (nil, 0, true) + // Mid-round the context holds another round's staged writes: saving + // would commit half of it and rolling back would silently drop it. + // That round reconciles its own dirty rows; the next load repairs the rest. + if !inChangeset { + do { + let walletIds = Set(wallets.map(\.walletId)) + let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + .filter { row in + row.involvedAccounts.contains { walletIds.contains($0.wallet.walletId) } + || (row.inputs + row.outputs).contains { + Self.resolvedWalletId(of: $0).map { walletIds.contains($0) } == true + } + } + let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + try reconcileTransactionAccounting(transactions, txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) })) + try backgroundContext.save() + } catch { + backgroundContext.rollback() + SDKLogger.event( + "persistence_wallet_load_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("transaction_accounting")], error: error + ) + return (nil, 0, true) + } } let restorable = wallets.filter { wallet in wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index ee419f93954..ccde14865f4 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -94,6 +94,51 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertTrue(try XCTUnwrap(fresh.fetch(FetchDescriptor()).first).isSpent) } + func testShouldNotCommitOrDropOpenRoundWhenLoadRunsMidChangeset() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction(txid: walletId, transactionData: Data()) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: 0, netAmount: 40 + ) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) + coin.walletId = walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(funding) + context.insert(spender) + context.insert(coin) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let identityId = Data(repeating: 9, count: 32) + let fetchIdentity = { + try ModelContext(container).fetch(FetchDescriptor()).first { $0.identityId == identityId } + } + + handler.beginChangeset(walletId: walletId) + handler.persistIdentities( + walletId: walletId, + upserts: [.init( + identityId: identityId, balance: 100, revision: 1, identityIndex: 0, label: nil, + status: 0, walletId: walletId, dpnsNames: [], dashpayProfile: nil, contactProfiles: [] + )], + removed: [] + ) + XCTAssertFalse(handler.loadWalletList().errored) + XCTAssertNil(try fetchIdentity(), "load must not commit half of an open round") + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertNotNil(try fetchIdentity(), "load must not roll back an open round") + + // The skipped full pass runs on the next load outside any round. + XCTAssertFalse(handler.loadWalletList().errored) + let repaired = try ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == spender.txid } + XCTAssertEqual(repaired?.netAmount, -100) + } + func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext From 97661696b2d4de9aca388b86c7c7c80e012515d7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:27:01 +0000 Subject: [PATCH 11/39] fix(platform-version): select folded DRIVE_ABCI_QUERY_VERSIONS_V2 in PV15 #5057 folded DRIVE_ABCI_QUERY_VERSIONS_V3 into V2 (identical values) and deleted v3.rs; PV14 was updated but PV15, introduced on v4.3-dev, still imported V3, so the v4.2-dev -> v4.3-dev merge stopped compiling. Pure rename; the selected query versions are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-version/src/version/v15.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e49b5133ace..151896fed16 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -16,7 +16,7 @@ use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; -use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; +use crate::version::drive_abci_versions::drive_abci_query_versions::v2::DRIVE_ABCI_QUERY_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; @@ -43,7 +43,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { methods: DRIVE_ABCI_METHOD_VERSIONS_V10, validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, - query: DRIVE_ABCI_QUERY_VERSIONS_V3, + query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, }, dpp: DPPVersion { From 28796ac1762915e71541f0d1277f535600286c9d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:36:58 +0000 Subject: [PATCH 12/39] fix(platform-wallet): drop unused IdentityGettersV0 import in dpns The v4.2-dev -> v4.3-dev merge (704307eafc) left an unused import and stray blank lines in dpns.rs, failing clippy -D warnings and rustfmt. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/wallet/identity/network/dpns.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index ae770c1207c..1dacd612155 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -1,11 +1,8 @@ //! DPNS name registration, resolution, search, and contest queries. - use super::signing_key::AvailableSigningKey; use dpp::fee::Credits; -use dpp::identity::accessors::IdentityGettersV0; - use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; From c2a5765a581dd567840717e9a3ed857d94e26182 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:13:56 +0000 Subject: [PATCH 13/39] test(drive): regenerate GroveDB structure for protocol version 15 The structure generator describes PlatformVersion::latest(), which is 15 once PLATFORM_V15 compiles. The committed JSON and a hard-coded "@14" assertion were stale. Regenerated the JSON (version labels only) and derive the expected fixture origin from PlatformVersion::latest(). Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive/grovedb-structure.json | 70 ++++++++++++------------ packages/rs-drive/src/structure/tests.rs | 8 ++- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 7e1b8bc989f..35918595374 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_protocol_version": 14, + "latest_protocol_version": 15, "element_kinds": [ { "name": "Item", @@ -5206,7 +5206,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "00", "right": { @@ -5215,7 +5215,7 @@ } }, "contract_groups.groups.group": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "02", "left": { @@ -5230,7 +5230,7 @@ } }, "contract_groups.members.contract": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5242,7 +5242,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@15", "tree": { "hex": "01", "left": { @@ -5254,7 +5254,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@15", "tree": { "hex": "80", "left": { @@ -5272,7 +5272,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "4d", "left": { @@ -5284,7 +5284,7 @@ } }, "group_actions.contract.group.active.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5293,7 +5293,7 @@ } }, "group_actions.contract.group.closed.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@15", "tree": { "hex": "53", "left": { @@ -5302,7 +5302,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5327,7 +5327,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5346,7 +5346,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "80", "left": { @@ -5368,7 +5368,7 @@ }, { "state": "budgeted_key_and_contract", - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "80", "left": { @@ -5394,7 +5394,7 @@ ] }, "identities.identity.contract_info.bound": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@15", "tree": { "hex": "01", "left": { @@ -5403,7 +5403,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "03", "left": { @@ -5415,7 +5415,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@15", "tree": { "hex": "02", "left": { @@ -5430,7 +5430,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "45", "left": { @@ -5442,7 +5442,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5470,14 +5470,14 @@ "states": [ { "state": "future", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "73" } }, { "state": "running", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@15", "tree": { "hex": "6d", "left": { @@ -5505,7 +5505,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@15", "tree": { "hex": "74", "left": { @@ -5528,7 +5528,7 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5540,7 +5540,7 @@ } }, "root": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "40", "left": { @@ -5597,7 +5597,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "65", "left": { @@ -5609,7 +5609,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5627,7 +5627,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5648,7 +5648,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5663,7 +5663,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@15", "tree": { "hex": "c0", "left": { @@ -5672,7 +5672,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "80", "left": { @@ -5684,7 +5684,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "01", "left": { @@ -5693,7 +5693,7 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "64", "left": { @@ -5705,7 +5705,7 @@ } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "70", "left": { @@ -5714,7 +5714,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5723,7 +5723,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@15", "tree": { "hex": "01", "left": { @@ -5732,7 +5732,7 @@ } }, "withdrawals": { - "origin": "genesis@14", + "origin": "genesis@15", "tree": { "hex": "03", "left": { diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index ba40a7bbf55..2757dcd315b 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -119,7 +119,13 @@ fn should_record_a_contract_layer_with_its_documents_on_top() { // fixture. Documents are read most and sit at the root of the layer; the // contract itself and everything else hang below. let contract = &json["layer_shapes"]["contracts.contract"]; - assert_eq!(contract["origin"], "fixture contracts_with_documents@14"); + assert_eq!( + contract["origin"], + format!( + "fixture contracts_with_documents@{}", + PlatformVersion::latest().protocol_version + ) + ); assert_eq!(contract["tree"]["hex"], "01"); assert_eq!(contract["tree"]["left"]["hex"], "00"); assert_eq!(contract["tree"]["right"]["hex"], "02"); From c4a5b2312c91e829f43083e9b248c96bb5a37eef Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:25:51 +0200 Subject: [PATCH 14/39] fix(platform-wallet-storage)!: harden wallet history restore after #5150 review (#5208) Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 6 - Cargo.lock | 1 - .../rs-platform-wallet-storage/Cargo.toml | 2 - .../V019__core_transaction_accounting.rs | 12 +- .../src/sqlite/error.rs | 56 +- .../src/sqlite/migrations.rs | 13 +- .../src/sqlite/migrations/legacy_v019.rs | 632 ++++++++++++++ .../src/sqlite/persister.rs | 15 +- .../src/sqlite/rehydrate.rs | 788 ++++++++++++++++-- .../src/sqlite/schema/accounts.rs | 6 +- .../src/sqlite/schema/core_history.rs | 292 ++++++- .../src/sqlite/schema/core_state.rs | 255 +++++- .../tests/persistence_error_kind_mapping.rs | 41 + .../tests/sqlite_error_classification.rs | 38 +- .../tests/sqlite_schema_pinning.rs | 2 +- .../tests/sqlite_spent_rehydration.rs | 146 +++- .../src/changeset/changeset.rs | 4 + .../KeyWallet/TransactionDecoder.swift | 3 + .../Models/PersistentTransaction.swift | 74 +- .../PlatformWalletPersistenceHandler.swift | 64 +- .../Core/Views/TransactionDetailView.swift | 36 +- .../Views/TransactionDirectionStyle.swift | 25 + .../Core/Views/TransactionListView.swift | 35 +- .../Views/StorageModelListViews.swift | 8 +- .../TransactionAccountingTests.swift | 130 ++- 25 files changed, 2425 insertions(+), 259 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3738f94cf7c..ee85f2183c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,3 @@ -## Unreleased - -### Fixed - -- **platform-wallet-storage:** Restore confirmed Core spend and finality state on SQLite load so old funding transactions cannot make already-spent outputs selectable again. - ## [4.2.0-beta.7](https://github.com/dashpay/platform/compare/v4.2.0-beta.6...v4.2.0-beta.7) (2026-09-29) diff --git a/Cargo.lock b/Cargo.lock index a593de4f7f5..bdb7d2c4477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5350,7 +5350,6 @@ dependencies = [ "chacha20poly1305", "chrono", "clap", - "dash-async", "dash-sdk", "dashcore", "dbus-secret-service-keyring-store", diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index d506d239da1..fe491fcc76b 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -41,7 +41,6 @@ platform-wallet = { path = "../rs-platform-wallet", features = [ "eddsa", ], optional = true } serde = { version = "1", features = ["derive"], optional = true } -dash-async = { path = "../rs-dash-async", optional = true } key-wallet = { workspace = true, optional = true } dashcore = { workspace = true, optional = true } dpp = { path = "../rs-dpp", optional = true } @@ -226,7 +225,6 @@ sqlite = [ "dep:platform-wallet", "dep:serde", "dep:key-wallet", - "dep:dash-async", "dep:dashcore", "dep:dpp", "dep:dash-sdk", diff --git a/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs index b92003e8545..d563fd867f0 100644 --- a/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs +++ b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs @@ -1,4 +1,5 @@ -//! Index raw inputs so late output ownership can repair the spending history. +//! Index raw inputs so late output ownership can repair the spending history, +//! and keep each record's pre-repair blob so every repair stays reversible. pub fn migration() -> String { "CREATE TABLE core_transaction_inputs ( @@ -9,6 +10,13 @@ pub fn migration() -> String { FOREIGN KEY (wallet_id, txid) REFERENCES core_transactions(wallet_id, txid) ON DELETE CASCADE ); CREATE INDEX idx_core_transaction_inputs_outpoint - ON core_transaction_inputs(wallet_id, outpoint);" + ON core_transaction_inputs(wallet_id, outpoint); + CREATE TABLE core_transaction_record_originals ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + record_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE + );" .to_owned() } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 2a7a30414b9..2c64509fd18 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -32,12 +32,12 @@ pub enum AutoBackupOperation { } /// Errors produced by the wallet-storage SQLite backend. +/// +/// `#[non_exhaustive]`: new failure modes get their own variant, so matches +/// outside this crate need a wildcard arm. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum WalletStorageError { - /// Confirmed Core history could not be replayed into the restored wallet. - #[error("could not restore confirmed Core history: {0}")] - CoreHistoryReplay(#[source] dash_async::AsyncError), - /// File-system I/O error reaching the database or backup files. #[error("io error")] Io(#[from] std::io::Error), @@ -422,6 +422,37 @@ pub enum WalletStorageError { blob_height: Option, }, + /// An incoming transaction record reuses a stored txid with a different + /// raw transaction body; neither copy is trusted to replace the other. + #[error( + "transaction {txid} in wallet {} arrived with a raw body that differs from the stored one", + hex::encode(wallet_id) + )] + TransactionBodyConflict { + wallet_id: [u8; 32], + txid: dashcore::Txid, + }, + + /// The `wallets.network` label is not one this build knows, so stored + /// scripts cannot be turned back into addresses. + #[error( + "wallet {} has unknown network label {label:?}", + hex::encode(wallet_id) + )] + UnknownWalletNetwork { wallet_id: [u8; 32], label: String }, + + /// A transaction's net amount (owned outputs minus owned inputs) does not + /// fit the `i64` the record stores. + #[error( + "net amount {value} of transaction {txid} in wallet {} does not fit i64", + hex::encode(wallet_id) + )] + NetAmountOverflow { + wallet_id: [u8; 32], + txid: dashcore::Txid, + value: i128, + }, + /// A blob exceeded the decode allocation cap (default 16 MiB). /// Separate from [`Self::BlobDecode`] so operators can distinguish an /// oversize blob from a structural decode failure. @@ -710,8 +741,7 @@ impl WalletStorageError { // `ToSqlConversionFailure`, `InvalidColumnIndex`) — is a // logic bug, not a contention failure. Self::Sqlite(_) => false, - Self::CoreHistoryReplay(_) - | Self::Io(_) + Self::Io(_) | Self::Migration(_) | Self::IntegrityCheckFailed { .. } | Self::IntegrityCheckRunFailed { .. } @@ -759,6 +789,9 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::TransactionBodyConflict { .. } + | Self::UnknownWalletNetwork { .. } + | Self::NetAmountOverflow { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -804,6 +837,11 @@ impl WalletStorageError { // Typed re-mapping of an FK violation — same class as the raw // `ConstraintViolation` above, so it reports the same kind. Self::IdentityKeyWalletMismatch { .. } => PersistenceErrorKind::Constraint, + // History invariants checked in Rust on the write path: the incoming + // record contradicts stored history, so the data is wrong, not the engine. + Self::TransactionBodyConflict { .. } | Self::NetAmountOverflow { .. } => { + PersistenceErrorKind::Constraint + } // Refinery surfaces FK / constraint problems through rusqlite; // if that path leaks through here the typed variant lives in // `Self::Migration`, which we leave as `Fatal` since a @@ -863,6 +901,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::UnknownWalletNetwork { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -876,7 +915,6 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::CoreHistoryReplay(_) | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, } } @@ -897,7 +935,6 @@ impl WalletStorageError { }, Self::Sqlite(_) => "sqlite_other", Self::FlushRetryable { .. } => "flush_retryable", - Self::CoreHistoryReplay(_) => "core_history_replay", Self::Io(_) => "io", Self::Migration(_) => "migration", Self::IntegrityCheckFailed { .. } => "integrity_check_failed", @@ -946,6 +983,9 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", + Self::TransactionBodyConflict { .. } => "transaction_body_conflict", + Self::UnknownWalletNetwork { .. } => "unknown_wallet_network", + Self::NetAmountOverflow { .. } => "net_amount_overflow", Self::BlobTooLarge { .. } => "blob_too_large", Self::IntegerOverflow { .. } => "integer_overflow", Self::RehydrationPoolMismatch { .. } => "rehydration_pool_mismatch", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index e152a68a97a..77e76fc1f77 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -10,6 +10,7 @@ use crate::sqlite::error::WalletStorageError; use refinery_core::error::WrapMigrationError; mod legacy_v008; +mod legacy_v019; // Generates a `migrations` module with `runner()`; path is relative to // the crate root. @@ -78,7 +79,7 @@ impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { } else if query == self.pool_sql { legacy_v008::convert_pools(&self.tx)?; } else if query == self.history_sql { - super::schema::core_history::migrate(&self.tx)?; + legacy_v019::repair_history(&self.tx)?; } count += 1; } @@ -424,6 +425,16 @@ pub fn embedded_migrations_sql() -> Vec { .collect() } +/// Undo V019 so the next [`run`] replays it over the current rows. +#[cfg(test)] +pub(crate) fn rewind_to_v018(conn: &rusqlite::Connection) { + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs new file mode 100644 index 00000000000..2b3554e3453 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -0,0 +1,632 @@ +//! Frozen V019 history repair. It backfills the raw-input index and corrects +//! stored accounting for databases migrating from V018 or earlier. +//! +//! Frozen on purpose: the live per-round repair in `schema::core_history` may +//! evolve, but V019 must keep doing exactly what it did when it shipped. It +//! shares only these live helpers, which must stay behaviour-stable: the blob +//! codec and its size/width gates (`blob`), `id32`, `wallets::parse_network`, +//! `i64_to_u64` and the `WalletStorageError` variants it returns. +//! `TransactionRecord`'s encoding is owned upstream (key-wallet) and cannot be +//! frozen here. Do not edit. + +use std::collections::BTreeMap; + +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, ScriptBuf, Txid}; +use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Transaction}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::{blob, id32, wallets}; +use crate::sqlite::util::safe_cast::i64_to_u64; + +/// Read a stored record; undecodable bytes are an error the caller classifies. +fn read_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + let mut stmt = tx.prepare_cached( + "SELECT length(record_blob), record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + // Gate the stored length before the payload is materialized. + let Some(len) = row.get::<_, Option>(0)? else { + return Ok(None); + }; + blob::check_size(len)?; + let payload: Vec = row.get(1)?; + let record: TransactionRecord = blob::decode(&payload)?; + if record.txid != *txid { + return Err(WalletStorageError::blob_decode( + "transaction record names another transaction", + )); + } + Ok(Some(record)) +} + +/// Whether `error` reports stored bytes that cannot be decoded, not a database failure. +fn is_unreadable(error: &WalletStorageError) -> bool { + matches!( + error, + WalletStorageError::BincodeDecode { .. } + | WalletStorageError::BlobDecode { .. } + | WalletStorageError::BlobTooLarge { .. } + | WalletStorageError::HashDecode { .. } + | WalletStorageError::IntegerOverflow { .. } + ) +} + +/// Drop an undecodable record that a Core resync re-delivers, and force that resync. +/// +/// Only a block-confirmed record (typed `height` set) is re-delivered by a +/// filter rescan; an unconfirmed one may never be seen again, so it keeps the +/// migration failing rather than losing it. Lowering `synced_height` to just +/// below the wallet's birth height makes the next SPV start rescan the wallet +/// from its birth, which re-records the transaction and re-applies its spends. +/// The spent marks and outputs it already produced stay as they are: +/// conservative until the rescan confirms them. +fn drop_for_resync( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, + error: WalletStorageError, +) -> Result<(), WalletStorageError> { + let height: Option = tx.query_row( + "SELECT height FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| row.get(0), + )?; + if height.is_none() { + return Err(error); + } + let birth_height: i64 = tx.query_row( + "SELECT birth_height FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + let rescan_from = (birth_height - 1).max(0); + tx.execute( + "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_sync_state SET synced_height = MIN(COALESCE(synced_height, ?2), ?2) \ + WHERE wallet_id = ?1", + params![wallet_id.as_slice(), rescan_from], + )?; + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %txid, + %error, + rescan_from, + "dropped an undecodable confirmed transaction record; Core history rescans from birth" + ); + Ok(()) +} + +/// Index raw inputs independently of when their ownership becomes known. +fn index_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) VALUES (?1, ?2, ?3)", + )?; + for input in &record.transaction.input { + stmt.execute(params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode_outpoint(&input.previous_output)?, + ])?; + } + Ok(()) +} + +fn network( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let label: String = tx.query_row( + "SELECT network FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |r| r.get(0), + )?; + wallets::parse_network(&label).ok_or_else(|| WalletStorageError::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) +} + +fn owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let mut stmt = tx.prepare_cached( + "SELECT value, length(script), script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(outpoint)? + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let value = i64_to_u64("core_utxos.value", row.get(0)?)?; + blob::check_size(row.get(1)?)?; + let script: Vec = row.get(2)?; + if contact_only_script(tx, wallet_id, &script)? { + return Ok(None); + } + let address = Address::from_script(&ScriptBuf::from_bytes(script), network)?; + Ok(Some((value, address))) +} + +/// Whether `script` is tracked only by a contact's watch-only (DashPay external) chain. +fn contact_only_script( + conn: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) \ + AND NOT EXISTS(SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", + params![wallet_id.as_slice(), script], + |r| r.get(0), + )?) +} + +fn repair_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + mut record: TransactionRecord, + network: dashcore::Network, +) -> Result<(), WalletStorageError> { + let record_txid = record.txid; + let txid = &record_txid; + let original = blob::encode(&record)?; + let mut inputs = BTreeMap::new(); + for detail in record.input_details.drain(..) { + if !contact_only_script(tx, wallet_id, detail.address.script_pubkey().as_bytes())? { + inputs.insert(detail.index, detail); + } + } + for (index, input) in record.transaction.input.iter().enumerate() { + if let Some((value, address)) = + owned_output(tx, wallet_id, &input.previous_output, network)? + { + inputs.insert( + index as u32, + InputDetail { + index: index as u32, + value, + address, + }, + ); + // Stale mempool rows cannot overrule a later sweep's release. + if !matches!(record.context, TransactionContext::Mempool) { + // Record the spender so the mark stays attributable and + // reversible; an existing claim by another spender stands. + tx.execute( + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() + ], + )?; + } + } + } + let mut outputs = BTreeMap::new(); + for mut detail in record.output_details.drain(..) { + if let Some(address) = &detail.address { + if contact_only_script(tx, wallet_id, address.script_pubkey().as_bytes())? { + detail.role = OutputRole::Sent; + } + } + outputs.insert(detail.index, detail); + } + for (index, output) in record.transaction.output.iter().enumerate() { + let index = index as u32; + if let Some((_, address)) = owned_output( + tx, + wallet_id, + &OutPoint { + txid: *txid, + vout: index, + }, + network, + )? { + let role = outputs.get(&index).map_or(OutputRole::Received, |d| { + if d.role == OutputRole::Change { + OutputRole::Change + } else { + OutputRole::Received + } + }); + outputs.insert( + index, + OutputDetail { + index, + role, + address: Some(address), + value: output.value, + }, + ); + } + } + // Empty metadata is not accounting evidence (e.g. confirmation-only placeholders). + if inputs.is_empty() && outputs.is_empty() { + return Ok(()); + } + let received: i128 = outputs + .values() + .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) + .map(|d| i128::from(d.value)) + .sum(); + let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); + let net = received - spent; + record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: *txid, + value: net, + })?; + let has_ours = outputs + .values() + .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); + let has_external = record + .transaction + .output + .iter() + .enumerate() + .any(|(i, output)| { + !output.script_pubkey.is_op_return() + && !outputs.get(&(i as u32)).is_some_and(|d| { + matches!( + d.role, + OutputRole::Received | OutputRole::Change | OutputRole::Unspendable + ) + }) + }); + record.direction = if record.transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if inputs.is_empty() { + TransactionDirection::Incoming + } else if !has_external && (has_ours || record.transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + }; + record.input_details = inputs.into_values().collect(); + record.output_details = outputs.into_values().collect(); + let repaired = blob::encode(&record)?; + if repaired != original { + // Append-only: the first pre-repair blob is kept verbatim and never + // replaced, so a wrong repair can always be undone. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) \ + SELECT wallet_id, txid, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + repaired, + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ], + )?; + } + Ok(()) +} + +pub(super) fn repair_history(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { + // Keys are collected first so drops never race the open cursor. + let mut keys = Vec::new(); + { + let mut stmt = tx.prepare_cached( + "SELECT length(wallet_id), wallet_id, length(txid), txid \ + FROM core_transactions WHERE record_blob IS NOT NULL", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; + let wallet_id: Vec = row.get(1)?; + let wallet_id = id32("core_transactions.wallet_id", &wallet_id)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid: Vec = row.get(3)?; + keys.push((wallet_id, Txid::from_slice(&txid)?)); + } + } + for (wallet_id, txid) in keys { + match read_record(tx, &wallet_id, &txid) { + Ok(Some(record)) => { + index_record(tx, &wallet_id, &record)?; + repair_record(tx, &wallet_id, record, network(tx, &wallet_id)?)?; + } + Ok(None) => {} + Err(error) if is_unreadable(&error) => drop_for_resync(tx, &wallet_id, &txid, error)?, + Err(error) => return Err(error), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use dashcore::address::Payload; + use dashcore::{BlockHash, PubkeyHash, Transaction as CoreTransaction, TxIn, TxOut}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + use platform_wallet::changeset::CoreChangeSet; + use rusqlite::Connection; + + use super::*; + use crate::sqlite::migrations::rewind_to_v018; + use crate::sqlite::schema::core_state; + + fn address(marker: u8) -> Address { + Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([marker; 20])), + ) + } + + fn utxo(outpoint: OutPoint, value: u64, address: Address) -> Utxo { + Utxo { + outpoint, + txout: TxOut { + value, + script_pubkey: address.script_pubkey(), + }, + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } + } + + /// An oversize confirmed record fails its length gate before its payload + /// is read, and is dropped with a rescan from just below the birth height. + #[test] + fn should_drop_oversize_confirmed_record_for_resync() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xC2u8; 32]; + let txid = Txid::from_byte_array([0x72; 32]); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 50)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 900, 900)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 300, 1, zeroblob(?3))", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES + 1).unwrap() + ], + ) + .unwrap(); + rewind_to_v018(&conn); + + crate::sqlite::migrations::run(&mut conn).unwrap(); + + let (records, synced): (i64, i64) = conn + .query_row( + "SELECT (SELECT count(*) FROM core_transactions WHERE wallet_id = ?1), \ + (SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1)", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(records, 0, "the oversize record must be dropped"); + assert_eq!( + synced, 49, + "the rescan must restart just below the birth height" + ); + } + + /// Pins V019's observable result on a V018-shaped database: the repaired + /// record, the preserved original, the input index and the spent marks. + #[test] + fn should_pin_v019_repair_of_a_v018_database() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xC1u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let (own, change, contact, external) = (address(1), address(2), address(3), address(4)); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + let funding = OutPoint::new(Txid::from_byte_array([0x71; 32]), 0); + let body = CoreTransaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: funding, + ..Default::default() + }], + output: vec![ + TxOut { + value: 30_000, + script_pubkey: change.script_pubkey(), + }, + TxOut { + value: 50_000, + script_pubkey: contact.script_pubkey(), + }, + TxOut { + value: 15_000, + script_pubkey: external.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let txid = body.txid(); + // What an old build stored: the input was not known to be ours and + // the contact's output was credited as received. + let original = TransactionRecord::new( + body, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new(101, BlockHash::all_zeros(), 7)), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![ + OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(change.clone()), + value: 30_000, + }, + OutputDetail { + index: 1, + role: OutputRole::Received, + address: Some(contact), + value: 50_000, + }, + ], + 80_000, + ); + { + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![ + utxo(funding, 100_000, own.clone()), + utxo(OutPoint::new(txid, 0), 30_000, change.clone()), + ], + ..Default::default() + }, + ) + .unwrap(); + tx.execute( + "INSERT OR REPLACE INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 101, 1, ?3)", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + blob::encode(&original).unwrap() + ], + ) + .unwrap(); + rewind_to_v018(&tx); + tx.commit().unwrap(); + } + + crate::sqlite::migrations::run(&mut conn).unwrap(); + + let mut expected = original.clone(); + expected.input_details = vec![InputDetail { + index: 0, + value: 100_000, + address: own, + }]; + expected.output_details = vec![ + OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(change), + value: 30_000, + }, + OutputDetail { + index: 1, + role: OutputRole::Sent, + address: Some(address(3)), + value: 50_000, + }, + ]; + expected.net_amount = -70_000; + expected.direction = TransactionDirection::Outgoing; + let read_blob = |sql: &str| -> Vec { + conn.query_row( + sql, + params![&wallet_id[..], txid.as_byte_array().as_slice()], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + read_blob( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2" + ), + blob::encode(&expected).unwrap() + ); + assert_eq!( + read_blob( + "SELECT record_blob FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2" + ), + blob::encode(&original).unwrap() + ); + let (spent, spender): (bool, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(&funding).unwrap()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!(spent); + assert_eq!(spender.as_deref(), Some(txid.as_byte_array().as_slice())); + let indexed: i64 = conn + .query_row( + "SELECT count(*) FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2 AND outpoint = ?3", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + blob::encode_outpoint(&funding).unwrap() + ], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(indexed, 1); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 28623c535e2..546bc42a13c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -21,6 +21,7 @@ use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; use crate::sqlite::load_ctx::{LoadCtx, LoadDegradation, LoadSite}; use crate::sqlite::rehydrate::{ apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool, + restore_recorded_transactions, }; use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; use crate::sqlite::schema; @@ -1849,9 +1850,13 @@ fn load_one_wallet( )) })?; } - let (wallet, wallet_info) = - super::rehydrate::restore_confirmed_transactions(wallet_info, wallet, core_state.records) - .map_err(PersistenceError::from)?; + let mut wallet = wallet; + restore_recorded_transactions( + &mut wallet_info, + &mut wallet, + core_state.records, + &core_state.instant_locks_for_non_final_records, + ); Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, @@ -2418,6 +2423,9 @@ mod tests { "tracked_masternodes", // load_tracked_masternodes ]; const INFRASTRUCTURE: &[&str] = &["refinery_schema_history"]; + // Append-only archive of pre-repair history blobs. Never loaded: it + // exists so a wrong history repair can be undone by hand. + const RETAINED_FOR_RECOVERY: &[&str] = &["core_transaction_record_originals"]; // `load()` rehydrates these only with the `shielded` feature on, so // the classification follows the build rather than claiming one. #[cfg(feature = "shielded")] @@ -2459,6 +2467,7 @@ mod tests { && !READ_BY_A_DEDICATED_API.contains(&table.as_str()) && !LOAD_UNIMPLEMENTED_TABLES.contains(&table.as_str()) && !INFRASTRUCTURE.contains(&table.as_str()) + && !RETAINED_FOR_RECOVERY.contains(&table.as_str()) && !FEATURE_GATED.contains(&table.as_str()) && !NOT_REHYDRATED_WITHOUT_FEATURE.contains(&table.as_str()) }) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index db27b6106ce..56b800c521b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,14 +4,16 @@ //! the manager consumes the carried snapshot directly, so no wrong-seed check //! runs here; that gate lives in the resolver-backed signing entrypoints. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use dashcore::OutPoint; +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::{OutPoint, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; use key_wallet::managed_account::transaction_record::TransactionRecord; -use key_wallet::transaction_checking::WalletTransactionChecker; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -264,8 +266,9 @@ pub(crate) fn restore_provider_platform_node_pool( /// Coinbase-maturity nuance re-warms on sync. `is_instantlocked` is NOT /// among them: it is rebuilt from `core_instant_locks` above, for every /// UTXO a replayed lock covers. -/// - **Transaction records**: the SQLite loader replays confirmed history -/// through the wallet checker after this projection. +/// - **Transaction records**: the SQLite loader replays recorded history +/// through the wallet checker after this projection, in dependency order +/// (in-set parents first, otherwise chain order). /// /// # Errors /// @@ -420,12 +423,19 @@ pub fn apply_persisted_core_state( Ok(()) } -/// Restore spend and finality guards without re-crediting outputs excluded by persistence. -pub(crate) fn restore_confirmed_transactions( - mut wallet_info: ManagedWalletInfo, - mut wallet: Wallet, +/// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. +/// +/// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a +/// redelivered funding transaction would re-credit an output they reserve. +/// `instant_locks` are the persisted InstantSend locks: a lock that arrived +/// after its transaction was stored never rewrote the stored record, so the +/// replay upgrades that record's mempool context itself. +pub(crate) fn restore_recorded_transactions( + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, records: Vec, -) -> Result<(Wallet, ManagedWalletInfo), WalletStorageError> { + instant_locks: &BTreeMap, +) { // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. let placed: HashMap = wallet_info @@ -437,78 +447,189 @@ pub(crate) fn restore_confirmed_transactions( account.utxos.keys().map(move |outpoint| (*outpoint, owner)) }) .collect(); - let mut confirmed: Vec<_> = records - .into_iter() - .filter(|record| record.block_info().is_some()) - .collect(); - if confirmed.is_empty() { - return Ok((wallet, wallet_info)); + if records.is_empty() { + return; } - confirmed.sort_by_key(|record| { - record - .block_info() - .map(|block| (block.height(), block.position())) - }); - - // The checker only mutates in-memory state; no network requests or persistence. - dash_async::block_on(async move { - for record in confirmed { + // TODO(bound-load-history-replay): every stored record is replayed on each + // load; bounding it to records above the last chain lock needs care so + // finality and spend guards for older records are not lost. + // TODO(expire-unconfirmed-spend-reservations): mempool records are replayed + // on every load with no expiry, so a forged or never-mined spend of a wallet + // outpoint keeps its reservation across load and rescan; only a conflicting + // IS-locked or confirmed spend releases it. Sibling of + // TODO(release-repair-spends-after-reorg) in `core_history`. + let replay = replay_order(records); + + // Kept only to undo a replay the checker suspended part-way through. + let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); + let completed = poll_ready(async { + for record in replay { + // The lock set already holds this txid (load marked the restored + // UTXOs), so a later lock event is deduplicated: the InstantSend + // context, and the conflict sweep it runs, must come from here. + let lock = instant_locks + .get(&record.txid) + .filter(|lock| lock_matches_record(lock, &record)); + let context = match (record.context, lock) { + (TransactionContext::Mempool, Some(lock)) => { + TransactionContext::InstantSend(lock.clone()) + } + (context, _) => context, + }; wallet_info - .check_core_transaction( - &record.transaction, - record.context, - &mut wallet, - true, - false, - ) + .check_core_transaction(&record.transaction, context, wallet, true, false) .await; } + }) + .is_some(); + if !completed { + // Degrade to the pre-replay projection: persisted spends stay + // excluded, only redelivery guards are missing until the next sync. + tracing::error!( + wallet_id = %hex::encode(wallet_info.wallet_id), + "transaction checker suspended during load replay; restored spend guards skipped" + ); + *wallet_info = info_before; + *wallet = wallet_before; + return; + } - let spent: HashSet<_> = wallet_info - .observed_spent_outpoints() - .keys() - .copied() - .collect(); - // Replay credits an output to the account whose pool derives it. When - // that differs from the load-time fallback, the fallback copy is a - // duplicate: drop it so each outpoint lives in exactly one account. - let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .flat_map(|account| { - let owner = funds_account_type(account); - let placed = &placed; - account.utxos.keys().filter_map(move |outpoint| { - placed - .get(outpoint) - .filter(|parked| **parked != owner) - .map(|parked| (*outpoint, *parked)) - }) + let spent: HashSet<_> = wallet_info + .observed_spent_outpoints() + .keys() + .copied() + .collect(); + // Replay credits an output to the account whose pool derives it. When + // that differs from the load-time fallback, the fallback copy is a + // duplicate: drop it so each outpoint lives in exactly one account. + let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + let placed = &placed; + account.utxos.keys().filter_map(move |outpoint| { + placed + .get(outpoint) + .filter(|parked| **parked != owner) + .map(|parked| (*outpoint, *parked)) }) + }) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + account.utxos.retain(|outpoint, _| { + placed.contains_key(outpoint) + && !spent.contains(outpoint) + && !misplaced.contains(&(*outpoint, owner)) + }); + } + // Finalize replayed records before a sync checkpoint can prune their spend guards. + if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { + wallet_info.apply_chain_lock(chain_lock); + } + wallet_info.update_balance(); +} + +/// Whether `lock` really locks `record`, so upgrading its context is safe. +/// +/// The lock map is keyed by the stored `txid` column and a record's `txid` is +/// stored beside its transaction, so neither is proof on its own. A mismatch +/// keeps the record's stored context: the lock's conflict sweep must not drop +/// history on the strength of a lock that belongs to another transaction. +fn lock_matches_record(lock: &InstantLock, record: &TransactionRecord) -> bool { + let transaction_txid = record.transaction.txid(); + let matches = lock.txid == record.txid && transaction_txid == record.txid; + if !matches { + tracing::warn!( + record_txid = %record.txid, + transaction_txid = %transaction_txid, + lock_txid = %lock.txid, + "persisted InstantSend lock does not match its transaction record; replaying without it" + ); + } + matches +} + +/// Poll `future` once, returning its output only if it completed without suspending. +/// +/// The wallet checker is `async` only by trait shape: it never awaits, so it +/// completes on the first poll and load needs no async runtime. A test pins +/// that; an upstream change that adds a real await fails it. +fn poll_ready(future: F) -> Option { + let mut future = std::pin::pin!(future); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + match future.as_mut().poll(&mut cx) { + std::task::Poll::Ready(output) => Some(output), + std::task::Poll::Pending => None, + } +} + +/// Order records as the chain would deliver them: every in-set parent ahead of +/// its children, otherwise confirmed by block position, then unconfirmed. +/// +/// Dependencies span both partitions: a parent's stored record can still say +/// mempool after it confirmed (a height-only confirmation never rewrites an +/// existing record), while its child's record is already confirmed. +fn replay_order(records: Vec) -> Vec { + let mut records = records; + records.sort_by_key(|record| { + let block = record.block_info(); + ( + block.is_none(), + block.map(|block| (block.height(), block.position())), + record.txid, + ) + }); + let index: HashMap = records + .iter() + .enumerate() + .map(|(position, record)| (record.txid, position)) + .collect(); + let mut children: Vec> = vec![Vec::new(); records.len()]; + let mut waiting_on: Vec = vec![0; records.len()]; + for (child, record) in records.iter().enumerate() { + let parents: BTreeSet = record + .transaction + .input + .iter() + .filter_map(|input| index.get(&input.previous_output.txid).copied()) + .filter(|parent| *parent != child) .collect(); - for account in wallet_info.accounts.all_funding_accounts_mut() { - let owner = funds_account_type(account); - account.utxos.retain(|outpoint, _| { - placed.contains_key(outpoint) - && !spent.contains(outpoint) - && !misplaced.contains(&(*outpoint, owner)) - }); + waiting_on[child] = parents.len(); + for parent in parents { + children[parent].push(child); } - // Finalize replayed records before a sync checkpoint can prune their spend guards. - if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { - wallet_info.apply_chain_lock(chain_lock); + } + // Sorted positions, so the smallest ready one is always next in chain order. + let mut ready: BTreeSet = (0..records.len()) + .filter(|position| waiting_on[*position] == 0) + .collect(); + let mut emitted = vec![false; records.len()]; + let mut order = Vec::with_capacity(records.len()); + while let Some(position) = ready.pop_first() { + emitted[position] = true; + order.push(position); + for &child in &children[position] { + waiting_on[child] -= 1; + if waiting_on[child] == 0 { + ready.insert(child); + } } - wallet_info.update_balance(); - (wallet, wallet_info) - }) - .map_err(WalletStorageError::CoreHistoryReplay) + } + // Unreachable for real transactions (txids cannot form a cycle); keep the + // rest in chain order rather than drop a reservation. + order.extend((0..records.len()).filter(|position| !emitted[*position])); + let mut slots: Vec> = records.into_iter().map(Some).collect(); + order + .into_iter() + .filter_map(|position| slots[position].take()) + .collect() } /// Account identity of a funds account, stable across replay mutations. -fn funds_account_type( - account: &key_wallet::managed_account::ManagedCoreFundsAccount, -) -> AccountType { +fn funds_account_type(account: &ManagedCoreFundsAccount) -> AccountType { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; account.managed_account_type().to_account_type() } @@ -548,9 +669,7 @@ fn route_to_funds_account( /// to pick one account among funding accounts that share a numeric index /// (Standard BIP44/BIP32 and CoinJoin can all sit at index 0; DashPay accounts /// all carry index 0 and differ only by the identity pair). -fn owning_account_of( - account: &key_wallet::managed_account::ManagedCoreFundsAccount, -) -> OwningAccount { +fn owning_account_of(account: &ManagedCoreFundsAccount) -> OwningAccount { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let at = account.managed_account_type().to_account_type(); let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(&at); @@ -626,7 +745,7 @@ const MAX_NORMAL_CHILD_INDEX: u32 = (1u32 << 31) - 1; /// /// Never touches key material — the xpub is the keyless account public key. fn extend_pools_for_restored_addresses( - account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + account: &mut ManagedCoreFundsAccount, manifest: &[AccountRegistrationEntry], restored_addresses: &[key_wallet::Address], wallet_id: [u8; 32], @@ -3256,4 +3375,525 @@ mod tests { "the restored UTXO must carry instant-locked status, not wait for the next sync" ); } + + /// A fresh random wallet and its first BIP44 receive address. + fn wallet_with_receive_address() -> (Wallet, ManagedWalletInfo, dashcore::Address) { + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + (wallet, info, address) + } + + /// Load replays history without an async runtime by polling the checker + /// once. If upstream ever makes it suspend, this fails in CI instead of + /// load silently skipping the restored spend guards in production. + #[test] + fn should_complete_transaction_checker_on_first_poll() { + use dashcore::hashes::Hash; + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([9; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let block = + TransactionContext::InBlock(BlockInfo::new(1, dashcore::BlockHash::all_zeros(), 1)); + for (tx, context) in [(&funding, block), (&spend, TransactionContext::Mempool)] { + let result = + poll_ready(info.check_core_transaction(tx, context, &mut wallet, true, false)); + assert!( + result.is_some_and(|r| r.is_relevant), + "the checker must complete on its first poll" + ); + } + } + + /// Same-block funding and spend, with and without in-block positions: an + /// output the load projection still parks as unspent must end up excluded, + /// and recorded as observed spent, whichever of the two is stored first. + #[tokio::test] + async fn should_exclude_spent_output_for_either_same_height_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + for (spend_first, positioned) in + [(false, false), (true, false), (false, true), (true, true)] + { + let case = format!("spend_first={spend_first} positioned={positioned}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([15; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let (spent, available) = ( + OutPoint::new(funding.txid(), 0), + OutPoint::new(funding.txid(), 1), + ); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let context = |position: u32| { + let block = BlockInfo::new(100, BlockHash::from_byte_array([7; 32]), 100); + TransactionContext::InBlock(if positioned { + block.with_position(position) + } else { + block + }) + }; + let mut records = info + .check_core_transaction(&funding, context(1), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, context(2), &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|r| r + .block_info() + .is_some_and(|b| b.position().is_some() == positioned))); + if spend_first { + records.reverse(); + } + + // A stale projection that still parks the spent output as unspent. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for outpoint in [spent, available] { + account.utxos.insert( + outpoint, + Utxo { + outpoint, + txout: funding.output[outpoint.vout as usize].clone(), + address: address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()); + + let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&spent), "{case}"); + assert!(coins.contains_key(&available), "{case}"); + assert!( + restored.observed_spent_outpoints().contains_key(&spent), + "{case}" + ); + assert_eq!(restored.balance.total(), 20_000, "{case}"); + } + } + + /// A lock that arrived after its transaction was stored lives only in + /// `core_instant_locks`; the stored record still says mempool. Replay must + /// restore the InstantSend context and run its conflict sweep, since the + /// already-marked lock deduplicates any later lock event. + #[tokio::test] + async fn should_replay_mempool_record_with_persisted_lock_as_instant_send() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([21; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = |value| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + // Both double spends as stored: unconfirmed, recorded independently. + let (mut winner, mut loser) = (spend(99_000), spend(98_000)); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + assert_eq!(records.len(), 3); + // Unconfirmed siblings replay by txid: lock the later one so the + // loser is already recorded when the winner's sweep runs. + if winner.txid() < loser.txid() { + std::mem::swap(&mut winner, &mut loser); + } + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + + let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); + + // Alone, the locked spend comes back InstantSend. + let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); + let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; + restore_recorded_transactions(&mut alone, &mut wallet, pair, &locks); + assert!( + alone.accounts.standard_bip44_accounts[&0] + .transactions() + .get(&winner.txid()) + .is_some_and(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "the locked record must come back InstantSend, not mempool" + ); + + // Replayed after a conflicting spend, its lock sweeps that spend. + let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); + restore_recorded_transactions(&mut contested, &mut wallet, records.clone(), &locks); + assert!( + !contested.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid()), + "the lock's conflict sweep must drop the competing spend" + ); + } + + /// A persisted lock is trusted only when it names the record it is keyed + /// under and that record's transaction really has that txid; otherwise the + /// record replays in its stored mempool context and no sweep runs. + #[tokio::test] + async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction( + &spend, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + let foreign = Txid::from_byte_array([24; 32]); + let lock_for = |txid| InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid, + ..Default::default() + }; + let mut forged_record = records.clone(); + forged_record + .iter_mut() + .find(|record| record.txid == spend.txid()) + .unwrap() + .txid = foreign; + let cases = [ + // The lock row is keyed under the record but locks another txid. + ( + "lock txid", + records.clone(), + spend.txid(), + lock_for(foreign), + ), + // Record and lock agree, but the record's transaction is another one. + ("record txid", forged_record, foreign, lock_for(foreign)), + ]; + for (case, records, key, lock) in cases { + let locks: BTreeMap = [(key, lock)].into_iter().collect(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); + let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions + .values() + .any(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "{case}: a mismatched lock must not upgrade any record" + ); + assert!( + transactions.contains_key(&spend.txid()), + "{case}: the spend must still replay in its stored context" + ); + } + } + + /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. + fn replay_record( + parents: &[Txid], + value: u64, + context: TransactionContext, + ) -> TransactionRecord { + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + + let transaction = Transaction { + version: 1, + lock_time: 0, + input: parents + .iter() + .map(|parent| TxIn { + previous_output: OutPoint::new(*parent, 0), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + fn in_block(height: u32, position: Option) -> TransactionContext { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::BlockInfo; + + let block = BlockInfo::new(height, dashcore::BlockHash::all_zeros(), height); + TransactionContext::InBlock(position.map_or(block, |p| block.with_position(p))) + } + + fn replayed_txids(records: Vec) -> Vec { + replay_order(records).into_iter().map(|r| r.txid).collect() + } + + /// An unconfirmed child whose txid sorts ahead of its parent's still + /// replays after it, so its input reserves the parent's output. + #[test] + fn should_replay_unconfirmed_parent_before_its_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([1; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = (0..) + .map(|value| replay_record(&[parent.txid], value, TransactionContext::Mempool)) + .find(|child| child.txid < parent.txid) + .unwrap(); + let expected = vec![parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, parent]), expected); + } + + /// A parent whose stored record is still mempool replays ahead of a child + /// already recorded as confirmed. + #[test] + fn should_replay_mempool_parent_before_its_confirmed_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([2; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = replay_record(&[parent.txid], 900, in_block(50, Some(3))); + let unrelated = replay_record(&[Txid::from_byte_array([3; 32])], 700, in_block(40, None)); + let expected = vec![unrelated.txid, parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, unrelated, parent]), expected); + } + + /// Independent records follow chain order: height, then in-block + /// position, then unconfirmed. + #[test] + fn should_order_independent_records_by_height_then_block_position() { + use dashcore::hashes::Hash; + + let funding = |marker| [Txid::from_byte_array([marker; 32])]; + let pending = replay_record(&funding(4), 1, TransactionContext::Mempool); + let late_second = replay_record(&funding(5), 2, in_block(10, Some(2))); + let late_first = replay_record(&funding(6), 3, in_block(10, Some(1))); + let early = replay_record(&funding(7), 4, in_block(9, Some(5))); + let expected = vec![early.txid, late_first.txid, late_second.txid, pending.txid]; + + assert_eq!( + replayed_txids(vec![pending, late_second, late_first, early]), + expected + ); + } + + /// Within one block, in-block position decides: a spend follows the + /// funding transaction it spends, and unrelated transactions keep their + /// place around the pair. + #[test] + fn should_keep_block_position_order_for_same_block_spends() { + use dashcore::hashes::Hash; + + let parent = replay_record(&[Txid::from_byte_array([9; 32])], 10, in_block(20, Some(1))); + let child = replay_record(&[parent.txid], 9, in_block(20, Some(2))); + let before = replay_record(&[Txid::from_byte_array([10; 32])], 8, in_block(20, Some(0))); + let after = replay_record(&[Txid::from_byte_array([11; 32])], 7, in_block(20, Some(3))); + let expected = vec![before.txid, parent.txid, child.txid, after.txid]; + + assert_eq!(replayed_txids(vec![after, child, before, parent]), expected); + } + + /// Records that name each other as parents (impossible for real txids) + /// still all replay, after everything that is ready. + #[test] + fn should_keep_every_record_of_a_dependency_cycle() { + use dashcore::hashes::Hash; + + let (a, b) = ( + Txid::from_byte_array([0xAA; 32]), + Txid::from_byte_array([0xBB; 32]), + ); + let mut first = replay_record(&[b], 1, TransactionContext::Mempool); + first.txid = a; + let mut second = replay_record(&[a], 2, TransactionContext::Mempool); + second.txid = b; + let ready = replay_record( + &[Txid::from_byte_array([8; 32])], + 3, + TransactionContext::Mempool, + ); + let expected = vec![ready.txid, a, b]; + + assert_eq!(replayed_txids(vec![second, first, ready]), expected); + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 7213558f3b4..d7ab594edcd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -653,6 +653,10 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ "platform_payment", ]; +/// Database label of `AccountType::DashpayExternalAccount`; SQL that +/// singles out contact watch-only rows binds this instead of a literal. +pub(crate) const DASHPAY_EXTERNAL_LABEL: &str = "dashpay_external"; + /// Stable database label for an `AccountType` variant (the `Debug` impl is not /// a stable format; this match is the contract). An added upstream variant /// fails this match's exhaustiveness check at compile time. @@ -715,7 +719,7 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' AccountType::ProviderOperatorKeys => "provider_operator", AccountType::ProviderPlatformKeys => "provider_platform", AccountType::DashpayReceivingFunds { .. } => "dashpay_receiving", - AccountType::DashpayExternalAccount { .. } => "dashpay_external", + AccountType::DashpayExternalAccount { .. } => DASHPAY_EXTERNAL_LABEL, AccountType::PlatformPayment { .. } => "platform_payment", } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index dd7081c4668..7d9a91f3ab7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -12,6 +12,7 @@ use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; +use super::accounts::DASHPAY_EXTERNAL_LABEL; use super::{blob, core_state, wallets}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::LoadCtx; @@ -24,15 +25,14 @@ pub(super) fn preserve_known_details( incoming: &TransactionRecord, ) -> Result { let mut merged = incoming.clone(); - let Some(previous) = - core_state::get_tx_record(tx, wallet_id, &incoming.txid, &LoadCtx::strict())? - else { + let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { return Ok(merged); }; if previous.transaction != incoming.transaction { - return Err(WalletStorageError::blob_decode( - "same transaction id has different raw transaction bodies", - )); + return Err(WalletStorageError::TransactionBodyConflict { + wallet_id: *wallet_id, + txid: incoming.txid, + }); } let mut inputs: BTreeMap<_, _> = previous .input_details @@ -61,6 +61,15 @@ pub(super) fn preserve_known_details( Ok(merged) } +/// Read a stored record strictly: corrupt history is an error, never skipped. +fn prior_record( + conn: &Connection, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + core_state::get_tx_record(conn, wallet_id, txid, &LoadCtx::strict()) +} + /// Index raw inputs independently of when their ownership becomes known. pub(super) fn index_record( tx: &Transaction<'_>, @@ -68,7 +77,8 @@ pub(super) fn index_record( record: &TransactionRecord, ) -> Result<(), WalletStorageError> { let mut stmt = tx.prepare_cached( - "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) VALUES (?1, ?2, ?3)", + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) \ + VALUES (?1, ?2, ?3)", )?; for input in &record.transaction.input { stmt.execute(params![ @@ -120,8 +130,10 @@ fn network( params![wallet_id.as_slice()], |r| r.get(0), )?; - wallets::parse_network(&label) - .ok_or_else(|| WalletStorageError::blob_decode("wallets.network is unknown")) + wallets::parse_network(&label).ok_or_else(|| WalletStorageError::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) } fn owned_output( @@ -130,7 +142,10 @@ fn owned_output( outpoint: &OutPoint, network: dashcore::Network, ) -> Result, WalletStorageError> { - let mut stmt = tx.prepare_cached("SELECT value, length(script), script FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0")?; + let mut stmt = tx.prepare_cached( + "SELECT value, length(script), script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )?; let mut rows = stmt.query(params![ wallet_id.as_slice(), blob::encode_outpoint(outpoint)? @@ -155,8 +170,12 @@ pub(crate) fn contact_only_script( script: &[u8], ) -> Result { Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) AND NOT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", - params![wallet_id.as_slice(), script], |r| r.get(0))?) + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) \ + AND NOT EXISTS(SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 AND account_type != ?3)", + params![wallet_id.as_slice(), script, DASHPAY_EXTERNAL_LABEL], + |r| r.get(0), + )?) } fn repair_record( @@ -165,8 +184,7 @@ fn repair_record( txid: &Txid, network: dashcore::Network, ) -> Result<(), WalletStorageError> { - let Some(mut record) = core_state::get_tx_record(tx, wallet_id, txid, &LoadCtx::strict())? - else { + let Some(mut record) = prior_record(tx, wallet_id, txid)? else { return Ok(()); }; let original = blob::encode(&record)?; @@ -190,11 +208,21 @@ fn repair_record( ); // Stale mempool rows cannot overrule a later sweep's release. if !matches!(record.context, TransactionContext::Mempool) { + // Record the spender so the mark stays attributable and + // reversible; an existing claim by another spender stands. + // TODO(release-repair-spends-after-reorg): release rows whose + // `spent_in_txid` spender is reorged out and never re-mined; + // needs verification of how upstream downgrades a stored + // record's context on reorg. tx.execute( - "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", params![ wallet_id.as_slice(), - blob::encode_outpoint(&input.previous_output)? + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() ], )?; } @@ -248,8 +276,11 @@ fn repair_record( .map(|d| i128::from(d.value)) .sum(); let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); - record.net_amount = i64::try_from(received - spent).map_err(|_| { - WalletStorageError::blob_decode("wallet transaction net amount exceeds i64") + let net = received - spent; + record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: *txid, + value: net, })?; let has_ours = outputs .values() @@ -268,19 +299,24 @@ fn repair_record( ) }) }); - record.direction = if record.transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else if inputs.is_empty() { - TransactionDirection::Incoming - } else if !has_external && (has_ours || record.transaction_type == TransactionType::AssetLock) { - TransactionDirection::Internal - } else { - TransactionDirection::Outgoing - }; + record.direction = repaired_direction( + record.transaction_type, + !inputs.is_empty(), + has_ours, + has_external, + ); record.input_details = inputs.into_values().collect(); record.output_details = outputs.into_values().collect(); let repaired = blob::encode(&record)?; if repaired != original { + // Append-only: the first pre-repair blob is kept verbatim and never + // replaced, so a wrong repair can always be undone. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) \ + SELECT wallet_id, txid, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; tx.execute( "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", params![ @@ -293,22 +329,192 @@ fn repair_record( Ok(()) } -/// Backfill the input index and correct existing history in the migration transaction. -pub(crate) fn migrate(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { - let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; - let wallet_id: Vec = row.get(1)?; - let wallet_id = super::id32("core_transactions.wallet_id", &wallet_id)?; - blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; - let txid: Vec = row.get(3)?; - let txid = Txid::from_slice(&txid)?; - if let Some(record) = core_state::get_tx_record(tx, &wallet_id, &txid, &LoadCtx::strict())? - { - index_record(tx, &wallet_id, &record)?; - repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?)?; +/// Direction of a repaired record. The Swift SDK's +/// `PersistentTransaction.reconciledAccounting` applies the same rule; keep +/// both in step (each side tests the same case table). +/// +/// `has_external` counts every output that is neither ours nor an OP_RETURN +/// burn, so an asset lock is internal only when nothing leaves the wallet. +fn repaired_direction( + transaction_type: TransactionType, + spends_ours: bool, + has_ours: bool, + has_external: bool, +) -> TransactionDirection { + if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !spends_ours { + TransactionDirection::Incoming + } else if !has_external && (has_ours || transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + } +} + +#[cfg(test)] +mod tests { + use dashcore::address::Payload; + use dashcore::{PubkeyHash, Transaction as CoreTransaction, TxOut}; + use key_wallet::account::{AccountType, StandardAccountType}; + + use super::*; + + const WALLET_ID: WalletId = [0x5Au8; 32]; + + fn wallet_db(network: &str) -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + // The schema CHECK rejects unknown labels; a corrupt or newer file may still carry one. + conn.pragma_update(None, "ignore_check_constraints", true) + .unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, 0)", + params![&WALLET_ID[..], network], + ) + .unwrap(); + conn + } + + fn record(output_values: &[u64], received: &[u64]) -> TransactionRecord { + let script = Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([7; 20])), + ) + .script_pubkey(); + let body = CoreTransaction { + version: 1, + lock_time: 0, + input: Vec::new(), + output: output_values + .iter() + .map(|&value| TxOut { + value, + script_pubkey: script.clone(), + }) + .collect(), + special_transaction_payload: None, + }; + let details = received + .iter() + .enumerate() + .map(|(index, &value)| OutputDetail { + index: index as u32, + role: OutputRole::Received, + address: None, + value, + }) + .collect(); + TransactionRecord::new( + body, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + details, + 0, + ) + } + + fn store(conn: &Connection, record: &TransactionRecord) { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![ + &WALLET_ID[..], + record.txid.as_byte_array().as_slice(), + blob::encode(record).unwrap() + ], + ) + .unwrap(); + } + + #[test] + fn should_report_a_body_conflict_for_the_same_txid_with_another_body() { + let mut conn = wallet_db("testnet"); + let stored = record(&[1_000], &[]); + store(&conn, &stored); + let mut incoming = record(&[2_000], &[]); + incoming.txid = stored.txid; + + let tx = conn.transaction().unwrap(); + let err = preserve_known_details(&tx, &WALLET_ID, &incoming).unwrap_err(); + + assert!( + matches!( + err, + WalletStorageError::TransactionBodyConflict { wallet_id, txid } + if wallet_id == WALLET_ID && txid == stored.txid + ), + "got {err:?}" + ); + } + + #[test] + fn should_report_an_unknown_wallet_network_label() { + let mut conn = wallet_db("moonnet"); + let tx = conn.transaction().unwrap(); + + let err = network(&tx, &WALLET_ID).unwrap_err(); + + assert!( + matches!( + &err, + WalletStorageError::UnknownWalletNetwork { wallet_id, label } + if *wallet_id == WALLET_ID && label == "moonnet" + ), + "got {err:?}" + ); + } + + #[test] + fn should_report_a_net_amount_that_does_not_fit_i64() { + let mut conn = wallet_db("testnet"); + let stored = record(&[u64::MAX, u64::MAX], &[u64::MAX, u64::MAX]); + store(&conn, &stored); + let tx = conn.transaction().unwrap(); + + let err = + repair_record(&tx, &WALLET_ID, &stored.txid, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!( + err, + WalletStorageError::NetAmountOverflow { wallet_id, txid, value } + if wallet_id == WALLET_ID + && txid == stored.txid + && value == 2 * i128::from(u64::MAX) + ), + "got {err:?}" + ); + } + + /// Shared with the Swift SDK's `TransactionAccountingTests` direction table. + #[test] + fn should_classify_repaired_direction_like_the_swift_sdk() { + use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; + use TransactionType::{AssetLock, Standard}; + // (type, spends ours, has owned output, has external output, expected) + let cases = [ + (Standard, true, true, false, Internal), + (Standard, true, true, true, Outgoing), + (Standard, true, false, false, Outgoing), + (AssetLock, true, false, false, Internal), + (AssetLock, true, true, false, Internal), + (AssetLock, true, false, true, Outgoing), + (Standard, false, true, false, Incoming), + (TransactionType::CoinJoin, true, true, false, CoinJoin), + ]; + for (kind, spends_ours, has_ours, has_external, expected) in cases { + assert_eq!( + repaired_direction(kind, spends_ours, has_ours, has_external), + expected, + "{kind:?} spends_ours={spends_ours} has_ours={has_ours} has_external={has_external}" + ); } } - Ok(()) } 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 a6b1c32c6a2..a9ee0597b90 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 @@ -18,6 +18,7 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; +use crate::sqlite::schema::core_history; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; // PUBLIC material only: core-chain state reaching `record_blob` / @@ -94,7 +95,7 @@ pub fn apply( record_blob = excluded.record_blob", )?; for incoming in &cs.records { - let record = super::core_history::preserve_known_details(tx, wallet_id, incoming)?; + let record = core_history::preserve_known_details(tx, wallet_id, incoming)?; let block_info = record.block_info(); let height = block_info.map(|b| i64::from(b.height())); let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); @@ -110,7 +111,7 @@ pub fn apply( finalized, payload, ])?; - super::core_history::index_record(tx, wallet_id, &record)?; + core_history::index_record(tx, wallet_id, &record)?; } } // `addresses_derived` is intentionally NOT persisted here — the pool @@ -247,7 +248,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - super::core_history::apply(tx, wallet_id, cs)?; + core_history::apply(tx, wallet_id, cs)?; return Ok(()); } @@ -411,7 +412,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - super::core_history::apply(tx, wallet_id, cs)?; + core_history::apply(tx, wallet_id, cs)?; Ok(()) } @@ -1009,7 +1010,7 @@ pub fn load_state( let script = dashcore::ScriptBuf::from_bytes(script_bytes); // A contact's watch-only output is never ours to spend, whatever // an older build recorded; the fallback would make it spendable. - if super::core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { + if core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { continue; } if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { @@ -1366,6 +1367,7 @@ pub fn list_unspent_utxos( #[cfg(test)] mod tests { use super::*; + use crate::sqlite::migrations::rewind_to_v018; use dashcore::address::Payload; use dashcore::hashes::Hash; use dashcore::{BlockHash, OutPoint, PubkeyHash, Transaction, TxOut, Txid}; @@ -1510,6 +1512,36 @@ mod tests { ) .unwrap(); assert!(spent, "late funding must not resurrect the spent coin"); + let spender: Option> = tx + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + spender.as_deref(), + Some(AsRef::<[u8]>::as_ref(&spending.txid)), + "a repair mark names its spender so it can be reverted" + ); + let original: Vec = tx + .query_row( + "SELECT record_blob FROM core_transaction_record_originals \ + WHERE wallet_id = ?1 AND txid = ?2", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&spending.txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + blob::decode::(&original) + .unwrap() + .net_amount, + 90_000, + "the pre-repair record is kept verbatim" + ); apply( &tx, &wallet_id, @@ -1567,7 +1599,12 @@ mod tests { ], ) .unwrap(); - tx.execute_batch("DROP TABLE IF EXISTS core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + tx.execute_batch( + "DROP TABLE IF EXISTS core_transaction_inputs; \ + DROP TABLE IF EXISTS core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); tx.commit().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let repaired = get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) @@ -1597,21 +1634,54 @@ mod tests { .net_amount, -150_000 ); + let original: Vec = conn + .query_row( + "SELECT record_blob FROM core_transaction_record_originals \ + WHERE wallet_id = ?1 AND txid = ?2", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&spending.txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + original, + blob::encode(&spending).unwrap(), + "V019 keeps the record it rewrote" + ); } - #[test] - fn should_roll_back_history_migration_on_corrupt_record() { + /// A V018 database whose wallet 0xAD has one corrupt record at `height`. + fn v018_with_corrupt_record(height: Option) -> (Connection, [u8; 32]) { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); - conn.execute_batch("DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + rewind_to_v018(&conn); let wallet_id = [0xADu8; 32]; conn.execute( - "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 100)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 500, 500)", params![&wallet_id[..]], ) .unwrap(); - conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![&wallet_id[..], &[0u8;32][..], &[0xffu8][..]]).unwrap(); - assert!(crate::sqlite::migrations::run(&mut conn).is_err()); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, ?3, 0, ?4)", + params![&wallet_id[..], &[0u8; 32][..], height, &[0xffu8][..]], + ) + .unwrap(); + (conn, wallet_id) + } + + #[test] + fn should_fail_history_migration_on_corrupt_unconfirmed_record() { + let (mut conn, _) = v018_with_corrupt_record(None); + assert!( + crate::sqlite::migrations::run(&mut conn).is_err(), + "a resync cannot restore an unconfirmed record, so it must not be dropped" + ); let tables: i64 = conn .query_row( "SELECT count(*) FROM sqlite_master WHERE name = 'core_transaction_inputs'", @@ -1631,8 +1701,96 @@ mod tests { } #[test] - fn should_keep_uncredited_outputs_spent() { - use platform_wallet::changeset::changeset::UtxoCreditVerdict; + fn should_drop_corrupt_confirmed_record_and_rescan_its_wallet() { + let (mut conn, wallet_id) = v018_with_corrupt_record(Some(150)); + let kept = transaction_record( + Txid::from_byte_array([0x11; 32]), + TransactionContext::InBlock(BlockInfo::new(160, BlockHash::all_zeros(), 1)), + ); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 160, 1, ?3)", + params![ + &wallet_id[..], + AsRef::<[u8]>::as_ref(&kept.txid), + blob::encode(&kept).unwrap() + ], + ) + .unwrap(); + crate::sqlite::migrations::run(&mut conn) + .expect("a re-deliverable corrupt record must not block opening"); + let rows: Vec> = conn + .prepare_cached("SELECT txid FROM core_transactions WHERE wallet_id = ?1") + .unwrap() + .query_map(params![&wallet_id[..]], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows, vec![AsRef::<[u8]>::as_ref(&kept.txid).to_vec()]); + let (last_processed, synced): (i64, i64) = conn + .query_row( + "SELECT last_processed_height, synced_height FROM core_sync_state \ + WHERE wallet_id = ?1", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!( + synced, 99, + "the filter checkpoint rewinds to just below birth" + ); + assert_eq!( + last_processed, 500, + "the processed watermark stays monotonic" + ); + let (cs, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(cs.synced_height, Some(99), "load hands the rewind to SPV"); + } + + #[test] + fn should_reject_corrupt_prior_record_when_storing_the_transaction() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xA9u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![ + &wallet_id[..], + AsRef::<[u8]>::as_ref(&record.txid), + &[0xffu8][..] + ], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + assert!( + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record], + ..Default::default() + }, + ) + .is_err(), + "normal operation treats corrupt stored history as an error" + ); + } + + #[test] + fn should_mark_observed_spent_and_doomed_outputs_spent() { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xAEu8; 32]; @@ -1669,6 +1827,59 @@ mod tests { } } + #[test] + fn should_keep_prior_spent_flag_for_uncredited_outputs() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xA8u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + let stored_spent = |outpoint: &OutPoint| -> Option { + tx.query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| row.get(0), + ) + .optional() + .unwrap() + }; + let uncredited = |utxo: Utxo| CoreChangeSet { + utxo_credit_verdicts: [(utxo.outpoint, UtxoCreditVerdict::Uncredited)].into(), + new_utxos: vec![utxo], + ..Default::default() + }; + + let fresh = sample_utxo(Txid::from_byte_array([3; 32]), 100, true); + apply(&tx, &wallet_id, &uncredited(fresh.clone())).unwrap(); + assert_eq!( + stored_spent(&fresh.outpoint), + None, + "never materialize an uncredited output" + ); + + let known = sample_utxo(Txid::from_byte_array([4; 32]), 100, true); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![known.clone()], + utxo_credit_verdicts: [(known.outpoint, UtxoCreditVerdict::Doomed)].into(), + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &uncredited(known.clone())).unwrap(); + assert_eq!( + stored_spent(&known.outpoint), + Some(true), + "an uncredited replay keeps the spend" + ); + } + #[test] fn should_exclude_historical_contact_outputs_from_accounting() { use key_wallet::managed_account::transaction_record::{OutputDetail, OutputRole}; @@ -1692,7 +1903,13 @@ mod tests { }]; record.net_amount = output.value() as i64; output.outpoint.txid = record.txid; - conn.execute("INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", params![&wallet_id[..], output.txout.script_pubkey.as_bytes()]).unwrap(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], output.txout.script_pubkey.as_bytes()], + ) + .unwrap(); let tx = conn.transaction().unwrap(); apply( &tx, @@ -1771,14 +1988,12 @@ mod tests { crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xB2u8; 32]; let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); - conn.execute_batch( - "DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;", - ) - .unwrap(); + rewind_to_v018(&conn); crate::sqlite::migrations::run(&mut conn).unwrap(); let unspent_rows = |outpoint: &OutPoint| -> i64 { conn.query_row( - "SELECT count(*) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 0", + "SELECT count(*) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 0", params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], |r| r.get(0), ) diff --git a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs index 79e7ea60238..4954aae4323 100644 --- a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; +use dashcore::hashes::Hash; use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; use platform_wallet_storage::sqlite::error::{AutoBackupOperation, WalletStorageError}; use platform_wallet_storage::sqlite::util::safe_cast::SafeCastTarget; @@ -150,6 +151,39 @@ fn tc_code_004_b_identity_index_variants_map_to_constraint_kind() { } } +/// History invariants are enforced in Rust on the write path: an incoming +/// record that contradicts stored history is a data fault, not a retryable +/// or engine failure. +#[test] +fn history_integrity_variants_map_to_constraint_kind() { + let txid = dashcore::Txid::from_byte_array([0x44; 32]); + let cases: Vec<(&str, WalletStorageError)> = vec![ + ( + "TransactionBodyConflict", + WalletStorageError::TransactionBodyConflict { + wallet_id: [0xAA; 32], + txid, + }, + ), + ( + "NetAmountOverflow", + WalletStorageError::NetAmountOverflow { + wallet_id: [0xAA; 32], + txid, + value: i128::from(i64::MIN) - 1, + }, + ), + ]; + for (label, err) in cases { + assert!(!err.is_transient(), "{label}: must not be transient"); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Constraint, + "{label}: trait-boundary kind must be Constraint" + ); + } +} + /// Every remaining fatal-but-not-constraint variant maps to `Fatal`. /// Spot-check enough variants to lock the table; the /// exhaustiveness is guarded by the wildcard-free invariant test. @@ -232,6 +266,13 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { "BlobDecode", WalletStorageError::BlobDecode { reason: "len" }, ), + ( + "UnknownWalletNetwork", + WalletStorageError::UnknownWalletNetwork { + wallet_id: [0xAA; 32], + label: "moonnet".into(), + }, + ), ( "ForeignKeysNotEnforced", WalletStorageError::ForeignKeysNotEnforced, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 935b204a2bc..6884ea5dca3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -4,10 +4,10 @@ //! plus the boundary mapping of `FlushRetryable` into //! `PersistenceError::Backend`. //! -//! The check is a wildcard-free `match` with one arm per variant (no -//! `_`), so a new `WalletStorageError` variant fails to compile here -//! until it is classified — mirroring the matches in `error::is_transient` -//! / `error::error_kind_str`. +//! The check is a `match` with one arm per variant. The enum is +//! `#[non_exhaustive]`, so this external test needs a panicking `_` arm; +//! the compile-time guard lives in the wildcard-free matches of +//! `error::is_transient` / `error::error_kind_str`. use std::path::PathBuf; @@ -214,6 +214,19 @@ fn samples() -> Vec { typed_height: Some(100), blob_height: Some(101), }, + WalletStorageError::TransactionBodyConflict { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + }, + WalletStorageError::UnknownWalletNetwork { + wallet_id: [0x33; 32], + label: "moonnet".into(), + }, + WalletStorageError::NetAmountOverflow { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + value: i128::from(u64::MAX) * 2, + }, WalletStorageError::BlobTooLarge { len_bytes: 32 * 1024 * 1024, limit_bytes: 16 * 1024 * 1024, @@ -351,7 +364,6 @@ fn samples() -> Vec { highest_used: Some(u32::MAX - 5), gap_limit: 20, }, - WalletStorageError::CoreHistoryReplay(dash_async::AsyncError::Generic("test".into())), WalletStorageError::DatabasePathIsSymlink { path: PathBuf::from("/tmp/wallet.db"), }, @@ -364,10 +376,8 @@ fn samples() -> Vec { #[test] fn tc_p2_005_is_transient_table() { fn classify(err: &WalletStorageError) -> (bool, &'static str) { - // Every arm asserts the expected (transient, kind_str) pair - // and returns it for the outer assertion. A new variant - // landing in WalletStorageError makes this match fail to - // compile until classified. + // Every arm returns the expected (transient, kind_str) pair + // for the outer assertion. match err { // SQLite path discriminates by inner ErrorCode — split // into busy / locked / other to mirror error_kind_str. @@ -438,6 +448,11 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::CoreTransactionEntryMismatch { .. } => { (false, "core_transaction_entry_mismatch") } + WalletStorageError::TransactionBodyConflict { .. } => { + (false, "transaction_body_conflict") + } + WalletStorageError::UnknownWalletNetwork { .. } => (false, "unknown_wallet_network"), + WalletStorageError::NetAmountOverflow { .. } => (false, "net_amount_overflow"), WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), @@ -494,8 +509,11 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::EmptyPoolAddressScript { .. } => { (false, "empty_pool_address_script") } - WalletStorageError::CoreHistoryReplay(_) => (false, "core_history_replay"), WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), + // `WalletStorageError` is `#[non_exhaustive]`, so this external test + // crate needs a catch-all arm. Exhaustiveness is enforced in-crate by + // the wildcard-free matches in `src/sqlite/error.rs`. + other => panic!("sample {other:?} has no expected classification"), } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 511f1b612d0..d27c6cce39b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -21,7 +21,7 @@ const EXPECTED_ID_FINGERPRINT: &str = /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "50cbaacf66de2622f65f60699115a7a154345c250659b546a1d9a0658b575a97"; + "6023660fb488d9d3a98bb069bcb9950bdf125c3e8a8264f2a3dd3bd36a0844b8"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index 46076a9bb99..e296482379c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -45,6 +45,13 @@ struct Fixture { impl Fixture { async fn new(spend_context: TransactionContext) -> Self { + Self::with_funding(block(100), spend_context).await + } + + async fn with_funding( + funding_context: TransactionContext, + spend_context: TransactionContext, + ) -> Self { let mut wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); @@ -91,7 +98,7 @@ impl Fixture { special_transaction_payload: None, }; let funding_result = info - .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .check_core_transaction(&funding, funding_context, &mut wallet, true, true) .await; let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] .utxos @@ -170,6 +177,19 @@ impl Fixture { assert_eq!(selection.selected[0].outpoint, self.available); } + fn assert_spent_stored(&self, expected: bool) { + let spent: bool = self + .persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE substr(outpoint, 2, 32) = ?1 AND value = 100000", + [self.spent.txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(spent, expected, "stored spent flag of the reserved input"); + } + async fn redeliver(&self, wallet: &mut Wallet, info: &mut ManagedWalletInfo) { let result = info .check_core_transaction(&self.funding, block(100), wallet, true, true) @@ -235,9 +255,23 @@ async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { #[tokio::test] async fn should_not_release_inputs_reserved_by_unconfirmed_spend() { let fixture = Fixture::new(TransactionContext::Mempool).await; - let (_, info) = fixture.load(); + let (mut wallet, mut info) = fixture.load(); fixture.assert_spent_excluded(&info); assert!(!info.observed_spent_outpoints().contains_key(&fixture.spent)); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); +} + +#[tokio::test] +async fn should_keep_unconfirmed_funding_reserved_when_it_confirms_after_reload() { + let fixture = + Fixture::with_funding(TransactionContext::Mempool, TransactionContext::Mempool).await; + let (mut wallet, mut info) = fixture.load(); + assert!(!info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fixture.spent)); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); } #[tokio::test] @@ -458,3 +492,111 @@ async fn should_keep_replayed_output_only_in_its_owning_account() { .unwrap(); assert_eq!(stored, 3, "load must not delete stored rows"); } + +#[tokio::test] +async fn should_drop_replay_credit_for_contact_only_script() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let [contact, ours]: [Address; 2] = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_addresses(Some(&xpub), 2, true) + .unwrap() + .try_into() + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([16; 32]), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 40_000, + script_pubkey: contact.script_pubkey(), + }, + TxOut { + value: 7_000, + script_pubkey: ours.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let contact_coin = OutPoint::new(funding.txid(), 0); + let our_coin = OutPoint::new(funding.txid(), 1); + let result = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + assert_eq!(coins.len(), 2); + let (persister, _dir, _) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: coins, + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + // The store tracks the script only on a contact's watch-only chain, + // yet the rebuilt funds account still derives it, so replay credits it. + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_address_pool WHERE script = ?1", + [contact.script_pubkey().as_bytes()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + rusqlite::params![&wallet.wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + } + + let mut state = persister.load().unwrap(); + let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + assert!( + info.accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&contact_coin)), + "a replay-credited contact coin must not survive load" + ); + assert!(info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&our_coin)); + assert_eq!(info.balance.total(), 7_000); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 0c9a8988d75..f678c407003 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -626,6 +626,10 @@ fn coalesce_newest_wins( } /// Keep the last correction per account before folding account contributions. +// TODO(unify-record-coalescing): `coalesce_newest_wins` (the `Merge` path) +// can regress a confirmed context to an older mempool slice; this helper +// keeps the highest context rank. Unify them once `Merge` semantics are +// reviewed. pub(crate) fn coalesce_account_records(records: &mut Vec) { let mut positions = BTreeMap::new(); for mut record in std::mem::take(records) { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift index 380deeeeff7..7224d3dc282 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift @@ -39,6 +39,9 @@ public struct DecodedTransaction: Sendable, Equatable { public let valueDuffs: UInt64 /// Raw scriptPubKey bytes. public let scriptPubkey: Data + + /// `true` for an `OP_RETURN` (0x6a) data-carrier / burn output. + var isOpReturn: Bool { scriptPubkey.first == 0x6a } } /// Transaction id in consensus (internal) byte order — reverse for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 0fe3efe8f2e..e1d43179cc1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -238,11 +238,11 @@ public final class PersistentTransaction { && seen.insert($0.outpoint).inserted } } - let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + let wallets = owningWalletIds let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } - if wallets.count == 1, wallets.contains(walletId), !hasUnownedTxos { return netAmount } + // Unresolved inputs make any amount provisional, the stored one included. guard pendingInputs.isEmpty else { return nil } + if wallets.count == 1, wallets.contains(walletId), !hasUnownedTxos { return netAmount } let walletInputs = owned(inputs) let walletOutputs = owned(outputs) guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } @@ -254,20 +254,45 @@ public final class PersistentTransaction { /// Direction relative to one wallet for transactions shared by multiple local wallets. public func direction(for walletId: Data) -> UInt32 { - let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) - guard wallets.count > 1, direction != 3, transactionTypeKind != 1, !isAssetLock else { return direction } + guard owningWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, + typedKind != .coinJoin, !isAssetLock else { return direction } let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId } - return spendsOurs ? 1 : 0 + return spendsOurs ? CoreDirectionCode.outgoing : CoreDirectionCode.incoming } /// Format the wallet's Core value movement in DASH. public func formattedAmount(for walletId: Data) -> String { guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } - return String(format: "%@%.8f DASH", amount >= 0 ? "+" : "-", Double(amount.magnitude) / 100_000_000) + return Self.format(duffs: amount) + } + + /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. + public func displayNetAmount(for walletId: Data?) -> Int64? { + walletId.map { netAmount(for: $0) } ?? netAmount + } + + /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. + public func displayDirectionCode(for walletId: Data?) -> UInt32 { + walletId.map { direction(for: $0) } ?? direction + } + + /// Formatted amount for `walletId`, or the stored scalar's when no wallet scope is given. + public func displayFormattedAmount(for walletId: Data?) -> String { + walletId.map { formattedAmount(for: $0) } ?? formattedAmount + } + + /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. + static func format(duffs: Int64) -> String { + String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) + } + + /// Local wallets owning at least one of this transaction's TXOs. + private var owningWalletIds: Set { + Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) } static func reconciledAccounting( @@ -287,20 +312,24 @@ public final class PersistentTransaction { guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { return nil } + // Same rule as the Rust repair (`core_history::repaired_direction`): + // internal only when nothing leaves the wallet and something stays in + // it, or an asset lock burns into Platform. Both sides test one table. let direction: UInt32 - if previousDirection == 3 { direction = 3 } - else if inputs.isEmpty { direction = 0 } - else if isAssetLock || allOutputsOwned { direction = 2 } - else { direction = 1 } + if previousDirection == CoreDirectionCode.coinJoin { direction = CoreDirectionCode.coinJoin } + else if inputs.isEmpty { direction = CoreDirectionCode.incoming } + else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { + direction = CoreDirectionCode.internalTransfer + } else { direction = CoreDirectionCode.outgoing } return (received - spent, direction) } public var directionName: String { switch direction { - case 0: return "Incoming" - case 1: return "Outgoing" - case 2: return "Internal" - case 3: return "CoinJoin" + case CoreDirectionCode.incoming: return "Incoming" + case CoreDirectionCode.outgoing: return "Outgoing" + case CoreDirectionCode.internalTransfer: return "Internal" + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Unknown" } } @@ -404,12 +433,19 @@ public final class PersistentTransaction { } public var formattedAmount: String { - let dash = Double(abs(netAmount)) / 100_000_000.0 - let sign = netAmount >= 0 ? "+" : "-" - return String(format: "%@%.8f DASH", sign, dash) + Self.format(duffs: netAmount) } } +/// Wire values of `PersistentTransaction.direction`, matching `directionName` +/// and the FFI's `TransactionDirection` discriminants. +public enum CoreDirectionCode { + public static let incoming: UInt32 = 0 + public static let outgoing: UInt32 = 1 + public static let internalTransfer: UInt32 = 2 + public static let coinJoin: UInt32 = 3 +} + /// Typed mirror of Rust's /// `key_wallet::transaction_checking::transaction_router::TransactionType`, /// pinned to the `u8` discriminants emitted by diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 354e2cf32e8..0de8973282d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -419,6 +419,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { error: error ) backgroundContext.rollback() + // Rows staged by the failed save are gone; never reconcile them. + accountingDirty.removeAll() } } @@ -2547,8 +2549,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes // A context-only recovery record has zero accounting; a funded asset lock burns Core value. - let preserveLockAccounting = tx.transaction_type_kind == 6 && tx.net_amount == 0 && !tx.has_fee - && record.netAmount != 0 && record.inputs.contains(where: Self.isWalletOwnedTxo) + // A stored debit is itself the proof we funded it: its inputs may not be linked yet. + let preserveLockAccounting = tx.transaction_type_kind == TransactionTypeKind.assetLock.rawValue + && tx.net_amount == 0 && !tx.has_fee + && record.netAmount < 0 if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) @@ -3298,6 +3302,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() + // Out-of-round writers (heal paths, deferred backfills) stage + // entries too; they are not this round's to reconcile and may + // point at rows a failed save rolled back. Load-time accounting + // repairs whatever they touched. + self.accountingDirty.removeAll() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -3427,8 +3436,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if roundAdvancedFinalityBoundary { collectFinalizedSweptTombstones(walletId: walletId) } + // Display-only accounting: a failure here must not fail the + // round, and is reported under its own event, not save_failed. + // Recomputed values that did land are consistent on their own. + // TODO(test-round-accounting-failure): cover this catch with a + // FetchFaultInjector test; pinning which read faults needs a Swift run. do { try reconcileTransactionAccounting(Array(accountingDirty.values)) + } catch { + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("round"), "wallet_reference": .reference(walletId)], + error: error + ) + } + do { try backgroundContext.save() committedRoundGeneration &+= 1 SDKLogger.event( @@ -6810,7 +6832,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Repair only fully resolved spends; missing prevouts are not evidence of external ownership. func reconcileTransactionAccounting( - _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil + _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil, + addresses: [String: PersistentCoreAddress]? = nil ) throws { for transaction in transactions where !transaction.isDeleted { guard let transactionNetwork = network @@ -6837,16 +6860,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var amounts: [UInt64] = [] var allOutputsOwned = true let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted && Self.isWalletOwnedTxo($0) }.map(\.vout)) + // An address-matched output with no linked TXO carries no wallet of + // its own, so it counts only for the spending wallets: another local + // wallet's credit must not hide inside the sender's scalar. + let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) for (index, output) in decoded.outputs.enumerated() { // OP_RETURN burns (including asset locks) are not spendable Core outputs. - if output.scriptPubkey.first == 0x6a { continue } + if output.isOpReturn { continue } var belongs = ownedVouts.contains(UInt32(index)) if !belongs, let address = output.address { let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) let owner: PersistentCoreAddress? if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } + else if let addresses { owner = addresses[address] } else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } - if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag, + spendingWallets.contains(account.wallet.walletId) { belongs = true } } @@ -6855,7 +6884,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if let accounting = PersistentTransaction.reconciledAccounting( inputs: inputs, ownedOutputAmounts: amounts, allOutputsOwned: allOutputsOwned, - previousDirection: transaction.transactionTypeKind == 1 ? 3 : transaction.direction, + previousDirection: transaction.typedKind == .coinJoin + ? CoreDirectionCode.coinJoin : transaction.direction, isAssetLock: transaction.isAssetLock ) { transaction.netAmount = accounting.netAmount @@ -6912,6 +6942,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Mid-round the context holds another round's staged writes: saving // would commit half of it and rolling back would silently drop it. // That round reconciles its own dirty rows; the next load repairs the rest. + // TODO(persist-accounting-backfill-marker): this pass re-reads all + // history on every launch; a persisted completion marker needs a + // SwiftData shape change (a new live schema version) or a side + // channel, which is a product decision. if !inChangeset { do { let walletIds = Set(wallets.map(\.walletId)) @@ -6923,15 +6957,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - try reconcileTransactionAccounting(transactions, txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) })) + // One read instead of one per unlinked output. + let addresses = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + try reconcileTransactionAccounting( + transactions, + // Never trap on a duplicate outpoint; a live row wins over a deleted one. + txos: Dictionary(txos.map { ($0.outpoint, $0) }, uniquingKeysWith: { kept, other in + kept.isDeleted ? other : kept + }), + addresses: Dictionary(addresses.map { ($0.address, $0) }, uniquingKeysWith: { first, _ in first }) + ) try backgroundContext.save() } catch { + // Display-only accounting must never block restoring wallets: + // drop this pass's edits and restore on the stored values. backgroundContext.rollback() SDKLogger.event( - "persistence_wallet_load_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("transaction_accounting")], error: error + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("load")], error: error ) - return (nil, 0, true) } } let restorable = wallets.filter { wallet in diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index 37bd50aedad..ba2092b1f68 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -4,8 +4,10 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction var walletId: Data? = nil - private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } - private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @@ -18,12 +20,12 @@ struct TransactionDetailView: View { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } if transaction.isProviderSpecial && netAmount == 0 { return nil } - return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } private var typeDescription: String { @@ -34,11 +36,11 @@ struct TransactionDetailView: View { return transaction.displayDirection } switch direction { - case 0: + case CoreDirectionCode.incoming: return "Received" - case 1: + case CoreDirectionCode.outgoing: return "Sent" - case 3: + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Self-Transfer" @@ -49,14 +51,7 @@ struct TransactionDetailView: View { if transaction.isAssetLock { return "lock.fill" } if transaction.isAssetUnlock { return "lock.open.fill" } if transaction.isProviderSpecial { return "server.rack" } - switch direction { - case 0: - return "arrow.down.circle.fill" - case 1: - return "arrow.up.circle.fill" - default: - return "arrow.triangle.2.circlepath" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -66,14 +61,7 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial { return .orange } - switch direction { - case 0: - return .green - case 1: - return .red - default: - return .blue - } + return TransactionDirectionStyle.color(for: direction) } private var isConfirmed: Bool { @@ -201,7 +189,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, netAmount < 0 { + if let fee = formattedFee, let amount = netAmount, amount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift new file mode 100644 index 00000000000..589aa6bb7f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift @@ -0,0 +1,25 @@ +import SwiftUI +import SwiftDashSDK + +/// Direction icon and colour shared by the transaction list and detail views. +enum TransactionDirectionStyle { + static func icon(for direction: UInt32) -> String { + switch direction { + case CoreDirectionCode.incoming: return "arrow.down.circle.fill" + case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" + case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" + case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" + default: return "questionmark.circle" + } + } + + /// Internal transfers share the outgoing colour: they still pay a fee. + static func color(for direction: UInt32) -> Color { + switch direction { + case CoreDirectionCode.incoming: return .green + case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red + case CoreDirectionCode.coinJoin: return .blue + default: return .secondary + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 4e0033ff220..5d84aaca7e4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -29,10 +29,9 @@ struct TransactionListView: View { @Query private var walletAccounts: [PersistentAccount] @Query private var transactionObservation: [PersistentTransaction] /// Per-wallet asset-lock rows. Used to look up the *locked* amount - /// for each asset-lock tx — `PersistentTransaction.netAmount` is - /// the wallet's input-vs-output diff, which sees the credit - /// output as "to-self" and reports ~0 for asset locks. The - /// `amountDuffs` on the asset-lock row is the actual L1 burn. + /// for each asset-lock tx: `amountDuffs` on the asset-lock row is the + /// payload funding amount, while `PersistentTransaction.netAmount` is + /// the Core debit, which includes the fee. @Query private var assetLocks: [PersistentAssetLock] /// This wallet's owning identities. The DashPay payment / contact /// join below must be scoped to these — two identities in one store @@ -222,8 +221,10 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction var walletId: Data? = nil - private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } - private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in @@ -253,14 +254,7 @@ struct TransactionRowView: View { // `Internal` — the wallet just sees its own owner/voting/payout // keys in the payload — so the self-transfer arrows would lie. if transaction.isProviderSpecial { return "server.rack" } - // direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin - switch direction { - case 0: return "arrow.down.circle.fill" - case 1: return "arrow.up.circle.fill" - case 2: return "arrow.triangle.2.circlepath" - case 3: return "shuffle.circle.fill" - default: return "questionmark.circle" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -278,12 +272,7 @@ struct TransactionRowView: View { if transaction.isProviderSpecial { return .orange } - switch direction { - case 0: return .green - case 1, 2: return .red - case 3: return .blue - default: return .secondary - } + return TransactionDirectionStyle.color(for: direction) } /// Primary label: the contact context for a DashPay payment, else the @@ -400,7 +389,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, netAmount < 0 { + if let fee = transaction.fee, let amount = netAmount, amount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) @@ -424,7 +413,7 @@ struct TransactionRowView: View { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } // A payload-only provider special tx moves no wallet balance; // `+0.00000000 DASH` reads as a broken zero-value receive, so @@ -434,6 +423,6 @@ struct TransactionRowView: View { if transaction.isProviderSpecial && netAmount == 0 { return transaction.providerSpecialName ?? transaction.transactionType } - return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index b4627318cf0..ec38b7dae0f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1162,10 +1162,10 @@ struct TransactionStorageListView: View { // Direction. switch directionFilter { case .all: break - case .incoming where record.direction != 0: return false - case .outgoing where record.direction != 1: return false - case .internalTx where record.direction != 2: return false - case .coinjoin where record.direction != 3: return false + case .incoming where record.direction != CoreDirectionCode.incoming: return false + case .outgoing where record.direction != CoreDirectionCode.outgoing: return false + case .internalTx where record.direction != CoreDirectionCode.internalTransfer: return false + case .coinjoin where record.direction != CoreDirectionCode.coinJoin: return false default: break } // Type. Treat the legacy `"Standard"` placeholder (the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index ccde14865f4..6018b4ae68d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -31,7 +31,7 @@ final class TransactionAccountingTests: XCTestCase { inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: true, previousDirection: 0, isAssetLock: false )?.direction, 2) let lock = PersistentTransaction.reconciledAccounting( - inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 2, isAssetLock: true + inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: true, previousDirection: 2, isAssetLock: true ) XCTAssertEqual(lock?.netAmount, -100) XCTAssertEqual(lock?.direction, 2) @@ -40,6 +40,40 @@ final class TransactionAccountingTests: XCTestCase { )?.direction, 3) } + /// Same case table as the Rust repair's + /// `should_classify_repaired_direction_like_the_swift_sdk`. + func testShouldClassifyDirectionLikeTheRustRepair() { + let spent = input(100) + // (spends ours, owned output amounts, all outputs owned, asset lock, previous direction, expected) + let (incoming, outgoing, internalTransfer, coinJoin) = ( + CoreDirectionCode.incoming, CoreDirectionCode.outgoing, + CoreDirectionCode.internalTransfer, CoreDirectionCode.coinJoin + ) + let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ + (true, [99], true, false, incoming, internalTransfer), + (true, [40], false, false, incoming, outgoing), + (true, [], true, false, incoming, outgoing), + (true, [], true, true, incoming, internalTransfer), + (true, [40], true, true, incoming, internalTransfer), + (true, [], false, true, incoming, outgoing), + (false, [40], true, false, incoming, incoming), + (true, [99], true, false, coinJoin, coinJoin), + ] + for (index, (spendsOurs, owned, allOwned, isLock, previous, expected)) in cases.enumerated() { + let result = PersistentTransaction.reconciledAccounting( + inputs: spendsOurs ? [spent] : [], ownedOutputAmounts: owned, + allOutputsOwned: allOwned, previousDirection: previous, isAssetLock: isLock + ) + XCTAssertEqual(result?.direction, expected, "case \(index)") + } + } + + func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { + XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") + XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") + XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) + } + func testShouldRejectOverflowInsteadOfWrappingHistory() { XCTAssertNil(PersistentTransaction.reconciledAccounting( inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false @@ -54,7 +88,8 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(tx.netAmount(for: first.walletId), -100) XCTAssertEqual(tx.netAmount(for: other.walletId), -200) } - private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40) -> Data { + /// `burn` makes the single output an OP_RETURN, as an asset lock's is. + private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40, burn: Bool = false) -> Data { var bytes = Data([2, 0, 0, 0, UInt8(inputs.count)]) for txid in inputs { bytes.append(txid) @@ -62,7 +97,8 @@ final class TransactionAccountingTests: XCTestCase { } bytes.append(1) withUnsafeBytes(of: outputValue.littleEndian) { bytes.append(contentsOf: $0) } - bytes.append(contentsOf: [0, 0, 0, 0, 0]) + bytes.append(contentsOf: burn ? [1, 0x6a] : [0]) + bytes.append(contentsOf: [0, 0, 0, 0]) return bytes } @@ -139,6 +175,18 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(repaired?.netAmount, -100) } + func testShouldRestoreWalletsWhenLoadTimeAccountingFails() throws { + let container = try DashModelContainer.createInMemory() + container.mainContext.insert(PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet)) + try container.mainContext.save() + let injector = FetchFaultInjector(faulting: PersistentCoreAddress.self) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet, modelFetcher: injector + ) + XCTAssertFalse(handler.loadWalletList().errored, "display accounting must not block restore") + XCTAssertTrue(injector.observedReads.contains("PersistentCoreAddress")) + } + func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext @@ -242,7 +290,7 @@ final class TransactionAccountingTests: XCTestCase { let walletId = Data(repeating: 1, count: 32) context.insert(PersistentWallet(walletId: walletId, network: .testnet)) let spenderId = Data(repeating: 3, count: 32) - let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0) + let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0, burn: true) let spender = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) spender.transactionTypeKind = 6 let coin = input(100) @@ -258,6 +306,26 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(row.context, 2) } + func testShouldPreserveAssetLockDebitWhenNoInputIsLinkedYet() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [Data(repeating: 2, count: 32)], outputValue: 0, burn: true) + let lock = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) + lock.transactionTypeKind = 6 + lock.fee = 7 + context.insert(lock) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200, "a synthetic zero update must not erase the debit") + XCTAssertEqual(row.fee, 7) + XCTAssertEqual(row.direction, 2) + } + func testShouldRepairNoChangeAssetLockToFullCoreDebit() throws { let container = try DashModelContainer.createInMemory() let walletId = Data(repeating: 1, count: 32) @@ -267,13 +335,65 @@ final class TransactionAccountingTests: XCTestCase { let spenderId = Data(repeating: 3, count: 32) persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) persist(handler, walletId: walletId, txid: spenderId, - bytes: serializedSpend(inputs: [walletId], outputValue: 0), kind: 6, inputTxids: [walletId]) + bytes: serializedSpend(inputs: [walletId], outputValue: 0, burn: true), kind: 6, inputTxids: [walletId]) let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) XCTAssertEqual(row.netAmount, -100) XCTAssertEqual(row.direction, 2) XCTAssertTrue(row.isAssetLock) } + func testShouldNotReportStoredAmountWhileInputsArePending() { + let walletId = Data(repeating: 1, count: 32) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let change = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + change.walletId = walletId + tx.outputs = [change] + XCTAssertEqual(tx.netAmount(for: walletId), 40) + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletId + )] + XCTAssertNil(tx.netAmount(for: walletId), "a missing input makes the stored amount provisional") + } + + func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let senderId = Data(repeating: 1, count: 32) + let receiver = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let receiverAccount = PersistentAccount( + wallet: receiver, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + // P2PKH to pubkey hash 0x05 x 20 on testnet: B's address with no TXO row yet. + let receiverAddress = PersistentCoreAddress( + address: "yLmzEvw3frCPS4cyRmFFeKbt64fUPzMwFh", poolTypeTag: 0, addressIndex: 0, derivationPath: "" + ) + receiverAddress.account = receiverAccount + context.insert(PersistentWallet(walletId: senderId, network: .testnet)) + context.insert(receiver) + context.insert(receiverAccount) + context.insert(receiverAddress) + var bytes = Data([2, 0, 0, 0, 1]) + bytes.append(senderId) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) + withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: [25, 0x76, 0xa9, 0x14] + [UInt8](repeating: 5, count: 20) + [0x88, 0xac]) + bytes.append(contentsOf: [0, 0, 0, 0]) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: 1, netAmount: -100 + ) + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100, "the receiving wallet's credit is not the sender's") + XCTAssertEqual(row.netAmount(for: senderId), -100) + } + func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext From 12798644f38d82e0b74fae99bdd49dcd3fe16843 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:33:17 +0000 Subject: [PATCH 15/39] fix(platform-wallet-storage): gate V019 blob length before reading it, document V019 - V019 `read_record` now queries `length(record_blob)` on its own and fetches the blob only after `check_size` passes. SQLite evaluates every result column before returning a row, so the old single `SELECT length(b), b` loaded an oversize blob into SQLite's heap before the check could run. - SCHEMA.md: add the V019 migration row and sections for `core_transaction_inputs` and `core_transaction_record_originals`, list both among the not-yet-diagrammed tables, and name all three writers of `core_utxos.spent_in_txid` (apply_sweep, runtime history repair, V019 repair). - Tests: import `migrations` at the top of the core_state and legacy_v019 test modules instead of calling `crate::sqlite::migrations::run` inline. - Swift tests: build `PersistentTransaction` fixtures with `CoreDirectionCode` instead of raw direction integers. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-storage/SCHEMA.md | 49 ++++++++++++-- .../src/sqlite/migrations/legacy_v019.rs | 44 ++++++------ .../src/sqlite/schema/core_state.rs | 67 +++++++++---------- .../PersistentTransactionDisplayTests.swift | 7 +- .../TransactionAccountingTests.swift | 22 +++--- 5 files changed, 120 insertions(+), 69 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 769378695c1..0732b63f062 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -42,7 +42,7 @@ Any `meta_*` row whose parent object does not exist — because it was never cre A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives. -The tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. The diagrams cover 21 of V001's 23 tables; `pending_contact_crypto` and `ignored_senders` appear in the [Tables](#tables) section but are not diagrammed. They show the V001 tables as amended in place by every later migration that changes one of them (the current `core_utxos`, `core_transactions`, `platform_addresses`, and `asset_locks` shapes). Nine tables added by later migrations are not yet diagrammed here: `core_address_pool`, `meta_data_versions`, and `meta_store_generation` (V009, plus its V010–V011 `core_address_pool` columns), `invitations` (V003), `shielded_viewing_keys` (V013), `dpns_name_states` (V005), `tracked_masternodes` (V006), and the `identity_scan_states` / `identity_scan_failed_indices` pair (V017) — see the [Migrations](#migrations) log for what each adds in the meantime. +The tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. The diagrams cover 21 of V001's 23 tables; `pending_contact_crypto` and `ignored_senders` appear in the [Tables](#tables) section but are not diagrammed. They show the V001 tables as amended in place by every later migration that changes one of them (the current `core_utxos`, `core_transactions`, `platform_addresses`, and `asset_locks` shapes). Eleven tables added by later migrations are not yet diagrammed here: `core_address_pool`, `meta_data_versions`, and `meta_store_generation` (V009, plus its V010–V011 `core_address_pool` columns), `invitations` (V003), `shielded_viewing_keys` (V013), `dpns_name_states` (V005), `tracked_masternodes` (V006), the `identity_scan_states` / `identity_scan_failed_indices` pair (V017), and the `core_transaction_inputs` / `core_transaction_record_originals` pair (V019) — see the [Migrations](#migrations) log for what each adds in the meantime. ## Diagram 1 — Core / L1 (Bitcoin/Dash layer) @@ -89,7 +89,7 @@ erDiagram BLOB script "scriptPubKey bytes" INTEGER is_sweep_placeholder "1 until funding arrives" INTEGER spent "0 | 1" - BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" + BLOB spent_in_txid "spender claiming the row: apply_sweep hold, runtime or V019 history repair; else NULL" INTEGER winner_mined_height "V007: sweep winner's mined height; NULL when unstamped or materialised" } @@ -402,9 +402,20 @@ One row per UTXO, spent or unspent. Owning-account identity is derived from `core_address_pool` while loading wallet state, and confirmation height is derived from `core_transactions.height`. -`spent_in_txid` is written only by -`apply_sweep`, naming the winner that took an input a swept loser claimed -but this store had no released record for. It is set to NULL by a trigger +`spent_in_txid` names the transaction that claims the row. Three writers set +it: + +- `apply_sweep`, naming the winner that took an input a swept loser claimed + but this store had no released record for; +- the runtime history repair (`schema::core_history`), which marks an owned + output `spent = 1` for each non-mempool stored record that spends it, + including a record stored before the output itself was known; +- the V019 migration repair (`migrations::legacy_v019`), which does the same + once over every stored record. + +Both repairs only fill the link: an existing claim (`spent = 1` with a +non-NULL `spent_in_txid`) stands, so a repair never overwrites another +spender. It is set to NULL by a trigger when its referenced `core_transactions` row is deleted (instead of a native `ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` column) — and by a later sweep that releases the same outpoint. @@ -440,6 +451,33 @@ spent and is permanently outside the collector's reach. the collector's per-round scan touches tombstones rather than the wallet's full spent history. +### `core_transaction_inputs` + +Raw-input index (V019): one row per input outpoint of every stored transaction +record, written whether or not the store knows the spent output yet. When an +owned output is recorded later, the runtime history repair looks up its +outpoint here to find the stored records that spend it and repairs their +accounting (`input_details`, direction, the output's spent mark). Rows are +written with `INSERT OR IGNORE` on each record write; the V019 migration +backfilled them for records stored before it. + +- PK: `(wallet_id, txid, outpoint)`. +- FK: `(wallet_id, txid) → core_transactions(wallet_id, txid) ON DELETE + CASCADE`. +- Index: `idx_core_transaction_inputs_outpoint(wallet_id, outpoint)`. + +### `core_transaction_record_originals` + +Append-only archive (V019) of the pre-repair `core_transactions.record_blob`. +Before a history repair (runtime or V019) first rewrites a record, the blob it +replaces is copied here with `INSERT OR IGNORE`, so the first original is kept +verbatim and never replaced. It is never loaded; it exists so a wrong repair +can be undone by hand. + +- PK: `(wallet_id, txid)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. There is no FK to + `core_transactions`: the original outlives its transaction row. + ### `core_instant_locks` Instant-lock blobs for transactions that are broadcast but not yet @@ -847,3 +885,4 @@ table-rebuild migration, as V004 does. | V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | | V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | | V018 | `V018__identity_hard_delete.rs` | Retires identity tombstoning. Adds `cascade_children_on_identity_delete` (brooms `identity_keys` / `contacts` / `ignored_senders` / `pending_contact_crypto` by the deleted identity id, covering the rows no live FK reaches) plus its access-path indexes `idx_contacts_owner`, `idx_ignored_senders_owner`, and `idx_pending_contact_crypto_owner`; purges every already-tombstoned identity and its dependents; drops `identities.tombstoned`. | +| V019 | `V019__core_transaction_accounting.rs` | Adds `core_transaction_inputs` (raw-input index, with `idx_core_transaction_inputs_outpoint`) and `core_transaction_record_originals` (append-only archive of pre-repair record blobs). A data repair (`legacy_v019::repair_history`) then runs once over every stored record: it backfills the input index, rewrites `core_transactions.record_blob` with corrected input details and direction (archiving the original first), and marks spent the owned outputs that non-mempool records spend, recording the spender in `spent_in_txid` unless another claim stands. A confirmed record whose blob cannot be decoded (or exceeds the blob size limit) is dropped along with its index rows and `core_sync_state.synced_height` is lowered to just below the wallet's birth height, so the next SPV start rescans it; an undecodable unconfirmed record fails the migration instead. | diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs index 2b3554e3453..55217297aca 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -18,7 +18,7 @@ use key_wallet::managed_account::transaction_record::{ }; use key_wallet::transaction_checking::{TransactionContext, TransactionType}; use platform_wallet::wallet::platform_wallet::WalletId; -use rusqlite::{params, Transaction}; +use rusqlite::{params, OptionalExtension, Transaction}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::{blob, id32, wallets}; @@ -30,23 +30,27 @@ fn read_record( wallet_id: &WalletId, txid: &Txid, ) -> Result, WalletStorageError> { - let mut stmt = tx.prepare_cached( - "SELECT length(record_blob), record_blob FROM core_transactions \ - WHERE wallet_id = ?1 AND txid = ?2", - )?; - let mut rows = stmt.query(params![ - wallet_id.as_slice(), - txid.as_byte_array().as_slice() - ])?; - let Some(row) = rows.next()? else { - return Ok(None); - }; - // Gate the stored length before the payload is materialized. - let Some(len) = row.get::<_, Option>(0)? else { + let key = params![wallet_id.as_slice(), txid.as_byte_array().as_slice()]; + // Gate the stored length in its own statement: SQLite evaluates every + // result column before a row is returned, so selecting the payload + // alongside its length would load an oversize blob before the check. + let len: Option> = tx + .prepare_cached( + "SELECT length(record_blob) FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )? + .query_row(key, |row| row.get(0)) + .optional()?; + let Some(Some(len)) = len else { return Ok(None); }; blob::check_size(len)?; - let payload: Vec = row.get(1)?; + let payload: Vec = tx + .prepare_cached( + "SELECT record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )? + .query_row(key, |row| row.get(0))?; let record: TransactionRecord = blob::decode(&payload)?; if record.txid != *txid { return Err(WalletStorageError::blob_decode( @@ -388,7 +392,7 @@ mod tests { use rusqlite::Connection; use super::*; - use crate::sqlite::migrations::rewind_to_v018; + use crate::sqlite::migrations::{self, rewind_to_v018}; use crate::sqlite::schema::core_state; fn address(marker: u8) -> Address { @@ -420,7 +424,7 @@ mod tests { #[test] fn should_drop_oversize_confirmed_record_for_resync() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xC2u8; 32]; let txid = Txid::from_byte_array([0x72; 32]); conn.execute( @@ -446,7 +450,7 @@ mod tests { .unwrap(); rewind_to_v018(&conn); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let (records, synced): (i64, i64) = conn .query_row( @@ -468,7 +472,7 @@ mod tests { #[test] fn should_pin_v019_repair_of_a_v018_database() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xC1u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -563,7 +567,7 @@ mod tests { tx.commit().unwrap(); } - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let mut expected = original.clone(); expected.input_details = vec![InputDetail { 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 a9ee0597b90..319d414ca55 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 @@ -1367,7 +1367,7 @@ pub fn list_unspent_utxos( #[cfg(test)] mod tests { use super::*; - use crate::sqlite::migrations::rewind_to_v018; + use crate::sqlite::migrations::{self, rewind_to_v018}; use dashcore::address::Payload; use dashcore::hashes::Hash; use dashcore::{BlockHash, OutPoint, PubkeyHash, Transaction, TxOut, Txid}; @@ -1425,7 +1425,7 @@ mod tests { #[test] fn should_repair_history_when_spent_funding_arrives_late() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xAB; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1562,7 +1562,7 @@ mod tests { #[test] fn should_repair_existing_history_during_migration() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xAC; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1606,7 +1606,7 @@ mod tests { ) .unwrap(); tx.commit().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let repaired = get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) .unwrap() .unwrap(); @@ -1626,7 +1626,7 @@ mod tests { !spent, "a stale mempool attempt cannot undo a released coin during migration" ); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); assert_eq!( get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) .unwrap() @@ -1652,7 +1652,7 @@ mod tests { /// A V018 database whose wallet 0xAD has one corrupt record at `height`. fn v018_with_corrupt_record(height: Option) -> (Connection, [u8; 32]) { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); rewind_to_v018(&conn); let wallet_id = [0xADu8; 32]; conn.execute( @@ -1679,7 +1679,7 @@ mod tests { fn should_fail_history_migration_on_corrupt_unconfirmed_record() { let (mut conn, _) = v018_with_corrupt_record(None); assert!( - crate::sqlite::migrations::run(&mut conn).is_err(), + migrations::run(&mut conn).is_err(), "a resync cannot restore an unconfirmed record, so it must not be dropped" ); let tables: i64 = conn @@ -1717,8 +1717,7 @@ mod tests { ], ) .unwrap(); - crate::sqlite::migrations::run(&mut conn) - .expect("a re-deliverable corrupt record must not block opening"); + migrations::run(&mut conn).expect("a re-deliverable corrupt record must not block opening"); let rows: Vec> = conn .prepare_cached("SELECT txid FROM core_transactions WHERE wallet_id = ?1") .unwrap() @@ -1756,7 +1755,7 @@ mod tests { #[test] fn should_reject_corrupt_prior_record_when_storing_the_transaction() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xA9u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1792,7 +1791,7 @@ mod tests { #[test] fn should_mark_observed_spent_and_doomed_outputs_spent() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xAEu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1830,7 +1829,7 @@ mod tests { #[test] fn should_keep_prior_spent_flag_for_uncredited_outputs() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xA8u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1884,7 +1883,7 @@ mod tests { fn should_exclude_historical_contact_outputs_from_accounting() { use key_wallet::managed_account::transaction_record::{OutputDetail, OutputRole}; let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xAFu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1967,7 +1966,7 @@ mod tests { #[test] fn should_not_load_contact_only_outputs_as_spendable() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xB1u8; 32]; let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); let (cs, owners) = load_state( @@ -1985,11 +1984,11 @@ mod tests { #[test] fn should_keep_contact_only_rows_but_exclude_them_after_history_migration() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xB2u8; 32]; let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); rewind_to_v018(&conn); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let unspent_rows = |outpoint: &OutPoint| -> i64 { conn.query_row( "SELECT count(*) FROM core_utxos \ @@ -2024,7 +2023,7 @@ mod tests { fn should_preserve_known_inputs_when_replay_has_no_historical_txo() { use key_wallet::managed_account::transaction_record::InputDetail; let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xB0u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2077,7 +2076,7 @@ mod tests { #[test] fn should_preserve_spendability_for_unknown_credit_verdicts() { let mut conn = Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0xB1u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2141,7 +2140,7 @@ mod tests { #[test] fn load_state_rejects_oversize_instant_lock_txid() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let w = [0xABu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2169,7 +2168,7 @@ mod tests { #[test] fn load_state_reconciles_utxo_height_from_confirmed_transaction_record() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x42u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2237,7 +2236,7 @@ mod tests { #[test] fn load_state_restores_confirmed_recordless_utxo_height() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x44u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2280,7 +2279,7 @@ mod tests { #[test] fn height_only_placeholder_does_not_regress() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x49u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2330,7 +2329,7 @@ mod tests { #[test] fn load_state_treats_height_zero_as_confirmed() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x4Au8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2365,7 +2364,7 @@ mod tests { #[test] fn transaction_record_always_overrides_height_only_placeholder() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x45u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2465,7 +2464,7 @@ mod tests { #[test] fn load_state_defaults_utxo_without_transaction_record_to_unconfirmed() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x43u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2523,7 +2522,7 @@ mod tests { #[test] fn load_state_tolerates_transaction_blob_txid_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x46u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2578,7 +2577,7 @@ mod tests { fn load_state_blob_height_wins_over_drifted_typed_column_in_either_scan_order() { for (case, typed_byte) in [0x10, 0xF0].into_iter().enumerate() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x50 + case as u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) \ @@ -2645,7 +2644,7 @@ mod tests { #[test] fn load_state_tolerates_transaction_blob_height_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x47u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2709,7 +2708,7 @@ mod tests { #[test] fn get_tx_record_declines_a_txid_drifted_row_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x4Bu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2765,7 +2764,7 @@ mod tests { #[test] fn get_tx_record_tolerates_blob_height_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x4Cu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2823,7 +2822,7 @@ mod tests { #[test] fn load_used_addresses_wraps_address_error_as_address_decode() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let w = [0x99u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2858,7 +2857,7 @@ mod tests { #[test] fn apply_refuses_an_empty_script_on_a_new_utxo() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x5Bu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2893,7 +2892,7 @@ mod tests { #[test] fn apply_refuses_an_empty_script_on_a_synthetic_spent_row() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x5Cu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2925,7 +2924,7 @@ mod tests { #[test] fn apply_still_marks_an_existing_utxo_spent() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x5Du8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift index 02b0bc78db8..f3f6d5deb40 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift @@ -12,7 +12,10 @@ final class PersistentTransactionDisplayTests: XCTestCase { /// Direction 2 = internal — the raw classification every special /// tx gets (asset locks, provider txs), which the display helpers /// exist to override. - private func makeTransaction(kind: UInt8, direction: UInt32 = 2) -> PersistentTransaction { + private func makeTransaction( + kind: UInt8, + direction: UInt32 = CoreDirectionCode.internalTransfer + ) -> PersistentTransaction { let tx = PersistentTransaction( txid: Data(repeating: 0xAB, count: 32), transactionData: Data(), @@ -53,7 +56,7 @@ final class PersistentTransactionDisplayTests: XCTestCase { XCTAssertEqual(makeTransaction(kind: 7).displayDirection, "Asset Unlock") // …and non-special kinds fall through to the raw direction. XCTAssertEqual(makeTransaction(kind: 0).displayDirection, "Internal") - XCTAssertEqual(makeTransaction(kind: 0, direction: 0).displayDirection, "Incoming") + XCTAssertEqual(makeTransaction(kind: 0, direction: CoreDirectionCode.incoming).displayDirection, "Incoming") XCTAssertEqual(makeTransaction(kind: 0xFF).displayDirection, "Internal") } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 6018b4ae68d..d4a6612ceec 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -110,7 +110,7 @@ final class TransactionAccountingTests: XCTestCase { let funding = PersistentTransaction(txid: walletId, transactionData: Data()) let spender = PersistentTransaction( txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: 0, netAmount: 40 + direction: CoreDirectionCode.incoming, netAmount: 40 ) let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) coin.walletId = walletId @@ -138,7 +138,7 @@ final class TransactionAccountingTests: XCTestCase { let funding = PersistentTransaction(txid: walletId, transactionData: Data()) let spender = PersistentTransaction( txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: 0, netAmount: 40 + direction: CoreDirectionCode.incoming, netAmount: 40 ) let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) coin.walletId = walletId @@ -196,7 +196,7 @@ final class TransactionAccountingTests: XCTestCase { let spender = PersistentTransaction( txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)]), - direction: 1, netAmount: -200 + direction: CoreDirectionCode.outgoing, netAmount: -200 ) coin.spendingTransaction = spender context.insert(coin) @@ -291,7 +291,10 @@ final class TransactionAccountingTests: XCTestCase { context.insert(PersistentWallet(walletId: walletId, network: .testnet)) let spenderId = Data(repeating: 3, count: 32) let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0, burn: true) - let spender = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) + let spender = PersistentTransaction( + txid: spenderId, transactionData: bytes, + direction: CoreDirectionCode.internalTransfer, netAmount: -200 + ) spender.transactionTypeKind = 6 let coin = input(100) coin.spendingTransaction = spender @@ -313,7 +316,10 @@ final class TransactionAccountingTests: XCTestCase { context.insert(PersistentWallet(walletId: walletId, network: .testnet)) let spenderId = Data(repeating: 3, count: 32) let bytes = serializedSpend(inputs: [Data(repeating: 2, count: 32)], outputValue: 0, burn: true) - let lock = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) + let lock = PersistentTransaction( + txid: spenderId, transactionData: bytes, + direction: CoreDirectionCode.internalTransfer, netAmount: -200 + ) lock.transactionTypeKind = 6 lock.fee = 7 context.insert(lock) @@ -380,7 +386,7 @@ final class TransactionAccountingTests: XCTestCase { bytes.append(contentsOf: [25, 0x76, 0xa9, 0x14] + [UInt8](repeating: 5, count: 20) + [0x88, 0xac]) bytes.append(contentsOf: [0, 0, 0, 0]) let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: 1, netAmount: -100 + txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: CoreDirectionCode.outgoing, netAmount: -100 ) let coin = input(100) coin.spendingTransaction = spender @@ -408,7 +414,7 @@ final class TransactionAccountingTests: XCTestCase { let coin = input(100) let spender = PersistentTransaction( txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: 2, netAmount: -60 + direction: CoreDirectionCode.internalTransfer, netAmount: -60 ) coin.spendingTransaction = spender let contactOutput = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) @@ -442,7 +448,7 @@ final class TransactionAccountingTests: XCTestCase { coin.account = contactAccount let spender = PersistentTransaction( txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: 0, netAmount: 40 + direction: CoreDirectionCode.incoming, netAmount: 40 ) coin.spendingTransaction = spender let received = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) From b4b73669a8f23aab161c00b6cdad9a223d5ca95f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:19:46 +0000 Subject: [PATCH 16/39] fix(platform-wallet): let the higher-ranked record win account coalescing wholesale coalesce_account_records kept a later record's body but copied in the earlier record's higher-ranked context, fabricating a record whose finality came from a different observation. The higher context_rank record now wins intact; on equal rank the later one wins. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/changeset.rs | 88 +++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index f678c407003..334a9d1f5e2 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -625,21 +625,26 @@ fn coalesce_newest_wins( } } -/// Keep the last correction per account before folding account contributions. +/// Keep one record per `(txid, account_type)` before folding account +/// contributions. The record with the higher [`context_rank`] wins +/// wholesale; on equal rank the later record wins. Records are never +/// spliced together: a record's amounts, inputs and outputs are only +/// coherent with the context they were observed under, so mixing a later +/// body with an earlier, higher-ranked context would fabricate a record +/// whose finality no single observation ever reported. // TODO(unify-record-coalescing): `coalesce_newest_wins` (the `Merge` path) -// can regress a confirmed context to an older mempool slice; this helper -// keeps the highest context rank. Unify them once `Merge` semantics are +// can regress a confirmed record to an older mempool slice; this helper +// keeps the higher-ranked record. Unify them once `Merge` semantics are // reviewed. pub(crate) fn coalesce_account_records(records: &mut Vec) { let mut positions = BTreeMap::new(); - for mut record in std::mem::take(records) { + for record in std::mem::take(records) { let key = (record.txid, record.account_type); if let Some(&position) = positions.get(&key) { let previous: &TransactionRecord = &records[position]; - if context_rank(&previous.context) > context_rank(&record.context) { - record.context = previous.context.clone(); + if context_rank(&record.context) >= context_rank(&previous.context) { + records[position] = record; } - records[position] = record; } else { positions.insert(key, records.len()); records.push(record); @@ -3621,3 +3626,72 @@ mod utxo_credit_verdict_merge_tests { assert!(older.utxo_credit_verdicts.is_empty()); } } + +#[cfg(test)] +mod coalesce_account_records_tests { + use super::*; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + + fn record(net_amount: i64, 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, + key_wallet::account::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(), + net_amount, + ); + record.txid = Txid::from_byte_array([7; 32]); + record + } + + fn in_block() -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new(100, BlockHash::all_zeros(), 0)) + } + + #[test] + fn should_keep_confirmed_record_intact_when_stale_mempool_record_follows() { + let mut records = vec![ + record(500, in_block()), + record(900, TransactionContext::Mempool), + ]; + coalesce_account_records(&mut records); + assert_eq!(records.len(), 1); + assert_eq!(records[0].net_amount, 500); + assert!(matches!(records[0].context, TransactionContext::InBlock(_))); + } + + #[test] + fn should_replace_with_later_record_of_equal_or_higher_rank() { + let mut equal = vec![ + record(500, TransactionContext::Mempool), + record(900, TransactionContext::Mempool), + ]; + coalesce_account_records(&mut equal); + assert_eq!(equal.len(), 1); + assert_eq!(equal[0].net_amount, 900); + + let mut higher = vec![ + record(500, TransactionContext::Mempool), + record(900, in_block()), + ]; + coalesce_account_records(&mut higher); + assert_eq!(higher.len(), 1); + assert_eq!(higher[0].net_amount, 900); + assert!(matches!(higher[0].context, TransactionContext::InBlock(_))); + } +} From fcfa46463ad63e4dcebdb479cb299d42e34fba47 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:39:54 +0000 Subject: [PATCH 17/39] test(swift-sdk): expect the load-time accounting reads before the lock fetch The load path now runs the accounting reconcile before restoring unspent TXOs, so the fault-injection test observes three more reads ahead of the faulted PersistentAssetLock fetch. Co-Authored-By: Claude Opus 5.5 --- .../AssetLockSpendVisibilityTests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index b9aca49a5bc..1a77a6fcc30 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -347,8 +347,13 @@ final class AssetLockSpendVisibilityTests: XCTestCase { XCTAssertEqual( injector.observedReads, - ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], - "the wallet and unspent-TXO reads must have been served — only the lock read failed" + [ + // Wallet list, then the load-time accounting reconcile. + "PersistentWallet", "PersistentTransaction", "PersistentTxo", "PersistentCoreAddress", + // Unspent-TXO restore, then the faulted lock read. + "PersistentTxo", "PersistentAssetLock", + ], + "every read before the lock read must have been served — only the lock read failed" ) XCTAssertTrue( errored, From b004bc902178d940349e1180394d98679938b204 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:55:47 +0000 Subject: [PATCH 18/39] fix(swift-sdk): keep incoming amounts when a payment has foreign inputs `upsertTransaction` writes a never-pruned pending input for every input whose prevout has no local TXO, including every foreign input of an ordinary incoming payment, so `netAmount(for:)` returned nil ("Amount unavailable") for received payments and hid provider names and fees. The stored scalar is the sole participating wallet's own accounting, so it now answers ahead of the pending-input check; that check only makes the TXO-computed multi-wallet amount provisional, and only for pending inputs the queried wallet recorded. Participation includes `involvedAccounts`, so a payload-only row recorded by one wallet keeps its stored amount and a row recorded by two wallets never hands one of them the other's scalar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../Models/PersistentTransaction.swift | 33 +++++++++++----- .../TransactionAccountingTests.swift | 38 +++++++++++++++++-- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index e1d43179cc1..b403e367c46 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -228,7 +228,14 @@ public final class PersistentTransaction { } } - /// Core value movement for one wallet; the stored scalar spans all locally owned TXOs. + /// Core value movement for one wallet; `nil` when it cannot be derived yet. + /// + /// The stored scalar is the last recording wallet's (Rust `net_amount`, + /// or the reconciliation over its own TXOs), so it answers only when + /// `walletId` is the sole participant. Pending inputs do not veto that + /// answer: `upsertTransaction` writes one for every input whose prevout + /// has no local TXO — every foreign input of an incoming payment — and + /// never prunes them, so they cannot tell a late input of ours apart. public func netAmount(for walletId: Data) -> Int64? { func owned(_ rows: [PersistentTxo]) -> [PersistentTxo] { var seen = Set() @@ -238,11 +245,11 @@ public final class PersistentTransaction { && seen.insert($0.outpoint).inserted } } - let wallets = owningWalletIds let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } - // Unresolved inputs make any amount provisional, the stored one included. - guard pendingInputs.isEmpty else { return nil } - if wallets.count == 1, wallets.contains(walletId), !hasUnownedTxos { return netAmount } + if participatingWalletIds == [walletId], !hasUnownedTxos { return netAmount } + // Computed from TXOs alone: a pending input this wallet recorded may be + // one of its own still-unlinked coins, so the sum is only provisional. + guard !pendingInputs.contains(where: { $0.walletId == walletId }) else { return nil } let walletInputs = owned(inputs) let walletOutputs = owned(outputs) guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } @@ -254,7 +261,7 @@ public final class PersistentTransaction { /// Direction relative to one wallet for transactions shared by multiple local wallets. public func direction(for walletId: Data) -> UInt32 { - guard owningWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, + guard participatingWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, typedKind != .coinJoin, !isAssetLock else { return direction } let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) @@ -289,10 +296,16 @@ public final class PersistentTransaction { String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) } - /// Local wallets owning at least one of this transaction's TXOs. - private var owningWalletIds: Set { - Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + /// Local wallets owning one of this transaction's TXOs or having recorded it + /// (`involvedAccounts`), whose last writer's accounting is the stored scalar. + private var participatingWalletIds: Set { + let owning = (inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) } + let recording = involvedAccounts.compactMap { account -> Data? in + let wallet: PersistentWallet? = account.wallet + return wallet?.walletId + } + return Set(owning + recording) } static func reconciledAccounting( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index d4a6612ceec..6c0713ddd5e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -348,18 +348,50 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertTrue(row.isAssetLock) } - func testShouldNotReportStoredAmountWhileInputsArePending() { + func testShouldReportSoleWalletsStoredAmountDespitePendingInputs() { let walletId = Data(repeating: 1, count: 32) let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) let change = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) change.walletId = walletId tx.outputs = [change] - XCTAssertEqual(tx.netAmount(for: walletId), 40) tx.pendingInputs = [PersistentPendingInput( outpoint: Data(repeating: 9, count: 36), inputIndex: 0, spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletId )] - XCTAssertNil(tx.netAmount(for: walletId), "a missing input makes the stored amount provisional") + XCTAssertEqual(tx.netAmount(for: walletId), 40, "the sole wallet's stored amount is Rust's net_amount") + } + + func testShouldNotComputeSharedAmountWhileOwnInputsArePending() { + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let toA = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + toA.walletId = walletA + let toB = PersistentTxo(transaction: tx, vout: 1, amount: 60, address: "", height: 1) + toB.walletId = walletB + tx.outputs = [toA, toB] + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletA + )] + XCTAssertNil(tx.netAmount(for: walletA), "an unlinked input A recorded may be A's own coin") + XCTAssertEqual(tx.netAmount(for: walletB), 60) + } + + func testShouldKeepIncomingAmountWhenInputsAreForeign() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let paymentId = Data(repeating: 3, count: 32) + let foreign = Data(repeating: 2, count: 32) + persist(handler, walletId: walletId, txid: paymentId, + bytes: serializedSpend(inputs: [foreign]), net: 40, + inputTxids: [foreign], outputs: [(paymentId, 40)]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == paymentId }) + XCTAssertEqual(row.netAmount, 40) + XCTAssertEqual(row.netAmount(for: walletId), 40, "a foreign input is not an unresolved input of ours") } func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { From 1c8ec8295c626797fd3ec1c4bca5a0b69c92ca4f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:55:47 +0000 Subject: [PATCH 19/39] test(swift-sdk): cover wallet-scoped direction and amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin `direction(for:)` and `netAmount(for:)` for an A→B transfer between two local wallets, and when "Amount unavailable" is expected for rows without linked TXOs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../TransactionAccountingTests.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 6c0713ddd5e..47b6e018f14 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -394,6 +394,59 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(row.netAmount(for: walletId), 40, "a foreign input is not an unresolved input of ours") } + func testShouldScopeDirectionAndAmountToEachWalletOfALocalTransfer() { + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + let (amount, fee): (UInt64, UInt64) = (90, 10) + let coin = input(amount + fee, wallet: 1) + // Whichever wallet recorded last owns the stored scalars; here the sender. + let tx = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: Data(), + direction: CoreDirectionCode.outgoing, netAmount: -Int64(amount + fee) + ) + let credit = PersistentTxo(transaction: tx, vout: 0, amount: amount, address: "", height: 1) + credit.walletId = walletB + tx.inputs = [coin] + tx.outputs = [credit] + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) + XCTAssertEqual(tx.netAmount(for: walletA), -Int64(amount + fee)) + XCTAssertEqual(tx.netAmount(for: walletB), Int64(amount)) + XCTAssertEqual(tx.formattedAmount(for: walletB), "+0.00000090 DASH") + } + + func testShouldReportAmountUnavailableOnlyWhenNoWalletAccountingApplies() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + func makeAccount(of walletId: Data) -> PersistentAccount { + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let account = PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + context.insert(wallet) + context.insert(account) + return account + } + let (accountA, accountB) = (makeAccount(of: walletA), makeAccount(of: walletB)) + // Payload-only (no linked TXO), e.g. a provider tx matched by key. + let payloadOnly = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) + let shared = PersistentTransaction(txid: Data(repeating: 4, count: 32), transactionData: Data(), netAmount: 40) + let unrecorded = PersistentTransaction(txid: Data(repeating: 5, count: 32), transactionData: Data(), netAmount: 40) + context.insert(payloadOnly) + context.insert(shared) + context.insert(unrecorded) + payloadOnly.involvedAccounts = [accountA] + shared.involvedAccounts = [accountA, accountB] + try context.save() + + XCTAssertEqual(payloadOnly.netAmount(for: walletA), 0, "the sole recorder's stored amount stands") + XCTAssertEqual(payloadOnly.formattedAmount(for: walletA), "+0.00000000 DASH") + XCTAssertNil(shared.netAmount(for: walletA), "the stored scalar may be the other recorder's") + XCTAssertNil(shared.netAmount(for: walletB)) + XCTAssertEqual(shared.formattedAmount(for: walletA), "Amount unavailable") + XCTAssertNil(unrecorded.netAmount(for: walletA), "a wallet with no stake has no amount") + } + func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext From a70ee6040055a75ee30fbbc17ca2b4192b00d9ad Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:00:16 +0000 Subject: [PATCH 20/39] fix(platform-wallet-storage): validate birth height before scheduling a V019 rescan V019's corruption recovery subtracted one from the raw stored `birth_height`, so `i64::MIN` panicked with subtract-overflow inside `migrations::run`. Gate it through `i64_to_u32` (typed IntegerOverflow, the whole upgrade rolls back) and use `saturating_sub(1)`. Out-of-range heights are no longer silently clamped to a rescan from genesis. The runtime repair in `schema::core_history` never reads `birth_height`, so it has no equivalent to fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/migrations/legacy_v019.rs | 76 ++++++++++++++----- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs index 55217297aca..541f8442faa 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -5,7 +5,7 @@ //! evolve, but V019 must keep doing exactly what it did when it shipped. It //! shares only these live helpers, which must stay behaviour-stable: the blob //! codec and its size/width gates (`blob`), `id32`, `wallets::parse_network`, -//! `i64_to_u64` and the `WalletStorageError` variants it returns. +//! `i64_to_u64`, `i64_to_u32` and the `WalletStorageError` variants they return. //! `TransactionRecord`'s encoding is owned upstream (key-wallet) and cannot be //! frozen here. Do not edit. @@ -22,7 +22,7 @@ use rusqlite::{params, OptionalExtension, Transaction}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::{blob, id32, wallets}; -use crate::sqlite::util::safe_cast::i64_to_u64; +use crate::sqlite::util::safe_cast::{i64_to_u32, i64_to_u64}; /// Read a stored record; undecodable bytes are an error the caller classifies. fn read_record( @@ -100,7 +100,8 @@ fn drop_for_resync( params![wallet_id.as_slice()], |row| row.get(0), )?; - let rescan_from = (birth_height - 1).max(0); + // A corrupt birth height fails the migration instead of choosing a rescan height. + let rescan_from = i64_to_u32("wallets.birth_height", birth_height)?.saturating_sub(1); tx.execute( "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], @@ -419,17 +420,16 @@ mod tests { } } - /// An oversize confirmed record fails its length gate before its payload - /// is read, and is dropped with a rescan from just below the birth height. - #[test] - fn should_drop_oversize_confirmed_record_for_resync() { + /// A V018 database holding one oversize confirmed record for a wallet born + /// at `birth_height`, synced to 900. + fn seed_oversize_confirmed_record(birth_height: i64) -> (Connection, WalletId) { let mut conn = Connection::open_in_memory().unwrap(); migrations::run(&mut conn).unwrap(); let wallet_id = [0xC2u8; 32]; let txid = Txid::from_byte_array([0x72; 32]); conn.execute( - "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 50)", - params![&wallet_id[..]], + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + params![&wallet_id[..], birth_height], ) .unwrap(); conn.execute( @@ -449,17 +449,29 @@ mod tests { ) .unwrap(); rewind_to_v018(&conn); + (conn, wallet_id) + } + + /// `(record count, synced_height)` for `wallet_id`. + fn records_and_synced_height(conn: &Connection, wallet_id: &WalletId) -> (i64, i64) { + conn.query_row( + "SELECT (SELECT count(*) FROM core_transactions WHERE wallet_id = ?1), \ + (SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1)", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + } + + /// An oversize confirmed record fails its length gate before its payload + /// is read, and is dropped with a rescan from just below the birth height. + #[test] + fn should_drop_oversize_confirmed_record_for_resync() { + let (mut conn, wallet_id) = seed_oversize_confirmed_record(50); migrations::run(&mut conn).unwrap(); - let (records, synced): (i64, i64) = conn - .query_row( - "SELECT (SELECT count(*) FROM core_transactions WHERE wallet_id = ?1), \ - (SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1)", - params![&wallet_id[..]], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); + let (records, synced) = records_and_synced_height(&conn, &wallet_id); assert_eq!(records, 0, "the oversize record must be dropped"); assert_eq!( synced, 49, @@ -467,6 +479,36 @@ mod tests { ); } + #[test] + fn should_rescan_from_genesis_when_the_wallet_is_born_at_zero() { + let (mut conn, wallet_id) = seed_oversize_confirmed_record(0); + + migrations::run(&mut conn).unwrap(); + + assert_eq!(records_and_synced_height(&conn, &wallet_id), (0, 0)); + } + + /// A stored birth height outside `u32` fails the migration with a typed + /// error, instead of overflowing, and leaves the record and sync state alone. + #[test] + fn should_reject_out_of_range_birth_height_before_scheduling_a_rescan() { + for birth_height in [i64::MIN, -1, i64::from(u32::MAX) + 1, i64::MAX] { + let (mut conn, wallet_id) = seed_oversize_confirmed_record(birth_height); + + let error = migrations::run(&mut conn).unwrap_err(); + + assert!( + format!("{error:?}").contains("wallets.birth_height"), + "birth height {birth_height}: {error:?}" + ); + assert_eq!( + records_and_synced_height(&conn, &wallet_id), + (1, 900), + "birth height {birth_height}: the failed upgrade must roll back" + ); + } + } + /// Pins V019's observable result on a V018-shaped database: the repaired /// record, the preserved original, the input index and the spent marks. #[test] From c0f3a4ca1c6b9ce232c6ef18f9f072a8766a4470 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:10:24 +0000 Subject: [PATCH 21/39] fix(platform-wallet): classify asset locks as internal on the live path Upstream key-wallet classifies each account on its own. For an asset lock with no change output, the funding account says `Outgoing`, because the OP_RETURN burn is not an owned output. The keys account says `Internal`. The live fold kept the funding verdict, while the SQLite repair, the frozen V019 migration and Swift all write `Internal`. As a result, the same row flipped whenever storage repaired it. The fold also summed the keys account's `+credit` marker net into the wallet net. Live asset-lock rows therefore read `-fee`, while every repair path wrote `-(credit + fee)`, with or without change (ARCH-002). A test reproduced this before the fix (folded net -1000 = -fee). Changes: - Add one wallet-level accounting rule in `changeset::wallet_accounting`: - `wallet_direction` (public) is the rule the repair already used. - `apply_wallet_accounting` sets net to owned outputs minus owned inputs, and sets direction via `wallet_direction`. - `fold_same_txid_records` now applies that rule to every record, folded or single, after the per-txid merge. A funding slice that never meets its keys marker is normalised too. - Records with no details (keys markers only) keep upstream's net and direction, mirroring repair's "empty metadata is not evidence". - The storage runtime repair calls `platform_wallet::changeset::wallet_direction` instead of its own copy. The Swift-shared case table stays in storage under the same name. - The frozen V019 logic is untouched. - `test_support::fold_wallet_records` exposes the live fold, so storage can pin repair parity against it. No existing test pinned the old live behaviour, so none needed updating. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/schema/core_history.rs | 245 +++++++++++++++--- .../src/changeset/changeset.rs | 68 +++-- .../src/changeset/core_bridge.rs | 177 +++++++++++++ .../rs-platform-wallet/src/changeset/mod.rs | 2 + .../src/changeset/wallet_accounting.rs | 105 ++++++++ .../rs-platform-wallet/src/test_support.rs | 13 +- 6 files changed, 541 insertions(+), 69 deletions(-) create mode 100644 packages/rs-platform-wallet/src/changeset/wallet_accounting.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 7d9a91f3ab7..79b59a3efc0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -5,10 +5,10 @@ use std::collections::{BTreeMap, HashSet}; use dashcore::hashes::Hash; use dashcore::{Address, OutPoint, ScriptBuf, Txid}; use key_wallet::managed_account::transaction_record::{ - InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + InputDetail, OutputDetail, OutputRole, TransactionRecord, }; -use key_wallet::transaction_checking::{TransactionContext, TransactionType}; -use platform_wallet::changeset::CoreChangeSet; +use key_wallet::transaction_checking::TransactionContext; +use platform_wallet::changeset::{wallet_direction, CoreChangeSet}; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; @@ -299,7 +299,9 @@ fn repair_record( ) }) }); - record.direction = repaired_direction( + // The live projection classifies with the same rule, so repair never + // flips a row it just wrote. + record.direction = wallet_direction( record.transaction_type, !inputs.is_empty(), has_ours, @@ -329,34 +331,14 @@ fn repair_record( Ok(()) } -/// Direction of a repaired record. The Swift SDK's -/// `PersistentTransaction.reconciledAccounting` applies the same rule; keep -/// both in step (each side tests the same case table). -/// -/// `has_external` counts every output that is neither ours nor an OP_RETURN -/// burn, so an asset lock is internal only when nothing leaves the wallet. -fn repaired_direction( - transaction_type: TransactionType, - spends_ours: bool, - has_ours: bool, - has_external: bool, -) -> TransactionDirection { - if transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else if !spends_ours { - TransactionDirection::Incoming - } else if !has_external && (has_ours || transaction_type == TransactionType::AssetLock) { - TransactionDirection::Internal - } else { - TransactionDirection::Outgoing - } -} - #[cfg(test)] mod tests { use dashcore::address::Payload; use dashcore::{PubkeyHash, Transaction as CoreTransaction, TxOut}; use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + use platform_wallet::test_support::fold_wallet_records; use super::*; @@ -493,7 +475,8 @@ mod tests { ); } - /// Shared with the Swift SDK's `TransactionAccountingTests` direction table. + /// Shared with the Swift SDK's `TransactionAccountingTests` direction + /// table; pins the rule repair takes from `platform_wallet`. #[test] fn should_classify_repaired_direction_like_the_swift_sdk() { use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; @@ -511,10 +494,214 @@ mod tests { ]; for (kind, spends_ours, has_ours, has_external, expected) in cases { assert_eq!( - repaired_direction(kind, spends_ours, has_ours, has_external), + wallet_direction(kind, spends_ours, has_ours, has_external), expected, "{kind:?} spends_ours={spends_ours} has_ours={has_ours} has_external={has_external}" ); } } + + const FUNDING: u64 = 100_000_000; + const LOCK_CREDIT: u64 = 60_000_000; + const LOCK_FEE: u64 = 1_000; + + fn address(byte: u8) -> Address { + Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), + ) + } + + fn bip44() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + /// The funding account's record upstream emits for a spend of one + /// wallet coin worth [`FUNDING`], classified the way upstream + /// `record_transaction` does. `outputs` are `(script, value, role)`. + fn funding_slice( + kind: TransactionType, + outputs: &[(ScriptBuf, u64, OutputRole)], + ) -> TransactionRecord { + let body = CoreTransaction { + version: 3, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([0x11; 32]), + vout: 0, + }, + ..Default::default() + }], + output: outputs + .iter() + .map(|(script, value, _)| TxOut { + value: *value, + script_pubkey: script.clone(), + }) + .collect(), + special_transaction_payload: None, + }; + let details: Vec = outputs + .iter() + .enumerate() + .map(|(index, (script, value, role))| OutputDetail { + index: index as u32, + role: *role, + address: Address::from_script(script, dashcore::Network::Testnet).ok(), + value: *value, + }) + .collect(); + let owned: u64 = details + .iter() + .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) + .map(|d| d.value) + .sum(); + let has_sent = details.iter().any(|d| d.role == OutputRole::Sent); + let direction = if !has_sent && owned > 0 { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + }; + TransactionRecord::new( + body, + bip44(), + TransactionContext::Mempool, + kind, + direction, + vec![InputDetail { + index: 0, + value: FUNDING, + address: address(1), + }], + details, + owned as i64 - FUNDING as i64, + ) + } + + /// The keys account's thin marker for an asset lock: `Internal`, no + /// details, net `+credit` (the OP_RETURN output's value). + fn keys_marker(funding: &TransactionRecord) -> TransactionRecord { + let credit = funding.transaction.output[0].value; + TransactionRecord::new( + funding.transaction.clone(), + AccountType::AssetLockAddressTopUp, + TransactionContext::Mempool, + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + credit as i64, + ) + } + + /// The live projection and the SQLite repair must agree on a row's + /// net and direction, or the row flips each time storage repairs + /// what the live path just wrote. Covers the asset-lock shapes (no + /// change, change, paying an external output) and a plain + /// cross-account transfer. + #[test] + fn should_repair_live_folded_records_without_changing_their_accounting() { + let burn = || ScriptBuf::new_op_return(&[]); + let change = FUNDING - LOCK_CREDIT - LOCK_FEE; + // With no change, everything but the fee is burned into credits. + let lock_no_change = funding_slice( + TransactionType::AssetLock, + &[(burn(), FUNDING - LOCK_FEE, OutputRole::Unspendable)], + ); + let lock_change = funding_slice( + TransactionType::AssetLock, + &[ + (burn(), LOCK_CREDIT, OutputRole::Unspendable), + (address(2).script_pubkey(), change, OutputRole::Change), + ], + ); + let lock_external = funding_slice( + TransactionType::AssetLock, + &[ + (burn(), LOCK_CREDIT, OutputRole::Unspendable), + ( + address(2).script_pubkey(), + change - 5_000, + OutputRole::Change, + ), + (address(9).script_pubkey(), 5_000, OutputRole::Sent), + ], + ); + // Output 0 lands on a second account of the same wallet, which the + // funding account's local view can only call `Sent`. + let transfer = funding_slice( + TransactionType::Standard, + &[( + address(3).script_pubkey(), + FUNDING - LOCK_FEE, + OutputRole::Sent, + )], + ); + let mut transfer_receiver = transfer.clone(); + transfer_receiver.account_type = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + transfer_receiver.direction = TransactionDirection::Incoming; + transfer_receiver.input_details.clear(); + transfer_receiver.output_details[0].role = OutputRole::Received; + transfer_receiver.net_amount = (FUNDING - LOCK_FEE) as i64; + + let lock_net = -((LOCK_CREDIT + LOCK_FEE) as i64); + let cases = [ + ( + "asset lock, no change", + vec![lock_no_change.clone(), keys_marker(&lock_no_change)], + TransactionDirection::Internal, + -(FUNDING as i64), + ), + ( + "asset lock, change", + vec![lock_change.clone(), keys_marker(&lock_change)], + TransactionDirection::Internal, + lock_net, + ), + ( + "asset lock, no change, funding slice alone", + vec![lock_no_change.clone()], + TransactionDirection::Internal, + -(FUNDING as i64), + ), + ( + "asset lock paying an external output", + vec![lock_external.clone(), keys_marker(&lock_external)], + TransactionDirection::Outgoing, + lock_net - 5_000, + ), + ( + "cross-account transfer", + vec![transfer, transfer_receiver], + TransactionDirection::Internal, + -(LOCK_FEE as i64), + ), + ]; + for (name, mut records, direction, net) in cases { + fold_wallet_records(&mut records); + assert_eq!(records.len(), 1, "{name}"); + let live = &records[0]; + assert_eq!(live.direction, direction, "{name}: live direction"); + assert_eq!(live.net_amount, net, "{name}: live net"); + + let mut conn = wallet_db("testnet"); + store(&conn, live); + let tx = conn.transaction().unwrap(); + repair_record(&tx, &WALLET_ID, &live.txid, dashcore::Network::Testnet).unwrap(); + let repaired = prior_record(&tx, &WALLET_ID, &live.txid).unwrap().unwrap(); + + assert_eq!( + repaired.direction, live.direction, + "{name}: repaired direction" + ); + assert_eq!(repaired.net_amount, live.net_amount, "{name}: repaired net"); + } + } } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 334a9d1f5e2..9d8d8c92b81 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -35,6 +35,7 @@ use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::{AddressInfo, Network, PlatformP2PKHAddress, Utxo}; use crate::changeset::identity_scan_state::IdentityScanStateEntry; +use crate::changeset::wallet_accounting::apply_wallet_accounting; use crate::wallet::platform_wallet::WalletId; use dpp::balances::credits::Credits; @@ -423,9 +424,6 @@ impl HighestUsedIndexes { /// −0.005 stored for a −2.61920199 spend). /// /// The fold, per txid group of 2+ records: -/// - `net_amount` — the SUM of the slices: each account's -/// `received − spent` over disjoint detail sets, so the sum is the -/// wallet's `Σreceived − Σspent` by construction. /// - `input_details` / `output_details` — the union (deduped by input /// index / output index): the slices are disjoint per account, and the /// union is exactly the wallet-relevant view downstream consumers @@ -433,15 +431,7 @@ impl HighestUsedIndexes { /// - `fee` — the first `Some` (only the funding account's record carries /// one, and disjoint accounts cannot disagree); left `None` when no /// record knew it. -/// - `direction` — recomputed over the MERGED details with the same rule -/// upstream applies per account (`record_transaction`): `CoinJoin` -/// transaction type wins outright; otherwise no `Sent` output + our -/// inputs + our outputs → `Internal` (a cross-account move whose -/// account-local slices said `Outgoing`/`Incoming` is, wallet-level, a -/// self-transfer); otherwise our inputs → `Outgoing`, else `Incoming`. -/// Deriving from the net's sign instead erased `Internal` and -/// `CoinJoin`: an internal transfer nets −fee and would relabel -/// `Outgoing`. +/// - `net_amount` / `direction` — see the wallet-level pass below. /// - `context` — the most advanced in the group (`Mempool` < /// `InstantSend` < `InBlock` < `InChainLockedBlock`), so a group mixing /// a stale mempool observation with a confirmed one keeps the @@ -451,6 +441,22 @@ impl HighestUsedIndexes { /// input details) so the row's account attribution names the spender, /// else the first record. /// +/// Then EVERY record — folded or single — gets its wallet-level +/// `net_amount` and `direction` from its details via +/// [`apply_wallet_accounting`], the rule the SQLite repair also applies, +/// so a row never changes accounting when storage repairs it: +/// - `net_amount` is `Σ owned outputs − Σ owned inputs`. Over +/// detail-bearing slices that equals the sum of their nets, but a +/// keys-account marker's `+credit` (Platform credits, not Core funds) +/// stays out: an asset lock nets `−(credit + fee)`, not `−fee`. +/// - `direction` is [`wallet_direction`](super::wallet_direction): account-local slices that said +/// `Outgoing`/`Incoming` for a cross-account move become `Internal`, and +/// an asset lock with no change is `Internal` like one with change. +/// Deriving direction from the net's sign instead would erase +/// `Internal` and `CoinJoin`. +/// - A group or record with no details at all (keys markers only) keeps +/// upstream's net and direction. +/// /// Order-preserving for untouched records; a fold lands at the group's /// FIRST position (`group[0]`) regardless of which record supplied the /// funding metadata, so unrelated records between two slices never move @@ -458,8 +464,16 @@ impl HighestUsedIndexes { /// reach here (filtered at projection — see /// `core_bridge::is_contact_watch_only`). pub(crate) fn fold_same_txid_records(records: &mut Vec) { - use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; - use key_wallet::transaction_checking::transaction_router::TransactionType; + fold_groups(records); + for record in records.iter_mut() { + apply_wallet_accounting(record); + } +} + +/// The per-txid merge of [`fold_same_txid_records`], before the +/// wallet-level accounting pass. +fn fold_groups(records: &mut Vec) { + use key_wallet::managed_account::transaction_record::OutputRole; if records.len() < 2 { return; @@ -547,30 +561,10 @@ pub(crate) fn fold_same_txid_records(records: &mut Vec) { drop_idx.insert(base_pos); let first_pos = group[0]; drop_idx.remove(&first_pos); + // The slice sum stands only for a detail-less group (keys + // markers alone); `apply_wallet_accounting` recomputes net and + // direction from the merged details otherwise. merged.net_amount = net; - // Wallet-level direction over the merged details — same rule - // upstream applies per account (see the doc comment). The sign - // of the net cannot express `Internal` or `CoinJoin`. - merged.direction = if merged.transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else { - let has_inputs = !merged.input_details.is_empty(); - let has_sent = merged - .output_details - .iter() - .any(|d| d.role == OutputRole::Sent); - let has_our_outputs = merged - .output_details - .iter() - .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); - if !has_sent && has_inputs && has_our_outputs { - TransactionDirection::Internal - } else if has_inputs { - TransactionDirection::Outgoing - } else { - TransactionDirection::Incoming - } - }; folded.insert(first_pos, merged); } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 9888909f5c6..4481e1b833b 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -3702,6 +3702,183 @@ mod contact_watch_only_projection_tests { ); } + /// Platform credits an asset lock moves into the wallet's own + /// keys account (`AssetLockPayload.credit_outputs`, mirrored by the + /// OP_RETURN output's value). + const LOCK_CREDIT: u64 = 60_000_000; + const LOCK_FEE: u64 = 1_000; + + /// The credit an asset lock spending [`our_input`] burns: everything + /// but the fee when there is no change, else [`LOCK_CREDIT`]. + fn lock_credit(change: bool) -> u64 { + if change { + LOCK_CREDIT + } else { + FUNDING - LOCK_FEE + } + } + + /// An asset lock spending [`our_input`]: output 0 burns + /// [`lock_credit`] into the OP_RETURN, output 1 (when `change`) is + /// our change, and the rest is the fee. + fn asset_lock_tx(change: bool) -> Transaction { + let mut tx = tx_with(&[]); + tx.version = 3; + tx.output.push(TxOut { + value: lock_credit(change), + script_pubkey: ScriptBuf::new_op_return(&[]), + }); + if change { + tx.output.push(TxOut { + value: FUNDING - LOCK_CREDIT - LOCK_FEE, + script_pubkey: our_change_address().script_pubkey(), + }); + } + tx + } + + /// The two per-account records upstream emits for an asset lock: + /// the funding account's slice (classified by upstream + /// `record_transaction`: `Unspendable` OP_RETURN, `Change` output, + /// `Outgoing` when nothing of ours comes back) and the keys + /// account's thin marker (`Internal`, no details, `+credit` net). + fn asset_lock_slices(change: bool) -> (TransactionRecord, TransactionRecord) { + let tx = asset_lock_tx(change); + let mut outputs = vec![OutputDetail { + index: 0, + role: OutputRole::Unspendable, + address: None, + value: lock_credit(change), + }]; + let (direction, change_value) = if change { + let value = FUNDING - LOCK_CREDIT - LOCK_FEE; + outputs.push(output(1, OutputRole::Change, &our_change_address(), value)); + (TransactionDirection::Internal, value) + } else { + (TransactionDirection::Outgoing, 0) + }; + let funding = record_with( + &tx, + bip44_account_0(), + in_block(1_000), + TransactionType::AssetLock, + direction, + vec![our_input()], + outputs, + change_value as i64 - FUNDING as i64, + ); + let keys = record_with( + &tx, + AccountType::AssetLockAddressTopUp, + in_block(1_000), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + lock_credit(change) as i64, + ); + (funding, keys) + } + + /// The wallet's Core balance drops by the locked credit plus the fee + /// — the credit leaves Core for the wallet's own Platform keys, so + /// the move is `Internal` and the net is `-(credit + fee)`, with or + /// without change. The keys account's `+credit` marker net counts + /// Platform credits, not Core funds, and must not be summed in (it + /// made the live row read `-fee` while every repair path wrote + /// `-(credit + fee)`). + #[tokio::test] + async fn should_project_asset_lock_as_internal_net_of_credit_and_fee() { + for change in [false, true] { + let (funding, keys) = asset_lock_slices(change); + let cs = + build_core_changeset(&test_manager(), &block_processed(vec![funding, keys])).await; + + assert_eq!(cs.records.len(), 1, "change={change}"); + let row = &cs.records[0]; + assert_eq!( + row.direction, + TransactionDirection::Internal, + "change={change}" + ); + assert_eq!( + row.net_amount, + -((lock_credit(change) + LOCK_FEE) as i64), + "change={change}" + ); + assert_eq!(row.account_type, bip44_account_0(), "change={change}"); + } + } + + /// A funding slice that never meets the keys marker (the keys + /// account did not match, or its slice lands in another round) is + /// projected with the same wallet rule — not left on upstream's + /// account-local `Outgoing`. + #[tokio::test] + async fn should_project_a_lone_asset_lock_funding_slice_as_internal() { + for change in [false, true] { + let (funding, _) = asset_lock_slices(change); + let cs = build_core_changeset(&test_manager(), &block_processed(vec![funding])).await; + + assert_eq!(cs.records.len(), 1, "change={change}"); + assert_eq!( + cs.records[0].direction, + TransactionDirection::Internal, + "change={change}" + ); + assert_eq!( + cs.records[0].net_amount, + -((lock_credit(change) + LOCK_FEE) as i64), + "change={change}" + ); + } + } + + /// The keys account's marker alone carries no Core accounting + /// evidence, so the projection leaves upstream's verdict alone. + #[tokio::test] + async fn should_keep_a_lone_keys_marker_as_upstream_emitted_it() { + let (_, keys) = asset_lock_slices(false); + let cs = build_core_changeset(&test_manager(), &block_processed(vec![keys])).await; + + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].direction, TransactionDirection::Internal); + assert_eq!(cs.records[0].net_amount, lock_credit(false) as i64); + } + + /// An asset lock that also pays someone else is a real outgoing + /// payment; the asset-lock exception covers only the burn. + #[tokio::test] + async fn should_project_asset_lock_paying_an_external_output_as_outgoing() { + let (mut funding, keys) = asset_lock_slices(true); + let paid = 5_000_000; + let mut tx = funding.transaction.clone(); + tx.output[1].value -= paid; + tx.output.push(TxOut { + value: paid, + script_pubkey: contact_address().script_pubkey(), + }); + funding.transaction = tx.clone(); + funding.txid = tx.txid(); + funding.output_details[1].value -= paid; + funding.net_amount -= paid as i64; + funding + .output_details + .push(output(2, OutputRole::Sent, &contact_address(), paid)); + let mut keys = keys; + keys.transaction = tx.clone(); + keys.txid = tx.txid(); + + let cs = build_core_changeset(&test_manager(), &block_processed(vec![funding, keys])).await; + + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); + assert_eq!( + cs.records[0].net_amount, + -((LOCK_CREDIT + LOCK_FEE + paid) as i64) + ); + } + /// A fold lands at the group's FIRST position even when the funding /// record (the metadata source) appears later — unrelated records /// between the slices must not move ahead of the folded transaction. diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 4125b8df568..f67ad6f5335 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -29,6 +29,7 @@ pub mod shielded_changeset_disabled; #[cfg(feature = "shielded")] pub mod shielded_sync_start_state; pub mod traits; +mod wallet_accounting; pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ @@ -60,3 +61,4 @@ pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncSta pub use traits::{ ListedCoreTxid, PersistenceError, PersistenceErrorKind, PlatformWalletPersistence, }; +pub use wallet_accounting::wallet_direction; diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs new file mode 100644 index 00000000000..4baddc131c2 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -0,0 +1,105 @@ +//! Wallet-level accounting of a Core transaction record: the one +//! direction rule and the one net formula every Rust path applies. +//! +//! Upstream `key-wallet` classifies each matched account on its own, and +//! its account-local views disagree for an asset lock: the funding +//! account reads a lock with no change as `Outgoing` (the OP_RETURN burn +//! is not an owned output), while the keys account holding the credit +//! keys reads it as `Internal`. From the wallet's side an asset lock +//! moves Core duffs into its own Platform credits, so only the fee +//! leaves the wallet. The live projection (`fold_same_txid_records`) and +//! the SQLite repair (`platform-wallet-storage`'s `core_history`) both +//! use [`wallet_direction`], so a row cannot change direction when +//! storage repairs it. + +use key_wallet::managed_account::transaction_record::{ + OutputRole, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::transaction_router::TransactionType; + +/// Classify a transaction from the wallet's point of view. +/// +/// - `spends_ours`: at least one input spends a wallet-owned output. +/// - `has_ours`: at least one output is wallet-owned (`Received`/`Change`). +/// - `has_external`: at least one output is neither wallet-owned nor an +/// OP_RETURN burn, so value leaves the wallet. +/// +/// An asset lock is `Internal` whenever nothing leaves the wallet, even +/// with no change output: its OP_RETURN value becomes the wallet's own +/// Platform credits. The Swift SDK's +/// `PersistentTransaction.reconciledAccounting` and the frozen V019 +/// migration apply the same rule; keep them in step. The case table +/// shared with Swift lives in `platform-wallet-storage` +/// (`should_classify_repaired_direction_like_the_swift_sdk`). +pub fn wallet_direction( + transaction_type: TransactionType, + spends_ours: bool, + has_ours: bool, + has_external: bool, +) -> TransactionDirection { + if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !spends_ours { + TransactionDirection::Incoming + } else if !has_external && (has_ours || transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + } +} + +/// Recompute a record's wallet-level `net_amount` and `direction` from +/// its input and output details. +/// +/// The net is `Σ owned outputs − Σ owned inputs`: the change in the +/// wallet's Core balance. A keys-account marker's `+credit` (Platform +/// credits, not Core funds) is therefore not part of it, so an asset lock +/// nets `−(credit + fee)`, as the SQLite repair computes. +/// +/// A record with no details carries no accounting evidence (a keys-account +/// marker that met no funding slice), so it is left as upstream emitted +/// it. The SQLite repair skips such records for the same reason. +pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { + if record.input_details.is_empty() && record.output_details.is_empty() { + return; + } + let owned: i128 = record + .output_details + .iter() + .filter(|d| is_owned(d.role)) + .map(|d| i128::from(d.value)) + .sum(); + let spent: i128 = record + .input_details + .iter() + .map(|d| i128::from(d.value)) + .sum(); + // Unreachable for real amounts (the whole supply fits i64 many times + // over); saturate rather than panic on a corrupt record. + let net = owned - spent; + record.net_amount = i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }); + + let has_ours = record.output_details.iter().any(|d| is_owned(d.role)); + let has_external = record + .transaction + .output + .iter() + .enumerate() + .any(|(index, output)| { + !output.script_pubkey.is_op_return() + && !record.output_details.iter().any(|d| { + d.index as usize == index + && (is_owned(d.role) || d.role == OutputRole::Unspendable) + }) + }); + record.direction = wallet_direction( + record.transaction_type, + !record.input_details.is_empty(), + has_ours, + has_external, + ); +} + +fn is_owned(role: OutputRole) -> bool { + matches!(role, OutputRole::Received | OutputRole::Change) +} diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index b9446432ee0..9e6661e5bab 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -18,6 +18,7 @@ use dashcore::Txid; use dashcore::{Network, Transaction}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::bip32::ExtendedPubKey; +use key_wallet::managed_account::transaction_record::TransactionRecord; // Only the `#[cfg(test)]` CoinJoin fixture needs the trait (for // `next_address_with_info` on a non-standard account); gate it to match so a // `test-utils`-only build does not flag it unused. @@ -32,6 +33,7 @@ use tokio::sync::RwLock; #[cfg(test)] use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use crate::changeset::changeset::fold_same_txid_records; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -173,6 +175,13 @@ impl ExtendedPubKeySigner for WalletSigner { } } +/// Runs the live projection's per-txid fold and wallet-level accounting +/// over upstream per-account records, so downstream crates can pin their +/// own accounting (the SQLite repair) against the live path. +pub fn fold_wallet_records(records: &mut Vec) { + fold_same_txid_records(records); +} + /// Builds a testnet wallet manager whose `account_type`/index-0 account /// holds a single spendable UTXO (10_000_000 duffs) — the whole balance /// rides on that one input, so a leaked reservation strands it. Returns @@ -283,9 +292,7 @@ pub(crate) fn observed_spend_event( tx: &Transaction, ) -> key_wallet_manager::WalletEvent { use dashcore::Address as DashAddress; - use key_wallet::managed_account::transaction_record::{ - InputDetail, TransactionDirection, TransactionRecord, - }; + use key_wallet::managed_account::transaction_record::{InputDetail, TransactionDirection}; use key_wallet::transaction_checking::transaction_router::TransactionType; let record = TransactionRecord::new( From 6e82bf1cfd205e004c4e713e52101535ffebee99 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:19:24 +0000 Subject: [PATCH 22/39] test(platform-wallet): pin live-fold accounting for non-asset-lock transactions The wallet-level accounting pass runs on every projected record, including singles, so this regression table shows that the live projection's net and direction are unchanged for every other transaction shape. The table covers 14 cases: - plain receive - send with and without change - self-transfer within one account and between two accounts - CoinJoin - provider registration, with an own and with an external collateral - asset unlock - coinbase - payment to a contact, with the watch-only slice present - payment from a contact into DashPay receiving funds - a single keys marker - a keys-only group The expected values were derived by running the table against the pre-pass fold (a70ee60400); all 14 pass there. One case changes on purpose. A group made only of keys-account markers, with no details, used to re-derive `Incoming` from its empty details. It now keeps upstream's `Internal`, which matches a single marker and the SQLite repair (that repair skips detail-less records). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/changeset/core_bridge.rs | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 4481e1b833b..bb1c2583170 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -3879,6 +3879,387 @@ mod contact_watch_only_projection_tests { ); } + /// The wallet-level accounting pass runs on every projected record, + /// singles included. Outside asset locks it must reproduce what the + /// projection emitted before the pass existed: a single record kept + /// upstream's own `net_amount`/`direction`, and a group summed its + /// slices' nets and re-derived direction over the merged details. + /// Each case feeds upstream-shaped records (upstream's own net and + /// direction for its account slice) through the live projection and + /// pins that prior output (verified against the pre-pass fold). + /// + /// One intentional difference: a group made only of keys-account + /// markers (no details) used to re-derive `Incoming` from its empty + /// details. It now keeps upstream's `Internal`, like a single marker + /// always did and like the SQLite repair, which ignores detail-less + /// records. + #[tokio::test] + async fn should_keep_live_fold_accounting_for_non_asset_lock_transactions() { + const PAID: u64 = 60_000_000; + const FEE: u64 = 1_000; + let external = contact_address; + let second_account = || AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + let slice = |tx: &Transaction, + account: AccountType, + kind: TransactionType, + direction: TransactionDirection, + inputs: Vec, + outputs: Vec, + net: i64| { + record_with( + tx, + account, + in_block(1_000), + kind, + direction, + inputs, + outputs, + net, + ) + }; + use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; + use TransactionType::{ + AssetUnlock, CoinJoin as CoinJoinTx, Coinbase, ProviderRegistration, Standard, + }; + + let receive = tx_with(&[(&our_receive_address(), PAID)]); + let send_change = tx_with(&[(&external(), PAID), (&our_change_address(), CHANGE)]); + let send_all = tx_with(&[(&external(), FUNDING - FEE)]); + let self_send = tx_with(&[(&our_receive_address(), FUNDING - FEE)]); + let cross = tx_with(&[ + (&our_receive_address(), PAID), + (&our_change_address(), CHANGE), + ]); + let mix = tx_with(&[(&our_receive_address(), FUNDING - FEE)]); + let provider = tx_with(&[ + (&our_receive_address(), PAID), + (&our_change_address(), FUNDING - PAID - FEE), + ]); + let provider_ext = tx_with(&[ + (&external(), PAID), + (&our_change_address(), FUNDING - PAID - FEE), + ]); + let unlock = tx_with(&[(&our_receive_address(), PAID)]); + let mut coinbase = tx_with(&[(&our_receive_address(), PAID)]); + coinbase.lock_time = 9; + let keys_only = tx_with(&[]); + let (_, contact_funding, contact_watch_only) = contact_payment_records(); + let from_contact = tx_with(&[(&our_receive_address(), PAID)]); + + let cases: Vec<(&str, Vec, TransactionDirection, i64)> = vec![ + ( + "plain receive", + vec![slice( + &receive, + bip44_account_0(), + Standard, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "send with change", + vec![slice( + &send_change, + bip44_account_0(), + Standard, + Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &external(), PAID), + output(1, OutputRole::Change, &our_change_address(), CHANGE), + ], + CHANGE as i64 - FUNDING as i64, + )], + Outgoing, + CHANGE as i64 - FUNDING as i64, + ), + ( + "send without change", + vec![slice( + &send_all, + bip44_account_0(), + Standard, + Outgoing, + vec![our_input()], + vec![output(0, OutputRole::Sent, &external(), FUNDING - FEE)], + -(FUNDING as i64), + )], + Outgoing, + -(FUNDING as i64), + ), + ( + "self-transfer within one account", + vec![slice( + &self_send, + bip44_account_0(), + Standard, + Internal, + vec![our_input()], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + FUNDING - FEE, + )], + -(FEE as i64), + )], + Internal, + -(FEE as i64), + ), + ( + "self-transfer between own accounts", + vec![ + slice( + &cross, + bip44_account_0(), + Standard, + Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &our_receive_address(), PAID), + output(1, OutputRole::Change, &our_change_address(), CHANGE), + ], + CHANGE as i64 - FUNDING as i64, + ), + slice( + &cross, + second_account(), + Standard, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + ), + ], + Internal, + (PAID + CHANGE) as i64 - FUNDING as i64, + ), + ( + "coinjoin", + vec![slice( + &mix, + AccountType::CoinJoin { index: 0 }, + CoinJoinTx, + CoinJoin, + vec![our_input()], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + FUNDING - FEE, + )], + -(FEE as i64), + )], + CoinJoin, + -(FEE as i64), + ), + ( + "provider registration with owner-key marker", + vec![ + slice( + &provider, + bip44_account_0(), + ProviderRegistration, + Internal, + vec![our_input()], + vec![ + output(0, OutputRole::Received, &our_receive_address(), PAID), + output( + 1, + OutputRole::Change, + &our_change_address(), + FUNDING - PAID - FEE, + ), + ], + -(FEE as i64), + ), + slice( + &provider, + AccountType::ProviderOwnerKeys, + ProviderRegistration, + Internal, + vec![], + vec![], + 0, + ), + ], + Internal, + -(FEE as i64), + ), + ( + "provider registration paying external collateral", + vec![ + slice( + &provider_ext, + bip44_account_0(), + ProviderRegistration, + Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &external(), PAID), + output( + 1, + OutputRole::Change, + &our_change_address(), + FUNDING - PAID - FEE, + ), + ], + -((PAID + FEE) as i64), + ), + slice( + &provider_ext, + AccountType::ProviderOwnerKeys, + ProviderRegistration, + Internal, + vec![], + vec![], + 0, + ), + ], + Outgoing, + -((PAID + FEE) as i64), + ), + ( + "asset unlock", + vec![slice( + &unlock, + bip44_account_0(), + AssetUnlock, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "coinbase", + vec![slice( + &coinbase, + bip44_account_0(), + Coinbase, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "keys-only single marker", + vec![slice( + &keys_only, + AccountType::IdentityRegistration, + TransactionType::AssetLock, + Internal, + vec![], + vec![], + PAID as i64, + )], + Internal, + PAID as i64, + ), + ( + "payment to a contact (watch-only slice present)", + vec![contact_funding, contact_watch_only], + Outgoing, + CHANGE as i64 - FUNDING as i64, + ), + ( + "payment from a contact into DashPay receiving funds", + vec![slice( + &from_contact, + AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [2u8; 32], + friend_identity_id: [1u8; 32], + }, + Standard, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "keys-only group", + vec![ + slice( + &keys_only, + AccountType::IdentityRegistration, + TransactionType::AssetLock, + Internal, + vec![], + vec![], + PAID as i64, + ), + slice( + &keys_only, + AccountType::AssetLockAddressTopUp, + TransactionType::AssetLock, + Internal, + vec![], + vec![], + CHANGE as i64, + ), + ], + // Intentional change (see the doc comment): was `Incoming`. + Internal, + (PAID + CHANGE) as i64, + ), + ]; + let mut failures = Vec::new(); + for (name, records, direction, net) in cases { + let cs = build_core_changeset(&test_manager(), &block_processed(records)).await; + assert_eq!(cs.records.len(), 1, "{name}"); + let got = (cs.records[0].direction, cs.records[0].net_amount); + if got != (direction, net) { + failures.push(format!( + "{name}: expected {:?}, got {got:?}", + (direction, net) + )); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + /// A fold lands at the group's FIRST position even when the funding /// record (the metadata source) appears later — unrelated records /// between the slices must not move ahead of the folded transaction. From 700258285bcf24e68746361e8da4a222743594ee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:19:24 +0000 Subject: [PATCH 23/39] docs(swift-sdk): point the direction rule comment at wallet_direction The Rust repair's local `repaired_direction` was replaced by the shared `platform_wallet::changeset::wallet_direction`, which both the live projection and the SQLite repair use. The comment is the only change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../SwiftDashSDK/Persistence/Models/PersistentTransaction.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index b403e367c46..16aa04636b9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -325,7 +325,7 @@ public final class PersistentTransaction { guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { return nil } - // Same rule as the Rust repair (`core_history::repaired_direction`): + // Same rule as Rust (`platform_wallet::changeset::wallet_direction`): // internal only when nothing leaves the wallet and something stays in // it, or an asset lock burns into Platform. Both sides test one table. let direction: UInt32 From cbed68d55296ab00f42d36fe86f4ee4d230bcd37 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 30 Sep 2026 19:20:53 -0500 Subject: [PATCH 24/39] test(drive): regenerate grovedb structure snapshot after v5.0-dev merge The v5.0-dev merge into v5.1-dev added the contract_with_team_actions fixture, recorded at protocol 14. Regenerate so its origin labels follow the latest protocol version (15) like the rest of the snapshot. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-drive/grovedb-structure.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 31b1140999f..89eaede0709 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -5517,7 +5517,7 @@ } }, "contracts.contract.other.team_actions": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "58", "left": { @@ -5526,7 +5526,7 @@ } }, "contracts.contract.other.team_actions.active.team_action": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "53", "left": { @@ -5535,7 +5535,7 @@ } }, "contracts.contract.other.team_actions.closed.team_action": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "53", "left": { From b1610d3f7a35c93e6aa105779fe604c3c0426c04 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:12:53 +0000 Subject: [PATCH 25/39] fix(platform-wallet-storage): ignore witness-only body differences for a known txid The body-conflict check compared whole `Transaction` values, including `TxIn::witness`, which the txid does not commit to. dashcore still decodes BIP144-style witnesses, so a peer could serve a known txid with an extra witness and the resulting non-transient conflict wiped the whole flush buffer. Compare the bodies' computed txids instead and keep the stored body, so a witness-only variant neither fails the flush nor replaces it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/error.rs | 7 +++--- .../src/sqlite/schema/core_history.rs | 25 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 2c64509fd18..029476658ed 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -422,10 +422,11 @@ pub enum WalletStorageError { blob_height: Option, }, - /// An incoming transaction record reuses a stored txid with a different - /// raw transaction body; neither copy is trusted to replace the other. + /// An incoming transaction record reuses a stored txid with a body whose + /// txid-committed content differs (witness-only differences are not a + /// conflict); neither copy is trusted to replace the other. #[error( - "transaction {txid} in wallet {} arrived with a raw body that differs from the stored one", + "transaction {txid} in wallet {} arrived with a body whose txid differs from the stored one", hex::encode(wallet_id) )] TransactionBodyConflict { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 79b59a3efc0..21c2a374f9f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -28,12 +28,15 @@ pub(super) fn preserve_known_details( let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { return Ok(merged); }; - if previous.transaction != incoming.transaction { + // Compare only txid-committed content: a peer may attach BIP144 witnesses + // to a known txid, and that must neither conflict nor replace the stored body. + if previous.transaction.txid() != incoming.transaction.txid() { return Err(WalletStorageError::TransactionBodyConflict { wallet_id: *wallet_id, txid: incoming.txid, }); } + merged.transaction = previous.transaction; let mut inputs: BTreeMap<_, _> = previous .input_details .into_iter() @@ -436,6 +439,26 @@ mod tests { ); } + /// A peer can serve a known txid with BIP144 witnesses attached; the txid + /// does not commit to them, so the stored body stands and the flush goes on. + #[test] + fn should_keep_the_stored_body_when_a_same_txid_body_differs_only_in_witness() { + let mut conn = wallet_db("testnet"); + let mut stored = record(&[1_000], &[]); + stored.transaction.input.push(dashcore::TxIn::default()); + stored.txid = stored.transaction.txid(); + store(&conn, &stored); + let mut incoming = stored.clone(); + incoming.transaction.input[0].witness = dashcore::Witness::from_slice(&[[0xAB]]); + assert_eq!(incoming.transaction.txid(), stored.txid); + assert_ne!(incoming.transaction, stored.transaction); + + let tx = conn.transaction().unwrap(); + let merged = preserve_known_details(&tx, &WALLET_ID, &incoming).unwrap(); + + assert_eq!(merged.transaction, stored.transaction); + } + #[test] fn should_report_an_unknown_wallet_network_label() { let mut conn = wallet_db("moonnet"); From a4e1c3f1b5d7b8e2f9debe3ed48abb671b4aa889 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:18:03 +0000 Subject: [PATCH 26/39] refactor(platform-wallet): share the net-amount formula with the SQLite repair The storage repair re-implemented the owned-output net, the has_ours and has_external scans and the Received|Change ownership predicate that the live fold already applies. Expose `wallet_accounting` (net as i128 plus direction) and `is_owned` from `platform_wallet::changeset` and call them from both paths; each keeps its own i64 overflow policy (live saturates, repair errors). The module doc now states what is shared and that the frozen V019 migration keeps its own copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/schema/core_history.rs | 49 +++++-------------- .../rs-platform-wallet/src/changeset/mod.rs | 2 +- .../src/changeset/wallet_accounting.rs | 48 +++++++++++------- 3 files changed, 41 insertions(+), 58 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 21c2a374f9f..441541bebb9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -8,7 +8,7 @@ use key_wallet::managed_account::transaction_record::{ InputDetail, OutputDetail, OutputRole, TransactionRecord, }; use key_wallet::transaction_checking::TransactionContext; -use platform_wallet::changeset::{wallet_direction, CoreChangeSet}; +use platform_wallet::changeset::{is_owned, wallet_accounting, CoreChangeSet}; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; @@ -53,8 +53,8 @@ pub(super) fn preserve_known_details( for detail in &incoming.output_details { let keep_previous = outputs .get(&detail.index) - .is_some_and(|old| matches!(old.role, OutputRole::Received | OutputRole::Change)) - && !matches!(detail.role, OutputRole::Received | OutputRole::Change); + .is_some_and(|old| is_owned(old.role)) + && !is_owned(detail.role); if !keep_previous { outputs.insert(detail.index, detail.clone()); } @@ -273,45 +273,17 @@ fn repair_record( if inputs.is_empty() && outputs.is_empty() { return Ok(()); } - let received: i128 = outputs - .values() - .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) - .map(|d| i128::from(d.value)) - .sum(); - let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); - let net = received - spent; + record.input_details = inputs.into_values().collect(); + record.output_details = outputs.into_values().collect(); + // The live projection computes the same accounting, so repair never + // flips a row it just wrote; only the overflow policy differs. + let (net, direction) = wallet_accounting(&record); record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { wallet_id: *wallet_id, txid: *txid, value: net, })?; - let has_ours = outputs - .values() - .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); - let has_external = record - .transaction - .output - .iter() - .enumerate() - .any(|(i, output)| { - !output.script_pubkey.is_op_return() - && !outputs.get(&(i as u32)).is_some_and(|d| { - matches!( - d.role, - OutputRole::Received | OutputRole::Change | OutputRole::Unspendable - ) - }) - }); - // The live projection classifies with the same rule, so repair never - // flips a row it just wrote. - record.direction = wallet_direction( - record.transaction_type, - !inputs.is_empty(), - has_ours, - has_external, - ); - record.input_details = inputs.into_values().collect(); - record.output_details = outputs.into_values().collect(); + record.direction = direction; let repaired = blob::encode(&record)?; if repaired != original { // Append-only: the first pre-repair blob is kept verbatim and never @@ -341,6 +313,7 @@ mod tests { use key_wallet::account::{AccountType, StandardAccountType}; use key_wallet::managed_account::transaction_record::TransactionDirection; use key_wallet::transaction_checking::TransactionType; + use platform_wallet::changeset::wallet_direction; use platform_wallet::test_support::fold_wallet_records; use super::*; @@ -580,7 +553,7 @@ mod tests { .collect(); let owned: u64 = details .iter() - .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) + .filter(|d| is_owned(d.role)) .map(|d| d.value) .sum(); let has_sent = details.iter().any(|d| d.role == OutputRole::Sent); diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index f67ad6f5335..3d81e6bb810 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -61,4 +61,4 @@ pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncSta pub use traits::{ ListedCoreTxid, PersistenceError, PersistenceErrorKind, PlatformWalletPersistence, }; -pub use wallet_accounting::wallet_direction; +pub use wallet_accounting::{is_owned, wallet_accounting, wallet_direction}; diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 4baddc131c2..9500569c5d0 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -1,5 +1,11 @@ -//! Wallet-level accounting of a Core transaction record: the one -//! direction rule and the one net formula every Rust path applies. +//! Wallet-level accounting of a Core transaction record: the ownership +//! predicate ([`is_owned`]), the direction rule ([`wallet_direction`]) and +//! the net formula ([`wallet_accounting`]). The live projection +//! (`fold_same_txid_records`) and the SQLite repair +//! (`platform-wallet-storage`'s `core_history`) both call them, so a row +//! cannot change accounting when storage repairs it; they differ only on +//! an `i64` overflow (live saturates, repair errors). The frozen V019 +//! migration keeps its own self-contained copy. //! //! Upstream `key-wallet` classifies each matched account on its own, and //! its account-local views disagree for an asset lock: the funding @@ -7,10 +13,7 @@ //! is not an owned output), while the keys account holding the credit //! keys reads it as `Internal`. From the wallet's side an asset lock //! moves Core duffs into its own Platform credits, so only the fee -//! leaves the wallet. The live projection (`fold_same_txid_records`) and -//! the SQLite repair (`platform-wallet-storage`'s `core_history`) both -//! use [`wallet_direction`], so a row cannot change direction when -//! storage repairs it. +//! leaves the wallet. use key_wallet::managed_account::transaction_record::{ OutputRole, TransactionDirection, TransactionRecord, @@ -49,12 +52,7 @@ pub fn wallet_direction( } /// Recompute a record's wallet-level `net_amount` and `direction` from -/// its input and output details. -/// -/// The net is `Σ owned outputs − Σ owned inputs`: the change in the -/// wallet's Core balance. A keys-account marker's `+credit` (Platform -/// credits, not Core funds) is therefore not part of it, so an asset lock -/// nets `−(credit + fee)`, as the SQLite repair computes. +/// its input and output details, saturating a net that does not fit `i64`. /// /// A record with no details carries no accounting evidence (a keys-account /// marker that met no funding slice), so it is left as upstream emitted @@ -63,6 +61,21 @@ pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { if record.input_details.is_empty() && record.output_details.is_empty() { return; } + let (net, direction) = wallet_accounting(record); + // Unreachable for real amounts (the whole supply fits i64 many times + // over); saturate rather than panic on a corrupt record. + record.net_amount = i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }); + record.direction = direction; +} + +/// Wallet-level net amount and direction of `record`, from its details. +/// +/// The net is `Σ owned outputs − Σ owned inputs`: the change in the +/// wallet's Core balance. A keys-account marker's `+credit` (Platform +/// credits, not Core funds) is therefore not part of it, so an asset lock +/// nets `−(credit + fee)`. The net is returned as `i128` so each caller +/// picks its own policy for a value outside `i64`. +pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirection) { let owned: i128 = record .output_details .iter() @@ -74,11 +87,6 @@ pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { .iter() .map(|d| i128::from(d.value)) .sum(); - // Unreachable for real amounts (the whole supply fits i64 many times - // over); saturate rather than panic on a corrupt record. - let net = owned - spent; - record.net_amount = i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }); - let has_ours = record.output_details.iter().any(|d| is_owned(d.role)); let has_external = record .transaction @@ -92,14 +100,16 @@ pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { && (is_owned(d.role) || d.role == OutputRole::Unspendable) }) }); - record.direction = wallet_direction( + let direction = wallet_direction( record.transaction_type, !record.input_details.is_empty(), has_ours, has_external, ); + (owned - spent, direction) } -fn is_owned(role: OutputRole) -> bool { +/// Whether an output with this role belongs to the wallet. +pub fn is_owned(role: OutputRole) -> bool { matches!(role, OutputRole::Received | OutputRole::Change) } From be7f8ecd4cd905312b55dff017ed1b9e1e54de8c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:18:34 +0000 Subject: [PATCH 27/39] fix(swift-sdk): read account wallets through the Optional cast in accounting reconcile The reconcile pass read `account.wallet` directly in the network fallback, the unlinked-output owner check and the load-time filter. That relationship is fault-loaded, and the rest of the handler reads it through an Optional cast so that a store inconsistency cannot trap. The load filter runs before wallets restore, where a trap would get past the surrounding do/catch. Read it through the cast everywhere, and build the load filter from `walletOwnsTransaction` so there is a single ownership predicate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../PlatformWalletPersistenceHandler.swift | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 0de8973282d..c52fa78e4bc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -112,6 +112,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { && txo.coreAddress?.account?.accountType != dashpayExternalAccountTypeTag } + /// Network of the account's wallet, read through the same Optional cast + /// as `resolvedWalletId(of:)`. + static func walletNetwork(of account: PersistentAccount?) -> Network? { + let wallet: PersistentWallet? = account?.wallet + return wallet?.network + } + static func walletOwnsTransaction( walletId: Data, transaction: PersistentTransaction @@ -6837,9 +6844,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) throws { for transaction in transactions where !transaction.isDeleted { guard let transactionNetwork = network - ?? transaction.involvedAccounts.first?.wallet.network - ?? transaction.inputs.first?.account?.wallet.network - ?? transaction.outputs.first?.account?.wallet.network, + ?? Self.walletNetwork(of: transaction.involvedAccounts.first) + ?? Self.walletNetwork(of: transaction.inputs.first?.account) + ?? Self.walletNetwork(of: transaction.outputs.first?.account), let decoded = try? TransactionDecoder.decode( transaction.transactionData, network: transactionNetwork ), !decoded.inputs.isEmpty else { continue } @@ -6874,9 +6881,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } else if let addresses { owner = addresses[address] } else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } - if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag, - spendingWallets.contains(account.wallet.walletId) { - belongs = true + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { + let ownerWallet: PersistentWallet? = account.wallet + if let ownerWallet, spendingWallets.contains(ownerWallet.walletId) { + belongs = true + } } } if belongs { amounts.append(output.valueDuffs) } @@ -6951,10 +6960,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let walletIds = Set(wallets.map(\.walletId)) let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) .filter { row in - row.involvedAccounts.contains { walletIds.contains($0.wallet.walletId) } - || (row.inputs + row.outputs).contains { - Self.resolvedWalletId(of: $0).map { walletIds.contains($0) } == true - } + walletIds.contains { Self.walletOwnsTransaction(walletId: $0, transaction: row) } } let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) // One read instead of one per unlinked output. From 628efc395c28a0e05e6ab646e75a3f0f4afe43b2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:32:31 +0000 Subject: [PATCH 28/39] test(drive): relabel team-action layers in grovedb-structure.json for protocol 15 The v5.1-dev merge brought the team-action layers recorded at protocol 14. The base relabel to 15 did not cover them, so the structure check failed. The tree layout is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- packages/rs-drive/grovedb-structure.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 31b1140999f..89eaede0709 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -5517,7 +5517,7 @@ } }, "contracts.contract.other.team_actions": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "58", "left": { @@ -5526,7 +5526,7 @@ } }, "contracts.contract.other.team_actions.active.team_action": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "53", "left": { @@ -5535,7 +5535,7 @@ } }, "contracts.contract.other.team_actions.closed.team_action": { - "origin": "fixture contract_with_team_actions@14", + "origin": "fixture contract_with_team_actions@15", "tree": { "hex": "53", "left": { From f78bccb3e8794b3af101adb25f2d511d209c8e4a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:45:50 +0000 Subject: [PATCH 29/39] perf(platform-wallet): index accounted outputs once in wallet accounting The external-output check rescanned output_details for every transaction output, so large self-splits cost O(N^2) on both the live fold and the SQLite runtime repair. Collect the owned/unspendable output indices into a set first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/changeset/wallet_accounting.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 9500569c5d0..942f09d7589 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -15,6 +15,8 @@ //! moves Core duffs into its own Platform credits, so only the fee //! leaves the wallet. +use std::collections::BTreeSet; + use key_wallet::managed_account::transaction_record::{ OutputRole, TransactionDirection, TransactionRecord, }; @@ -88,18 +90,19 @@ pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirect .map(|d| i128::from(d.value)) .sum(); let has_ours = record.output_details.iter().any(|d| is_owned(d.role)); + // Indexed once so the per-output check below stays linear. + let accounted: BTreeSet = record + .output_details + .iter() + .filter(|d| is_owned(d.role) || d.role == OutputRole::Unspendable) + .map(|d| d.index as usize) + .collect(); let has_external = record .transaction .output .iter() .enumerate() - .any(|(index, output)| { - !output.script_pubkey.is_op_return() - && !record.output_details.iter().any(|d| { - d.index as usize == index - && (is_owned(d.role) || d.role == OutputRole::Unspendable) - }) - }); + .any(|(index, output)| !output.script_pubkey.is_op_return() && !accounted.contains(&index)); let direction = wallet_direction( record.transaction_type, !record.input_details.is_empty(), From defafa246128bd116c915541f664ebc24290fb4e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:45:29 +0000 Subject: [PATCH 30/39] fix(platform-wallet-storage): rebuild spend marks for height-only funding on load A spender whose funding output persists only as a height-only row replayed with no owned input: load excludes spent outputs, so the checker saw an irrelevant transaction and rebuilt no account spent mark. Once a chain lock pruned the observed spend (or for any unconfirmed spend), a redelivered funding transaction re-credited the consumed output as selectable. Before replay, stage every owned input named by a stored record's input_details whose funding no replayed record credits. Replaying the spender then removes it and records the spent mark; the retention pass drops anything left, since staged coins are never part of the persisted unspent set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/rehydrate.rs | 45 ++++++++++++++++ .../tests/sqlite_spent_rehydration.rs | 52 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 56b800c521b..d0e8b280e4b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -462,6 +462,7 @@ pub(crate) fn restore_recorded_transactions( // Kept only to undo a replay the checker suspended part-way through. let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); + stage_recorded_spent_inputs(wallet_info, &replay, &placed); let completed = poll_ready(async { for record in replay { // The lock set already holds this txid (load marked the restored @@ -532,6 +533,50 @@ pub(crate) fn restore_recorded_transactions( wallet_info.update_balance(); } +/// Park every owned input a record spends whose funding no replayed record credits. +/// +/// Persistence excludes spent outputs from the load projection, so without +/// this a spender of a height-only funding row replays with no owned input and +/// rebuilds no spent mark; a redelivered funding transaction would then +/// re-credit the coin once finality prunes the observed spend. The stored +/// `input_details` are the evidence: wallet-owned by construction and repaired +/// from persisted outputs. Staged coins are never in `placed`, so the +/// retention pass drops whatever replay leaves behind. +fn stage_recorded_spent_inputs( + wallet_info: &mut ManagedWalletInfo, + records: &[TransactionRecord], + placed: &HashMap, +) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let replayed: HashSet = records.iter().map(|record| record.txid).collect(); + let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); + for record in records { + for detail in &record.input_details { + let Some(input) = record.transaction.input.get(detail.index as usize) else { + continue; + }; + let outpoint = input.previous_output; + if placed.contains_key(&outpoint) || replayed.contains(&outpoint.txid) { + continue; + } + let Some(account) = accounts + .iter_mut() + .find(|account| account.contains_address(&detail.address)) + else { + continue; + }; + account.utxos.entry(outpoint).or_insert_with(|| { + let txout = dashcore::TxOut { + value: detail.value, + script_pubkey: detail.address.script_pubkey(), + }; + key_wallet::Utxo::new(outpoint, txout, detail.address.clone(), 0, false) + }); + } + } +} + /// Whether `lock` really locks `record`, so upgrading its context is safe. /// /// The lock map is keyed by the stored `txid` column and a record's `txid` is diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index e296482379c..fafb08ff9d8 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -51,6 +51,31 @@ impl Fixture { async fn with_funding( funding_context: TransactionContext, spend_context: TransactionContext, + ) -> Self { + Self::build(funding_context, spend_context, true).await + } + + /// The funding transaction persisted only through its UTXOs: a + /// height-only `core_transactions` row with no record to replay. + async fn with_height_only_funding(spend_context: TransactionContext) -> Self { + let fixture = Self::build(block(100), spend_context, false).await; + let (height, has_record): (Option, bool) = fixture + .persister + .lock_conn_for_test() + .query_row( + "SELECT height, record_blob IS NOT NULL FROM core_transactions WHERE txid = ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!((height, has_record), (Some(100), false)); + fixture + } + + async fn build( + funding_context: TransactionContext, + spend_context: TransactionContext, + store_funding_record: bool, ) -> Self { let mut wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); @@ -112,6 +137,9 @@ impl Fixture { let mut records = funding_result.new_records; records.extend(spending_result.new_records); assert_eq!(records.len(), 2); + if !store_funding_record { + records.retain(|record| record.txid != funding.txid()); + } let (persister, dir, path) = common::fresh_persister(); persister .store( @@ -239,6 +267,30 @@ async fn should_keep_spent_output_excluded_after_finality_pruning() { fixture.redeliver(&mut wallet, &mut info).await; } +#[tokio::test] +async fn should_keep_spent_output_excluded_after_finality_pruning_with_height_only_funding() { + let fixture = Fixture::with_height_only_funding(block(200)).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty()); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_keep_unconfirmed_spend_reservation_with_height_only_funding() { + let fixture = Fixture::with_height_only_funding(TransactionContext::Mempool).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); +} + #[tokio::test] async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { let fixture = Fixture::new(block(200)).await; From 0f09a3a8d44add5294b19be37af8fc9e6196fcfe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:49:21 +0000 Subject: [PATCH 31/39] fix(platform-wallet-storage): settle InstantSend conflicts regardless of replay order Unconfirmed siblings replay by txid, so an InstantSend winner replayed before its conflicting mempool loser swept nothing: the loser was not installed yet, and on its own replay its wallet-owned change was credited and survived the retention pass as selectable. The opposite order swept it. Collect every record replayed in InstantSend context and run its conflict sweep once more after the full replay, so the outcome no longer depends on sibling order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/rehydrate.rs | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index d0e8b280e4b..4b52af8ba63 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -463,6 +463,7 @@ pub(crate) fn restore_recorded_transactions( // Kept only to undo a replay the checker suspended part-way through. let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); stage_recorded_spent_inputs(wallet_info, &replay, &placed); + let mut instant_send_winners = Vec::new(); let completed = poll_ready(async { for record in replay { // The lock set already holds this txid (load marked the restored @@ -478,8 +479,11 @@ pub(crate) fn restore_recorded_transactions( (context, _) => context, }; wallet_info - .check_core_transaction(&record.transaction, context, wallet, true, false) + .check_core_transaction(&record.transaction, context.clone(), wallet, true, false) .await; + if matches!(context, TransactionContext::InstantSend(_)) { + instant_send_winners.push((record.transaction, context)); + } } }) .is_some(); @@ -494,6 +498,11 @@ pub(crate) fn restore_recorded_transactions( *wallet = wallet_before; return; } + // A lock's sweep only reaches conflicts already replayed; siblings replay + // by txid, so settle the ones that came after their winner here. + for (transaction, context) in &instant_send_winners { + wallet_info.sweep_conflicts(transaction, context); + } let spent: HashSet<_> = wallet_info .observed_spent_outpoints() @@ -3692,6 +3701,120 @@ mod tests { ); } + /// An InstantSend winner must sweep its conflicting mempool sibling whichever + /// of the two replays first, so the loser's wallet-owned change, still + /// persisted as unspent, does not come back selectable. + #[tokio::test] + async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + for lock_later_txid in [false, true] { + let case = format!("lock_later_txid={lock_later_txid}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = |change| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 99_000 - change, + script_pubkey: dashcore::ScriptBuf::new(), + }, + TxOut { + value: change, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + let (mut winner, mut loser) = (spend(40_000), spend(30_000)); + if (winner.txid() > loser.txid()) != lock_later_txid { + std::mem::swap(&mut winner, &mut loser); + } + // Both siblings as stored: unconfirmed, each credited its change. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + let change = OutPoint::new(tx.txid(), 1); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + change, + Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + ); + } + assert_eq!(records.len(), 3, "{case}"); + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!( + !account.transactions().contains_key(&loser.txid()), + "{case}: the lock must sweep the competing spend" + ); + assert!( + !account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), + "{case}: the swept spend's change must not stay selectable" + ); + assert!( + account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), + "{case}: the winner's change stays" + ); + assert_eq!(restored.balance.total(), winner.output[1].value, "{case}"); + } + } + /// A persisted lock is trusted only when it names the record it is keyed /// under and that record's transaction really has that txid; otherwise the /// record replays in its stored mempool context and no sweep runs. From bd49cfe257a3c8e56daf203eff78fe5cb4d79351 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:52:25 +0000 Subject: [PATCH 32/39] fix(platform-wallet-storage): gate owned-output script length before reading it `owned_output` in the live history repair and the frozen V019 migration selected `length(script)` and `script` in one statement. SQLite loads every result column before returning the row, so an oversize script was materialised before `check_size` ran; past the connection length cap the read failed as a raw SQLite TooBig instead of the typed BlobTooLarge. Read the length in its own statement and fetch the script only once it passes the gate. V019 migrated data is unchanged (pinned by its existing repair test); only the error for an oversize script differs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../src/sqlite/migrations/legacy_v019.rs | 76 ++++++++++++++++--- .../src/sqlite/schema/core_history.rs | 72 ++++++++++++++---- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs index 541f8442faa..a9db717433b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -165,20 +165,30 @@ fn owned_output( outpoint: &OutPoint, network: dashcore::Network, ) -> Result, WalletStorageError> { - let mut stmt = tx.prepare_cached( - "SELECT value, length(script), script FROM core_utxos \ - WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", - )?; - let mut rows = stmt.query(params![ - wallet_id.as_slice(), - blob::encode_outpoint(outpoint)? - ])?; - let Some(row) = rows.next()? else { + let encoded = blob::encode_outpoint(outpoint)?; + let key = params![wallet_id.as_slice(), encoded]; + // Gate the script length in its own statement: SQLite evaluates every + // result column before a row is returned. + let Some((value, len)) = tx + .prepare_cached( + "SELECT value, length(script) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .optional()? + else { return Ok(None); }; - let value = i64_to_u64("core_utxos.value", row.get(0)?)?; - blob::check_size(row.get(1)?)?; - let script: Vec = row.get(2)?; + let value = i64_to_u64("core_utxos.value", value)?; + blob::check_size(len)?; + let script: Vec = tx + .prepare_cached( + "SELECT script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| row.get(0))?; if contact_only_script(tx, wallet_id, &script)? { return Ok(None); } @@ -396,6 +406,48 @@ mod tests { use crate::sqlite::migrations::{self, rewind_to_v018}; use crate::sqlite::schema::core_state; + #[test] + fn should_reject_oversize_owned_output_script_before_reading_it() { + const WALLET: WalletId = [0xC2u8; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&WALLET[..]], + ) + .unwrap(); + + use rusqlite::limits::Limit; + + use crate::sqlite::conn::SQLITE_MAX_BLOB_BYTES; + + let outpoint = OutPoint::new(Txid::from_byte_array([0x42; 32]), 0); + // Over the connection's length cap, so reading the column itself + // fails: only a length-only pre-read reports it as oversize. + let script = vec![0u8; SQLITE_MAX_BLOB_BYTES as usize + 1]; + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + &WALLET[..], + blob::encode_outpoint(&outpoint).unwrap(), + script + ], + ) + .unwrap(); + drop(script); + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) + .unwrap(); + let tx = conn.transaction().unwrap(); + + let err = owned_output(&tx, &WALLET, &outpoint, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "got {err:?}" + ); + } + fn address(marker: u8) -> Address { Address::new( dashcore::Network::Testnet, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 441541bebb9..a57c0992695 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -10,7 +10,7 @@ use key_wallet::managed_account::transaction_record::{ use key_wallet::transaction_checking::TransactionContext; use platform_wallet::changeset::{is_owned, wallet_accounting, CoreChangeSet}; use platform_wallet::wallet::platform_wallet::WalletId; -use rusqlite::{params, Connection, Transaction}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; use super::accounts::DASHPAY_EXTERNAL_LABEL; use super::{blob, core_state, wallets}; @@ -145,20 +145,30 @@ fn owned_output( outpoint: &OutPoint, network: dashcore::Network, ) -> Result, WalletStorageError> { - let mut stmt = tx.prepare_cached( - "SELECT value, length(script), script FROM core_utxos \ - WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", - )?; - let mut rows = stmt.query(params![ - wallet_id.as_slice(), - blob::encode_outpoint(outpoint)? - ])?; - let Some(row) = rows.next()? else { + let encoded = blob::encode_outpoint(outpoint)?; + let key = params![wallet_id.as_slice(), encoded]; + // Gate the script length in its own statement: SQLite evaluates every + // result column before a row is returned. + let Some((value, len)) = tx + .prepare_cached( + "SELECT value, length(script) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .optional()? + else { return Ok(None); }; - let value = i64_to_u64("core_utxos.value", row.get(0)?)?; - blob::check_size(row.get(1)?)?; - let script: Vec = row.get(2)?; + let value = i64_to_u64("core_utxos.value", value)?; + blob::check_size(len)?; + let script: Vec = tx + .prepare_cached( + "SELECT script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| row.get(0))?; if contact_only_script(tx, wallet_id, &script)? { return Ok(None); } @@ -320,6 +330,42 @@ mod tests { const WALLET_ID: WalletId = [0x5Au8; 32]; + #[test] + fn should_reject_oversize_owned_output_script_before_reading_it() { + const WALLET: WalletId = WALLET_ID; + let mut conn = wallet_db("testnet"); + + use rusqlite::limits::Limit; + + use crate::sqlite::conn::SQLITE_MAX_BLOB_BYTES; + + let outpoint = OutPoint::new(Txid::from_byte_array([0x42; 32]), 0); + // Over the connection's length cap, so reading the column itself + // fails: only a length-only pre-read reports it as oversize. + let script = vec![0u8; SQLITE_MAX_BLOB_BYTES as usize + 1]; + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + &WALLET[..], + blob::encode_outpoint(&outpoint).unwrap(), + script + ], + ) + .unwrap(); + drop(script); + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) + .unwrap(); + let tx = conn.transaction().unwrap(); + + let err = owned_output(&tx, &WALLET, &outpoint, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "got {err:?}" + ); + } + fn wallet_db(network: &str) -> Connection { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); From d82b65ec3a69bb8df6c6ffd0b8c1ec7e14558540 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:01:04 +0000 Subject: [PATCH 33/39] docs(swift-sdk): mark shared-wallet amount gap for Rust-sourced accounting A foreign payment to two local wallets shows "Amount unavailable" because Swift cannot tell a foreign pending input from an unlinked own one. Rust's wallet-scoped accounting (#5226) replaces this re-derivation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH --- .../Persistence/Models/PersistentTransaction.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 16aa04636b9..a26c60724c8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -249,6 +249,10 @@ public final class PersistentTransaction { if participatingWalletIds == [walletId], !hasUnownedTxos { return netAmount } // Computed from TXOs alone: a pending input this wallet recorded may be // one of its own still-unlinked coins, so the sum is only provisional. + // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two + // local wallets leaves an unresolvable pending input per wallet, so both + // show "Amount unavailable". Store Rust's per-wallet net amount instead + // (tracked in #5226). guard !pendingInputs.contains(where: { $0.walletId == walletId }) else { return nil } let walletInputs = owned(inputs) let walletOutputs = owned(outputs) From 4e632ce241747e6d4e2ddec7c5f47d3d9c204484 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:20:53 +0000 Subject: [PATCH 34/39] fix(platform-wallet-storage): sweep confirmed conflicts after history replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile confirmed and chainlocked spends after every persisted competitor has replayed, removing stale loser change while preserving upstream lock precedence. Extend the sibling regression across settlement types and both txid orders, including InstantSend competitors. Validation: 41 rehydration tests, 13 SQLite restart tests, scoped Clippy and rustfmt passed. The expanded regression failed before the fix. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/rehydrate.rs | 254 ++++++++++-------- 1 file changed, 149 insertions(+), 105 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 4b52af8ba63..83bd1e5606d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -463,7 +463,7 @@ pub(crate) fn restore_recorded_transactions( // Kept only to undo a replay the checker suspended part-way through. let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); stage_recorded_spent_inputs(wallet_info, &replay, &placed); - let mut instant_send_winners = Vec::new(); + let mut settled_winners = Vec::new(); let completed = poll_ready(async { for record in replay { // The lock set already holds this txid (load marked the restored @@ -481,8 +481,13 @@ pub(crate) fn restore_recorded_transactions( wallet_info .check_core_transaction(&record.transaction, context.clone(), wallet, true, false) .await; - if matches!(context, TransactionContext::InstantSend(_)) { - instant_send_winners.push((record.transaction, context)); + if matches!( + context, + TransactionContext::InstantSend(_) + | TransactionContext::InBlock(_) + | TransactionContext::InChainLockedBlock(_) + ) { + settled_winners.push((record.transaction, context)); } } }) @@ -498,9 +503,9 @@ pub(crate) fn restore_recorded_transactions( *wallet = wallet_before; return; } - // A lock's sweep only reaches conflicts already replayed; siblings replay - // by txid, so settle the ones that came after their winner here. - for (transaction, context) in &instant_send_winners { + // A settled spend's first sweep cannot reach competitors replayed later. + // The upstream sweep preserves ChainLock/InstantSend precedence. + for (transaction, context) in &settled_winners { wallet_info.sweep_conflicts(transaction, context); } @@ -3701,9 +3706,7 @@ mod tests { ); } - /// An InstantSend winner must sweep its conflicting mempool sibling whichever - /// of the two replays first, so the loser's wallet-owned change, still - /// persisted as unspent, does not come back selectable. + /// Settled spends must sweep persisted competing change after all siblings replay. #[tokio::test] async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { use dashcore::hashes::Hash; @@ -3712,106 +3715,147 @@ mod tests { use key_wallet::transaction_checking::BlockInfo; use key_wallet::Utxo; - for lock_later_txid in [false, true] { - let case = format!("lock_later_txid={lock_later_txid}"); - let (mut wallet, mut info, address) = wallet_with_receive_address(); - let funding = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), - ..Default::default() - }], - output: vec![TxOut { - value: 100_000, - script_pubkey: address.script_pubkey(), - }], - special_transaction_payload: None, - }; - let spend = |change| Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(funding.txid(), 0), - ..Default::default() - }], - output: vec![ - TxOut { - value: 99_000 - change, - script_pubkey: dashcore::ScriptBuf::new(), - }, - TxOut { - value: change, + for (settlement, locked_competitor) in [ + ("instant_send", false), + ("block", false), + ("chainlock", false), + ("block", true), + ("chainlock", true), + ] { + for winner_later_txid in [false, true] { + let case = format!("{settlement}, locked_competitor={locked_competitor}, winner_later_txid={winner_later_txid}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, script_pubkey: address.script_pubkey(), - }, - ], - special_transaction_payload: None, - }; - let block = TransactionContext::InBlock(BlockInfo::new( - 100, - BlockHash::from_byte_array([9; 32]), - 100, - )); - let mut records = info - .check_core_transaction(&funding, block, &mut wallet, true, true) - .await - .new_records; - let (mut winner, mut loser) = (spend(40_000), spend(30_000)); - if (winner.txid() > loser.txid()) != lock_later_txid { - std::mem::swap(&mut winner, &mut loser); - } - // Both siblings as stored: unconfirmed, each credited its change. - let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); - for tx in [&winner, &loser] { - let mut scratch = info.clone(); - records.extend( - scratch - .check_core_transaction( - tx, - TransactionContext::Mempool, - &mut wallet, - true, - true, - ) - .await - .new_records, - ); - let change = OutPoint::new(tx.txid(), 1); - restored - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap() - .utxos - .insert( - change, - Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + }], + special_transaction_payload: None, + }; + let spend = |change| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 99_000 - change, + script_pubkey: dashcore::ScriptBuf::new(), + }, + TxOut { + value: change, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + let (mut winner, mut loser) = (spend(40_000), spend(30_000)); + if (winner.txid() > loser.txid()) != winner_later_txid { + std::mem::swap(&mut winner, &mut loser); + } + // Both siblings as stored: unconfirmed, each credited its change. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, ); - } - assert_eq!(records.len(), 3, "{case}"); - let lock = InstantLock { - inputs: vec![OutPoint::new(funding.txid(), 0)], - txid: winner.txid(), - ..Default::default() - }; - let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + let change = OutPoint::new(tx.txid(), 1); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + change, + Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + ); + } + assert_eq!(records.len(), 3, "{case}"); + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let mut locks = BTreeMap::new(); + if settlement == "instant_send" { + locks.insert(winner.txid(), lock); + } else { + let block = BlockInfo::new(101, BlockHash::from_byte_array([10; 32]), 101); + let record = records + .iter_mut() + .find(|r| r.txid == winner.txid()) + .unwrap(); + record.context = if settlement == "chainlock" { + TransactionContext::InChainLockedBlock(block) + } else { + TransactionContext::InBlock(block) + }; + } + if locked_competitor { + locks.insert( + loser.txid(), + InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: loser.txid(), + ..Default::default() + }, + ); + } - restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); - let account = &restored.accounts.standard_bip44_accounts[&0]; - assert!( - !account.transactions().contains_key(&loser.txid()), - "{case}: the lock must sweep the competing spend" - ); - assert!( - !account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), - "{case}: the swept spend's change must not stay selectable" - ); - assert!( - account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), - "{case}: the winner's change stays" - ); - assert_eq!(restored.balance.total(), winner.output[1].value, "{case}"); + let account = &restored.accounts.standard_bip44_accounts[&0]; + let keep_competitor = settlement == "block" && locked_competitor; + assert_eq!( + account.transactions().contains_key(&loser.txid()), + keep_competitor, + "{case}: only a chainlock can overrule an InstantSend lock" + ); + assert_eq!( + account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), + keep_competitor, + "{case}: the swept spend's change must not stay selectable" + ); + assert!( + account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), + "{case}: the winner's change stays" + ); + let expected_balance = winner.output[1].value + + if keep_competitor { + loser.output[1].value + } else { + 0 + }; + assert_eq!(restored.balance.total(), expected_balance, "{case}"); + } } } From 97179c67d9c9d545b5ffdf121cb3ccdd64ea6362 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:56:02 +0000 Subject: [PATCH 35/39] fix(platform-wallet-storage): reconcile settled history across accounts Resolve replay conflicts wallet-wide in ChainLock and InstantSend precedence before defeated transactions can act as winners. Persist sweep and released input facts atomically, preserve recordless spend claims, and rebuild from the corrected projection. Recovery loads roll back database repairs. Cover cross-account descendants, persisted finality, released inputs, surviving spenders, recovery and repair failure rollback. 123 targeted tests passed; scoped all-feature Clippy and formatting are clean. Co-Authored-By: Codex GPT-6 --- packages/rs-platform-wallet-storage/SCHEMA.md | 8 + .../src/sqlite/persister.rs | 71 ++- .../src/sqlite/rehydrate.rs | 403 ++++++++++++++++-- .../src/sqlite/schema/core_state.rs | 59 ++- .../tests/sqlite_spent_rehydration.rs | 381 +++++++++++++++++ 5 files changed, 877 insertions(+), 45 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 0732b63f062..ab3f8fc465d 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -420,6 +420,14 @@ when its referenced `core_transactions` row is deleted (instead of a native `ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` column) — and by a later sweep that releases the same outpoint. +On load, recorded conflicts are reconciled wallet-wide using persisted +ChainLock and InstantSend finality. The loader applies the sweep results and +rebuilds the wallet from the repaired rows in one transaction, so losing +outputs disappear and genuinely released materialized inputs become available. +Replay preserves a surviving `spent_in_txid` claim even when its winner has +no stored transaction body. Strict loads commit this repair; Recovery loads +return the repaired projection but roll back all database changes. + What gates the funding UTXO's own later upsert (`execute_upsert_utxo`) is the row's shape, not that link: a never-materialised held row (`is_sweep_placeholder = 1`, `spent = 1` — the placeholder `apply_sweep` writes for an input whose funding this store had not seen) stays spent when the funding arrives, with diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 546bc42a13c..520bae7fdba 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; use dpp::prelude::Identifier; +use platform_wallet::changeset::changeset::SweepBatch; use platform_wallet::changeset::{ ClientStartState, IdentityChangeSet, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, @@ -1471,9 +1472,10 @@ impl PlatformWalletPersistence for SqlitePersister { /// /// # Concurrency /// - /// Holds the connection mutex for the whole read, so concurrent + /// Holds the connection mutex for the whole load, so concurrent /// `store` / `flush` / `delete_wallet` block until it returns. Intended /// for one-shot startup use, not the hot write path. + /// Conflict repairs commit per wallet under Strict; Recovery rolls them back. /// /// # Examples /// @@ -1567,7 +1569,7 @@ impl PlatformWalletPersistence for SqlitePersister { if unreadable.contains(&wallet_id) { continue; } - match load_one_wallet(&conn, wallet_id, &ctx) { + match load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy) { Ok(wallet_state) => { state.wallets.insert(wallet_id, wallet_state); } @@ -1684,7 +1686,45 @@ fn load_one_wallet( conn: &Connection, wallet_id: WalletId, ctx: &LoadCtx, + policy: LoadPolicy, ) -> Result { + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + let (mut state, sweeps) = load_wallet_snapshot(&tx, wallet_id, ctx)?; + if !sweeps.is_empty() { + schema::core_state::apply_replay_sweeps(&tx, &wallet_id, sweeps) + .map_err(PersistenceError::from)?; + // Rebuild from the repaired projection, including released materialized inputs. + let (repaired, remaining) = load_wallet_snapshot(&tx, wallet_id, ctx)?; + if !remaining.is_empty() { + return Err(PersistenceError::from(WalletStorageError::blob_decode( + "conflicting transaction history remained after replay reconciliation", + ))); + } + state = repaired; + } + if policy == LoadPolicy::Recovery { + tx.rollback() + } else { + tx.commit() + } + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + Ok(state) +} + +fn load_wallet_snapshot( + conn: &Connection, + wallet_id: WalletId, + ctx: &LoadCtx, +) -> Result< + ( + platform_wallet::changeset::ClientWalletStartState, + Vec, + ), + PersistenceError, +> { let (network_str, birth_height) = schema::wallets::fetch(conn, &wallet_id) .map_err(PersistenceError::from)? .ok_or_else(|| { @@ -1851,23 +1891,26 @@ fn load_one_wallet( })?; } let mut wallet = wallet; - restore_recorded_transactions( + let sweeps = restore_recorded_transactions( &mut wallet_info, &mut wallet, core_state.records, &core_state.instant_locks_for_non_final_records, ); - Ok(platform_wallet::changeset::ClientWalletStartState { - wallet, - wallet_info, - identity_manager, - unused_asset_locks, - // This backend does not stage unconfirmed outgoing sends for replay - // yet; the FFI persister is the only producer today. Empty leaves the - // replay inert here, which is the behaviour this path had before the - // field existed. - unconfirmed_outgoing_txs: Vec::new(), - }) + Ok(( + platform_wallet::changeset::ClientWalletStartState { + wallet, + wallet_info, + identity_manager, + unused_asset_locks, + // This backend does not stage unconfirmed outgoing sends for replay + // yet; the FFI persister is the only producer today. Empty leaves the + // replay inert here, which is the behaviour this path had before the + // field existed. + unconfirmed_outgoing_txs: Vec::new(), + }, + sweeps, + )) } /// Count one wallet's whole loss and attribute it, or return so the caller diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 83bd1e5606d..fdd1eeaadbf 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -11,6 +11,7 @@ use dashcore::{OutPoint, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; @@ -19,6 +20,7 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; +use platform_wallet::changeset::changeset::SweepBatch; use platform_wallet::changeset::provider_key_account::{ rebuild_provider_key_account, ProviderAccountRebuildError, }; @@ -435,7 +437,7 @@ pub(crate) fn restore_recorded_transactions( wallet: &mut Wallet, records: Vec, instant_locks: &BTreeMap, -) { +) -> Vec { // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. let placed: HashMap = wallet_info @@ -448,7 +450,7 @@ pub(crate) fn restore_recorded_transactions( }) .collect(); if records.is_empty() { - return; + return Vec::new(); } // TODO(bound-load-history-replay): every stored record is replayed on each // load; bounding it to records above the last chain lock needs care so @@ -458,36 +460,58 @@ pub(crate) fn restore_recorded_transactions( // outpoint keeps its reservation across load and rescan; only a conflicting // IS-locked or confirmed spend releases it. Sibling of // TODO(release-repair-spends-after-reorg) in `core_history`. - let replay = replay_order(records); + let mut replay = replay_order(records); + for record in &mut replay { + record.context = match &record.context { + TransactionContext::InBlock(block) + if wallet_info + .metadata + .last_applied_chain_lock + .as_ref() + .is_some_and(|lock| block.height() <= lock.block_height) => + { + TransactionContext::InChainLockedBlock(*block) + } + TransactionContext::Mempool => instant_locks + .get(&record.txid) + .filter(|lock| lock_matches_record(lock, record)) + .map(|lock| TransactionContext::InstantSend(lock.clone())) + .unwrap_or(TransactionContext::Mempool), + context => context.clone(), + }; + } // Kept only to undo a replay the checker suspended part-way through. let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); + let mut sweeps = plan_replay_sweeps(wallet_info, &replay); + let removed: HashSet<_> = sweeps + .iter() + .flat_map(|sweep| sweep.txids.iter().copied()) + .collect(); + replay.retain(|record| !removed.contains(&record.txid)); + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| !removed.contains(&outpoint.txid)); + } stage_recorded_spent_inputs(wallet_info, &replay, &placed); - let mut settled_winners = Vec::new(); let completed = poll_ready(async { - for record in replay { - // The lock set already holds this txid (load marked the restored - // UTXOs), so a later lock event is deduplicated: the InstantSend - // context, and the conflict sweep it runs, must come from here. - let lock = instant_locks - .get(&record.txid) - .filter(|lock| lock_matches_record(lock, &record)); - let context = match (record.context, lock) { - (TransactionContext::Mempool, Some(lock)) => { - TransactionContext::InstantSend(lock.clone()) - } - (context, _) => context, - }; - wallet_info - .check_core_transaction(&record.transaction, context.clone(), wallet, true, false) + for record in &replay { + let result = wallet_info + .check_core_transaction( + &record.transaction, + record.context.clone(), + wallet, + true, + false, + ) .await; - if matches!( - context, - TransactionContext::InstantSend(_) - | TransactionContext::InBlock(_) - | TransactionContext::InChainLockedBlock(_) - ) { - settled_winners.push((record.transaction, context)); + if !result.swept_transactions.is_empty() { + sweeps.push(replay_sweep( + record, + result.swept_transactions, + result.released_outpoints, + )); } } }) @@ -501,12 +525,19 @@ pub(crate) fn restore_recorded_transactions( ); *wallet_info = info_before; *wallet = wallet_before; - return; + return Vec::new(); } // A settled spend's first sweep cannot reach competitors replayed later. // The upstream sweep preserves ChainLock/InstantSend precedence. - for (transaction, context) in &settled_winners { - wallet_info.sweep_conflicts(transaction, context); + for record in &replay { + let result = wallet_info.sweep_conflicts(&record.transaction, &record.context); + if !result.txids.is_empty() { + sweeps.push(replay_sweep( + record, + result.txids, + result.released_outpoints, + )); + } } let spent: HashSet<_> = wallet_info @@ -545,6 +576,86 @@ pub(crate) fn restore_recorded_transactions( wallet_info.apply_chain_lock(chain_lock); } wallet_info.update_balance(); + sweeps +} + +fn replay_sweep( + record: &TransactionRecord, + txids: Vec, + released_outpoints: Vec, +) -> SweepBatch { + SweepBatch { + txids, + superseded_by: record.txid, + winner_mined_height: record.block_info().map(|block| block.height()), + released_outpoints, + } +} + +/// Resolve the full history before a defeated lock can sweep another spender. +fn plan_replay_sweeps( + wallet_info: &ManagedWalletInfo, + records: &[TransactionRecord], +) -> Vec { + let mut conflicts = wallet_info.clone(); + // A scratch account lets the upstream sweep see descendants across account boundaries. + let Some(account) = conflicts + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + else { + return Vec::new(); + }; + account + .transactions_mut() + .extend(records.iter().map(|record| (record.txid, record.clone()))); + let mut winners: Vec<_> = records + .iter() + .filter(|record| !matches!(record.context, TransactionContext::Mempool)) + .collect(); + winners.sort_by_key(|record| { + let priority = match record.context { + TransactionContext::InChainLockedBlock(_) => 0, + TransactionContext::InstantSend(_) => 1, + _ => 2, + }; + (priority, record.txid) + }); + let mut removed = HashSet::new(); + let mut sweeps = Vec::new(); + for record in winners { + if removed.contains(&record.txid) { + continue; + } + let result = conflicts.sweep_conflicts(&record.transaction, &record.context); + if !result.txids.is_empty() { + removed.extend(result.txids.iter().copied()); + sweeps.push(replay_sweep( + record, + result.txids, + result.released_outpoints, + )); + } + } + // An input released by an earlier sweep can be claimed by a later surviving winner. + let claimed: HashSet<_> = records + .iter() + .filter(|record| !removed.contains(&record.txid)) + .flat_map(|record| { + record + .transaction + .input + .iter() + .map(|input| input.previous_output) + }) + .collect(); + for sweep in &mut sweeps { + sweep + .released_outpoints + .retain(|outpoint| !removed.contains(&outpoint.txid) && !claimed.contains(outpoint)); + } + sweeps } /// Park every owned input a record spends whose funding no replayed record credits. @@ -3721,6 +3832,8 @@ mod tests { ("chainlock", false), ("block", true), ("chainlock", true), + ("persisted_chainlock", true), + ("persisted_chainlock_below", true), ] { for winner_later_txid in [false, true] { let case = format!("{settlement}, locked_competitor={locked_competitor}, winner_later_txid={winner_later_txid}"); @@ -3819,6 +3932,18 @@ mod tests { TransactionContext::InBlock(block) }; } + if settlement.starts_with("persisted_chainlock") { + restored.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: if settlement == "persisted_chainlock" { + 101 + } else { + 100 + }, + block_hash: BlockHash::from_byte_array([10; 32]), + signature: [0; 96].into(), + }); + } if locked_competitor { locks.insert( loser.txid(), @@ -3833,7 +3958,8 @@ mod tests { restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); let account = &restored.accounts.standard_bip44_accounts[&0]; - let keep_competitor = settlement == "block" && locked_competitor; + let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") + && locked_competitor; assert_eq!( account.transactions().contains_key(&loser.txid()), keep_competitor, @@ -3859,6 +3985,223 @@ mod tests { } } + #[tokio::test] + async fn should_remove_cross_account_descendant_of_conflicting_spend_after_replay() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + let mut wallet = Wallet::new_random( + Network::Testnet, + WalletAccountCreationOptions::BIP44AccountsOnly([0, 1].into_iter().collect()), + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let addresses: Vec<_> = [0, 1] + .into_iter() + .map(|index| { + let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; + info.accounts + .standard_bip44_accounts + .get_mut(&index) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap() + }) + .collect(); + let tx = |previous_output: OutPoint, output: TxOut| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![output], + special_transaction_payload: None, + }; + let funding = tx( + OutPoint::new(Txid::from_byte_array([41; 32]), 0), + TxOut { + value: 100_000, + script_pubkey: addresses[0].script_pubkey(), + }, + ); + let root = tx( + OutPoint::new(funding.txid(), 0), + TxOut { + value: 99_000, + script_pubkey: addresses[0].script_pubkey(), + }, + ); + let child = tx( + OutPoint::new(root.txid(), 0), + TxOut { + value: 98_000, + script_pubkey: addresses[1].script_pubkey(), + }, + ); + let winner = tx( + OutPoint::new(funding.txid(), 0), + TxOut { + value: 97_000, + script_pubkey: dashcore::ScriptBuf::new(), + }, + ); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([42; 32]), + height, + )) + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + let funding_info = info.clone(); + records.extend( + info.check_core_transaction( + &root, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + records.extend( + info.check_core_transaction( + &child, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + assert!(info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&root.txid())); + assert!(!info.accounts.standard_bip44_accounts[&1] + .transactions() + .contains_key(&root.txid())); + assert!(info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&child.txid())); + assert!(info.accounts.standard_bip44_accounts[&1] + .transactions() + .contains_key(&child.txid())); + let mut winner_info = funding_info; + records.extend( + winner_info + .check_core_transaction(&winner, block(101), &mut wallet, true, true) + .await + .new_records, + ); + platform_wallet::test_support::fold_wallet_records(&mut records); + let child_outpoint = OutPoint::new(child.txid(), 0); + use crate::{SqlitePersister, SqlitePersisterConfig}; + use platform_wallet::changeset::{ + PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, + }; + let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let path = dir.path().join("cross-account-replay.sqlite"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let funded = Utxo::new( + OutPoint::new(funding.txid(), 0), + funding.output[0].clone(), + addresses[0].clone(), + 100, + false, + ); + let root_coin = Utxo::new( + OutPoint::new(root.txid(), 0), + root.output[0].clone(), + addresses[0].clone(), + 0, + false, + ); + let child_coin = Utxo::new( + child_outpoint, + child.output[0].clone(), + addresses[1].clone(), + 0, + false, + ); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: manifest_for(&wallet), + core: Some(CoreChangeSet { + records, + new_utxos: vec![funded.clone(), root_coin.clone(), child_coin], + spent_utxos: vec![funded, root_coin], + synced_height: Some(101), + last_processed_height: Some(101), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let mut loaded = persister.load().unwrap(); + let loaded = loaded + .wallets + .remove(&wallet.wallet_id) + .unwrap() + .wallet_info; + let loaded_coins = &loaded.accounts.standard_bip44_accounts[&1].utxos; + use key_wallet::wallet::managed_wallet_info::coin_selection::{ + CoinSelector, SelectionStrategy, + }; + use key_wallet::wallet::managed_wallet_info::fee::FeeRate; + let selection = CoinSelector::new(SelectionStrategy::LargestFirst).select_coins( + loaded_coins.values(), + 10_000, + FeeRate::default(), + 101, + ); + assert!( + !loaded_coins.contains_key(&child_outpoint), + "SQLite store/load must discard a cross-account descendant of a conflicting spend" + ); + assert!( + selection.is_err(), + "a conflicting descendant cannot fund a spend" + ); + assert_eq!(loaded.balance.total(), 0); + for txid in [root.txid(), child.txid()] { + assert!(persister + .get_core_tx_record(wallet.wallet_id, &txid) + .unwrap() + .is_none()); + } + assert_eq!( + persister.load().unwrap().wallets[&wallet.wallet_id] + .wallet_info + .balance + .total(), + 0 + ); + } + /// A persisted lock is trusted only when it names the record it is keyed /// under and that record's transaction really has that txid; otherwise the /// record replays in its stored mempool context and no sweep runs. 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 319d414ca55..9126822c576 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 @@ -10,7 +10,7 @@ use dashcore::ephemerealdata::chain_lock::ChainLock; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; -use platform_wallet::changeset::changeset::UtxoCreditVerdict; +use platform_wallet::changeset::changeset::{SweepBatch, UtxoCreditVerdict}; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -506,6 +506,63 @@ fn surviving_stored_input_claims( Ok(claims) } +/// Apply replay settlement without releasing a recordless winner's durable claim. +pub fn apply_replay_sweeps( + tx: &Transaction<'_>, + wallet_id: &WalletId, + mut sweeps: Vec, +) -> Result<(), WalletStorageError> { + use dashcore::hashes::Hash; + + let removed: HashSet<_> = sweeps + .iter() + .flat_map(|sweep| sweep.txids.iter().copied()) + .collect(); + let candidates: HashSet<_> = sweeps + .iter() + .flat_map(|sweep| sweep.released_outpoints.iter().copied()) + .collect(); + let mut held = HashMap::new(); + for outpoint in candidates { + let key = blob::encode_outpoint(&outpoint)?; + let length: Option = tx.query_row( + "SELECT length(spent_in_txid) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 1", + params![wallet_id.as_slice(), &key[..]], |row| row.get(0), + ).optional()?.flatten(); + let Some(length) = length else { continue }; + blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; + let (claim, height): (Vec, Option) = tx.query_row( + "SELECT spent_in_txid, winner_mined_height FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..]], |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if !removed.contains(&dashcore::Txid::from_slice(&claim)?) { + held.insert(outpoint, (claim, height)); + } + } + for sweep in &mut sweeps { + sweep + .released_outpoints + .retain(|outpoint| !held.contains_key(outpoint)); + } + apply( + tx, + wallet_id, + &CoreChangeSet { + sweeps, + ..Default::default() + }, + )?; + // The generic sweep attributes held inputs to its winner; retain other winners' provenance. + for (outpoint, (claim, height)) in held { + let key = blob::encode_outpoint(&outpoint)?; + tx.execute( + "UPDATE core_utxos SET spent = 1, spent_in_txid = ?3, winner_mined_height = ?4 WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..], claim, height], + )?; + } + Ok(()) +} + /// Delete a swept transaction's row and outputs, then resolve the coins it /// claimed to spend. /// diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index fafb08ff9d8..6fc716267ca 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -652,3 +652,384 @@ async fn should_drop_replay_credit_for_contact_only_script() { .contains_key(&our_coin)); assert_eq!(info.balance.total(), 7_000); } + +#[tokio::test] +async fn should_restore_extra_input_released_by_a_persisted_conflict_after_restart() { + assert_conflict_restart(ConflictCase::Released).await; +} + +#[tokio::test] +async fn should_restore_released_input_with_height_only_funding_after_restart() { + assert_conflict_restart(ConflictCase::HeightOnlyFunding).await; +} + +#[tokio::test] +async fn should_keep_extra_input_reserved_by_a_surviving_spend_after_restart() { + assert_conflict_restart(ConflictCase::SurvivingClaim).await; +} + +#[tokio::test] +async fn should_resolve_locked_competitor_using_persisted_chainlock_after_restart() { + assert_conflict_restart(ConflictCase::PersistedChainLock).await; +} + +#[tokio::test] +async fn should_not_rewrite_conflicting_history_during_recovery_load() { + assert_conflict_restart(ConflictCase::Recovery).await; +} + +#[tokio::test] +async fn should_preserve_a_recordless_winners_claim_during_replay() { + assert_conflict_restart(ConflictCase::RecordlessClaim).await; +} + +#[tokio::test] +async fn should_preserve_a_recordless_winners_placeholder_during_replay() { + assert_conflict_restart(ConflictCase::RecordlessPlaceholder).await; +} + +#[tokio::test] +async fn should_roll_back_a_failed_replay_repair() { + assert_conflict_restart(ConflictCase::FailedRepair).await; +} + +#[tokio::test] +async fn should_not_let_a_defeated_lock_remove_a_surviving_spender() { + assert_conflict_restart(ConflictCase::DefeatedLock).await; +} + +#[derive(Clone, Copy)] +enum ConflictCase { + Released, + HeightOnlyFunding, + SurvivingClaim, + PersistedChainLock, + Recovery, + RecordlessClaim, + RecordlessPlaceholder, + FailedRepair, + DefeatedLock, +} + +async fn assert_conflict_restart(case: ConflictCase) { + let record_funding = !matches!(case, ConflictCase::HeightOnlyFunding); + let surviving_claim = matches!( + case, + ConflictCase::SurvivingClaim | ConflictCase::DefeatedLock + ); + let chainlocked = matches!( + case, + ConflictCase::PersistedChainLock | ConflictCase::DefeatedLock + ); + let recordless_claim = matches!( + case, + ConflictCase::RecordlessClaim | ConflictCase::RecordlessPlaceholder + ); + let expect_released = !surviving_claim && !recordless_claim; + let recovery = matches!(case, ConflictCase::Recovery); + + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([71; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .into_iter() + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + }; + let coins: Vec<_> = funding + .output + .iter() + .enumerate() + .map(|(vout, output)| { + Utxo::new( + OutPoint::new(funding.txid(), vout as u32), + output.clone(), + address.clone(), + 100, + false, + ) + }) + .collect(); + let loser = Transaction { + version: 1, + lock_time: 0, + input: coins + .iter() + .map(|coin| TxIn { + previous_output: coin.outpoint, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 119_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let winner = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: coins[0].outpoint, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + if !record_funding { + records.clear(); + } + let mut competing = info.clone(); + let mut other_spender = info.clone(); + records.extend( + info.check_core_transaction(&loser, TransactionContext::Mempool, &mut wallet, true, true) + .await + .new_records, + ); + records.extend( + competing + .check_core_transaction( + &winner, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + let mut surviving_txid = None; + if surviving_claim { + let mut extra_spender = winner.clone(); + extra_spender.input[0].previous_output = coins[1].outpoint; + extra_spender.output[0].value = 19_000; + surviving_txid = Some(extra_spender.txid()); + records.extend( + other_spender + .check_core_transaction( + &extra_spender, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + let mut locks = std::collections::BTreeMap::new(); + if chainlocked { + records + .iter_mut() + .find(|record| record.txid == winner.txid()) + .unwrap() + .context = block(101); + locks.insert( + loser.txid(), + InstantLock { + inputs: coins.iter().map(|coin| coin.outpoint).collect(), + txid: loser.txid(), + ..Default::default() + }, + ); + } else { + locks.insert( + winner.txid(), + InstantLock { + inputs: vec![coins[0].outpoint], + txid: winner.txid(), + ..Default::default() + }, + ); + } + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("released-input.sqlite"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records, + new_utxos: coins + .iter() + .cloned() + .chain([Utxo::new( + OutPoint::new(loser.txid(), 0), + loser.output[0].clone(), + address, + 0, + false, + )]) + .collect(), + spent_utxos: coins.clone(), + instant_locks_for_non_final_records: locks, + last_applied_chain_lock: chainlocked.then_some(ChainLock { + block_height: 101, + block_hash: BlockHash::from_byte_array([101; 32]), + signature: [0; 96].into(), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let extra_key: Vec = persister + .lock_conn_for_test() + .query_row( + "SELECT outpoint FROM core_utxos WHERE wallet_id = ?1 AND value = 20000", + [wallet.wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let missing_winner = Txid::from_byte_array([72; 32]); + if recordless_claim { + let conn = persister.lock_conn_for_test(); + conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized) VALUES (?1, ?2, 101, 0)", + rusqlite::params![wallet.wallet_id.as_slice(), missing_winner.as_byte_array().as_slice()]).unwrap(); + conn.execute( + "UPDATE core_utxos SET spent_in_txid = ?1 WHERE wallet_id = ?2 AND value = 20000", + rusqlite::params![ + missing_winner.as_byte_array().as_slice(), + wallet.wallet_id.as_slice() + ], + ) + .unwrap(); + } + if matches!(case, ConflictCase::RecordlessPlaceholder) { + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key]).unwrap(); + } + if matches!(case, ConflictCase::FailedRepair) { + persister.lock_conn_for_test().execute_batch( + "CREATE TRIGGER fail_replay_repair BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent = 0 BEGIN SELECT RAISE(ABORT, 'injected replay repair failure'); END;", + ).unwrap(); + assert!(persister.load().is_err()); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some()); + let spent: bool = persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + |row| row.get(0), + ) + .unwrap(); + assert!(spent); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER fail_replay_repair") + .unwrap(); + } + drop(persister); + for _ in 0..2 { + let policy = if recovery { + platform_wallet_storage::LoadPolicy::Recovery + } else { + platform_wallet_storage::LoadPolicy::Strict + }; + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) + .unwrap(); + let state = persister.load().unwrap(); + let info = &state.wallets[&wallet.wallet_id].wallet_info; + let account = &info.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser.txid())); + if let Some(txid) = surviving_txid { + assert!(account.transactions().contains_key(&txid)); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &txid) + .unwrap() + .is_some()); + } + assert!( + !account.utxos.contains_key(&coins[0].outpoint), + "winner still spends the shared input" + ); + assert_eq!( + account.utxos.contains_key(&coins[1].outpoint), + expect_released, + "the extra input is free only when no surviving transaction claims it" + ); + assert_eq!( + info.balance.total(), + if expect_released { 20_000 } else { 0 } + ); + assert_eq!( + persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some(), + recovery + ); + if recordless_claim { + let claim: Vec = persister + .lock_conn_for_test() + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(claim, missing_winner.as_byte_array()); + } + let selection = CoinSelector::new(SelectionStrategy::LargestFirst).select_coins( + account.utxos.values(), + 5_000, + FeeRate::default(), + 100, + ); + if !expect_released { + assert!(selection.is_err()); + } else { + assert_eq!(selection.unwrap().selected[0].outpoint, coins[1].outpoint); + } + } +} From 8d6d9e7b5d291cdf7292eb78351fbe476074bb85 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:50:06 +0000 Subject: [PATCH 36/39] fix(swift-sdk): preserve wallet accounting when participants change Retain the surviving wallet's amount and direction before deleting another wallet's transaction links. Preserve unavailable amounts through a nullable V4 field and the existing frozen V3 migration graph. Scope shared asset-lock direction to ordinary wallet outputs without changing own conversions. Add deletion/reopen, unresolved-amount, asset-lock direction and V3 migration regressions. Static checks passed; Swift/Xcode tools are unavailable on Linux. Co-Authored-By: Codex GPT-6 --- packages/swift-sdk/SCHEMA_RELEASES.md | 36 +++-- .../Persistence/DashModelContainer.swift | 38 +++-- .../Models/PersistentTransaction.swift | 36 ++++- .../PlatformWalletPersistenceHandler.swift | 11 +- .../DashLegacySchemaMigrationTests.swift | 19 ++- .../DashModelMigrationTests.swift | 16 +- .../DashReleasedSchemaTests.swift | 31 ++++ .../TransactionAccountingTests.swift | 142 ++++++++++++++++++ 8 files changed, 276 insertions(+), 53 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 2183bfbe76d..2e568c859da 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -5,7 +5,8 @@ distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the model checksum match the observed App Store 9.0.2 database. Active models are -V3. Other intermediate development shapes remain unsupported. +V4. The released V3 graph is preserved by `DashSchemaSnapshotV3`. Other +intermediate development shapes remain unsupported. The old V2 fixture was generated from September 8 sources containing 13 properties added on August 28, after the August 27 App Store release. The @@ -15,25 +16,28 @@ separate from archive-captured releases. This identifies a matching model source, not the confirmed build commit of Apple's binary. V2 is reserved: automated release capture must not register another shape under that number. -The main migration plan is historical V2 → V3. Accepted V1 has a separate -V1 → V3 plan: V1 already contains the 13 properties missing from historical -V2, so a V1 → V2 → V3 chain could discard values. Routing uses model metadata +The main migration plan is historical V2 → V3 → V4. Accepted V1 has a separate +V1 → V4 plan: V1 already contains the 13 properties missing from historical +V2, so a chain through V2 could discard values. Routing uses model metadata and runs after recovery; a version label alone never selects an unknown beta schema. The former live V2 is accepted only when its complete graph matches -current V3 exactly, entity hashes and checksum alike. That alias therefore -lasts only until the next live-graph change: after it, every store still -labelled `2.0.0` with the former live shape becomes `unsupported-v2` and fails -closed. Check internal devices still carrying that label before the next -shape change and migrate or deliberately reset them then, rather than -discovering them afterwards as failed opens. +frozen V3 exactly, entity hashes and checksum alike. The alias remains supported +when the live graph changes; other stores labelled `2.0.0` fail closed. + +V4 adds the optional `PersistentTransaction.netAmountUnavailable` marker through +a lightweight migration. Existing rows receive `nil`, preserving their stored +accounting. When deleting one participant of a shared transaction, the remaining +wallet's amount and direction are saved before the ownership links disappear. +If its amount was unresolved, the marker keeps it unavailable across restart; +an authoritative wallet record or complete accounting reconciliation clears it. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, -`accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +`accepted-v1-to-v4`, `historical-v2-to-v4`, `previous-live-v2-v3-shape`, +`published-v3-to-v4`, `unsupported-v2`, `labelled-current-v4`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a -label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the +label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` or `4.0.0` label takes the default plan without any probe, and a probe failure on the undecidable labels refuses the open with the probe's own error while leaving the store untouched. @@ -137,9 +141,9 @@ The run failed before upload, so this provenance establishes a tested source layout rather than proof of publication. Regression tests exercise the public factory, data/default preservation, writes, reopen, and failure recovery. -Keep the bridge for installations that skip the V3 app release. When advancing -to V4, bind `DashSchemaV3` to its released snapshot and retain the legacy-to-V3 -step before the normal V3-to-current plan. The bridge must never automatically +Keep the bridge for installations that skip the V3 app release. `DashSchemaV3` +stays bound to its released snapshot, retaining the legacy-to-V3 step before +the normal V3-to-current plan. The bridge must never automatically follow the latest live model graph. The release observer's one-time `bootstrap` only records its observation baseline; it neither runs this migration nor proves V1's App Store provenance. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ea878415c36..22fbdb63ffc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -100,7 +100,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data. @@ -172,7 +172,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +185,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) return container } catch { logMigrationFailure(error) @@ -201,7 +201,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("3.0.0")]) + "target_version": .publicText("4.0.0")]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +218,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("3.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +236,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("3.0.0")]) + "route": .publicText(route), "target_version": .publicText("4.0.0")]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -247,7 +247,7 @@ public enum DashModelContainer { // probe stays fatal here: the open fails with the probe's own error // instead of an inapplicable plan, and the store is untouched. if versions == ["1.0.0"], try matches(DashSchemaV1.self) { - logRoute("accepted-v1-to-v3") + logRoute("accepted-v1-to-v4") return DashAcceptedV1MigrationPlan.self } if versions == ["2.0.0"] { @@ -261,12 +261,14 @@ public enum DashModelContainer { throw DashLegacyStoreSQLite.Failure.unsupported( "The database identifies itself as schema 2.0.0 but its model does not match the supported historical or current schema. The original database has not been replaced. Contact support; do not delete the app.") } - logRoute(historical ? "historical-v2-to-v3" : "previous-live-v2-current-shape") + logRoute(historical ? "historical-v2-to-v4" : "previous-live-v2-v3-shape") } else if versions == ["3.0.0"] { + logRoute("published-v3-to-v4") + } else if versions == ["4.0.0"] { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("labelled-current-v3") + logRoute("labelled-current-v4") } else { logRoute("ordinary-current-plan") } @@ -287,12 +289,13 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -300,9 +303,9 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV4.self] } static var stages: [MigrationStage] { - [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self)] + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV4.self)] } } @@ -372,9 +375,14 @@ public enum DashSchemaV2: VersionedSchema { } } -/// Current working schema. A later shape change must preserve this graph as -/// the fixed legacy-bridge target before introducing another live version. +/// Frozen released graph and fixed target of the legacy-store bridge. public enum DashSchemaV3: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } +} + +/// Live schema adds an optional accounting-availability marker; existing rows keep nil. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index a26c60724c8..a27fb766d11 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -86,6 +86,9 @@ public final class PersistentTransaction { public var transactionTypeKind: UInt8 = 0xFF /// Net Core amount in duffs across locally owned TXOs (positive=received, negative=sent). public var netAmount: Int64 + /// Set when removing a wallet left no authoritative amount for the survivor. + /// `nil` preserves the accounting of rows migrated from V3. + public var netAmountUnavailable: Bool? = nil /// Fee in duffs (nil if unknown). public var fee: UInt64? /// User-assigned label. @@ -246,7 +249,9 @@ public final class PersistentTransaction { } } let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } - if participatingWalletIds == [walletId], !hasUnownedTxos { return netAmount } + if participatingWalletIds == [walletId], !hasUnownedTxos { + return netAmountUnavailable == true ? nil : netAmount + } // Computed from TXOs alone: a pending input this wallet recorded may be // one of its own still-unlinked coins, so the sum is only provisional. // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two @@ -266,14 +271,37 @@ public final class PersistentTransaction { /// Direction relative to one wallet for transactions shared by multiple local wallets. public func direction(for walletId: Data) -> UInt32 { guard participatingWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, - typedKind != .coinJoin, !isAssetLock else { return direction } + typedKind != .coinJoin else { return direction } let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId } + if isAssetLock { + let ownedOutputs = outputs.filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + if spendsOurs { + return ownedOutputs.contains { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) != walletId + } ? CoreDirectionCode.outgoing : direction + } + return ownedOutputs.contains { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + } ? CoreDirectionCode.incoming : direction + } return spendsOurs ? CoreDirectionCode.outgoing : CoreDirectionCode.incoming } + /// Retain the sole surviving wallet's accounting before ownership links disappear. + func preserveAccounting(removingWallet walletId: Data) { + let participants = participatingWalletIds + guard participants.count == 2, participants.contains(walletId), + let survivor = participants.first(where: { $0 != walletId }) else { return } + let amount = netAmount(for: survivor) + let survivorDirection = direction(for: survivor) + if let amount { netAmount = amount } + netAmountUnavailable = amount == nil + direction = survivorDirection + } + /// Format the wallet's Core value movement in DASH. public func formattedAmount(for walletId: Data) -> String { guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } @@ -282,7 +310,7 @@ public final class PersistentTransaction { /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. public func displayNetAmount(for walletId: Data?) -> Int64? { - walletId.map { netAmount(for: $0) } ?? netAmount + walletId.map { netAmount(for: $0) } ?? (netAmountUnavailable == true ? nil : netAmount) } /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. @@ -450,7 +478,7 @@ public final class PersistentTransaction { } public var formattedAmount: String { - Self.format(duffs: netAmount) + netAmountUnavailable == true ? "Amount unavailable" : Self.format(duffs: netAmount) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c52fa78e4bc..d7046d96cfe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2555,11 +2555,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.hasBlockPosition = tx.has_block_position let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes - // A context-only recovery record has zero accounting; a funded asset lock burns Core value. - // A stored debit is itself the proof we funded it: its inputs may not be linked yet. + // Empty asset-lock recovery records cannot replace a funded debit or an unavailable amount. let preserveLockAccounting = tx.transaction_type_kind == TransactionTypeKind.assetLock.rawValue && tx.net_amount == 0 && !tx.has_fee - && record.netAmount < 0 + && (record.netAmount < 0 || record.netAmountUnavailable == true) if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) @@ -2584,6 +2583,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { : nil if !preserveLockAccounting { record.netAmount = tx.net_amount + record.netAmountUnavailable = false record.fee = tx.has_fee ? tx.fee : nil } accountingDirty[record.txid] = record @@ -6416,6 +6416,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if let walletRow = walletRow { + // Shared scalars must be scoped while the departing wallet's TXOs still exist. + for transaction in try backgroundContext.fetch(FetchDescriptor()) { + transaction.preserveAccounting(removingWallet: walletId) + } // Wallet → identities is `.nullify`; this delete // path cascades them explicitly. let identitiesToDelete = Array(walletRow.identities) @@ -6898,6 +6902,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { isAssetLock: transaction.isAssetLock ) { transaction.netAmount = accounting.netAmount + transaction.netAmountUnavailable = false transaction.direction = accounting.direction } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 982d9ab0afd..26a2fd54943 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,15 +11,18 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV4: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV5: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, BridgeFutureV4.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .custom(fromVersion: DashSchemaV3.self, toVersion: BridgeFutureV4.self, + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -770,7 +773,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV4.self) + let schema = Schema(versionedSchema: BridgeFutureV5.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( @@ -887,7 +890,9 @@ final class DashLegacySchemaMigrationTests: XCTestCase { let container = try ModelContainer(for: schema, configurations: [ ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) ]) - try verifyRows(container.mainContext) + let wallet = try XCTUnwrap(container.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(wallet.name, "historical audit wallet") } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) let container = try open(url, hooks: .init(visit: { _, _ in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1ac6248f4cc..a45c3e9321f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -75,11 +75,11 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let log = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(log.contains("event=store_open_started")) - XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) + XCTAssertTrue(log.contains("route=\"historical-v2-to-v4\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"3.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v3\"")) + XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV3RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["3.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index d283b5a20c0..7e268061af8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -99,6 +99,37 @@ final class DashReleasedSchemaTests: XCTestCase { } } + @MainActor + func testShouldMigrateV3AccountingAvailabilityAndPersistAnUnavailableAmount() throws { + let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { + $0.version.versionIdentifier == Schema.Version(3, 0, 0) + }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("accounting.store") + try FileManager.default.copyItem(at: source(fixture), to: url) + let txid = Data(repeating: 0x32, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertNil(row.netAmountUnavailable, "V3 rows retain their stored accounting by default") + XCTAssertEqual(row.displayNetAmount(for: nil), row.netAmount) + row.netAmountUnavailable = true + try container.mainContext.save() + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertEqual(row.netAmountUnavailable, true) + XCTAssertNil(row.displayNetAmount(for: nil)) + XCTAssertEqual(row.formattedAmount, "Amount unavailable") + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) + } + } + @MainActor func testRuntimePlanHasNoDuplicateModelChecksums() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 47b6e018f14..6fc27f08526 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -394,6 +394,148 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(row.netAmount(for: walletId), 40, "a foreign input is not an unresolved input of ours") } + private func localTransfer(in context: ModelContext, assetLock: Bool = false) throws -> PersistentTransaction { + let walletA = PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet) + let walletB = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let accountA = PersistentAccount(wallet: walletA, accountType: 0, accountIndex: 0, accountTypeName: "Standard") + let accountB = PersistentAccount(wallet: walletB, accountType: 0, accountIndex: 0, accountTypeName: "Standard") + context.insert(walletA) + context.insert(walletB) + context.insert(accountA) + context.insert(accountB) + let tx = PersistentTransaction( + txid: Data(repeating: 3, count: 32), + transactionData: serializedSpend(inputs: [walletA.walletId], outputValue: 90), + direction: CoreDirectionCode.internalTransfer, netAmount: -10 + ) + tx.transactionTypeKind = assetLock ? TransactionTypeKind.assetLock.rawValue : TransactionTypeKind.standard.rawValue + let coin = input(100) + coin.account = accountA + coin.isSpent = true + coin.spendingTransaction = tx + let credit = PersistentTxo(transaction: tx, vout: 0, amount: 90, address: "", height: 1) + credit.walletId = walletB.walletId + credit.account = accountB + context.insert(tx) + context.insert(coin) + context.insert(credit) + tx.involvedAccounts = [accountA, accountB] + try context.save() + return tx + } + + func testShouldKeepSurvivingWalletAccountingAfterDeletingEitherTransferWallet() throws { + for deletedWallet in [UInt8(1), UInt8(2)] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let survivor = Data(repeating: deletedWallet == 1 ? 2 : 1, count: 32) + let amount: Int64 = deletedWallet == 1 ? 90 : -100 + let direction = deletedWallet == 1 ? CoreDirectionCode.incoming : CoreDirectionCode.outgoing + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + _ = try localTransfer(in: container.mainContext) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + try handler.deleteWalletData(walletId: Data(repeating: deletedWallet, count: 32)) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertEqual(row.netAmount(for: survivor), amount) + XCTAssertEqual(row.direction(for: survivor), direction) + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertEqual(row.netAmount(for: survivor), amount) + XCTAssertEqual(row.direction(for: survivor), direction) + } + } + } + + func testShouldKeepUnavailableAmountAfterDeletingAnotherParticipant() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let survivor = Data(repeating: 2, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let foreign = Data(repeating: 9, count: 32) + tx.transactionData = serializedSpend(inputs: [Data(repeating: 1, count: 32), foreign], outputValue: 90) + tx.netAmount = 0 + let pending = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: foreign, vout: 0), inputIndex: 1, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: survivor + ) + container.mainContext.insert(pending) + try container.mainContext.save() + XCTAssertNil(tx.netAmount(for: survivor)) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: Data(repeating: 1, count: 32)) + // An empty asset-lock recovery update must not invent accounting. + persist(handler, walletId: survivor, txid: tx.txid, bytes: tx.transactionData, kind: 6) + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertNil(row.netAmount(for: survivor)) + XCTAssertEqual(row.formattedAmount(for: survivor), "Amount unavailable") + // A new authoritative wallet record can make the amount available. + persist(handler, walletId: survivor, txid: row.txid, bytes: row.transactionData, net: 90, kind: 6) + let updated = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == row.txid }) + XCTAssertEqual(updated.netAmount(for: survivor), 90) + } + } + + func testShouldScopeSharedAssetLockDirectionBeforeAndAfterReconciliation() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let walletA = Data(repeating: 1, count: 32) + let walletB = Data(repeating: 2, count: 32) + // A's Rust record is outgoing, while reconciliation sees both local wallets. + tx.direction = CoreDirectionCode.outgoing + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.direction, CoreDirectionCode.internalTransfer) + XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(row.direction(for: walletB), CoreDirectionCode.incoming) + } + + func testShouldKeepOwnAssetLockConversionInternalWithAnotherKeysOnlyParticipant() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let walletA = Data(repeating: 1, count: 32) + let change = try XCTUnwrap(tx.outputs.first) + change.walletId = walletA + change.account = tx.inputs.first?.account + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) + XCTAssertEqual(tx.direction(for: Data(repeating: 2, count: 32)), CoreDirectionCode.internalTransfer) + // A no-change conversion remains internal too. + container.mainContext.delete(change) + tx.transactionData = serializedSpend(inputs: [walletA], outputValue: 90, burn: true) + try container.mainContext.save() + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.internalTransfer) + } + func testShouldScopeDirectionAndAmountToEachWalletOfALocalTransfer() { let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) let (amount, fee): (UInt64, UInt64) = (90, 10) From 9894a0cef8d159c47e6c6b08b76d25c8b882f952 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:42:58 +0000 Subject: [PATCH 37/39] fix(wallet-storage): preserve durable claims during replay recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore recordless spent guards with claimant provenance, remove swept asset-lock lifecycle rows atomically, reject unsafe replay operands, and return wallet-scoped reconciliation failures. Pin the minimal upstream restoration API and cover restart, finality, conflicts, and rollback. Validation: 133 targeted tests, package rustfmt, and CI-matching Clippy pass. Co-Authored-By: Codex (GPT-6) 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- Cargo.lock | 24 +- Cargo.toml | 16 +- packages/rs-platform-wallet-storage/SCHEMA.md | 7 + .../src/sqlite/persister.rs | 17 +- .../src/sqlite/rehydrate.rs | 104 ++++- .../src/sqlite/schema/asset_locks.rs | 32 +- .../src/sqlite/schema/core_state.rs | 30 +- .../tests/sqlite_spent_rehydration.rs | 362 +++++++++++++++++- 8 files changed, 552 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e145602a267..dbb010fee5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1654,7 +1654,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dash-network", ] @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "async-trait", "chrono", @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "anyhow", "base64-compat", @@ -1815,12 +1815,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dashcore-rpc-json", "hex", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dashcore", "grovedb-bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dashcore-private", "grovedb-bincode", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" [[package]] name = "glob" @@ -4154,7 +4154,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "aes", "async-trait", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=18f7f3e695e770ea5d2820aa85597d45160d1b8e#18f7f3e695e770ea5d2820aa85597d45160d1b8e" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index 95d014a3bbd..2d40e087ae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,14 +65,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "18f7f3e695e770ea5d2820aa85597d45160d1b8e" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index ab3f8fc465d..c810d624d3a 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -427,6 +427,10 @@ outputs disappear and genuinely released materialized inputs become available. Replay preserves a surviving `spent_in_txid` claim even when its winner has no stored transaction body. Strict loads commit this repair; Recovery loads return the repaired projection but roll back all database changes. +After replay, every remaining spent row restores an in-memory guard with its +optional claimant, including unmaterialized placeholders. Funding redelivery +cannot credit it; later conflict removal releases a restored guard only when +its known claimant is removed. Unknown claims remain protected. What gates the funding UTXO's own later upsert (`execute_upsert_utxo`) is the row's shape, not that link: a never-materialised held row (`is_sweep_placeholder = 1`, `spent = 1` — the placeholder `apply_sweep` writes for an input whose @@ -642,6 +646,9 @@ unfiltered inspection reader (`schema::asset_locks::list_active`). The rehydration feed reads through `schema::asset_locks::load_unconsumed`, which filters at the SQL level (`status NOT IN ('consumed')`), so a spent one-shot lock is never resurrected as actionable. +Load-time Core conflict reconciliation also removes the losing transactions' +non-consumed lifecycle rows in the same wallet transaction. Consumed history +survives, and Recovery rolls back both Core and lifecycle repairs. - PK: `(wallet_id, outpoint)`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 520bae7fdba..2643e5feb8a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1698,9 +1698,13 @@ fn load_one_wallet( // Rebuild from the repaired projection, including released materialized inputs. let (repaired, remaining) = load_wallet_snapshot(&tx, wallet_id, ctx)?; if !remaining.is_empty() { - return Err(PersistenceError::from(WalletStorageError::blob_decode( - "conflicting transaction history remained after replay reconciliation", - ))); + return Err(PersistenceError::from( + WalletStorageError::WalletRehydrationFailed { + wallet_id, + cause: "conflicting transaction history remained after replay reconciliation" + .to_string(), + }, + )); } state = repaired; } @@ -1896,7 +1900,12 @@ fn load_wallet_snapshot( &mut wallet, core_state.records, &core_state.instant_locks_for_non_final_records, - ); + ) + .map_err(PersistenceError::from)?; + // Restore durable claims after replay/finality, including claims with no spend body. + let spent = + schema::core_state::load_spent_claims(conn, &wallet_id).map_err(PersistenceError::from)?; + wallet_info.restore_spent_outpoints(&spent); Ok(( platform_wallet::changeset::ClientWalletStartState { wallet, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index fdd1eeaadbf..b289bbf2319 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::transaction::TransactionPayload; use dashcore::{OutPoint, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; @@ -31,6 +32,7 @@ use crate::sqlite::provider_accounts::{insert_platform_node_pool_entry, Platform use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; use crate::sqlite::schema::accounts::{self, AccountManifest}; use crate::sqlite::schema::core_pool::{self, OwningAccount}; +use crate::sqlite::util::safe_cast; use crate::WalletStorageError; /// Build a [`Wallet`] that will be provided to the platform-wallet during rehydration. @@ -437,7 +439,8 @@ pub(crate) fn restore_recorded_transactions( wallet: &mut Wallet, records: Vec, instant_locks: &BTreeMap, -) -> Vec { +) -> Result, WalletStorageError> { + validate_replay_amounts(wallet_info, &records)?; // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. let placed: HashMap = wallet_info @@ -450,7 +453,7 @@ pub(crate) fn restore_recorded_transactions( }) .collect(); if records.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } // TODO(bound-load-history-replay): every stored record is replayed on each // load; bounding it to records above the last chain lock needs care so @@ -525,7 +528,7 @@ pub(crate) fn restore_recorded_transactions( ); *wallet_info = info_before; *wallet = wallet_before; - return Vec::new(); + return Ok(Vec::new()); } // A settled spend's first sweep cannot reach competitors replayed later. // The upstream sweep preserves ChainLock/InstantSend precedence. @@ -576,7 +579,88 @@ pub(crate) fn restore_recorded_transactions( wallet_info.apply_chain_lock(chain_lock); } wallet_info.update_balance(); - sweeps + Ok(sweeps) +} + +/// The upstream checker casts each unsigned operand before signed subtraction. +/// A fitting net does not make an overflowing operand safe. Include every source +/// of replay inputs: durable coins, parent outputs, and staged input details. +fn validate_replay_amounts( + wallet_info: &ManagedWalletInfo, + records: &[TransactionRecord], +) -> Result<(), WalletStorageError> { + fn total( + field: &'static str, + values: impl IntoIterator, + ) -> Result<(), WalletStorageError> { + let mut sum = 0_u64; + for value in values { + safe_cast::u64_to_i64(field, value)?; + // Both operands are at most i64::MAX, so this addition fits u64. + sum += value; + safe_cast::u64_to_i64(field, sum)?; + } + Ok(()) + } + + let mut amounts: HashMap = HashMap::new(); + let mut remember = |outpoint, value| { + amounts + .entry(outpoint) + .and_modify(|known| *known = (*known).max(value)) + .or_insert(value); + }; + for account in wallet_info.accounts.all_funding_accounts() { + for (outpoint, coin) in &account.utxos { + safe_cast::u64_to_i64("replay.utxo", coin.txout.value)?; + remember(*outpoint, coin.txout.value); + } + } + for record in records { + total( + "replay.input_details", + record.input_details.iter().map(|d| d.value), + )?; + total( + "replay.output_details", + record.output_details.iter().map(|d| d.value), + )?; + let credit_outputs = match &record.transaction.special_transaction_payload { + Some(TransactionPayload::AssetLockPayloadType(payload)) => { + payload.credit_outputs.as_slice() + } + _ => &[], + }; + total( + "replay.outputs", + record + .transaction + .output + .iter() + .chain(credit_outputs) + .map(|o| o.value), + )?; + let txid = record.transaction.txid(); + for (index, output) in record.transaction.output.iter().enumerate() { + remember(OutPoint::new(txid, index as u32), output.value); + } + for detail in &record.input_details { + if let Some(input) = record.transaction.input.get(detail.index as usize) { + remember(input.previous_output, detail.value); + } + } + } + for record in records { + total( + "replay.inputs", + record + .transaction + .input + .iter() + .filter_map(|input| amounts.get(&input.previous_output).copied()), + )?; + } + Ok(()) } fn replay_sweep( @@ -3702,7 +3786,8 @@ mod tests { }, ); } - restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()); + restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()) + .unwrap(); let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; assert!(!coins.contains_key(&spent), "{case}"); @@ -3797,7 +3882,7 @@ mod tests { // Alone, the locked spend comes back InstantSend. let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; - restore_recorded_transactions(&mut alone, &mut wallet, pair, &locks); + restore_recorded_transactions(&mut alone, &mut wallet, pair, &locks).unwrap(); assert!( alone.accounts.standard_bip44_accounts[&0] .transactions() @@ -3808,7 +3893,8 @@ mod tests { // Replayed after a conflicting spend, its lock sweeps that spend. let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); - restore_recorded_transactions(&mut contested, &mut wallet, records.clone(), &locks); + restore_recorded_transactions(&mut contested, &mut wallet, records.clone(), &locks) + .unwrap(); assert!( !contested.accounts.standard_bip44_accounts[&0] .transactions() @@ -3955,7 +4041,7 @@ mod tests { ); } - restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks).unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") @@ -4286,7 +4372,7 @@ mod tests { for (case, records, key, lock) in cases { let locks: BTreeMap = [(key, lock)].into_iter().collect(); let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); - restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks).unwrap(); let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); assert!( !transactions diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index f437755606a..8b08d33e53c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -24,9 +24,11 @@ use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; use { - dashcore::OutPoint, platform_wallet::changeset::AssetLockEntry, - platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, rusqlite::Connection, - std::collections::BTreeMap, + dashcore::{OutPoint, Txid}, + platform_wallet::changeset::AssetLockEntry, + platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, + rusqlite::Connection, + std::collections::{BTreeMap, HashSet}, }; use crate::sqlite::schema::blob::impl_persistable_blob; @@ -196,6 +198,30 @@ pub fn apply( Ok(()) } +/// Remove replay losers from the resumable lifecycle in the Core repair transaction. +pub(crate) fn remove_swept( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txids: &HashSet, +) -> Result<(), WalletStorageError> { + let mut changes = AssetLockChangeSet::default(); + { + let mut stmt = tx.prepare( + "SELECT length(outpoint), outpoint FROM asset_locks WHERE wallet_id = ?1 AND status != 'consumed'", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + let bytes: Vec = row.get(1)?; + let outpoint = blob::decode_outpoint(&bytes)?; + if txids.contains(&outpoint.txid) { + changes.removed.insert(outpoint); + } + } + } + apply(tx, wallet_id, &changes) +} + /// Test-only drift guard for the `asset_locks.status` TEXT-column /// domain **as the writer sees it** (production code never reads this /// — the writer maps through [`status_str`] and the on-disk CHECK 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 9126822c576..2731fd86027 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use dashcore::ephemerealdata::chain_lock::ChainLock; +use dashcore::{hashes::Hash, OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; @@ -16,10 +17,10 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; -use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; use crate::sqlite::schema::core_history; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; +use crate::sqlite::schema::{asset_locks, blob}; // PUBLIC material only: core-chain state reaching `record_blob` / // `islock_blob` (transaction records + InstantLocks are public chain data). @@ -544,6 +545,7 @@ pub fn apply_replay_sweeps( .released_outpoints .retain(|outpoint| !held.contains_key(outpoint)); } + asset_locks::remove_swept(tx, wallet_id, &removed)?; apply( tx, wallet_id, @@ -955,6 +957,32 @@ fn upsert_sync_state( Ok(()) } +/// Durable spend guards include recordless claims and unmaterialized sweep placeholders. +pub(crate) fn load_spent_claims( + conn: &Connection, + wallet_id: &WalletId, +) -> Result)>, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT length(outpoint), outpoint, length(spent_in_txid), spent_in_txid \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut outpoints = Vec::new(); + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + let bytes: Vec = row.get(1)?; + let claimant = if let Some(length) = row.get::<_, Option>(2)? { + blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; + let raw: Vec = row.get(3)?; + Some(Txid::from_slice(&raw)?) + } else { + None + }; + outpoints.push((blob::decode_outpoint(&bytes)?, claimant)); + } + Ok(outpoints) +} + /// Bulk-reconstruct the keyless [`CoreChangeSet`] projection for one wallet /// from the `core_*` tables, plus the per-outpoint owning-account side channel. /// PUBLIC material only; mints no `Wallet`. `network` (from `wallets`) turns a diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index 6fc716267ca..9e6d1679cc0 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -708,6 +708,10 @@ enum ConflictCase { RecordlessClaim, RecordlessPlaceholder, FailedRepair, + Assets, + AssetsRecovery, + AssetsFailedRepair, + Unconverged, DefeatedLock, } @@ -726,7 +730,11 @@ async fn assert_conflict_restart(case: ConflictCase) { ConflictCase::RecordlessClaim | ConflictCase::RecordlessPlaceholder ); let expect_released = !surviving_claim && !recordless_claim; - let recovery = matches!(case, ConflictCase::Recovery); + let recovery = matches!(case, ConflictCase::Recovery | ConflictCase::AssetsRecovery); + let assets = matches!( + case, + ConflictCase::Assets | ConflictCase::AssetsRecovery | ConflictCase::AssetsFailedRepair + ); use dashcore::ephemerealdata::instant_lock::InstantLock; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; @@ -945,11 +953,92 @@ async fn assert_conflict_restart(case: ConflictCase) { "UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE wallet_id = ?1 AND outpoint = ?2", rusqlite::params![wallet.wallet_id.as_slice(), &extra_key]).unwrap(); } - if matches!(case, ConflictCase::FailedRepair) { + let mut other_wallet_id = None; + if assets { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let entries: std::collections::BTreeMap<_, _> = + [AssetLockStatus::Broadcast, AssetLockStatus::Consumed] + .into_iter() + .enumerate() + .map(|(vout, status)| { + let out_point = OutPoint::new(loser.txid(), vout as u32); + ( + out_point, + AssetLockEntry { + out_point, + transaction: loser.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status, + proof: None, + }, + ) + }) + .collect(); + let other = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + other_wallet_id = Some(other.wallet_id); + let outpoint = OutPoint::new(loser.txid(), 0); + persister + .store( + other.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: other + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + asset_locks: Some(AssetLockChangeSet { + asset_locks: [(outpoint, entries[&outpoint].clone())] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: entries, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + } + if matches!( + case, + ConflictCase::FailedRepair | ConflictCase::AssetsFailedRepair + ) { persister.lock_conn_for_test().execute_batch( "CREATE TRIGGER fail_replay_repair BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent = 0 BEGIN SELECT RAISE(ABORT, 'injected replay repair failure'); END;", ).unwrap(); assert!(persister.load().is_err()); + if assets { + assert_eq!( + asset_count(&persister), + 3, + "failed reconciliation must roll back asset lifecycle deletions" + ); + } assert!(persister .get_core_tx_record(wallet.wallet_id, &loser.txid()) .unwrap() @@ -968,6 +1057,18 @@ async fn assert_conflict_restart(case: ConflictCase) { .execute_batch("DROP TRIGGER fail_replay_repair") .unwrap(); } + if matches!(case, ConflictCase::Unconverged) { + persister.lock_conn_for_test().execute_batch("CREATE TRIGGER preserve_history BEFORE DELETE ON core_transactions BEGIN SELECT RAISE(IGNORE); END;").unwrap(); + let error = persister.load().unwrap_err(); + assert!( + matches!(typed_error(error), platform_wallet_storage::WalletStorageError::WalletRehydrationFailed { wallet_id, .. } if wallet_id == wallet.wallet_id) + ); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some()); + return; + } drop(persister); for _ in 0..2 { let policy = if recovery { @@ -978,7 +1079,29 @@ async fn assert_conflict_restart(case: ConflictCase) { let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) .unwrap(); - let state = persister.load().unwrap(); + let mut state = persister.load().unwrap(); + if assets { + assert!( + state.wallets[&wallet.wallet_id] + .unused_asset_locks + .is_empty(), + "swept asset locks must not be resumable" + ); + assert_eq!( + asset_count(&persister), + if recovery { 3 } else { 2 }, + "consumed history is retained and Recovery rolls back removals" + ); + } + if let Some(other_id) = other_wallet_id { + assert!( + state.wallets[&other_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&OutPoint::new(loser.txid(), 0))), + "the same asset lock in another wallet must survive" + ); + } let info = &state.wallets[&wallet.wallet_id].wallet_info; let account = &info.accounts.standard_bip44_accounts[&0]; assert!(!account.transactions().contains_key(&loser.txid())); @@ -1031,5 +1154,238 @@ async fn assert_conflict_restart(case: ConflictCase) { } else { assert_eq!(selection.unwrap().selected[0].outpoint, coins[1].outpoint); } + if recordless_claim { + let loaded = state.wallets.get_mut(&wallet.wallet_id).unwrap(); + loaded + .wallet_info + .check_core_transaction(&funding, block(100), &mut loaded.wallet, true, true) + .await; + assert!( + !loaded.wallet_info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coins[1].outpoint), + "recordless claim must survive funding redelivery" + ); + assert_eq!(loaded.wallet_info.balance.total(), 0); + } + } +} + +fn asset_count(persister: &SqlitePersister) -> i64 { + persister + .lock_conn_for_test() + .query_row("SELECT COUNT(*) FROM asset_locks", [], |row| row.get(0)) + .unwrap() +} + +#[tokio::test] +async fn should_remove_swept_asset_locks_on_restart() { + assert_conflict_restart(ConflictCase::Assets).await; +} + +#[tokio::test] +async fn should_rollback_swept_asset_locks_in_recovery() { + assert_conflict_restart(ConflictCase::AssetsRecovery).await; +} + +#[tokio::test] +async fn should_rollback_swept_asset_locks_on_sql_failure() { + assert_conflict_restart(ConflictCase::AssetsFailedRepair).await; +} + +#[tokio::test] +async fn should_guard_recordless_spends_with_missing_funding_and_finality() { + for funding_state in ["absent", "height_only", "record"] { + for spender_row in [false, true] { + for placeholder in [false, true] { + for known_claimant in [false, true] { + let fixture = + Fixture::build(block(100), block(200), funding_state == "record").await; + { + let conn = fixture.persister.lock_conn_for_test(); + if spender_row { + conn.execute( + "UPDATE core_transactions SET record_blob = NULL WHERE txid != ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + ) + .unwrap(); + } else { + conn.execute( + "DELETE FROM core_transactions WHERE txid != ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + ) + .unwrap(); + } + if funding_state == "absent" { + conn.execute( + "DELETE FROM core_transactions WHERE txid = ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + ) + .unwrap(); + } + if !known_claimant { + conn.execute( + "UPDATE core_utxos SET spent_in_txid = NULL WHERE spent = 1", + [], + ) + .unwrap(); + } + if placeholder { + conn.execute("UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE spent = 1", []).unwrap(); + } + } + let (mut wallet, mut info) = fixture.load(); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: [0; 96].into(), + }); + info.check_core_transaction( + &fixture.funding, + block(100), + &mut wallet, + true, + true, + ) + .await; + fixture.assert_spent_excluded(&info); + // A later loser must not release a different durable claimant's input. + let mut loser = fixture.funding.clone(); + loser.input = [fixture.spent, fixture.available] + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .collect(); + loser.output.truncate(1); + loser.output[0].value = 119_000; + info.check_core_transaction( + &loser, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + let mut winner = loser.clone(); + winner.input.remove(0); + winner.output[0] = TxOut { + value: 19_000, + script_pubkey: ScriptBuf::new(), + }; + info.check_core_transaction(&winner, block(301), &mut wallet, true, true) + .await; + info.check_core_transaction( + &fixture.funding, + block(100), + &mut wallet, + true, + true, + ) + .await; + assert!( + !info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fixture.spent), + "a later conflict cannot release a recordless durable claim" + ); + assert_eq!(info.balance.total(), 0); + } + } + } } } + +async fn assert_replay_amount_rejected(values: &[u64], received: u64) { + use key_wallet::managed_account::transaction_record::{InputDetail, OutputDetail, OutputRole}; + use platform_wallet_storage::{sqlite::schema::blob, WalletStorageError}; + let fixture = Fixture::with_height_only_funding(block(200)).await; + let conn = fixture.persister.lock_conn_for_test(); + let bytes: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE record_blob IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + let mut record: key_wallet::managed_account::transaction_record::TransactionRecord = + blob::decode(&bytes).unwrap(); + let address = record.input_details[0].address.clone(); + record.transaction.input = values + .iter() + .enumerate() + .map(|(index, _)| TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([90; 32]), index as u32), + ..Default::default() + }) + .collect(); + record.input_details = values + .iter() + .enumerate() + .map(|(index, value)| InputDetail { + index: index as u32, + value: *value, + address: address.clone(), + }) + .collect(); + record.transaction.output = vec![TxOut { + value: received, + script_pubkey: address.script_pubkey(), + }]; + record.output_details = vec![OutputDetail { + index: 0, + value: received, + address: Some(address), + role: OutputRole::Received, + }]; + record.net_amount = + i64::try_from(i128::from(received) - values.iter().map(|v| i128::from(*v)).sum::()) + .unwrap(); + record.txid = record.transaction.txid(); + conn.execute("DELETE FROM core_transactions", []).unwrap(); + conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, 200, 0, ?3)", rusqlite::params![fixture.wallet_id.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(&record).unwrap()]).unwrap(); + drop(conn); + let error = fixture + .persister + .load() + .expect_err("unsafe checker operands must return a typed error"); + assert!( + matches!( + typed_error(error), + WalletStorageError::IntegerOverflow { .. } + ), + "unsafe checker arithmetic must be rejected before replay" + ); +} + +#[tokio::test] +async fn should_reject_individually_overflowing_replay_input_with_fitting_net() { + assert_replay_amount_rejected(&[1_u64 << 63], 1).await; +} + +#[tokio::test] +async fn should_reject_cumulatively_overflowing_replay_inputs_with_fitting_net() { + assert_replay_amount_rejected(&[i64::MAX as u64, 1], 1).await; +} + +#[tokio::test] +async fn should_reject_overflowing_replay_output_with_fitting_net() { + assert_replay_amount_rejected(&[i64::MAX as u64], 1_u64 << 63).await; +} + +#[tokio::test] +async fn should_report_wallet_scoped_unconverged_replay() { + assert_conflict_restart(ConflictCase::Unconverged).await; +} + +fn typed_error( + error: platform_wallet::changeset::PersistenceError, +) -> platform_wallet_storage::WalletStorageError { + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("expected backend error") + }; + *source + .downcast::() + .unwrap() +} From a2b6f5064e61a9965470e1d7de4ae5110ec7a30d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:06:57 +0000 Subject: [PATCH 38/39] fix(wallet-storage): retain unknown claims during startup repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep existing spent rows with NULL claimants distinct from absent rows. Preserve their nullable claimant and height through conflict repair, reload, and funding redelivery while allowing known loser claims to release. Validation: 76 targeted restart/sweep tests, rustfmt, and CI-matching Clippy. Co-Authored-By: Codex (GPT-6) 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/schema/core_state.rs | 22 +++-- .../tests/sqlite_spent_rehydration.rs | 97 ++++++++++++++++++- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 2731fd86027..97d49725d60 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 @@ -507,7 +507,7 @@ fn surviving_stored_input_claims( Ok(claims) } -/// Apply replay settlement without releasing a recordless winner's durable claim. +/// Apply replay settlement without releasing surviving or unknown durable claims. pub fn apply_replay_sweeps( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -526,17 +526,25 @@ pub fn apply_replay_sweeps( let mut held = HashMap::new(); for outpoint in candidates { let key = blob::encode_outpoint(&outpoint)?; - let length: Option = tx.query_row( + let length: Option> = tx.query_row( "SELECT length(spent_in_txid) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 1", params![wallet_id.as_slice(), &key[..]], |row| row.get(0), - ).optional()?.flatten(); + ).optional()?; let Some(length) = length else { continue }; - blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; - let (claim, height): (Vec, Option) = tx.query_row( + if let Some(length) = length { + blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; + } + let (claim, height): (Option>, Option) = tx.query_row( "SELECT spent_in_txid, winner_mined_height FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet_id.as_slice(), &key[..]], |row| Ok((row.get(0)?, row.get(1)?)), )?; - if !removed.contains(&dashcore::Txid::from_slice(&claim)?) { + // A NULL claimant is an unknown durable spend, not evidence that the coin is free. + if claim + .as_deref() + .map(Txid::from_slice) + .transpose()? + .is_none_or(|claimant| !removed.contains(&claimant)) + { held.insert(outpoint, (claim, height)); } } @@ -554,7 +562,7 @@ pub fn apply_replay_sweeps( ..Default::default() }, )?; - // The generic sweep attributes held inputs to its winner; retain other winners' provenance. + // The generic sweep attributes held inputs to its winner; retain the original provenance. for (outpoint, (claim, height)) in held { let key = blob::encode_outpoint(&outpoint)?; tx.execute( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index 9e6d1679cc0..777a566958f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -688,6 +688,33 @@ async fn should_preserve_a_recordless_winners_placeholder_during_replay() { assert_conflict_restart(ConflictCase::RecordlessPlaceholder).await; } +#[tokio::test] +async fn should_preserve_unknown_materialized_claim_during_startup_conflict_repair() { + for height in [None, Some(102)] { + assert_conflict_restart(ConflictCase::UnknownClaim { + placeholder: false, + height, + }) + .await; + } +} + +#[tokio::test] +async fn should_preserve_unknown_placeholder_claim_during_startup_conflict_repair() { + for height in [None, Some(102)] { + assert_conflict_restart(ConflictCase::UnknownClaim { + placeholder: true, + height, + }) + .await; + } +} + +#[tokio::test] +async fn should_reject_malformed_claimant_during_startup_conflict_repair() { + assert_conflict_restart(ConflictCase::MalformedClaim).await; +} + #[tokio::test] async fn should_roll_back_a_failed_replay_repair() { assert_conflict_restart(ConflictCase::FailedRepair).await; @@ -707,6 +734,11 @@ enum ConflictCase { Recovery, RecordlessClaim, RecordlessPlaceholder, + UnknownClaim { + placeholder: bool, + height: Option, + }, + MalformedClaim, FailedRepair, Assets, AssetsRecovery, @@ -729,7 +761,8 @@ async fn assert_conflict_restart(case: ConflictCase) { case, ConflictCase::RecordlessClaim | ConflictCase::RecordlessPlaceholder ); - let expect_released = !surviving_claim && !recordless_claim; + let unknown_claim = matches!(case, ConflictCase::UnknownClaim { .. }); + let expect_released = !surviving_claim && !recordless_claim && !unknown_claim; let recovery = matches!(case, ConflictCase::Recovery | ConflictCase::AssetsRecovery); let assets = matches!( case, @@ -934,6 +967,41 @@ async fn assert_conflict_restart(case: ConflictCase) { |row| row.get(0), ) .unwrap(); + // Release controls carry a known loser claim; an unknown durable claim must stay held. + if expect_released { + persister + .lock_conn_for_test() + .execute( + "UPDATE core_utxos SET spent_in_txid = ?1 WHERE wallet_id = ?2 AND outpoint = ?3", + rusqlite::params![ + loser.txid().as_byte_array().as_slice(), + wallet.wallet_id.as_slice(), + &extra_key + ], + ) + .unwrap(); + } + if let ConflictCase::UnknownClaim { height, .. } = case { + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET spent_in_txid = NULL, winner_mined_height = ?1 WHERE wallet_id = ?2 AND outpoint = ?3", + rusqlite::params![height, wallet.wallet_id.as_slice(), &extra_key], + ).unwrap(); + } + if matches!(case, ConflictCase::MalformedClaim) { + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET spent_in_txid = zeroblob(31) WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + ).unwrap(); + assert!(matches!( + typed_error(persister.load().unwrap_err()), + platform_wallet_storage::WalletStorageError::BlobDecode { .. } + )); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some()); + return; + } let missing_winner = Txid::from_byte_array([72; 32]); if recordless_claim { let conn = persister.lock_conn_for_test(); @@ -948,7 +1016,14 @@ async fn assert_conflict_restart(case: ConflictCase) { ) .unwrap(); } - if matches!(case, ConflictCase::RecordlessPlaceholder) { + if matches!( + case, + ConflictCase::RecordlessPlaceholder + | ConflictCase::UnknownClaim { + placeholder: true, + .. + } + ) { persister.lock_conn_for_test().execute( "UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE wallet_id = ?1 AND outpoint = ?2", rusqlite::params![wallet.wallet_id.as_slice(), &extra_key]).unwrap(); @@ -1154,7 +1229,7 @@ async fn assert_conflict_restart(case: ConflictCase) { } else { assert_eq!(selection.unwrap().selected[0].outpoint, coins[1].outpoint); } - if recordless_claim { + if recordless_claim || unknown_claim { let loaded = state.wallets.get_mut(&wallet.wallet_id).unwrap(); loaded .wallet_info @@ -1168,6 +1243,22 @@ async fn assert_conflict_restart(case: ConflictCase) { ); assert_eq!(loaded.wallet_info.balance.total(), 0); } + if let ConflictCase::UnknownClaim { + placeholder, + height, + } = case + { + let persisted: (bool, Option>, Option, bool) = persister.lock_conn_for_test().query_row( + "SELECT spent, spent_in_txid, winner_mined_height, is_sweep_placeholder FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ).expect("unknown claim must survive repair and reopen"); + assert_eq!( + persisted, + (true, None, height, placeholder), + "repair must retain nullable provenance through reload and funding redelivery" + ); + } } } From f2e572b1ec433e4b6e3a4c823fb63de2a330341c Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:53:42 +0200 Subject: [PATCH 39/39] test(wallet-storage): verify clean event-driven spend restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive real wallet events through the SQLite adapter, reopen the database, and verify funding replay cannot make spent coins available. Cover pending, mined and finalized spends plus recordless conflict winners. Removing only restore_spent_outpoints reproduces both recordless-winner failures while ordinary spends and uninterrupted controls remain correct. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../tests/sqlite_clean_restart.rs | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_clean_restart.rs diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_clean_restart.rs b/packages/rs-platform-wallet-storage/tests/sqlite_clean_restart.rs new file mode 100644 index 00000000000..90dfbfed6d2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_clean_restart.rs @@ -0,0 +1,323 @@ +#![cfg(feature = "sqlite")] + +mod common; + +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use dashcore::{ + block::{Header, Version}, + bls_sig_utils::BLSSignature, + ephemerealdata::chain_lock::ChainLock, + hashes::Hash, + Block, BlockHash, CompactTarget, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxMerkleNode, + TxOut, Txid, +}; +use key_wallet::{ + transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}, + wallet::{ + initialization::WalletAccountCreationOptions, + managed_wallet_info::{ + coin_selection::{CoinSelector, SelectionStrategy}, + fee::FeeRate, + }, + ManagedWalletInfo, + }, +}; +use platform_wallet::{ + changeset::{ + spawn_wallet_event_adapter, AccountRegistrationEntry, PlatformWalletChangeSet, + PlatformWalletPersistence, WalletMetadataEntry, + }, + key_wallet_manager::{WalletEvent, WalletInterface, WalletManager}, + wallet::platform_wallet::PlatformWalletInfo, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use tokio::sync::{mpsc, RwLock}; +use tokio_util::sync::CancellationToken; + +type Manager = Arc>>; + +fn transaction(input: OutPoint, outputs: Vec) -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: input, + ..Default::default() + }], + output: outputs, + special_transaction_payload: None, + } +} + +async fn mine(manager: &Manager, wallet_id: [u8; 32], tx: Transaction, height: u32) { + let block = Block { + header: Header { + version: Version::default(), + prev_blockhash: BlockHash::from_byte_array([height as u8; 32]), + merkle_root: TxMerkleNode::all_zeros(), + time: height, + bits: CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata: vec![tx], + }; + manager + .write() + .await + .process_block_for_wallets( + &block, + block.block_hash(), + height, + &BTreeSet::from([wallet_id]), + ) + .await; +} + +async fn persist_events( + manager: &Manager, + persister: &Arc, + events: &mut mpsc::UnboundedReceiver, +) { + // Close one finite batch so completion proves every real manager event was stored. + let (sender, receiver) = mpsc::unbounded_channel(); + while let Ok(event) = events.try_recv() { + sender.send(event).unwrap(); + } + drop(sender); + let fault = Arc::new(AtomicBool::new(false)); + spawn_wallet_event_adapter( + Arc::clone(manager), + Arc::downgrade(persister), + receiver, + Arc::clone(&fault), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!fault.load(Ordering::Relaxed)); +} + +fn assert_coins(info: &ManagedWalletInfo, funding: &Transaction, phase: &str) { + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert!( + !coins.contains_key(&OutPoint::new(funding.txid(), 0)), + "{phase}: spent funding output must not return" + ); + assert!(coins.contains_key(&OutPoint::new(funding.txid(), 1))); + assert_eq!(info.balance.total(), 20_000, "{phase}: spendable balance"); + assert!( + CoinSelector::new(SelectionStrategy::LargestFirst) + .select_coins(coins.values(), 50_000, FeeRate::default(), 300) + .is_err(), + "{phase}: spent output must not fund a new payment" + ); +} + +async fn clean_restart(recordless_winner: bool, finalized: bool, pending: bool) { + let (persister, _dir, path) = common::fresh_persister(); + assert!(persister.load().unwrap().wallets.is_empty()); + let persister = Arc::new(persister); + let mut manager = WalletManager::::new(Network::Testnet); + let mut events = manager.take_persistence_receiver().unwrap(); + let wallet_id = manager.create_wallet_from_mnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + 0, WalletAccountCreationOptions::Default, + ).unwrap(); + let xpub = manager + .get_wallet(&wallet_id) + .unwrap() + .accounts + .standard_bip44_accounts[&0] + .account_xpub; + let address = 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(); + persister + .store( + wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: manager + .get_wallet(&wallet_id) + .unwrap() + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + ..Default::default() + }, + ) + .unwrap(); + let funding = transaction( + OutPoint::new(Txid::from_byte_array([13; 32]), 0), + vec![ + TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }, + TxOut { + value: 20_000, + script_pubkey: address.script_pubkey(), + }, + ], + ); + let spent = OutPoint::new(funding.txid(), 0); + let spending = transaction( + spent, + vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + ); + let manager = Arc::new(RwLock::new(manager)); + if recordless_winner { + // A wallet-paying mempool spend loses to a mined payment to somebody else. + let loser = transaction( + spent, + vec![TxOut { + value: 98_000, + script_pubkey: address.script_pubkey(), + }], + ); + assert!( + manager + .write() + .await + .process_mempool_transaction(&loser, None) + .await + .is_relevant + ); + persist_events(&manager, &persister, &mut events).await; + } else { + mine(&manager, wallet_id, funding.clone(), 100).await; + persist_events(&manager, &persister, &mut events).await; + } + if pending { + assert!( + manager + .write() + .await + .process_mempool_transaction(&spending, None) + .await + .is_relevant + ); + } else { + mine(&manager, wallet_id, spending.clone(), 200).await; + } + persist_events(&manager, &persister, &mut events).await; + if finalized { + let mut guard = manager.write().await; + guard.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + if !recordless_winner { + guard.update_wallet_synced_height(&wallet_id, 300); + } + drop(guard); + persist_events(&manager, &persister, &mut events).await; + } + if recordless_winner { + let records: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT COUNT(*) FROM core_transactions WHERE record_blob IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + records, 0, + "the live event path must produce the recordless state" + ); + let claims: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE spent = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(claims, 1, "sweep must persist the winner's input claim"); + } + let (mut uninterrupted_wallet, mut uninterrupted_info) = { + let guard = manager.read().await; + ( + guard.get_wallet(&wallet_id).unwrap().clone(), + guard + .get_wallet_info(&wallet_id) + .unwrap() + .core_wallet + .clone(), + ) + }; + drop(events); + drop(manager); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let mut restored = reopened.load().unwrap().wallets.remove(&wallet_id).unwrap(); + let context = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([100; 32]), + 100, + )); + uninterrupted_info + .check_core_transaction( + &funding, + context.clone(), + &mut uninterrupted_wallet, + true, + true, + ) + .await; + assert_coins(&uninterrupted_info, &funding, "uninterrupted wallet"); + restored + .wallet_info + .check_core_transaction(&funding, context, &mut restored.wallet, true, true) + .await; + assert_coins(&restored.wallet_info, &funding, "restored wallet"); +} + +#[tokio::test] +async fn should_preserve_spend_after_clean_event_persistence_and_restart() { + clean_restart(false, false, false).await; +} + +#[tokio::test] +async fn should_preserve_pending_spend_after_clean_event_persistence_and_restart() { + clean_restart(false, false, true).await; +} + +#[tokio::test] +async fn should_preserve_finalized_spend_after_clean_event_persistence_and_restart() { + clean_restart(false, true, false).await; +} + +#[tokio::test] +async fn should_preserve_recordless_winner_after_clean_event_persistence_and_restart() { + clean_restart(true, false, false).await; +} + +#[tokio::test] +async fn should_preserve_finalized_recordless_winner_after_clean_event_persistence_and_restart() { + clean_restart(true, true, false).await; +}