Skip to content
Merged
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/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1554,7 +1554,7 @@ struct AppScene: View {
return
}
guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else {
if app.didRejectScannedPaymentForInsufficientBalance {
if app.didRejectScannedPayment {
PaykitPaymentRequestPresentationCoordinator.handleUnavailablePaymentRoute(
request,
app: app,
Expand Down
8 changes: 4 additions & 4 deletions Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1184,10 +1184,10 @@ enum PaykitPaymentRequestPresentationCoordinator {
manager: PaykitPaymentRequestManager,
resetWalletSendState: () -> Void
) {
let insufficientBalance = app.didRejectScannedPaymentForInsufficientBalance
let paymentRejected = app.didRejectScannedPayment
app.resetSendState()
resetWalletSendState()
manager.handleUnavailablePaymentRoute(request, insufficientBalance: insufficientBalance)
manager.handleUnavailablePaymentRoute(request, paymentRejected: paymentRejected)
}
}

Expand Down Expand Up @@ -2334,8 +2334,8 @@ final class PaykitPaymentRequestManager {
return true
}

func handleUnavailablePaymentRoute(_ request: PaykitPaymentRequest, insufficientBalance: Bool) {
if insufficientBalance {
func handleUnavailablePaymentRoute(_ request: PaykitPaymentRequest, paymentRejected: Bool) {
if paymentRejected {
_ = markPresentedIfPending(request)
} else {
deferPresentation(request)
Expand Down
29 changes: 23 additions & 6 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class AppViewModel: ObservableObject {
@Published var isManualEntryInputValid: Bool = false
@Published var manualEntryValidationResult: ManualEntryValidationResult = .empty
@Published var contactPaymentContext: ContactPaymentContext?
private(set) var didRejectScannedPaymentForInsufficientBalance = false
private(set) var didRejectScannedPayment = false

// LNURL
@Published var lnurlPayData: LnurlPayData?
Expand Down Expand Up @@ -271,7 +271,7 @@ class AppViewModel: ObservableObject {

/// Shows insufficient spending balance toast with amount-specific or generic description
private func showInsufficientSpendingToast(invoiceAmount: UInt64, spendingBalance: UInt64) {
didRejectScannedPaymentForInsufficientBalance = true
didRejectScannedPayment = true
let amountNeeded = invoiceAmount > spendingBalance ? invoiceAmount - spendingBalance : 0
let description = amountNeeded > 0
? t(
Expand All @@ -288,11 +288,27 @@ class AppViewModel: ObservableObject {
)
}

/// Validates onchain balance and shows toast if insufficient. Returns true if sufficient.
private func validateIncomingOnchainPaymentRequestAmount() -> Bool {
guard let amount = contactPaymentContext?.incomingPaymentRequest?.amountSats, amount < Env.dustLimit else { return true }
didRejectScannedPayment = true
toast(
type: .error,
title: t("wallet__lnurl_pay__error_min__title"),
description: t(
"wallet__lnurl_pay__error_min__description",
variables: ["amount": CurrencyFormatter.formatSats(UInt64(Env.dustLimit))]
),
accessibilityIdentifier: "PaymentRequestAmountTooLowToast"
)
return false
}

/// Validates the onchain request amount and balance, showing feedback when invalid.
private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool {
guard validateIncomingOnchainPaymentRequestAmount() else { return false }
if invoiceAmount > 0 {
guard onchainBalance >= invoiceAmount else {
didRejectScannedPaymentForInsufficientBalance = true
didRejectScannedPayment = true
let amountNeeded = invoiceAmount - onchainBalance
toast(
type: .error,
Expand All @@ -308,7 +324,7 @@ class AppViewModel: ObservableObject {
} else {
// Zero-amount invoice: user must have some balance to proceed
guard onchainBalance > 0 else {
didRejectScannedPaymentForInsufficientBalance = true
didRejectScannedPayment = true
toast(
type: .error,
title: t("other__pay_insufficient_savings"),
Expand Down Expand Up @@ -571,7 +587,7 @@ extension AppViewModel {
}
}
scannedDataHandlingId = handlingId
didRejectScannedPaymentForInsufficientBalance = false
didRejectScannedPayment = false
defer {
if scannedDataHandlingId == handlingId {
scannedDataHandlingId = nil
Expand Down Expand Up @@ -750,6 +766,7 @@ extension AppViewModel {

// Fallback to on-chain if address is available
guard !invoice.address.isEmpty else { return }
guard validateIncomingOnchainPaymentRequestAmount() else { return }

// If node is running, validate balance immediately
if paymentState.isNodeRunning {
Expand Down
14 changes: 12 additions & 2 deletions Bitkit/Views/Wallets/Send/SendSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,9 +438,19 @@ struct SendSheet: View {
}
}

/// Validates onchain balance and shows toast + dismisses sheet if insufficient.
/// Returns true if sufficient, false if insufficient.
/// Validates the onchain request amount and balance, showing setup failure feedback when invalid.
private func validateOnchainBalanceAndDismissIfInsufficient(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool {
if let amount = app.contactPaymentContext?.incomingPaymentRequest?.amountSats, amount < Env.dustLimit {
showPaymentSetupFailure(
title: t("wallet__lnurl_pay__error_min__title"),
description: t(
"wallet__lnurl_pay__error_min__description",
variables: ["amount": CurrencyFormatter.formatSats(UInt64(Env.dustLimit))]
),
accessibilityIdentifier: "PaymentRequestAmountTooLowToast"
)
return false
}
if invoiceAmount > 0 {
guard onchainBalance >= invoiceAmount else {
let amountNeeded = invoiceAmount - onchainBalance
Expand Down
215 changes: 213 additions & 2 deletions BitkitTests/PaykitPaymentRequestServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
import LDKNode
import Observation
import Paykit
import SwiftUI
import UIKit
import UserNotifications
import XCTest
Expand Down Expand Up @@ -3718,7 +3719,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {

try app.handleLnurlPayInvoice(data)
XCTAssertEqual(checkedAmount, request.amountSats)
XCTAssertTrue(app.didRejectScannedPaymentForInsufficientBalance)
XCTAssertTrue(app.didRejectScannedPayment)
XCTAssertNil(app.lnurlPayData)

hasCapacity = true
Expand Down Expand Up @@ -4202,6 +4203,209 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
}

func testBelowMinimumOnchainRequestShowsMinimumAndRemainsRetryable() async throws {
for nodeIsRunning in [false, true] {
for amount in ["0.00000001", "0.000001", "0.00000546"] {
let method = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh)
let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(amount: amount, endpoints: [method.rawValue])])
let manager = paymentRequestManager(sdk: sdk)
await manager.refresh()
let request = try XCTUnwrap(manager.requestsForPresentation().first)
let app = AppViewModel(
sheetViewModel: SheetViewModel(),
navigationViewModel: NavigationViewModel(),
scanPaymentOperations: ScanPaymentOperations(
state: {
ScanPaymentState(
isNodeRunning: nodeIsRunning,
spendableOnchainBalanceSats: 100_000,
totalLightningBalanceSats: 0,
hasChannels: false,
hasUsableChannels: false
)
},
canSendLightning: { _ in false }
)
)
defer { ToastWindowManager.shared.hideToast() }

for _ in 0 ..< 2 {
let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request)
XCTAssertTrue(app.claimContactPaymentContext(context))
try await app.handleScannedData(
"bitcoin:bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x",
claimedContactPaymentContext: context
)

XCTAssertNil(app.scannedOnchainInvoice)
XCTAssertNil(app.scannedLightningInvoice)
XCTAssertTrue(app.didRejectScannedPayment)
let toast = try XCTUnwrap(ToastWindowManager.shared.currentToast)
XCTAssertEqual(toast.accessibilityIdentifier, "PaymentRequestAmountTooLowToast")
XCTAssertEqual(toast.title, t("wallet__lnurl_pay__error_min__title"))
XCTAssertEqual(toast.description, t(
"wallet__lnurl_pay__error_min__description",
variables: ["amount": CurrencyFormatter.formatSats(547)]
))
var didResetWalletSendState = false
PaykitPaymentRequestPresentationCoordinator.handleUnavailablePaymentRoute(
request, app: app, manager: manager,
resetWalletSendState: { didResetWalletSendState = true }
)
XCTAssertTrue(didResetWalletSendState)
XCTAssertNil(app.contactPaymentContext)
XCTAssertEqual(manager.pendingRequests, [request])
XCTAssertTrue(manager.requestsForPresentation().isEmpty)
XCTAssertTrue(manager.requestPresentation(request))
}
let snapshot = await sdk.snapshot()
XCTAssertTrue(snapshot.acceptedRequests.isEmpty)
}
}
}

func testBelowMinimumUnifiedRequestClosesSheetAfterSyncFallback() async throws {
let onchainMethod = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh)
let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(
amount: "0.000001", endpoints: [onchainMethod.rawValue, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue]
)])
let manager = paymentRequestManager(sdk: sdk)
await manager.refresh()
let request = try XCTUnwrap(manager.requestsForPresentation().first)
let sheets = SheetViewModel()
let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel())
let wallet = PaymentRequestSendWallet()
let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request)
XCTAssertTrue(app.claimContactPaymentContext(context))
app.selectedWalletToPayFrom = .lightning
app.scannedOnchainInvoice = OnChainInvoice(
address: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x", amountSatoshis: 0, label: nil, message: nil, params: nil
)
app.scannedLightningInvoice = LightningInvoice(
bolt11: "test-invoice", paymentHash: String(repeating: "00", count: 32).hexaData,
amountSatoshis: 0, timestampSeconds: 0, expirySeconds: 0, isExpired: false,
description: nil, networkType: .regtest, payeeNodeId: nil
)
sheets.showSheet(.send, data: SendConfig(view: .confirm, onDismiss: {
manager.dismissPreparingRequest(request)
app.resetSendState()
}))
let view = SendSheet(config: SendSheetItem(initialRoute: .confirm))
.environment(manager)
.environment(HwWalletManager())
.environment(TrezorManager())
.environmentObject(app)
.environmentObject(wallet as WalletViewModel)
.environmentObject(sheets)
.environmentObject(NetworkMonitor())
.environmentObject(SettingsViewModel.shared)
.environmentObject(TagManager())
.environmentObject(PubkyProfileManager())
.environmentObject(ActivityListViewModel())
.environmentObject(ContactsManager())
.environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService()))
.environmentObject(FeeEstimatesManager())
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
window.rootViewController?.view.layoutIfNeeded()
defer {
window.isHidden = true
window.rootViewController = nil
ToastWindowManager.shared.hideToast()
}
try await waitUntil { wallet.sendAmountSats == request.amountSats }

wallet.nodeLifecycleState = .running
try await waitUntil { sheets.activeSheetConfiguration == nil }

let toast = try XCTUnwrap(ToastWindowManager.shared.currentToast)
XCTAssertEqual(toast.accessibilityIdentifier, "PaymentRequestAmountTooLowToast")
XCTAssertEqual(toast.description, t(
"wallet__lnurl_pay__error_min__description",
variables: ["amount": CurrencyFormatter.formatSats(547)]
))
XCTAssertNil(app.contactPaymentContext)
XCTAssertEqual(manager.pendingRequests, [request])
XCTAssertTrue(manager.requestsForPresentation().isEmpty)
XCTAssertTrue(manager.requestPresentation(request))
let snapshot = await sdk.snapshot()
XCTAssertTrue(snapshot.acceptedRequests.isEmpty)
}

func testMinimumOnchainRequestCanOpenPayment() async throws {
let method = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh)
let request = try XCTUnwrap(PaykitPaymentRequest(
record: paymentRequestRecord(amount: "0.00000547", endpoints: [method.rawValue]), now: Date()
))
let app = AppViewModel(
sheetViewModel: SheetViewModel(),
navigationViewModel: NavigationViewModel(),
scanPaymentOperations: ScanPaymentOperations(
state: {
ScanPaymentState(
isNodeRunning: true,
spendableOnchainBalanceSats: 100_000,
totalLightningBalanceSats: 0,
hasChannels: false,
hasUsableChannels: false
)
},
canSendLightning: { _ in false }
)
)
let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request)
XCTAssertTrue(app.claimContactPaymentContext(context))

try await app.handleScannedData(
"bitcoin:bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x",
claimedContactPaymentContext: context
)

XCTAssertNotNil(app.scannedOnchainInvoice)
XCTAssertNil(app.scannedLightningInvoice)
XCTAssertFalse(app.didRejectScannedPayment)
XCTAssertEqual(app.selectedWalletToPayFrom, .onchain)
XCTAssertTrue(app.ownsContactPaymentContext(context))
}

func testLightningRequestBelowOnchainMinimumCanOpenPayment() throws {
let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord(amount: "0.000001"), now: Date()))
var checkedAmount: UInt64?
let app = AppViewModel(
sheetViewModel: SheetViewModel(),
navigationViewModel: NavigationViewModel(),
scanPaymentOperations: ScanPaymentOperations(
state: {
ScanPaymentState(
isNodeRunning: true,
spendableOnchainBalanceSats: 0,
totalLightningBalanceSats: 10000,
hasChannels: true,
hasUsableChannels: true
)
},
canSendLightning: {
checkedAmount = $0
return true
}
)
)
XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request)))
let data = LnurlPayData(
uri: "https://example.com/pay", callback: "https://example.com/callback",
minSendable: 1000, maxSendable: 5_000_000,
metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil
)

try app.handleLnurlPayInvoice(data)

XCTAssertEqual(checkedAmount, 100)
XCTAssertNotNil(app.lnurlPayData)
XCTAssertEqual(app.selectedWalletToPayFrom, .lightning)
XCTAssertFalse(app.didRejectScannedPayment)
}

func testUnaffordableRequestUsesRequestedAmountAndStopsAutomaticPresentation() async throws {
let clock = PaymentRequestTestClock(Date())
let onchainMethod = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh)
Expand Down Expand Up @@ -4241,7 +4445,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {

XCTAssertNil(app.scannedOnchainInvoice)
XCTAssertNil(app.scannedLightningInvoice)
XCTAssertTrue(app.didRejectScannedPaymentForInsufficientBalance)
XCTAssertTrue(app.didRejectScannedPayment)

var didResetWalletSendState = false
PaykitPaymentRequestPresentationCoordinator.handleUnavailablePaymentRoute(
Expand Down Expand Up @@ -8740,3 +8944,10 @@ private actor HardwarePaymentProofMemoryStore: PaykitPaymentProofStoring {
self.proofs = proofs
}
}

@MainActor
private final class PaymentRequestSendWallet: WalletViewModel {
override func setFeeRate(speed: TransactionSpeed, isCurrentSend: () -> Bool = { true }) async throws {
selectedFeeRateSatsPerVByte = 2
}
}
1 change: 1 addition & 0 deletions changelog.d/next/913.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Payment requests below the minimum Bitcoin amount now show the minimum and close payment preparation.
4 changes: 4 additions & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,7 @@ the same file on both sides: `xcodebuildmcp` here, the `android` CLI against a `
checkout there (`android layout` is the `snapshot-ui` equivalent). A disagreement is worth writing
down — as an intentional platform difference, or as something to look into — but it is not by itself
a bug report. `AGENTS.md` has the commands.

`payment-requests/minimum-onchain-amount.xml` is shared with Android: minimum amount
feedback, preparation cleanup, and manual retry use the same actions and
`PaymentRequestAmountTooLowToast` identifier on both platforms.
Loading
Loading