Skip to content

Commit 6aeb1a8

Browse files
authored
Merge branch 'master' into codex-paykit-public-endpoints-pr527
2 parents 2881537 + f96221e commit 6aeb1a8

7 files changed

Lines changed: 103 additions & 45 deletions

File tree

Bitkit.xcodeproj/project.pbxproj

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@
509509
buildSettings = {
510510
CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements;
511511
CODE_SIGN_STYLE = Automatic;
512-
CURRENT_PROJECT_VERSION = 186;
512+
CURRENT_PROJECT_VERSION = 187;
513513
DEVELOPMENT_TEAM = KYH47R284B;
514514
GENERATE_INFOPLIST_FILE = YES;
515515
INFOPLIST_FILE = BitkitNotification/Info.plist;
@@ -521,7 +521,7 @@
521521
"@executable_path/Frameworks",
522522
"@executable_path/../../Frameworks",
523523
);
524-
MARKETING_VERSION = 2.2.0;
524+
MARKETING_VERSION = 2.2.1;
525525
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification;
526526
PRODUCT_NAME = "$(TARGET_NAME)";
527527
SDKROOT = iphoneos;
@@ -537,7 +537,7 @@
537537
buildSettings = {
538538
CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements;
539539
CODE_SIGN_STYLE = Automatic;
540-
CURRENT_PROJECT_VERSION = 186;
540+
CURRENT_PROJECT_VERSION = 187;
541541
DEVELOPMENT_TEAM = KYH47R284B;
542542
GENERATE_INFOPLIST_FILE = YES;
543543
INFOPLIST_FILE = BitkitNotification/Info.plist;
@@ -549,7 +549,7 @@
549549
"@executable_path/Frameworks",
550550
"@executable_path/../../Frameworks",
551551
);
552-
MARKETING_VERSION = 2.2.0;
552+
MARKETING_VERSION = 2.2.1;
553553
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification;
554554
PRODUCT_NAME = "$(TARGET_NAME)";
555555
SDKROOT = iphoneos;
@@ -683,7 +683,7 @@
683683
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
684684
CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements;
685685
CODE_SIGN_STYLE = Automatic;
686-
CURRENT_PROJECT_VERSION = 186;
686+
CURRENT_PROJECT_VERSION = 187;
687687
DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\"";
688688
DEVELOPMENT_TEAM = KYH47R284B;
689689
ENABLE_HARDENED_RUNTIME = YES;
@@ -709,7 +709,7 @@
709709
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
710710
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
711711
MACOSX_DEPLOYMENT_TARGET = 14.0;
712-
MARKETING_VERSION = 2.2.0;
712+
MARKETING_VERSION = 2.2.1;
713713
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit;
714714
PRODUCT_NAME = "$(TARGET_NAME)";
715715
SDKROOT = auto;
@@ -727,7 +727,7 @@
727727
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
728728
CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements;
729729
CODE_SIGN_STYLE = Automatic;
730-
CURRENT_PROJECT_VERSION = 186;
730+
CURRENT_PROJECT_VERSION = 187;
731731
DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\"";
732732
DEVELOPMENT_TEAM = KYH47R284B;
733733
ENABLE_HARDENED_RUNTIME = YES;
@@ -753,7 +753,7 @@
753753
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
754754
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
755755
MACOSX_DEPLOYMENT_TARGET = 14.0;
756-
MARKETING_VERSION = 2.2.0;
756+
MARKETING_VERSION = 2.2.1;
757757
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit;
758758
PRODUCT_NAME = "$(TARGET_NAME)";
759759
SDKROOT = auto;
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import Foundation
22

