diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5c0d66ab5..f32a407fa 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -663,7 +663,9 @@ class LightningRepo @Inject constructor( } suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("connectToTrustedPeers") { - runCatching { lightningService.connectToTrustedPeers() } + runCatching { lightningService.connectToTrustedPeers() }.also { + syncState() + } } suspend fun connectPeer(peer: PeerDetails): Result = executeWhenNodeRunning("connectPeer") { @@ -762,11 +764,23 @@ class LightningRepo @Inject constructor( bolt11: String, sats: ULong? = null, ): Result = executeWhenNodeRunning("payInvoice") { + waitForUsableChannels() runCatching { lightningService.send(bolt11, sats) }.also { syncState() } } + private suspend fun waitForUsableChannels() { + if (lightningService.channels?.any { it.isUsable } == true) return + + Logger.info("Waiting for usable channels before sending payment", context = TAG) + syncState() + + withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { + _lightningState.first { state -> state.channels.any { it.isUsable } } + } ?: Logger.warn("Timeout waiting for usable channels", context = TAG) + } + @Suppress("LongParameterList") suspend fun sendOnChain( address: Address, @@ -944,12 +958,19 @@ class LightningRepo @Inject constructor( } } - suspend fun canSend(amountSats: ULong, fallbackToCachedBalance: Boolean = true): Boolean { - return if (!_lightningState.value.nodeLifecycleState.isRunning() && fallbackToCachedBalance) { - amountSats <= (cacheStore.data.first().balance?.maxSendLightningSats ?: 0u) - } else { - lightningService.canSend(amountSats) + suspend fun canSend(amountSats: ULong, fallbackToCachedBalance: Boolean = true) = withContext(bgDispatcher) { + if (!_lightningState.value.nodeLifecycleState.canRun()) { + return@withContext false + } + if (_lightningState.value.nodeLifecycleState.isStarting() && fallbackToCachedBalance) { + return@withContext amountSats <= (cacheStore.data.first().balance?.maxSendLightningSats ?: 0u) + } + if (lightningService.channels == null) { + withTimeoutOrNull(CHANNELS_READY_TIMEOUT_MS) { + _lightningState.first { lightningService.channels != null } + } } + return@withContext lightningService.canSend(amountSats) } fun getNodeId(): String? = @@ -1135,6 +1156,8 @@ class LightningRepo @Inject constructor( private const val LENGTH_CHANNEL_ID_PREVIEW = 10 private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L + private const val CHANNELS_READY_TIMEOUT_MS = 15_000L + private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 508ce615d..f0d371c8c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1132,13 +1132,14 @@ class AppViewModel @Inject constructor( val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats val lnInvoice = extractViableLightningInvoice(invoice.params) + val amount = lnInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: invoice.amountSatoshis _sendUiState.update { it.copy( address = invoice.address, addressInput = scanResult, isAddressInputValid = true, - amount = invoice.amountSatoshis, - isUnified = lnInvoice != null && invoice.amountSatoshis <= maxSendOnchain && maxSendOnchain > 0u, + amount = amount, + isUnified = lnInvoice != null && amount <= maxSendOnchain && maxSendOnchain > 0u, decodedInvoice = lnInvoice, payMethod = lnInvoice?.let { SendMethod.LIGHTNING } ?: SendMethod.ONCHAIN, ) @@ -1192,7 +1193,7 @@ class AppViewModel @Inject constructor( } Logger.info( - when (invoice.amountSatoshis > 0u) { + when (amount > 0u) { true -> "Found amount in invoice, proceeding to edit amount" else -> "No amount found in invoice, proceeding to enter amount" }, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 014922880..18a441819 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -28,7 +28,6 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever -import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -36,7 +35,6 @@ import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of -import to.bitkit.models.BalanceState import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult @@ -187,8 +185,10 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `payInvoice should succeed when node is running`() = test { + fun `payInvoice should succeed when node is running and channels are usable`() = test { startNodeForTesting() + val usableChannel = createChannelDetails().copy(isUsable = true) + whenever(lightningService.channels).thenReturn(listOf(usableChannel)) val testPaymentId = "testPaymentId" whenever(lightningService.send("bolt11", 1000uL)).thenReturn(testPaymentId) @@ -197,6 +197,22 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(testPaymentId, result.getOrNull()) } + @Test + fun `payInvoice should proceed after timeout when channels are not usable`() = test { + startNodeForTesting() + val testPaymentId = "testPaymentId" + whenever(lightningService.send("bolt11", 1000uL)).thenReturn(testPaymentId) + + // Channels are ready but not usable (peer disconnected) + val readyButNotUsable = createChannelDetails().copy(isChannelReady = true, isUsable = false) + whenever(lightningService.channels).thenReturn(listOf(readyButNotUsable)) + + // payInvoice should wait, timeout, then still attempt to send + val result = sut.payInvoice("bolt11", 1000uL) + assertTrue(result.isSuccess) + assertEquals(testPaymentId, result.getOrNull()) + } + @Test fun `getPayments should fail when node is not running`() = test { val result = sut.getPayments() @@ -312,15 +328,8 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `canSend should use cached outbound when node is not running`() = test { - val cacheData = AppCacheData( - balance = BalanceState( - maxSendLightningSats = 2000uL - ) - ) - whenever(cacheStore.data).thenReturn(flowOf(cacheData)) - - assert(sut.canSend(1000uL, fallbackToCachedBalance = true)) + fun `canSend should return false when node is stopped`() = test { + assertFalse(sut.canSend(1000uL, fallbackToCachedBalance = true)) } @Test