diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dd04781e59..9584036efa 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -148,6 +148,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) @@ -880,6 +883,7 @@ class LightningRepo @Inject constructor( lifecycleMutex.withLock { stopLocked().mapCatching { Logger.debug("node stopped, calling wipeStorage", context = TAG) + lastKnownNodeId = null lightningService.wipeStorage(walletIndex) clearProbeOutcomes() _lightningState.update { @@ -1728,7 +1732,23 @@ 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 + } + + /** + * Node id of the current node, falling back to the one observed while it last ran. + * 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 + + 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 @@ -2157,6 +2177,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 914896aca9..dfc4f6716b 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)) { @@ -2082,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 { @@ -2124,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, @@ -2155,36 +2173,56 @@ 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 - } - 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.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.getLastKnownNodeId()) == 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( @@ -3295,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), @@ -3393,7 +3441,7 @@ class AppViewModel @Inject constructor( else -> SendFundingSource.Savings } - @Suppress("ReturnCount") + @Suppress("LongMethod", "ReturnCount") private suspend fun onScanLightning( invoice: LightningInvoice, scanResult: String, @@ -3410,6 +3458,11 @@ class AppViewModel @Inject constructor( return } + if (invoice.isOwnInvoice()) { + rejectOwnInvoiceScan() + return + } + val incomingPaymentRequest = activeIncomingPaymentRequest() if (incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis) == false) { rejectMismatchedPaymentRequest() @@ -3462,6 +3515,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 2eaaeb38cf..0ba4a9298b 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/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 36d7b9a845..299c8797d9 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1049,6 +1049,98 @@ 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 `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 `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()) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6ebf7254d0..162f35e6a8 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 @@ -93,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 @@ -4562,6 +4564,229 @@ 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.awaitNodeId()).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.awaitNodeId()).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 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.awaitNodeId()).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.awaitNodeId()).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 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.getLastKnownNodeId()).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.getLastKnownNodeId()).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.getLastKnownNodeId()).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.awaitNodeId()).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.awaitNodeId()).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" @@ -6872,12 +7097,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( @@ -6911,7 +7159,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, @@ -6920,7 +7172,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isExpired = false, description = "", networkType = NetworkType.REGTEST, - payeeNodeId = null, + payeeNodeId = payeeNodeId, ) private suspend fun enablePublicPaykitSharing() { @@ -7152,3 +7404,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..cff28c141b --- /dev/null +++ b/changelog.d/next/901.fixed.md @@ -0,0 +1 @@ +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. diff --git a/journeys/README.md b/journeys/README.md index ee9686f31b..1e37f8f5ff 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -129,6 +129,7 @@ fixtures, push notifications) live in each suite's README. | [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | | [restore-wallet](restore-wallet) | 1 | Pasting a seed fragment on Restore wallet; needs a wallet-free device; no README | | [security](security) | 1 | PIN result sheet layout at a long locale and font scale; no README | +| [send](send) | 1 | Own-invoice guard on the send flow; needs a spending channel and savings; no README | | [shop](shop) | 1 | Shop Discover category titles and web view handoff; needs Bitrefill reachable; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | | [tags](tags) | 1 | Tag input length cap on an activity; no backend, no README | @@ -157,6 +158,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `payment-requests/requested-resolution-failure.xml` | not ported | | `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 | +| `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | diff --git a/journeys/send/own-invoice-guard.xml b/journeys/send/own-invoice-guard.xml new file mode 100644 index 0000000000..d429b74ce3 --- /dev/null +++ b/journeys/send/own-invoice-guard.xml @@ -0,0 +1,33 @@ + + + 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. 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") + 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"` — 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 + 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 + +