From c32193f13e612cf9d189eefa07aeaed2449a7de2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 27 Jan 2026 11:17:00 -0300 Subject: [PATCH 01/13] fix: start and stop race condition --- .../to/bitkit/repositories/LightningRepo.kt | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f44ee9eec..a2bd68db0 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -108,6 +109,7 @@ class LightningRepo @Inject constructor( private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) + private val lifecycleMutex = Mutex() init { observeConnectivityForSyncRetry() @@ -269,6 +271,15 @@ class LightningRepo @Inject constructor( eventHandler?.let { _eventHandlers.add(it) } + // Wait for any in-progress stop to complete to avoid race conditions + val currentLifecycleState = _lightningState.value.nodeLifecycleState + if (currentLifecycleState == NodeLifecycleState.Stopping) { + Logger.debug("Waiting for node to finish stopping before starting...", context = TAG) + withTimeoutOrNull(30.seconds) { + _lightningState.first { it.nodeLifecycleState != NodeLifecycleState.Stopping } + } ?: Logger.warn("Timeout waiting for node to stop, proceeding anyway", context = TAG) + } + val initialLifecycleState = _lightningState.value.nodeLifecycleState if (initialLifecycleState.isRunningOrStarting()) { Logger.info("LDK node start skipped, lifecycle state: $initialLifecycleState", context = TAG) @@ -374,16 +385,36 @@ class LightningRepo @Inject constructor( } suspend fun stop(): Result = withContext(bgDispatcher) { - if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { - return@withContext Result.success(Unit) - } + lifecycleMutex.withLock { + if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { + return@withLock Result.success(Unit) + } - runCatching { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } - lightningService.stop() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } - }.onFailure { - Logger.error("Node stop error", it, context = TAG) + // Wait for any in-progress start to complete + val currentState = _lightningState.value.nodeLifecycleState + if (currentState == NodeLifecycleState.Starting) { + Logger.debug("Waiting for node to finish starting before stopping...", context = TAG) + withTimeoutOrNull(30.seconds) { + _lightningState.first { it.nodeLifecycleState != NodeLifecycleState.Starting } + } ?: Logger.warn("Timeout waiting for node to start, proceeding with stop", context = TAG) + } + + runCatching { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } + lightningService.stop() + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + }.onFailure { + Logger.error("Node stop error", it, context = TAG) + // On failure, check actual node state and update accordingly + // If node is still running, revert to Running state to allow retry + if (lightningService.node != null && getStatus()?.isRunning == true) { + Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + } else { + // Node appears stopped, update state + _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + } + } } } From b2477a0c949280ee2741ecd1be7cf08d367f1417 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 27 Jan 2026 12:08:50 -0300 Subject: [PATCH 02/13] fix: get status directly from service --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a2bd68db0..1af4e6a44 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -407,7 +407,7 @@ class LightningRepo @Inject constructor( Logger.error("Node stop error", it, context = TAG) // On failure, check actual node state and update accordingly // If node is still running, revert to Running state to allow retry - if (lightningService.node != null && getStatus()?.isRunning == true) { + if (lightningService.node != null && lightningService.status?.isRunning == true) { Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } } else { From 27d4f0cfbe206cf837243c32d306497b64cf613c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 27 Jan 2026 13:37:53 -0300 Subject: [PATCH 03/13] fix: add mutex to start() for symmetric lifecycle protection Co-Authored-By: Claude Opus 4.5 --- .../to/bitkit/repositories/LightningRepo.kt | 162 +++++++++--------- 1 file changed, 77 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1af4e6a44..5ce6b4694 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -271,97 +271,98 @@ class LightningRepo @Inject constructor( eventHandler?.let { _eventHandlers.add(it) } - // Wait for any in-progress stop to complete to avoid race conditions - val currentLifecycleState = _lightningState.value.nodeLifecycleState - if (currentLifecycleState == NodeLifecycleState.Stopping) { - Logger.debug("Waiting for node to finish stopping before starting...", context = TAG) - withTimeoutOrNull(30.seconds) { - _lightningState.first { it.nodeLifecycleState != NodeLifecycleState.Stopping } - } ?: Logger.warn("Timeout waiting for node to stop, proceeding anyway", context = TAG) - } - - val initialLifecycleState = _lightningState.value.nodeLifecycleState - if (initialLifecycleState.isRunningOrStarting()) { - Logger.info("LDK node start skipped, lifecycle state: $initialLifecycleState", context = TAG) - return@withContext Result.success(Unit) - } + // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) + var shouldRetryStart = false + var initialLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped + + val result = lifecycleMutex.withLock { + initialLifecycleState = _lightningState.value.nodeLifecycleState + if (initialLifecycleState.isRunningOrStarting()) { + Logger.info("LDK node start skipped, lifecycle state: $initialLifecycleState", context = TAG) + return@withLock Result.success(Unit) + } - runCatching { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } - - // Setup if needed - if (lightningService.node == null) { - val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) - if (setupResult.isFailure) { - _lightningState.update { - it.copy( - nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: NodeSetupError() + runCatching { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } + + // Setup if needed + if (lightningService.node == null) { + val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) + if (setupResult.isFailure) { + _lightningState.update { + it.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting( + setupResult.exceptionOrNull() ?: NodeSetupError() + ) ) - ) + } + return@withLock setupResult } - return@withContext setupResult } - } - if (getStatus()?.isRunning == true) { - Logger.info("LDK node already running", context = TAG) - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } - lightningService.startEventListener(::onEvent).onFailure { - Logger.warn("Failed to start event listener", it, context = TAG) - return@withContext Result.failure(it) + if (getStatus()?.isRunning == true) { + Logger.info("LDK node already running", context = TAG) + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + lightningService.startEventListener(::onEvent).onFailure { + Logger.warn("Failed to start event listener", it, context = TAG) + return@withLock Result.failure(it) + } + return@withLock Result.success(Unit) } - return@withContext Result.success(Unit) - } - // Start node - lightningService.start(timeout, ::onEvent) + // Start node + lightningService.start(timeout, ::onEvent) - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } - // Initial state sync - syncState() - updateGeoBlockState() - refreshChannelCache() + // Initial state sync + syncState() + updateGeoBlockState() + refreshChannelCache() - // Post-startup tasks (non-blocking) - connectToTrustedPeers().onFailure { - Logger.error("Failed to connect to trusted peers", it, context = TAG) - } + // Post-startup tasks (non-blocking) + connectToTrustedPeers().onFailure { + Logger.error("Failed to connect to trusted peers", it, context = TAG) + } - sync().onFailure { e -> - Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) - } - scope.launch { registerForNotifications() } - Unit - }.onFailure { e -> - val currentLifecycleState = _lightningState.value.nodeLifecycleState - if (currentLifecycleState.isRunning()) { - Logger.warn("Start error occurred but node is $currentLifecycleState, skipping retry", e, context = TAG) - return@withContext Result.success(Unit) - } + sync().onFailure { e -> + Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) + } + scope.launch { registerForNotifications() } + Result.success(Unit) + }.getOrElse { e -> + val currentState = _lightningState.value.nodeLifecycleState + if (currentState.isRunning()) { + Logger.warn("Start error but node is $currentState, skipping retry", e, context = TAG) + return@withLock Result.success(Unit) + } - if (shouldRetry) { - val retryDelay = 2.seconds - Logger.warn("Start error, retrying after $retryDelay...", e, context = TAG) - _lightningState.update { it.copy(nodeLifecycleState = initialLifecycleState) } - - delay(retryDelay) - return@withContext start( - walletIndex = walletIndex, - timeout = timeout, - shouldRetry = false, - customServerUrl = customServerUrl, - customRgsServerUrl = customRgsServerUrl, - channelMigration = channelMigration, - ) - } else { - _lightningState.update { - it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) + if (shouldRetry) { + Logger.warn("Start error, will retry...", e, context = TAG) + _lightningState.update { it.copy(nodeLifecycleState = initialLifecycleState) } + shouldRetryStart = true + Result.failure(e) + } else { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) } + Result.failure(e) } - return@withContext Result.failure(e) } } + + // Retry OUTSIDE the mutex to avoid deadlock (Kotlin Mutex is non-reentrant) + if (shouldRetryStart) { + delay(2.seconds) + return@withContext start( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = false, + customServerUrl = customServerUrl, + customRgsServerUrl = customRgsServerUrl, + channelMigration = channelMigration, + ) + } + + result } private suspend fun onEvent(event: Event) { @@ -390,15 +391,6 @@ class LightningRepo @Inject constructor( return@withLock Result.success(Unit) } - // Wait for any in-progress start to complete - val currentState = _lightningState.value.nodeLifecycleState - if (currentState == NodeLifecycleState.Starting) { - Logger.debug("Waiting for node to finish starting before stopping...", context = TAG) - withTimeoutOrNull(30.seconds) { - _lightningState.first { it.nodeLifecycleState != NodeLifecycleState.Starting } - } ?: Logger.warn("Timeout waiting for node to start, proceeding with stop", context = TAG) - } - runCatching { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() From ca7f9780edcf0a5213a619f522b5b1c1385732ec Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Feb 2026 08:47:23 -0300 Subject: [PATCH 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 ) } From eb1a383039284acb48b4e57a81cb696d0f712cf3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Feb 2026 07:08:25 -0300 Subject: [PATCH 12/13] fix: check timestamp on empty graph nodes --- app/src/main/java/to/bitkit/services/LightningService.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index fa955258d..026d311e3 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -289,6 +289,11 @@ class LightningService @Inject constructor( val graph = node.networkGraph() val graphNodes = graph.listNodes().toSet() if (graphNodes.isEmpty()) { + val rgsTimestamp = node.status().latestRgsSnapshotTimestamp + if (rgsTimestamp != null) { + Logger.warn("Network graph is empty despite RGS timestamp $rgsTimestamp", context = TAG) + return false + } Logger.debug("Network graph is empty, skipping validation", context = TAG) return true } From 1010b5f6c78155c5e77f26361c8b7ce1c29f09de Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Feb 2026 16:43:48 +0100 Subject: [PATCH 13/13] fix: reset graph both from local and vss cache --- .../main/java/to/bitkit/repositories/LightningRepo.kt | 10 ++++++++++ .../main/java/to/bitkit/services/LightningService.kt | 2 +- .../java/to/bitkit/repositories/LightningRepoTest.kt | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2be589ec6..8c03b0443 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -46,6 +46,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -92,6 +93,7 @@ class LightningRepo @Inject constructor( private val cacheStore: CacheStore, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val connectivityRepo: ConnectivityRepo, + private val vssBackupClient: VssBackupClient, ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -328,6 +330,14 @@ class LightningRepo @Inject constructor( Logger.warn("Network graph is stale, resetting and restarting...", context = TAG) lightningService.stop() lightningService.resetNetworkGraph(walletIndex) + // Also clear stale graph from VSS to prevent fallback restoration + runCatching { + vssBackupClient.setup(walletIndex).getOrThrow() + vssBackupClient.deleteObject("network_graph").getOrThrow() + Logger.info("Cleared stale network graph from VSS", context = TAG) + }.onFailure { + Logger.warn("Failed to clear graph from VSS", it, context = TAG) + } _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } shouldRestartForGraphReset = true return@withLock Result.success(Unit) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 026d311e3..4df47a556 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -271,7 +271,7 @@ class LightningService @Inject constructor( 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") + val graphFile = ldkPath.resolve("network_graph_cache") if (graphFile.exists()) { graphFile.delete() Logger.info("Network graph cache deleted", context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index f1f4b6afe..703263fe0 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -32,6 +32,7 @@ import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of @@ -65,6 +66,7 @@ class LightningRepoTest : BaseUnitTest() { private val preActivityMetadataRepo = mock() private val lnurlService = mock() private val connectivityRepo = mock() + private val vssBackupClient = mock() @Before fun setUp() = runBlocking { @@ -82,6 +84,7 @@ class LightningRepoTest : BaseUnitTest() { cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, connectivityRepo = connectivityRepo, + vssBackupClient = vssBackupClient, ) }