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.