diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt index 56cfada63..e2f5108ad 100644 --- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt +++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt @@ -19,6 +19,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.ProbeOutcome import to.bitkit.utils.Logger import kotlin.time.Duration.Companion.seconds +import to.bitkit.repositories.ProbeReadiness as NodeProbeReadiness private const val TAG = "DevToolsProvider" private val DEV_JSON = Json { encodeDefaults = true } @@ -61,6 +62,7 @@ private sealed interface DevCommand { fun parse(method: String, arg: String?): DevCommand? = when (method) { CreateInvoice.METHOD -> CreateInvoice.parse(arg) ProbeInvoice.METHOD -> ProbeInvoice.parse(arg) + ProbeReadiness.METHOD -> ProbeReadiness else -> null } } @@ -123,6 +125,13 @@ private sealed interface DevCommand { ) } } + + data object ProbeReadiness : DevCommand { + const val METHOD = "probeReadiness" + + override suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult = + DevResult.ProbeReadiness.from(deps.lightningRepo().probeReadiness()) + } } @Serializable @@ -159,6 +168,43 @@ private sealed interface DevResult { } } + @Serializable + data class ProbeReadiness( + val ready: Boolean, + val nodeRunning: Boolean, + val lifecycle: String, + val peers: Int, + val connectedPeers: Int, + val channels: Int, + val readyChannels: Int, + val usableChannels: Int, + val outboundCapacitySats: ULong, + val syncHealthy: Boolean, + val nodeId: String? = null, + val graphNodeCount: Int? = null, + val graphChannelCount: Int? = null, + val latestRgsSyncTimestamp: ULong? = null, + ) : DevResult { + companion object { + fun from(readiness: NodeProbeReadiness) = ProbeReadiness( + ready = readiness.ready, + nodeRunning = readiness.nodeRunning, + lifecycle = readiness.lifecycle, + peers = readiness.peers, + connectedPeers = readiness.connectedPeers, + channels = readiness.channels, + readyChannels = readiness.readyChannels, + usableChannels = readiness.usableChannels, + outboundCapacitySats = readiness.outboundCapacitySats, + syncHealthy = readiness.syncHealthy, + nodeId = readiness.nodeId, + graphNodeCount = readiness.graphNodeCount, + graphChannelCount = readiness.graphChannelCount, + latestRgsSyncTimestamp = readiness.latestRgsSyncTimestamp, + ) + } + } + @Serializable data class Error(val message: String? = null) : DevResult fun toBundle() = bundleOf(KEY_RESULT to DEV_JSON.encodeToString(this)) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 405113654..4bbc4c9a6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1603,6 +1603,26 @@ class LightningRepo @Inject constructor( outcome?.let { Result.success(it) } ?: Result.failure(ProbeError.TimedOut()) } + + fun probeReadiness(): ProbeReadiness { + val state = _lightningState.value + val graph = getNetworkGraphInfo() + return ProbeReadiness( + nodeRunning = state.nodeLifecycleState.isRunning(), + nodeId = state.nodeId.takeIf { it.isNotBlank() }, + lifecycle = state.nodeLifecycleState.toString(), + peers = state.peers.size, + connectedPeers = state.peers.count { it.isConnected }, + channels = state.channels.size, + readyChannels = state.channels.count { it.isChannelReady }, + usableChannels = state.channels.count { it.isUsable }, + outboundCapacitySats = state.channels.totalNextOutboundHtlcLimitSats(), + graphNodeCount = graph?.nodeCount, + graphChannelCount = graph?.channelCount, + latestRgsSyncTimestamp = graph?.latestRgsSyncTimestamp, + syncHealthy = state.isSyncHealthy, + ) + } // endregion suspend fun restartNode(): Result = withContext(bgDispatcher) { @@ -1669,6 +1689,30 @@ data class ProbeDispatch( val paymentIds: Set, ) +data class ProbeReadiness( + val nodeRunning: Boolean, + val nodeId: String?, + val lifecycle: String, + val peers: Int, + val connectedPeers: Int, + val channels: Int, + val readyChannels: Int, + val usableChannels: Int, + val outboundCapacitySats: ULong, + val graphNodeCount: Int?, + val graphChannelCount: Int?, + val latestRgsSyncTimestamp: ULong?, + val syncHealthy: Boolean, +) { + val ready: Boolean + get() = nodeRunning && + connectedPeers > 0 && + usableChannels > 0 && + outboundCapacitySats > 0u && + (graphChannelCount ?: 0) > 0 && + syncHealthy +} + sealed interface ProbeOutcome { val paymentId: PaymentId val paymentHash: PaymentHash diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index b1903f9db..c04e983a7 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -52,6 +52,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.LnurlService import to.bitkit.services.LspNotificationsService +import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest import to.bitkit.utils.UrlValidator @@ -1372,6 +1373,96 @@ class LightningRepoTest : BaseUnitTest() { verifyBlocking(lightningService) { sendKeysendProbe(probeNodeId, 42_000uL) } } + @Test + fun `probeReadiness reports ready with connected peer, usable channel and network graph`() = test { + startNodeForTesting() + val peer = PeerDetails( + nodeId = probeNodeId, + address = "1.2.3.4:9735", + isConnected = true, + isPersisted = true, + ) + val channel = createChannelDetails().copy( + isChannelReady = true, + isUsable = true, + nextOutboundHtlcLimitMsat = 2_000_000u, + ) + whenever(lightningService.nodeId).thenReturn("node-1") + whenever(lightningService.peers).thenReturn(listOf(peer)) + whenever(lightningService.channels).thenReturn(listOf(channel)) + whenever(lightningService.getNetworkGraphInfo()) + .thenReturn(NetworkGraphInfo(nodeCount = 1500, channelCount = 4200, latestRgsSyncTimestamp = 123u)) + sut.syncState() + + val readiness = sut.probeReadiness() + + assertTrue(readiness.ready) + assertTrue(readiness.nodeRunning) + assertTrue(readiness.syncHealthy) + assertEquals("node-1", readiness.nodeId) + assertEquals(1, readiness.connectedPeers) + assertEquals(1, readiness.readyChannels) + assertEquals(1, readiness.usableChannels) + assertEquals(2_000uL, readiness.outboundCapacitySats) + assertEquals(1500, readiness.graphNodeCount) + assertEquals(4200, readiness.graphChannelCount) + assertEquals(123u, readiness.latestRgsSyncTimestamp) + } + + @Test + fun `probeReadiness reports not ready when usable channel has no outbound capacity`() = test { + startNodeForTesting() + val peer = PeerDetails( + nodeId = probeNodeId, + address = "1.2.3.4:9735", + isConnected = true, + isPersisted = true, + ) + val channel = createChannelDetails().copy( + isChannelReady = true, + isUsable = true, + nextOutboundHtlcLimitMsat = 0u, + ) + whenever(lightningService.peers).thenReturn(listOf(peer)) + whenever(lightningService.channels).thenReturn(listOf(channel)) + whenever(lightningService.getNetworkGraphInfo()) + .thenReturn(NetworkGraphInfo(nodeCount = 1500, channelCount = 4200, latestRgsSyncTimestamp = 123u)) + sut.syncState() + + val readiness = sut.probeReadiness() + + assertFalse(readiness.ready) + assertEquals(1, readiness.usableChannels) + assertEquals(0uL, readiness.outboundCapacitySats) + } + + @Test + fun `probeReadiness reports not ready when channels are not usable`() = test { + startNodeForTesting() + val peer = PeerDetails( + nodeId = probeNodeId, + address = "1.2.3.4:9735", + isConnected = true, + isPersisted = true, + ) + val channel = createChannelDetails().copy( + isChannelReady = true, + isUsable = false, + nextOutboundHtlcLimitMsat = 0u, + ) + whenever(lightningService.peers).thenReturn(listOf(peer)) + whenever(lightningService.channels).thenReturn(listOf(channel)) + whenever(lightningService.getNetworkGraphInfo()) + .thenReturn(NetworkGraphInfo(nodeCount = 1500, channelCount = 4200, latestRgsSyncTimestamp = 123u)) + sut.syncState() + + val readiness = sut.probeReadiness() + + assertFalse(readiness.ready) + assertEquals(0, readiness.usableChannels) + assertEquals(0uL, readiness.outboundCapacitySats) + } + @Test fun `start should not retry when node lifecycle state is Running`() = test { sut.setInitNodeLifecycleState()