Skip to content

Commit e1a50ba

Browse files
committed
Merge branch 'master' into feat/trezor-hidden-wallet-and-watcher
2 parents b7ac927 + d452354 commit e1a50ba

8 files changed

Lines changed: 226 additions & 59 deletions

File tree

Bitkit/Components/NumberPad.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ struct NumberPad: View {
1010
let type: NumberPadType
1111
let decimalSeparator: String
1212
let errorKey: String?
13+
let isDisabled: Bool
1314
let onDeleteLongPress: (() -> Void)?
1415
let onPress: (String) -> Void
1516

@@ -25,12 +26,14 @@ struct NumberPad: View {
2526
type: NumberPadType = .simple,
2627
decimalSeparator: String = ".",
2728
errorKey: String? = nil,
29+
isDisabled: Bool = false,
2830
onDeleteLongPress: (() -> Void)? = nil,
2931
onPress: @escaping (String) -> Void
3032
) {
3133
self.type = type
3234
self.decimalSeparator = decimalSeparator
3335
self.errorKey = errorKey
36+
self.isDisabled = isDisabled
3437
self.onDeleteLongPress = onDeleteLongPress
3538
self.onPress = onPress
3639
}
@@ -127,6 +130,8 @@ struct NumberPad: View {
127130
)
128131
}
129132
}
133+
.opacity(isDisabled ? 0.5 : 1)
134+
.disabled(isDisabled)
130135
}
131136
}
132137

Bitkit/ViewModels/TransferViewModel.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,45 @@ class TransferViewModel: ObservableObject {
360360
transferValues = calculateTransferValues(clientBalanceSat: clientBalanceSat, blocktankInfo: blocktankInfo)
361361
}
362362

363+
/// Calculates the max amount transferable to spending and the value to display as "Available".
364+
///
365+
/// The prospective client balance is clamped to the LSP's `maxClientBalanceSat` before
366+
/// computing liquidity options: an on-chain balance larger than the LSP's max channel size
367+
/// otherwise makes the liquidity calculation report `maxClientBalanceSat = 0` (the balance
368+
/// already saturates the channel), collapsing the spendable amount to zero and stranding the
369+
/// funds on-chain.
370+
///
371+
/// - `transferValues`: liquidity options for a given client balance (prod: `calculateTransferValues`)
372+
/// - `estimateOrderFee`: Blocktank order fee for a given client/LSP balance
373+
func calculateSpendingLimits(
374+
onchainAvailable: UInt64,
375+
lspMaxClientBalance: UInt64?,
376+
transferValues: (_ clientBalance: UInt64) -> TransferValues,
377+
estimateOrderFee: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> (networkFeeSat: UInt64, serviceFeeSat: UInt64)
378+
) async rethrows -> (available: UInt64, max: UInt64) {
379+
// First pass: estimate the LSP fee against the full on-chain balance.
380+
let values1 = transferValues(onchainAvailable)
381+
let lspBalance1 = max(values1.defaultLspBalance, values1.minLspBalance)
382+
let fee1 = try await estimateOrderFee(onchainAvailable, lspBalance1)
383+
let initialFees = fee1.networkFeeSat + fee1.serviceFeeSat
384+
let balanceAfterLspFee = onchainAvailable > initialFees ? onchainAvailable - initialFees : 0
385+
386+
let cappedClientBalance: UInt64 = {
387+
guard let cap = lspMaxClientBalance, cap > 0 else { return balanceAfterLspFee }
388+
return min(balanceAfterLspFee, cap)
389+
}()
390+
391+
// Second pass with the clamped balance.
392+
let values2 = transferValues(cappedClientBalance)
393+
guard values2.maxClientBalance > 0 else { return (0, 0) }
394+
let lspBalance2 = max(values2.defaultLspBalance, values2.minLspBalance)
395+
let fee2 = try await estimateOrderFee(cappedClientBalance, lspBalance2)
396+
let finalFees = fee2.networkFeeSat + fee2.serviceFeeSat
397+
let afterFee = onchainAvailable > finalFees ? onchainAvailable - finalFees : 0
398+
let result = min(values2.maxClientBalance, afterFee)
399+
return (result, result)
400+
}
401+
363402
/// Calculates max client balance accounting for LDK reserve requirement
364403
func getMaxClientBalance(maxChannelSize: UInt64) -> UInt64 {
365404
let minRemoteBalance = UInt64(Double(maxChannelSize) * 0.025)

Bitkit/Views/Transfer/SpendingAdvancedView.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,23 @@ struct SpendingAdvancedView: View {
117117
}
118118
}
119119
.onChange(of: transfer.transferValues.maxLspBalance, initial: true) { updateInputCap() }
120-
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
120+
.onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() }
121121
}
122122

123123
private func updateInputCap() {
124124
let maxLspBalance = transfer.transferValues.maxLspBalance
125125
amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil
126126
}
127127

