From d5d9fc3be36b2e2329cdff49bca365eb63bd08e2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 14:45:14 -0300 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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. From f9f728b6740dc0df62ad7f2727fd4d942bf2e63b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 2 Sep 2026 07:54:45 -0300 Subject: [PATCH 07/10] test: cover settled transfer maximums in journeys The receiving-capacity journey now walks the behaviour this branch adds: the number pad held disabled while the maximum settles, an entered capacity snapping down to the settled maximum with the toast naming it, and an early Max corrected once the maximum lands. It sizes the transfer at MAX rather than 25%, since settling only bites when the client balance and the capacity together crowd the funding budget. Walked on an iPhone 17 simulator against the stag0 regtest LSP. Two findings are recorded in the suite README and the journey description: the settle finishes faster than a snapshot round-trip, so the disabled pad cannot be caught there, and once LSP headroom is consumed the offered minimum can exceed the offered maximum, which leaves Continue disabled and the order-creation tail unwalkable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KpAgJrxa5FmL8PHuXUg7So --- journeys/README.md | 2 +- journeys/amount-limits/README.md | 73 +++++++++++++++---- .../transfer-spending-advanced-over-max.xml | 39 ++++++++-- 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 905af7d60..7e95c218c 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -130,7 +130,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | Suite | Journeys | Notes | | --- | --- | --- | -| [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | +| [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens; the two transfer journeys are adapted — iOS snaps to the max where Android rejects the keypress | | [widgets](widgets) | 2 | Widgets intro and add-widget flow | | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | diff --git a/journeys/amount-limits/README.md b/journeys/amount-limits/README.md index 586765dfc..96b711c83 100644 --- a/journeys/amount-limits/README.md +++ b/journeys/amount-limits/README.md @@ -40,34 +40,77 @@ screens (Send, Transfer→Spending, Receiving capacity, External node). ## Verified behaviour -Both walked on an iPhone 17 simulator. **The two screens cap differently, so assert "does not exceed +All three walked on an iPhone 17 simulator against the stag0 regtest backend. **Send caps by +rejecting the keypress while the two transfer screens snap to the maximum, so assert "does not exceed the stated maximum" — never a specific value:** -| Screen | Available | Nine taps on `N9` | After one `NRemove` | +| Screen | Cap | Nine taps on `N9` | After one `NRemove` | | --- | --- | --- | --- | | Send | 297 393 | **99 999** — the largest all-9s value still under the cap | 9 999 | | Transfer → Spending | 296 522 | **296 522** — clamped to the max exactly | 29 652 | +| Transfer → Spending | 22 016 | **22 016** — same shape, a later run on a wallet with less LSP headroom | 2 201 | +| Receiving capacity | 564 | **564** — clamped to the max exactly | 56 | -Continue stays enabled throughout on both: the cap is applied to the input rather than leaving it in -an invalid state. The `SpendingAmountExceededToast` fires on the first rejected keypress and is gone -well before a snapshot round-trip returns — race a `wait-for-ui` on its identifier against the taps. +The cap column is whatever that screen enforces, not the wallet balance: for the two transfer rows it +is the settled maximum, and it moves with LSP headroom between runs. Assert the shape, never a value. +The receiving-capacity row is the behaviour PR #686 adds — Android leaves `99` there, because it +rejects the third keypress instead of snapping. + +On Send and Transfer → Spending, Continue stays enabled throughout: the cap is applied to the input +rather than leaving it in an invalid state. On the receiving-capacity screen Continue can still be +disabled at the cap, because it also enforces a minimum — see the `Min` > `Max` note below. The +exceeded toasts fire on the first rejected keypress and are gone well before a snapshot round-trip +returns — race a `wait-for-ui` on the toast identifier against the taps. The Transfer → Spending max populates from the on-chain balance and cached Blocktank info, so the screen is reachable and cappable even with the regtest backend down. That is *not* a substitute for a real LSP quote: without one the numbers are not the ones a real transfer would use. -## Pending: PR #686 +### Two things the walk turned up + +**The settling window is shorter than a snapshot round-trip.** `isSettlingAdvancedCapacity` disables +the number pad and Continue for exactly as long as the budget read and the LSP quotes take, and +against a warm stag0 LSP that is under a second — eight back-to-back snapshots raced against the +Advanced tap all came back with the pad already enabled. The gate is real (`NumberPad(isDisabled:)` +is wired to it, and disabled buttons drop out of the snapshot's Targets list, which is how the +Spending amount screen's own spinner is visible), but do not expect to catch it here. Treat +"already settled" as a pass. + +**`Min` can exceed `Max` once LSP headroom runs out.** On a wallet with two existing Blocktank +channels the walk found `SpendingAdvancedMin` at 77 984 and `SpendingAdvancedMax` at 564 — the +minimum is derived from the client balance, the maximum from what is left of the LSP's channel-size +cap, and nothing reconciles them. Continue then stays disabled at every capacity, so the journey's +tail (Continue → order created) cannot be walked. That is a property of the regtest LSP allocation, +not of this PR; close a channel to free headroom, or accept that the run covers the cap behaviour +only. + +## Transfer maximums are settled before they are offered + +[#686](https://github.com/synonymdev/bitkit-ios/pull/686) (porting +[bitkit-android#1179](https://github.com/synonymdev/bitkit-android/pull/1179) and +[#1180](https://github.com/synonymdev/bitkit-android/pull/1180)) settles both transfer maximums on an +amount the wallet can actually pay the LSP order fee for, rather than deriving one and hoping it +fits. Three behaviours it adds are covered by `transfer-spending-advanced-over-max.xml`: + +- **The number pad and Continue are disabled while the maximum settles.** Settling costs one or two + live fee quotes, so the advanced screen holds `NumberPad(isDisabled:)` until they return. Min, + Default and Max stay tappable — that is the iOS shape; Android disables the amount buttons too. +- **An entered capacity above the settled maximum snaps down to it**, with + `SpendingAdvancedExceededToast` naming the settled value. This is the snap + `SpendingAmount.onMaxExceeded()` already performed, now on the advanced screen as well. +- **Tapping Max before the maximum settles is corrected once it does.** `updateInputCap()` brings the + entered amount down when the settled maximum lands below it, so an early Max does not leave a + capacity selected that no longer exists. -[#686](https://github.com/synonymdev/bitkit-ios/pull/686) settles both transfer maximums and adds to -`SpendingAdvancedView` the snap that `SpendingAmount.onMaxExceeded()` already performs — an entered -capacity above the settled maximum comes down to it instead of the keypress being rejected. The -existing assertion ("does not exceed the maximum receiving capacity") holds either way, but three -behaviours it introduces are not covered yet and should be added to -`transfer-spending-advanced-over-max.xml` when it lands: +The journey sizes the transfer at **MAX** rather than 25% for this reason: settling only bites when +the client balance and the receiving capacity together crowd the funding budget, and at 25% the +settled maximum is simply the LSP's advertised maximum. -- the number pad is disabled while the maximum settles -- an entered capacity above the settled maximum snaps down to it, with the toast showing the settled value -- tapping Max before the maximum settles brings the entered amount down once it does +**Regtest is regression coverage here, not proof.** The staging/regtest service fee *falls* as the +client balance rises, where production's rises, which leaves a vulnerable window roughly 2 satoshis +wide against about 37 in production. Expect the settled maximum to equal the advertised one and the +two "comes down" steps to hold trivially. The unit tests in `BitkitTests/TransferViewModelTests.swift` +are the gate for the fix itself. ## Identifiers used - Number pad keys: digits `N0`–`N9`, triple-zero `N000`, decimal `NDecimal`, delete `NRemove`. diff --git a/journeys/amount-limits/transfer-spending-advanced-over-max.xml b/journeys/amount-limits/transfer-spending-advanced-over-max.xml index 81b6a6e23..6fde01bec 100644 --- a/journeys/amount-limits/transfer-spending-advanced-over-max.xml +++ b/journeys/amount-limits/transfer-spending-advanced-over-max.xml @@ -1,25 +1,50 @@ - Verifies the receiving capacity (advanced) number pad blocks input exceeding the maximum LSP - balance, shows the "Receiving Capacity Maximum" warning toast, and still allows deleting digits - while at the cap. + Verifies the receiving capacity (advanced) screen settles its maximum on a capacity the wallet + can pay the order fee for before offering it, holds the number pad disabled while that settles, + and snaps an entered capacity above the settled maximum down to it with the "Receiving Capacity + Maximum" warning toast — while still allowing deleting digits at the cap. Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and a running node connected to the LSP. Start on the wallet home screen. The advanced screen is reached by first - setting a valid spending amount and continuing to the confirm screen. + setting a spending amount and continuing to the confirm screen. + + Adapted from Android on purpose, for the behaviour PR #686 ports from bitkit-android#1179 and + bitkit-android#1180. Three deviations, all deliberate: + * The transfer is sized at MAX rather than 25%. Settling only bites when the receiving capacity + and the client balance together crowd the funding budget; at 25% there is enough headroom that + the settled maximum is the LSP's advertised maximum and every "comes down" step is a no-op. + * iOS snaps the entered capacity down to the settled maximum, the way `SpendingAmount` already + does; Android rejects the keypress and leaves the display at the largest all-9s value still + under the cap. Both satisfy "does not exceed the maximum". + * iOS disables the number pad and Continue while the maximum settles, but leaves Min, Default + and Max tappable — tapping Max early is corrected once the maximum settles. Android disables + the amount buttons too. + + Walked on an iPhone 17 simulator against the stag0 regtest LSP, where two findings shaped the + steps below. The settle finished faster than a single snapshot round-trip, so the disabled number + pad was never caught — the step is written to accept "already settled". And with the LSP headroom + already consumed by two channels, the offered minimum (77 984) sat above the offered maximum + (564), which leaves Continue disabled at every capacity: the cap steps still hold, the + order-creation tail cannot be walked. Both are properties of the regtest allocation rather than + of the app; `README.md` has the detail. Regtest is regression coverage for this journey, not + proof of the fix — the unit tests are that. Tap the Savings balance card on the home screen (id "ActivitySavings") Tap "Transfer To Spending" (id "TransferToSpending") If a transfer intro screen appears, tap "Get Started" Wait until the spending amount screen (id "SpendingAmount") has loaded a positive maximum (id "SpendingAmountAvailable") - Tap "25%" (id "SpendingAmountQuarter") to set a valid amount within range + Tap "MAX" (id "SpendingAmountMax") to size the transfer at the ceiling, where the least headroom is left for the receiving capacity Tap Continue (id "SpendingAmountContinue") On the confirm screen, tap "Advanced" (id "SpendingConfirmAdvanced") Verify the receiving capacity screen (id "SpendingAdvanced") is visible + Verify the number pad and Continue are disabled while the maximum settles — a disabled button drops out of the snapshot's Targets list, so "N9" and "SpendingAdvancedContinue" missing from Targets is the signal, not an enabled flag. Against a warm LSP the settle finishes faster than one snapshot round-trip; report "already settled" rather than failing the step + Tap "MAX" (id "SpendingAdvancedMax") — unlike the number pad it stays tappable while the maximum settles + Wait for the number pad to be enabled (id "N9", predicate enabled), then verify the amount in the input field (id "SpendingAdvancedNumberField") does not exceed the maximum receiving capacity now offered Tap the "9" key (id "N9") nine times to enter a capacity far larger than the maximum allowed - Verify a "Receiving Capacity Maximum" warning toast appears (id "SpendingAdvancedExceededToast") - Verify the amount in the input field (id "SpendingAdvancedNumberField") does not exceed the maximum receiving capacity + Verify a "Receiving Capacity Maximum" warning toast appears (id "SpendingAdvancedExceededToast") and names the settled maximum + Verify the amount in the input field (id "SpendingAdvancedNumberField") snapped to the maximum receiving capacity exactly, and does not exceed it Tap the delete key (id "NRemove") once Verify the amount in the input field (id "SpendingAdvancedNumberField") decreased after the delete From 4c48d6b5e00f038c8283779342c92b69ef00089d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 2 Sep 2026 11:05:04 -0300 Subject: [PATCH 08/10] fix: cap the hardware transfer amount input --- .../Transfer/Hardware/SpendingAmountHw.swift | 8 +++- changelog.d/next/686.fixed.md | 2 +- journeys/README.md | 2 +- journeys/amount-limits/README.md | 3 ++ journeys/hardware-wallet/README.md | 9 ++++- .../transfer-to-spending-over-max.xml | 39 +++++++++++++++++++ 6 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 journeys/hardware-wallet/transfer-to-spending-over-max.xml diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 7d2d782ed..6e3482726 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -118,7 +118,10 @@ struct SpendingAmountHw: View { } ) } - .onChange(of: maxAllowed) { updateInputCap() } + // `initial: true` because `maxAllowed` lives on the shared TransferViewModel and survives + // this screen: re-entering with the limits already computed produces no change to observe, + // which would leave the fresh AmountInputViewModel uncapped. + .onChange(of: maxAllowed, initial: true) { updateInputCap() } .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } .onChange(of: transfer.hwTransferError) { _, error in guard let error else { return } @@ -140,7 +143,8 @@ struct SpendingAmountHw: View { "lightning__spending_amount__error_max__description", variables: ["amount": CurrencyFormatter.formatSats(maxAllowed)] ), - visibilityTime: Toast.visibilityTimeShort + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "HardwareTransferAmountExceededToast" ) } diff --git a/changelog.d/next/686.fixed.md b/changelog.d/next/686.fixed.md index 8302ea3e9..0f642409d 100644 --- a/changelog.d/next/686.fixed.md +++ b/changelog.d/next/686.fixed.md @@ -1 +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. +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, and the hardware wallet transfer amount is held to that maximum instead of accepting any amount you type. diff --git a/journeys/README.md b/journeys/README.md index 7e95c218c..8f20778e2 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -134,7 +134,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [widgets](widgets) | 2 | Widgets intro and add-widget flow | | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | -| [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | +| [hardware-wallet](hardware-wallet) | 16 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | ## Not ported diff --git a/journeys/amount-limits/README.md b/journeys/amount-limits/README.md index 96b711c83..13f643e49 100644 --- a/journeys/amount-limits/README.md +++ b/journeys/amount-limits/README.md @@ -4,6 +4,9 @@ These journeys exercise the "block number pad input exceeding the max/available The same `AmountInputViewModel` cap + `maxExceededCount` effect path backs all four amount-entry screens (Send, Transfer→Spending, Receiving capacity, External node). +A fifth screen shares that path — the hardware Transfer To Spending amount. It needs a paired device, +so its journey lives in [`../hardware-wallet/transfer-to-spending-over-max.xml`](../hardware-wallet/transfer-to-spending-over-max.xml). + ## What the feature does - Typing a digit that would push the amount **over the cap is rejected** — the display stays at the largest value still within the cap (e.g. tapping `9` repeatedly stops at `9 999` when the cap is diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index ca78ed6bf..8f1363d58 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -93,6 +93,12 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities - Row identifiers are inconsistent in the app and this is not a typo in the journeys: `HardwareWalletRowName` has **no** separator, while `HardwareWalletRowDelete_` and `HardwareWalletRow_` use an underscore. The `` is the full `trezor:` device id. +- **The amount number pad was ungated.** `SpendingAmountHw` observed `maxAllowed` without + `initial: true`, and that value lives on the shared `TransferViewModel`, so a screen that opens + with the limits already computed has no change to observe and the fresh `AmountInputViewModel` + keeps `maxAmountOverride` nil. Nine taps on `N9` entered **999 999 999** against an AVAILABLE of + 610 015, with no toast and no snap; Continue stayed disabled, so no unaffordable order was + reachable. Fixed in #686 and covered by `transfer-to-spending-over-max.xml`. - Confirmed resolving without the emulator: `HardwareWalletsSettings`, `HardwareWalletsScreen`, `AddHardwareWallet`, `HardwareWalletIntroScreen`/`Continue`/`Cancel`, `HardwareWalletScreen`, `HardwareTransferToSpending`, `HardwareTransferAmount` and its Available/25%/MAX/Continue controls, @@ -115,7 +121,8 @@ Walked as far as a simulator allows, against a wallet with two Trezor identities - Home and detail: tile `ActivityHardware`, screen `HardwareWalletScreen`, `RemoveHardwareWallet`, `RemoveHwWalletDialog`, `HwRemoveKeepBackupToggle`. - Transfer: `HardwareTransferToSpending`, `HardwareTransferAmount`, - `HardwareTransferAmountAvailable`/`Quarter`/`Max`/`Continue`, `HardwareTransferSign`, + `HardwareTransferAmountAvailable`/`Quarter`/`Max`/`Continue`, + over-max toast `HardwareTransferAmountExceededToast`, `HardwareTransferSign`, `HardwareTransferSignLearnMore`/`SignAdvanced`/`SignDefault`, `HardwareTransferOpenTrezorConnect`, `HardwareTransferSigned`, `HwTransferPassphraseSheet`/`Input`/`Continue`/`Cancel`. diff --git a/journeys/hardware-wallet/transfer-to-spending-over-max.xml b/journeys/hardware-wallet/transfer-to-spending-over-max.xml new file mode 100644 index 000000000..4e078cf8e --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-over-max.xml @@ -0,0 +1,39 @@ + + + Verifies the hardware Transfer To Spending amount number pad enforces the transferable maximum: + an entry above it snaps down to the maximum with the "Spending Balance Maximum" warning toast, + and deleting digits still works while at the cap. + + iOS-only — there is no counterpart under `bitkit-android/journeys/hardware-wallet`. It was added + alongside the fix for the bug it covers. `SpendingAmountHw` observed `maxAllowed` without + `initial: true`, and since that value lives on the shared `TransferViewModel` and outlives the + screen, arriving with the limits already computed produced no change to observe. The freshly + created `AmountInputViewModel` then kept `maxAmountOverride` nil, leaving the pad bounded only by + the global 999 999 999 sat ceiling. Measured on an iPhone 17 simulator: nine taps on `N9` entered + 999 999 999 against an AVAILABLE of 610 015, with no toast and no snap. Continue stayed disabled, + so nothing unaffordable could be ordered — the gap was in the input, not in the order. + + Precondition: a paired Bridge emulator whose native-segwit account holds spendable regtest funds, + and a reachable Blocktank LSP so a real maximum can be quoted. Start on the wallet home screen. + + The screen is entered twice on purpose. A first entry that computes the limits from zero fires + the change either way, so it passes even with the bug; the second entry, where the limits are + already settled, is the one that regressed. + + + Tap the hardware wallet tile (id "ActivityHardware") beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (id "HardwareWalletScreen") + Tap the "Transfer To Spending" button (id "HardwareTransferToSpending") + If the first-run Transfer To Spending intro is shown, tap "Get Started" + Verify the transfer amount screen opens (id "HardwareTransferAmount") + Wait for the limits to load (id "N9", predicate enabled) — the number pad is held disabled behind the AVAILABLE spinner until the device balance and the LSP quote return + Verify the AVAILABLE amount (id "HardwareTransferAmountAvailable") shows a positive value + Tap the "9" key (id "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears (id "HardwareTransferAmountExceededToast") and names the maximum + Verify the amount in the input field snapped to the maximum and does not exceed the AVAILABLE amount — 999 999 999 here is the regression this journey exists for + Tap the delete key (id "NRemove") once, and verify the amount decreased + Tap back (id "NavigationBack") to return to the hardware wallet screen + Tap "Transfer To Spending" (id "HardwareTransferToSpending") again — this second entry arrives with the limits already computed, which is the case that regressed + Wait for the number pad (id "N9", predicate enabled), then tap the "9" key nine times again + Verify the toast appears again (id "HardwareTransferAmountExceededToast") and the amount snapped to the same maximum, not to 999 999 999 + + From e95d049cb019319f7d2cb13fcff38c63a0ffd868 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 2 Sep 2026 11:40:45 -0300 Subject: [PATCH 09/10] chore: trim the transfer affordability comments --- Bitkit/ViewModels/TransferViewModel.swift | 68 ++++++++----------- .../Transfer/Hardware/SpendingAmountHw.swift | 8 +-- .../Views/Transfer/SpendingAdvancedView.swift | 11 ++- Bitkit/Views/Transfer/SpendingAmount.swift | 5 +- .../Transfer/TransferFundingBudget.swift | 15 ++-- 5 files changed, 42 insertions(+), 65 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index f51b8bd3b..9634fd635 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -855,17 +855,11 @@ class TransferViewModel: ObservableObject { ) } - /// 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. + /// Liquidity options for the advanced screen, with the maximum receiving capacity settled on one + /// the budget can 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 + /// The LSP prices both sides of the channel, so a higher capacity costs more, and its advertised + /// maximum knows nothing of the client balance already committed. func updateAdvancedTransferValues( clientBalanceSat: UInt64, budget: UInt64?, @@ -896,9 +890,9 @@ class TransferViewModel: ObservableObject { 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. + /// The highest receiving capacity `budget` can pay the order fee for, or nil when even + /// `minLspBalance` is out of reach — the confirm step does the rejecting rather than this + /// presenting a range with nothing valid in it. func settleAdvancedLspBalance( clientBalance: UInt64, budget: UInt64, @@ -929,8 +923,7 @@ class TransferViewModel: ObservableObject { ) } - /// 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. + /// Nil when the LSP will not quote: callers skip the check rather than reject. private func lspFeeQuote( clientBalance: UInt64, lspBalance: UInt64, @@ -942,11 +935,9 @@ class TransferViewModel: ObservableObject { /// 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. + /// 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 lands in a round or two. The invariant + /// `affordableFee <= headroom < overBudgetFee` keeps every candidate inside the bracket. private func settleCapacity( clientBalance: UInt64, headroom: UInt64, @@ -988,9 +979,9 @@ class TransferViewModel: ObservableObject { 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. + /// `span * numerator / denominator` without overflowing the intermediate product. The caller's + /// bracket guarantees `numerator < denominator`; the guard keeps `dividingFullWidth` from + /// trapping if a misconfigured LSP breaks that. private static func scaledSpan(span: UInt64, numerator: UInt64, denominator: UInt64) -> UInt64 { guard denominator > 0 else { return 0 } let product = span.multipliedFullWidth(by: numerator) @@ -998,8 +989,8 @@ class TransferViewModel: ObservableObject { 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. + /// Backstop before a raised capacity is ordered. Like `canFundOrder`, only a quoted and + /// definitively unaffordable capacity is rejected. func canFundAdvancedOrder( clientBalance: UInt64, receivingAmount: UInt64, @@ -1022,11 +1013,9 @@ class TransferViewModel: ObservableObject { 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. + /// The device's spendable balance, re-read at decision time. Never on-chain savings, which would + /// reject every hardware transfer. Nil without hardware capabilities, leaving the 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 @@ -1078,11 +1067,10 @@ class TransferViewModel: ObservableObject { /// 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. + /// `availableAmount - fee` is a different balance from the one that fee priced, and the service + /// fee moves with the client/LSP split — up with the client balance in production, down on + /// staging and regtest — so an order built there can cost more than the wallet holds. Each round + /// re-quotes its own candidate. private func resolveAffordableClientBalance( availableAmount: UInt64, quotedBalance: UInt64, @@ -1116,13 +1104,11 @@ class TransferViewModel: ObservableObject { 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. + /// Backstop before an order is created: re-quote the fee and confirm the funding source still + /// covers it and the balance. /// - /// 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. + /// A missing budget or quote does not block — that would lock people out whenever the node is + /// briefly unready, and the confirm step stays the authority. Both are logged. func canFundOrder( clientBalance: UInt64, budget: UInt64?, diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift index 6e3482726..cfe4bd615 100644 --- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift +++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift @@ -118,9 +118,8 @@ struct SpendingAmountHw: View { } ) } - // `initial: true` because `maxAllowed` lives on the shared TransferViewModel and survives - // this screen: re-entering with the limits already computed produces no change to observe, - // which would leave the fresh AmountInputViewModel uncapped. + // `initial: true` because `maxAllowed` outlives this screen: arriving with the limits + // already computed leaves no change to observe, and the input would go uncapped. .onChange(of: maxAllowed, initial: true) { updateInputCap() } .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } .onChange(of: transfer.hwTransferError) { _, error in @@ -189,8 +188,7 @@ 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. + // 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), diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 22e706772..e8435acbe 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -3,8 +3,7 @@ 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. + /// Set for a hardware transfer, so the capacity is priced against the device account. var walletId: String? @EnvironmentObject var app: AppViewModel @@ -19,7 +18,7 @@ struct SpendingAdvancedView: View { @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. + /// Reserved once and reused, so re-reading the budget doesn't burn a receive index. @State private var fundingAddress: String? var lspBalance: UInt64 { @@ -155,8 +154,7 @@ struct SpendingAdvancedView: View { } } - /// The budget this order has to fit under: the device account for a hardware transfer, this - /// wallet's on-chain savings otherwise. + /// 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) @@ -185,8 +183,7 @@ struct SpendingAdvancedView: View { 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. + // Settling can land the max below what is already entered. if maxLspBalance > 0, maxLspBalance < amountViewModel.amountSats { amountViewModel.updateFromSats(maxLspBalance, currency: currency) } diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index c22213c66..11e47d527 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -15,7 +15,7 @@ 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. + /// Reserved once and reused, so re-reading the budget doesn't burn a receive index. @State private var fundingAddress: String? private var amountSats: UInt64 { @@ -220,8 +220,7 @@ 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. + /// 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/TransferFundingBudget.swift b/Bitkit/Views/Transfer/TransferFundingBudget.swift index 74058727f..c6f3dbb94 100644 --- a/Bitkit/Views/Transfer/TransferFundingBudget.swift +++ b/Bitkit/Views/Transfer/TransferFundingBudget.swift @@ -2,22 +2,19 @@ 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. +/// the fee to sweep it at the fast rate. Shared by the amount and advanced screens so sizing and the +/// pre-order re-check use 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. + /// Reserved once per screen and reused: each call advances LDK's receive index, and this address + /// only ever prices a sweep, never receives. 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. + /// Nil when the fee estimates or the sweep calculation are unavailable: sizing then falls back to + /// a cheaper estimate, while a funding check skips rather than blocks. static func onchainBudget( address: String, feeEstimatesManager: FeeEstimatesManager, From a0c44b9f4b0d1d57622693c0f1adf1c625afdfc8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 4 Sep 2026 11:12:11 -0300 Subject: [PATCH 10/10] fix: make the iOS budget read as cheap as Android's --- .../Views/Transfer/TransferFundingBudget.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/Transfer/TransferFundingBudget.swift b/Bitkit/Views/Transfer/TransferFundingBudget.swift index c6f3dbb94..f8ce0a6a4 100644 --- a/Bitkit/Views/Transfer/TransferFundingBudget.swift +++ b/Bitkit/Views/Transfer/TransferFundingBudget.swift @@ -13,15 +13,25 @@ enum TransferFundingBudget { return try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) } - /// Nil when the fee estimates or the sweep calculation are unavailable: sizing then falls back to - /// a cheaper estimate, while a funding check skips rather than blocks. + /// Re-read on every Continue, so it uses cached fee rates rather than forcing a refresh that + /// would sit between the tap and the confirm screen. + /// + /// Nil when the fee estimates or the sweep fee are unavailable: sizing then 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 } + guard let feeEstimates = await feeEstimatesManager.getEstimates() else { return nil } let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeEstimates) - return try? await wallet.calculateMaxSendableAmount(address: address, satsPerVByte: fastFeeRate) + let spendable = UInt64(max(0, wallet.spendableOnchainBalanceSats)) + + guard let sweepFee = try? await LightningService.shared.estimateSendAllFee( + address: address, + satsPerVByte: fastFeeRate + ) else { return nil } + + return spendable.saturatingSub(sweepFee) } }