From d4d8599f90a0c3260f7890b9c092a0769af809e6 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Fri, 5 Jun 2026 14:25:05 -0500 Subject: [PATCH 1/7] fix: cap send amount number pad at available balance The send number pad now rejects keystrokes that would push the amount above the available sendable balance, reusing the existing over-max block (haptic + error flash) via a dynamic maxAmountOverride. Continue-button validation is unchanged as a backstop. Closes #346 --- Bitkit/ViewModels/AmountInputViewModel.swift | 14 ++++++-- .../Views/Wallets/Send/SendAmountView.swift | 10 ++++++ BitkitTests/NumberPadTests.swift | 34 +++++++++++++++++++ changelog.d/next/346.fixed.md | 1 + 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 changelog.d/next/346.fixed.md diff --git a/Bitkit/ViewModels/AmountInputViewModel.swift b/Bitkit/ViewModels/AmountInputViewModel.swift index c8b80eb05..2ce743030 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -7,6 +7,10 @@ class AmountInputViewModel: ObservableObject { @Published var displayText: String = "" @Published var errorKey: String? + /// Optional per-screen cap (e.g. the max sendable balance in the send flow). + /// When set, input is additionally blocked above this value, on top of `maxAmount`. + var maxAmountOverride: UInt64? + // MARK: - Constants private let maxAmount: UInt64 = 999_999_999 @@ -15,6 +19,12 @@ class AmountInputViewModel: ObservableObject { private let classicBitcoinDecimals = 8 private let fiatDecimals = 2 + /// The active upper bound for input: the global `maxAmount`, further restricted by `maxAmountOverride` when set. + private var effectiveMaxAmount: UInt64 { + guard let maxAmountOverride else { return maxAmount } + return Swift.min(maxAmount, maxAmountOverride) + } + // MARK: - Private Properties private var rawInputText: String = "" @@ -43,7 +53,7 @@ class AmountInputViewModel: ObservableObject { if currency.primaryDisplay == .bitcoin && currency.displayUnit == .modern { let newAmount = convertToSats(newText, currency: currency) - if newAmount <= maxAmount { + if newAmount <= effectiveMaxAmount { rawInputText = newText displayText = formatDisplayTextFromAmount(newAmount, currency: currency) amountSats = newAmount @@ -59,7 +69,7 @@ class AmountInputViewModel: ObservableObject { // For decimal input, check limits before updating state if !newText.isEmpty { let newAmount = convertToSats(newText, currency: currency) - if newAmount <= maxAmount { + if newAmount <= effectiveMaxAmount { // Update both raw input and display text rawInputText = newText // Format with grouping separators but not decimal formatting diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index d81fe176e..a71afba76 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -163,6 +163,8 @@ struct SendAmountView: View { await calculateRoutingFee() } } + + updateInputCap() } .onChange(of: app.selectedWalletToPayFrom) { _, newValue in // Recalculate max sendable amount when switching wallet types @@ -186,6 +188,9 @@ struct SendAmountView: View { } } } + .onChange(of: availableAmount) { _, _ in + updateInputCap() + } } private func onContinue() async { @@ -252,6 +257,11 @@ struct SendAmountView: View { } } + private func updateInputCap() { + // Don't cap when nothing is sendable, so the pad stays usable (Continue stays disabled instead). + amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil + } + private func calculateMaxSendableAmount() async { // Make sure we have everything we need to calculate the max sendable amount guard app.selectedWalletToPayFrom == .onchain else { return } diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index e5d8d2d16..b15224b50 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -53,6 +53,40 @@ final class NumberPadTests: XCTestCase { XCTAssertNotNil(viewModel.errorKey) } + func testMaxAmountOverrideBlocksInputAboveBalance() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + viewModel.maxAmountOverride = 50000 + + // Up to the cap is allowed + for digit in "50000" { + viewModel.handleNumberPadInput(String(digit), currency: currency) + } + XCTAssertEqual(viewModel.amountSats, 50000) + + // Next keystroke would make 500_000 > 50_000 and is blocked + viewModel.handleNumberPadInput("0", currency: currency) + XCTAssertEqual(viewModel.amountSats, 50000) // Should not change + XCTAssertNotNil(viewModel.errorKey) + } + + func testClearingMaxAmountOverrideRestoresGlobalCap() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + + // With a low override, input is blocked above it + viewModel.maxAmountOverride = 100 + viewModel.handleNumberPadInput("9", currency: currency) + viewModel.handleNumberPadInput("9", currency: currency) + viewModel.handleNumberPadInput("9", currency: currency) // 999 > 100 -> blocked + XCTAssertEqual(viewModel.amountSats, 99) + + // Clearing the override lets input grow again, up to the global cap + viewModel.maxAmountOverride = nil + viewModel.handleNumberPadInput("9", currency: currency) + XCTAssertEqual(viewModel.amountSats, 999) + } + // MARK: - Classic Bitcoin Tests func testClassicBitcoinDecimalInput() { diff --git a/changelog.d/next/346.fixed.md b/changelog.d/next/346.fixed.md new file mode 100644 index 000000000..2b713053d --- /dev/null +++ b/changelog.d/next/346.fixed.md @@ -0,0 +1 @@ +The send amount number pad now caps entry at your available balance, so you can no longer enter more than you can send. From 4868096f03ae20f8097ac8e90228b0a91c48e408 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Sat, 6 Jun 2026 07:09:19 -0500 Subject: [PATCH 2/7] fix: allow deleting an amount that is above the cap The cap rejected every keystroke whose result still exceeded it, including deletions. When an amount lands above the cap (a prefilled invoice over the available balance, or a cap that dropped after input), the user could not backspace to reduce it, since each intermediate value was still over the cap. Deletions now always apply; the cap only blocks growing the amount. --- Bitkit/ViewModels/AmountInputViewModel.swift | 11 ++++++-- BitkitTests/NumberPadTests.swift | 29 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AmountInputViewModel.swift b/Bitkit/ViewModels/AmountInputViewModel.swift index 2ce743030..3b268248d 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -48,12 +48,19 @@ class AmountInputViewModel: ObservableObject { maxDecimals: maxDecimals ) + // Deletions must always apply, even when the amount is above the cap (e.g. a + // prefilled invoice amount over the available balance, or a cap that dropped + // after input). The cap only blocks growing the amount; without this, each + // delete still leaves the amount over the cap and gets rejected, trapping the + // user with an invalid amount they can't reduce. + let isDeletion = key == "delete" + // For decimal input (classic Bitcoin and fiat), preserve the text as-is // For integer input (modern Bitcoin), format the final amount if currency.primaryDisplay == .bitcoin && currency.displayUnit == .modern { let newAmount = convertToSats(newText, currency: currency) - if newAmount <= effectiveMaxAmount { + if isDeletion || newAmount <= effectiveMaxAmount { rawInputText = newText displayText = formatDisplayTextFromAmount(newAmount, currency: currency) amountSats = newAmount @@ -69,7 +76,7 @@ class AmountInputViewModel: ObservableObject { // For decimal input, check limits before updating state if !newText.isEmpty { let newAmount = convertToSats(newText, currency: currency) - if newAmount <= effectiveMaxAmount { + if isDeletion || newAmount <= effectiveMaxAmount { // Update both raw input and display text rawInputText = newText // Format with grouping separators but not decimal formatting diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index b15224b50..3122e43f0 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -240,6 +240,35 @@ final class NumberPadTests: XCTestCase { XCTAssertEqual(viewModel.amountSats, 100_000) } + func testDeleteAllowedWhenAmountAboveCap() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + + // A prefilled amount lands above a low cap (e.g. an invoice that exceeds the + // available balance, set via updateFromSats which does not enforce the cap). + viewModel.maxAmountOverride = 1000 + viewModel.updateFromSats(123_456, currency: currency) + XCTAssertEqual(viewModel.amountSats, 123_456) + + // Adding a digit is still blocked: it would grow the amount further above the cap. + viewModel.handleNumberPadInput("7", currency: currency) + XCTAssertEqual(viewModel.amountSats, 123_456) // unchanged + XCTAssertNotNil(viewModel.errorKey) + + // Deleting is allowed even though the result is still above the cap, so the user + // can reduce an over-cap amount instead of being stuck. + viewModel.handleNumberPadInput("delete", currency: currency) + XCTAssertEqual(viewModel.displayText, "12 345") + XCTAssertEqual(viewModel.amountSats, 12345) + XCTAssertNil(viewModel.errorKey) + + // Keep deleting down below the cap. + viewModel.handleNumberPadInput("delete", currency: currency) // 1 234 + viewModel.handleNumberPadInput("delete", currency: currency) // 123 + XCTAssertEqual(viewModel.amountSats, 123) + XCTAssertNil(viewModel.errorKey) + } + // MARK: - Leading Zero Tests func testLeadingZeroBehavior() { From 480d7ac2f3a86905f161d12e5773d65b5ed1e8aa Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Fri, 5 Jun 2026 14:40:21 -0500 Subject: [PATCH 3/7] fix: cap number pad input on remaining amount screens Applies the maxAmountOverride cap (from the send-amount fix) to the LNURL pay/withdraw, spending, advanced-spending, and manual channel-funding screens, so the number pad refuses entry above each screen's contextual maximum. FundManualAmountView also gains the previously-missing upper-bound button validation, which let users proceed above the fundable balance. Receive and CJIT amount screens are intentionally left uncapped (a receive amount has no balance ceiling). --- .../Views/Transfer/FundManualAmountView.swift | 18 +++++++++++++++++- .../Views/Transfer/SpendingAdvancedView.swift | 7 +++++++ Bitkit/Views/Transfer/SpendingAmount.swift | 5 +++++ .../LnurlWithdraw/LnurlWithdrawAmount.swift | 7 +++++++ Bitkit/Views/Wallets/Send/LnurlPayAmount.swift | 8 ++++++++ changelog.d/next/amount-screens-cap.fixed.md | 1 + 6 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 changelog.d/next/amount-screens-cap.fixed.md diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index 880f6debc..563162450 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -15,6 +15,14 @@ struct FundManualAmountView: View { amountViewModel.amountSats } + private var fundableBalanceSats: UInt64 { + UInt64(max(0, wallet.channelFundableBalanceSats)) + } + + private var isValidAmount: Bool { + amountSats > 0 && amountSats <= fundableBalanceSats + } + var body: some View { VStack(spacing: 0) { NavigationBar(title: t("lightning__external__nav_title")) @@ -58,7 +66,7 @@ struct FundManualAmountView: View { amountViewModel.handleNumberPadInput(key, currency: currency) } - CustomButton(title: t("common__continue"), isDisabled: amountSats == 0) { + CustomButton(title: t("common__continue"), isDisabled: !isValidAmount) { navigation.navigate(.fundManualConfirm(lnPeer: lnPeer, amountSats: amountSats)) } .accessibilityIdentifier("ExternalAmountContinue") @@ -70,6 +78,14 @@ struct FundManualAmountView: View { .task { await connectToPeerIfNeeded() } + .onAppear { + updateInputCap() + } + .onChange(of: wallet.channelFundableBalanceSats) { updateInputCap() } + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = fundableBalanceSats > 0 ? fundableBalanceSats : nil } private var numberPadButtons: some View { diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 825135f64..daa0066bf 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -108,6 +108,7 @@ struct SpendingAdvancedView: View { ) updateFeeEstimate() + updateInputCap() } .onChange(of: lspBalance) { if isValid { @@ -116,6 +117,12 @@ struct SpendingAdvancedView: View { feeEstimate = nil } } + .onChange(of: transfer.transferValues.maxLspBalance) { updateInputCap() } + } + + private func updateInputCap() { + let maxLspBalance = transfer.transferValues.maxLspBalance + amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil } private var actionButtons: some View { diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 60c125bf9..c7a24bb76 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -87,6 +87,11 @@ struct SpendingAmount: View { await calculateMaxTransferAmount() } } + .onChange(of: maxTransferAmount) { updateInputCap() } + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = (maxTransferAmount ?? 0) > 0 ? maxTransferAmount : nil } private var actionButtons: some View { diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index 450c5b095..54cc03f42 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift @@ -77,7 +77,14 @@ struct LnurlWithdrawAmount: View { if amountViewModel.amountSats == 0 { amountViewModel.updateFromSats(UInt64(minAmount), currency: currency) } + updateInputCap() } + .onChange(of: maxAmount) { updateInputCap() } + } + + private func updateInputCap() { + let cap = max(minAmount, maxAmount) + amountViewModel.maxAmountOverride = cap > 0 ? UInt64(cap) : nil } private func handleContinue() { diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index dc1b46832..fe9ee8eb0 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -81,6 +81,14 @@ struct LnurlPayAmount: View { .navigationBarHidden(true) .padding(.horizontal, 16) .sheetBackground() + .onAppear { + updateInputCap() + } + .onChange(of: maxAmount) { updateInputCap() } + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = maxAmount > 0 ? maxAmount : nil } private func onContinue() { diff --git a/changelog.d/next/amount-screens-cap.fixed.md b/changelog.d/next/amount-screens-cap.fixed.md new file mode 100644 index 000000000..637e332ea --- /dev/null +++ b/changelog.d/next/amount-screens-cap.fixed.md @@ -0,0 +1 @@ +Number-pad entry on the spending, LNURL, and channel-funding amount screens now caps at the available maximum, and manual channel funding can no longer exceed your available balance. From bd7a772303cecc7f3d64e947ed696bef16926cbe Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Fri, 5 Jun 2026 16:52:16 -0500 Subject: [PATCH 4/7] chore: rename changelog fragment --- changelog.d/next/{amount-screens-cap.fixed.md => 585.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{amount-screens-cap.fixed.md => 585.fixed.md} (100%) diff --git a/changelog.d/next/amount-screens-cap.fixed.md b/changelog.d/next/585.fixed.md similarity index 100% rename from changelog.d/next/amount-screens-cap.fixed.md rename to changelog.d/next/585.fixed.md From 9f58295c50f22423c7010855de865795ccfa2ef7 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Tue, 9 Jun 2026 14:05:14 -0500 Subject: [PATCH 5/7] chore: consolidate changelog fragments into one Now that the send-amount cap (#584) and the remaining-screens cap live in a single PR, merge the two Fixed fragments into one entry. --- changelog.d/next/346.fixed.md | 2 +- changelog.d/next/585.fixed.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 changelog.d/next/585.fixed.md diff --git a/changelog.d/next/346.fixed.md b/changelog.d/next/346.fixed.md index 2b713053d..e50423590 100644 --- a/changelog.d/next/346.fixed.md +++ b/changelog.d/next/346.fixed.md @@ -1 +1 @@ -The send amount number pad now caps entry at your available balance, so you can no longer enter more than you can send. +Amount entry across the send, spending, LNURL, and channel-funding screens now caps the number pad at your available balance, so you can no longer enter more than you can send. diff --git a/changelog.d/next/585.fixed.md b/changelog.d/next/585.fixed.md deleted file mode 100644 index 637e332ea..000000000 --- a/changelog.d/next/585.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Number-pad entry on the spending, LNURL, and channel-funding amount screens now caps at the available maximum, and manual channel funding can no longer exceed your available balance. From 3637e091dbdba88db646bb59157be82efdccd27e Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Fri, 5 Jun 2026 14:44:09 -0500 Subject: [PATCH 6/7] refactor: migrate AmountInputViewModel to @Observable Replaces the legacy ObservableObject/@StateObject/@ObservedObject pattern with @Observable + @State, aligning with the project's stated SwiftUI direction. No behavior change: there are no two-way bindings to the view model, so no @Bindable is needed. Touches the view model, NumberPadTextField, and the 8 amount-entry call sites. --- Bitkit/Components/NumberPadTextField.swift | 2 +- Bitkit/ViewModels/AmountInputViewModel.swift | 9 +++++---- Bitkit/Views/Transfer/FundManualAmountView.swift | 2 +- Bitkit/Views/Transfer/SpendingAdvancedView.swift | 2 +- Bitkit/Views/Transfer/SpendingAmount.swift | 2 +- .../Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift | 2 +- Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift | 2 +- Bitkit/Views/Wallets/Receive/ReceiveEdit.swift | 2 +- Bitkit/Views/Wallets/Send/LnurlPayAmount.swift | 2 +- Bitkit/Views/Wallets/Send/SendAmountView.swift | 2 +- 10 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Bitkit/Components/NumberPadTextField.swift b/Bitkit/Components/NumberPadTextField.swift index 057b562d6..bf99b31c9 100644 --- a/Bitkit/Components/NumberPadTextField.swift +++ b/Bitkit/Components/NumberPadTextField.swift @@ -3,7 +3,7 @@ import SwiftUI /// NumberPadTextField - Amount view to be used with number pad struct NumberPadTextField: View { @EnvironmentObject var currency: CurrencyViewModel - @ObservedObject var viewModel: AmountInputViewModel + var viewModel: AmountInputViewModel var showConversion: Bool = true var showEditButton: Bool = false diff --git a/Bitkit/ViewModels/AmountInputViewModel.swift b/Bitkit/ViewModels/AmountInputViewModel.swift index 3b268248d..760afb898 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -1,11 +1,12 @@ import Foundation import SwiftUI +@Observable @MainActor -class AmountInputViewModel: ObservableObject { - @Published var amountSats: UInt64 = 0 - @Published var displayText: String = "" - @Published var errorKey: String? +final class AmountInputViewModel { + var amountSats: UInt64 = 0 + var displayText: String = "" + var errorKey: String? /// Optional per-screen cap (e.g. the max sendable balance in the send flow). /// When set, input is additionally blocked above this value, on top of `maxAmount`. diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index 563162450..351bd4121 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -8,7 +8,7 @@ struct FundManualAmountView: View { let lnPeer: LnPeer - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() @State private var didAttemptPeerConnection = false var amountSats: UInt64 { diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index daa0066bf..603a8f0b5 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -10,7 +10,7 @@ struct SpendingAdvancedView: View { @EnvironmentObject var transfer: TransferViewModel @Environment(\.dismiss) var dismiss - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() @State private var feeEstimate: UInt64? @State private var isLoading = false @State private var feeEstimateTask: Task? diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index c7a24bb76..c8a6fc870 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -10,7 +10,7 @@ struct SpendingAmount: View { @EnvironmentObject var transfer: TransferViewModel @EnvironmentObject var wallet: WalletViewModel - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() @State private var isLoading = false @State private var availableAmount: UInt64? @State private var maxTransferAmount: UInt64? diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index 54cc03f42..09da2e81f 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift @@ -6,7 +6,7 @@ struct LnurlWithdrawAmount: View { @EnvironmentObject var wallet: WalletViewModel let onContinue: () -> Void - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() var minAmount: Int { Int(max(1, app.lnurlWithdrawData!.minWithdrawableSat)) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift b/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift index f8350d99e..56265f522 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift @@ -9,7 +9,7 @@ struct ReceiveCjitAmount: View { @Binding var navigationPath: [ReceiveRoute] - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() var minimumAmount: UInt64 { blocktank.minCjitSats ?? 0 diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index f1cd554d4..f61389aba 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -12,7 +12,7 @@ struct ReceiveEdit: View { @Binding var navigationPath: [ReceiveRoute] - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() @State private var note = "" @State private var isAmountInputFocused: Bool = false @FocusState private var isNoteEditorFocused: Bool diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index fe9ee8eb0..49a5db19e 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -7,7 +7,7 @@ struct LnurlPayAmount: View { @Binding var navigationPath: [SendRoute] - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() var maxAmount: UInt64 { // TODO: subtract fee diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index a71afba76..da03ca857 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -8,7 +8,7 @@ struct SendAmountView: View { @Binding var navigationPath: [SendRoute] - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() @State private var maxSendableAmount: UInt64? @State private var routingFee: UInt64 = 0 From 39a71770775b7c09057973c2485eb000dfb05481 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Wed, 10 Jun 2026 12:55:27 -0500 Subject: [PATCH 7/7] fix: warn with a toast when number pad input exceeds the cap Matches the Android behavior from synonymdev/bitkit-android#908: a keypress blocked by the screen-specific cap now shows a short warning toast, with accessibilityIdentifier SendAmountExceededToast on the send-flow screens for E2E parity. Also replaces the onAppear + onChange cap-update duplication with a single onChange(initial: true) across the amount screens, per review feedback. --- Bitkit/Models/Toast.swift | 3 ++ .../Localization/en.lproj/Localizable.strings | 8 ++++ Bitkit/ViewModels/AmountInputViewModel.swift | 15 +++++++ .../Views/Transfer/FundManualAmountView.swift | 18 ++++++-- .../Views/Transfer/SpendingAdvancedView.swift | 16 ++++++- Bitkit/Views/Transfer/SpendingAmount.swift | 13 ++++++ .../LnurlWithdraw/LnurlWithdrawAmount.swift | 14 ++++++- .../Views/Wallets/Send/LnurlPayAmount.swift | 19 +++++++-- .../Views/Wallets/Send/SendAmountView.swift | 17 +++++--- BitkitTests/NumberPadTests.swift | 42 +++++++++++++++++++ changelog.d/next/346.fixed.md | 2 +- 11 files changed, 149 insertions(+), 18 deletions(-) diff --git a/Bitkit/Models/Toast.swift b/Bitkit/Models/Toast.swift index c206d9b27..2d824d910 100644 --- a/Bitkit/Models/Toast.swift +++ b/Bitkit/Models/Toast.swift @@ -5,6 +5,9 @@ struct Toast: Equatable { case success, info, lightning, warning, error } + /// Brief visibility for transient feedback (e.g. blocked number pad input). + static let visibilityTimeShort = 1.5 + let id: UUID let type: ToastType let title: String diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9841f90ce..0f40db181 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -164,6 +164,8 @@ "lightning__spending_confirm__default" = "Use Defaults"; "lightning__spending_advanced__title" = "Receiving\ncapacity"; "lightning__spending_advanced__fee" = "Liquidity fee"; +"lightning__spending_advanced__error_max__title" = "Receiving Capacity Maximum"; +"lightning__spending_advanced__error_max__description" = "The receiving capacity is currently limited to ₿ {amount}."; "lightning__liquidity__title" = "Liquidity\n& routing"; "lightning__liquidity__text" = "Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs."; "lightning__liquidity__label" = "Spending Balance Liquidity"; @@ -1145,6 +1147,8 @@ "wallet__send_available_savings" = "Available (savings)"; "wallet__send_max_spending__title" = "Reserve Balance"; "wallet__send_max_spending__description" = "The maximum spendable amount is a bit lower due to a required reserve balance."; +"wallet__send_amount_exceeded__title" = "Insufficient balance"; +"wallet__send_amount_exceeded__description" = "The amount exceeds your available balance."; "wallet__send_review" = "Confirm"; "wallet__send_confirming_in" = "Confirming in"; "wallet__send_invoice_expiration" = "Invoice expiration"; @@ -1357,9 +1361,13 @@ "wallet__lnurl_w_max" = "AvailablE TO WITHDRAW"; "wallet__lnurl_w_text" = "The funds you withdraw will be deposited into your Bitkit spending balance."; "wallet__lnurl_w_button" = "Withdraw"; +"wallet__lnurl_w_error_max__title" = "Amount Too High"; +"wallet__lnurl_w_error_max__description" = "The amount exceeds the maximum you can withdraw."; "wallet__lnurl_p_title" = "Pay Bitcoin"; "wallet__lnurl_pay__error_min__title" = "Amount Too Low"; "wallet__lnurl_pay__error_min__description" = "The minimum amount for this invoice is ₿ {amount}."; +"wallet__lnurl_pay__error_max__title" = "Amount Too High"; +"wallet__lnurl_pay__error_max__description" = "The amount exceeds this invoice's maximum."; "wallet__lnurl_p_max" = "Maximum amount"; "wallet__balance_hidden_title" = "Wallet Balance Hidden"; "wallet__balance_hidden_message" = "Swipe your wallet balance to reveal it again."; diff --git a/Bitkit/ViewModels/AmountInputViewModel.swift b/Bitkit/ViewModels/AmountInputViewModel.swift index 760afb898..c586885eb 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -12,6 +12,11 @@ final class AmountInputViewModel { /// When set, input is additionally blocked above this value, on top of `maxAmount`. var maxAmountOverride: UInt64? + /// Incremented each time input is blocked by the screen-specific cap (`maxAmountOverride`), + /// so views can react (e.g. show a toast). Not bumped when only the global + /// `maxAmount` blocks input. + private(set) var maxExceededCount = 0 + // MARK: - Constants private let maxAmount: UInt64 = 999_999_999 @@ -67,6 +72,7 @@ final class AmountInputViewModel { amountSats = newAmount errorKey = nil } else { + notifyMaxExceededIfCapped() Haptics.notify(.warning) errorKey = key DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -90,6 +96,7 @@ final class AmountInputViewModel { errorKey = nil } else { // Block input when limit exceeded + notifyMaxExceededIfCapped() Haptics.notify(.warning) errorKey = key DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -236,6 +243,14 @@ final class AmountInputViewModel { // MARK: - Private Methods + /// Signals blocked input to observers, but only when the screen-specific cap is the + /// limiting bound. Hitting the global `maxAmount` stays silent. + private func notifyMaxExceededIfCapped() { + if effectiveMaxAmount < maxAmount { + maxExceededCount += 1 + } + } + private func formatDisplayTextFromAmount(_ amountSats: UInt64, currency: CurrencyViewModel) -> String { if amountSats == 0 { return "" diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index 351bd4121..58c7bca73 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -78,16 +78,26 @@ struct FundManualAmountView: View { .task { await connectToPeerIfNeeded() } - .onAppear { - updateInputCap() - } - .onChange(of: wallet.channelFundableBalanceSats) { updateInputCap() } + .onChange(of: wallet.channelFundableBalanceSats, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func updateInputCap() { amountViewModel.maxAmountOverride = fundableBalanceSats > 0 ? fundableBalanceSats : nil } + private func showMaxExceededToast() { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(fundableBalanceSats)] + ), + visibilityTime: Toast.visibilityTimeShort + ) + } + private var numberPadButtons: some View { HStack(spacing: 8) { NumberPadActionButton( diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 603a8f0b5..dbd5f5717 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -108,7 +108,6 @@ struct SpendingAdvancedView: View { ) updateFeeEstimate() - updateInputCap() } .onChange(of: lspBalance) { if isValid { @@ -117,7 +116,8 @@ struct SpendingAdvancedView: View { feeEstimate = nil } } - .onChange(of: transfer.transferValues.maxLspBalance) { updateInputCap() } + .onChange(of: transfer.transferValues.maxLspBalance, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func updateInputCap() { @@ -125,6 +125,18 @@ struct SpendingAdvancedView: View { amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil } + private func showMaxExceededToast() { + app.toast( + type: .warning, + title: t("lightning__spending_advanced__error_max__title"), + description: t( + "lightning__spending_advanced__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(transfer.transferValues.maxLspBalance)] + ), + visibilityTime: Toast.visibilityTimeShort + ) + } + private var actionButtons: some View { HStack(spacing: 16) { NumberPadActionButton(text: t("common__min")) { diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index c8a6fc870..b5e1a5b1e 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -88,12 +88,25 @@ struct SpendingAmount: View { } } .onChange(of: maxTransferAmount) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func updateInputCap() { amountViewModel.maxAmountOverride = (maxTransferAmount ?? 0) > 0 ? maxTransferAmount : nil } + private func showMaxExceededToast() { + app.toast( + type: .warning, + title: t("lightning__spending_amount__error_max__title"), + description: t( + "lightning__spending_amount__error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxTransferAmount ?? 0)] + ), + visibilityTime: Toast.visibilityTimeShort + ) + } + private var actionButtons: some View { HStack(spacing: 8) { NumberPadActionButton( diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index 09da2e81f..1ebfd1bb9 100644 --- a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift +++ b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift @@ -77,9 +77,9 @@ struct LnurlWithdrawAmount: View { if amountViewModel.amountSats == 0 { amountViewModel.updateFromSats(UInt64(minAmount), currency: currency) } - updateInputCap() } - .onChange(of: maxAmount) { updateInputCap() } + .onChange(of: maxAmount, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func updateInputCap() { @@ -87,6 +87,16 @@ struct LnurlWithdrawAmount: View { amountViewModel.maxAmountOverride = cap > 0 ? UInt64(cap) : nil } + private func showMaxExceededToast() { + app.toast( + type: .warning, + title: t("wallet__lnurl_w_error_max__title"), + description: t("wallet__lnurl_w_error_max__description"), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "SendAmountExceededToast" + ) + } + private func handleContinue() { // If minimum is above the amount the user entered, automatically set amount to that minimum if amount < minAmount { diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 49a5db19e..6cd15e0fc 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -81,16 +81,27 @@ struct LnurlPayAmount: View { .navigationBarHidden(true) .padding(.horizontal, 16) .sheetBackground() - .onAppear { - updateInputCap() - } - .onChange(of: maxAmount) { updateInputCap() } + .onChange(of: maxAmount, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func updateInputCap() { amountViewModel.maxAmountOverride = maxAmount > 0 ? maxAmount : nil } + private func showMaxExceededToast() { + // The cap is min(invoice max, spending balance); word the message for whichever bound is hit. + let isInvoiceCapped = app.lnurlPayData!.maxSendableSat < UInt64(wallet.totalLightningSats) + + app.toast( + type: .warning, + title: t(isInvoiceCapped ? "wallet__lnurl_pay__error_max__title" : "wallet__send_amount_exceeded__title"), + description: t(isInvoiceCapped ? "wallet__lnurl_pay__error_max__description" : "wallet__send_amount_exceeded__description"), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "SendAmountExceededToast" + ) + } + private func onContinue() { let minSendableSats = max(1, app.lnurlPayData!.minSendableSat) diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index da03ca857..4259ed948 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -163,8 +163,6 @@ struct SendAmountView: View { await calculateRoutingFee() } } - - updateInputCap() } .onChange(of: app.selectedWalletToPayFrom) { _, newValue in // Recalculate max sendable amount when switching wallet types @@ -188,9 +186,8 @@ struct SendAmountView: View { } } } - .onChange(of: availableAmount) { _, _ in - updateInputCap() - } + .onChange(of: availableAmount, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func onContinue() async { @@ -262,6 +259,16 @@ struct SendAmountView: View { amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil } + private func showMaxExceededToast() { + app.toast( + type: .warning, + title: t("wallet__send_amount_exceeded__title"), + description: t("wallet__send_amount_exceeded__description"), + visibilityTime: Toast.visibilityTimeShort, + accessibilityIdentifier: "SendAmountExceededToast" + ) + } + private func calculateMaxSendableAmount() async { // Make sure we have everything we need to calculate the max sendable amount guard app.selectedWalletToPayFrom == .onchain else { return } diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index 3122e43f0..e3fedcc20 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -87,6 +87,48 @@ final class NumberPadTests: XCTestCase { XCTAssertEqual(viewModel.amountSats, 999) } + func testMaxExceededCountIncrementsWhenBlockedByOverride() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + viewModel.maxAmountOverride = 100 + + viewModel.handleNumberPadInput("9", currency: currency) + viewModel.handleNumberPadInput("9", currency: currency) + XCTAssertEqual(viewModel.maxExceededCount, 0) // 99 is within the cap + + viewModel.handleNumberPadInput("9", currency: currency) // 999 > 100 -> blocked + XCTAssertEqual(viewModel.maxExceededCount, 1) + + viewModel.handleNumberPadInput("9", currency: currency) // blocked again + XCTAssertEqual(viewModel.maxExceededCount, 2) + } + + func testMaxExceededCountNotIncrementedByGlobalCap() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + + // No override: exceeding the global max blocks input but stays silent + for digit in "999999999" { + viewModel.handleNumberPadInput(String(digit), currency: currency) + } + viewModel.handleNumberPadInput("0", currency: currency) + XCTAssertEqual(viewModel.amountSats, 999_999_999) // blocked + XCTAssertEqual(viewModel.maxExceededCount, 0) + } + + func testMaxExceededCountNotIncrementedByDelete() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + + // Prefilled amount above a low cap (e.g. an invoice over the available balance) + viewModel.maxAmountOverride = 1000 + viewModel.updateFromSats(123_456, currency: currency) + + viewModel.handleNumberPadInput("delete", currency: currency) + XCTAssertEqual(viewModel.amountSats, 12345) // delete applies + XCTAssertEqual(viewModel.maxExceededCount, 0) + } + // MARK: - Classic Bitcoin Tests func testClassicBitcoinDecimalInput() { diff --git a/changelog.d/next/346.fixed.md b/changelog.d/next/346.fixed.md index e50423590..5adcfdd88 100644 --- a/changelog.d/next/346.fixed.md +++ b/changelog.d/next/346.fixed.md @@ -1 +1 @@ -Amount entry across the send, spending, LNURL, and channel-funding screens now caps the number pad at your available balance, so you can no longer enter more than you can send. +Amount entry across the send, spending, LNURL, and channel-funding screens now caps the number pad at your available balance and briefly warns when you try to enter more than you can send.