Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Bitkit/ViewModels/TransferViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand Down
30 changes: 22 additions & 8 deletions Bitkit/Views/Transfer/SpendingAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions Bitkit/Views/Transfer/SpendingConfirm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -142,7 +144,9 @@ struct SpendingConfirm: View {
speed: .fast,
txFee: transactionFee,
utxosToSpend: selectedUtxos,
satsPerVbyte: satsPerVbyte
satsPerVbyte: satsPerVbyte,
isMaxAmount: shouldUseSendAll,
maxSendableAmount: maxSendableAmount
)
await wallet.updateBalanceState()

Expand All @@ -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,
Expand All @@ -192,6 +226,7 @@ struct SpendingConfirm: View {
transactionFee = fee
selectedUtxos = utxos
satsPerVbyte = fastFeeRate
shouldUseSendAll = false
}
}
} catch {
Expand All @@ -200,6 +235,8 @@ struct SpendingConfirm: View {
transactionFee = 0
selectedUtxos = nil
satsPerVbyte = nil
maxSendableAmount = nil
shouldUseSendAll = false
}
}
}
Expand Down
41 changes: 35 additions & 6 deletions Bitkit/Views/Wallets/Send/SendConfirmationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}
Expand Down
Loading