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 c8b80eb05..760afb898 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -1,11 +1,16 @@ 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`. + var maxAmountOverride: UInt64? // MARK: - Constants @@ -15,6 +20,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 = "" @@ -38,12 +49,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 <= maxAmount { + if isDeletion || newAmount <= effectiveMaxAmount { rawInputText = newText displayText = formatDisplayTextFromAmount(newAmount, currency: currency) amountSats = newAmount @@ -59,7 +77,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 isDeletion || newAmount <= effectiveMaxAmount { // Update both raw input and display text rawInputText = newText // Format with grouping separators but not decimal formatting diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index 880f6debc..351bd4121 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -8,13 +8,21 @@ struct FundManualAmountView: View { let lnPeer: LnPeer - @StateObject private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel = AmountInputViewModel() @State private var didAttemptPeerConnection = false var amountSats: UInt64 { 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..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? @@ -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..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? @@ -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..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)) @@ -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/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 dc1b46832..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 @@ -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/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index d81fe176e..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 @@ -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..3122e43f0 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() { @@ -206,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() { 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. diff --git a/changelog.d/next/585.fixed.md b/changelog.d/next/585.fixed.md new file mode 100644 index 000000000..637e332ea --- /dev/null +++ b/changelog.d/next/585.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.