diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 611dafe6e..a9426149d 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -126,7 +126,9 @@ class TransferViewModel: ObservableObject { speed: TransactionSpeed, txFee: UInt64, utxosToSpend: [SpendableUtxo]? = nil, - satsPerVbyte: UInt32? = nil + satsPerVbyte: UInt32? = nil, + isMaxAmount: Bool = false, + maxSendableAmount: UInt64? = nil ) async throws { let rate: UInt32 if let satsPerVbyte { @@ -149,8 +151,23 @@ class TransferViewModel: ObservableObject { let preTransferOnchainSats = lightningService.balances?.totalOnchainBalanceSats ?? 0 - // Pass pre-selected UTXOs to ensure same fee as calculated - let txid = try await lightningService.send(address: address, sats: order.feeSat, satsPerVbyte: rate, utxosToSpend: utxosToSpend) + // Verify we can afford the transfer when using sendAll + if isMaxAmount, let maxSendable = maxSendableAmount, maxSendable < order.feeSat { + throw AppError( + message: t("other__pay_insufficient_savings"), + debugMessage: "Fee rate changed. Max sendable: \(maxSendable), order requires: \(order.feeSat)" + ) + } + + // For sendAll (change would be dust), send entire balance + // Otherwise, send exact order.feeSat amount + let txid = try await lightningService.send( + address: address, + sats: order.feeSat, + satsPerVbyte: rate, + utxosToSpend: utxosToSpend, + isMaxAmount: isMaxAmount + ) let txTotalSats = order.feeSat + txFee diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 4a350fee0..5ba46af88 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -132,7 +132,8 @@ struct SpendingAmount: View { transfer.onOrderCreated(order: order) navigation.navigate(.spendingConfirm(order: order)) } catch { - app.toast(error) + let appError = AppError(error: error) + app.toast(type: .error, title: appError.message, description: appError.debugMessage) } } @@ -162,20 +163,33 @@ struct SpendingAmount: View { } let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeRates) + // Calculate max sendable amount (balance minus transaction fee) let calculatedAvailableAmount = try await wallet.calculateMaxSendableAmount( address: address, satsPerVByte: fastFeeRate ) - let values = transfer.calculateTransferValues(clientBalanceSat: calculatedAvailableAmount, blocktankInfo: info) - - let feeEstimate = try await blocktank.estimateOrderFee( + // 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: values.maxLspBalance + lspBalance: lspBalance1 ) - - let feeMaximum = UInt64(max(0, Int64(calculatedAvailableAmount) - Int64(feeEstimate.feeSat))) - let result = min(values.maxClientBalance, feeMaximum) + 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 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 diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index bb47f9489..77d8c1591 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -16,6 +16,8 @@ struct SpendingConfirm: View { @State private var transactionFee: UInt64 = 0 @State private var selectedUtxos: [SpendableUtxo]? @State private var satsPerVbyte: UInt32? + @State private var maxSendableAmount: UInt64? + @State private var shouldUseSendAll = false private var currentOrder: IBtOrder { transfer.displayOrder(for: order) @@ -142,7 +144,9 @@ struct SpendingConfirm: View { speed: .fast, txFee: transactionFee, utxosToSpend: selectedUtxos, - satsPerVbyte: satsPerVbyte + satsPerVbyte: satsPerVbyte, + isMaxAmount: shouldUseSendAll, + maxSendableAmount: maxSendableAmount ) await wallet.updateBalanceState() @@ -166,14 +170,44 @@ struct SpendingConfirm: View { let coreService = CoreService.shared let lightningService = LightningService.shared - if let feeRates = try await coreService.blocktank.fees(refresh: true) { - let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeRates) + guard let feeRates = try await coreService.blocktank.fees(refresh: true) else { + Logger.error("SpendingConfirm: feeRates is nil") + return + } - guard let address = currentOrder.payment?.onchain?.address else { - throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) - } + let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeRates) + + guard let address = currentOrder.payment?.onchain?.address else { + throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) + } + + // Calculate sendAll fee to check if change would be dust + let allUtxos = try await lightningService.listSpendableOutputs() + let balance = UInt64(wallet.spendableOnchainBalanceSats) + let sendAllFee = try await wallet.calculateTotalFee( + address: address, + amountSats: balance, + satsPerVByte: fastFeeRate, + utxosToSpend: allUtxos + ) + let maxSendable = balance >= sendAllFee ? balance - sendAllFee : 0 + + // Check if change would be dust (use sendAll in that case) + // This also covers the "max" case where expectedChange = 0 + let expectedChange = Int64(balance) - Int64(currentOrder.feeSat) - Int64(sendAllFee) + let useSendAll = expectedChange >= 0 && expectedChange < Int64(Env.dustLimit) - // Pre-select UTXOs to ensure same fee is used in send + if useSendAll { + // Use sendAll: change would be dust or zero (max case) + await MainActor.run { + transactionFee = sendAllFee + selectedUtxos = allUtxos + satsPerVbyte = fastFeeRate + maxSendableAmount = maxSendable + shouldUseSendAll = true + } + } else { + // Normal send with change output let utxos = try await lightningService.selectUtxosWithAlgorithm( targetAmountSats: currentOrder.feeSat, satsPerVbyte: fastFeeRate, @@ -192,6 +226,7 @@ struct SpendingConfirm: View { transactionFee = fee selectedUtxos = utxos satsPerVbyte = fastFeeRate + shouldUseSendAll = false } } } catch { @@ -200,6 +235,8 @@ struct SpendingConfirm: View { transactionFee = 0 selectedUtxos = nil satsPerVbyte = nil + maxSendableAmount = nil + shouldUseSendAll = false } } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 7645a14a1..47a79923e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -17,6 +17,7 @@ struct SendConfirmationView: View { @State private var biometricErrorMessage = "" @State private var transactionFee: Int = 0 @State private var routingFee: Int = 0 + @State private var shouldUseSendAll: Bool = false // Warning system private enum WarningType: String, CaseIterable { @@ -231,7 +232,9 @@ struct SendConfirmationView: View { navigationPath.append(.success(paymentHash)) } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis - let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: wallet.isMaxAmountSend) + // Use sendAll if explicitly MAX or if change would be dust + let useMaxAmount = wallet.isMaxAmountSend || shouldUseSendAll + let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) // Create pre-activity metadata for tags and activity address await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) @@ -565,20 +568,46 @@ struct SendConfirmationView: View { } do { - let fee = try await wallet.calculateTotalFee( + let lightningService = LightningService.shared + let spendableBalance = UInt64(wallet.spendableOnchainBalanceSats) + + // Calculate fee for sendAll to check if change would be dust + let allUtxos = try await lightningService.listSpendableOutputs() + let sendAllFee = try await wallet.calculateTotalFee( address: address, - amountSats: amountSats, + amountSats: spendableBalance, satsPerVByte: feeRate, - utxosToSpend: wallet.selectedUtxos + utxosToSpend: allUtxos ) - await MainActor.run { - transactionFee = Int(fee) + let expectedChange = Int64(spendableBalance) - Int64(amountSats) - Int64(sendAllFee) + let useSendAll = expectedChange >= 0 && expectedChange < Int64(Env.dustLimit) + + if useSendAll { + // Change would be dust - use sendAll and add dust to fee + await MainActor.run { + transactionFee = Int(sendAllFee) + shouldUseSendAll = true + } + } else { + // Normal send with change output + let fee = try await wallet.calculateTotalFee( + address: address, + amountSats: amountSats, + satsPerVByte: feeRate, + utxosToSpend: wallet.selectedUtxos + ) + + await MainActor.run { + transactionFee = Int(fee) + shouldUseSendAll = false + } } } catch { Logger.error("Failed to calculate actual fee: \(error)") await MainActor.run { transactionFee = 0 + shouldUseSendAll = false } } }