From d35c43090c28f5b1f57d238655fd56546b0d8ba7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 19:34:30 -0300 Subject: [PATCH 1/6] fix: block paying own lightning invoice Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 40 ++++- app/src/main/res/values/strings.xml | 2 + .../viewmodels/AppViewModelSendFlowTest.kt | 143 +++++++++++++++++- changelog.d/next/901.fixed.md | 1 + 4 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 changelog.d/next/901.fixed.md diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c912066536..5f027136e3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2064,6 +2064,15 @@ class AppViewModel @Inject constructor( return } + if (invoice.isOwnInvoice()) { + showAddressValidationError( + titleRes = R.string.other__pay_self_invoice_title, + descriptionRes = R.string.other__pay_self_invoice_description, + testTag = "SelfPaymentToast", + ) + return + } + if (invoice.amountSatoshis > 0uL) { lightningRepo.syncState() if (!lightningRepo.canSend(invoice.amountSatoshis)) { @@ -2167,6 +2176,13 @@ class AppViewModel @Inject constructor( ) return@takeIf false } + if (lnInv.isOwnInvoice()) { + Logger.debug( + "Skipped own lightning invoice in unified URI, defaulting to onchain", + context = TAG, + ) + return@takeIf false + } lightningRepo.waitForUsableChannels() val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u)) if (!canSend) { @@ -2187,6 +2203,12 @@ class AppViewModel @Inject constructor( } } + private fun LightningInvoice.isOwnInvoice(): Boolean { + val payee = payeeNodeId?.toHex()?.lowercase() ?: return false + val nodeId = lightningRepo.getNodeId()?.lowercase() ?: return false + return payee == nodeId + } + private fun showAddressValidationError( @StringRes titleRes: Int, @StringRes descriptionRes: Int, @@ -3393,7 +3415,7 @@ class AppViewModel @Inject constructor( else -> SendFundingSource.Savings } - @Suppress("ReturnCount") + @Suppress("LongMethod", "ReturnCount") private suspend fun onScanLightning( invoice: LightningInvoice, scanResult: String, @@ -3410,6 +3432,11 @@ class AppViewModel @Inject constructor( return } + if (invoice.isOwnInvoice()) { + rejectOwnInvoiceScan() + return + } + val incomingPaymentRequest = activeIncomingPaymentRequest() if (incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis) == false) { rejectMismatchedPaymentRequest() @@ -3462,6 +3489,17 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } + private fun rejectOwnInvoiceScan() { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__pay_self_invoice_title), + description = context.getString(R.string.other__pay_self_invoice_description), + testTag = "SelfPaymentToast", + ) + clearActiveContactPaymentContext(retryIncomingRequest = false) + hideSheet() + } + private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80e2fbddc7..ece6d23832 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -558,6 +558,8 @@ More ₿ needed to pay this Bitcoin invoice. Insufficient Spending Balance ₿ {amount} more needed to pay this Lightning invoice. + This invoice was created by your own wallet. Share it with someone else to receive a payment. + Cannot Pay Own Invoice Unable To Read QR Incorrect Network Bitkit is currently set to {selectedNetwork} but data is for {dataNetwork}. diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 829cd2bf29..2cd65fe283 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -84,6 +84,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.ext.fromHex import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState import to.bitkit.models.ConvertedAmount @@ -4540,6 +4541,111 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan of own invoice shows self payment toast without paying`() = test { + val bolt11 = "lnbcrt1ownquickpay" + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + sut.setIsAuthenticated(true) + runCurrent() + clearInvocations(toastManager) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + verify(lightningRepo, never()).canSend(any()) + verify(quickPayRepo, never()).canApply(any()) + assertNull(sut.quickPayData.value) + assertNull(sut.sendUiState.value.decodedInvoice) + assertNull(sut.currentSheet.value) + } + + @Test + fun `lightning scan of foreign invoice still uses QuickPay`() = test { + val bolt11 = "lnbcrt1foreignquickpay" + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = FOREIGN_NODE_ID.fromHex()) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value?.data) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + + @Test + fun `lightning scan is not blocked when node id is unavailable`() = test { + val bolt11 = "lnbcrt1ownnodestopped" + stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) + whenever(lightningRepo.getNodeId()).thenReturn(null) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(bolt11, sut.sendUiState.value.decodedInvoice?.bolt11) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `unified scan with own lightning invoice falls back to onchain`() = test { + val bolt11 = "lnbcrt1ownunified" + val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" + stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = OWN_NODE_ID.fromHex()) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + sut.setIsAuthenticated(true) + + sut.onScanResult(uri) + advanceUntilIdle() + + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertNull(sut.sendUiState.value.decodedInvoice) + assertFalse(sut.sendUiState.value.isUnified) + verify(lightningRepo, never()).canSend(any()) + } + + @Test + fun `unified scan with foreign lightning invoice keeps lightning`() = test { + val bolt11 = "lnbcrt1foreignunified" + val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" + stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = FOREIGN_NODE_ID.fromHex()) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.estimateRoutingFees(bolt11)).thenReturn(Result.success(1uL)) + sut.setIsAuthenticated(true) + + sut.onScanResult(uri) + advanceUntilIdle() + + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(bolt11, sut.sendUiState.value.decodedInvoice?.bolt11) + assertTrue(sut.sendUiState.value.isUnified) + } + + @Test + fun `manual input of own lightning invoice shows self payment toast`() = test { + val bolt11 = "lnbcrt1ownmanual" + stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + runCurrent() + clearInvocations(toastManager) + + sut.setSendEvent(SendEvent.AddressChange(bolt11)) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + assertFalse(sut.sendUiState.value.isAddressInputValid) + verify(lightningRepo, never()).canSend(any()) + } + @Test fun `hiding send sheet clears quickPayData`() = test { val bolt11 = "lnbcrt1quickpayhide" @@ -6850,12 +6956,35 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } - private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { + private suspend fun stubLightningScan(bolt11: String, amountSats: ULong, payeeNodeId: ByteArray? = null) { whenever { coreService.decode(bolt11) } - .thenReturn(Scanner.Lightning(lightningInvoice(bolt11, amountSats))) + .thenReturn(Scanner.Lightning(lightningInvoice(bolt11, amountSats, payeeNodeId))) whenever(lightningRepo.canSend(amountSats)).thenReturn(true) } + private suspend fun stubUnifiedScan(uri: String, bolt11: String, amountSats: ULong, payeeNodeId: ByteArray?) { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { coreService.decode(uri) }.thenReturn( + Scanner.OnChain( + OnChainInvoice( + address = REGTEST_ADDRESS, + amountSatoshis = amountSats, + label = null, + message = null, + params = mapOf("lightning" to bolt11), + ) + ) + ) + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).thenReturn( + ValidationResult( + address = REGTEST_ADDRESS, + network = NetworkType.REGTEST, + addressType = AddressType.P2WPKH, + ) + ) + stubLightningScan(bolt11 = bolt11, amountSats = amountSats, payeeNodeId = payeeNodeId) + } + private fun nonOnchainPaymentScans() = listOf( "lnbcrt1hardware" to Scanner.Lightning(lightningInvoice("lnbcrt1hardware", 1_000uL)), "lnurl1hardware" to Scanner.LnurlPay( @@ -6889,7 +7018,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { return privateContext } - private fun lightningInvoice(bolt11: String, amountSats: ULong) = LightningInvoice( + private fun lightningInvoice( + bolt11: String, + amountSats: ULong, + payeeNodeId: ByteArray? = null, + ) = LightningInvoice( bolt11 = bolt11, paymentHash = byteArrayOf(1, 2, 3), amountSatoshis = amountSats, @@ -6898,7 +7031,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isExpired = false, description = "", networkType = NetworkType.REGTEST, - payeeNodeId = null, + payeeNodeId = payeeNodeId, ) private suspend fun enablePublicPaykitSharing() { @@ -7130,3 +7263,5 @@ private const val SAMROCK_SETUP_URL = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" private const val HARDWARE_WALLET_ID = "trezor:wallet" private const val REGTEST_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" +private const val OWN_NODE_ID = "02abababababababababababababababababababababababababababababababab" +private const val FOREIGN_NODE_ID = "03cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" diff --git a/changelog.d/next/901.fixed.md b/changelog.d/next/901.fixed.md new file mode 100644 index 0000000000..a02a75e425 --- /dev/null +++ b/changelog.d/next/901.fixed.md @@ -0,0 +1 @@ +Scanning or opening an invoice created by your own wallet now shows an error instead of attempting to pay it. From 2b2a3a6fe1bf7e898bc78acb84ceb123888d92c5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 19:49:00 -0300 Subject: [PATCH 2/6] fix: wait for node id and flag own unified invoices Co-Authored-By: Claude Opus 5 (1M context) --- .../to/bitkit/repositories/LightningRepo.kt | 8 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 100 ++++++++----- .../bitkit/repositories/LightningRepoTest.kt | 30 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 131 +++++++++++++++++- changelog.d/next/901.fixed.md | 2 +- 5 files changed, 227 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 41fc0b3621..f9a0fc49fd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1651,6 +1651,10 @@ class LightningRepo @Inject constructor( fun getNodeId(): String? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.nodeId else null + suspend fun awaitNodeId(): String? = executeWhenNodeRunning("awaitNodeId", NODE_ID_WAIT_TIMEOUT) { + runCatching { requireNotNull(lightningService.nodeId) { "Node id not available" } } + }.getOrNull() + fun getBalances(): BalanceDetails? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.balances else null @@ -2063,6 +2067,10 @@ class LightningRepo @Inject constructor( private val BACKGROUND_STOP_DELAY = 5.seconds private val CHANNELS_USABLE_TIMEOUT = 15.seconds private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds + + /** Max time to wait for a starting node before its id is treated as unavailable. */ + private val NODE_ID_WAIT_TIMEOUT = 15.seconds + val SEND_LN_TIMEOUT = 10.seconds private val PROBE_TIMEOUT = 60.seconds private val PAYMENT_ROUTING_REFRESH_TIMEOUT = 20.seconds diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5f027136e3..be3e3072cc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2091,6 +2091,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isAddressInputValid = true) } } + @Suppress("LongMethod", "ReturnCount") private suspend fun validateOnChainAddress(invoice: OnChainInvoice) { val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } .getOrElse { @@ -2133,6 +2134,14 @@ class AppViewModel @Inject constructor( val maxSendOnchain = maximumAvailableOnchainSats(selectedMaxSendOnchain, hardwareWalletId) if (maxSendOnchain == 0uL) { + if (hardwareWalletId == null && hasOwnLightningInvoice(invoice.params)) { + showAddressValidationError( + titleRes = R.string.other__pay_self_invoice_title, + descriptionRes = R.string.other__pay_self_invoice_description, + testTag = "SelfPaymentToast", + ) + return + } showAddressValidationError( titleRes = R.string.other__pay_insufficient_savings, descriptionRes = R.string.other__pay_insufficient_savings_description, @@ -2164,51 +2173,58 @@ class AppViewModel @Inject constructor( } private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = - params?.get("lightning")?.let { bolt11 -> - runSuspendCatching { coreService.decode(bolt11) }.getOrNull() - ?.let { it as? Scanner.Lightning } - ?.invoice - ?.takeIf { lnInv -> - if (lnInv.isExpired) { - Logger.debug( - "Lightning invoice expired in unified URI, defaulting to onchain-only", - context = TAG - ) - return@takeIf false - } - if (lnInv.isOwnInvoice()) { - Logger.debug( - "Skipped own lightning invoice in unified URI, defaulting to onchain", - context = TAG, - ) - return@takeIf false - } - lightningRepo.waitForUsableChannels() - val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u)) - if (!canSend) { - val nodeState = lightningRepo.lightningState.value.nodeLifecycleState - if (nodeState is NodeLifecycleState.Stopped) { - Logger.debug( - "Node stopped, optimistically including LN invoice in unified QR", - context = TAG, - ) - return@takeIf true - } + decodeLightningParam(params) + ?.takeIf { lnInv -> + if (lnInv.isExpired) { + Logger.debug( + "Lightning invoice expired in unified URI, defaulting to onchain-only", + context = TAG + ) + return@takeIf false + } + if (lnInv.isOwnInvoice()) { + Logger.debug( + "Skipped own lightning invoice in unified URI, defaulting to onchain", + context = TAG, + ) + return@takeIf false + } + lightningRepo.waitForUsableChannels() + val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u)) + if (!canSend) { + val nodeState = lightningRepo.lightningState.value.nodeLifecycleState + if (nodeState is NodeLifecycleState.Stopped) { Logger.debug( - "Cannot pay unified invoice using LN, defaulting to onchain-only", + "Node stopped, optimistically including LN invoice in unified QR", context = TAG, ) + return@takeIf true } - return@takeIf canSend + Logger.debug( + "Cannot pay unified invoice using LN, defaulting to onchain-only", + context = TAG, + ) } - } + return@takeIf canSend + } + + private suspend fun LightningInvoice.isOwnInvoice(): Boolean = isPayee(lightningRepo.awaitNodeId()) - private fun LightningInvoice.isOwnInvoice(): Boolean { - val payee = payeeNodeId?.toHex()?.lowercase() ?: return false - val nodeId = lightningRepo.getNodeId()?.lowercase() ?: return false - return payee == nodeId + private fun LightningInvoice.isPayee(nodeId: String?): Boolean { + val payee = payeeNodeId?.toHex() ?: return false + return nodeId != null && payee.equals(nodeId, ignoreCase = true) } + private suspend fun hasOwnLightningInvoice(params: Map?): Boolean = + decodeLightningParam(params)?.isPayee(lightningRepo.getNodeId()) == true + + private suspend fun decodeLightningParam(params: Map?): LightningInvoice? = + params?.get("lightning")?.let { bolt11 -> + runSuspendCatching { coreService.decode(bolt11) }.getOrNull() + ?.let { it as? Scanner.Lightning } + ?.invoice + } + private fun showAddressValidationError( @StringRes titleRes: Int, @StringRes descriptionRes: Int, @@ -3317,6 +3333,16 @@ class AppViewModel @Inject constructor( // Check on-chain balance before proceeding to amount screen if (maxSendOnchain == 0uL && _sendUiState.value.payMethod == SendMethod.ONCHAIN) { + if (hardwareWalletId == null && hasOwnLightningInvoice(invoice.params)) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__pay_self_invoice_title), + description = context.getString(R.string.other__pay_self_invoice_description), + testTag = "SelfPaymentToast", + ) + clearActiveContactPaymentContext() + return + } toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__pay_insufficient_savings), diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 53d5756cfa..edf3ad6ad1 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -726,6 +726,36 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(testNodeId, sut.getNodeId()) } + @Test + fun `awaitNodeId should return null without waiting when node cannot run`() = test { + whenever(lightningService.nodeId).thenReturn("test_node_id") + + assertNull(sut.awaitNodeId()) + } + + @Test + fun `awaitNodeId should wait for starting node to run`() = test { + sut.setInitNodeLifecycleState() + val testNodeId = "test_node_id" + whenever(lightningService.nodeId).thenReturn(testNodeId) + + val nodeId = async { sut.awaitNodeId() } + runCurrent() + assertFalse(nodeId.isCompleted) + + startNodeForTesting() + + assertEquals(testNodeId, nodeId.await()) + } + + @Test + fun `awaitNodeId should return null when node does not start in time`() = test { + sut.setInitNodeLifecycleState() + whenever(lightningService.nodeId).thenReturn("test_node_id") + + assertNull(sut.awaitNodeId()) + } + @Test fun `getBalances should return null when node is not running`() = test { assertNull(sut.getBalances()) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2cd65fe283..0157f1e384 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -94,6 +94,7 @@ import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -4546,7 +4547,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1ownquickpay" enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) sut.setIsAuthenticated(true) runCurrent() clearInvocations(toastManager) @@ -4569,7 +4570,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1foreignquickpay" enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = FOREIGN_NODE_ID.fromHex()) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) sut.setIsAuthenticated(true) sut.onScanResult(bolt11) @@ -4579,11 +4580,64 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan waits for starting node id before QuickPay`() = test { + val bolt11 = "lnbcrt1ownstartingquickpay" + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) + whenever(lightningRepo.awaitNodeId()).doSuspendableAnswer { + delay(5.seconds) + OWN_NODE_ID + } + sut.setIsAuthenticated(true) + runCurrent() + clearInvocations(toastManager) + + sut.onScanResult(bolt11) + runCurrent() + + assertNull(sut.quickPayData.value) + assertNull(sut.currentSheet.value) + + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + verify(quickPayRepo, never()).canApply(any()) + assertNull(sut.quickPayData.value) + assertNull(sut.currentSheet.value) + } + + @Test + fun `lightning scan waits for starting node id before confirm`() = test { + val bolt11 = "lnbcrt1ownstartingconfirm" + stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) + whenever(lightningRepo.awaitNodeId()).doSuspendableAnswer { + delay(5.seconds) + OWN_NODE_ID + } + sut.setIsAuthenticated(true) + runCurrent() + clearInvocations(toastManager) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + verify(lightningRepo, never()).waitForUsableChannels() + verify(lightningRepo, never()).canSend(any()) + assertNull(sut.sendUiState.value.decodedInvoice) + assertNull(sut.currentSheet.value) + } + @Test fun `lightning scan is not blocked when node id is unavailable`() = test { val bolt11 = "lnbcrt1ownnodestopped" stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) - whenever(lightningRepo.getNodeId()).thenReturn(null) + whenever(lightningRepo.awaitNodeId()).thenReturn(null) sut.setIsAuthenticated(true) sut.onScanResult(bolt11) @@ -4599,7 +4653,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1ownunified" val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = OWN_NODE_ID.fromHex()) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) sut.setIsAuthenticated(true) sut.onScanResult(uri) @@ -4611,12 +4665,77 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo, never()).canSend(any()) } + @Test + fun `unified scan with own lightning invoice and no savings shows self payment toast`() = test { + val bolt11 = "lnbcrt1ownunifiednosavings" + val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" + stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = OWN_NODE_ID.fromHex()) + balanceState.value = BalanceState(maxSendOnchainSats = 0u) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + sut.setIsAuthenticated(true) + runCurrent() + clearInvocations(toastManager) + + sut.onScanResult(uri) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + verify(lightningRepo, never()).canSend(any()) + assertNull(sut.sendUiState.value.decodedInvoice) + } + + @Test + fun `unified scan with foreign lightning invoice and no savings keeps insufficient savings toast`() = test { + val bolt11 = "lnbcrt1foreignunifiednosavings" + val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" + stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = FOREIGN_NODE_ID.fromHex()) + balanceState.value = BalanceState(maxSendOnchainSats = 0u) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.canSend(any())).thenReturn(false) + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + sut.setIsAuthenticated(true) + runCurrent() + clearInvocations(toastManager) + + sut.onScanResult(uri) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("InsufficientSavingsToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `manual input of unified own lightning invoice with no savings shows self payment toast`() = test { + val bolt11 = "lnbcrt1ownunifiedmanual" + val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" + stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = OWN_NODE_ID.fromHex()) + balanceState.value = BalanceState(maxSendOnchainSats = 0u) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + runCurrent() + clearInvocations(toastManager) + + sut.setSendEvent(SendEvent.AddressChange(uri)) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("SelfPaymentToast", toastCaptor.lastValue.testTag) + assertFalse(sut.sendUiState.value.isAddressInputValid) + } + @Test fun `unified scan with foreign lightning invoice keeps lightning`() = test { val bolt11 = "lnbcrt1foreignunified" val uri = "bitcoin:$REGTEST_ADDRESS?amount=0.00001&lightning=$bolt11" stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = FOREIGN_NODE_ID.fromHex()) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) whenever(lightningRepo.estimateRoutingFees(bolt11)).thenReturn(Result.success(1uL)) sut.setIsAuthenticated(true) @@ -4632,7 +4751,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `manual input of own lightning invoice shows self payment toast`() = test { val bolt11 = "lnbcrt1ownmanual" stubLightningScan(bolt11 = bolt11, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) runCurrent() clearInvocations(toastManager) diff --git a/changelog.d/next/901.fixed.md b/changelog.d/next/901.fixed.md index a02a75e425..cff28c141b 100644 --- a/changelog.d/next/901.fixed.md +++ b/changelog.d/next/901.fixed.md @@ -1 +1 @@ -Scanning or opening an invoice created by your own wallet now shows an error instead of attempting to pay it. +Paying a Lightning invoice created by your own wallet now shows an error, and unified QR codes containing one fall back to an on-chain send. From 5a907e097b18499af92a65ad981aec7abb3e6037 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 17:20:16 -0300 Subject: [PATCH 3/6] fix: remember node id for own invoice guard Co-Authored-By: Claude Opus 5 (1M context) --- .../to/bitkit/repositories/LightningRepo.kt | 24 +++++++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../bitkit/repositories/LightningRepoTest.kt | 41 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 6 +-- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f9a0fc49fd..a3757ddfa5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -147,6 +147,9 @@ class LightningRepo @Inject constructor( @Volatile private var isWiping = false + @Volatile + private var lastKnownNodeId: String? = null + private val channelCache = ConcurrentHashMap() private val probeOutcomeCache = ConcurrentHashMap() private val probeOutcomeSignal = MutableSharedFlow(extraBufferCapacity = 64) @@ -296,6 +299,7 @@ class LightningRepo @Inject constructor( channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { runCatching { + lastKnownNodeId = null val trustedPeers = fetchTrustedPeers() lightningService.setup( walletIndex, @@ -823,6 +827,7 @@ class LightningRepo @Inject constructor( Logger.debug("node stopped, calling wipeStorage", context = TAG) lightningService.wipeStorage(walletIndex) clearProbeOutcomes() + lastKnownNodeId = null _lightningState.update { LightningState( nodeStatus = it.nodeStatus, @@ -1649,11 +1654,22 @@ class LightningRepo @Inject constructor( } fun getNodeId(): String? = - if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.nodeId else null + if (_lightningState.value.nodeLifecycleState.isRunning()) { + lightningService.nodeId?.also { lastKnownNodeId = it } + } else { + null + } - suspend fun awaitNodeId(): String? = executeWhenNodeRunning("awaitNodeId", NODE_ID_WAIT_TIMEOUT) { - runCatching { requireNotNull(lightningService.nodeId) { "Node id not available" } } - }.getOrNull() + /** + * Node id of the current node, falling back to the one observed while it last ran. + * The id is cleared on node setup and storage wipe, so it always belongs to the active wallet. + */ + fun getLastKnownNodeId(): String? = getNodeId() ?: lastKnownNodeId + + suspend fun awaitNodeId(): String? = lastKnownNodeId + ?: executeWhenNodeRunning("awaitNodeId", NODE_ID_WAIT_TIMEOUT) { + runCatching { requireNotNull(lightningService.nodeId) { "Node id not available" } } + }.getOrNull()?.also { lastKnownNodeId = it } fun getBalances(): BalanceDetails? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.balances else null diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index be3e3072cc..455b84f99c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2216,7 +2216,7 @@ class AppViewModel @Inject constructor( } private suspend fun hasOwnLightningInvoice(params: Map?): Boolean = - decodeLightningParam(params)?.isPayee(lightningRepo.getNodeId()) == true + decodeLightningParam(params)?.isPayee(lightningRepo.getLastKnownNodeId()) == true private suspend fun decodeLightningParam(params: Map?): LightningInvoice? = params?.get("lightning")?.let { bolt11 -> diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index edf3ad6ad1..e7a3b21fad 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -756,6 +756,47 @@ class LightningRepoTest : BaseUnitTest() { assertNull(sut.awaitNodeId()) } + @Test + fun `awaitNodeId should return last known id after node stops`() = test { + val testNodeId = "test_node_id" + whenever(lightningService.nodeId).thenReturn(testNodeId) + startNodeForTesting() + assertEquals(testNodeId, sut.awaitNodeId()) + + whenever(lightningService.stop()).thenReturn(Unit) + sut.stop() + whenever(lightningService.nodeId).thenReturn(null) + + assertEquals(testNodeId, sut.awaitNodeId()) + } + + @Test + fun `getLastKnownNodeId should return remembered id while node is not running`() = test { + val testNodeId = "test_node_id" + whenever(lightningService.nodeId).thenReturn(testNodeId) + startNodeForTesting() + assertEquals(testNodeId, sut.getNodeId()) + + whenever(lightningService.stop()).thenReturn(Unit) + sut.stop() + + assertNull(sut.getNodeId()) + assertEquals(testNodeId, sut.getLastKnownNodeId()) + } + + @Test + fun `getLastKnownNodeId should return null after storage wipe`() = test { + whenever(lightningService.nodeId).thenReturn("test_node_id") + startNodeForTesting() + assertEquals("test_node_id", sut.getNodeId()) + whenever(lightningService.stop()).thenReturn(Unit) + + assertTrue(sut.wipeStorage(0).isSuccess) + whenever(lightningService.nodeId).thenReturn(null) + + assertNull(sut.getLastKnownNodeId()) + } + @Test fun `getBalances should return null when node is not running`() = test { assertNull(sut.getBalances()) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0157f1e384..d5e2a4b9b2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4672,7 +4672,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = OWN_NODE_ID.fromHex()) balanceState.value = BalanceState(maxSendOnchainSats = 0u) whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.getLastKnownNodeId()).thenReturn(OWN_NODE_ID) sut.setIsAuthenticated(true) runCurrent() clearInvocations(toastManager) @@ -4694,7 +4694,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = FOREIGN_NODE_ID.fromHex()) balanceState.value = BalanceState(maxSendOnchainSats = 0u) whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.getLastKnownNodeId()).thenReturn(OWN_NODE_ID) whenever(lightningRepo.canSend(any())).thenReturn(false) whenever(lightningRepo.lightningState) .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) @@ -4717,7 +4717,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubUnifiedScan(uri = uri, bolt11 = bolt11, amountSats = 1_000u, payeeNodeId = OWN_NODE_ID.fromHex()) balanceState.value = BalanceState(maxSendOnchainSats = 0u) whenever(lightningRepo.awaitNodeId()).thenReturn(OWN_NODE_ID) - whenever(lightningRepo.getNodeId()).thenReturn(OWN_NODE_ID) + whenever(lightningRepo.getLastKnownNodeId()).thenReturn(OWN_NODE_ID) runCurrent() clearInvocations(toastManager) From d3910ff20e1cb3836e445e247d8f20e3307fffb8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 08:17:06 -0300 Subject: [PATCH 4/6] fix: keep remembered node id across node restarts Co-Authored-By: Claude Opus 5 (1M context) --- .../to/bitkit/repositories/LightningRepo.kt | 6 +++--- .../bitkit/repositories/LightningRepoTest.kt | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a3757ddfa5..f9dcbfce81 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -299,7 +299,6 @@ class LightningRepo @Inject constructor( channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { runCatching { - lastKnownNodeId = null val trustedPeers = fetchTrustedPeers() lightningService.setup( walletIndex, @@ -825,9 +824,9 @@ class LightningRepo @Inject constructor( lifecycleMutex.withLock { stopLocked().mapCatching { Logger.debug("node stopped, calling wipeStorage", context = TAG) + lastKnownNodeId = null lightningService.wipeStorage(walletIndex) clearProbeOutcomes() - lastKnownNodeId = null _lightningState.update { LightningState( nodeStatus = it.nodeStatus, @@ -1662,7 +1661,8 @@ class LightningRepo @Inject constructor( /** * Node id of the current node, falling back to the one observed while it last ran. - * The id is cleared on node setup and storage wipe, so it always belongs to the active wallet. + * The id is derived from the wallet mnemonic and only cleared on storage wipe, which is the + * single path to another mnemonic, so it always belongs to the active wallet. */ fun getLastKnownNodeId(): String? = getNodeId() ?: lastKnownNodeId diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index e7a3b21fad..de0a6be6ce 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -797,6 +797,27 @@ class LightningRepoTest : BaseUnitTest() { assertNull(sut.getLastKnownNodeId()) } + @Test + fun `awaitNodeId should return last known id when restart after stop fails`() = test { + val testNodeId = "test_node_id" + whenever(lightningService.nodeId).thenReturn(testNodeId) + startNodeForTesting() + assertEquals(testNodeId, sut.awaitNodeId()) + + whenever(lightningService.stop()).thenReturn(Unit) + sut.stop() + whenever(lightningService.nodeId).thenReturn(null) + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(IllegalStateException("electrum server unreachable")) + + assertTrue(sut.start(shouldRetry = false).isFailure) + assertIs(sut.lightningState.value.nodeLifecycleState) + + assertEquals(testNodeId, sut.awaitNodeId()) + assertEquals(testNodeId, sut.getLastKnownNodeId()) + } + @Test fun `getBalances should return null when node is not running`() = test { assertNull(sut.getBalances()) From 0a83c999c95da9a81e961c4fa0b2e7e8b2478b10 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:35:57 -0300 Subject: [PATCH 5/6] docs: add own invoice guard journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 ++ journeys/send/own-invoice-guard.xml | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 journeys/send/own-invoice-guard.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..53cb268052 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -121,6 +121,7 @@ fixtures, push notifications) live in each suite's README. | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | +| [send](send) | 1 | Own-invoice guard on the send flow; needs a spending channel and savings; no README | | [widgets](widgets) | 2 | Needs no backend — the quickest way to see the loop work; no README | ## Cross-platform @@ -141,6 +142,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/send/own-invoice-guard.xml b/journeys/send/own-invoice-guard.xml new file mode 100644 index 0000000000..6484a5b401 --- /dev/null +++ b/journeys/send/own-invoice-guard.xml @@ -0,0 +1,28 @@ + + + Paying a Lightning invoice created by this wallet is rejected with a "Cannot Pay Own Invoice" + toast before QuickPay or the Confirm screen, and a unified QR carrying an own invoice falls back + to an on-chain send. Precondition: onboarded dev wallet, node running, a usable spending channel + and a savings balance. The toast never reaches `android layout`; assert it from a screenshot + taken within ~2s of the deep link. Android only for now: `bitkit-ios` has no own-invoice guard. + + + Tap Receive on the home screen (tag "Receive") + Tap Edit (tag "SpecifyInvoiceButton"), tap the amount field (tag "ReceiveNumberPadTextField"), enter 1000 with "N1" and "N000", tap Continue (tag "ReceiveNumberPadSubmit"), then tap QR Code (tag "ShowQrReceive") + Read the unified `bitcoin:` URI from the QR element's content-desc in `android layout`, and take the `lightning=` value as the own invoice; never type it + Press the device back button until the wallet overview is visible + Run `adb shell am start -a android.intent.action.VIEW -d "lightning:<own invoice>" to.bitkit.dev` + Verify from a screenshot that an error toast titled "Cannot Pay Own Invoice" is visible (tag "SelfPaymentToast") + Verify that the wallet overview is still visible and no Send sheet, QuickPay screen or Confirm screen opened + Run `adb shell am start -a android.intent.action.VIEW -d '<unified bitcoin: URI>' to.bitkit.dev`, quoting the URI so the shell keeps the `&` + Verify that the send amount screen (tag "send_amount_screen") is visible with 1 000 prefilled and the SAVINGS source selected + Verify that the app log contains "Skipped own lightning invoice in unified URI, defaulting to onchain" + Tap Continue (tag "ContinueAmount"), then tap Show Details (tag "SendConfirmToggleDetails") + Verify that the Confirm screen shows FROM Savings and TO the wallet's own on-chain address (tag "ReviewUri"); do not swipe to pay + Press the device back button until the wallet overview is visible + Open Settings ▸ QuickPay (tag "QuickpaySettings"), turn QuickPay on, and return to the wallet overview + Run `adb shell am start -a android.intent.action.VIEW -d "lightning:<own invoice>" to.bitkit.dev` + Verify from a screenshot that the "Cannot Pay Own Invoice" toast is visible and the QuickPay screen did not open + Open Settings ▸ QuickPay and turn QuickPay back off if it was off before this journey + + From cb65310224a2484f86983bb321c0f401c0497f7c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:38:36 -0300 Subject: [PATCH 6/6] docs: quote unified uri so device shell keeps it whole Co-Authored-By: Claude Opus 5 (1M context) --- journeys/send/own-invoice-guard.xml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/journeys/send/own-invoice-guard.xml b/journeys/send/own-invoice-guard.xml index 6484a5b401..d429b74ce3 100644 --- a/journeys/send/own-invoice-guard.xml +++ b/journeys/send/own-invoice-guard.xml @@ -4,7 +4,12 @@ toast before QuickPay or the Confirm screen, and a unified QR carrying an own invoice falls back to an on-chain send. Precondition: onboarded dev wallet, node running, a usable spending channel and a savings balance. The toast never reaches `android layout`; assert it from a screenshot - taken within ~2s of the deep link. Android only for now: `bitkit-ios` has no own-invoice guard. + taken within ~2s of the deep link. The unified URI has to be quoted twice: `adb shell` joins its + arguments and hands them to the device shell without re-escaping, so quotes written bare on the + host are eaten there and the device shell splits the URI at the `&` before `lightning=`, + leaving an on-chain-only URI. Wrap the whole remote command in double quotes and the URI in + single quotes so one level survives to the device. Android only for now: `bitkit-ios` has no + own-invoice guard. Tap Receive on the home screen (tag "Receive") @@ -14,8 +19,8 @@ Run `adb shell am start -a android.intent.action.VIEW -d "lightning:<own invoice>" to.bitkit.dev` Verify from a screenshot that an error toast titled "Cannot Pay Own Invoice" is visible (tag "SelfPaymentToast") Verify that the wallet overview is still visible and no Send sheet, QuickPay screen or Confirm screen opened - Run `adb shell am start -a android.intent.action.VIEW -d '<unified bitcoin: URI>' to.bitkit.dev`, quoting the URI so the shell keeps the `&` - Verify that the send amount screen (tag "send_amount_screen") is visible with 1 000 prefilled and the SAVINGS source selected + Run `adb shell "am start -a android.intent.action.VIEW -d '<unified bitcoin: URI>' to.bitkit.dev"` — outer double quotes, inner single quotes, so the device shell receives the whole URI as one word (see the description) + Verify that the send amount screen (tag "send_amount_screen") is visible with 1 000 prefilled and the SAVINGS source selected; without the guard the `lightning=` param would route this to a Lightning send instead Verify that the app log contains "Skipped own lightning invoice in unified URI, defaulting to onchain" Tap Continue (tag "ContinueAmount"), then tap Show Details (tag "SendConfirmToggleDetails") Verify that the Confirm screen shows FROM Savings and TO the wallet's own on-chain address (tag "ReviewUri"); do not swipe to pay