From 43af31d451f89a3d0d19772afae06256eaf91f9d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 10 Sep 2026 20:02:26 +0200 Subject: [PATCH] fix: create order only on swipe --- Bitkit/AppScene.swift | 3 +- Bitkit/Extensions/IBtOrder+Mock.swift | 6 +- Bitkit/MainNavView.swift | 6 +- Bitkit/ViewModels/BlocktankViewModel.swift | 29 ++- Bitkit/ViewModels/NavigationViewModel.swift | 8 +- Bitkit/ViewModels/TransferViewModel.swift | 147 +++++++++---- .../Transfer/Hardware/SpendingAmountHw.swift | 7 +- .../Transfer/Hardware/SpendingHwSign.swift | 81 +++---- .../Transfer/Hardware/SpendingHwSigned.swift | 9 +- .../Views/Transfer/SpendingAdvancedView.swift | 35 +-- Bitkit/Views/Transfer/SpendingAmount.swift | 9 +- Bitkit/Views/Transfer/SpendingConfirm.swift | 121 ++++++----- .../Transfer/TransferLearnMoreView.swift | 11 +- BitkitTests/TransferViewModelHwTests.swift | 200 +++++++++++++++++- BitkitTests/TransferViewModelTests.swift | 166 +++++++++++++-- changelog.d/next/738.fixed.md | 1 + .../hardware-wallet/transfer-to-spending.xml | 4 +- 17 files changed, 635 insertions(+), 208 deletions(-) create mode 100644 changelog.d/next/738.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..0ca55c8eb 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -270,8 +270,7 @@ struct AppScene: View { return UInt64(TransactionSpeed.fast.getFeeRate(from: rates)) }, hwAddressProvider: { - let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) - return try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) + try await LightningService.shared.addressInfoForType(.nativeSegwit, atIndex: 0).address }, onBalanceRefresh: { await walletVm.updateBalanceState() } )) diff --git a/Bitkit/Extensions/IBtOrder+Mock.swift b/Bitkit/Extensions/IBtOrder+Mock.swift index 4b9fbeddf..06502863f 100644 --- a/Bitkit/Extensions/IBtOrder+Mock.swift +++ b/Bitkit/Extensions/IBtOrder+Mock.swift @@ -3,16 +3,18 @@ import Foundation extension IBtOrder { static func mock( + id: String = "order123", state2: BtOrderState2 = .created, channel: IBtChannel? = nil, + feeSat: UInt64 = 1000, lspBalanceSat: UInt64 = 50000, clientBalanceSat: UInt64 = 85967 ) -> IBtOrder { return IBtOrder( - id: "order123", + id: id, state: .created, state2: state2, - feeSat: 1000, + feeSat: feeSat, networkFeeSat: 2483, serviceFeeSat: 1520, lspBalanceSat: lspBalanceSat, diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0415b928b..c07784141 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -455,9 +455,9 @@ struct MainNavView: View { case let .spendingAmountHw(walletId): SpendingAmountHw(walletId: walletId) case let .spendingHwSign(walletId): SpendingHwSign(walletId: walletId) case .spendingHwSigned: SpendingHwSigned() - case let .spendingConfirm(order): SpendingConfirm(order: order) - case let .spendingAdvanced(order, walletId): SpendingAdvancedView(order: order, walletId: walletId) - case let .transferLearnMore(order): TransferLearnMoreView(order: order) + case .spendingConfirm: SpendingConfirm() + case let .spendingAdvanced(walletId): SpendingAdvancedView(walletId: walletId) + case .transferLearnMore: TransferLearnMoreView() case .settingUp: SettingUpView() case .fundingAdvanced: FundAdvancedOptions() case let .fundManual(nodeUri): FundManualSetupView(initialNodeUri: nodeUri) diff --git a/Bitkit/ViewModels/BlocktankViewModel.swift b/Bitkit/ViewModels/BlocktankViewModel.swift index b6876e61f..330e66cdf 100644 --- a/Bitkit/ViewModels/BlocktankViewModel.swift +++ b/Bitkit/ViewModels/BlocktankViewModel.swift @@ -337,18 +337,16 @@ class BlocktankViewModel: ObservableObject { appendMirroredErrorDescription(from: wrappedValue, to: &candidates) } + func estimateFundingAmount(clientBalance: UInt64, lspBalance: UInt64? = nil) async throws -> UInt64 { + let receivingBalance = lspBalance ?? (clientBalance * 2) + try validateChannelSize(clientBalance: clientBalance, lspBalance: receivingBalance) + let estimate = try await estimateOrderFee(clientBalance: clientBalance, lspBalance: receivingBalance) + return clientBalance.saturatingAdd(estimate.networkFeeSat.saturatingAdd(estimate.serviceFeeSat)) + } + func createOrder(clientBalance: UInt64, lspBalance: UInt64? = nil) async throws -> IBtOrder { let finalReceivingBalanceSats = lspBalance ?? (clientBalance * 2) - - if let btBOptions = info?.options { - // Validate they're within the limits - if (clientBalance + finalReceivingBalanceSats) > btBOptions.maxChannelSizeSat { - Logger.error("Channel size exceeds maximum: \(clientBalance + finalReceivingBalanceSats) > \(btBOptions.maxChannelSizeSat)") - throw CustomServiceError.channelSizeExceedsMaximum - } - } else { - Logger.warn("Has not refreshed Blocktank info yet, skipping validation of limits") - } + try validateChannelSize(clientBalance: clientBalance, lspBalance: finalReceivingBalanceSats) guard orderClient.nodeId() != nil else { throw CustomServiceError.nodeNotStarted @@ -368,6 +366,17 @@ class BlocktankViewModel: ObservableObject { return try await orderClient.submit(finalReceivingBalanceSats, defaultChannelExpiryWeeks, options) } + private func validateChannelSize(clientBalance: UInt64, lspBalance: UInt64) throws { + guard let btBOptions = info?.options else { + Logger.warn("Has not refreshed Blocktank info yet, skipping validation of limits") + return + } + if (clientBalance + lspBalance) > btBOptions.maxChannelSizeSat { + Logger.error("Channel size exceeds maximum: \(clientBalance + lspBalance) > \(btBOptions.maxChannelSizeSat)") + throw CustomServiceError.channelSizeExceedsMaximum + } + } + func openChannel(orderId: String) async throws -> IBtOrder { let order = try await coreService.blocktank.open(orderId: orderId) diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 60c59d3e6..a6b89ed5d 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -43,11 +43,9 @@ enum Route: Hashable { case spendingAmountHw(walletId: String) case spendingHwSign(walletId: String) case spendingHwSigned - case spendingConfirm(order: IBtOrder) - /// `walletId` names the hardware wallet funding the transfer, so the shared advanced screen - /// prices the capacity against the device account rather than this wallet's savings. - case spendingAdvanced(order: IBtOrder, walletId: String? = nil) - case transferLearnMore(order: IBtOrder) + case spendingConfirm + case spendingAdvanced(walletId: String? = nil) + case transferLearnMore case settingUp case fundingAdvanced case fundManual(nodeUri: String?) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index e8d84c72b..9179d6659 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -3,9 +3,16 @@ import LDKNode import SwiftUI struct TransferUiState { + var clientBalanceSat: UInt64 = 0 + var lspBalanceSat: UInt64 = 0 + var feeSat: UInt64 = 0 + var isAdvanced = false + var isConfirming = false var order: IBtOrder? - var defaultOrder: IBtOrder? - var isAdvanced: Bool = false + + var lspFeeSat: UInt64 { + feeSat.saturatingSub(clientBalanceSat) + } } struct TransferValues { @@ -20,9 +27,9 @@ struct HwSpendingState: Equatable { var isLoading = false var isSigning = false var hasPendingBroadcast = false - /// The hidden wallet needs its passphrase before the device can sign for it. var isPassphraseRequired = false var isVerifyingPassphrase = false + var isCreatingOrder = false var miningFeeSats: UInt64 = 0 var maxAllowedToSend: UInt64 = 0 var balanceAfterFee: UInt64 = 0 @@ -116,7 +123,10 @@ protocol HwTransferConnecting: Sendable { @MainActor class TransferViewModel: ObservableObject { + private static let orderReuseMargin: TimeInterval = 60 + @Published var uiState = TransferUiState() + private var fundedOrderId: String? @Published var lightningSetupStep: Int = 0 @Published var transferValues = TransferValues() @@ -155,6 +165,7 @@ class TransferViewModel: ObservableObject { private var refreshTask: Task? private var hwSignTask: Task? private var hwPassphraseTask: Task? + private var hwOrderCreationToken: UUID? private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? private var activeHwTransferWalletId: String? @@ -299,25 +310,60 @@ class TransferViewModel: ObservableObject { } } - func onOrderCreated(order: IBtOrder) { - clearPendingHwFundingBroadcast() - hwSpending.miningFeeSats = 0 - uiState.order = order - uiState.isAdvanced = false - uiState.defaultOrder = nil + var isSpendingBusy: Bool { + uiState.isConfirming || hwSpending.isSigning || hwSpending.isCreatingOrder } - func onAdvancedOrderCreated(order: IBtOrder) { + func onEstimateReady(clientBalance: UInt64, lspBalance: UInt64, feeSat: UInt64, isAdvanced: Bool = false) { + guard !isSpendingBusy else { return } clearPendingHwFundingBroadcast() + uiState.order = nil hwSpending.miningFeeSats = 0 - let defaultOrder = uiState.order + uiState.clientBalanceSat = clientBalance + uiState.lspBalanceSat = lspBalance + uiState.feeSat = feeSat + uiState.isAdvanced = isAdvanced + } + + func onOrderCreated(order: IBtOrder) { uiState.order = order - uiState.defaultOrder = defaultOrder - uiState.isAdvanced = true } - func displayOrder(for order: IBtOrder) -> IBtOrder { - uiState.order ?? order + func orderForConfirmation( + createOrder: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> IBtOrder, + isCurrent: () -> Bool = { true } + ) async throws -> IBtOrder? { + guard isCurrent() else { return nil } + if let order = uiState.order { + if pendingHwFundingBroadcast?.orderId == order.id { + return order + } + if isReusableSpendingOrder(order) { + return orderForDisplayedFee(order) + } + } + uiState.order = nil + let order = try await createOrder(uiState.clientBalanceSat, uiState.lspBalanceSat) + guard isCurrent() else { return nil } + return orderForDisplayedFee(order) + } + + private func isReusableSpendingOrder(_ order: IBtOrder, now: Date = Date()) -> Bool { + guard order.state2 == .created, order.id != fundedOrderId else { return false } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let expiresAt = formatter.date(from: order.orderExpiresAt) ?? ISO8601DateFormatter().date(from: order.orderExpiresAt) + else { return false } + return expiresAt > now.addingTimeInterval(Self.orderReuseMargin) + } + + private func orderForDisplayedFee(_ order: IBtOrder) -> IBtOrder? { + uiState.order = order + guard order.feeSat <= uiState.feeSat else { + uiState.feeSat = order.feeSat + return nil + } + return order } func payOrder( @@ -380,9 +426,6 @@ class TransferViewModel: ObservableObject { ) } - /// Records a paid order and starts watching it, after the funding tx was broadcast (local LDK - /// send or hardware-signed). For the hardware path, also creates the pending on-chain activity - /// (the tx is broadcast externally, so LDK's own activity sync won't surface it). private func fundPaidOrder( order: IBtOrder, txId: String, @@ -393,6 +436,7 @@ class TransferViewModel: ObservableObject { preTransferOnchainSats: UInt64? = nil, activityWalletId: String = WalletScope.default ) async { + fundedOrderId = order.id do { let transferId = try await transferService.createTransfer( type: .toSpending, @@ -405,7 +449,6 @@ class TransferViewModel: ObservableObject { Logger.info("Created transfer tracking record: \(transferId)", context: "TransferViewModel") } catch { Logger.error("Failed to create transfer tracking record", context: error.localizedDescription) - // Don't throw - we still want to continue with the order } if createTransferActivity { @@ -423,16 +466,15 @@ class TransferViewModel: ObservableObject { watchOrder(orderId: order.id) } - /// Starts watching an order from app restart (when no UI state is set) func startWatchingOrderFromRestart(_ order: IBtOrder) async { Logger.info("Starting to watch order from restart: \(order.id)") - // Set the order in UI state so the watching logic works uiState.order = order + uiState.clientBalanceSat = order.clientBalanceSat + uiState.lspBalanceSat = order.lspBalanceSat + uiState.feeSat = order.feeSat uiState.isAdvanced = false - uiState.defaultOrder = nil - // Start watching the order watchOrder(orderId: order.id) } @@ -540,17 +582,16 @@ class TransferViewModel: ObservableObject { return currentStep } - func onDefaultClick() { - clearPendingHwFundingBroadcast() - hwSpending.miningFeeSats = 0 - let defaultOrder = uiState.defaultOrder - uiState.order = defaultOrder - uiState.defaultOrder = nil - uiState.isAdvanced = false + func onDefaultClick( + lspBalance: UInt64, + estimateFundingAmount: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> UInt64 + ) async throws { + guard !isSpendingBusy else { return } + let clientBalance = uiState.clientBalanceSat + let feeSat = try await estimateFundingAmount(clientBalance, lspBalance) + onEstimateReady(clientBalance: clientBalance, lspBalance: lspBalance, feeSat: feeSat) } - // MARK: - Hardware Wallet Transfer - /// Compute the available/MAX/quarter limits for a hardware-wallet transfer: the signer resolves /// the device's native-segwit balance minus an on-chain fee reserve, then the shared /// spending-limit calculation clamps it to the LSP receiving cap. @@ -592,24 +633,52 @@ class TransferViewModel: ObservableObject { hwSpending.isLoading = false } - /// Best-effort offline mining-fee estimate for the Sign screen (`fingerprint: nil` compose). - func updateHwFundingFeeEstimate(order: IBtOrder, walletId: String) async { + func updateHwFundingFeeEstimate(walletId: String) async { guard let hwSigner else { return } guard !hwSpending.hasPendingBroadcast else { return } - guard let address = order.payment?.onchain?.address, !address.isEmpty else { return } do { + let address: String = if let orderAddress = uiState.order?.payment?.onchain?.address { + orderAddress + } else { + try await hwSigner.addressProvider() + } hwSpending.miningFeeSats = try await hwSigner.estimateOfflineFundingMiningFee( walletId: walletId, address: address, - sats: order.feeSat + sats: uiState.feeSat ) } catch { Logger.debug("Skipped offline hardware funding fee estimate for '\(walletId)'", context: "TransferViewModel") } } - /// Pay for the order by composing and signing the funding send on the Trezor (via the signer), - /// then record and watch it. Coordination only — the device orchestration lives in `HwFundingSigner`. + func onTransferToSpendingHwConfirm( + walletId: String, + createOrder: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> IBtOrder + ) async { + guard !isSpendingBusy else { return } + let token = UUID() + hwOrderCreationToken = token + hwSpending.isCreatingOrder = true + defer { + if hwOrderCreationToken == token { + hwOrderCreationToken = nil + hwSpending.isCreatingOrder = false + } + } + + do { + guard let order = try await orderForConfirmation( + createOrder: createOrder, + isCurrent: { self.hwOrderCreationToken == token } + ) else { return } + guard hwOrderCreationToken == token else { return } + onTransferToSpendingHwConfirm(order: order, walletId: walletId) + } catch { + hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription) + } + } + func onTransferToSpendingHwConfirm(order: IBtOrder, walletId: String) { guard !hwSpending.isSigning else { return } guard let hwSigner else { @@ -751,6 +820,8 @@ class TransferViewModel: ObservableObject { // signed transaction waiting to be broadcast, so they are dropped before the guard below — // otherwise leaving mid-verify would leave the reopen running and the prompt set to reappear. onHwPassphraseDismiss() + hwOrderCreationToken = nil + hwSpending.isCreatingOrder = false guard pendingHwFundingBroadcast == nil else { return } let walletId = activeHwTransferWalletId diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index cfe4bd615..9175f9a46 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -171,10 +171,10 @@ struct SpendingAmountHw: View { } private func onContinue() async { + guard !transfer.isSpendingBusy else { return } isLoading = true defer { isLoading = false } - // Wait for the node to be running if it's not already (needed to open the channel later). if wallet.nodeLifecycleState != .running { let isReady = await wallet.waitForNodeToRun(timeoutSeconds: 30.0) guard isReady else { @@ -188,7 +188,6 @@ struct SpendingAmountHw: View { } do { - // The device account, never on-chain savings, which would reject every hardware transfer. let canFund = await transfer.canFundOrder( clientBalance: amountSats, budget: transfer.hwFundingBudget(walletId: walletId), @@ -210,9 +209,9 @@ struct SpendingAmountHw: View { let values = transfer.calculateTransferValues(clientBalanceSat: amountSats, blocktankInfo: blocktank.info) let lspBalance = max(values.defaultLspBalance, values.minLspBalance) - let order = try await blocktank.createOrder(clientBalance: amountSats, lspBalance: lspBalance) + let feeSat = try await blocktank.estimateFundingAmount(clientBalance: amountSats, lspBalance: lspBalance) - transfer.onOrderCreated(order: order) + transfer.onEstimateReady(clientBalance: amountSats, lspBalance: lspBalance, feeSat: feeSat) navigation.navigate(.spendingHwSign(walletId: walletId)) } catch { let appError = AppError(error: error) diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index f7730e4a0..3c5e03d6f 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -1,42 +1,35 @@ import BitkitCore import SwiftUI -/// "Sign with your device" — shows the Blocktank order fees and asks the user to sign the funding -/// transaction on the Trezor. Reuses the existing Learn More / Advanced controls; on-device signing -/// replaces the local swipe-to-pay. Advances to the Signed screen on success. struct SpendingHwSign: View { let walletId: String @EnvironmentObject var app: AppViewModel + @EnvironmentObject var blocktank: BlocktankViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var transfer: TransferViewModel var body: some View { - if let order = transfer.uiState.order { - content(order: transfer.displayOrder(for: order)) + if transfer.uiState.feeSat > 0 { + content() } else { - // No active order (e.g. after process death) — bail back to the wallet. Color.clear.onAppear { navigation.reset() } } } - /// Figma "Visual" width as a fraction of the 375-wide frame (256/375). private let illustrationWidthRatio = 256.0 / 375.0 - /// Figma top of the Trezor "Visual" within the content area below the nav bar: - /// (visualTop - navHeight) / (frameHeight - navHeight - homeIndicator). private let illustrationTopRatio = (488.0 - 92.0) / (812.0 - 92.0 - 34.0) - private func content(order: IBtOrder) -> some View { + private func content() -> some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(transfer.isSpendingBusy) .padding(.bottom, 16) - // The Trezor is a background visual behind the content (including the bottom button), so - // it renders at its natural aspect and doesn't get squeezed by the vertical layout. ZStack(alignment: .top) { trezorIllustration - belowNav(order: order) + belowNav() } } .accessibilityElement(children: .contain) @@ -44,9 +37,9 @@ struct SpendingHwSign: View { .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() - .task(id: order.id) { + .task(id: transfer.uiState.feeSat) { transfer.warmUpHardwareConnection(walletId: walletId) - await transfer.updateHwFundingFeeEstimate(order: order, walletId: walletId) + await transfer.updateHwFundingFeeEstimate(walletId: walletId) } .onChange(of: transfer.hwSignedEvent) { navigation.navigate(.spendingHwSigned) @@ -56,18 +49,17 @@ struct SpendingHwSign: View { app.toast(error) transfer.hwTransferError = nil } - // A local sheet, not a route: it belongs to this order and this wallet, and swiping it away - // must take the same path as Cancel. .sheet(isPresented: passphrasePromptBinding) { HwPassphrasePromptSheet( isVerifying: transfer.hwSpending.isVerifyingPassphrase, - onSubmit: { transfer.onHwPassphraseSubmit(order: order, walletId: walletId, passphrase: $0) }, + onSubmit: { passphrase in + guard let order = transfer.uiState.order else { return } + transfer.onHwPassphraseSubmit(order: order, walletId: walletId, passphrase: passphrase) + }, onCancel: { transfer.onHwPassphraseDismiss() } ) } .onDisappear { - // Cancel an in-flight sign only when the user truly leaves the flow (back/reset), not when - // pushing deeper (Learn More / Advanced / Signed) which keeps this route in the path. let stillInFlow = navigation.path.contains { if case .spendingHwSign = $0 { return true @@ -92,7 +84,11 @@ struct SpendingHwSign: View { ) } - private func belowNav(order: IBtOrder) -> some View { + private var isBusy: Bool { + transfer.isSpendingBusy + } + + private func belowNav() -> some View { VStack(alignment: .leading, spacing: 0) { DisplayText( t( @@ -103,16 +99,16 @@ struct SpendingHwSign: View { accentColor: .purpleAccent ) - SpendingHwFeeGrid(order: order, miningFeeSats: transfer.hwSpending.miningFeeSats) + SpendingHwFeeGrid(state: transfer.uiState, miningFeeSats: transfer.hwSpending.miningFeeSats) .padding(.top, 16) HStack(spacing: 16) { CustomButton( title: t("common__learn_more"), size: .small, - isDisabled: transfer.hwSpending.isSigning || transfer.hwSpending.hasPendingBroadcast + isDisabled: isBusy || transfer.hwSpending.hasPendingBroadcast ) { - navigation.navigate(.transferLearnMore(order: order)) + navigation.navigate(.transferLearnMore) } .accessibilityIdentifier("HardwareTransferSignLearnMore") @@ -120,18 +116,28 @@ struct SpendingHwSign: View { CustomButton( title: t("lightning__spending_confirm__default"), size: .small, - isDisabled: transfer.hwSpending.isSigning || transfer.hwSpending.hasPendingBroadcast + isDisabled: isBusy || transfer.hwSpending.hasPendingBroadcast ) { - transfer.onDefaultClick() + do { + let values = transfer.calculateTransferValues( + clientBalanceSat: transfer.uiState.clientBalanceSat, + blocktankInfo: blocktank.info + ) + try await transfer.onDefaultClick(lspBalance: max(values.defaultLspBalance, values.minLspBalance)) { + try await blocktank.estimateFundingAmount(clientBalance: $0, lspBalance: $1) + } + } catch { + app.toast(error) + } } .accessibilityIdentifier("HardwareTransferSignDefault") } else { CustomButton( title: t("common__advanced"), size: .small, - isDisabled: transfer.hwSpending.isSigning || transfer.hwSpending.hasPendingBroadcast + isDisabled: isBusy || transfer.hwSpending.hasPendingBroadcast ) { - navigation.navigate(.spendingAdvanced(order: order, walletId: walletId)) + navigation.navigate(.spendingAdvanced(walletId: walletId)) } .accessibilityIdentifier("HardwareTransferSignAdvanced") } @@ -147,10 +153,12 @@ struct SpendingHwSign: View { ? "common__retry" : "lightning__transfer_hw__open_connect" ), - isDisabled: transfer.hwSpending.isSigning, - isLoading: transfer.hwSpending.isSigning + isDisabled: isBusy, + isLoading: isBusy ) { - transfer.onTransferToSpendingHwConfirm(order: order, walletId: walletId) + await transfer.onTransferToSpendingHwConfirm(walletId: walletId) { clientBalance, lspBalance in + try await blocktank.createOrder(clientBalance: clientBalance, lspBalance: lspBalance) + } } .accessibilityIdentifier("HardwareTransferOpenTrezorConnect") } @@ -166,25 +174,22 @@ struct SpendingHwSign: View { .frame(width: side, height: side) .position(x: geo.size.width / 2, y: geo.size.height * illustrationTopRatio + side / 2) } - // Span the full screen width (negate the screen's horizontal content padding) so the visual - // matches the Figma sizing, which is measured against the full frame. .padding(.horizontal, -16) .allowsHitTesting(false) .accessibilityHidden(true) } } -/// Blocktank order fee summary shared by the hardware Sign and Signed screens. struct SpendingHwFeeGrid: View { - let order: IBtOrder + let state: TransferUiState var miningFeeSats: UInt64 = 0 private var lspFee: UInt64 { - order.feeSat - order.clientBalanceSat + state.lspFeeSat } private var total: UInt64 { - order.feeSat + miningFeeSats + state.feeSat + miningFeeSats } var body: some View { @@ -206,7 +211,7 @@ struct SpendingHwFeeGrid: View { HStack { FeeDisplayRow( label: t("lightning__spending_confirm__amount"), - amount: order.clientBalanceSat + amount: state.clientBalanceSat ) .frame(maxWidth: .infinity) diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift index 7190c1d27..e3d58338e 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSigned.swift @@ -11,21 +11,22 @@ struct SpendingHwSigned: View { private let autoForwardDelay: UInt64 = 1_000_000_000 var body: some View { - if let order = transfer.uiState.order { - content(order: transfer.displayOrder(for: order)) + if transfer.uiState.feeSat > 0 { + content() } else { Color.clear.onAppear { navigation.reset() } } } - private func content(order: IBtOrder) -> some View { + private func content() -> some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(transfer.isSpendingBusy) .padding(.bottom, 16) DisplayText(t("lightning__transfer_hw__signed_title"), accentColor: .purpleAccent) - SpendingHwFeeGrid(order: order, miningFeeSats: transfer.hwSpending.miningFeeSats) + SpendingHwFeeGrid(state: transfer.uiState, miningFeeSats: transfer.hwSpending.miningFeeSats) .padding(.top, 16) Image("check") diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index e5ffe374c..19aa7dd94 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -2,8 +2,6 @@ import BitkitCore import SwiftUI struct SpendingAdvancedView: View { - let order: IBtOrder - /// Set for a hardware transfer, so the capacity is priced against the device account. var walletId: String? @EnvironmentObject var app: AppViewModel @@ -93,7 +91,7 @@ struct SpendingAdvancedView: View { do { let canFund = await transfer.canFundAdvancedOrder( - clientBalance: order.clientBalanceSat, + clientBalance: transfer.uiState.clientBalanceSat, receivingAmount: lspBalance, budget: fundingBudget(), estimateOrderFee: estimateOrderFee @@ -108,11 +106,16 @@ struct SpendingAdvancedView: View { return } - let newOrder = try await blocktank.createOrder( - clientBalance: order.clientBalanceSat, + let feeSat = try await blocktank.estimateFundingAmount( + clientBalance: transfer.uiState.clientBalanceSat, lspBalance: lspBalance ) - transfer.onAdvancedOrderCreated(order: newOrder) + transfer.onEstimateReady( + clientBalance: transfer.uiState.clientBalanceSat, + lspBalance: lspBalance, + feeSat: feeSat, + isAdvanced: true + ) dismiss() } catch { app.toast(error) @@ -128,7 +131,7 @@ struct SpendingAdvancedView: View { .bottomSafeAreaPadding() .task { await transfer.updateAdvancedTransferValues( - clientBalanceSat: order.clientBalanceSat, + clientBalanceSat: transfer.uiState.clientBalanceSat, budget: { await fundingBudget() }, transferValues: { transfer.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: blocktank.info) }, estimateOrderFee: estimateOrderFee @@ -243,7 +246,7 @@ struct SpendingAdvancedView: View { feeEstimateTask = Task { do { let estimate = try await blocktank.estimateOrderFee( - clientBalance: order.clientBalanceSat, + clientBalance: transfer.uiState.clientBalanceSat, lspBalance: lspBalance ) guard !Task.isCancelled else { return } @@ -260,15 +263,13 @@ struct SpendingAdvancedView: View { #Preview { NavigationStack { - SpendingAdvancedView( - order: IBtOrder.mock(lspBalanceSat: 100_000, clientBalanceSat: 50000) - ) - .environmentObject(AppViewModel()) - .environmentObject(CurrencyViewModel()) - .environmentObject(BlocktankViewModel()) - .environmentObject(FeeEstimatesManager()) - .environmentObject(TransferViewModel()) - .environmentObject(WalletViewModel()) + SpendingAdvancedView() + .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(BlocktankViewModel()) + .environmentObject(FeeEstimatesManager()) + .environmentObject(TransferViewModel()) + .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 11e47d527..db3f96534 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -172,10 +172,10 @@ struct SpendingAmount: View { } private func onContinue() async { + guard !transfer.isSpendingBusy else { return } isLoading = true defer { isLoading = false } - // Wait for node to be running if it's not already if wallet.nodeLifecycleState != .running { let isReady = await wallet.waitForNodeToRun(timeoutSeconds: 30.0) guard isReady else { @@ -210,17 +210,16 @@ struct SpendingAmount: View { let values = transfer.calculateTransferValues(clientBalanceSat: amountSats, blocktankInfo: blocktank.info) let lspBalance = max(values.defaultLspBalance, values.minLspBalance) - let order = try await blocktank.createOrder(clientBalance: amountSats, lspBalance: lspBalance) + let feeSat = try await blocktank.estimateFundingAmount(clientBalance: amountSats, lspBalance: lspBalance) - transfer.onOrderCreated(order: order) - navigation.navigate(.spendingConfirm(order: order)) + transfer.onEstimateReady(clientBalance: amountSats, lspBalance: lspBalance, feeSat: feeSat) + navigation.navigate(.spendingConfirm) } catch { let appError = AppError(error: error) app.toast(type: .error, title: appError.message, description: appError.debugMessage) } } - /// Sizes the limits, and re-checks them before the order is placed. private func fundingBudget() async -> UInt64? { do { let address: String diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index cd2d79fda..77cd8ce93 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -3,38 +3,36 @@ import LDKNode import SwiftUI struct SpendingConfirm: View { - let order: IBtOrder - @EnvironmentObject var app: AppViewModel + @EnvironmentObject var blocktank: BlocktankViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var transfer: TransferViewModel @EnvironmentObject var wallet: WalletViewModel - @State private var isPaying = false + private var isPaying: Bool { + transfer.isSpendingBusy + } + @State private var hideSwipeButton = false @State private var transactionFee: UInt64 = 0 @State private var selectedUtxos: [SpendableUtxo]? @State private var satsPerVbyte: UInt32? @State private var maxSendableAmount: UInt64? @State private var shouldUseSendAll = false - - private var currentOrder: IBtOrder { - transfer.displayOrder(for: order) - } - var lspFee: UInt64 { - currentOrder.feeSat - currentOrder.clientBalanceSat + transfer.uiState.lspFeeSat } var total: UInt64 { - currentOrder.feeSat + transactionFee + transfer.uiState.feeSat + transactionFee } var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("lightning__transfer__nav_title")) + .disabled(isPaying) .padding(.bottom, 16) DisplayText(t("lightning__transfer__confirm"), accentColor: .purpleAccent) @@ -57,7 +55,7 @@ struct SpendingConfirm: View { HStack { FeeDisplayRow( label: t("lightning__spending_confirm__amount"), - amount: currentOrder.clientBalanceSat + amount: transfer.uiState.clientBalanceSat ) .frame(maxWidth: .infinity) @@ -72,9 +70,9 @@ struct SpendingConfirm: View { if transfer.uiState.isAdvanced { LightningChannel( - capacity: currentOrder.lspBalanceSat + currentOrder.clientBalanceSat, - localBalance: currentOrder.clientBalanceSat, - remoteBalance: currentOrder.lspBalanceSat, + capacity: transfer.uiState.lspBalanceSat + transfer.uiState.clientBalanceSat, + localBalance: transfer.uiState.clientBalanceSat, + remoteBalance: transfer.uiState.lspBalanceSat, status: .open, showLabels: true ) @@ -100,23 +98,34 @@ struct SpendingConfirm: View { HStack(spacing: 16) { CustomButton(title: t("common__learn_more"), size: .small) { - navigation.navigate(.transferLearnMore(order: currentOrder)) + navigation.navigate(.transferLearnMore) } .accessibilityIdentifier("SpendingConfirmMore") if transfer.uiState.isAdvanced { CustomButton(title: t("lightning__spending_confirm__default"), size: .small) { - transfer.onDefaultClick() + do { + let values = transfer.calculateTransferValues( + clientBalanceSat: transfer.uiState.clientBalanceSat, + blocktankInfo: blocktank.info + ) + try await transfer.onDefaultClick(lspBalance: max(values.defaultLspBalance, values.minLspBalance)) { + try await blocktank.estimateFundingAmount(clientBalance: $0, lspBalance: $1) + } + } catch { + app.toast(error) + } } .accessibilityIdentifier("SpendingConfirmDefault") } else { CustomButton(title: t("common__advanced"), size: .small) { - navigation.navigate(.spendingAdvanced(order: currentOrder)) + navigation.navigate(.spendingAdvanced()) } .accessibilityIdentifier("SpendingConfirmAdvanced") } } .frame(maxWidth: .infinity, alignment: .leading) + .disabled(isPaying) Spacer() @@ -135,18 +144,42 @@ struct SpendingConfirm: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) - .task { - await calculateTransactionFee() + .task(id: transfer.uiState.feeSat) { + await sizeFunding() + } + } + + private func sizeFunding() async { + do { + let address: String = if let orderAddress = transfer.uiState.order?.payment?.onchain?.address { + orderAddress + } else { + try await LightningService.shared.addressInfoForType(.nativeSegwit, atIndex: 0).address + } + try await calculateTransactionFee(address: address, amountSats: transfer.uiState.feeSat) + } catch { + app.toast(error) } } private func onConfirm() async throws { - guard let rate = satsPerVbyte else { return } - isPaying = true + guard let confirmedFeeRate = satsPerVbyte, !transfer.isSpendingBusy else { return } + transfer.uiState.isConfirming = true + defer { transfer.uiState.isConfirming = false } do { + guard let order = try await transfer.orderForConfirmation(createOrder: { clientBalance, lspBalance in + try await blocktank.createOrder(clientBalance: clientBalance, lspBalance: lspBalance) + }) else { + throw AppError(message: t("other__try_again"), debugMessage: "Order fee changed after confirmation") + } + guard let address = order.payment?.onchain?.address else { + throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) + } + try await calculateTransactionFee(address: address, amountSats: order.feeSat, feeRate: confirmedFeeRate) + guard let rate = satsPerVbyte else { return } try await transfer.payOrder( - order: currentOrder, + order: order, speed: .fast, txFee: transactionFee, satsPerVbyte: rate, @@ -155,57 +188,49 @@ struct SpendingConfirm: View { maxSendableAmount: maxSendableAmount ) await wallet.updateBalanceState() - try await Task.sleep(nanoseconds: 1_000_000_000) - navigation.navigate(.settingUp) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { hideSwipeButton = true } } catch { - isPaying = false app.toast(error) throw error } } - private func calculateTransactionFee() async { + private func calculateTransactionFee(address: String, amountSats: UInt64, feeRate: UInt32? = nil) async throws { do { let lightningService = LightningService.shared - guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else { - Logger.error("SpendingConfirm: feeEstimates is nil") - await MainActor.run { - app.toast(type: .error, title: t("other__try_again")) + let fastFeeRate: UInt32 + if let feeRate { + fastFeeRate = feeRate + } else { + guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else { + Logger.error("SpendingConfirm: feeEstimates is nil") + throw AppError(message: t("other__try_again"), debugMessage: nil) } - return - } - - let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeEstimates) - - guard let address = currentOrder.payment?.onchain?.address else { - throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) + fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeEstimates) } let balance = UInt64(wallet.spendableOnchainBalanceSats) let allUtxos = try await lightningService.listSpendableOutputs() - // Try normal coin selection first; fall through to sendAll on failure var useSendAll = false var normalFee: UInt64 = 0 var normalUtxos: [SpendableUtxo]? do { let utxos = try await lightningService.selectUtxosWithAlgorithm( - targetAmountSats: currentOrder.feeSat, + targetAmountSats: amountSats, satsPerVbyte: fastFeeRate, coinSelectionAlgorythm: .largestFirst, utxos: nil ) normalFee = try await wallet.calculateTotalFee( address: address, - amountSats: currentOrder.feeSat, + amountSats: amountSats, satsPerVByte: fastFeeRate, utxosToSpend: utxos ) @@ -214,7 +239,7 @@ struct SpendingConfirm: View { let totalInput = utxos.reduce(UInt64(0)) { $0 + $1.valueSats } useSendAll = DustChangeHelper.shouldUseSendAllToAvoidDust( totalInput: totalInput, - amountSats: currentOrder.feeSat, + amountSats: amountSats, normalFee: normalFee, isMaxAmount: true ) @@ -228,18 +253,14 @@ struct SpendingConfirm: View { address: address, satsPerVByte: fastFeeRate ) - // Use spendable balance (not utxoTotal) to respect anchor reserves let maxSendable = balance >= sendAllFee ? balance - sendAllFee : 0 - if maxSendable < currentOrder.feeSat { + if maxSendable < amountSats { Logger.error( - "Insufficient balance for transfer: maxSendable=\(maxSendable), orderFee=\(currentOrder.feeSat)", + "Insufficient balance for transfer: maxSendable=\(maxSendable), orderFee=\(amountSats)", context: "SpendingConfirm" ) - await MainActor.run { - app.toast(type: .error, title: t("other__pay_insufficient_savings")) - } - return + throw AppError(message: t("other__pay_insufficient_savings"), debugMessage: nil) } await MainActor.run { @@ -265,8 +286,8 @@ struct SpendingConfirm: View { satsPerVbyte = nil maxSendableAmount = nil shouldUseSendAll = false - app.toast(type: .error, title: t("other__try_again")) } + throw error } } } diff --git a/Bitkit/Views/Transfer/TransferLearnMoreView.swift b/Bitkit/Views/Transfer/TransferLearnMoreView.swift index 93cd4effe..5c762c1e3 100644 --- a/Bitkit/Views/Transfer/TransferLearnMoreView.swift +++ b/Bitkit/Views/Transfer/TransferLearnMoreView.swift @@ -2,7 +2,7 @@ import BitkitCore import SwiftUI struct TransferLearnMoreView: View { - let order: IBtOrder + @EnvironmentObject var transfer: TransferViewModel @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @@ -24,9 +24,9 @@ struct TransferLearnMoreView: View { VStack(alignment: .leading, spacing: 16) { SubtitleText(t("lightning__liquidity__label")) LightningChannel( - capacity: order.lspBalanceSat + order.clientBalanceSat, - localBalance: order.clientBalanceSat, - remoteBalance: order.lspBalanceSat, + capacity: transfer.uiState.lspBalanceSat + transfer.uiState.clientBalanceSat, + localBalance: transfer.uiState.clientBalanceSat, + remoteBalance: transfer.uiState.lspBalanceSat, status: .open, showLabels: true ) @@ -46,7 +46,8 @@ struct TransferLearnMoreView: View { #Preview { NavigationStack { - TransferLearnMoreView(order: IBtOrder.mock()) + TransferLearnMoreView() + .environmentObject(TransferViewModel()) .environmentObject(WalletViewModel()) .environmentObject(AppViewModel()) .environmentObject(CurrencyViewModel()) diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index 96796a726..ed5ecc8c4 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -22,12 +22,14 @@ final class TransferViewModelHwTests: XCTestCase { funding: MockHwFunding, connecting: MockHwConnecting, feeRate: UInt64? = 2, + sizingAddress: String? = nil, timeouts: (reconnect: Double, compose: Double, sign: Double, broadcast: Double) = (reconnect: 5, compose: 5, sign: 5, broadcast: 5) ) -> TransferViewModel { TransferViewModel( hwFunding: funding, hwConnecting: connecting, hwFeeRateProvider: { feeRate }, + hwAddressProvider: sizingAddress.map { address in { address } }, hwTimeouts: timeouts, transferDefaults: transferDefaults ) @@ -383,8 +385,35 @@ final class TransferViewModelHwTests: XCTestCase { vm.cancelHwSigning() XCTAssertTrue(vm.hwSpending.hasPendingBroadcast, "leaving must retain an uncertain signed transaction") - vm.onOrderCreated(order: .mock()) - XCTAssertFalse(vm.hwSpending.hasPendingBroadcast, "starting a new order discards the previous retry state") + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 200_000, feeSat: 101_000) + XCTAssertFalse(vm.hwSpending.hasPendingBroadcast, "starting a new transfer discards the previous retry state") + } + + func testBroadcastRetryReusesThePendingOrderAfterItExpires() async { + let funding = MockHwFunding() + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + var order = IBtOrder.mock() + order.orderExpiresAt = "2000-01-01T00:00:00.175Z" + vm.onOrderCreated(order: order) + + vm.onTransferToSpendingHwConfirm(order: order, walletId: "trezor:wallet") + await awaitSigningComplete(vm) + XCTAssertTrue(vm.hwSpending.hasPendingBroadcast) + + funding.broadcastError = nil + var createCalls = 0 + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { _, _ in + createCalls += 1 + var replacement = IBtOrder.mock() + replacement.id = "replacement" + return replacement + } + await awaitSigningComplete(vm) + + XCTAssertEqual(createCalls, 0) + XCTAssertEqual(funding.signCalls, 1, "the retry reuses the signed transaction") + XCTAssertEqual(funding.broadcastCalls, 2) } func testBroadcastRetryDoesNotReuseSignedTransactionAfterOrderAddressChanges() async { @@ -572,19 +601,178 @@ final class TransferViewModelHwTests: XCTestCase { func testUpdateHwFundingFeeEstimateSetsMiningFeeBeforeSigning() async { let funding = MockHwFunding() - let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) - let order = IBtOrder.mock() + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting(), sizingAddress: "bcrt1qsizing") + let estimate = IBtOrder.mock(feeSat: 101_000, lspBalanceSat: 200_000, clientBalanceSat: 100_000) - await vm.updateHwFundingFeeEstimate(order: order, walletId: "trezor:wallet") + vm.onEstimateReady(clientBalance: estimate.clientBalanceSat, lspBalance: estimate.lspBalanceSat, feeSat: estimate.feeSat) + await vm.updateHwFundingFeeEstimate(walletId: "trezor:wallet") XCTAssertEqual(vm.hwSpending.miningFeeSats, funding.funding.miningFeeSats) XCTAssertEqual(funding.estimateCalls.count, 1) + XCTAssertEqual(funding.estimateCalls.first?.address, "bcrt1qsizing", "sized against an app address; the order does not exist yet") + XCTAssertEqual(funding.estimateCalls.first?.sats, estimate.feeSat) XCTAssertTrue(funding.composeCalls.isEmpty) } + func testUpdateHwFundingFeeEstimateSkipsWithoutASizingAddress() async { + let funding = MockHwFunding() + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + + await vm.updateHwFundingFeeEstimate(walletId: "trezor:wallet") + + XCTAssertEqual(vm.hwSpending.miningFeeSats, 0) + XCTAssertTrue(funding.estimateCalls.isEmpty) + } + + func testConfirmCreatesTheOrderOnceThenSigns() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + let estimate = IBtOrder.mock(feeSat: 101_000, lspBalanceSat: 200_000, clientBalanceSat: 100_000) + vm.onEstimateReady(clientBalance: estimate.clientBalanceSat, lspBalance: estimate.lspBalanceSat, feeSat: estimate.feeSat) + var createCalls = 0 + + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { clientBalance, lspBalance in + createCalls += 1 + return IBtOrder.mock(feeSat: estimate.feeSat, lspBalanceSat: lspBalance, clientBalanceSat: clientBalance) + } + await awaitSigningComplete(vm) + + XCTAssertEqual(createCalls, 1) + XCTAssertEqual(vm.uiState.order?.id, "order123") + XCTAssertEqual(funding.composeCalls.first?.sats, estimate.feeSat) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(vm.hwSignedEvent, 1) + + vm.onEstimateReady(clientBalance: estimate.clientBalanceSat, lspBalance: estimate.lspBalanceSat, feeSat: estimate.feeSat) + XCTAssertNil(vm.uiState.order) + } + + func testConfirmSurfacesAFailedOrderCreationWithoutSigning() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + let estimate = IBtOrder.mock(feeSat: 101_000, lspBalanceSat: 200_000, clientBalanceSat: 100_000) + vm.onEstimateReady(clientBalance: estimate.clientBalanceSat, lspBalance: estimate.lspBalanceSat, feeSat: estimate.feeSat) + + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { _, _ in + throw MockHwFunding.TestError() + } + await awaitSigningComplete(vm) + + if case .generic = vm.hwTransferError {} else { + XCTFail("expected .generic error, got \(String(describing: vm.hwTransferError))") + } + XCTAssertNil(vm.uiState.order) + XCTAssertEqual(connecting.ensureCalls, 0) + XCTAssertEqual(funding.signCalls, 0) + XCTAssertFalse(vm.hwSpending.isCreatingOrder) + } + + func testCancellingWhileCreatingTheOrderDoesNotStartSigning() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + let order = IBtOrder.mock(feeSat: 101_000, lspBalanceSat: 200_000, clientBalanceSat: 100_000) + vm.onEstimateReady(clientBalance: order.clientBalanceSat, lspBalance: order.lspBalanceSat, feeSat: order.feeSat) + + let confirmTask = Task { @MainActor in + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { _, _ in + try await Task.sleep(nanoseconds: 200_000_000) + return order + } + } + let deadline = Date().addingTimeInterval(1) + while !vm.hwSpending.isCreatingOrder, Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(vm.hwSpending.isCreatingOrder) + + vm.cancelHwSigning() + await confirmTask.value + + XCTAssertFalse(vm.hwSpending.isCreatingOrder) + XCTAssertFalse(vm.hwSpending.isSigning) + XCTAssertEqual(connecting.ensureCalls, 0) + XCTAssertEqual(funding.composeCalls.count, 0) + XCTAssertEqual(funding.signCalls, 0) + } + + func testAnOlderOrderCreationCannotReplaceANewerConfirmation() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let vm = makeViewModel(funding: funding, connecting: connecting) + let oldOrder = IBtOrder.mock(feeSat: 101_000, lspBalanceSat: 200_000, clientBalanceSat: 100_000) + var newOrder = IBtOrder.mock(feeSat: 201_000, lspBalanceSat: 300_000, clientBalanceSat: 200_000) + newOrder.id = "new-order" + newOrder.payment?.onchain?.address = "bc1qneworder" + vm.onEstimateReady( + clientBalance: oldOrder.clientBalanceSat, + lspBalance: oldOrder.lspBalanceSat, + feeSat: oldOrder.feeSat + ) + + let oldConfirmation = Task { @MainActor in + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { _, _ in + try await Task.sleep(nanoseconds: 150_000_000) + return oldOrder + } + } + let oldCreationDeadline = Date().addingTimeInterval(1) + while !vm.hwSpending.isCreatingOrder, Date() < oldCreationDeadline { + await Task.yield() + } + XCTAssertTrue(vm.hwSpending.isCreatingOrder) + vm.cancelHwSigning() + vm.onEstimateReady( + clientBalance: newOrder.clientBalanceSat, + lspBalance: newOrder.lspBalanceSat, + feeSat: newOrder.feeSat + ) + + let newConfirmation = Task { @MainActor in + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { _, _ in + try await Task.sleep(nanoseconds: 300_000_000) + return newOrder + } + } + let newCreationDeadline = Date().addingTimeInterval(1) + while !vm.hwSpending.isCreatingOrder, Date() < newCreationDeadline { + await Task.yield() + } + XCTAssertTrue(vm.hwSpending.isCreatingOrder) + await oldConfirmation.value + XCTAssertTrue(vm.hwSpending.isCreatingOrder, "the older request must not clear the newer request's state") + + await newConfirmation.value + await awaitSigningComplete(vm) + + XCTAssertEqual(vm.uiState.order?.id, newOrder.id) + XCTAssertEqual(funding.composeCalls.last?.address, "bc1qneworder") + XCTAssertEqual(funding.signCalls, 1) + } + + func testConfirmRaisesThePassphrasePromptAfterTheOrderExists() async { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + connecting.walletsNeedingPassphrase = ["trezor:wallet"] + let vm = makeViewModel(funding: funding, connecting: connecting) + let estimate = IBtOrder.mock(feeSat: 101_000, lspBalanceSat: 200_000, clientBalanceSat: 100_000) + vm.onEstimateReady(clientBalance: estimate.clientBalanceSat, lspBalance: estimate.lspBalanceSat, feeSat: estimate.feeSat) + + await vm.onTransferToSpendingHwConfirm(walletId: "trezor:wallet") { clientBalance, lspBalance in + IBtOrder.mock(feeSat: estimate.feeSat, lspBalanceSat: lspBalance, clientBalanceSat: clientBalance) + } + + XCTAssertTrue(vm.hwSpending.isPassphraseRequired) + XCTAssertNotNil(vm.uiState.order, "the prompt's submit signs the created order") + XCTAssertEqual(funding.signCalls, 0) + } + func testReentrancyGuardIgnoresConcurrentConfirm() async { let funding = MockHwFunding() - funding.composeError = MockHwFunding.TestError() // fail before the network-bound funding tail + funding.composeError = MockHwFunding.TestError() let connecting = MockHwConnecting() let vm = makeViewModel(funding: funding, connecting: connecting) diff --git a/BitkitTests/TransferViewModelTests.swift b/BitkitTests/TransferViewModelTests.swift index 1d37d7ee8..017954085 100644 --- a/BitkitTests/TransferViewModelTests.swift +++ b/BitkitTests/TransferViewModelTests.swift @@ -19,22 +19,148 @@ final class TransferViewModelTests: XCTestCase { } @MainActor - func testDisplayOrderPrefersUiStateOrder() { - let viewModel = makeViewModel() - let baseOrder = makeOrder(id: "base", clientBalanceSat: 100_000, lspBalanceSat: 50000) - let updatedOrder = makeOrder(id: "updated", clientBalanceSat: 150_000, lspBalanceSat: 75000) + func testEstimatesChangeBalancesWithoutCreatingAnOrder() async throws { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + XCTAssertNil(vm.uiState.order) + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 150_000, feeSat: 102_000, isAdvanced: true) + XCTAssertTrue(vm.uiState.isAdvanced) + XCTAssertNil(vm.uiState.order) + + try await vm.onDefaultClick(lspBalance: 50000) { client, lsp in + XCTAssertEqual(client, 100_000) + XCTAssertEqual(lsp, 50000) + return 101_500 + } + XCTAssertEqual(vm.uiState.lspBalanceSat, 50000) + XCTAssertEqual(vm.uiState.feeSat, 101_500) + XCTAssertFalse(vm.uiState.isAdvanced) + XCTAssertNil(vm.uiState.order) + } - let fallback = viewModel.displayOrder(for: baseOrder) - XCTAssertEqual(fallback.id, baseOrder.id) - XCTAssertEqual(fallback.clientBalanceSat, baseOrder.clientBalanceSat) + @MainActor + func testConfirmationReusesTheOrderUntilANewQuoteStarts() async throws { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + var calls = 0 + let create: (UInt64, UInt64) async throws -> IBtOrder = { client, lsp in + calls += 1 + return self.makeOrder(id: "order-\(calls)", clientBalanceSat: client, lspBalanceSat: lsp, feeSat: client + 1000) + } + let firstValue = try await vm.orderForConfirmation(createOrder: create) + let first = try XCTUnwrap(firstValue) + let retryValue = try await vm.orderForConfirmation(createOrder: create) + let retry = try XCTUnwrap(retryValue) + XCTAssertEqual(first.id, retry.id) + XCTAssertEqual(calls, 1) + + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + XCTAssertNil(vm.uiState.order) + let refreshedValue = try await vm.orderForConfirmation(createOrder: create) + let refreshed = try XCTUnwrap(refreshedValue) + XCTAssertNotEqual(refreshed.id, first.id) + XCTAssertEqual(calls, 2) + } - viewModel.uiState.order = updatedOrder - let result = viewModel.displayOrder(for: baseOrder) - XCTAssertEqual(result.id, updatedOrder.id) - XCTAssertEqual(result.clientBalanceSat, updatedOrder.clientBalanceSat) + @MainActor + func testConfirmationRequiresAnotherSwipeWhenTheCreatedOrderCostsMore() async throws { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + let createdOrder = makeOrder(id: "created", clientBalanceSat: 100_000, lspBalanceSat: 50000, feeSat: 102_000) + var calls = 0 + + let first = try await vm.orderForConfirmation { _, _ in + calls += 1 + return createdOrder + } + + XCTAssertNil(first) + XCTAssertEqual(vm.uiState.order?.id, createdOrder.id) + XCTAssertEqual(vm.uiState.feeSat, createdOrder.feeSat) + + let confirmed = try await vm.orderForConfirmation { _, _ in + calls += 1 + return createdOrder + } + + XCTAssertEqual(confirmed?.id, createdOrder.id) + XCTAssertEqual(calls, 1) } - // MARK: - calculateSpendingLimits (Transfer → Spending max) + @MainActor + func testConfirmationReplacesAnExpiredRetainedOrder() async throws { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + vm.onOrderCreated( + order: makeOrder( + id: "expired", + clientBalanceSat: 100_000, + lspBalanceSat: 50000, + feeSat: 101_000, + expiresAt: "2000-01-01T00:00:00Z" + ) + ) + let replacement = makeOrder(id: "replacement", clientBalanceSat: 100_000, lspBalanceSat: 50000, feeSat: 101_000) + var calls = 0 + + let confirmed = try await vm.orderForConfirmation { _, _ in + calls += 1 + return replacement + } + + XCTAssertEqual(confirmed?.id, replacement.id) + XCTAssertEqual(vm.uiState.order?.id, replacement.id) + XCTAssertEqual(calls, 1) + } + + @MainActor + func testConfirmationReusesAnUnexpiredOrderWithFractionalSeconds() async throws { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + let retained = makeOrder( + id: "retained", + clientBalanceSat: 100_000, + lspBalanceSat: 50000, + feeSat: 101_000, + expiresAt: "2099-01-01T00:00:00.175Z" + ) + vm.onOrderCreated(order: retained) + var calls = 0 + + let confirmed = try await vm.orderForConfirmation { _, _ in + calls += 1 + return self.makeOrder(id: "replacement", clientBalanceSat: 100_000, lspBalanceSat: 50000, feeSat: 101_000) + } + + XCTAssertEqual(confirmed?.id, retained.id) + XCTAssertEqual(calls, 0) + } + + @MainActor + func testConfirmationBlocksReplacingTheTransfer() { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + vm.uiState.isConfirming = true + vm.onEstimateReady(clientBalance: 200_000, lspBalance: 150_000, feeSat: 202_000) + XCTAssertEqual(vm.uiState.clientBalanceSat, 100_000) + XCTAssertEqual(vm.uiState.lspBalanceSat, 50000) + XCTAssertEqual(vm.uiState.feeSat, 101_000) + } + + @MainActor + func testFailedCreationPreservesTheEstimate() async { + let vm = makeViewModel() + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + struct CreateFailed: Error {} + do { + _ = try await vm.orderForConfirmation { _, _ in throw CreateFailed() } + XCTFail("Expected creation to fail") + } catch { + XCTAssertTrue(error is CreateFailed) + } + XCTAssertNil(vm.uiState.order) + XCTAssertEqual(vm.uiState.feeSat, 101_000) + } @MainActor func testSpendingLimitsCapsAtLspMaxClientBalanceWhenOnchainExceedsIt() async throws { @@ -600,14 +726,20 @@ final class TransferViewModelTests: XCTestCase { private static let lspBalance: UInt64 = 252_368 private static let networkFee: UInt64 = 2112 private static let serviceFee: UInt64 = 286 - private static let lspFee: UInt64 = 2398 // networkFee + serviceFee - - private func makeOrder(id: String, clientBalanceSat: UInt64, lspBalanceSat: UInt64) -> IBtOrder { + private static let lspFee: UInt64 = 2398 + + private func makeOrder( + id: String, + clientBalanceSat: UInt64, + lspBalanceSat: UInt64, + feeSat: UInt64 = 1000, + expiresAt: String = "2099-01-01T00:00:00Z" + ) -> IBtOrder { IBtOrder( id: id, state: .created, state2: .created, - feeSat: 1000, + feeSat: feeSat, networkFeeSat: 2483, serviceFeeSat: 1520, lspBalanceSat: lspBalanceSat, @@ -617,7 +749,7 @@ final class TransferViewModelTests: XCTestCase { clientNodeId: "node123", channelExpiryWeeks: 52, channelExpiresAt: "2025-03-14T10:30:00Z", - orderExpiresAt: "2024-03-21T15:45:00Z", + orderExpiresAt: expiresAt, channel: nil, lspNode: .init(alias: "", pubkey: "", connectionStrings: [], readonly: nil), lnurl: nil, diff --git a/changelog.d/next/738.fixed.md b/changelog.d/next/738.fixed.md new file mode 100644 index 000000000..932995c09 --- /dev/null +++ b/changelog.d/next/738.fixed.md @@ -0,0 +1 @@ +Fixed the transfer to spending flow creating a Blocktank order every time you tapped Continue, so an order is now only created when you confirm the transfer. diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index 6710395ec..c616e0676 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -14,9 +14,9 @@ If the first-run Transfer To Spending intro is shown, tap "Get Started" Verify the transfer amount screen opens (id "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row (id "HardwareTransferAmountAvailable"), the 25% and MAX quick buttons, and a number pad Tap the "25%" quick button (id "HardwareTransferAmountQuarter") to set a valid amount below the available limit - Tap "Continue" (id "HardwareTransferAmountContinue") and wait for the Blocktank order to be created + Tap "Continue" (id "HardwareTransferAmountContinue") and wait for the fee estimate Verify the sign screen opens (id "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration - Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect") + Tap "Open Trezor Connect" (id "HardwareTransferOpenTrezorConnect") to create the Blocktank order. If its fee is higher than the estimate, verify the fee cells refresh and tap "Open Trezor Connect" again; otherwise signing starts immediately Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order Verify the transaction signed screen appears (id "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears