From d5d9fc3be36b2e2329cdff49bca365eb63bd08e2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 14:45:14 -0300 Subject: [PATCH 1/6] feat: saturating stup method --- Bitkit/Extensions/FixedWidthInteger+Saturating.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift index 3a954b13a..e56e9968e 100644 --- a/Bitkit/Extensions/FixedWidthInteger+Saturating.swift +++ b/Bitkit/Extensions/FixedWidthInteger+Saturating.swift @@ -5,4 +5,9 @@ extension FixedWidthInteger { let (sum, overflow) = addingReportingOverflow(other) return overflow ? Self.max : sum } + + func saturatingSub(_ other: Self) -> Self { + let (difference, overflow) = subtractingReportingOverflow(other) + return overflow ? Self.min : difference + } } From 2549ff87ba251975ea97b14bbf96be15cc676ad7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 14:58:20 -0300 Subject: [PATCH 2/6] feat: budget loop logic --- Bitkit/ViewModels/TransferViewModel.swift | 267 +++++++++++++++++++++- 1 file changed, 260 insertions(+), 7 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 57c23588e..7465ee72e 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -118,6 +118,8 @@ class TransferViewModel: ObservableObject { @Published var uiState = TransferUiState() @Published var lightningSetupStep: Int = 0 @Published var transferValues = TransferValues() + + @Published var isSettlingAdvancedCapacity = false @Published var selectedChannelIds: [String] = [] @Published var channelsToClose: [ChannelDetails] = [] @Published var transferUnavailable = false @@ -174,6 +176,7 @@ class TransferViewModel: ObservableObject { private let swapQuoteTimeout: TimeInterval = 15 /// Minimum sats held back from a swap to cover Lightning routing fees. private static let minLnRoutingFeeReserveSats: UInt64 = 10 + private static let maxAffordabilityRounds = 2 init( coreService: CoreService = .shared, @@ -851,8 +854,181 @@ class TransferViewModel: ObservableObject { ) } - func updateTransferValues(clientBalanceSat: UInt64, blocktankInfo: IBtInfo?) { - transferValues = calculateTransferValues(clientBalanceSat: clientBalanceSat, blocktankInfo: blocktankInfo) + /// Liquidity options for the advanced screen, with the offered maximum receiving capacity settled + /// on one the funding budget can actually pay the order fee for. + /// + /// The LSP prices both sides of the channel, so raising the receiving capacity raises the order + /// fee. Its advertised maximum knows nothing of the client balance already committed to the + /// order, so offering it against a balance sized near the budget buys an order the wallet cannot + /// fund. Settling it here means the Max button, and the ceiling the input enforces, land on a + /// capacity that can be ordered rather than one the confirm step rejects. + /// + /// - `transferValues`: liquidity options for a given client balance (prod: `calculateTransferValues`) + /// - `estimateOrderFee`: Blocktank order fee for a given client/LSP balance + func updateAdvancedTransferValues( + clientBalanceSat: UInt64, + budget: UInt64?, + transferValues: (_ clientBalanceSat: UInt64) -> TransferValues, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async { + isSettlingAdvancedCapacity = true + defer { isSettlingAdvancedCapacity = false } + + var values = transferValues(clientBalanceSat) + self.transferValues = values + + guard let budget, values.maxLspBalance > values.minLspBalance else { return } + + let settled = await settleAdvancedLspBalance( + clientBalance: clientBalanceSat, + budget: budget, + minLspBalance: values.minLspBalance, + maxLspBalance: values.maxLspBalance, + estimateOrderFee: estimateOrderFee + ) + + guard let settled, settled < values.maxLspBalance else { return } + Logger.info("Settled max capacity '\(values.maxLspBalance)' on affordable '\(settled)'", context: "TransferViewModel") + values.maxLspBalance = settled + // The Default button must not hand back a capacity the settled max just excluded. + values.defaultLspBalance = min(values.defaultLspBalance, settled) + self.transferValues = values + } + + /// The highest receiving capacity `budget` can still pay the order fee for, or nil when even + /// `minLspBalance` is out of reach — the offer is then left alone and the confirm step does the + /// rejecting, rather than presenting a range with nothing valid in it. + func settleAdvancedLspBalance( + clientBalance: UInt64, + budget: UInt64, + minLspBalance: UInt64, + maxLspBalance: UInt64, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async -> UInt64? { + let headroom = budget.saturatingSub(clientBalance) + + guard let maxFee = await lspFeeQuote(clientBalance: clientBalance, lspBalance: maxLspBalance, estimateOrderFee: estimateOrderFee) else { + Logger.warn("Advertising unsettled max capacity '\(maxLspBalance)', fee quote unavailable", context: "TransferViewModel") + return maxLspBalance + } + if maxFee <= headroom { return maxLspBalance } + + guard let minFee = await lspFeeQuote(clientBalance: clientBalance, lspBalance: minLspBalance, estimateOrderFee: estimateOrderFee), + minFee <= headroom + else { return nil } + + return await settleCapacity( + clientBalance: clientBalance, + headroom: headroom, + affordable: minLspBalance, + affordableFee: minFee, + overBudget: maxLspBalance, + overBudgetFee: maxFee, + estimateOrderFee: estimateOrderFee + ) + } + + /// The liquidity fee for one client/LSP split, or nil when the LSP will not quote it. Callers + /// treat nil as "skip this check" rather than as a rejection. + private func lspFeeQuote( + clientBalance: UInt64, + lspBalance: UInt64, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async -> UInt64? { + guard let fee = try? await estimateOrderFee(clientBalance, lspBalance) else { return nil } + return fee.networkFeeSat.saturatingAdd(fee.serviceFeeSat) + } + + /// Walks the affordable/over-budget bracket inward along the fee rate its two priced ends imply. + /// + /// Unlike the client balance, a satoshi off the capacity only takes a fraction of a satoshi off + /// the fee, so stepping down by the shortfall would barely move. Interpolating through the + /// implied rate lands in a round or two instead. The bracket invariant + /// `affordableFee <= headroom < overBudgetFee` is what keeps each candidate strictly inside the + /// bracket, and is also why `scaledSpan` can never exceed the span. + private func settleCapacity( + clientBalance: UInt64, + headroom: UInt64, + affordable: UInt64, + affordableFee: UInt64, + overBudget: UInt64, + overBudgetFee: UInt64, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async -> UInt64 { + var settled = affordable + var settledFee = affordableFee + var ceiling = overBudget + var ceilingFee = overBudgetFee + + for _ in 0 ..< Self.maxAffordabilityRounds { + let feeSpan = ceilingFee.saturatingSub(settledFee) + guard feeSpan > 0 else { return settled } + + let candidate = settled.saturatingAdd( + Self.scaledSpan( + span: ceiling.saturatingSub(settled), + numerator: headroom.saturatingSub(settledFee), + denominator: feeSpan + ) + ) + guard candidate > settled, + let candidateFee = await lspFeeQuote(clientBalance: clientBalance, lspBalance: candidate, estimateOrderFee: estimateOrderFee) + else { return settled } + + if candidateFee <= headroom { + settled = candidate + settledFee = candidateFee + } else { + ceiling = candidate + ceilingFee = candidateFee + } + } + + return settled + } + + /// `span * numerator / denominator` without overflowing the 64-bit intermediate product. The + /// caller's bracket guarantees `numerator < denominator`, so the quotient always fits; the guard + /// keeps `dividingFullWidth` total for the misconfigured-LSP case that would otherwise trap. + private static func scaledSpan(span: UInt64, numerator: UInt64, denominator: UInt64) -> UInt64 { + guard denominator > 0 else { return 0 } + let product = span.multipliedFullWidth(by: numerator) + guard product.high < denominator else { return span } + return denominator.dividingFullWidth(product).quotient + } + + /// Backstop before a raised receiving capacity is ordered. Same non-blocking semantics as + /// `canFundOrder`: only a successfully quoted, definitively unaffordable capacity is rejected. + func canFundAdvancedOrder( + clientBalance: UInt64, + receivingAmount: UInt64, + budget: UInt64?, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async -> Bool { + guard let budget else { + Logger.warn("Skipped capacity check for '\(receivingAmount)', no sized budget available", context: "TransferViewModel") + return true + } + guard let fee = try? await estimateOrderFee(clientBalance, receivingAmount) else { + Logger.warn("Skipped capacity check for '\(receivingAmount)', fee quote unavailable", context: "TransferViewModel") + return true + } + + let cost = clientBalance.saturatingAdd(fee.networkFeeSat.saturatingAdd(fee.serviceFeeSat)) + if cost > budget { + Logger.info("Priced capacity '\(receivingAmount)' at '\(cost)', over funding budget '\(budget)'", context: "TransferViewModel") + } + return cost <= budget + } + + /// The device's spendable balance for `walletId`, re-read live at decision time. + /// + /// Never the on-chain savings balance: a hardware transfer is funded by the device, so an + /// on-chain read would reject every one of them. Nil when the hardware capabilities aren't + /// injected, which leaves the funding guards non-blocking in previews and tests. + func hwFundingBudget(walletId: String) async -> UInt64? { + guard let hwSigner else { return nil } + return try? await hwSigner.availability(walletId: walletId).available } /// Calculates the max amount transferable to spending and the value to display as "Available". @@ -875,8 +1051,7 @@ class TransferViewModel: ObservableObject { let values1 = transferValues(onchainAvailable) let lspBalance1 = max(values1.defaultLspBalance, values1.minLspBalance) let fee1 = try await estimateOrderFee(onchainAvailable, lspBalance1) - let initialFees = fee1.networkFeeSat + fee1.serviceFeeSat - let balanceAfterLspFee = onchainAvailable > initialFees ? onchainAvailable - initialFees : 0 + let balanceAfterLspFee = onchainAvailable.saturatingSub(fee1.networkFeeSat.saturatingAdd(fee1.serviceFeeSat)) let cappedClientBalance: UInt64 = { guard let cap = lspMaxClientBalance, cap > 0 else { return balanceAfterLspFee } @@ -888,12 +1063,90 @@ class TransferViewModel: ObservableObject { guard values2.maxClientBalance > 0 else { return (0, 0) } let lspBalance2 = max(values2.defaultLspBalance, values2.minLspBalance) let fee2 = try await estimateOrderFee(cappedClientBalance, lspBalance2) - let finalFees = fee2.networkFeeSat + fee2.serviceFeeSat - let afterFee = onchainAvailable > finalFees ? onchainAvailable - finalFees : 0 - let result = min(values2.maxClientBalance, afterFee) + + let affordable = await resolveAffordableClientBalance( + availableAmount: onchainAvailable, + quotedBalance: cappedClientBalance, + quotedFee: fee2.networkFeeSat.saturatingAdd(fee2.serviceFeeSat), + transferValues: transferValues, + estimateOrderFee: estimateOrderFee + ) + let result = min(values2.maxClientBalance, affordable) return (result, result) } + /// Settles the advertised max on a client balance the LSP has actually priced. + /// + /// The second-pass quote prices `quotedBalance`, but `availableAmount - fee` is a *different* + /// balance, and the service fee moves with the client/LSP split — upward with the client balance + /// in production, downward on staging and regtest. An order built at that unpriced balance can + /// therefore cost more than the wallet holds. Each round re-quotes its candidate, so only a + /// balance whose own quote fits the budget is returned. + private func resolveAffordableClientBalance( + availableAmount: UInt64, + quotedBalance: UInt64, + quotedFee: UInt64, + transferValues: (_ clientBalance: UInt64) -> TransferValues, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async -> UInt64 { + var candidate = quotedBalance + var fee = quotedFee + + for _ in 0 ..< Self.maxAffordabilityRounds { + if candidate.saturatingAdd(fee) <= availableAmount { return candidate } + candidate = availableAmount.saturatingSub(fee) + // Re-price against the split order creation will pick for this balance, not the earlier one. + let values = transferValues(candidate) + let lspBalance = max(values.defaultLspBalance, values.minLspBalance) + guard let requoted = await lspFeeQuote(clientBalance: candidate, lspBalance: lspBalance, estimateOrderFee: estimateOrderFee) else { + Logger.warn("Advertising unverified max '\(candidate)', fee quote unavailable", context: "TransferViewModel") + return candidate + } + fee = requoted + } + + if candidate.saturatingAdd(fee) <= availableAmount { return candidate } + let fallback = availableAmount.saturatingSub(fee) + Logger.warn( + "Max '\(candidate)' still over budget '\(availableAmount)' after \(Self.maxAffordabilityRounds) rounds, " + + "advertising unverified '\(fallback)'", + context: "TransferViewModel" + ) + return fallback + } + + /// Backstop before an order is created: re-quote the fee for `clientBalance` and confirm the + /// funding source still covers both it and the balance itself. + /// + /// Neither a missing budget nor an unavailable quote blocks the transfer — blocking there would + /// lock people out of the flow whenever the node is briefly unready, and the confirm step prices + /// the real order and stays the authority. Both cases are logged so support logs show why a + /// check was skipped. + func canFundOrder( + clientBalance: UInt64, + budget: UInt64?, + transferValues: (_ clientBalance: UInt64) -> TransferValues, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async -> Bool { + guard let budget else { + Logger.warn("Skipped funding check for '\(clientBalance)', no sized budget available", context: "TransferViewModel") + return true + } + + let values = transferValues(clientBalance) + let lspBalance = max(values.defaultLspBalance, values.minLspBalance) + guard let fee = try? await estimateOrderFee(clientBalance, lspBalance) else { + Logger.warn("Skipped funding check for '\(clientBalance)', fee quote unavailable", context: "TransferViewModel") + return true + } + + let cost = clientBalance.saturatingAdd(fee.networkFeeSat.saturatingAdd(fee.serviceFeeSat)) + if cost > budget { + Logger.info("Priced amount '\(clientBalance)' at '\(cost)', over funding budget '\(budget)'", context: "TransferViewModel") + } + return cost <= budget + } + /// Calculates max client balance accounting for LDK reserve requirement func getMaxClientBalance(maxChannelSize: UInt64) -> UInt64 { let minRemoteBalance = UInt64(Double(maxChannelSize) * 0.025) From 5388c5fbc74e9a25d5b826b34ea984beeba12a71 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 15:11:56 -0300 Subject: [PATCH 3/6] fea: make UI update to budget gates --- Bitkit/Components/NumberPadActionButton.swift | 1 + Bitkit/MainNavView.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 4 + Bitkit/ViewModels/NavigationViewModel.swift | 4 +- .../Transfer/Hardware/SpendingAmountHw.swift | 21 ++++++ .../Transfer/Hardware/SpendingHwSign.swift | 2 +- .../Views/Transfer/SpendingAdvancedView.swift | 75 +++++++++++++++++-- Bitkit/Views/Transfer/SpendingAmount.swift | 56 +++++++++++--- .../Transfer/TransferFundingBudget.swift | 30 ++++++++ 9 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 Bitkit/Views/Transfer/TransferFundingBudget.swift diff --git a/Bitkit/Components/NumberPadActionButton.swift b/Bitkit/Components/NumberPadActionButton.swift index a377905b0..f8e83c96a 100644 --- a/Bitkit/Components/NumberPadActionButton.swift +++ b/Bitkit/Components/NumberPadActionButton.swift @@ -39,6 +39,7 @@ struct NumberPadActionButton: View { .stroke(variant == .secondary ? color : Color.clear, lineWidth: 2) ) .cornerRadius(8) + .opacity(disabled ? 0.5 : 1) } .disabled(disabled) .buttonStyle(NoAnimationButtonStyle()) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index cf45f67b6..41efb02c3 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -443,7 +443,7 @@ struct MainNavView: View { case let .spendingHwSign(walletId): SpendingHwSign(walletId: walletId) case .spendingHwSigned: SpendingHwSigned() case let .spendingConfirm(order): SpendingConfirm(order: order) - case let .spendingAdvanced(order): SpendingAdvancedView(order: order) + case let .spendingAdvanced(order, walletId): SpendingAdvancedView(order: order, walletId: walletId) case let .transferLearnMore(order): TransferLearnMoreView(order: order) case .settingUp: SettingUpView() case .fundingAdvanced: FundAdvancedOptions() diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index e2598bb0d..2968b19f9 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -212,6 +212,8 @@ "lightning__spending_amount__quarter" = "25%"; "lightning__spending_amount__error_min__title" = "Savings Balance Minimum"; "lightning__spending_amount__error_min__description" = "A minimum of ₿ {amount} is needed to set up your spending balance."; +"lightning__spending_amount__error_balance__description" = "Your savings cannot cover this transfer and its fees. Try a smaller amount."; +"lightning__spending_amount__error_balance__title" = "Insufficient Savings"; "lightning__spending_amount__error_max__title" = "Spending Balance Maximum"; "lightning__spending_amount__error_max__description" = "The amount you can transfer to your spending balance is currently limited to ₿ {amount}."; "lightning__spending_amount__error_max__description_zero" = "Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center."; @@ -223,6 +225,8 @@ "lightning__spending_confirm__default" = "Use Defaults"; "lightning__spending_advanced__title" = "Receiving\ncapacity"; "lightning__spending_advanced__fee" = "Liquidity fee"; +"lightning__spending_advanced__error_balance__description" = "Your savings cannot cover the liquidity fee for this receiving capacity. Choose a smaller amount."; +"lightning__spending_advanced__error_balance__title" = "Not Enough Funds"; "lightning__spending_advanced__error_max__title" = "Receiving Capacity Maximum"; "lightning__spending_advanced__error_max__description" = "The receiving capacity is currently limited to ₿ {amount}."; "lightning__liquidity__title" = "Liquidity\n& routing"; diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index ba841f693..b4b1d97cb 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -42,7 +42,9 @@ enum Route: Hashable { case spendingHwSign(walletId: String) case spendingHwSigned case spendingConfirm(order: IBtOrder) - case spendingAdvanced(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 settingUp case fundingAdvanced diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index eaaf2f0b3..7d2d782ed 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -185,6 +185,27 @@ struct SpendingAmountHw: View { } do { + // The budget is the device account, never on-chain savings: these funds never sit in + // this wallet, so an on-chain read would reject every hardware transfer. + let canFund = await transfer.canFundOrder( + clientBalance: amountSats, + budget: transfer.hwFundingBudget(walletId: walletId), + transferValues: { transfer.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: blocktank.info) }, + estimateOrderFee: { clientBalance, lspBalance in + let estimate = try await blocktank.estimateOrderFee(clientBalance: clientBalance, lspBalance: lspBalance) + return (estimate.networkFeeSat, estimate.serviceFeeSat) + } + ) + guard canFund else { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_balance__title"), + description: t("lightning__spending_amount__error_balance__description"), + visibilityTime: Toast.visibilityTimeShort + ) + return + } + 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) diff --git a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift index 7d2a4dbef..495d10f6c 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingHwSign.swift @@ -123,7 +123,7 @@ struct SpendingHwSign: View { size: .small, isDisabled: transfer.hwSpending.isSigning || transfer.hwSpending.hasPendingBroadcast ) { - navigation.navigate(.spendingAdvanced(order: order)) + navigation.navigate(.spendingAdvanced(order: order, walletId: walletId)) } .accessibilityIdentifier("HardwareTransferSignAdvanced") } diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 03bd9eedf..4d2e4f7a7 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -3,17 +3,24 @@ import SwiftUI struct SpendingAdvancedView: View { let order: IBtOrder + /// Set when the transfer is funded by a hardware wallet, so the capacity is priced against the + /// device account instead of this wallet's savings. + var walletId: String? @EnvironmentObject var app: AppViewModel @EnvironmentObject var blocktank: BlocktankViewModel @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var transfer: TransferViewModel + @EnvironmentObject var wallet: WalletViewModel @Environment(\.dismiss) var dismiss @State private var amountViewModel = AmountInputViewModel() @State private var feeEstimate: UInt64? @State private var isLoading = false @State private var feeEstimateTask: Task? + /// Reserved once and reused, so re-reading the budget on Continue doesn't burn a receive index. + @State private var fundingAddress: String? var lspBalance: UInt64 { amountViewModel.amountSats @@ -21,7 +28,7 @@ struct SpendingAdvancedView: View { private var isValid: Bool { let values = transfer.transferValues - guard lspBalance > 0, values.maxLspBalance > 0 else { return false } + guard !transfer.isSettlingAdvancedCapacity, lspBalance > 0, values.maxLspBalance > 0 else { return false } return lspBalance >= values.minLspBalance && lspBalance <= values.maxLspBalance } @@ -71,7 +78,8 @@ struct SpendingAdvancedView: View { NumberPad( type: amountViewModel.getNumberPadType(currency: currency), - errorKey: amountViewModel.errorKey + errorKey: amountViewModel.errorKey, + isDisabled: transfer.isSettlingAdvancedCapacity ) { key in amountViewModel.handleNumberPadInput(key, currency: currency) } @@ -85,6 +93,22 @@ struct SpendingAdvancedView: View { defer { isLoading = false } do { + let canFund = await transfer.canFundAdvancedOrder( + clientBalance: order.clientBalanceSat, + receivingAmount: lspBalance, + budget: fundingBudget(), + estimateOrderFee: estimateOrderFee + ) + guard canFund else { + app.toast( + type: .warning, + title: t("lightning__spending_advanced__error_balance__title"), + description: t("lightning__spending_advanced__error_balance__description"), + visibilityTime: Toast.visibilityTimeShort + ) + return + } + let newOrder = try await blocktank.createOrder( clientBalance: order.clientBalanceSat, lspBalance: lspBalance @@ -102,9 +126,11 @@ struct SpendingAdvancedView: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .task { - transfer.updateTransferValues( + await transfer.updateAdvancedTransferValues( clientBalanceSat: order.clientBalanceSat, - blocktankInfo: blocktank.info + budget: fundingBudget(), + transferValues: { transfer.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: blocktank.info) }, + estimateOrderFee: estimateOrderFee ) updateFeeEstimate() @@ -120,6 +146,39 @@ struct SpendingAdvancedView: View { .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } } + private var estimateOrderFee: (UInt64, UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) { + { clientBalance, lspBalance in + let estimate = try await blocktank.estimateOrderFee(clientBalance: clientBalance, lspBalance: lspBalance) + return (estimate.networkFeeSat, estimate.serviceFeeSat) + } + } + + /// The budget this order has to fit under: the device account for a hardware transfer, this + /// wallet's on-chain savings otherwise. + private func fundingBudget() async -> UInt64? { + if let walletId { + return await transfer.hwFundingBudget(walletId: walletId) + } + + do { + let address: String + if let fundingAddress { + address = fundingAddress + } else { + address = try await TransferFundingBudget.reserveSizingAddress() + fundingAddress = address + } + return await TransferFundingBudget.onchainBudget( + address: address, + feeEstimatesManager: feeEstimatesManager, + wallet: wallet + ) + } catch { + Logger.warn("Failed to resolve advanced funding budget: \(error)", context: "SpendingAdvancedView") + return nil + } + } + private func updateInputCap() { let maxLspBalance = transfer.transferValues.maxLspBalance amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil @@ -148,21 +207,21 @@ struct SpendingAdvancedView: View { private var actionButtons: some View { HStack(spacing: 16) { - NumberPadActionButton(text: t("common__min")) { + NumberPadActionButton(text: t("common__min"), disabled: transfer.isSettlingAdvancedCapacity) { amountViewModel.updateFromSats(transfer.transferValues.minLspBalance, currency: currency) } .accessibilityIdentifier("SpendingAdvancedMin") Spacer() - NumberPadActionButton(text: t("common__default")) { + NumberPadActionButton(text: t("common__default"), disabled: transfer.isSettlingAdvancedCapacity) { amountViewModel.updateFromSats(transfer.transferValues.defaultLspBalance, currency: currency) } .accessibilityIdentifier("SpendingAdvancedDefault") Spacer() - NumberPadActionButton(text: t("common__max")) { + NumberPadActionButton(text: t("common__max"), disabled: transfer.isSettlingAdvancedCapacity) { amountViewModel.updateFromSats(transfer.transferValues.maxLspBalance, currency: currency) } .accessibilityIdentifier("SpendingAdvancedMax") @@ -201,7 +260,9 @@ struct SpendingAdvancedView: View { .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 f25d5c977..be3b1718f 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -1,5 +1,4 @@ import BitkitCore -import LDKNode import SwiftUI struct SpendingAmount: View { @@ -16,6 +15,8 @@ struct SpendingAmount: View { @State private var isCalculatingMax = true @State private var availableAmount: UInt64? @State private var maxTransferAmount: UInt64? + /// Reserved once and reused, so re-reading the budget on Continue doesn't burn a receive index. + @State private var fundingAddress: String? private var amountSats: UInt64 { amountViewModel.amountSats @@ -177,6 +178,25 @@ struct SpendingAmount: View { } do { + let canFund = await transfer.canFundOrder( + clientBalance: amountSats, + budget: fundingBudget(), + transferValues: { transfer.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: blocktank.info) }, + estimateOrderFee: { clientBalance, lspBalance in + let estimate = try await blocktank.estimateOrderFee(clientBalance: clientBalance, lspBalance: lspBalance) + return (estimate.networkFeeSat, estimate.serviceFeeSat) + } + ) + guard canFund else { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_balance__title"), + description: t("lightning__spending_amount__error_balance__description"), + visibilityTime: Toast.visibilityTimeShort + ) + return + } + 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) @@ -189,6 +209,28 @@ struct SpendingAmount: View { } } + /// The on-chain budget this transfer has to fit under, used both to size the limits and to + /// re-check them before the order is placed. + private func fundingBudget() async -> UInt64? { + do { + let address: String + if let fundingAddress { + address = fundingAddress + } else { + address = try await TransferFundingBudget.reserveSizingAddress() + fundingAddress = address + } + return await TransferFundingBudget.onchainBudget( + address: address, + feeEstimatesManager: feeEstimatesManager, + wallet: wallet + ) + } catch { + Logger.warn("Failed to resolve transfer funding budget: \(error)", context: "SpendingAmount") + return nil + } + } + private func calculateMaxTransferAmount() async { guard let info = blocktank.info else { await MainActor.run { @@ -199,10 +241,7 @@ struct SpendingAmount: View { } do { - let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) - let address = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) - - guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else { + guard let calculatedAvailableAmount = await fundingBudget() else { await MainActor.run { let fallback = fallbackMaxTransferAmount(info: info) availableAmount = fallback @@ -210,13 +249,6 @@ struct SpendingAmount: View { } return } - let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeEstimates) - - // Calculate max sendable amount (balance minus transaction fee) - let calculatedAvailableAmount = try await wallet.calculateMaxSendableAmount( - address: address, - satsPerVByte: fastFeeRate - ) let (available, maxAmount) = try await transfer.calculateSpendingLimits( onchainAvailable: calculatedAvailableAmount, diff --git a/Bitkit/Views/Transfer/TransferFundingBudget.swift b/Bitkit/Views/Transfer/TransferFundingBudget.swift new file mode 100644 index 000000000..74058727f --- /dev/null +++ b/Bitkit/Views/Transfer/TransferFundingBudget.swift @@ -0,0 +1,30 @@ +import Foundation +import LDKNode + +/// The on-chain ceiling a transfer-to-spending order has to fit under: the spendable balance minus +/// the fee to sweep it at the fast rate. +/// +/// Shared by the amount and advanced screens so the budget the limits are sized against and the one +/// re-read before an order is placed come from the same calculation. +@MainActor +enum TransferFundingBudget { + /// Reserved once per screen and reused for every re-read. `nextNonReservedReceiveAddress` + /// advances LDK's receive index on each call, and this address only ever prices a sweep — it is + /// never funded — so taking a fresh one per Continue tap would burn indexes for nothing. + static func reserveSizingAddress() async throws -> String { + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + return try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) + } + + /// Nil when the fee estimates or the sweep calculation are unavailable. Callers decide what that + /// means: sizing falls back to a cheaper estimate, while a funding check skips rather than blocks. + static func onchainBudget( + address: String, + feeEstimatesManager: FeeEstimatesManager, + wallet: WalletViewModel + ) async -> UInt64? { + guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else { return nil } + let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeEstimates) + return try? await wallet.calculateMaxSendableAmount(address: address, satsPerVByte: fastFeeRate) + } +} From e6af5b21bccef792b6cd28c27810924a744cf9cd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 15:46:24 -0300 Subject: [PATCH 4/6] test: transfer budget tests --- BitkitTests/TransferViewModelHwTests.swift | 47 +++ BitkitTests/TransferViewModelTests.swift | 424 +++++++++++++++++++++ 2 files changed, 471 insertions(+) diff --git a/BitkitTests/TransferViewModelHwTests.swift b/BitkitTests/TransferViewModelHwTests.swift index e9ab2cf84..6769f1d3d 100644 --- a/BitkitTests/TransferViewModelHwTests.swift +++ b/BitkitTests/TransferViewModelHwTests.swift @@ -240,6 +240,53 @@ final class TransferViewModelHwTests: XCTestCase { XCTAssertFalse(vm.hwSpending.isLoading) } + /// Regression: the funding guards must price a hardware transfer against the device account. + /// Reading on-chain savings here would reject every one of them — those funds never sit in this + /// wallet. + func testHwFundingBudgetReadsTheDeviceAccount() async { + let funding = MockHwFunding() + funding.account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + funding.maxSpendable = 990_000 + let vm = TransferViewModel( + hwFunding: funding, + hwConnecting: MockHwConnecting(), + hwFeeRateProvider: { 2 }, + hwAddressProvider: { "bcrt1qtest" } + ) + + let budget = await vm.hwFundingBudget(walletId: "trezor:wallet") + + XCTAssertEqual(budget, 990_000) + XCTAssertEqual(funding.maxSpendableCalls.count, 1) + } + + /// Without an address to compose against, the budget still comes from the device balance — via + /// the conservative reserve clamp rather than an exact `sendMax`. + func testHwFundingBudgetFallsBackToTheDeviceReserveClamp() async { + let funding = MockHwFunding() + funding.account = HwFundingAccount(xpub: "zpubNS", addressType: .nativeSegwit, balanceSats: 1_000_000) + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + + let budget = await vm.hwFundingBudget(walletId: "trezor:wallet") + + XCTAssertEqual(funding.maxSpendableCalls.count, 0) + let clamped = try? XCTUnwrap(budget) + XCTAssertNotNil(clamped) + XCTAssertGreaterThan(clamped ?? 0, 0) + XCTAssertLessThan(clamped ?? 0, funding.account.balanceSats) + } + + func testHwFundingBudgetIsNilWhenTheDeviceIsUnreachable() async { + let funding = MockHwFunding() + funding.accountError = MockHwFunding.TestError() + let vm = makeViewModel(funding: funding, connecting: MockHwConnecting()) + + let budget = await vm.hwFundingBudget(walletId: "trezor:wallet") + + // An unreadable device balance leaves the guard non-blocking rather than rejecting. + XCTAssertNil(budget) + } + func testUpdateHwLimitsClearsStalePreviousDeviceCap() async { let funding = MockHwFunding() funding.accountError = MockHwFunding.TestError() diff --git a/BitkitTests/TransferViewModelTests.swift b/BitkitTests/TransferViewModelTests.swift index 2a454b355..a9ec9b499 100644 --- a/BitkitTests/TransferViewModelTests.swift +++ b/BitkitTests/TransferViewModelTests.swift @@ -97,6 +97,430 @@ final class TransferViewModelTests: XCTestCase { XCTAssertEqual(result.available, 0) } + // MARK: - calculateSpendingLimits affordability (bitkit-android #1179) + + /// Production LSP: the service fee grows with the client balance, so `available - fee` sits above + /// the balance that fee priced. These are the quotes from the reported failure, where the order + /// came to 265,727 against 265,726 available. + @MainActor + func testSpendingMaxIsAffordableWhenTheServiceFeeRisesWithTheClientBalance() async throws { + let viewModel = TransferViewModel() + let available: UInt64 = 265_726 + let quotes: [UInt64: UInt64] = [available: 4165, 261_561: 4128] + var feeCalls: [UInt64] = [] + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: available, + lspMaxClientBalance: nil, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { clientBalance, _ in + feeCalls.append(clientBalance) + return try (XCTUnwrap(quotes[clientBalance]), 0) + } + ) + + XCTAssertEqual(result.max, 261_561) + // The order the user can build at this max must stay within what they can actually pay. + XCTAssertLessThanOrEqual(result.max + (quotes[result.max] ?? 0), available) + // The old derivation: `available - fee(261_561)`, a balance that quote never priced. + XCTAssertNotEqual(result.max, available - 4128) + // Already affordable, so the common path costs no extra round trip. + XCTAssertEqual(feeCalls.count, 2) + } + + /// Staging/regtest LSP: it charges the LSP side harder than the client side, so the second quote + /// is dearer than the first and no ordering assumption holds. Capping alone would not fix this. + @MainActor + func testSpendingMaxIsAffordableWhenTheServiceFeeFallsWithTheClientBalance() async throws { + let viewModel = TransferViewModel() + let available: UInt64 = 266_478 + let quotes: [UInt64: UInt64] = [available: 1798, 264_680: 1800, 264_678: 1801, 264_677: 1801] + var feeCalls: [UInt64] = [] + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: available, + lspMaxClientBalance: nil, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { clientBalance, _ in + feeCalls.append(clientBalance) + return try (XCTUnwrap(quotes[clientBalance]), 0) + } + ) + + XCTAssertEqual(result.max, 264_677) + // The settled max funds its own order rather than merely undercutting the first quote. + XCTAssertLessThanOrEqual(result.max + (quotes[result.max] ?? 0), available) + XCTAssertEqual(feeCalls, [available, 264_680, 264_678, 264_677]) + } + + /// Order creation recomputes the LSP balance from the chosen amount, so a re-quote priced against + /// an earlier balance would verify an order that is never created. + @MainActor + func testSpendingMaxRequotePricesTheSplitTheOrderWillUse() async throws { + let viewModel = TransferViewModel() + let available: UInt64 = 266_478 + let maxChannel: UInt64 = 1_403_872 + let quotes: [UInt64: UInt64] = [available: 1798, 264_680: 1800, 264_678: 1801, 264_677: 1801] + var feeCalls: [(clientBalance: UInt64, lspBalance: UInt64)] = [] + + _ = try await viewModel.calculateSpendingLimits( + onchainAvailable: available, + lspMaxClientBalance: nil, + transferValues: { clientBalance in + // Each client balance gets its own LSP side, mirroring maxChannelSize - clientBalance. + TransferValues( + defaultLspBalance: maxChannel - clientBalance, + minLspBalance: 50000, + maxLspBalance: maxChannel - clientBalance, + maxClientBalance: Self.optionMaxClientBalance + ) + }, + estimateOrderFee: { clientBalance, lspBalance in + feeCalls.append((clientBalance, lspBalance)) + return try (XCTUnwrap(quotes[clientBalance]), 0) + } + ) + + XCTAssertEqual(feeCalls.count, 4) + for call in feeCalls { + XCTAssertEqual(call.lspBalance, maxChannel - call.clientBalance, "quote for \(call.clientBalance) priced the wrong split") + } + } + + @MainActor + func testSpendingMaxKeepsTheLastCandidateWhenTheRequoteFails() async throws { + let viewModel = TransferViewModel() + let available: UInt64 = 266_478 + let quotes: [UInt64: UInt64] = [available: 1798, 264_680: 1800] + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: available, + lspMaxClientBalance: nil, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { clientBalance, _ in + guard let fee = quotes[clientBalance] else { throw AppError(message: "lsp unreachable", debugMessage: nil) } + return (fee, 0) + } + ) + + // The step-down candidate is still published rather than the unaffordable quoted balance. + XCTAssertEqual(result.max, 264_678) + } + + @MainActor + func testSpendingMaxFallsBackWhenTheRoundsAreExhausted() async throws { + let viewModel = TransferViewModel() + let available: UInt64 = 266_478 + // The fee rises as fast as the balance steps down, so no candidate ever becomes affordable. + let quotes: [UInt64: UInt64] = [available: 1800, 264_678: 2000, 264_478: 2200, 264_278: 2400] + var feeCalls: [UInt64] = [] + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: available, + lspMaxClientBalance: nil, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { clientBalance, _ in + feeCalls.append(clientBalance) + return try (XCTUnwrap(quotes[clientBalance]), 0) + } + ) + + // The exhausted loop advertises availableAmount minus the last quote, not the last candidate. + XCTAssertEqual(result.max, available - 2400) + XCTAssertEqual(feeCalls.count, 4) + } + + // MARK: - Advanced receiving capacity (bitkit-android #1180) + + @MainActor + func testAdvancedCapacityKeepsTheLspMaxWhenTheBudgetCoversIt() async { + let viewModel = TransferViewModel() + var quoteCount = 0 + + let settled = await viewModel.settleAdvancedLspBalance( + clientBalance: Self.advancedClientBalance, + budget: Self.advancedBudget, + minLspBalance: 50000, + maxLspBalance: 400_000, + estimateOrderFee: { _, lspBalance in + quoteCount += 1 + return (Self.capacityPricedFee(lspBalance), 0) + } + ) + + XCTAssertEqual(settled, 400_000) + XCTAssertEqual(quoteCount, 1) + } + + @MainActor + func testAdvancedCapacitySettlesBelowTheLspMaxWhenTheFeeOutgrowsTheBudget() async throws { + let viewModel = TransferViewModel() + var quotedCapacities: [UInt64] = [] + + let resolved = await viewModel.settleAdvancedLspBalance( + clientBalance: Self.advancedClientBalance, + budget: Self.advancedBudget, + minLspBalance: 50000, + maxLspBalance: 2_000_000, + estimateOrderFee: { _, lspBalance in + quotedCapacities.append(lspBalance) + return (Self.capacityPricedFee(lspBalance), 0) + } + ) + let settled = try XCTUnwrap(resolved) + + // The fee is 1_000 + 1% of the capacity, and the budget leaves 10_000 over the client balance. + XCTAssertEqual(settled, 900_000) + XCTAssertLessThanOrEqual(Self.capacityPricedFee(settled), Self.advancedHeadroom) + XCTAssertTrue(quotedCapacities.contains(settled), "the offered max must itself have been priced") + } + + @MainActor + func testAdvancedCapacityIsNilWhenEvenTheMinimumIsUnaffordable() async { + let viewModel = TransferViewModel() + + let settled = await viewModel.settleAdvancedLspBalance( + clientBalance: Self.advancedClientBalance, + budget: Self.advancedClientBalance + 500, + minLspBalance: 50000, + maxLspBalance: 2_000_000, + estimateOrderFee: { _, lspBalance in (Self.capacityPricedFee(lspBalance), 0) } + ) + + // Rejection is left to the confirm step rather than offering a range with nothing valid in it. + XCTAssertNil(settled) + } + + @MainActor + func testAdvancedCapacityAdvertisesTheLspMaxWhenTheQuoteIsUnavailable() async { + let viewModel = TransferViewModel() + + let settled = await viewModel.settleAdvancedLspBalance( + clientBalance: Self.advancedClientBalance, + budget: Self.advancedBudget, + minLspBalance: 50000, + maxLspBalance: 2_000_000, + estimateOrderFee: { _, _ in throw AppError(message: "lsp unreachable", debugMessage: nil) } + ) + + XCTAssertEqual(settled, 2_000_000) + } + + @MainActor + func testAdvancedCapacityStopsAtTheLastAffordableCapacityWhenARequoteFails() async { + let viewModel = TransferViewModel() + + let settled = await viewModel.settleAdvancedLspBalance( + clientBalance: Self.advancedClientBalance, + budget: Self.advancedBudget, + minLspBalance: 50000, + maxLspBalance: 2_000_000, + estimateOrderFee: { _, lspBalance in + // Only the two bracketing quotes succeed; every interpolated candidate fails. + guard lspBalance == 50000 || lspBalance == 2_000_000 else { + throw AppError(message: "lsp unreachable", debugMessage: nil) + } + return (Self.capacityPricedFee(lspBalance), 0) + } + ) + + XCTAssertEqual(settled, 50000) + } + + /// A concave fee curve makes the interpolation overshoot, so the candidate becomes the new + /// ceiling instead of being advertised. Whatever comes back must still be affordable. + @MainActor + func testAdvancedCapacityNeverAdvertisesAnOverBudgetCandidate() async throws { + let viewModel = TransferViewModel() + // Steep to 200k, then near-flat — the linear guess between the two ends underestimates the fee. + let fee: (UInt64) -> UInt64 = { 1000 + min($0, 200_000) / 20 + $0.saturatingSub(200_000) / 1000 } + var quotedCapacities: [UInt64] = [] + + let resolved = await viewModel.settleAdvancedLspBalance( + clientBalance: Self.advancedClientBalance, + budget: Self.advancedBudget, + minLspBalance: 50000, + maxLspBalance: 1_000_000, + estimateOrderFee: { _, lspBalance in + quotedCapacities.append(lspBalance) + return (fee(lspBalance), 0) + } + ) + let settled = try XCTUnwrap(resolved) + + XCTAssertLessThanOrEqual(fee(settled), Self.advancedHeadroom) + XCTAssertLessThan(settled, 1_000_000) + // Candidates that priced over the headroom were rejected, not returned. + XCTAssertTrue(quotedCapacities.contains { fee($0) > Self.advancedHeadroom && $0 != 1_000_000 }) + } + + @MainActor + func testUpdateAdvancedTransferValuesSettlesTheMaxAndClearsTheFlag() async { + let viewModel = TransferViewModel() + let values = TransferValues( + defaultLspBalance: 1_500_000, + minLspBalance: 50000, + maxLspBalance: 2_000_000, + maxClientBalance: Self.optionMaxClientBalance + ) + + await viewModel.updateAdvancedTransferValues( + clientBalanceSat: Self.advancedClientBalance, + budget: Self.advancedBudget, + transferValues: { _ in values }, + estimateOrderFee: { _, lspBalance in (Self.capacityPricedFee(lspBalance), 0) } + ) + + XCTAssertEqual(viewModel.transferValues.maxLspBalance, 900_000) + // Default must not hand back a capacity the settled max just excluded. + XCTAssertEqual(viewModel.transferValues.defaultLspBalance, 900_000) + XCTAssertFalse(viewModel.isSettlingAdvancedCapacity) + } + + @MainActor + func testUpdateAdvancedTransferValuesLeavesAnAffordableMaxUntouched() async { + let viewModel = TransferViewModel() + let values = TransferValues( + defaultLspBalance: 100_000, + minLspBalance: 50000, + maxLspBalance: 400_000, + maxClientBalance: Self.optionMaxClientBalance + ) + + await viewModel.updateAdvancedTransferValues( + clientBalanceSat: Self.advancedClientBalance, + budget: Self.advancedBudget, + transferValues: { _ in values }, + estimateOrderFee: { _, lspBalance in (Self.capacityPricedFee(lspBalance), 0) } + ) + + XCTAssertEqual(viewModel.transferValues.maxLspBalance, 400_000) + XCTAssertEqual(viewModel.transferValues.defaultLspBalance, 100_000) + } + + // MARK: - Funding guards + + @MainActor + func testCanFundOrderRejectsAnAmountOverTheBudget() async { + let viewModel = TransferViewModel() + + let canFund = await viewModel.canFundOrder( + clientBalance: 260_000, + budget: 265_000, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { _, _ in (6000, 0) } // 260_000 + 6_000 is over the budget + ) + + XCTAssertFalse(canFund) + } + + @MainActor + func testCanFundOrderAcceptsAnAmountThatFitsTheBudget() async { + let viewModel = TransferViewModel() + + let canFund = await viewModel.canFundOrder( + clientBalance: 260_000, + budget: 265_000, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { _, _ in (1000, 0) } + ) + + XCTAssertTrue(canFund) + } + + @MainActor + func testCanFundOrderDoesNotBlockWhenTheBudgetIsUnknown() async { + let viewModel = TransferViewModel() + + let canFund = await viewModel.canFundOrder( + clientBalance: 260_000, + budget: nil, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { _, _ in (6000, 0) } + ) + + // An unreadable balance must not block the flow; confirm stays the authority. + XCTAssertTrue(canFund) + } + + @MainActor + func testCanFundOrderDoesNotBlockWhenTheQuoteFails() async { + let viewModel = TransferViewModel() + + let canFund = await viewModel.canFundOrder( + clientBalance: 260_000, + budget: 265_000, + transferValues: { _ in Self.values(maxClientBalance: Self.optionMaxClientBalance) }, + estimateOrderFee: { _, _ in throw AppError(message: "lsp unreachable", debugMessage: nil) } + ) + + // A quote the LSP will not give must not block the user; confirm stays the authority. + XCTAssertTrue(canFund) + } + + @MainActor + func testCanFundAdvancedOrderRejectsACapacityOverTheBudget() async { + let viewModel = TransferViewModel() + + let canFund = await viewModel.canFundAdvancedOrder( + clientBalance: 260_000, + receivingAmount: 900_000, + budget: 265_000, + estimateOrderFee: { _, _ in (6000, 0) } + ) + + XCTAssertFalse(canFund) + } + + @MainActor + func testCanFundAdvancedOrderDoesNotBlockWhenTheBudgetIsUnknownOrUnquoted() async { + let viewModel = TransferViewModel() + + let unsizedBudget = await viewModel.canFundAdvancedOrder( + clientBalance: 260_000, + receivingAmount: 900_000, + budget: nil, + estimateOrderFee: { _, _ in (6000, 0) } + ) + let failedQuote = await viewModel.canFundAdvancedOrder( + clientBalance: 260_000, + receivingAmount: 900_000, + budget: 265_000, + estimateOrderFee: { _, _ in throw AppError(message: "lsp unreachable", debugMessage: nil) } + ) + + XCTAssertTrue(unsizedBudget) + XCTAssertTrue(failedQuote) + } + + @MainActor + func testHwFundingBudgetIsNilWithoutDeviceCapabilities() async { + let viewModel = TransferViewModel() + + // No hardware capabilities injected, so the funding guards degrade to non-blocking. + let budget = await viewModel.hwFundingBudget(walletId: "wallet-1") + + XCTAssertNil(budget) + } + + private static let advancedClientBalance: UInt64 = 100_000 + private static let advancedBudget: UInt64 = 110_000 + private static let advancedHeadroom: UInt64 = advancedBudget - advancedClientBalance + + /// Prices an order at a flat 1_000 plus 1% of the receiving capacity, as the LSP charges both sides. + private static func capacityPricedFee(_ lspBalance: UInt64) -> UInt64 { + 1000 + lspBalance / 100 + } + + private static func values(maxClientBalance: UInt64) -> TransferValues { + TransferValues( + defaultLspBalance: lspBalance, + minLspBalance: lspBalance, + maxLspBalance: 0, + maxClientBalance: maxClientBalance + ) + } + private static let onChainBalance: UInt64 = 10_000_000 private static let lspMaxClientBalance: UInt64 = 1_766_193 private static let optionMaxClientBalance: UInt64 = 1_687_598 From ccab5e306b8450b2ff7a219e0fa95dfdf9066a69 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 16:03:25 -0300 Subject: [PATCH 5/6] fix: clamp entered capacity to settled max: --- Bitkit/Views/Transfer/SpendingAdvancedView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 4d2e4f7a7..ab54d7b7a 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -182,6 +182,12 @@ struct SpendingAdvancedView: View { private func updateInputCap() { let maxLspBalance = transfer.transferValues.maxLspBalance amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil + + // Settling the max can land it below what is already entered, so the amount comes down with + // it rather than leaving a capacity that no longer exists selected. + if maxLspBalance > 0, maxLspBalance < amountViewModel.amountSats { + amountViewModel.updateFromSats(maxLspBalance, currency: currency) + } } private func onMaxExceeded() { @@ -207,21 +213,21 @@ struct SpendingAdvancedView: View { private var actionButtons: some View { HStack(spacing: 16) { - NumberPadActionButton(text: t("common__min"), disabled: transfer.isSettlingAdvancedCapacity) { + NumberPadActionButton(text: t("common__min")) { amountViewModel.updateFromSats(transfer.transferValues.minLspBalance, currency: currency) } .accessibilityIdentifier("SpendingAdvancedMin") Spacer() - NumberPadActionButton(text: t("common__default"), disabled: transfer.isSettlingAdvancedCapacity) { + NumberPadActionButton(text: t("common__default")) { amountViewModel.updateFromSats(transfer.transferValues.defaultLspBalance, currency: currency) } .accessibilityIdentifier("SpendingAdvancedDefault") Spacer() - NumberPadActionButton(text: t("common__max"), disabled: transfer.isSettlingAdvancedCapacity) { + NumberPadActionButton(text: t("common__max")) { amountViewModel.updateFromSats(transfer.transferValues.maxLspBalance, currency: currency) } .accessibilityIdentifier("SpendingAdvancedMax") From 760eba7f4968457bbc4eb35453c5f4d673e5c3e5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 16:05:44 -0300 Subject: [PATCH 6/6] fix: drop number pad opacity --- Bitkit/Components/NumberPadActionButton.swift | 1 - changelog.d/next/686.fixed.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/next/686.fixed.md diff --git a/Bitkit/Components/NumberPadActionButton.swift b/Bitkit/Components/NumberPadActionButton.swift index f8e83c96a..a377905b0 100644 --- a/Bitkit/Components/NumberPadActionButton.swift +++ b/Bitkit/Components/NumberPadActionButton.swift @@ -39,7 +39,6 @@ struct NumberPadActionButton: View { .stroke(variant == .secondary ? color : Color.clear, lineWidth: 2) ) .cornerRadius(8) - .opacity(disabled ? 0.5 : 1) } .disabled(disabled) .buttonStyle(NoAnimationButtonStyle()) diff --git a/changelog.d/next/686.fixed.md b/changelog.d/next/686.fixed.md new file mode 100644 index 000000000..8302ea3e9 --- /dev/null +++ b/changelog.d/next/686.fixed.md @@ -0,0 +1 @@ +Transfers to spending now offer a maximum amount and receiving capacity your savings can actually cover the fees for, so transferring your full balance no longer fails with an insufficient funds error.