From 492f0f54b532ab3350b4c629e4456f8ae95dfe14 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Feb 2026 18:35:50 +0700 Subject: [PATCH 1/2] fix: use sendAll when change would be dust instead of creating dust change output Use normal fee (recipient + change) instead of sendAll fee when checking if change would be dust. The sendAll fee was for a 1-output tx, causing expectedChange to be overestimated and dust change outputs to be created when sending almost the max amount. --- Bitkit/Views/Transfer/SpendingConfirm.swift | 45 +++++++++---------- .../Wallets/Send/SendConfirmationView.swift | 29 ++++++------ 2 files changed, 36 insertions(+), 38 deletions(-) diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 77d8c1591..05fbf422e 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -181,49 +181,48 @@ struct SpendingConfirm: View { 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( + let allUtxos = try await lightningService.listSpendableOutputs() + + // Fee for normal send (recipient + change) - used to check if change would be dust + let utxos = try await lightningService.selectUtxosWithAlgorithm( + targetAmountSats: currentOrder.feeSat, + satsPerVbyte: fastFeeRate, + coinSelectionAlgorythm: .largestFirst, + utxos: nil + ) + let normalFee = try await wallet.calculateTotalFee( address: address, - amountSats: balance, + amountSats: currentOrder.feeSat, satsPerVByte: fastFeeRate, - utxosToSpend: allUtxos + utxosToSpend: utxos ) - let maxSendable = balance >= sendAllFee ? balance - sendAllFee : 0 + let maxSendable = balance >= normalFee ? balance - normalFee : 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 expectedChange = Int64(balance) - Int64(currentOrder.feeSat) - Int64(normalFee) let useSendAll = expectedChange >= 0 && expectedChange < Int64(Env.dustLimit) if useSendAll { // Use sendAll: change would be dust or zero (max case) + let sendAllFee = try await wallet.calculateTotalFee( + address: address, + amountSats: balance, + satsPerVByte: fastFeeRate, + utxosToSpend: allUtxos + ) await MainActor.run { transactionFee = sendAllFee selectedUtxos = allUtxos satsPerVbyte = fastFeeRate - maxSendableAmount = maxSendable + maxSendableAmount = balance >= sendAllFee ? balance - sendAllFee : 0 shouldUseSendAll = true } } else { // Normal send with change output - let utxos = try await lightningService.selectUtxosWithAlgorithm( - targetAmountSats: currentOrder.feeSat, - satsPerVbyte: fastFeeRate, - coinSelectionAlgorythm: .largestFirst, - utxos: nil - ) - - let fee = try await wallet.calculateTotalFee( - address: address, - amountSats: currentOrder.feeSat, - satsPerVByte: fastFeeRate, - utxosToSpend: utxos - ) - await MainActor.run { - transactionFee = fee + transactionFee = normalFee selectedUtxos = utxos satsPerVbyte = fastFeeRate shouldUseSendAll = false diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9406e60fb..8928003a0 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -577,35 +577,34 @@ struct SendConfirmationView: View { 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( + // Fee for normal send (recipient + change outputs) - used to check if change would be dust + let normalFee = try await wallet.calculateTotalFee( address: address, - amountSats: spendableBalance, + amountSats: amountSats, satsPerVByte: feeRate, - utxosToSpend: allUtxos + utxosToSpend: wallet.selectedUtxos ) - - let expectedChange = Int64(spendableBalance) - Int64(amountSats) - Int64(sendAllFee) + let totalInput = wallet.selectedUtxos?.reduce(0) { $0 + $1.valueSats } ?? spendableBalance + let expectedChange = Int64(totalInput) - Int64(amountSats) - Int64(normalFee) let useSendAll = expectedChange >= 0 && expectedChange < Int64(Env.dustLimit) if useSendAll { // Change would be dust - use sendAll and add dust to fee + let allUtxos = try await lightningService.listSpendableOutputs() + let sendAllFee = try await wallet.calculateTotalFee( + address: address, + amountSats: spendableBalance, + satsPerVByte: feeRate, + utxosToSpend: allUtxos + ) 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) + transactionFee = Int(normalFee) shouldUseSendAll = false } } From ca0510791af039b043ec6d35863d532f2cabcec7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 19 Feb 2026 18:55:08 +0700 Subject: [PATCH 2/2] Add DustChangeHelper and use for sendAll when change would be dust Co-authored-by: Cursor --- Bitkit/Utilities/DustChangeHelper.swift | 21 +++++ Bitkit/Views/Transfer/SpendingConfirm.swift | 7 +- .../Wallets/Send/SendConfirmationView.swift | 7 +- BitkitTests/DustChangeHelperTests.swift | 93 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 Bitkit/Utilities/DustChangeHelper.swift create mode 100644 BitkitTests/DustChangeHelperTests.swift diff --git a/Bitkit/Utilities/DustChangeHelper.swift b/Bitkit/Utilities/DustChangeHelper.swift new file mode 100644 index 000000000..3deff2277 --- /dev/null +++ b/Bitkit/Utilities/DustChangeHelper.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Helper for determining when to use sendAll to avoid creating dust change outputs. +enum DustChangeHelper { + /// Returns true if the expected change would be dust (below dust limit), so sendAll should be used. + /// - Parameters: + /// - totalInput: Total sats from selected UTXOs (or spendable balance) + /// - amountSats: Amount to send to recipient + /// - normalFee: Fee for a normal send (recipient + change outputs) + /// - dustLimit: Minimum non-dust amount (default: Env.dustLimit) + /// - Returns: true when change would be dust and sendAll should be used + static func shouldUseSendAllToAvoidDust( + totalInput: UInt64, + amountSats: UInt64, + normalFee: UInt64, + dustLimit: UInt64 = UInt64(Env.dustLimit) + ) -> Bool { + let expectedChange = Int64(totalInput) - Int64(amountSats) - Int64(normalFee) + return expectedChange >= 0 && expectedChange < Int64(dustLimit) + } +} diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 05fbf422e..4e6bb9496 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -201,8 +201,11 @@ struct SpendingConfirm: View { // 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(normalFee) - let useSendAll = expectedChange >= 0 && expectedChange < Int64(Env.dustLimit) + let useSendAll = DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: balance, + amountSats: currentOrder.feeSat, + normalFee: normalFee + ) if useSendAll { // Use sendAll: change would be dust or zero (max case) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 8928003a0..82e8cb4e2 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -585,8 +585,11 @@ struct SendConfirmationView: View { utxosToSpend: wallet.selectedUtxos ) let totalInput = wallet.selectedUtxos?.reduce(0) { $0 + $1.valueSats } ?? spendableBalance - let expectedChange = Int64(totalInput) - Int64(amountSats) - Int64(normalFee) - let useSendAll = expectedChange >= 0 && expectedChange < Int64(Env.dustLimit) + let useSendAll = DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: totalInput, + amountSats: amountSats, + normalFee: normalFee + ) if useSendAll { // Change would be dust - use sendAll and add dust to fee diff --git a/BitkitTests/DustChangeHelperTests.swift b/BitkitTests/DustChangeHelperTests.swift new file mode 100644 index 000000000..aee641dd9 --- /dev/null +++ b/BitkitTests/DustChangeHelperTests.swift @@ -0,0 +1,93 @@ +@testable import Bitkit +import XCTest + +final class DustChangeHelperTests: XCTestCase { + private let dustLimit: UInt64 = 547 + + // MARK: - Change would be dust -> use sendAll + + func testChangeBelowDustLimit_ShouldUseSendAll() { + // totalInput: 100_000, amount: 99_500, fee: 500 -> change = 0 + XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 99500, + normalFee: 500, + dustLimit: dustLimit + )) + + // totalInput: 100_000, amount: 99_000, fee: 500 -> change = 500 (below 547) + XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 99000, + normalFee: 500, + dustLimit: dustLimit + )) + + // totalInput: 100_000, amount: 98_954, fee: 500 -> change = 546 (just below dust) + XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 98954, + normalFee: 500, + dustLimit: dustLimit + )) + } + + func testChangeAtDustLimit_ShouldNotUseSendAll() { + // change = 547 is at the limit; < 547 means dust. So 547 is NOT dust. + // totalInput: 100_000, amount: 98_953, fee: 500 -> change = 547 (at limit, NOT dust) + XCTAssertFalse(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 98953, + normalFee: 500, + dustLimit: dustLimit + )) + } + + func testChangeAboveDustLimit_ShouldNotUseSendAll() { + // totalInput: 100_000, amount: 98_000, fee: 500 -> change = 1_500 + XCTAssertFalse(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 98000, + normalFee: 500, + dustLimit: dustLimit + )) + + // totalInput: 100_000, amount: 98_954, fee: 495 -> change = 551 + XCTAssertFalse(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 98954, + normalFee: 495, + dustLimit: dustLimit + )) + } + + func testMaxSend_ChangeZero_ShouldUseSendAll() { + // totalInput: 100_000, amount: 99_500, fee: 500 -> change = 0 (max case) + XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 99500, + normalFee: 500, + dustLimit: dustLimit + )) + } + + func testInsufficientFunds_NegativeChange_ShouldNotUseSendAll() { + // totalInput: 100_000, amount: 100_000, fee: 500 -> change = -500 + XCTAssertFalse(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 100_000, + normalFee: 500, + dustLimit: dustLimit + )) + } + + func testUsesEnvDustLimit_WhenNotSpecified() { + // Verify default uses Env.dustLimit (547): change 546 is dust + XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust( + totalInput: 100_000, + amountSats: 99454, + normalFee: 0 + // dustLimit omitted -> uses Env.dustLimit + )) + } +}