diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 3a1c0bf63..eddab2ffd 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -246,7 +246,7 @@ internal object Env { @Suppress("ConstPropertyName") object Defaults { /** Default Bolt11 invoice expiry in seconds. */ - const val bolt11InvoiceExpirySeconds = 3_600u + const val bolt11ExpirySec = 86_400u /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index d5c7b44d4..5d54b9783 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -57,6 +57,7 @@ import javax.inject.Named import javax.inject.Singleton import kotlin.math.ceil import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds @Singleton @@ -463,7 +464,7 @@ class BlocktankRepo @Inject constructor( val invoice = lightningRepo.createInvoice( amountSats = null, description = "blocktank-gift-code:$code", - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = 1.hours.inWholeSeconds.toUInt(), ).getOrThrow() Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4fbc8f43b..82dda5e1e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -58,6 +58,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowTimestamp @@ -926,7 +927,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoice( amountSats: ULong? = null, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() runCatching { lightningService.receive(amountSats, description, expirySeconds) } @@ -935,7 +936,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoiceMsats( amountMsats: ULong, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } @@ -1019,15 +1020,50 @@ class LightningRepo @Inject constructor( } suspend fun waitForUsableChannels() = withContext(bgDispatcher) { - if (_lightningState.value.channels.any { it.isUsable }) return@withContext + var state = _lightningState.value + if (!state.nodeLifecycleState.canRun()) return@withContext + if (state.hasUsableChannels()) return@withContext + + state = waitForChannelsToLoadIfNeeded(state) ?: return@withContext + if (!state.nodeLifecycleState.canRun()) return@withContext + + if (state.channels.isEmpty()) { + if (state.nodeLifecycleState.isRunning()) { + syncState() + state = _lightningState.value + } + + if (state.channels.isEmpty()) return@withContext // no channel exists, don't wait + if (state.hasUsableChannels()) return@withContext + } Logger.info("Waiting for usable channels before sending payment", context = TAG) withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { - _lightningState.first { state -> state.channels.any { it.isUsable } } + _lightningState.first { it.shouldStopWaitingForUsableChannels() } } ?: Logger.warn("Timed out waiting for usable channels", context = TAG) } + private suspend fun waitForChannelsToLoadIfNeeded(state: LightningState): LightningState? { + if (state.channels.isNotEmpty() || state.nodeLifecycleState.isRunning()) return state + + Logger.info("Waiting for node to load channels before sending payment", context = TAG) + return withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { + _lightningState.first { it.shouldStopWaitingForLoadedChannels() } + } ?: run { + Logger.warn("Timed out waiting for node to load channels", context = TAG) + null + } + } + + private fun LightningState.hasUsableChannels() = channels.any { it.isUsable } + + private fun LightningState.shouldStopWaitingForLoadedChannels() = + !nodeLifecycleState.canRun() || nodeLifecycleState.isRunning() || channels.isNotEmpty() + + private fun LightningState.shouldStopWaitingForUsableChannels() = + !nodeLifecycleState.canRun() || channels.isEmpty() || hasUsableChannels() + @Suppress("LongParameterList") suspend fun sendOnChain( address: Address, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 95f9f43aa..8baa73bb5 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -70,6 +70,7 @@ import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.ext.channelId @@ -1523,11 +1524,15 @@ class BlocktankService( ) } - suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String { + suspend fun regtestCloseChannel( + fundingTxId: String, + vout: UInt, + forceCloseAfterS: UInt = Defaults.bolt11ExpirySec, + ): String { return com.synonym.bitkitcore.regtestCloseChannel( fundingTxId = fundingTxId, vout = vout, - forceCloseAfterS = forceCloseAfterS, + forceCloseAfterS = forceCloseAfterS.toULong(), ) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 275402438..979bb6459 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -595,7 +595,7 @@ class LightningService @Inject constructor( suspend fun receive( sat: ULong? = null, description: String, - expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySecs: UInt = Defaults.bolt11ExpirySec, ): String { return receiveMsats(amountMsat = sat?.let { it * 1000u }, description = description, expirySecs = expirySecs) } @@ -603,7 +603,7 @@ class LightningService @Inject constructor( suspend fun receiveMsats( amountMsat: ULong? = null, description: String, - expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySecs: UInt = Defaults.bolt11ExpirySec, ): String { val node = this.node ?: throw ServiceError.NodeNotSetup() diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index fcc6ff45c..8c7da3f38 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -270,7 +270,7 @@ fun BlocktankRegtestScreen( runCatching { val voutNum = vout.toUIntOrNull() ?: error("Invalid Vout: $vout") val closeAfter = - forceCloseAfter.toULongOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") + forceCloseAfter.toUIntOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") val closingTxId = viewModel.regtestCloseChannel( fundingTxId = fundingTxId, vout = voutNum, diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt index f6cf74e20..1a915251c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import to.bitkit.env.Defaults import to.bitkit.services.CoreService import javax.inject.Inject @@ -28,7 +29,11 @@ class BlocktankRegtestViewModel @Inject constructor( ) } - suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String { + suspend fun regtestCloseChannel( + fundingTxId: String, + vout: UInt, + forceCloseAfterS: UInt = Defaults.bolt11ExpirySec, + ): String { return coreService.blocktank.regtestCloseChannel( fundingTxId = fundingTxId, vout = vout, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d297addb7..908867ab6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -155,6 +155,7 @@ import java.math.BigDecimal import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -1398,6 +1399,7 @@ class AppViewModel @Inject constructor( resetSendState() resetQuickPay() + val fromMainScanner = isMainScanner val input = result.removeLightningSchemes() // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 @@ -1439,20 +1441,20 @@ class AppViewModel @Inject constructor( .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() - if (isMainScanner && scan.isLightningRelated()) { - showSheet(Sheet.Send()) - } - - handleDecodedScan(scan, input) + handleDecodedScan(scan, input, fromMainScanner) } @Suppress("CyclomaticComplexMethod") - private suspend fun handleDecodedScan(scan: Scanner?, input: String) = when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input) - is Scanner.Lightning -> onScanLightning(scan.invoice, input) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data) - is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data) } - is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data) } + private suspend fun handleDecodedScan( + scan: Scanner?, + input: String, + fromMainScanner: Boolean, + ) = when (scan) { + is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) + is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) + is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data, fromMainScanner) } + is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } @@ -1493,7 +1495,11 @@ class AppViewModel @Inject constructor( } @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") - private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { + private suspend fun onScanOnchain( + invoice: OnChainInvoice, + scanResult: String, + fromMainScanner: Boolean, + ) { val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } .getOrElse { hideSheet() @@ -1542,14 +1548,11 @@ class AppViewModel @Inject constructor( val quickPayHandled = handleQuickPayIfApplicable( amountSats = lnAmountSats, invoice = lnInvoice, + fromMainScanner = fromMainScanner, ) if (quickPayHandled) return - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) refreshOnchainSendIfNeeded() estimateLightningRoutingFeesIfNeeded() return @@ -1593,14 +1596,14 @@ class AppViewModel @Inject constructor( context = TAG, ) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLightning(invoice: LightningInvoice, scanResult: String) { + private suspend fun onScanLightning( + invoice: LightningInvoice, + scanResult: String, + fromMainScanner: Boolean, + ) { if (invoice.isExpired) { hideSheet() toast( @@ -1613,7 +1616,11 @@ class AppViewModel @Inject constructor( return } - val quickPayHandled = handleQuickPayIfApplicable(amountSats = invoice.amountSatoshis, invoice = invoice) + val quickPayHandled = handleQuickPayIfApplicable( + amountSats = invoice.amountSatoshis, + invoice = invoice, + fromMainScanner = fromMainScanner, + ) if (quickPayHandled) return lightningRepo.waitForUsableChannels() @@ -1645,23 +1652,15 @@ class AppViewModel @Inject constructor( if (invoice.amountSatoshis > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } Logger.info("No amount found in invoice, proceeding to enter amount", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlPay(data: LnurlPayData) { + private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -1692,26 +1691,22 @@ class AppViewModel @Inject constructor( if (isFixed) { Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG) - val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data) + val quickPayHandled = handleQuickPayIfApplicable( + amountSats = displaySats, + lnurlPay = data, + fromMainScanner = fromMainScanner, + ) if (quickPayHandled) return - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } Logger.info("No amount found in lnurlPay, proceeding to enter amount manually", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData) { + private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -1740,30 +1735,39 @@ class AppViewModel @Inject constructor( if (isFixed || minWithdrawable == maxWithdrawable) { delay(TRANSITION_SCREEN_MS) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.WithdrawConfirm)) - } else { - setSendEffect(SendEffect.NavigateToWithdrawConfirm) - } + navigateToSendRoute( + fromMainScanner, + SendRoute.WithdrawConfirm, + SendEffect.NavigateToWithdrawConfirm, + ) return } - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlAuth(data: LnurlAuthData) { + private suspend fun onScanLnurlAuth(data: LnurlAuthData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) - if (!isMainScanner) { + if (!fromMainScanner) { hideSheet() delay(TRANSITION_SCREEN_MS) } showSheet(Sheet.LnurlAuth(domain = data.domain, lnurl = data.uri, k1 = data.k1)) } + private fun navigateToSendRoute( + fromMainScanner: Boolean, + route: SendRoute, + effect: SendEffect, + ) { + if (fromMainScanner) { + showSheet(Sheet.Send(route)) + return + } + + setSendEffect(effect) + } + fun requestLnurlAuth(callback: String, k1: String, domain: String) { viewModelScope.launch { lightningRepo.requestLnurlAuth( @@ -1831,6 +1835,7 @@ class AppViewModel @Inject constructor( private suspend fun handleQuickPayIfApplicable( amountSats: ULong, + fromMainScanner: Boolean, lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { @@ -1866,11 +1871,7 @@ class AppViewModel @Inject constructor( Logger.debug("QuickPayData: $quickPayData", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.QuickPay)) - } else { - setSendEffect(SendEffect.NavigateToQuickPay) - } + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) return true } @@ -2094,7 +2095,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoiceMsats( amountMsats = lnurl.data.maxWithdrawable, description = lnurl.data.defaultDescription, - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = LNURL_EXPIRY_SEC, ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( @@ -2104,7 +2105,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoice( amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = LNURL_EXPIRY_SEC, ) }.getOrNull() @@ -2804,15 +2805,10 @@ class AppViewModel @Inject constructor( private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val PUBKYAUTH_SCHEME = "pubkyauth" + private val LNURL_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() } } -private fun Scanner?.isLightningRelated(): Boolean = when (this) { - is Scanner.Lightning, is Scanner.LnurlPay -> true - is Scanner.OnChain -> invoice.params?.containsKey("lightning") == true - else -> false -} - // region send contract @Stable data class SendUiState( diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 2d23942f3..7078603ac 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -392,6 +392,25 @@ class LightningRepoTest : BaseUnitTest() { assertFalse(sut.canSend(1000uL)) } + @Test + fun `waitForUsableChannels waits for running state before treating empty channels as absent`() = test { + sut.setInitNodeLifecycleState() + val channel = createChannelDetails().copy( + isUsable = true, + nextOutboundHtlcLimitMsat = 2_000_000u, + ) + whenever(lightningService.channels).thenReturn(listOf(channel)) + + val wait = async { sut.waitForUsableChannels() } + + assertFalse(wait.isCompleted) + + startNodeForTesting() + + assertTrue(wait.isCompleted) + assertEquals(listOf(channel), sut.lightningState.value.channels) + } + @Test fun `wipeStorage should stop node and call service wipe`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 41dd84eb1..99bf1e647 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -371,6 +371,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(contactKey, pendingContactPaymentContext(paymentHash)?.publicKey) } + @Test + fun `main scanner lightning scan opens send sheet`() = test { + val bolt11 = "lnbcrt1scanner" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `main scanner zero amount lightning scan opens amount sheet`() = test { + val bolt11 = "lnbcrt1zeroamount" + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + } + + @Test + fun `main scanner lightning scan opens QuickPay when enabled`() = test { + val bolt11 = "lnbcrt1scannerquickpay" + enableQuickPay(thresholdSats = 1000u) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" diff --git a/changelog.d/next/925.fixed.md b/changelog.d/next/925.fixed.md new file mode 100644 index 000000000..1e44ac09f --- /dev/null +++ b/changelog.d/next/925.fixed.md @@ -0,0 +1 @@ +Payment QR scans now route reliably and avoid unnecessary delays when Lightning channels are unavailable.