Skip to content

Commit 7b38855

Browse files
authored
Merge pull request #744 from dashpay/fix/missing-transactions
fix: resolve transaction list not updating during sync
2 parents 70de58e + 2b50399 commit 7b38855

1 file changed

Lines changed: 195 additions & 39 deletions

File tree

DashWallet/Sources/UI/Home/Views/HomeViewModel.swift

Lines changed: 195 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,27 @@ class HomeViewModel: ObservableObject {
3636
private let queue = DispatchQueue(label: "HomeViewModel", qos: .userInitiated)
3737
private let coinJoinService = CoinJoinService.shared
3838
private var timeSkewDialogShown: Bool = false
39-
39+
4040
static let shared: HomeViewModel = {
4141
return HomeViewModel(transactionSource: DSWalletSource())
4242
}()
43-
43+
4444
private let transactionSource: TransactionSource
4545
private var txByHash: [String: TransactionListDataItem] = [:]
4646
private var crowdNodeTxSet = FullCrowdNodeSignUpTxSet()
4747
private var coinJoinTxSets: [String: CoinJoinMixingTxSet] = [:] // Grouped by date
4848
private var metadataProviders: [MetadataProvider] = []
49+
50+
/// Tracks whether a full reload is currently in progress to prevent race conditions
51+
/// with incremental updates (Fix #3)
52+
private var isReloading: Bool = false
53+
54+
/// Tracks whether initial data load has completed (Fix #2)
55+
private var hasCompletedInitialLoad: Bool = false
56+
57+
/// Debounce timer for sync state changes to prevent excessive reloads (Fix #4)
58+
private var syncStateDebounceWorkItem: DispatchWorkItem?
59+
private let syncStateDebounceInterval: TimeInterval = 0.5
4960

5061
@Published private(set) var txItems: [TransactionGroup] = []
5162
@Published var shortcutItems: [ShortcutAction] = []
@@ -141,6 +152,10 @@ class HomeViewModel: ObservableObject {
141152
self.crowdNodeTxSet = FullCrowdNodeSignUpTxSet()
142153
self.coinJoinTxSets.removeAll()
143154

155+
// Reset load tracking flags so the new network's data loads correctly
156+
self.hasCompletedInitialLoad = false
157+
self.isReloading = false
158+
144159
// Update UI-bound property on main thread
145160
DispatchQueue.main.async {
146161
self.txItems = []
@@ -174,9 +189,13 @@ class HomeViewModel: ObservableObject {
174189
}
175190
.store(in: &cancellableBag)
176191

192+
// Fix #5: Balance changes often indicate new transactions, so reload the full
193+
// transaction list, not just shortcuts. This ensures newly received or sent
194+
// transactions appear in the UI promptly.
177195
NotificationCenter.default.publisher(for: NSNotification.Name.DSWalletBalanceDidChange)
178196
.sink { [weak self] _ in
179-
self?.reloadShortcuts()
197+
DSLogger.log("HomeViewModel: Wallet balance changed, reloading transactions and shortcuts")
198+
self?.reloadTxsAndShortcuts()
180199
}
181200
.store(in: &cancellableBag)
182201

@@ -188,11 +207,13 @@ class HomeViewModel: ObservableObject {
188207
}
189208
.store(in: &cancellableBag)
190209

210+
// Fix #1: Always reload transactions when sync starts, not just during resync.
211+
// Previously, this only reloaded if isResyncingWallet was true, which meant
212+
// normal sync operations wouldn't refresh the transaction list.
191213
NotificationCenter.default.publisher(for: Notification.Name.DSChainManagerSyncWillStart)
192214
.sink { [weak self] _ in
193-
if DWGlobalOptions.sharedInstance().isResyncingWallet {
194-
self?.reloadTxsAndShortcuts()
195-
}
215+
DSLogger.log("HomeViewModel: Sync will start, reloading transactions")
216+
self?.reloadTxsAndShortcuts()
196217
}
197218
.store(in: &cancellableBag)
198219

@@ -210,22 +231,23 @@ class HomeViewModel: ObservableObject {
210231
private func reloadTxDataSource() {
211232
self.queue.async { [weak self] in
212233
guard let self = self else { return }
213-
234+
235+
// Fix #3: Set reload flag to prevent race conditions with incremental updates
236+
self.isReloading = true
237+
DSLogger.log("HomeViewModel: Starting full transaction reload")
238+
214239
let transactions = transactionSource.allTransactions
215240
self.crowdNodeTxSet = FullCrowdNodeSignUpTxSet()
216241
self.coinJoinTxSets = [:]
217-
242+
218243
var items: [TransactionListDataItem] = transactions.compactMap { tx -> TransactionListDataItem? in
219244
Tx.shared.updateRateIfNeeded(for: tx)
220-
245+
221246
if !self.passesFilter(tx: tx, displayMode: self.displayMode) {
222247
return nil
223248
}
224-
225-
if !self.crowdNodeTxSet.isComplete && self.crowdNodeTxSet.tryInclude(tx: tx) {
226-
return nil
227-
}
228-
249+
250+
// Fix #7: Remove duplicate CrowdNode check - only need to check once
229251
if !self.crowdNodeTxSet.isComplete && self.crowdNodeTxSet.tryInclude(tx: tx) {
230252
// CrowdNode transactions will be included below
231253
return nil
@@ -234,15 +256,15 @@ class HomeViewModel: ObservableObject {
234256
let date = DWDateFormatter.sharedInstance.dateOnly(from: tx.date)
235257
let coinJoinTxSet = self.coinJoinTxSets[date] ?? CoinJoinMixingTxSet()
236258
self.coinJoinTxSets[date] = coinJoinTxSet
237-
259+
238260
if coinJoinTxSet.tryInclude(tx: tx) {
239261
// CoinJoin transactions will be included below
240262
return nil
241263
}
242-
264+
243265
return .tx(Transaction(transaction: tx), self.resolveMetadata(for: tx.txHashData))
244266
}
245-
267+
246268
self.txByHash.removeAll()
247269
items.forEach { item in
248270
self.txByHash[item.id] = item
@@ -266,11 +288,17 @@ class HomeViewModel: ObservableObject {
266288
grouping: items.sorted(by: { $0.date > $1.date }),
267289
by: { DWDateFormatter.sharedInstance.dateOnly(from: $0.date) }
268290
)
269-
291+
270292
let array = groupedItems.map { key, items in
271293
TransactionGroup(id: key, date: items.first!.date, items: items)
272294
}.sorted { $0.date > $1.date }
273-
295+
296+
// Fix #2 & #3: Mark initial load complete and clear reload flag
297+
self.hasCompletedInitialLoad = true
298+
self.isReloading = false
299+
300+
DSLogger.log("HomeViewModel: Full reload complete, \(array.count) groups, \(self.txByHash.count) transactions cached")
301+
274302
DispatchQueue.main.async {
275303
self.txItems = array
276304
}
@@ -280,42 +308,158 @@ class HomeViewModel: ObservableObject {
280308
private func onTransactionStatusChanged(tx: DSTransaction) {
281309
self.queue.async { [weak self] in
282310
guard let self = self else { return }
283-
311+
312+
// Fix #3: Skip incremental updates while a full reload is in progress
313+
// to prevent race conditions that could cause missing transactions
314+
if self.isReloading {
315+
DSLogger.log("HomeViewModel: Skipping incremental update during full reload for tx: \(tx.txHashHexString)")
316+
return
317+
}
318+
319+
// Fix #2: If initial load hasn't completed yet, the cache is empty and
320+
// incremental updates won't work correctly. Trigger a full reload instead.
321+
if !self.hasCompletedInitialLoad {
322+
DSLogger.log("HomeViewModel: Initial load not complete, triggering full reload for tx: \(tx.txHashHexString)")
323+
DispatchQueue.main.async {
324+
self.reloadTxsAndShortcuts()
325+
}
326+
return
327+
}
328+
284329
if !self.passesFilter(tx: tx, displayMode: self.displayMode) {
285330
return
286331
}
287-
332+
288333
Tx.shared.updateRateIfNeeded(for: tx)
289-
var itemId = tx.txHashHexString
334+
let txHashHex = tx.txHashHexString
335+
var itemId = txHashHex
290336
var txItem: TransactionListDataItem = .tx(Transaction(transaction: tx), resolveMetadata(for: tx.txHashData))
291-
let dateKey = DWDateFormatter.sharedInstance.dateOnly(from: tx.date)
337+
let newDateKey = DWDateFormatter.sharedInstance.dateOnly(from: tx.date)
338+
339+
// Track if this transaction was absorbed by a grouped set (CrowdNode/CoinJoin)
340+
var wasAbsorbedByGroup = false
292341

293342
if self.crowdNodeTxSet.tryInclude(tx: tx) {
294343
itemId = FullCrowdNodeSignUpTxSet.id
295344
txItem = .crowdnode(self.crowdNodeTxSet)
345+
wasAbsorbedByGroup = true
346+
DSLogger.log("HomeViewModel: Transaction \(txHashHex) absorbed by CrowdNode group")
296347
} else {
297-
let coinJoinTxSet = self.coinJoinTxSets[dateKey] ?? CoinJoinMixingTxSet()
298-
self.coinJoinTxSets[dateKey] = coinJoinTxSet
299-
348+
let coinJoinTxSet = self.coinJoinTxSets[newDateKey] ?? CoinJoinMixingTxSet()
349+
self.coinJoinTxSets[newDateKey] = coinJoinTxSet
350+
300351
if coinJoinTxSet.tryInclude(tx: tx) {
301352
itemId = coinJoinTxSet.id
302353
txItem = .coinjoin(coinJoinTxSet)
354+
wasAbsorbedByGroup = true
355+
DSLogger.log("HomeViewModel: Transaction \(txHashHex) absorbed by CoinJoin group for date \(newDateKey)")
356+
}
357+
}
358+
359+
// Fix: Check if this transaction exists under its original hash (not group ID)
360+
// This handles the case where a transaction was previously shown individually
361+
// but is now being absorbed by a CoinJoin/CrowdNode group
362+
if wasAbsorbedByGroup && self.txByHash[txHashHex] != nil {
363+
DSLogger.log("HomeViewModel: Removing individual transaction \(txHashHex) as it's now in a group")
364+
365+
// Perform iteration and removal entirely on main thread to avoid race conditions
366+
// where txItems could change between finding indices and performing removal
367+
DispatchQueue.main.async {
368+
// Find the group containing this transaction by searching current state
369+
for groupIndex in 0..<self.txItems.count {
370+
guard groupIndex < self.txItems.count else { break }
371+
372+
if let itemIndex = self.txItems[groupIndex].items.firstIndex(where: { $0.id == txHashHex }) {
373+
// Verify bounds are still valid before removal
374+
guard groupIndex < self.txItems.count,
375+
itemIndex < self.txItems[groupIndex].items.count else { break }
376+
377+
self.txItems[groupIndex].items.remove(at: itemIndex)
378+
379+
// Remove empty groups (re-check bounds after item removal)
380+
if groupIndex < self.txItems.count && self.txItems[groupIndex].items.isEmpty {
381+
self.txItems.remove(at: groupIndex)
382+
}
383+
384+
// Only remove from cache after confirming UI removal succeeded
385+
self.queue.async {
386+
self.txByHash.removeValue(forKey: txHashHex)
387+
}
388+
break
389+
}
390+
}
303391
}
304392
}
305393

306394
if let existingItem = self.txByHash[itemId] {
307395
// Updating existing item
308396
self.txByHash[itemId] = txItem
397+
398+
// Fix: Find the OLD date group where this transaction currently lives
399+
// Transaction dates can change when going from unconfirmed to confirmed
400+
var oldGroupIndex: Int? = nil
401+
var oldItemIndex: Int? = nil
402+
var oldDateKey: String? = nil
403+
404+
for (gIdx, group) in self.txItems.enumerated() {
405+
if let iIdx = group.items.firstIndex(where: { $0.id == itemId }) {
406+
oldGroupIndex = gIdx
407+
oldItemIndex = iIdx
408+
oldDateKey = group.id
409+
break
410+
}
411+
}
412+
309413
var isChanged = true
310-
311414
if case let .tx(existingTx, oldMetadata) = existingItem, case let .tx(newTx, metadata) = txItem {
312415
isChanged = newTx.state != existingTx.state || oldMetadata != metadata
313416
}
314-
315-
if isChanged {
316-
if let groupIndex = self.txItems.firstIndex(where: { $0.id == dateKey }),
317-
let itemIndex = self.txItems[groupIndex].items.firstIndex(where: { $0.id == itemId }) {
417+
418+
// Fix: Handle transaction moving between date groups (e.g., unconfirmed -> confirmed)
419+
if let oldDateKey = oldDateKey, oldDateKey != newDateKey {
420+
DSLogger.log("HomeViewModel: Transaction \(itemId) date changed from \(oldDateKey) to \(newDateKey)")
421+
422+
// Remove from old group
423+
if let oldGIdx = oldGroupIndex, let oldIIdx = oldItemIndex {
318424
DispatchQueue.main.async {
425+
// Validate both group index and item index bounds before removal
426+
// txItems may have changed between when indices were captured and now
427+
guard oldGIdx >= 0,
428+
oldGIdx < self.txItems.count,
429+
oldIIdx >= 0,
430+
oldIIdx < self.txItems[oldGIdx].items.count else {
431+
DSLogger.log("HomeViewModel: Skipping removal - indices out of bounds (groupIdx: \(oldGIdx), itemIdx: \(oldIIdx))")
432+
return
433+
}
434+
435+
self.txItems[oldGIdx].items.remove(at: oldIIdx)
436+
437+
// Remove empty groups (re-check bounds after item removal)
438+
if oldGIdx < self.txItems.count && self.txItems[oldGIdx].items.isEmpty {
439+
self.txItems.remove(at: oldGIdx)
440+
}
441+
442+
// Add to new group (similar to new item logic)
443+
if let newGroupIndex = self.txItems.firstIndex(where: { $0.id == newDateKey }) {
444+
self.txItems[newGroupIndex].items.append(txItem)
445+
self.txItems[newGroupIndex].items.sort { $0.date > $1.date }
446+
} else {
447+
let newGroup = TransactionGroup(id: newDateKey, date: txItem.date, items: [txItem])
448+
let insertIndex = self.txItems.firstIndex(where: { $0.date < txItem.date })
449+
if let index = insertIndex {
450+
self.txItems.insert(newGroup, at: index)
451+
} else {
452+
self.txItems.append(newGroup)
453+
}
454+
}
455+
}
456+
}
457+
} else if isChanged {
458+
// Same date group, just update in place
459+
if let groupIndex = oldGroupIndex, let itemIndex = oldItemIndex {
460+
DispatchQueue.main.async {
461+
guard groupIndex < self.txItems.count,
462+
itemIndex < self.txItems[groupIndex].items.count else { return }
319463
let updatedGroup = self.txItems[groupIndex]
320464
var updatedItems = updatedGroup.items
321465
updatedItems[itemIndex] = txItem
@@ -328,8 +472,8 @@ class HomeViewModel: ObservableObject {
328472
// New item
329473
self.txByHash[itemId] = txItem
330474
let shouldShowReclassify = self.shouldDisplayReclassifyTransaction && tx.date > reclassifyTransactionsActivatedAt
331-
332-
if let groupIndex = self.txItems.firstIndex(where: { $0.id == dateKey }) {
475+
476+
if let groupIndex = self.txItems.firstIndex(where: { $0.id == newDateKey }) {
333477
// Add to an existing date group
334478
DispatchQueue.main.async {
335479
self.txItems[groupIndex].items.append(txItem)
@@ -338,9 +482,9 @@ class HomeViewModel: ObservableObject {
338482
}
339483
} else {
340484
// Create a new date group
341-
let newGroup = TransactionGroup(id: dateKey, date: txItem.date, items: [txItem])
485+
let newGroup = TransactionGroup(id: newDateKey, date: txItem.date, items: [txItem])
342486
let insertIndex = self.txItems.firstIndex(where: { $0.date < txItem.date })
343-
487+
344488
DispatchQueue.main.async {
345489
if let index = insertIndex {
346490
self.txItems.insert(newGroup, at: index)
@@ -433,10 +577,22 @@ extension HomeViewModel {
433577
}
434578

435579
private func onSyncStateChanged() {
436-
self.reloadTxsAndShortcuts()
437-
#if DASHPAY
438-
self.checkJoinDashPay()
439-
#endif
580+
// Fix #4: Debounce sync state changes to prevent excessive reloads.
581+
// During active sync, state can change rapidly which would cause
582+
// multiple expensive full reloads in quick succession.
583+
syncStateDebounceWorkItem?.cancel()
584+
585+
let workItem = DispatchWorkItem { [weak self] in
586+
guard let self = self else { return }
587+
DSLogger.log("HomeViewModel: Sync state changed (debounced), reloading")
588+
self.reloadTxsAndShortcuts()
589+
#if DASHPAY
590+
self.checkJoinDashPay()
591+
#endif
592+
}
593+
594+
syncStateDebounceWorkItem = workItem
595+
DispatchQueue.main.asyncAfter(deadline: .now() + syncStateDebounceInterval, execute: workItem)
440596
}
441597

442598
func reloadTxsAndShortcuts() {

0 commit comments

Comments
 (0)