From f597f9f7b251621dd937c652233d5b21a41c7eb2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 12 Oct 2026 05:39:20 -0500 Subject: [PATCH 1/3] fix: show Lightning invoice fee estimates --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../viewmodels/AppViewModelSendFlowTest.kt | 53 ++++++++++++++++++- changelog.d/next/1461.fixed.md | 1 + journeys/send/lightning-fee-review.xml | 19 +++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 changelog.d/next/1461.fixed.md create mode 100644 journeys/send/lightning-fee-review.xml diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 418b2bf8eb..8b6a154d32 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4152,6 +4152,7 @@ class AppViewModel @Inject constructor( if (amount > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) + estimateLightningRoutingFeesIfNeeded() navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ce0a4a79d7..da8166923d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -546,6 +546,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } .thenReturn(Result.success(2u)) whenever(lightningRepo.canSend(any())).thenReturn(true) + whenever { lightningRepo.estimateRoutingFees(any()) }.thenReturn(Result.failure(Exception("No route"))) + whenever { lightningRepo.estimateRoutingFeesForAmount(any(), any()) } + .thenReturn(Result.failure(Exception("No route"))) whenever(toastManager.currentToast).thenReturn(toastState) } @@ -5285,14 +5288,59 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `fixed lightning invoice has valid amount`() = test { + fun `fixed lightning invoice opens confirmation with its amount and routing fee`() = test { val bolt11 = "lnbcrt1fixedamount" stubLightningScan(bolt11 = bolt11, amountSats = 1_000uL) + whenever(lightningRepo.estimateRoutingFees(bolt11)).thenReturn(Result.success(2uL)) + sut.setIsAuthenticated(true) sut.onScanResult(bolt11) advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertTrue(sut.sendUiState.value.isAmountInputValid) + assertEquals(1_000uL, sut.sendUiState.value.amount) + assertEquals(2L, sut.sendUiState.value.lightningFeeSats) + } + + @Test + fun `fixed lightning invoice remains reviewable when routing fee estimation fails`() = test { + val bolt11 = "lnbcrt1unavailablefee" + stubLightningScan(bolt11 = bolt11, amountSats = 1_000uL) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(1_000uL, sut.sendUiState.value.amount) + assertNull(sut.sendUiState.value.lightningFeeSats) + } + + @Test + fun `new scan cancels pending routing fee estimation before opening confirmation`() = test { + val firstInvoice = "lnbcrt1pendingfee" + val latestInvoice = "lnbcrt1latestfee" + val estimateStarted = CompletableDeferred() + stubLightningScan(bolt11 = firstInvoice, amountSats = 1_000uL) + stubLightningScan(bolt11 = latestInvoice, amountSats = 2_000uL) + whenever(lightningRepo.estimateRoutingFees(firstInvoice)).doSuspendableAnswer { + estimateStarted.complete(Unit) + awaitCancellation() + } + whenever(lightningRepo.estimateRoutingFees(latestInvoice)).thenReturn(Result.success(3uL)) + sut.setIsAuthenticated(true) + + sut.onScanResult(firstInvoice) + estimateStarted.await() + assertNull(sut.currentSheet.value) + sut.onScanResult(latestInvoice) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(latestInvoice, sut.sendUiState.value.decodedInvoice?.bolt11) + assertEquals(2_000uL, sut.sendUiState.value.amount) + assertEquals(3L, sut.sendUiState.value.lightningFeeSats) } @Test @@ -7430,6 +7478,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever(lightningRepo.estimateRoutingFeesForAmount(bolt11, request.amountSats)) + .thenReturn(Result.success(4uL)) val privateContext = stubOpenedPaymentRequest(request, bolt11) whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) @@ -7441,6 +7491,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertEquals(4L, sut.sendUiState.value.lightningFeeSats) assertTrue(sut.sendUiState.value.isAmountInputValid) assertTrue(sut.sendUiState.value.isPaymentRequest) assertEquals( diff --git a/changelog.d/next/1461.fixed.md b/changelog.d/next/1461.fixed.md new file mode 100644 index 0000000000..4cee665c03 --- /dev/null +++ b/changelog.d/next/1461.fixed.md @@ -0,0 +1 @@ +Fixed missing fee estimates when reviewing Lightning invoices with a set amount. diff --git a/journeys/send/lightning-fee-review.xml b/journeys/send/lightning-fee-review.xml new file mode 100644 index 0000000000..44c92cf77d --- /dev/null +++ b/journeys/send/lightning-fee-review.xml @@ -0,0 +1,19 @@ + + + Verifies that fixed-amount Lightning invoices and Paykit requests show an available numeric + routing-fee estimate before payment approval. Preconditions: two owned regtest wallets linked + as contacts, usable Spending channels with a nonzero route fee, and QuickPay disabled on the + sending wallet. Keep both apps running. Review and cancel each payment without submitting it. + + + On the receiving wallet, create a fresh 1,000-sat Lightning invoice from Receive and copy its complete invoice + Open that invoice on the sending wallet with a lightning: deep link + Verify confirmation shows 1,000 sats without requiring amount entry + Open Show Details and verify Spending, the recipient invoice and a numeric estimated fee + Dismiss the send review without swiping to pay and verify no outgoing payment was created + On the receiving wallet, create a fresh 1,000-sat payment request for the sending contact with Spending as its only accepted payment method + On the sending wallet, open the received payment request and tap Pay if it has not opened automatically + Open Show Details and verify confirmation shows 1,000 sats, Spending, the receiving contact and a numeric estimated fee + Dismiss the send review without swiping to pay and verify the request remains unpaid + + From 90b8dd48d85c5b9228f25ece4be8d7763f13b9e2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 12 Oct 2026 07:33:51 -0500 Subject: [PATCH 2/3] fix: ignore stale invoice fee estimates --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8b6a154d32..bcc191d08e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4152,7 +4152,9 @@ class AppViewModel @Inject constructor( if (amount > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) + val generation = sendStateGeneration estimateLightningRoutingFeesIfNeeded() + if (generation != sendStateGeneration) return navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } @@ -5549,6 +5551,7 @@ class AppViewModel @Inject constructor( } private suspend fun estimateLightningRoutingFeesIfNeeded() { + val generation = sendStateGeneration val currentState = _sendUiState.value if (currentState.payMethod != SendMethod.LIGHTNING) return val decodedInvoice = currentState.decodedInvoice ?: return @@ -5561,6 +5564,7 @@ class AppViewModel @Inject constructor( feeResult.onSuccess { fee -> _sendUiState.update { + if (generation != sendStateGeneration) return@update it it.copy( lightningFeeSats = fee.toLong(), lastLightningFee = fee.toLong(), diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index da8166923d..4cd294c464 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -5343,6 +5343,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(3L, sut.sendUiState.value.lightningFeeSats) } + @Test + fun `reset send flow ignores a pending invoice routing fee and confirmation`() = test { + val bolt11 = "lnbcrt1pendingfee" + val estimateStarted = CompletableDeferred() + val feeEstimate = CompletableDeferred>() + stubLightningScan(bolt11 = bolt11, amountSats = 1_000uL) + whenever(lightningRepo.estimateRoutingFees(bolt11)).doSuspendableAnswer { + estimateStarted.complete(Unit) + feeEstimate.await() + } + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + estimateStarted.await() + val newSheet = Sheet.Send(SendRoute.Recipient) + sut.showSheet(newSheet) + sut.resetSendState() + advanceUntilIdle() + val newState = sut.sendUiState.value + + feeEstimate.complete(Result.success(2uL)) + advanceUntilIdle() + + assertEquals(newState, sut.sendUiState.value) + assertEquals(newSheet, sut.currentSheet.value) + } + @Test fun `fixed LNURL payment has valid amount`() = test { val lnurl = "lnurl1fixedamount" From 0d7d9292a9c94f16af36ac3ac2d8a9929032d00b Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 12 Oct 2026 08:30:20 -0500 Subject: [PATCH 3/3] fix: open lightning review before fee estimation --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index bcc191d08e..be41023b6e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4152,10 +4152,8 @@ class AppViewModel @Inject constructor( if (amount > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) - val generation = sendStateGeneration - estimateLightningRoutingFeesIfNeeded() - if (generation != sendStateGeneration) return navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) + estimateLightningRoutingFeesIfNeeded() return } Logger.info("No amount found in invoice, proceeding to enter amount", context = TAG) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4cd294c464..14dc65d9ae 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -5288,18 +5288,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `fixed lightning invoice opens confirmation with its amount and routing fee`() = test { + fun `fixed lightning invoice opens confirmation before its routing fee returns`() = test { val bolt11 = "lnbcrt1fixedamount" + val estimateStarted = CompletableDeferred() + val feeEstimate = CompletableDeferred>() stubLightningScan(bolt11 = bolt11, amountSats = 1_000uL) - whenever(lightningRepo.estimateRoutingFees(bolt11)).thenReturn(Result.success(2uL)) + whenever(lightningRepo.estimateRoutingFees(bolt11)).doSuspendableAnswer { + estimateStarted.complete(Unit) + feeEstimate.await() + } sut.setIsAuthenticated(true) sut.onScanResult(bolt11) + estimateStarted.await() + advanceUntilIdle() + val pendingSheet = sut.currentSheet.value + val pendingState = sut.sendUiState.value + feeEstimate.complete(Result.success(2uL)) advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), pendingSheet) + assertTrue(pendingState.isAmountInputValid) + assertEquals(1_000uL, pendingState.amount) + assertNull(pendingState.lightningFeeSats) assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - assertTrue(sut.sendUiState.value.isAmountInputValid) - assertEquals(1_000uL, sut.sendUiState.value.amount) assertEquals(2L, sut.sendUiState.value.lightningFeeSats) } @@ -5318,7 +5330,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `new scan cancels pending routing fee estimation before opening confirmation`() = test { + fun `new scan replaces an invoice with pending routing fee estimation`() = test { val firstInvoice = "lnbcrt1pendingfee" val latestInvoice = "lnbcrt1latestfee" val estimateStarted = CompletableDeferred() @@ -5333,7 +5345,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(firstInvoice) estimateStarted.await() - assertNull(sut.currentSheet.value) + advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) sut.onScanResult(latestInvoice) advanceUntilIdle()