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/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 c8b80eb05..c586885eb 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -1,11 +1,21 @@ 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? + + /// 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 @@ -15,6 +25,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,17 +54,25 @@ 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 errorKey = nil } else { + notifyMaxExceededIfCapped() Haptics.notify(.warning) errorKey = key DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -59,7 +83,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 @@ -72,6 +96,7 @@ class AmountInputViewModel: ObservableObject { errorKey = nil } else { // Block input when limit exceeded + notifyMaxExceededIfCapped() Haptics.notify(.warning) errorKey = key DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -218,6 +243,14 @@ class AmountInputViewModel: ObservableObject { // 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 880f6debc..58c7bca73 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,24 @@ struct FundManualAmountView: View { .task { await connectToPeerIfNeeded() } + .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 { diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index 825135f64..dbd5f5717 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? @@ -116,6 +116,25 @@ struct SpendingAdvancedView: View { feeEstimate = nil } } + .onChange(of: transfer.transferValues.maxLspBalance, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } + } + + private func updateInputCap() { + let maxLspBalance = transfer.transferValues.maxLspBalance + 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 { diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 60c125bf9..b5e1a5b1e 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,24 @@ struct SpendingAmount: View { await calculateMaxTransferAmount() } } + .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 { diff --git a/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift b/Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift index 450c5b095..1ebfd1bb9 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)) @@ -78,6 +78,23 @@ struct LnurlWithdrawAmount: View { amountViewModel.updateFromSats(UInt64(minAmount), currency: currency) } } + .onChange(of: maxAmount, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } + } + + private func updateInputCap() { + let cap = max(minAmount, maxAmount) + 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() { 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..6cd15e0fc 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,25 @@ struct LnurlPayAmount: View { .navigationBarHidden(true) .padding(.horizontal, 16) .sheetBackground() + .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() { diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index d81fe176e..4259ed948 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 @@ -186,6 +186,8 @@ struct SendAmountView: View { } } } + .onChange(of: availableAmount, initial: true) { updateInputCap() } + .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } private func onContinue() async { @@ -252,6 +254,21 @@ 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 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 e5d8d2d16..e3fedcc20 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -53,6 +53,82 @@ 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) + } + + 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() { @@ -206,6 +282,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..5adcfdd88 --- /dev/null +++ b/changelog.d/next/346.fixed.md @@ -0,0 +1 @@ +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.