diff --git a/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme b/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme index 1db566a33..cfb07f7e6 100644 --- a/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme +++ b/Bitkit.xcodeproj/xcshareddata/xcschemes/BitkitAITests.xcscheme @@ -31,8 +31,7 @@ shouldAutocreateTestPlan = "YES"> + skipped = "NO"> Void)? let onPress: (String) -> Void @@ -25,12 +26,14 @@ struct NumberPad: View { type: NumberPadType = .simple, decimalSeparator: String = ".", errorKey: String? = nil, + isDisabled: Bool = false, onDeleteLongPress: (() -> Void)? = nil, onPress: @escaping (String) -> Void ) { self.type = type self.decimalSeparator = decimalSeparator self.errorKey = errorKey + self.isDisabled = isDisabled self.onDeleteLongPress = onDeleteLongPress self.onPress = onPress } @@ -127,6 +130,8 @@ struct NumberPad: View { ) } } + .opacity(isDisabled ? 0.5 : 1) + .disabled(isDisabled) } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index c93dbff66..5a903bc26 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -360,6 +360,45 @@ class TransferViewModel: ObservableObject { transferValues = calculateTransferValues(clientBalanceSat: clientBalanceSat, blocktankInfo: blocktankInfo) } + /// Calculates the max amount transferable to spending and the value to display as "Available". + /// + /// The prospective client balance is clamped to the LSP's `maxClientBalanceSat` before + /// computing liquidity options: an on-chain balance larger than the LSP's max channel size + /// otherwise makes the liquidity calculation report `maxClientBalanceSat = 0` (the balance + /// already saturates the channel), collapsing the spendable amount to zero and stranding the + /// funds on-chain. + /// + /// - `transferValues`: liquidity options for a given client balance (prod: `calculateTransferValues`) + /// - `estimateOrderFee`: Blocktank order fee for a given client/LSP balance + func calculateSpendingLimits( + onchainAvailable: UInt64, + lspMaxClientBalance: UInt64?, + transferValues: (_ clientBalance: UInt64) -> TransferValues, + estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64) + ) async rethrows -> (available: UInt64, max: UInt64) { + // First pass: estimate the LSP fee against the full on-chain balance. + 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 cappedClientBalance: UInt64 = { + guard let cap = lspMaxClientBalance, cap > 0 else { return balanceAfterLspFee } + return min(balanceAfterLspFee, cap) + }() + + // Second pass with the clamped balance. + let values2 = transferValues(cappedClientBalance) + 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) + return (result, result) + } + /// 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/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index dbd5f5717..03bd9eedf 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -117,7 +117,7 @@ struct SpendingAdvancedView: View { } } .onChange(of: transfer.transferValues.maxLspBalance, initial: true) { updateInputCap() } - .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } } private func updateInputCap() { @@ -125,6 +125,15 @@ struct SpendingAdvancedView: View { amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil } + private func onMaxExceeded() { + // Snap the input to the max so the user lands on the highest allowed amount. + let maxLspBalance = transfer.transferValues.maxLspBalance + if maxLspBalance > 0 { + amountViewModel.updateFromSats(maxLspBalance, currency: currency) + } + showMaxExceededToast() + } + private func showMaxExceededToast() { app.toast( type: .warning, diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index b5e1a5b1e..f25d5c977 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -1,3 +1,4 @@ +import BitkitCore import LDKNode import SwiftUI @@ -12,6 +13,7 @@ struct SpendingAmount: View { @State private var amountViewModel = AmountInputViewModel() @State private var isLoading = false + @State private var isCalculatingMax = true @State private var availableAmount: UInt64? @State private var maxTransferAmount: UInt64? @@ -19,6 +21,23 @@ struct SpendingAmount: View { amountViewModel.amountSats } + /// Inputs the max calculation depends on. A single `.task(id:)` keyed on this restarts the + /// calculation (with structured cancellation) whenever either input changes, so concurrent + /// reloads can neither overlap nor leak the loading flag. + private struct MaxCalcInputs: Equatable { + let maxChannelSizeSat: UInt64? + let maxClientBalanceSat: UInt64? + let spendableOnchainBalanceSats: Int + } + + private var maxCalcInputs: MaxCalcInputs { + MaxCalcInputs( + maxChannelSizeSat: blocktank.info?.options.maxChannelSizeSat, + maxClientBalanceSat: blocktank.info?.options.maxClientBalanceSat, + spendableOnchainBalanceSats: wallet.spendableOnchainBalanceSats + ) + } + private var isValidAmount: Bool { guard let max = maxTransferAmount else { return false } return amountSats <= max @@ -61,7 +80,8 @@ struct SpendingAmount: View { NumberPad( type: amountViewModel.getNumberPadType(currency: currency), - errorKey: amountViewModel.errorKey + errorKey: amountViewModel.errorKey, + isDisabled: isCalculatingMax ) { key in amountViewModel.handleNumberPadInput(key, currency: currency) } @@ -79,22 +99,28 @@ struct SpendingAmount: View { .padding(.horizontal, 16) .bottomSafeAreaPadding() .offlineOverlay(title: t("lightning__transfer__nav_title")) - .task(id: blocktank.info?.options.maxChannelSizeSat) { + .task(id: maxCalcInputs) { + await MainActor.run { isCalculatingMax = true } await calculateMaxTransferAmount() - } - .onChange(of: wallet.spendableOnchainBalanceSats) { - Task { - await calculateMaxTransferAmount() + if !Task.isCancelled { + await MainActor.run { isCalculatingMax = false } } } .onChange(of: maxTransferAmount) { updateInputCap() } - .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } + .onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() } } private func updateInputCap() { amountViewModel.maxAmountOverride = (maxTransferAmount ?? 0) > 0 ? maxTransferAmount : nil } + private func onMaxExceeded() { + if let max = maxTransferAmount { + amountViewModel.updateFromSats(max, currency: currency) + } + showMaxExceededToast() + } + private func showMaxExceededToast() { app.toast( type: .warning, @@ -178,10 +204,9 @@ struct SpendingAmount: View { guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else { await MainActor.run { - let balance = UInt64(wallet.spendableOnchainBalanceSats) - availableAmount = balance - let values = transfer.calculateTransferValues(clientBalanceSat: balance, blocktankInfo: info) - maxTransferAmount = min(values.maxClientBalance, balance) + let fallback = fallbackMaxTransferAmount(info: info) + availableAmount = fallback + maxTransferAmount = fallback } return } @@ -193,40 +218,37 @@ struct SpendingAmount: View { satsPerVByte: fastFeeRate ) - // First pass: estimate with calculatedAvailableAmount to get approximate clientBalance - let values1 = transfer.calculateTransferValues(clientBalanceSat: calculatedAvailableAmount, blocktankInfo: info) - let lspBalance1 = max(values1.defaultLspBalance, values1.minLspBalance) - let feeEstimate1 = try await blocktank.estimateOrderFee( - clientBalance: calculatedAvailableAmount, - lspBalance: lspBalance1 - ) - let lspFees1 = feeEstimate1.networkFeeSat + feeEstimate1.serviceFeeSat - let approxClientBalance = UInt64(max(0, Int64(calculatedAvailableAmount) - Int64(lspFees1))) - - // Second pass: recalculate lspBalance with actual clientBalance (same as onContinue will use) - // This ensures fee estimation matches the actual order creation - let values2 = transfer.calculateTransferValues(clientBalanceSat: approxClientBalance, blocktankInfo: info) - let lspBalance2 = max(values2.defaultLspBalance, values2.minLspBalance) - let feeEstimate2 = try await blocktank.estimateOrderFee( - clientBalance: approxClientBalance, - lspBalance: lspBalance2 + let (available, maxAmount) = try await transfer.calculateSpendingLimits( + onchainAvailable: calculatedAvailableAmount, + lspMaxClientBalance: info.options.maxClientBalanceSat, + transferValues: { transfer.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: info) }, + estimateOrderFee: { clientBalance, lspBalance in + let estimate = try await blocktank.estimateOrderFee(clientBalance: clientBalance, lspBalance: lspBalance) + return (estimate.networkFeeSat, estimate.serviceFeeSat) + } ) - let lspFees = feeEstimate2.networkFeeSat + feeEstimate2.serviceFeeSat - let maxClientBalance = UInt64(max(0, Int64(calculatedAvailableAmount) - Int64(lspFees))) - let result = min(values2.maxClientBalance, maxClientBalance) await MainActor.run { - availableAmount = calculatedAvailableAmount - maxTransferAmount = result + availableAmount = available + maxTransferAmount = maxAmount } } catch { Logger.error("Failed to calculate max transfer amount: \(error)") await MainActor.run { - let balance = UInt64(wallet.spendableOnchainBalanceSats) - availableAmount = balance - let values = transfer.calculateTransferValues(clientBalanceSat: balance, blocktankInfo: info) - maxTransferAmount = min(values.maxClientBalance, balance) + let fallback = fallbackMaxTransferAmount(info: info) + availableAmount = fallback + maxTransferAmount = fallback } } } + + /// Fallback max when fee estimates are unavailable: clamp the client balance to the LSP's max + /// client balance so the liquidity calculation doesn't collapse to zero on a saturating balance. + private func fallbackMaxTransferAmount(info: IBtInfo) -> UInt64 { + let balance = UInt64(wallet.spendableOnchainBalanceSats) + let lspMaxClientBalance = info.options.maxClientBalanceSat + let clientBalance = lspMaxClientBalance > 0 ? min(balance, lspMaxClientBalance) : balance + let values = transfer.calculateTransferValues(clientBalanceSat: clientBalance, blocktankInfo: info) + return min(values.maxClientBalance, balance) + } } diff --git a/BitkitTests/TransferViewModelTests.swift b/BitkitTests/TransferViewModelTests.swift index dac9f5f38..2a454b355 100644 --- a/BitkitTests/TransferViewModelTests.swift +++ b/BitkitTests/TransferViewModelTests.swift @@ -19,6 +19,92 @@ final class TransferViewModelTests: XCTestCase { XCTAssertEqual(result.clientBalanceSat, updatedOrder.clientBalanceSat) } + // MARK: - calculateSpendingLimits (Transfer → Spending max) + + @MainActor + func testSpendingLimitsCapsAtLspMaxClientBalanceWhenOnchainExceedsIt() async throws { + let viewModel = TransferViewModel() + var feeCallBalances: [UInt64] = [] + // The liquidity calc reports no receiving room (maxLspBalance = 0) because the client + // balance saturates the channel — the regression this guards against. + let values = TransferValues( + defaultLspBalance: Self.lspBalance, + minLspBalance: Self.lspBalance, + maxLspBalance: 0, + maxClientBalance: Self.optionMaxClientBalance + ) + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: Self.onChainBalance, + lspMaxClientBalance: Self.lspMaxClientBalance, + transferValues: { _ in values }, + estimateOrderFee: { clientBalance, _ in + feeCallBalances.append(clientBalance) + return (Self.networkFee, Self.serviceFee) + } + ) + + XCTAssertEqual(result.max, Self.optionMaxClientBalance) + XCTAssertEqual(result.available, result.max) + // The order fee must be estimated against the clamped client balance, not the full balance. + XCTAssertEqual(feeCallBalances.last, Self.lspMaxClientBalance) + } + + @MainActor + func testSpendingLimitsUsesFullBalanceWhenLspInfoUnavailable() async throws { + let viewModel = TransferViewModel() + var feeCallBalances: [UInt64] = [] + let values = TransferValues( + defaultLspBalance: Self.lspBalance, + minLspBalance: Self.lspBalance, + maxLspBalance: 0, + maxClientBalance: Self.optionMaxClientBalance + ) + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: Self.onChainBalance, + lspMaxClientBalance: nil, + transferValues: { _ in values }, + estimateOrderFee: { clientBalance, _ in + feeCallBalances.append(clientBalance) + return (Self.networkFee, Self.serviceFee) + } + ) + + XCTAssertEqual(result.max, Self.optionMaxClientBalance) + // Without an LSP cap the order fee is estimated against the balance after the LSP fee. + XCTAssertEqual(feeCallBalances.last, Self.onChainBalance - Self.lspFee) + } + + @MainActor + func testSpendingLimitsIsZeroWhenLiquidityReportsZeroClientBalance() async throws { + let viewModel = TransferViewModel() + let values = TransferValues( + defaultLspBalance: Self.lspBalance, + minLspBalance: Self.lspBalance, + maxLspBalance: 0, + maxClientBalance: 0 + ) + + let result = try await viewModel.calculateSpendingLimits( + onchainAvailable: Self.onChainBalance, + lspMaxClientBalance: Self.lspMaxClientBalance, + transferValues: { _ in values }, + estimateOrderFee: { _, _ in (Self.networkFee, Self.serviceFee) } + ) + + XCTAssertEqual(result.max, 0) + XCTAssertEqual(result.available, 0) + } + + private static let onChainBalance: UInt64 = 10_000_000 + private static let lspMaxClientBalance: UInt64 = 1_766_193 + private static let optionMaxClientBalance: UInt64 = 1_687_598 + private static let lspBalance: UInt64 = 252_368 + private static let networkFee: UInt64 = 2112 + private static let serviceFee: UInt64 = 286 + private static let lspFee: UInt64 = 2398 // networkFee + serviceFee + private func makeOrder(id: String, clientBalanceSat: UInt64, lspBalanceSat: UInt64) -> IBtOrder { IBtOrder( id: id, diff --git a/changelog.d/next/595.fixed.md b/changelog.d/next/595.fixed.md new file mode 100644 index 000000000..a89aeae45 --- /dev/null +++ b/changelog.d/next/595.fixed.md @@ -0,0 +1 @@ +Fixed Transfer to Spending showing a zero maximum when your on-chain balance exceeds the LSP's channel limit, and the displayed available balance now matches the amount you can actually transfer.