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
21 changes: 19 additions & 2 deletions Bitkit/ViewModels/AmountInputViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = ""
Expand All @@ -38,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 <= maxAmount {
if isDeletion || newAmount <= effectiveMaxAmount {
rawInputText = newText
displayText = formatDisplayTextFromAmount(newAmount, currency: currency)
amountSats = newAmount
Expand All @@ -59,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 <= maxAmount {
if isDeletion || newAmount <= effectiveMaxAmount {
// Update both raw input and display text
rawInputText = newText
// Format with grouping separators but not decimal formatting
Expand Down
10 changes: 10 additions & 0 deletions Bitkit/Views/Wallets/Send/SendAmountView.swift
Original file line number Diff line number Diff line change
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.
Loading