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 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/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)
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..ab54d7b7a 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,9 +146,48 @@ 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
+
+ // 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() {
@@ -201,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 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)
+ }
+}
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..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.