128+
private func onMaxExceeded() {
129+
// Snap the input to the max so the user lands on the highest allowed amount.
130+
let maxLspBalance = transfer.transferValues.maxLspBalance
131+
if maxLspBalance > 0 {
132+
amountViewModel.updateFromSats(maxLspBalance, currency: currency)
133+
}
134+
showMaxExceededToast()
135+
}
136+
128137
private func showMaxExceededToast() {
129138
app.toast(
130139
type: .warning,

Bitkit/Views/Transfer/SpendingAmount.swift

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import BitkitCore
12
import LDKNode
23
import SwiftUI
34

@@ -12,13 +13,31 @@ struct SpendingAmount: View {
1213

1314
@State private var amountViewModel = AmountInputViewModel()
1415
@State private var isLoading = false
16+
@State private var isCalculatingMax = true
1517
@State private var availableAmount: UInt64?
1618
@State private var maxTransferAmount: UInt64?
1719

1820
private var amountSats: UInt64 {
1921
amountViewModel.amountSats
2022
}
2123

24+
/// Inputs the max calculation depends on. A single `.task(id:)` keyed on this restarts the
25+
/// calculation (with structured cancellation) whenever either input changes, so concurrent
26+
/// reloads can neither overlap nor leak the loading flag.
27+
private struct MaxCalcInputs: Equatable {
28+
let maxChannelSizeSat: UInt64?
29+
let maxClientBalanceSat: UInt64?
30+
let spendableOnchainBalanceSats: Int
31+
}
32+
33+
private var maxCalcInputs: MaxCalcInputs {
34+
MaxCalcInputs(
35+
maxChannelSizeSat: blocktank.info?.options.maxChannelSizeSat,
36+
maxClientBalanceSat: blocktank.info?.options.maxClientBalanceSat,
37+
spendableOnchainBalanceSats: wallet.spendableOnchainBalanceSats
38+
)
39+
}
40+
2241
private var isValidAmount: Bool {
2342
guard let max = maxTransferAmount else { return false }
2443
return amountSats <= max
@@ -61,7 +80,8 @@ struct SpendingAmount: View {
6180

6281
NumberPad(
6382
type: amountViewModel.getNumberPadType(currency: currency),
64-
errorKey: amountViewModel.errorKey
83+
errorKey: amountViewModel.errorKey,
84+
isDisabled: isCalculatingMax
6585
) { key in
6686
amountViewModel.handleNumberPadInput(key, currency: currency)
6787
}
@@ -79,22 +99,28 @@ struct SpendingAmount: View {
7999
.padding(.horizontal, 16)
80100
.bottomSafeAreaPadding()
81101
.offlineOverlay(title: t("lightning__transfer__nav_title"))
82-
.task(id: blocktank.info?.options.maxChannelSizeSat) {
102+
.task(id: maxCalcInputs) {
103+
await MainActor.run { isCalculatingMax = true }
83104
await calculateMaxTransferAmount()
84-
}
85-
.onChange(of: wallet.spendableOnchainBalanceSats) {
86-
Task {
87-
await calculateMaxTransferAmount()
105+
if !Task.isCancelled {
106+
await MainActor.run { isCalculatingMax = false }
88107
}
89108
}
90109
.onChange(of: maxTransferAmount) { updateInputCap() }
91-
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
110+
.onChange(of: amountViewModel.maxExceededCount) { onMaxExceeded() }
92111
}
93112

94113
private func updateInputCap() {
95114
amountViewModel.maxAmountOverride = (maxTransferAmount ?? 0) > 0 ? maxTransferAmount : nil
96115
}
97116

117+
private func onMaxExceeded() {
118+
if let max = maxTransferAmount {
119+
amountViewModel.updateFromSats(max, currency: currency)
120+
}
121+
showMaxExceededToast()
122+
}
123+
98124
private func showMaxExceededToast() {
99125
app.toast(
100126
type: .warning,
@@ -178,10 +204,9 @@ struct SpendingAmount: View {
178204

179205
guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else {
180206
await MainActor.run {
181-
let balance = UInt64(wallet.spendableOnchainBalanceSats)
182-
availableAmount = balance
183-
let values = transfer.calculateTransferValues(clientBalanceSat: balance, blocktankInfo: info)
184-
maxTransferAmount = min(values.maxClientBalance, balance)
207+
let fallback = fallbackMaxTransferAmount(info: info)
208+
availableAmount = fallback
209+
maxTransferAmount = fallback
185210
}
186211
return
187212
}
@@ -193,40 +218,37 @@ struct SpendingAmount: View {
193218
satsPerVByte: fastFeeRate
194219
)
195220

196-
// First pass: estimate with calculatedAvailableAmount to get approximate clientBalance
197-
let values1 = transfer.calculateTransferValues(clientBalanceSat: calculatedAvailableAmount, blocktankInfo: info)
198-
let lspBalance1 = max(values1.defaultLspBalance, values1.minLspBalance)
199-
let feeEstimate1 = try await blocktank.estimateOrderFee(
200-
clientBalance: calculatedAvailableAmount,
201-
lspBalance: lspBalance1
202-
)
203-
let lspFees1 = feeEstimate1.networkFeeSat + feeEstimate1.serviceFeeSat
204-
let approxClientBalance = UInt64(max(0, Int64(calculatedAvailableAmount) - Int64(lspFees1)))
205-
206-
// Second pass: recalculate lspBalance with actual clientBalance (same as onContinue will use)
207-
// This ensures fee estimation matches the actual order creation
208-
let values2 = transfer.calculateTransferValues(clientBalanceSat: approxClientBalance, blocktankInfo: info)
209-
let lspBalance2 = max(values2.defaultLspBalance, values2.minLspBalance)
210-
let feeEstimate2 = try await blocktank.estimateOrderFee(
211-
clientBalance: approxClientBalance,
212-
lspBalance: lspBalance2
221+
let (available, maxAmount) = try await transfer.calculateSpendingLimits(
222+
onchainAvailable: calculatedAvailableAmount,
223+
lspMaxClientBalance: info.options.maxClientBalanceSat,
224+
transferValues: { transfer.calculateTransferValues(clientBalanceSat: $0, blocktankInfo: info) },
225+
estimateOrderFee: { clientBalance, lspBalance in
226+
let estimate = try await blocktank.estimateOrderFee(clientBalance: clientBalance, lspBalance: lspBalance)
227+
return (estimate.networkFeeSat, estimate.serviceFeeSat)
228+
}
213229
)
214-
let lspFees = feeEstimate2.networkFeeSat + feeEstimate2.serviceFeeSat
215-
let maxClientBalance = UInt64(max(0, Int64(calculatedAvailableAmount) - Int64(lspFees)))
216-
let result = min(values2.maxClientBalance, maxClientBalance)
217230

218231
await MainActor.run {
219-
availableAmount = calculatedAvailableAmount
220-
maxTransferAmount = result
232+
availableAmount = available
233+
maxTransferAmount = maxAmount
221234
}
222235
} catch {
223236
Logger.error("Failed to calculate max transfer amount: \(error)")
224237
await MainActor.run {
225-
let balance = UInt64(wallet.spendableOnchainBalanceSats)
226-
availableAmount = balance
227-
let values = transfer.calculateTransferValues(clientBalanceSat: balance, blocktankInfo: info)
228-
maxTransferAmount = min(values.maxClientBalance, balance)
238+
let fallback = fallbackMaxTransferAmount(info: info)
239+
availableAmount = fallback
240+
maxTransferAmount = fallback
229241
}
230242
}
231243
}
244+
245+
/// Fallback max when fee estimates are unavailable: clamp the client balance to the LSP's max
246+
/// client balance so the liquidity calculation doesn't collapse to zero on a saturating balance.
247+
private func fallbackMaxTransferAmount(info: IBtInfo) -> UInt64 {
248+
let balance = UInt64(wallet.spendableOnchainBalanceSats)
249+
let lspMaxClientBalance = info.options.maxClientBalanceSat
250+
let clientBalance = lspMaxClientBalance > 0 ? min(balance, lspMaxClientBalance) : balance
251+
let values = transfer.calculateTransferValues(clientBalanceSat: clientBalance, blocktankInfo: info)
252+
return min(values.maxClientBalance, balance)
253+
}
232254
}

Bitkit/Views/Wallets/Activity/ActivityItemView.swift

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -566,33 +566,37 @@ struct ActivityItemView: View {
566566
}
567567
.accessibilityIdentifier(boostButtonIdentifier)
568568

569-
if isTransfer, let channelId = transferChannelId {
570-
CustomButton(
571-
title: t("lightning__connection"), size: .small,
572-
icon: Image("bolt-hollow")
573-
.foregroundColor(accentColor),
574-
shouldExpand: true
575-
) {
576-
navigation.navigate(.connectionDetail(channelId: channelId))
577-
}
578-
.accessibilityIdentifier("ChannelButton")
579-
} else {
580-
CustomButton(
581-
title: t("wallet__activity_explore"), size: .small,
582-
icon: Image("branch")
583-
.foregroundColor(accentColor),
584-
shouldExpand: true
585-
) {
586-
navigation.navigate(.activityExplorer(viewModel.activity))
587-
}
588-
.accessibilityIdentifier("ActivityTxDetails")
589-
}
569+
exploreButton
590570
}
591571
.frame(maxWidth: .infinity)
572+
573+
if isTransfer, let channelId = transferChannelId {
574+
CustomButton(
575+
title: t("lightning__connection"), size: .small,
576+
icon: Image("bolt-hollow")
577+
.foregroundColor(accentColor),
578+
shouldExpand: true
579+
) {
580+
navigation.navigate(.connectionDetail(channelId: channelId))
581+
}
582+
.accessibilityIdentifier("ChannelButton")
583+
}
592584
}
593585
.frame(maxWidth: .infinity)
594586
}
595587

588+
private var exploreButton: some View {
589+
CustomButton(
590+
title: t("wallet__activity_explore"), size: .small,
591+
icon: Image("branch")
592+
.foregroundColor(accentColor),
593+
shouldExpand: true
594+
) {
595+
navigation.navigate(.activityExplorer(viewModel.activity))
596+
}
597+
.accessibilityIdentifier("ActivityTxDetails")
598+
}
599+
596600
private func detachContact() async {
597601
do {
598602
try await activityList.setContact(nil, forPaymentId: viewModel.activityId)

0 commit comments

Comments
 (0)