From d4d8599f90a0c3260f7890b9c092a0769af809e6 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Fri, 5 Jun 2026 14:25:05 -0500 Subject: [PATCH 1/2] 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/2] 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() {