diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 418b2bf8eb..8a578796ac 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3905,6 +3905,17 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(payMethod = SendMethod.ONCHAIN) } } } + if (_sendUiState.value.payMethod == SendMethod.ONCHAIN && amount <= Defaults.dustLimit.toULong()) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__lnurl_pay__error_min__title), + description = context.getString(R.string.wallet__lnurl_pay__error_min__description) + .replace("{amount}", formatMoneyValue(Defaults.dustLimit.toULong().safe() + 1uL.safe())), + testTag = "PaymentRequestAmountTooLowToast", + ) + clearActiveContactPaymentContext(retryIncomingRequest = false) + return + } if ( !validateAmount(amount) && _sendUiState.value.payMethod == SendMethod.ONCHAIN && diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ce0a4a79d7..e71a122a56 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -7509,6 +7509,157 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `incoming onchain requests below minimum explain the minimum instead of a shortfall`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(context.getString(R.string.wallet__lnurl_pay__error_min__title)).thenReturn("Amount Too Low") + whenever(context.getString(R.string.wallet__lnurl_pay__error_min__description)) + .thenReturn("The minimum amount for this invoice is ₿ {amount}.") + whenever(formatMoneyValue(547uL)).thenReturn("547") + sut.setIsAuthenticated(true) + runCurrent() + + for (amount in listOf(0uL, 1uL, 546uL)) { + val request = paymentRequest().copy(amountSats = amount) + stubIncomingOnchainRequest(request) + sut.openContactPayment( + paymentRequest = REGTEST_ADDRESS, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + runCurrent() + + assertFalse(sut.sendUiState.value.isAmountInputValid) + assertNull(activeContactPaymentContext()) + } + + verify(toastManager, times(3)).enqueue( + check { + assertEquals("PaymentRequestAmountTooLowToast", it.testTag) + assertEquals("Amount Too Low", it.title) + assertEquals("The minimum amount for this invoice is ₿ 547.", it.description) + }, + ) + verify(context, never()).getString(R.string.other__pay_insufficient_savings_amount_description) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + } + + @Test + fun `below minimum request closes preparation and does not block another request`() = test { + val request = paymentRequest().copy(amountSats = 100uL) + stubIncomingOnchainRequest(request) + whenever(context.getString(R.string.wallet__lnurl_pay__error_min__title)).thenReturn("Amount Too Low") + whenever(context.getString(R.string.wallet__lnurl_pay__error_min__description)).thenReturn("Minimum: {amount}") + whenever(formatMoneyValue(547uL)).thenReturn("547") + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { + delay(250) + Result.success(PublicPaykitPaymentResult.Opened(REGTEST_ADDRESS, privatePaymentContext(7uL))) + } + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + sut.setIsAuthenticated(true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + + advanceTimeBy(250) + runCurrent() + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + assertEquals(listOf(request), pendingPaykitPaymentRequests.value) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS + 250) + runCurrent() + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + + val payable = paymentRequest().copy(paymentRequestId = "payable-request", amountSats = 547uL) + stubIncomingOnchainRequest(payable) + pendingPaykitPaymentRequests.value = listOf(request, payable) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(payable, activeContactPaymentContext()?.incomingPaymentRequest) + assertTrue(sut.sendUiState.value.isAmountInputValid) + } + + @Test + fun `incoming onchain request at minimum remains payable`() = test { + val request = paymentRequest().copy(amountSats = 547uL) + stubIncomingOnchainRequest(request) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + sut.setIsAuthenticated(true) + runCurrent() + + sut.openContactPayment( + paymentRequest = REGTEST_ADDRESS, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + runCurrent() + + assertTrue(sut.sendUiState.value.isAmountInputValid) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + verify(context, never()).getString(R.string.wallet__lnurl_pay__error_min__description) + } + + @Test + fun `incoming onchain request with insufficient savings still reports the actual shortfall`() = test { + val request = paymentRequest() + stubIncomingOnchainRequest(request) + balanceState.value = BalanceState(maxSendOnchainSats = 1_000u) + whenever(context.getString(R.string.other__pay_insufficient_savings)).thenReturn("Insufficient Savings") + whenever(context.getString(R.string.other__pay_insufficient_savings_amount_description)) + .thenReturn("₿ {amount} more needed to pay this Bitcoin invoice.") + whenever(formatMoneyValue(1_500uL)).thenReturn("1500") + sut.setIsAuthenticated(true) + runCurrent() + + sut.openContactPayment( + paymentRequest = REGTEST_ADDRESS, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + runCurrent() + + verify(toastManager).enqueue( + check { + assertEquals("Insufficient Savings", it.title) + assertEquals("₿ 1500 more needed to pay this Bitcoin invoice.", it.description) + }, + ) + assertNull(activeContactPaymentContext()) + verify(context, never()).getString(R.string.wallet__lnurl_pay__error_min__description) + } + + @Test + fun `small incoming unified request remains payable over Lightning`() = test { + val request = paymentRequest().copy(amountSats = 100uL) + val uri = "bitcoin:$REGTEST_ADDRESS?lightning=lnbcrt1smallrequest" + stubUnifiedScan(uri, "lnbcrt1smallrequest", request.amountSats, FOREIGN_NODE_ID.fromHex()) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.estimateRoutingFees("lnbcrt1smallrequest")).thenReturn(Result.success(1uL)) + sut.setIsAuthenticated(true) + runCurrent() + + sut.openContactPayment( + paymentRequest = uri, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + runCurrent() + + assertTrue(sut.sendUiState.value.isAmountInputValid) + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + verify(context, never()).getString(R.string.wallet__lnurl_pay__error_min__description) + } + @Test fun `outgoing payment request creation continues after backgrounding`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") @@ -11272,6 +11423,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) + private suspend fun stubIncomingOnchainRequest(request: PaykitPaymentRequest) { + whenever { coreService.decode(REGTEST_ADDRESS) }.thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, emptyMap())), + ) + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).thenReturn( + ValidationResult(REGTEST_ADDRESS, NetworkType.REGTEST, AddressType.P2WPKH), + ) + stubOpenedPaymentRequest(request, REGTEST_ADDRESS) + } + private suspend fun stubOpenedPaymentRequest( request: PaykitPaymentRequest, paymentRequest: String, diff --git a/changelog.d/next/1457.fixed.md b/changelog.d/next/1457.fixed.md new file mode 100644 index 0000000000..2f2340df21 --- /dev/null +++ b/changelog.d/next/1457.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 c1181d7ad0..0ab84680d3 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -156,6 +156,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `payment-requests/requested-resolution-failure.xml` | same file and journey name; both platforms keep the preparing confirmation open across retries and stop automatic retries when it closes. Native progress and request-row identifiers differ. Android's subscription-reminder preparation has no intermediate Send sheet; see `paykit-clock-changes.md`. | | `payment-requests/request-summary.xml` | same file and journey name; Android additionally checks the open Sent receipt's live lifecycle subtitle after payment. iOS keeps a static note/date receipt without that subtitle, so those checks are not ported. | | `payment-requests/success-dismissal.xml` | same file, journey name and actions; both platforms dismiss successful and unpaid requests without a send error or duplicate payment. | +| `payment-requests/minimum-onchain-amount.xml` | shared — minimum amount feedback, preparation cleanup, and manual retry use the same journey and `PaymentRequestAmountTooLowToast` identifier on both platforms. | | No matching intermediate return route | `amount-limits/custom-fee-wallet-switch.xml` is iOS-only: Custom returns through the fee picker before confirmation. Android's `setTransactionSpeed` returns directly to confirmation. | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 4b7ae32035..6b1d462c10 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -96,6 +96,7 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Contact Detail pay action: `ContactPay` - Request or Pay sheet: `RequestOrPaySheet` (its Pay and Request buttons carry no tag; find them by text) - Payment Request amount screen: `PaymentRequestAmount` +- Below-minimum on-chain request toast: `PaymentRequestAmountTooLowToast` `android layout` can omit test tags applied to plain `Box` and `Column` containers. Use the raw UI Automator hierarchy when a documented container tag is not present in the formatted layout output. @@ -162,6 +163,8 @@ An endpoint rejected as `endpoint_not_payable`, including an already-paid one-ti ends preparation immediately with `PaymentRequestUnavailableToast`. The request stays pending for manual retry, but does not automatically reopen during the current identity's app session. `unpayable-endpoint.xml` checks this using an address already recorded as paid by the payer. +`minimum-onchain-amount.xml` checks requests below the on-chain minimum, including +the boundary at 546 sats, manual retry, and preparation of a new request for 547 sats. Closing during preparation leaves the request pending and suppresses automatic reopening for the current identity's app session; Pay from the request list or details explicitly retries it. An unfunded wallet can verify loading followed by native rejection, not an enabled payment control. diff --git a/journeys/payment-requests/minimum-onchain-amount.xml b/journeys/payment-requests/minimum-onchain-amount.xml new file mode 100644 index 0000000000..6f2615ca72 --- /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 + +