3-
/// Helper for determining when to use sendAll to avoid creating dust change outputs.
3+
/// Helper for detecting dust change during on-chain sends.
44
enum DustChangeHelper {
5-
/// Returns true if the expected change would be dust (below dust limit), so sendAll should be used.
5+
/// Returns true if the expected change would be dust (below dust limit).
66
/// - Parameters:
77
/// - totalInput: Total sats from selected UTXOs (or spendable balance)
88
/// - amountSats: Amount to send to recipient
99
/// - normalFee: Fee for a normal send (recipient + change outputs)
1010
/// - dustLimit: Minimum non-dust amount (default: Env.dustLimit)
11-
/// - Returns: true when change would be dust and sendAll should be used
12-
static func shouldUseSendAllToAvoidDust(
11+
/// - Returns: true when change would be dust
12+
static func wouldCreateDustChange(
1313
totalInput: UInt64,
1414
amountSats: UInt64,
1515
normalFee: UInt64,
@@ -18,4 +18,20 @@ enum DustChangeHelper {
1818
let expectedChange = Int64(totalInput) - Int64(amountSats) - Int64(normalFee)
1919
return expectedChange >= 0 && expectedChange < Int64(dustLimit)
2020
}
21+
22+
/// Returns true only when the caller is allowed to use sendAll to avoid dust change.
23+
static func shouldUseSendAllToAvoidDust(
24+
totalInput: UInt64,
25+
amountSats: UInt64,
26+
normalFee: UInt64,
27+
isMaxAmount: Bool,
28+
dustLimit: UInt64 = UInt64(Env.dustLimit)
29+
) -> Bool {
30+
isMaxAmount && wouldCreateDustChange(
31+
totalInput: totalInput,
32+
amountSats: amountSats,
33+
normalFee: normalFee,
34+
dustLimit: dustLimit
35+
)
36+
}
2137
}

Bitkit/Views/Transfer/SpendingConfirm.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ struct SpendingConfirm: View {
212212
useSendAll = DustChangeHelper.shouldUseSendAllToAvoidDust(
213213
totalInput: totalInput,
214214
amountSats: currentOrder.feeSat,
215-
normalFee: normalFee
215+
normalFee: normalFee,
216+
isMaxAmount: true
216217
)
217218
} catch {
218219
Logger.info("Normal coin selection failed, using sendAll: \(error)")

Bitkit/Views/Wallets/Send/SendConfirmationView.swift

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ struct SendConfirmationView: View {
1818
@State private var showingBiometricError = false
1919
@State private var biometricErrorMessage = ""
2020
@State private var transactionFee: Int = 0
21-
@State private var shouldUseSendAll: Bool = false
2221
@State private var currentWarning: WarningType?
2322
@State private var pendingWarnings: [WarningType] = []
2423
@State private var warningContinuation: CheckedContinuation<Bool, Error>?
@@ -485,8 +484,7 @@ struct SendConfirmationView: View {
485484
}
486485
} else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice {
487486
let amount = wallet.sendAmountSats ?? invoice.amountSatoshis
488-
// Use sendAll if explicitly MAX or if change would be dust
489-
let useMaxAmount = wallet.isMaxAmountSend || shouldUseSendAll
487+
let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount)
490488
let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount)
491489

492490
// Create pre-activity metadata for tags and activity address
@@ -621,6 +619,28 @@ struct SendConfirmationView: View {
621619
return true
622620
}
623621

622+
private func shouldUseMaxOnchainSend(address: String, amountSats: UInt64, feeRate: UInt32? = nil) async -> Bool {
623+
guard wallet.isMaxAmountSend else { return false }
624+
guard let rate = feeRate ?? wallet.selectedFeeRateSatsPerVByte else { return false }
625+
626+
do {
627+
let currentMaxSendable = try await wallet.calculateMaxSendableAmount(address: address, satsPerVByte: rate)
628+
let matchesCurrentMax = amountSats == currentMaxSendable
629+
630+
if !matchesCurrentMax {
631+
Logger.warn(
632+
"Ignoring stale max on-chain send flag: amount=\(amountSats), currentMaxSendable=\(currentMaxSendable)",
633+
context: "SendConfirmationView"
634+
)
635+
}
636+
637+
return matchesCurrentMax
638+
} catch {
639+
Logger.error("Failed to verify max on-chain send amount: \(error)", context: "SendConfirmationView")
640+
return false
641+
}
642+
}
643+
624644
private func createPreActivityMetadata(
625645
paymentId: String,
626646
paymentHash: String? = nil,
@@ -741,43 +761,31 @@ struct SendConfirmationView: View {
741761
}
742762

743763
do {
744-
// Fee for normal send (recipient + change outputs) - used to check if change would be dust
745-
let normalFee = try await wallet.calculateTotalFee(
746-
address: address,
747-
amountSats: amountSats,
748-
satsPerVByte: feeRate,
749-
utxosToSpend: wallet.selectedUtxos
750-
)
751-
let totalInput = wallet.selectedUtxos?.reduce(0) { $0 + $1.valueSats }
752-
?? UInt64(wallet.spendableOnchainBalanceSats)
753-
let useSendAll = DustChangeHelper.shouldUseSendAllToAvoidDust(
754-
totalInput: totalInput,
755-
amountSats: amountSats,
756-
normalFee: normalFee
757-
)
758-
759-
if useSendAll {
760-
// Change would be dust - use sendAll and add dust to fee
764+
if await shouldUseMaxOnchainSend(address: address, amountSats: amountSats, feeRate: feeRate) {
761765
let sendAllFee = try await wallet.estimateSendAllFee(
762766
address: address,
763767
satsPerVByte: feeRate
764768
)
765769
await MainActor.run {
766770
transactionFee = Int(sendAllFee)
767-
shouldUseSendAll = true
768-
}
769-
} else {
770-
// Normal send with change output
771-
await MainActor.run {
772-
transactionFee = Int(normalFee)
773-
shouldUseSendAll = false
774771
}
772+
return
773+
}
774+
775+
// Fee for normal send (recipient + change outputs).
776+
let normalFee = try await wallet.calculateTotalFee(
777+
address: address,
778+
amountSats: amountSats,
779+
satsPerVByte: feeRate,
780+
utxosToSpend: wallet.selectedUtxos
781+
)
782+
await MainActor.run {
783+
transactionFee = Int(normalFee)
775784
}
776785
} catch {
777786
Logger.error("Failed to calculate actual fee: \(error)")
778787
await MainActor.run {
779788
transactionFee = 0
780-
shouldUseSendAll = false
781789
app.toast(type: .error, title: t("other__try_again"))
782790
}
783791
}

BitkitTests/DustChangeHelperTests.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,32 @@ import XCTest
44
final class DustChangeHelperTests: XCTestCase {
55
private let dustLimit: UInt64 = 547
66

7-
// MARK: - Change would be dust -> use sendAll
7+
// MARK: - Change would be dust
8+
9+
func testReportedNonMaxDustChange_ShouldNotUseSendAll() {
10+
XCTAssertTrue(DustChangeHelper.wouldCreateDustChange(
11+
totalInput: 5450,
12+
amountSats: 5000,
13+
normalFee: 181,
14+
dustLimit: dustLimit
15+
))
16+
17+
XCTAssertFalse(DustChangeHelper.shouldUseSendAllToAvoidDust(
18+
totalInput: 5450,
19+
amountSats: 5000,
20+
normalFee: 181,
21+
isMaxAmount: false,
22+
dustLimit: dustLimit
23+
))
24+
}
825

926
func testChangeBelowDustLimit_ShouldUseSendAll() {
10-
// totalInput: 100_000, amount: 99_500, fee: 500 -> change = 0
27+
// totalInput: 100_000, amount: 99_500, fee: 500 -> change = 0 (explicit max case)
1128
XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust(
1229
totalInput: 100_000,
1330
amountSats: 99500,
1431
normalFee: 500,
32+
isMaxAmount: true,
1533
dustLimit: dustLimit
1634
))
1735

@@ -20,6 +38,7 @@ final class DustChangeHelperTests: XCTestCase {
2038
totalInput: 100_000,
2139
amountSats: 99000,
2240
normalFee: 500,
41+
isMaxAmount: true,
2342
dustLimit: dustLimit
2443
))
2544

@@ -28,6 +47,7 @@ final class DustChangeHelperTests: XCTestCase {
2847
totalInput: 100_000,
2948
amountSats: 98954,
3049
normalFee: 500,
50+
isMaxAmount: true,
3151
dustLimit: dustLimit
3252
))
3353
}
@@ -39,6 +59,7 @@ final class DustChangeHelperTests: XCTestCase {
3959
totalInput: 100_000,
4060
amountSats: 98953,
4161
normalFee: 500,
62+
isMaxAmount: true,
4263
dustLimit: dustLimit
4364
))
4465
}
@@ -49,6 +70,7 @@ final class DustChangeHelperTests: XCTestCase {
4970
totalInput: 100_000,
5071
amountSats: 98000,
5172
normalFee: 500,
73+
isMaxAmount: true,
5274
dustLimit: dustLimit
5375
))
5476

