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
+ }
}
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index a1b201357..7fc5c5e52 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -451,7 +451,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 634feaa4b..c0757ade1 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -218,6 +218,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.";
@@ -229,6 +231,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/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift
index 67f2a0693..9634fd635 100644
--- a/Bitkit/ViewModels/TransferViewModel.swift
+++ b/Bitkit/ViewModels/TransferViewModel.swift
@@ -119,6 +119,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
@@ -175,6 +177,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,
@@ -852,8 +855,170 @@ class TransferViewModel: ObservableObject {
)
}
- func updateTransferValues(clientBalanceSat: UInt64, blocktankInfo: IBtInfo?) {
- transferValues = calculateTransferValues(clientBalanceSat: clientBalanceSat, blocktankInfo: blocktankInfo)
+ /// 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 a higher capacity costs more, and its advertised
+ /// maximum knows nothing of the client balance already committed.
+ 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 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,
+ 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
+ )
+ }
+
+ /// Nil when the LSP will not quote: callers skip the check rather than reject.
+ 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.
+ ///
+ /// 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,
+ 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 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)
+ guard product.high < denominator else { return span }
+ return denominator.dividingFullWidth(product).quotient
+ }
+
+ /// Backstop before a raised capacity is ordered. Like `canFundOrder`, only a quoted and
+ /// 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, 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
}
/// Calculates the max amount transferable to spending and the value to display as "Available".
@@ -876,8 +1041,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 }
@@ -889,12 +1053,87 @@ 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.
+ ///
+ /// `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,
+ 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 and confirm the funding source still
+ /// covers it and the balance.
+ ///
+ /// 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?,
+ 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)
diff --git a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift
index eaaf2f0b3..cfe4bd615 100644
--- a/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift
+++ b/Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift
@@ -118,7 +118,9 @@ struct SpendingAmountHw: View {
}
)
}
- .onChange(of: maxAllowed) { updateInputCap() }
+ // `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
guard let error else { return }
@@ -140,7 +142,8 @@ struct SpendingAmountHw: View {
"lightning__spending_amount__error_max__description",
variables: ["amount": CurrencyFormatter.formatSats(maxAllowed)]
),
- visibilityTime: Toast.visibilityTimeShort
+ visibilityTime: Toast.visibilityTimeShort,
+ accessibilityIdentifier: "HardwareTransferAmountExceededToast"
)
}
@@ -185,6 +188,26 @@ struct SpendingAmountHw: View {
}
do {
+ // The device account, never on-chain savings, which would reject every hardware transfer.
+ let canFund = await transfer.canFundOrder(
+ clientBalance: amountSats,
+ budget: transfer.hwFundingBudget(walletId: walletId),
+ 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 746c69a67..e8435acbe 100644
--- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift
+++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift
@@ -3,17 +3,23 @@ import SwiftUI
struct SpendingAdvancedView: View {
let order: IBtOrder
+ /// Set for a hardware transfer, so the capacity is priced against the device account.
+ var walletId: String?
@EnvironmentObject var app: AppViewModel
@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 doesn't burn a receive index.
+ @State private var fundingAddress: String?
var lspBalance: UInt64 {
amountViewModel.amountSats
@@ -21,7 +27,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 +77,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 +92,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
@@ -104,9 +127,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()
@@ -122,9 +147,46 @@ 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 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
+
+ // Settling can land the max below what is already entered.
+ if maxLspBalance > 0, maxLspBalance < amountViewModel.amountSats {
+ amountViewModel.updateFromSats(maxLspBalance, currency: currency)
+ }
}
private func onMaxExceeded() {
@@ -204,7 +266,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 262cdb605..11e47d527 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 doesn't burn a receive index.
+ @State private var fundingAddress: String?
private var amountSats: UInt64 {
amountViewModel.amountSats
@@ -188,6 +189,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)
@@ -200,6 +220,27 @@ struct SpendingAmount: View {
}
}
+ /// Sizes the limits, and re-checks 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 {
@@ -210,10 +251,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
@@ -221,13 +259,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..f8ce0a6a4
--- /dev/null
+++ b/Bitkit/Views/Transfer/TransferFundingBudget.swift
@@ -0,0 +1,37 @@
+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 sizing and the
+/// pre-order re-check use the same calculation.
+@MainActor
+enum TransferFundingBudget {
+ /// 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)
+ }
+
+ /// 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() else { return nil }
+ let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeEstimates)
+ 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)
+ }
+}
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
diff --git a/changelog.d/next/686.fixed.md b/changelog.d/next/686.fixed.md
new file mode 100644
index 000000000..0f642409d
--- /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, 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 905af7d60..8f20778e2 100644
--- a/journeys/README.md
+++ b/journeys/README.md
@@ -130,11 +130,11 @@ 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 |
-| [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 586765dfc..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
@@ -40,34 +43,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 |
+
+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.
-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.
+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
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
+
+