Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit/Components/NumberPadTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions Bitkit/ViewModels/AmountInputViewModel.swift
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 = ""
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 18 additions & 2 deletions Bitkit/Views/Transfer/FundManualAmountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion Bitkit/Views/Transfer/SpendingAdvancedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
Expand Down Expand Up @@ -108,6 +108,7 @@ struct SpendingAdvancedView: View {
)

updateFeeEstimate()
updateInputCap()
}
.onChange(of: lspBalance) {
if isValid {
Expand All @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion Bitkit/Views/Transfer/SpendingAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion Bitkit/Views/Wallets/Send/LnurlPayAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
12 changes: 11 additions & 1 deletion Bitkit/Views/Wallets/Send/SendAmountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -163,6 +163,8 @@ struct SendAmountView: View {
await calculateRoutingFee()
}
}

updateInputCap()
}
.onChange(of: app.selectedWalletToPayFrom) { _, newValue in
// Recalculate max sendable amount when switching wallet types
Expand All @@ -186,6 +188,9 @@ struct SendAmountView: View {
}
}
}
.onChange(of: availableAmount) { _, _ in
updateInputCap()
}
}

private func onContinue() async {
Expand Down Expand Up @@ -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 }
Expand Down
63 changes: 63 additions & 0 deletions BitkitTests/NumberPadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/346.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/next/585.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
CypherPoet marked this conversation as resolved.