From ca7f9780edcf0a5213a619f522b5b1c1385732ec Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 08:47:23 -0300 Subject: [PATCH 1/8] feat: create resetNetworkGraph method --- .../to/bitkit/services/LightningService.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 84fe835cb..a4589644e 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -262,6 +262,45 @@ class LightningService @Inject constructor( Logger.info("LDK storage wiped", context = TAG) } + /** + * Resets the network graph cache, forcing a full RGS sync on next startup. + * This is useful when the cached graph is stale or missing nodes. + * Note: Node must be stopped before calling this. + */ + fun resetNetworkGraph(walletIndex: Int) { + if (node != null) throw ServiceError.NodeStillRunning() + Logger.warn("Resetting network graph cache…", context = TAG) + val ldkPath = Path(Env.ldkStoragePath(walletIndex)).toFile() + val graphFile = ldkPath.resolve("network_graph") + if (graphFile.exists()) { + graphFile.delete() + Logger.info("Network graph cache deleted", context = TAG) + } else { + Logger.info("No network graph cache found", context = TAG) + } + } + + /** + * Validates that all trusted peers are present in the network graph. + * Returns false if any trusted peer is missing, indicating the graph cache is stale. + */ + fun validateNetworkGraph(): Boolean { + val node = this.node ?: return true + val graph = node.networkGraph() + val graphNodes = graph.listNodes().toSet() + val missingPeers = trustedPeers.filter { it.nodeId !in graphNodes } + if (missingPeers.isNotEmpty()) { + Logger.warn( + "Network graph missing ${missingPeers.size} trusted peers: " + + "${missingPeers.joinToString { it.nodeId.take(20) + "..." }}", + context = TAG, + ) + return false + } + Logger.debug("Network graph validated: all ${trustedPeers.size} trusted peers present", context = TAG) + return true + } + suspend fun sync() { val node = this.node ?: throw ServiceError.NodeNotSetup() From 0f7bfe05ac23ad662c32c282163a6b138094aba2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 08:50:14 -0300 Subject: [PATCH 2/8] fix: check stale graph on start --- .../to/bitkit/repositories/LightningRepo.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f44ee9eec..cad6b5467 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -262,6 +262,7 @@ class LightningRepo @Inject constructor( customRgsServerUrl: String? = null, eventHandler: NodeEventHandler? = null, channelMigration: ChannelDataMigration? = null, + shouldValidateGraph: Boolean = true, ): Result = withContext(bgDispatcher) { if (_isRecoveryMode.value) { return@withContext Result.failure(RecoveryModeError()) @@ -313,6 +314,23 @@ class LightningRepo @Inject constructor( updateGeoBlockState() refreshChannelCache() + // Validate network graph has trusted peers (RGS cache can become stale) + if (shouldValidateGraph && !lightningService.validateNetworkGraph()) { + Logger.warn("Network graph is stale, resetting and restarting...", context = TAG) + lightningService.stop() + lightningService.resetNetworkGraph(walletIndex) + return@withContext start( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = shouldRetry, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + eventHandler = eventHandler, + channelMigration = channelMigration, + shouldValidateGraph = false, // Prevent infinite loop + ) + } + // Post-startup tasks (non-blocking) connectToTrustedPeers().onFailure { Logger.error("Failed to connect to trusted peers", it, context = TAG) From 4897047158e4db00813e31e75b927c6b505f7d84 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 09:09:03 -0300 Subject: [PATCH 3/8] test: update tests --- .../bitkit/repositories/LightningRepoTest.kt | 6 +++ .../java/to/bitkit/ui/WalletViewModelTest.kt | 39 ++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 10ed883c3..f1f4b6afe 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -91,6 +91,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever(lightningService.sync()).thenReturn(Unit) + whenever(lightningService.validateNetworkGraph()).thenReturn(true) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) val blocktank = mock() whenever(coreService.blocktank).thenReturn(blocktank) @@ -107,6 +108,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(mock()) whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(lightningService.validateNetworkGraph()).thenReturn(true) val blocktank = mock() whenever(coreService.blocktank).thenReturn(blocktank) whenever(blocktank.info(any())).thenReturn(null) @@ -388,6 +390,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(mock()) whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(lightningService.validateNetworkGraph()).thenReturn(true) whenever(lightningService.sync()).thenThrow(RuntimeException("Sync failed")) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) val blocktank = mock() @@ -621,6 +624,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(lightningService.validateNetworkGraph()).thenReturn(true) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) val blocktank = mock() @@ -665,6 +669,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(lightningService.validateNetworkGraph()).thenReturn(true) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) val blocktank = mock() @@ -690,6 +695,7 @@ class LightningRepoTest : BaseUnitTest() { // lightningService.start() succeeds (state becomes Running at line 241) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(lightningService.validateNetworkGraph()).thenReturn(true) // lightningService.nodeId throws during syncState() (called at line 244, AFTER state = Running) whenever(lightningService.nodeId).thenThrow(RuntimeException("error during syncState")) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 579fc2287..fdfbacb49 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -241,8 +241,18 @@ class WalletViewModelTest : BaseUnitTest() { whenever(testWalletRepo.walletExists()).thenReturn(true) whenever(testLightningRepo.lightningState).thenReturn(lightningState) whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) - whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(Unit)) + whenever( + testLightningRepo.start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + any(), + ), + ).thenReturn(Result.success(Unit)) val testSut = WalletViewModel( context = context, @@ -262,7 +272,16 @@ class WalletViewModelTest : BaseUnitTest() { testSut.start() advanceUntilIdle() - verify(testLightningRepo).start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(testLightningRepo).start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + any(), + ) verify(testWalletRepo).refreshBip21() } @@ -282,8 +301,18 @@ class WalletViewModelTest : BaseUnitTest() { whenever(testWalletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) whenever(testLightningRepo.lightningState).thenReturn(lightningState) whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) - whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(Unit)) + whenever( + testLightningRepo.start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + any(), + ), + ).thenReturn(Result.success(Unit)) val testSut = WalletViewModel( context = context, From 009e9bdc80d4974f9d5513d116007be8a3a7d3d6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 09:09:08 -0300 Subject: [PATCH 4/8] test: update tests --- .../java/to/bitkit/androidServices/LightningNodeServiceTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index f8c6bd9aa..918e31ac8 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -101,6 +101,7 @@ class LightningNodeServiceTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + any(), ) } doAnswer { capturedHandler = it.getArgument(5) as? NodeEventHandler From ef078146ae98933fb92e129b5c8e231e04ad9ee6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 09:15:55 -0300 Subject: [PATCH 5/8] fix: skip graph validation when empty Co-Authored-By: Claude Opus 4.5 --- app/src/main/java/to/bitkit/services/LightningService.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index a4589644e..8cdcc44f1 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -288,6 +288,10 @@ class LightningService @Inject constructor( val node = this.node ?: return true val graph = node.networkGraph() val graphNodes = graph.listNodes().toSet() + if (graphNodes.isEmpty()) { + Logger.debug("Network graph is empty, skipping validation", context = TAG) + return true + } val missingPeers = trustedPeers.filter { it.nodeId !in graphNodes } if (missingPeers.isNotEmpty()) { Logger.warn( From 5e61aff167ec74f30747ef0ec30aabcc9caff41e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 09:18:38 -0300 Subject: [PATCH 6/8] chore: lint --- app/src/main/java/to/bitkit/services/LightningService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 8cdcc44f1..2205e4db1 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -296,7 +296,7 @@ class LightningService @Inject constructor( if (missingPeers.isNotEmpty()) { Logger.warn( "Network graph missing ${missingPeers.size} trusted peers: " + - "${missingPeers.joinToString { it.nodeId.take(20) + "..." }}", + missingPeers.joinToString { it.nodeId.take(20) + "..." }, context = TAG, ) return false From ba66a93c14a4d16d48c5b9521d1b933c1fe70c08 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 09:40:54 -0300 Subject: [PATCH 7/8] fix: relax graph validation --- .../to/bitkit/services/LightningService.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 2205e4db1..e7028ff40 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -282,7 +282,7 @@ class LightningService @Inject constructor( /** * Validates that all trusted peers are present in the network graph. - * Returns false if any trusted peer is missing, indicating the graph cache is stale. + * Returns false if all trusted peers are missing, indicating the graph cache is stale. */ fun validateNetworkGraph(): Boolean { val node = this.node ?: return true @@ -293,15 +293,24 @@ class LightningService @Inject constructor( return true } val missingPeers = trustedPeers.filter { it.nodeId !in graphNodes } - if (missingPeers.isNotEmpty()) { + if (missingPeers.size == trustedPeers.size) { Logger.warn( - "Network graph missing ${missingPeers.size} trusted peers: " + - missingPeers.joinToString { it.nodeId.take(20) + "..." }, + "Network graph missing all ${trustedPeers.size} trusted peers", context = TAG, ) return false } - Logger.debug("Network graph validated: all ${trustedPeers.size} trusted peers present", context = TAG) + if (missingPeers.isNotEmpty()) { + Logger.debug( + "Network graph missing ${missingPeers.size}/${trustedPeers.size} trusted peers", + context = TAG, + ) + } + val presentCount = trustedPeers.size - missingPeers.size + Logger.debug( + "Network graph validated: $presentCount/${trustedPeers.size} trusted peers present", + context = TAG, + ) return true } From a8e8bebf9f1f03131b7913bc9b07167c27202358 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 09:56:59 -0300 Subject: [PATCH 8/8] fix: re-add graph validation after merge Co-Authored-By: Claude Opus 4.5 --- .../to/bitkit/repositories/LightningRepo.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f7ae00dc0..ef4492d2e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -274,6 +274,7 @@ class LightningRepo @Inject constructor( // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) var shouldRetryStart = false + var shouldRestartForGraphReset = false var initialLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped val result = lifecycleMutex.withLock { @@ -321,6 +322,16 @@ class LightningRepo @Inject constructor( updateGeoBlockState() refreshChannelCache() + // Validate network graph has trusted peers (RGS cache can become stale) + if (shouldValidateGraph && !lightningService.validateNetworkGraph()) { + Logger.warn("Network graph is stale, resetting and restarting...", context = TAG) + lightningService.stop() + lightningService.resetNetworkGraph(walletIndex) + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } + shouldRestartForGraphReset = true + return@withLock Result.success(Unit) + } + // Post-startup tasks (non-blocking) connectToTrustedPeers().onFailure { Logger.error("Failed to connect to trusted peers", it, context = TAG) @@ -360,6 +371,21 @@ class LightningRepo @Inject constructor( customServerUrl = customServerUrl, customRgsServerUrl = customRgsServerUrl, channelMigration = channelMigration, + shouldValidateGraph = shouldValidateGraph, + ) + } + + // Restart after graph reset OUTSIDE the mutex to avoid deadlock + if (shouldRestartForGraphReset) { + return@withContext start( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = shouldRetry, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + eventHandler = eventHandler, + channelMigration = channelMigration, + shouldValidateGraph = false, // Prevent infinite loop ) }