diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 51d6f81d..6490ad36 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/synonymdev/bitkit-core", "state" : { "branch" : "master", - "revision" : "aa2ad84cd4ced707ede1ed8efe036c0ddb696241" + "revision" : "0e66950a564871a011dec99d5333e8ecbfdb543b" } }, { diff --git a/Bitkit/ViewModels/BlocktankViewModel.swift b/Bitkit/ViewModels/BlocktankViewModel.swift index 9f54a1ef..71531f0f 100644 --- a/Bitkit/ViewModels/BlocktankViewModel.swift +++ b/Bitkit/ViewModels/BlocktankViewModel.swift @@ -209,13 +209,12 @@ class BlocktankViewModel: ObservableObject { ) } + /// Calculates default LSP balance for CJIT channels using bitkit-core private func getDefaultLspBalance(clientBalance: UInt64) async throws -> UInt64 { if info == nil { try await refreshInfo() } - let maxLspBalance = info?.options.maxChannelSizeSat ?? 0 - // Get current rates guard let rates = currencyService.loadCachedRates(), let eurRate = currencyService.getCurrentRate(for: "EUR", from: rates) else { @@ -223,30 +222,14 @@ class BlocktankViewModel: ObservableObject { throw CustomServiceError.currencyRateUnavailable } - // Calculate thresholds in sats - let threshold1 = currencyService.convertFiatToSats(fiatValue: 225, rate: eurRate) - let threshold2 = currencyService.convertFiatToSats(fiatValue: 495, rate: eurRate) - let defaultLspBalance = currencyService.convertFiatToSats(fiatValue: 450, rate: eurRate) - - Logger.debug("getDefaultLspBalance - clientBalance: \(clientBalance)") - Logger.debug("getDefaultLspBalance - maxLspBalance: \(maxLspBalance)") - Logger.debug("getDefaultLspBalance - defaultLspBalance: \(defaultLspBalance)") - - // Safely calculate lspBalance to avoid arithmetic overflow - var lspBalance: UInt64 = 0 - if defaultLspBalance > clientBalance { - lspBalance = defaultLspBalance - clientBalance - } - - if clientBalance > threshold1 { - lspBalance = clientBalance - } - - if clientBalance > threshold2 { - lspBalance = maxLspBalance - } + let satsPerEur = currencyService.convertFiatToSats(fiatValue: 1, rate: eurRate) + let params = DefaultLspBalanceParams( + clientBalanceSat: clientBalance, + maxChannelSizeSat: info?.options.maxChannelSizeSat ?? 0, + satsPerEur: satsPerEur + ) - return min(lspBalance, maxLspBalance) + return BitkitCore.getDefaultLspBalance(params: params) } func refreshMinCjitSats() async throws { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 61bfc76e..5e9be557 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -294,91 +294,52 @@ class TransferViewModel: ObservableObject { selectedChannelIds = [] } - // MARK: - Balance Calculation Functions + // MARK: - Balance Calculation + + /// Calculates channel liquidity options using bitkit-core + func calculateTransferValues(clientBalanceSat: UInt64, blocktankInfo: IBtInfo?) -> TransferValues { + guard let blocktankInfo else { + return TransferValues() + } - func getDefaultLspBalance(clientBalanceSat: UInt64, maxLspBalance: UInt64) -> UInt64 { - // Get current rates guard let rates = currencyService.loadCachedRates(), let eurRate = currencyService.getCurrentRate(for: "EUR", from: rates) else { - Logger.error("Failed to get rates for getDefaultLspBalance", context: "TransferViewModel") - return 0 + Logger.error("Failed to get rates for calculateTransferValues", context: "TransferViewModel") + return TransferValues() } - // Calculate thresholds in sats - let threshold1 = currencyService.convertFiatToSats(fiatValue: 225, rate: eurRate) - let threshold2 = currencyService.convertFiatToSats(fiatValue: 495, rate: eurRate) - let defaultLspBalanceSats = currencyService.convertFiatToSats(fiatValue: 450, rate: eurRate) - - var lspBalance = Int64(defaultLspBalanceSats) - Int64(clientBalanceSat) - - // Ensure non-negative result - if lspBalance < 0 { - lspBalance = 0 - } + let satsPerEur = currencyService.convertFiatToSats(fiatValue: 1, rate: eurRate) + let existingChannelsTotalSat = totalBtChannelsValueSats(blocktankInfo: blocktankInfo) - if clientBalanceSat > threshold1 { - lspBalance = Int64(clientBalanceSat) - } + let params = ChannelLiquidityParams( + clientBalanceSat: clientBalanceSat, + existingChannelsTotalSat: existingChannelsTotalSat, + minChannelSizeSat: blocktankInfo.options.minChannelSizeSat, + maxChannelSizeSat: blocktankInfo.options.maxChannelSizeSat, + satsPerEur: satsPerEur + ) - if clientBalanceSat > threshold2 { - lspBalance = Int64(maxLspBalance) - } + let options = BitkitCore.calculateChannelLiquidityOptions(params: params) - return min(UInt64(lspBalance), maxLspBalance) + return TransferValues( + defaultLspBalance: options.defaultLspBalanceSat, + minLspBalance: options.minLspBalanceSat, + maxLspBalance: options.maxLspBalanceSat, + maxClientBalance: options.maxClientBalanceSat + ) } - func getMinLspBalance(clientBalance: UInt64, minChannelSize: UInt64) -> UInt64 { - // LSP balance must be at least 2.5% of the channel size for LDK to accept (reserve balance) - let ldkMinimum = UInt64(Double(clientBalance) * 0.025) - // Channel size must be at least minChannelSize - let lspMinimum = clientBalance < minChannelSize ? minChannelSize - clientBalance : 0 - - return max(ldkMinimum, lspMinimum) + func updateTransferValues(clientBalanceSat: UInt64, blocktankInfo: IBtInfo?) { + transferValues = calculateTransferValues(clientBalanceSat: clientBalanceSat, blocktankInfo: blocktankInfo) } + /// Calculates max client balance accounting for LDK reserve requirement func getMaxClientBalance(maxChannelSize: UInt64) -> UInt64 { - // Remote balance must be at least 2.5% of the channel size for LDK to accept (reserve balance) let minRemoteBalance = UInt64(Double(maxChannelSize) * 0.025) return maxChannelSize - minRemoteBalance } - func updateTransferValues(clientBalanceSat: UInt64, blocktankInfo: IBtInfo?) { - transferValues = calculateTransferValues(clientBalanceSat: clientBalanceSat, blocktankInfo: blocktankInfo) - } - - func calculateTransferValues(clientBalanceSat: UInt64, blocktankInfo: IBtInfo?) -> TransferValues { - guard let blocktankInfo else { - return TransferValues() - } - - // Calculate the total value of existing Blocktank channels - let channelsSize = totalBtChannelsValueSats(blocktankInfo: blocktankInfo) - - let minChannelSizeSat = UInt64(blocktankInfo.options.minChannelSizeSat) - let maxChannelSizeSat = UInt64(blocktankInfo.options.maxChannelSizeSat) - - // Because LSP limits constantly change depending on network fees - // Add a 2% buffer to avoid fluctuations while making the order - let maxChannelSize1 = UInt64(Double(maxChannelSizeSat) * 0.98) - - // The maximum channel size the user can open including existing channels - let maxChannelSize2 = maxChannelSize1 > channelsSize ? maxChannelSize1 - channelsSize : 0 - let maxChannelSize = min(maxChannelSize1, maxChannelSize2) - - let minLspBalance = getMinLspBalance(clientBalance: clientBalanceSat, minChannelSize: minChannelSizeSat) - let maxLspBalance = maxChannelSize > clientBalanceSat ? maxChannelSize - clientBalanceSat : 0 - let defaultLspBalance = getDefaultLspBalance(clientBalanceSat: clientBalanceSat, maxLspBalance: maxLspBalance) - let maxClientBalance = getMaxClientBalance(maxChannelSize: maxChannelSize) - - return TransferValues( - defaultLspBalance: defaultLspBalance, - minLspBalance: minLspBalance, - maxLspBalance: maxLspBalance, - maxClientBalance: maxClientBalance - ) - } - // MARK: - Manual Channel Opening /// Opens a manual channel and tracks the transfer diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 5e0bdbbe..cbb9c6c5 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -8,20 +8,21 @@ struct SpendingAdvancedView: View { @EnvironmentObject var blocktank: BlocktankViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var transfer: TransferViewModel - @EnvironmentObject var wallet: WalletViewModel @Environment(\.dismiss) var dismiss @StateObject private var amountViewModel = AmountInputViewModel() @State private var feeEstimate: UInt64? + @State private var isLoading = false + @State private var feeEstimateTask: Task? var lspBalance: UInt64 { amountViewModel.amountSats } private var isValid: Bool { - let isAboveMin = lspBalance >= transfer.transferValues.minLspBalance - let isBelowMax = lspBalance <= transfer.transferValues.maxLspBalance - return isAboveMin && isBelowMax + let values = transfer.transferValues + guard lspBalance > 0, values.maxLspBalance > 0 else { return false } + return lspBalance >= values.minLspBalance && lspBalance <= values.maxLspBalance } var body: some View { @@ -73,15 +74,17 @@ struct SpendingAdvancedView: View { CustomButton( title: t("common__continue"), - isDisabled: !isValid + isDisabled: !isValid, + isLoading: isLoading ) { + isLoading = true + defer { isLoading = false } + do { - // Create a new order with the specified receiving capacity let newOrder = try await blocktank.createOrder( clientBalance: order.clientBalanceSat, lspBalance: lspBalance ) - transfer.onAdvancedOrderCreated(order: newOrder) dismiss() } catch { @@ -102,7 +105,11 @@ struct SpendingAdvancedView: View { updateFeeEstimate() } .onChange(of: lspBalance) { _ in - updateFeeEstimate() + if isValid { + updateFeeEstimate() + } else { + feeEstimate = nil + } } } @@ -129,17 +136,22 @@ struct SpendingAdvancedView: View { private func updateFeeEstimate() { guard lspBalance > 0 else { return } - Task { + feeEstimateTask?.cancel() + feeEstimate = nil + + feeEstimateTask = Task { do { - feeEstimate = nil let estimate = try await blocktank.estimateOrderFee( clientBalance: order.clientBalanceSat, lspBalance: lspBalance ) - feeEstimate = estimate.feeSat + guard !Task.isCancelled else { return } + await MainActor.run { + feeEstimate = estimate.feeSat + } } catch { - feeEstimate = nil - Logger.error("Failed to estimate fee: \(error.localizedDescription)") + guard !Task.isCancelled else { return } + Logger.debug("Fee estimation failed: \(error.localizedDescription)") } } } @@ -150,7 +162,6 @@ struct SpendingAdvancedView: View { SpendingAdvancedView( order: IBtOrder.mock(lspBalanceSat: 100_000, clientBalanceSat: 50000) ) - .environmentObject(WalletViewModel()) .environmentObject(AppViewModel()) .environmentObject(CurrencyViewModel()) .environmentObject(BlocktankViewModel()) diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 7397a900..fc7581f6 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -10,20 +10,16 @@ struct SpendingAmount: View { @StateObject private var amountViewModel = AmountInputViewModel() @State private var isLoading = false - @State private var maxSendableAmount: UInt64? - @State private var maxTransferAmount: UInt64 = 0 + @State private var availableAmount: UInt64? + @State private var maxTransferAmount: UInt64? - var amountSats: UInt64 { + private var amountSats: UInt64 { amountViewModel.amountSats } - // Calculate the maximum amount that can be transferred - private var availableAmount: UInt64 { - return maxSendableAmount ?? UInt64(wallet.spendableOnchainBalanceSats) - } - - private var transferValues: TransferValues { - transfer.calculateTransferValues(clientBalanceSat: amountSats, blocktankInfo: blocktank.info) + private var isValidAmount: Bool { + guard let max = maxTransferAmount else { return false } + return amountSats <= max } var body: some View { @@ -43,7 +39,15 @@ struct SpendingAmount: View { Spacer() HStack(alignment: .bottom) { - AvailableAmount(label: t("wallet__send_available"), amount: Int(availableAmount)) + if let available = availableAmount { + AvailableAmount(label: t("wallet__send_available"), amount: Int(available)) + } else { + HStack(spacing: 4) { + CaptionMText(t("wallet__send_available")) + ProgressView() + .scaleEffect(0.7) + } + } Spacer() @@ -60,17 +64,19 @@ struct SpendingAmount: View { amountViewModel.handleNumberPadInput(key, currency: currency) } - CustomButton(title: t("common__continue"), isLoading: isLoading) { - Task { - await onContinue() - } + CustomButton( + title: t("common__continue"), + isDisabled: !isValidAmount, + isLoading: isLoading + ) { + await onContinue() } } .navigationBarHidden(true) .padding(.horizontal, 16) .bottomSafeAreaPadding() - .task { - await calculateMaxSendableAmount() + .task(id: blocktank.info?.options.maxChannelSizeSat) { + await calculateMaxTransferAmount() } } @@ -86,93 +92,86 @@ struct SpendingAmount: View { } NumberPadActionButton(text: t("lightning__spending_amount__quarter")) { + guard let max = maxTransferAmount else { return } let quarter = UInt64(wallet.spendableOnchainBalanceSats) / 4 - let amount = min(quarter, maxTransferAmount) - amountViewModel.updateFromSats(amount, currency: currency) + amountViewModel.updateFromSats(min(quarter, max), currency: currency) } NumberPadActionButton(text: t("common__max")) { - amountViewModel.updateFromSats(maxTransferAmount, currency: currency) + guard let max = maxTransferAmount else { return } + amountViewModel.updateFromSats(max, currency: currency) } } } private func onContinue() async { - // TODO: check that we have enough onchain balance to cover the fee, see react native code - isLoading = true + defer { isLoading = false } do { - let transferValues = transfer.calculateTransferValues(clientBalanceSat: amountSats, blocktankInfo: blocktank.info) - let lspBalance = max(transferValues.defaultLspBalance, transferValues.minLspBalance) + 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) - isLoading = false - transfer.onOrderCreated(order: order) navigation.navigate(.spendingConfirm(order: order)) } catch { app.toast(error) - isLoading = false } } - private func calculateMaxSendableAmount() async { + private func calculateMaxTransferAmount() async { + guard let info = blocktank.info else { + await MainActor.run { + availableAmount = 0 + maxTransferAmount = 0 + } + return + } + let coreService = CoreService.shared let lightningService = LightningService.shared do { let address = try await lightningService.newAddress() - if let feeRates = try await coreService.blocktank.fees(refresh: true) { - let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeRates) - - let maxAmount = try await wallet.calculateMaxSendableAmount( - address: address, - satsPerVByte: fastFeeRate - ) - + guard let feeRates = try await coreService.blocktank.fees(refresh: true) else { await MainActor.run { - maxSendableAmount = maxAmount + let balance = UInt64(wallet.spendableOnchainBalanceSats) + availableAmount = balance + let values = transfer.calculateTransferValues(clientBalanceSat: balance, blocktankInfo: info) + maxTransferAmount = min(values.maxClientBalance, balance) } - - // Now calculate the max transfer amount using blocktank.estimateOrderFee - await calculateMaxTransferAmount(availableAmount: maxAmount) + return } - } catch { - Logger.error("Failed to calculate max sendable amount: \(error)") - await MainActor.run { - // Fall back to total balance if calculation fails - maxSendableAmount = UInt64(wallet.spendableOnchainBalanceSats) - maxTransferAmount = 0 - } - } - } + let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeRates) - private func calculateMaxTransferAmount(availableAmount: UInt64) async { - do { - let transferValues = transfer.calculateTransferValues(clientBalanceSat: availableAmount, blocktankInfo: blocktank.info) + let calculatedAvailableAmount = try await wallet.calculateMaxSendableAmount( + address: address, + satsPerVByte: fastFeeRate + ) + + let values = transfer.calculateTransferValues(clientBalanceSat: calculatedAvailableAmount, blocktankInfo: info) let feeEstimate = try await blocktank.estimateOrderFee( - clientBalance: availableAmount, - lspBalance: transferValues.maxLspBalance + clientBalance: calculatedAvailableAmount, + lspBalance: values.maxLspBalance ) - let feeMaximum = UInt64(max(0, Int64(availableAmount - feeEstimate.feeSat))) - - // Maximum is the minimum of max client balance and fee maximum - let result = min(transferValues.maxClientBalance, feeMaximum) + let feeMaximum = UInt64(max(0, Int64(calculatedAvailableAmount) - Int64(feeEstimate.feeSat))) + let result = min(values.maxClientBalance, feeMaximum) await MainActor.run { + availableAmount = calculatedAvailableAmount maxTransferAmount = result } - } catch { Logger.error("Failed to calculate max transfer amount: \(error)") await MainActor.run { - // Fall back to a simplified calculation - let transferValues = transfer.calculateTransferValues(clientBalanceSat: availableAmount, blocktankInfo: blocktank.info) - maxTransferAmount = min(transferValues.maxClientBalance, availableAmount) + let balance = UInt64(wallet.spendableOnchainBalanceSats) + availableAmount = balance + let values = transfer.calculateTransferValues(clientBalanceSat: balance, blocktankInfo: info) + maxTransferAmount = min(values.maxClientBalance, balance) } } }