@@ -57,6 +79,7 @@ final class DustChangeHelperTests: XCTestCase {
5779
totalInput: 100_000,
5880
amountSats: 98954,
5981
normalFee: 495,
82+
isMaxAmount: true,
6083
dustLimit: dustLimit
6184
))
6285
}
@@ -67,6 +90,7 @@ final class DustChangeHelperTests: XCTestCase {
6790
totalInput: 100_000,
6891
amountSats: 99500,
6992
normalFee: 500,
93+
isMaxAmount: true,
7094
dustLimit: dustLimit
7195
))
7296
}
@@ -77,6 +101,7 @@ final class DustChangeHelperTests: XCTestCase {
77101
totalInput: 100_000,
78102
amountSats: 100_000,
79103
normalFee: 500,
104+
isMaxAmount: true,
80105
dustLimit: dustLimit
81106
))
82107
}
@@ -86,7 +111,8 @@ final class DustChangeHelperTests: XCTestCase {
86111
XCTAssertTrue(DustChangeHelper.shouldUseSendAllToAvoidDust(
87112
totalInput: 100_000,
88113
amountSats: 99454,
89-
normalFee: 0
114+
normalFee: 0,
115+
isMaxAmount: true
90116
// dustLimit omitted -> uses Env.dustLimit
91117
))
92118
}

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Fix probe results and add keysend probes #522
2222
- Fix design: minor UI fixes #525
2323

24+
## [2.2.1] - 2026-05-05
25+
26+
### Fixed
27+
- Fixed on-chain sends so dust change can no longer turn a partial payment into a max-balance send #536
28+
2429
## [2.2.0] - 2026-04-07
2530

2631
### Fixed
2732
- Fix keyboard and UI issues in the calculator widget #513
2833
- Preserve msat precision for LNURL pay, withdraw callbacks and bolt11 #512
2934

30-
[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.2.0...HEAD
35+
[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.2.1...HEAD
36+
[2.2.1]: https://github.com/synonymdev/bitkit-ios/compare/v2.2.0...v2.2.1
3137
[2.2.0]: https://github.com/synonymdev/bitkit-ios/compare/v2.1.2...v2.2.0

changelog.d/next/536.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed on-chain sends so dust change can no longer turn a partial payment into a max-balance send.

0 commit comments

Comments
 (0)