diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 667cb2494..0c0f87994 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -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, diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index fb51d067a..0a9914f1d 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -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) } } @@ -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) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 8cd7cc522..962622025 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -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? @@ -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( @@ -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, @@ -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"), @@ -571,7 +587,7 @@ extension AppViewModel { } } scannedDataHandlingId = handlingId - didRejectScannedPaymentForInsufficientBalance = false + didRejectScannedPayment = false defer { if scannedDataHandlingId == handlingId { scannedDataHandlingId = nil @@ -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 { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index e6c79bd2f..d77048145 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -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 diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 69825d4bc..057f382ef 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4,6 +4,7 @@ import Foundation import LDKNode import Observation import Paykit +import SwiftUI import UIKit import UserNotifications import XCTest @@ -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 @@ -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) @@ -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( @@ -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 + } +} diff --git a/changelog.d/next/913.fixed.md b/changelog.d/next/913.fixed.md new file mode 100644 index 000000000..2f2340df2 --- /dev/null +++ b/changelog.d/next/913.fixed.md @@ -0,0 +1 @@ +Payment requests below the minimum Bitcoin amount now show the minimum and close payment preparation. diff --git a/journeys/README.md b/journeys/README.md index 17c03c1b3..dbf8d20fa 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -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. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 5730098b9..9982f508c 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -189,6 +189,7 @@ the SDK and can push the Pay step well past the budget. - Request or Pay sheet: `RequestOrPaySheet` (its Pay and Request buttons carry no identifier; find them by label). - Payment Request amount screen: `PaymentRequestAmount`. - Terminal feedback: `PaymentRequestUnavailableToast`. +- Minimum-amount feedback: `PaymentRequestAmountTooLowToast`. - Expiration feedback: `PaymentRequestExpiredToast`. - Private-link recovery feedback: `PaymentRequestWaitingForDetailsToast`. - Send failure: `SendFailure` and retry action `Retry`. @@ -252,3 +253,9 @@ are opened from Overview. The journeys therefore record each fixture's payment r its full row identifier, and include the required back and tab transitions. The accepted subscription must have no end date so cancellation is available. The proposal review must explain that its payment details are unsupported and offer no Subscribe control. + +## Minimum on-chain request amount + +`minimum-onchain-amount.xml` checks requests below the on-chain minimum, including +manual retry and a subsequent request at the 547-sat minimum. It uses the same fixture +setup and actions on iOS and Android, and stops before sending funds. diff --git a/journeys/payment-requests/minimum-onchain-amount.xml b/journeys/payment-requests/minimum-onchain-amount.xml new file mode 100644 index 000000000..6f2615ca7 --- /dev/null +++ b/journeys/payment-requests/minimum-onchain-amount.xml @@ -0,0 +1,18 @@ + + + Requires a linked fixture issuer that can send fixed BTC requests with fresh on-chain endpoints. Fund the payer with at least 100,000 spendable sats and leave it without Lightning channels. Checks minimum amount feedback, preparation cleanup, manual retry, and another payable request. Report this journey as blocked if the issuer fixture is unavailable. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities + Set the Bitcoin display unit to sats in Settings + Have the issuer send a new one-time BTC request for 100 sats with a future expiration and a fresh on-chain endpoint + Wait for incoming preparation to finish; verify the loading confirmation closes and the amount-too-low toast (id "PaymentRequestAmountTooLowToast") states the minimum is 547 sats without showing a balance shortfall + Open the pending-request bell (id "PaymentRequestsBell") and verify the unpaid request remains listed + Tap Pay for that request and verify the amount-too-low feedback appears again without leaving a loading confirmation + Have the issuer send a different request for 546 sats bound to a fresh unused on-chain endpoint + Verify the amount-too-low feedback states the minimum is 547 sats and the loading confirmation closes + Have the issuer send a different request for 547 sats bound to a fresh unused on-chain endpoint + Verify the new request reaches an enabled confirmation rather than waiting behind a rejected request + Dismiss the confirmation without paying and verify no new payment was created + +