From 55b2572180c5120ccbd6cc1e6236855b6998e408 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:26:47 +0300 Subject: [PATCH 1/6] perf(home): collapse queued timeline reconciles into one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `queue` is serial, and every trigger enqueued another full pass. During a restore that meant passes draining back-to-back, each re-reading the window and republishing the whole list to the main thread for a result the pass behind it was about to replace. From a 22-minute testnet restore of a 3240-transaction wallet: 203 reconciles, 82 of them (40%) ending on the same groups/items/rows as the one before. The bursts are the shape of the problem: 19:10:41 rebuild 21134ms 175 groups, 885 items, 1629 rows 19:10:42 rebuild 2131ms 175 groups, 889 items, 1634 rows 19:10:43 rebuild 2003ms 175 groups, 889 items, 1634 rows 19:10:44 rebuild 1570ms 175 groups, 889 items, 1634 rows A trigger arriving while a pass is in flight now sets a flag instead of enqueueing, and the running pass re-runs exactly once when it lands. A burst of N notifications costs two passes — the one running plus one that sees all of it — rather than N. The flag is released outside `performReload` so its early returns (unbound host, nil delta) release it too. **The 21 seconds was not 21 seconds of work.** `startedAt` is stamped when a pass is REQUESTED, on the main actor, so the reported duration included the wait behind everything already queued — a backlog reading as one slow rebuild. The line now separates them: Timeline rebuild complete in 21134ms (queued 19003ms, work 2131ms), … Also times the one part that is unavoidably main-thread — assigning `txItems`, which republishes the list for SwiftUI to diff — and logs it past 50ms. Both numbers exist so the next session measures this instead of inferring it. Deliberately not skipping publishes whose row set is unchanged: a row's id does not change when its transaction confirms, so equality on ids would swallow a legitimate update. Collapsing the passes removes the duplicate publishes without that risk. Clean `dashpay` build, iOS 26.5 simulator. --- .../Sources/UI/Home/Views/HomeViewModel.swift | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 634ef943b..ecf22d7d3 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -43,6 +43,20 @@ enum TransactionFilterCategory: CaseIterable { class HomeViewModel: ObservableObject { private var cancellableBag = Set() private let queue = DispatchQueue(label: "HomeViewModel", qos: .userInitiated) + /// Whether a reconcile pass is queued or running. `queue` is serial, so + /// without this every trigger enqueues another full pass and they drain + /// back-to-back — each one re-reading the window and republishing the whole + /// list to the main thread for a result the pass behind it is about to + /// replace. Main-actor state, mutated only from `reloadTxDataSource` and + /// `finishReloadPass`. + private var reloadPassInFlight = false + /// A trigger that arrived while a pass was in flight. Collapsed to a + /// single follow-up pass, so a burst of N notifications costs two passes + /// (the one running plus one more that sees all of it), not N. + private var reloadPassRequestedAgain = false + /// Triggers folded into the current pass — logged so the next session's + /// numbers say how much this actually absorbs. + private var reloadPassCoalesced = 0 private var timeSkewDialogShown: Bool = false /// Session guard so the proactive CoinJoin-sweep popup shows at most once /// per launch (re-evaluated each launch while a leftover balance exists). @@ -515,6 +529,18 @@ class HomeViewModel: ObservableObject { // once up front, asynchronously, keeps the pass free-running. let dispatch = { @MainActor [weak self] in guard let self else { return } + // A pass already owns the queue: record that the world changed + // again and let it re-run once when it lands. Enqueueing here + // instead is what produced bursts of identical rebuilds — the + // reported duration of each includes the wait behind the ones + // before it, so a backlog reads as a slow pass and every entry in + // it republishes the full list. + guard !self.reloadPassInFlight else { + self.reloadPassRequestedAgain = true + self.reloadPassCoalesced += 1 + return + } + self.reloadPassInFlight = true // Pair each request with the filter selection that was live when // it was made. Reading it inside the pass instead would let a pass // triggered by one change render a selection the user made after @@ -523,6 +549,7 @@ class HomeViewModel: ObservableObject { let startedAt = Date() self.queue.async { [weak self] in self?.performReload(selectedFilters: selectedFilters, startedAt: startedAt) + self?.finishReloadPass() } } if Thread.isMainThread { @@ -532,7 +559,30 @@ class HomeViewModel: ObservableObject { } } + /// Release the pass and, if the world moved while it ran, run exactly one + /// more. Called outside `performReload` so every early return in it — an + /// unbound host, a nil delta — still releases. + private func finishReloadPass() { + DispatchQueue.main.async { MainActor.assumeIsolated { [weak self] in + guard let self else { return } + let coalesced = self.reloadPassCoalesced + self.reloadPassCoalesced = 0 + self.reloadPassInFlight = false + if coalesced > 0 { + DWLogger.log("HomeViewModel: coalesced \(coalesced) reconcile trigger(s) into that pass") + } + guard self.reloadPassRequestedAgain else { return } + self.reloadPassRequestedAgain = false + self.reloadTxDataSource() + } } + } + private func performReload(selectedFilters: Set, startedAt: Date) { + // `startedAt` is stamped when the pass was REQUESTED, on the main + // actor. `workStartedAt` is when it actually got the queue. Reporting + // only their sum reads a backlog as a slow pass — which is exactly how + // a burst of triggers used to look like one 21-second rebuild. + let workStartedAt = Date() DWLogger.log("HomeViewModel: Starting timeline reconcile") // --- Stage 1: bring the wrapped-row window up to date. Every @@ -586,7 +636,11 @@ class HomeViewModel: ObservableObject { } // --- Stage 3: rebuild the visible list from the in-memory window. - rebuildTimelineItems(selectedFilters: selectedFilters, startedAt: startedAt, pageCompleted: false) + rebuildTimelineItems( + selectedFilters: selectedFilters, + startedAt: startedAt, + workStartedAt: workStartedAt, + pageCompleted: false) } /// Replace the window cache with a freshly fetched first page. @@ -659,6 +713,7 @@ class HomeViewModel: ObservableObject { let startedAt = Date() queue.async { [weak self] in guard let self else { return } + let workStartedAt = Date() guard self.hasOlderHistory, self.windowOldestDayStart > 0, let page = self.transactionSource.olderTimelinePage( endingBefore: self.windowOldestDayStart, @@ -678,7 +733,11 @@ class HomeViewModel: ObservableObject { // its rows' `lastUpdated` can postdate window updates the next // delta still has to pick up. DWLogger.log("HomeViewModel: Timeline paged to \(self.windowTxs.count) rows, older history: \(self.hasOlderHistory)") - self.rebuildTimelineItems(selectedFilters: selectedFilters, startedAt: startedAt, pageCompleted: true) + self.rebuildTimelineItems( + selectedFilters: selectedFilters, + startedAt: startedAt, + workStartedAt: workStartedAt, + pageCompleted: true) } } @@ -711,6 +770,7 @@ class HomeViewModel: ObservableObject { private func rebuildTimelineItems( selectedFilters: Set, startedAt: Date, + workStartedAt: Date, pageCompleted: Bool ) { let transactions = timelineInputTransactions() @@ -846,8 +906,10 @@ class HomeViewModel: ObservableObject { return TransactionGroup(id: key, date: first.date, items: items) }.sorted { $0.date > $1.date } - let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000) - DWLogger.log("HomeViewModel: Timeline rebuild complete in \(elapsedMs)ms, \(array.count) groups, \(self.txByHash.count) items cached, window \(windowTxs.count) rows") + let now = Date() + let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000) + let workMs = Int(now.timeIntervalSince(workStartedAt) * 1000) + DWLogger.log("HomeViewModel: Timeline rebuild complete in \(elapsedMs)ms (queued \(max(0, elapsedMs - workMs))ms, work \(workMs)ms), \(array.count) groups, \(self.txByHash.count) items cached, window \(windowTxs.count) rows") publishTimeline( array, @@ -904,7 +966,13 @@ class HomeViewModel: ObservableObject { pageCompleted: Bool ) { let canLoadMore = hasOlderHistory + let groupCount = array.count DispatchQueue.main.async { + // The one part of a reconcile that is unavoidably main-thread: + // assigning `txItems` republishes the whole list and SwiftUI diffs + // it. Timed so the cost of republishing is a number rather than an + // inference. + let publishStartedAt = Date() self.txItems = array self.hasLoadedInitialTxItems = true if self.canLoadMoreHistory != canLoadMore { @@ -920,6 +988,10 @@ class HomeViewModel: ObservableObject { if self.hasMasternodeHistory != hasMasternodes { self.hasMasternodeHistory = hasMasternodes } + let publishMs = Int(Date().timeIntervalSince(publishStartedAt) * 1000) + if publishMs >= 50 { + DWLogger.log("HomeViewModel: publish held the main thread \(publishMs)ms for \(groupCount) groups") + } } } From 48a1a8f1a5da46f1c67916874ae7a7e3993a3000 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:17:57 +0300 Subject: [PATCH 2/6] perf(sync): time the 1Hz main-actor progress tick, and cache the shield pool fee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both aimed at a user report that the WHOLE UI lags during sync — not one screen. **Instrumentation, because nothing here was ever measured.** `manager.$spvProgress` lands on `RunLoop.main` and enters `applyProgress` under `MainActor.assumeIsolated` with no throttle, once per progress tick (~1Hz for as long as any phase advances). From there it synchronously reaches FFI (`wallet.coreWallet().balance()`) and a main-context SwiftData fetch. That is the right shape for a uniform, screen-independent stutter, and it had no timing at all — so the tick and the balance bridge now log when they hold the main thread past 50ms, matching the pattern already used in `HomeViewModel.publishTimeline`. This is a measurement, not a fix. Whether to throttle the tick or move its reads off the main actor should be decided by the numbers it prints, not by me guessing a third time. **Cache `CoreToShieldedAmountPolicy.poolFeeCredits`.** It is read from `amountValidationMessage` and `canContinue`, both evaluated inside a SwiftUI `body`, so an uncached computed property crossed into Rust on every render and every keystroke of the Internal transfer screen. The value is a pure function of the protocol version and the fixed `(transfer, 2)` shape and cannot change within a launch. A failed estimate is deliberately not cached — that is a transient FFI condition, and caching it would leave the screen permanently unusable. Expect the cache to be hygiene rather than relief: the call is scalar Rust arithmetic with no handle and no lock, so it is sub-millisecond class. It is fixed because it is wrong, not because it is heavy. Clean `dashpay` build, iOS 26.5 simulator. --- .../SwiftDashSDKSPVCoordinator.swift | 24 +++++++++++++++++++ .../InternalTransferViewModel.swift | 18 ++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift index 8702934be..3c9285424 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift @@ -552,6 +552,19 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { @MainActor private func applyProgress(_ p: PlatformSpvSyncProgress) { + // Everything below runs on the main actor, once per progress tick + // (~1Hz for as long as any sync phase advances), and reaches + // synchronous FFI and a main-context SwiftData fetch. Nothing here has + // ever been timed, so a uniform app-wide stutter during sync has no + // way of being attributed. Measured here rather than assumed. + let tickStartedAt = Date() + defer { + let heldMs = Int(Date().timeIntervalSince(tickStartedAt) * 1000) + if heldMs >= 50 { + Self.logger.warning( + "🛰️ SPVCOORD :: progress tick held the main thread \(heldMs, privacy: .public)ms") + } + } let mappedState = mapState(p.overallState) let translated = SPVSyncProgress( state: mappedState, @@ -603,6 +616,17 @@ public final class SwiftDashSDKSPVCoordinator: NSObject, ObservableObject { /// publisher was removed in the SDK refactor, so the bridge replaces it. @MainActor private func refreshBalanceBridge() { + // Timed separately from the tick: this is the FFI half, and knowing + // which half is expensive decides whether the fix is throttling the + // tick or moving the read off the main actor. + let startedAt = Date() + defer { + let ms = Int(Date().timeIntervalSince(startedAt) * 1000) + if ms >= 50 { + Self.logger.warning( + "🛰️ SPVCOORD :: balance bridge held the main thread \(ms, privacy: .public)ms") + } + } guard let wallet = SwiftDashSDKHost.shared.wallet else { SwiftDashSDKWalletState.shared.clearBalance() return diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index d30cdd23b..8bf16a8b7 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -57,14 +57,28 @@ enum CoreToShieldedAmountPolicy { /// (50_000 duffs) × 1000 credits/duff. static let assetLockBaseCostCredits: UInt64 = 50_000_000 + /// Cached: this is read from `amountValidationMessage` and `canContinue`, + /// both of which a SwiftUI `body` evaluates — so an uncached property + /// crosses into Rust on every render and every keystroke. The value is a + /// pure function of the protocol version and the fixed `(transfer, 2)` + /// shape, so it cannot change within a launch. + private static var cachedPoolFeeCredits: UInt64?? + static var poolFeeCredits: UInt64? { + if let cached = cachedPoolFeeCredits { return cached } guard let shieldedFee = try? PlatformWalletManager.estimateShieldedFee( kind: .transfer, numActions: 2) - else { return nil } + else { + // Not cached: a failed estimate is a transient FFI condition, and + // caching it would keep the screen permanently unusable. + return nil + } let (total, overflow) = shieldedFee.addingReportingOverflow(assetLockBaseCostCredits) - return overflow ? nil : total + let value: UInt64? = overflow ? nil : total + cachedPoolFeeCredits = .some(value) + return value } /// User-entered Core amounts have duff precision (1000 Platform credits). From 4e62aaa6a2f89bccb2f633d5d6226fdffd096e00 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:04:13 +0300 Subject: [PATCH 3/6] perf(sync): report the durable watermark when the UI first calls sync done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `syncDone` is derived entirely from the SPV network phases — `case .synced` or `progress >= 0.999` — and knows nothing about how much of what was scanned is durably persisted. The two are routinely far apart: in a testnet session the app reported a completed sync at chain tip 2520269 while the persisted watermark stood at 2136000, 384k blocks behind, and transactions kept materializing for minutes afterwards. That gap is not cosmetic. The durable watermark is what a relaunch resumes from and what the transaction list is built out of, so "synced" while it trails means both a list that is still filling in and a rescan of that range on the next launch. This does not change the state machine yet. It logs the two heights side by side the first time each session calls a sync complete: ⛓️ SYNCSTATE :: reported done — scanned tip N, durable watermark M, behind by K block(s) because whether the watermark reliably reaches the tip is precisely what is unproven. One trace showed it land exactly on the tip; another ended before it did. Gating the indicator on a watermark that sometimes stops short would trade a premature "done" for a permanent "saving", which is worse. The line answers that from an ordinary session, and the gate can follow once it does. `persistedSyncedHeight()` is one bounded fetch on a state transition, not per tick — the main-thread cost this file's sibling instrumentation exists to catch. Clean `dashpay` build, iOS 26.5 simulator. --- .../SyncingActivityMonitor.swift | 29 +++++++++++++++++++ .../Sources/UI/Home/Views/HomeViewModel.swift | 15 ++++++++++ 2 files changed, 44 insertions(+) diff --git a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift index 7eeea3521..6cbd24627 100644 --- a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift +++ b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift @@ -17,6 +17,7 @@ import Combine import Foundation +import OSLog import SwiftDashSDK private let kMaxProgressDelta = 0.1 // 10% @@ -304,9 +305,37 @@ extension SyncingActivityMonitor { applyProgressWithPeakSmoothing(sdkProgress) isSyncing = (mapped == .syncing) + let wasDone = state == .syncDone state = mapped + + // `syncDone` is derived purely from the SPV network phases — it knows + // nothing about how much of what was scanned is durably persisted. + // The two can be far apart: the durable watermark is what a relaunch + // resumes from, and what the transaction list is built out of, so a + // wallet can report "synced" while rows are still materializing. + // + // Logged at the transition rather than gated on, because whether the + // watermark reliably reaches the tip is exactly what is unproven. One + // line per completion answers it from an ordinary session. + if mapped == .syncDone && !wasDone { + logDurableWatermarkAtCompletion(scannedTip: sdkSyncProgress.headers?.currentHeight ?? 0) + } } + /// One-shot read of the persisted sync height at the moment the UI first + /// calls a sync complete, next to the height that was actually scanned. + /// A single fetch on a state transition — not a per-tick cost. + private func logDurableWatermarkAtCompletion(scannedTip: UInt32) { + guard let durable = SwiftDashSDKWalletSource.persistedSyncedHeight() else { + Self.logger.warning("⛓️ SYNCSTATE :: reported done at tip \(scannedTip, privacy: .public); durable watermark unavailable") + return + } + let behind = scannedTip > durable ? scannedTip - durable : 0 + Self.logger.info("⛓️ SYNCSTATE :: reported done — scanned tip \(scannedTip, privacy: .public), durable watermark \(durable, privacy: .public), behind by \(behind, privacy: .public) block(s)") + } + + private static let logger = Logger(subsystem: "org.dashfoundation.dash", category: "sync-state") + /// Map SwiftDashSDK's per-phase progress to the snapshot fields the /// existing UI consumers expect. The phase priority order /// (headers → filterHeaders → filters → masternodes → finished) diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index ecf22d7d3..8b1ab4ebd 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -2442,6 +2442,21 @@ class SwiftDashSDKWalletSource: TransactionSource { /// cheap enough to block a worker queue on). `ModelContainer` is /// `Sendable`, so the caller then opens its own `ModelContext` on its /// own thread and all SwiftData work stays there. + /// The durable sync watermark the persister last wrote for the active + /// wallet — the height a relaunch resumes from, and the boundary below + /// which the transaction list is materialized. Distinct from the height + /// the SPV scan has reached, which is what the sync UI reports. + /// + /// One bounded fetch; call it on a state transition, not per tick. + static func persistedSyncedHeight() -> UInt32? { + guard let (container, walletId) = hostHandles() else { return nil } + let context = ModelContext(container) + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first?.syncedHeight + } + private static func hostHandles() -> (container: ModelContainer, walletId: Data)? { onMain { guard let container = SwiftDashSDKHost.shared.modelContainer, From 00dbee7e81c35104f5b04b447e6164794e1cd2cc Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:05:51 +0300 Subject: [PATCH 4/6] fix(home): stop fixture-backed home models observing the live wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HomeViewModel.init` wires every instance to the notification pipeline — the persister's SwiftData saves, balance changes, network changes, CoinJoin sweeps, DashPay. That is right for the shared model, and wrong for the one `MainTabbarController` builds for the onboarding demo: a `StubTransactionSource` serves four hardcoded fixture rows, so there is nothing a persister save or a balance change can teach it. They subscribed anyway, and nothing tears them down. A run that had finished onboarding twenty minutes earlier still held two such models, each rebuilding its four-row window on every notification — visible as reconciles arriving in fixed batches of three, two of them reporting a window the shared instance has never had. The source now declares whether it is backed by the real wallet, and a model built on a fixture source returns from `init` before the subscriptions. The protocol default is `true`, so a source that says nothing keeps its live wiring; only the stub opts out. Measurement of what this is worth is in the commit that follows. --- .../Sources/UI/Home/Views/HomeViewModel.swift | 19 +++++++++++++++++++ .../Stubs/StubTransactionSource.swift | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 8b1ab4ebd..814a4d681 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -243,6 +243,11 @@ class HomeViewModel: ObservableObject { // start immediately. self.reloadTxsAndShortcuts() + // A fixture-backed model has no live data to observe. Subscribing + // anyway is what kept the onboarding demos reacting to every persister + // save and balance change for the whole session. + guard transactionSource.isLiveWalletSource else { return } + self.observeCoinJoinSweep() self.observeWallet() self.observeNetworkChange() @@ -1685,6 +1690,16 @@ struct WalletTimelineDelta { protocol TransactionSource { var allTransactions: Array { get } + /// Whether this source is backed by the real wallet. + /// + /// A fixture source has nothing to learn from a persister save or a + /// balance change, so a view model built on one must not subscribe to + /// them: the onboarding demo screens outlive their own presentation, and + /// two such view models were found still rebuilding their four-row + /// fixture window on every notification twenty minutes into a session, + /// long after onboarding had finished. + var isLiveWalletSource: Bool { get } + /// The newest rows as a day-completed window of about `targetRowCount`. /// Nil when the source has no active wallet yet. func timelineWindow(targetRowCount: Int) -> WalletTimelineWindow? @@ -1706,6 +1721,10 @@ protocol TransactionSource { /// `allTransactions` set is one complete, already-loaded window; there is /// nothing older to page in and no store to answer deltas or gates from. extension TransactionSource { + /// Live unless a source opts out, so adding this cannot silently + /// disconnect a real one. + var isLiveWalletSource: Bool { true } + func timelineWindow(targetRowCount: Int) -> WalletTimelineWindow? { WalletTimelineWindow( walletId: Data(), diff --git a/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift b/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift index db6b6eeef..8ef5c1c92 100644 --- a/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift +++ b/DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swift @@ -21,6 +21,10 @@ import Foundation /// `.sdk`-shaped wrappers — no DashSync objects, no persisted rows. Amounts, /// directions and date spacing mirror the legacy `DWTransactionStub` fixture. class StubTransactionSource: TransactionSource { + /// Fixtures — nothing here reacts to the wallet, so a view model built on + /// this must not wire itself to live notifications. + var isLiveWalletSource: Bool { false } + /// (duffs, isSent) fixture rows, newest first. private static let fixtures: [(amount: Int64, sent: Bool)] = [ (314_000_000, true), // 3.14 sent From a7670c382a9a13bb113982914a9dcea4351d02ee Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:04:16 +0300 Subject: [PATCH 5/6] fix(home): stop the syncing header leaking a sync model per list update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SyncingHeaderView` is the transaction list's section header, so SwiftUI re-initializes the struct on every list update — and during a sync those never stop. It declared its model `@ObservedObject`, which does not own its value, so each re-init built another `SyncModelImpl`. That init registers with `SyncingActivityMonitor.shared` and with `NotificationCenter`; both hold it, so `deinit` — the only place either registration is undone — never ran. The cost is not the instances themselves but the fan-out: the monitor calls every accumulated observer on each progress tick, each one republishes, each republish invalidates this view, and the invalidation builds one more. A large-wallet scan reached 3321 live instances. `HomeView` had the same declaration for `BalanceModel`, which registers the same way; it is re-created far less often but still reached four. Both become `@StateObject`, so SwiftUI owns one per view identity. The monitor's observer list becomes a weak `NSHashTable` as well. It is a singleton that outlives every observer, so a strong array turns any missed `remove(observer:)` into an unbounded leak — as it just did, silently. Every observer is owned by whoever creates it (a view model, a `UIView`, a `@StateObject`), so the monitor has no reason to keep any alive. Measured on the large-wallet scan: live `SyncModelImpl` goes from 3321 to 1. --- .../SyncingActivityMonitor.swift | 18 ++++++++++++------ .../Home/Views/Cells/SyncingHeaderView.swift | 11 ++++++++++- .../Sources/UI/Home/Views/HomeView.swift | 8 +++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift index 6cbd24627..1d2ec65f1 100644 --- a/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift +++ b/DashWallet/Sources/Application/Syncyng Activity Monitor/SyncingActivityMonitor.swift @@ -179,7 +179,14 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { private var lastPeakDate: Date? private var cancellables = Set() - private var observers: [SyncingActivityMonitorObserver] = [] + /// Weak, because this is a singleton that outlives every observer and a + /// strong array here turns any missed `remove(observer:)` into an + /// unbounded leak — one did, silently, until a large-wallet scan had + /// accumulated 3321 live `SyncModelImpl`s and the per-tick fan-out over + /// them became the dominant cost. Every observer is owned by whoever + /// created it (a view model, a `UIView`, a `@StateObject`), so the monitor + /// has no reason to keep any of them alive. + private let observers = NSHashTable.weakObjects() private let observersLock = NSLock() override init() { @@ -200,22 +207,21 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { public func add(observer: SyncingActivityMonitorObserver) { observersLock.lock() defer { observersLock.unlock() } - observers.append(observer) + observers.add(observer) } @objc(removeObserver:) public func remove(observer: SyncingActivityMonitorObserver) { observersLock.lock() defer { observersLock.unlock() } - if let idx = observers.firstIndex(where: { $0 === observer }) { - observers.remove(at: idx) - } + observers.remove(observer) } private func observerSnapshot() -> [SyncingActivityMonitorObserver] { observersLock.lock() defer { observersLock.unlock() } - return observers + // `allObjects` already drops entries whose object has gone away. + return observers.allObjects.compactMap { $0 as? SyncingActivityMonitorObserver } } deinit { diff --git a/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift b/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift index 3caca48bb..e6c91a687 100644 --- a/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift +++ b/DashWallet/Sources/UI/Home/Views/Cells/SyncingHeaderView.swift @@ -21,7 +21,16 @@ import DashUIKit // MARK: - SyncingHeaderView struct SyncingHeaderView: View { - @ObservedObject private var model = SyncModelImpl() + // `@StateObject`, not `@ObservedObject`: this is the transaction list's + // section header, so SwiftUI re-initializes the struct on every list + // update — and during a sync those never stop. `@ObservedObject` does not + // own its value, so each re-init built another `SyncModelImpl`, whose + // `init` registers with `SyncingActivityMonitor.shared` and with + // `NotificationCenter`. Both hold it, `deinit` never ran, and the monitor + // then called every accumulated instance on each progress tick — each one + // republishing and invalidating this view again. A large-wallet scan + // reached 3321 live instances that way. + @StateObject private var model = SyncModelImpl() var onFilterTap: () -> Void var onSyncTap: () -> Void diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index d895e369a..d67b2e496 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -245,7 +245,13 @@ struct HomeViewContent: View { #endif @ObservedObject var viewModel: HomeViewModel - @ObservedObject private var balanceModel = BalanceModel() + // Owned here, so `@StateObject`: `BalanceModel.init` registers with + // `SyncingActivityMonitor.shared`, which holds its observers strongly, so + // an instance built by a struct re-init can never be released. `HomeView` + // is re-created far less often than the list header that hit this hard + // (see `SyncingHeaderView`), but four live models were still found in a + // single session. + @StateObject private var balanceModel = BalanceModel() #if DASHPAY @ObservedObject var joinDPViewModel: JoinDashPayViewModel #endif From 5e846933d88413d2ccc182d4370e25ada462ae54 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:00:53 +0300 Subject: [PATCH 6/6] style(ui): say which pass absorbs the coalesced triggers, and split the closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review points on the reconcile coalescing. **The count belongs to the follow-up pass, not the one that just ran.** `reloadPassCoalesced` counts triggers that arrive while a pass is in flight, and that pass is already past reading its inputs — they are served by the single follow-up `finishReloadPass()` schedules. Both the field's doc comment and the log line said "the current pass" and "that pass", which describes a behaviour the code does not have. Reworded to name the follow-up pass and to say that it is one pass however many triggers arrived, since that is the property the number is logged to measure. **`closure_end_indentation` at the `} }`.** `DispatchQueue.main.async { MainActor.assumeIsolated { … } }` closed both closures on one line, which SwiftLint flagged (expected 8, got 10). Split onto separate lines; the log call is wrapped for the line limit. No behaviour change. Checked with `swiftlint lint` on the file: the violation is gone, and the build is green. The file's other 80 findings are pre-existing and untouched. --- .../Sources/UI/Home/Views/HomeViewModel.swift | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 11461b920..e7fba9386 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -55,8 +55,11 @@ class HomeViewModel: ObservableObject { /// single follow-up pass, so a burst of N notifications costs two passes /// (the one running plus one more that sees all of it), not N. private var reloadPassRequestedAgain = false - /// Triggers folded into the current pass — logged so the next session's - /// numbers say how much this actually absorbs. + /// Triggers that arrived while a pass was in flight. They are not served + /// by that pass — it is already past reading its inputs — but by the one + /// follow-up pass `finishReloadPass()` schedules, however many arrived. + /// Logged so the next session's numbers say how much this actually + /// absorbs. private var reloadPassCoalesced = 0 private var timeSkewDialogShown: Bool = false /// Session guard so the proactive CoinJoin-sweep popup shows at most once @@ -606,18 +609,22 @@ class HomeViewModel: ObservableObject { /// more. Called outside `performReload` so every early return in it — an /// unbound host, a nil delta — still releases. private func finishReloadPass() { - DispatchQueue.main.async { MainActor.assumeIsolated { [weak self] in - guard let self else { return } - let coalesced = self.reloadPassCoalesced - self.reloadPassCoalesced = 0 - self.reloadPassInFlight = false - if coalesced > 0 { - DWLogger.log("HomeViewModel: coalesced \(coalesced) reconcile trigger(s) into that pass") + DispatchQueue.main.async { + MainActor.assumeIsolated { [weak self] in + guard let self else { return } + let coalesced = self.reloadPassCoalesced + self.reloadPassCoalesced = 0 + self.reloadPassInFlight = false + if coalesced > 0 { + DWLogger.log( + "HomeViewModel: coalesced \(coalesced) reconcile trigger(s) into one follow-up pass" + ) + } + guard self.reloadPassRequestedAgain else { return } + self.reloadPassRequestedAgain = false + self.reloadTxDataSource() } - guard self.reloadPassRequestedAgain else { return } - self.reloadPassRequestedAgain = false - self.reloadTxDataSource() - } } + } } private func performReload(selectedFilters: Set, startedAt: Date) {