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