diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 3cea77169..9f6133756 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -96,8 +96,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 d45dbde0d..6bd76d65e 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -460,9 +460,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 c18497cf0..491065215 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 @@ -117,6 +124,7 @@ protocol HwTransferConnecting: Sendable { @MainActor class TransferViewModel: ObservableObject { @Published var uiState = TransferUiState() + private var fundedOrderId: String? @Published var lightningSetupStep: Int = 0 @Published var transferValues = TransferValues() @@ -290,25 +298,38 @@ 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) { - clearPendingHwFundingBroadcast() + func onEstimateReady(clientBalance: UInt64, lspBalance: UInt64, feeSat: UInt64, isAdvanced: Bool = false) { + guard !isSpendingBusy else { return } + if uiState.clientBalanceSat != clientBalance || uiState.lspBalanceSat != lspBalance || + uiState.order?.id == fundedOrderId + { + 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 + ) async throws -> IBtOrder { + if let order = uiState.order, order.state2 == .created, order.id != fundedOrderId { + return order + } + let order = try await createOrder(uiState.clientBalanceSat, uiState.lspBalanceSat) + uiState.order = order + return order } func payOrder( @@ -371,9 +392,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, @@ -384,6 +402,7 @@ class TransferViewModel: ObservableObject { preTransferOnchainSats: UInt64? = nil, activityWalletId: String = WalletScope.default ) async { + fundedOrderId = order.id do { let transferId = try await transferService.createTransfer( type: .toSpending, @@ -396,7 +415,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 { @@ -414,16 +432,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) } @@ -531,17 +548,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. @@ -583,24 +599,41 @@ 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 } + hwSpending.isCreatingOrder = true + defer { hwSpending.isCreatingOrder = false } + + do { + let order = try await orderForConfirmation(createOrder: createOrder) + 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 { 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..edcff6d09 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,40 @@ 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 satsPerVbyte != nil, !transfer.isSpendingBusy else { return } + transfer.uiState.isConfirming = true + defer { transfer.uiState.isConfirming = false } do { + let order = try await transfer.orderForConfirmation { clientBalance, lspBalance in + try await blocktank.createOrder(clientBalance: clientBalance, lspBalance: lspBalance) + } + 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) + guard let rate = satsPerVbyte else { return } try await transfer.payOrder( - order: currentOrder, + order: order, speed: .fast, txFee: transactionFee, satsPerVbyte: rate, @@ -155,57 +186,45 @@ 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) 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")) - } - return + throw AppError(message: t("other__try_again"), debugMessage: nil) } 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) - } - 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 +233,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 +247,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 +280,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 edf24f5b6..bf707025c 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -11,12 +11,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 ) } @@ -370,8 +372,8 @@ 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 testBroadcastRetryDoesNotReuseSignedTransactionAfterOrderAddressChanges() async { @@ -559,19 +561,95 @@ 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 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 142bd628c..253685247 100644 --- a/BitkitTests/TransferViewModelTests.swift +++ b/BitkitTests/TransferViewModelTests.swift @@ -4,22 +4,72 @@ import XCTest final class TransferViewModelTests: XCTestCase { @MainActor - func testDisplayOrderPrefersUiStateOrder() { - let viewModel = TransferViewModel() - 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 = TransferViewModel() + 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 testConfirmationReusesTheOrderUntilTransferInputsChange() async throws { + let vm = TransferViewModel() + 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 first = try await vm.orderForConfirmation(createOrder: create) + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 50000, feeSat: 101_000) + let retry = try await vm.orderForConfirmation(createOrder: create) + XCTAssertEqual(first.id, retry.id) + XCTAssertEqual(calls, 1) + + vm.onEstimateReady(clientBalance: 100_000, lspBalance: 150_000, feeSat: 102_000, isAdvanced: true) + XCTAssertNil(vm.uiState.order) + let changed = try await vm.orderForConfirmation(createOrder: create) + XCTAssertEqual(changed.lspBalanceSat, 150_000) + 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 testConfirmationBlocksReplacingTheTransfer() { + let vm = TransferViewModel() + 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) } - // MARK: - calculateSpendingLimits (Transfer → Spending max) + @MainActor + func testFailedCreationPreservesTheEstimate() async { + let vm = TransferViewModel() + 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 { @@ -585,14 +635,14 @@ 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 static let lspFee: UInt64 = 2398 - private func makeOrder(id: String, clientBalanceSat: UInt64, lspBalanceSat: UInt64) -> IBtOrder { + private func makeOrder(id: String, clientBalanceSat: UInt64, lspBalanceSat: UInt64, feeSat: UInt64 = 1000) -> IBtOrder { IBtOrder( id: id, state: .created, state2: .created, - feeSat: 1000, + feeSat: feeSat, networkFeeSat: 2483, serviceFeeSat: 1520, lspBalanceSat: lspBalanceSat, 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.