From db3e0d83ea9748e275fcc46bc9a9d735a0fad899 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 22 Oct 2026 01:06:53 +0200 Subject: [PATCH 1/2] fix: prepare the wallet switch once when the review reappears SwiftUI can start the switch task twice for the same context when the confirmation reappears, which ran the on-chain preparation twice. The view now owns the run and keeps a live one for an unchanged context. The tags test now holds the resumed preparation until the pop back from tags has settled, so its push never overlaps that transition. --- .../Wallets/Send/SendConfirmationView.swift | 49 +++++++++++++++---- .../SendConfirmationLifecycleTests.swift | 31 +++++++++--- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 22df05bce..a8974af13 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -38,8 +38,15 @@ struct SendConfirmationView: View { @State private var hasStartedAutomaticPayment = false @State private var requiresPaymentConfirmation = false @State private var walletSwitchContext: ConfirmationContext? + @State private var walletSwitchRun: WalletSwitchRun? @State private var presentationID: UUID? + private struct WalletSwitchRun { + let id: UUID + let context: ConfirmationContext + let task: Task + } + private struct ConfirmationContext: Equatable { let presentationID: UUID let walletType: WalletType @@ -436,16 +443,8 @@ struct SendConfirmationView: View { walletSwitchContext = nil } } - .task(id: confirmationContext == walletSwitchContext ? walletSwitchContext : nil) { - guard let context = walletSwitchContext, !Task.isCancelled, confirmationContext == context else { return } - if context.walletType == .lightning { - await calculateTransactionFee() - } else { - await onSwitchToOnchainWallet(context: context) - } - if !Task.isCancelled, walletSwitchContext == context { - walletSwitchContext = nil - } + .onChange(of: confirmationContext == walletSwitchContext ? walletSwitchContext : nil, initial: true) { _, context in + startWalletSwitch(context: context) } .onChange(of: walletSwitchContext) { previous, context in // Clearing the context cancels the switch task, so the start runs outside it with the current setup state. @@ -453,6 +452,7 @@ struct SendConfirmationView: View { Task { @MainActor in await startAutomaticPaymentIfNeeded() } } .onDisappear { + startWalletSwitch(context: nil) if !isEditingSendDetails || sendContext != walletSwitchContext { walletSwitchContext = nil } @@ -1742,6 +1742,35 @@ struct SendConfirmationView: View { } } + /// SwiftUI can deliver the same switch twice when the confirmation reappears, so a live run for the context is kept. + @MainActor + private func startWalletSwitch(context: ConfirmationContext?) { + if let run = walletSwitchRun, run.context == context, !run.task.isCancelled { + return + } + walletSwitchRun?.task.cancel() + walletSwitchRun = nil + guard let context else { return } + let runID = UUID() + let task = Task { @MainActor in + defer { + if walletSwitchRun?.id == runID { + walletSwitchRun = nil + } + } + guard !Task.isCancelled, walletSwitchContext == context, confirmationContext == context else { return } + if context.walletType == .lightning { + await calculateTransactionFee() + } else { + await onSwitchToOnchainWallet(context: context) + } + if !Task.isCancelled, walletSwitchContext == context { + walletSwitchContext = nil + } + } + walletSwitchRun = WalletSwitchRun(id: runID, context: context, task: task) + } + @MainActor private func onSwitchToOnchainWallet(context: ConfirmationContext) async { func isCurrentSend() -> Bool { diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 3f833f335..8918e881b 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -277,7 +277,7 @@ final class SendConfirmationLifecycleTests: XCTestCase { snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") FeeEstimatesManager().devOverrideFeeEstimates = true for operation in [ControlledSendWallet.Operation.manualUtxos, .selection] { - let wallet = ControlledSendWallet(operation: operation) + let wallet = ControlledSendWallet(operation: operation, suspendsResumedPreparation: true) let app = AppViewModel() let sheets = SheetViewModel() sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil) @@ -304,13 +304,20 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(wallet.preparationCount, 1) XCTAssertEqual(navigation.path, [.confirm, .tag]) + // The resumed preparation waits until the pop back from tags has settled, so its push never overlaps that transition. navigation.path.removeLast() - try await navigation.waitForAppearance(operation == .manualUtxos ? .utxoSelection : .confirm) - let deadline = ContinuousClock.now + .seconds(3) - while wallet.preparationCount < 2, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } + try await navigation.waitForAppearance(.confirm) + await fulfillment(of: [wallet.resumedStarted], timeout: 3) XCTAssertEqual(wallet.preparationCount, 2) + wallet.resume() + if operation == .manualUtxos { + try await navigation.waitForAppearance(.utxoSelection) + } else { + let deadline = ContinuousClock.now + .seconds(3) + while wallet.selectedUtxos == nil, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + } XCTAssertEqual(app.selectedWalletToPayFrom, .onchain) XCTAssertEqual(navigation.path, operation == .manualUtxos ? [.confirm, .utxoSelection] : [.confirm]) if operation == .selection { @@ -587,15 +594,18 @@ private final class ControlledSendWallet: WalletViewModel { let started = XCTestExpectation(description: "Send preparation suspended") let finished = XCTestExpectation(description: "Send preparation resumed") + let resumedStarted = XCTestExpectation(description: "Resumed send preparation suspended") private let operation: Operation private let fails: Bool + private let suspendsResumedPreparation: Bool private var continuation: CheckedContinuation? private(set) var wasCancelled = false private(set) var preparationCount = 0 - init(operation: Operation, fails: Bool = false) { + init(operation: Operation, fails: Bool = false, suspendsResumedPreparation: Bool = false) { self.operation = operation self.fails = fails + self.suspendsResumedPreparation = suspendsResumedPreparation super.init( transferService: TransferService(lightningService: .shared, blocktankService: Bitkit.CoreService.shared.blocktank), sheetViewModel: SheetViewModel(), @@ -643,6 +653,13 @@ private final class ControlledSendWallet: WalletViewModel { private func suspend() async throws { preparationCount += 1 + if preparationCount == 2, suspendsResumedPreparation { + await withCheckedContinuation { continuation in + self.continuation = continuation + resumedStarted.fulfill() + } + return + } guard preparationCount == 1 else { return } await withCheckedContinuation { continuation in self.continuation = continuation From 9719d7ce2e8b3a999836d38ebe27a8310490a71f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 22 Oct 2026 01:11:57 +0200 Subject: [PATCH 2/2] test: check the resumed wallet switch prepares once --- BitkitTests/SendConfirmationLifecycleTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 8918e881b..a33391003 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -318,6 +318,7 @@ final class SendConfirmationLifecycleTests: XCTestCase { try await Task.sleep(for: .milliseconds(10)) } } + XCTAssertEqual(wallet.preparationCount, 2) XCTAssertEqual(app.selectedWalletToPayFrom, .onchain) XCTAssertEqual(navigation.path, operation == .manualUtxos ? [.confirm, .utxoSelection] : [.confirm]) if operation == .selection {