Skip to content

Commit c2c0c80

Browse files
authored
Merge pull request #765 from synonymdev/fix/stale-graph
fix: auto-recover stale network graph cache
2 parents 14dc5c4 + 9e4bf4e commit c2c0c80

5 files changed

Lines changed: 120 additions & 5 deletions

File tree

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ class LightningRepo @Inject constructor(
265265
customRgsServerUrl: String? = null,
266266
eventHandler: NodeEventHandler? = null,
267267
channelMigration: ChannelDataMigration? = null,
268+
shouldValidateGraph: Boolean = true,
268269
): Result<Unit> = withContext(bgDispatcher) {
269270
if (_isRecoveryMode.value) {
270271
return@withContext Result.failure(RecoveryModeError())
@@ -274,6 +275,7 @@ class LightningRepo @Inject constructor(
274275

275276
// Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant)
276277
var shouldRetryStart = false
278+
var shouldRestartForGraphReset = false
277279
var initialLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped
278280

279281
val result = lifecycleMutex.withLock {
@@ -321,6 +323,16 @@ class LightningRepo @Inject constructor(
321323
updateGeoBlockState()
322324
refreshChannelCache()
323325

326+
// Validate network graph has trusted peers (RGS cache can become stale)
327+
if (shouldValidateGraph && !lightningService.validateNetworkGraph()) {
328+
Logger.warn("Network graph is stale, resetting and restarting...", context = TAG)
329+
lightningService.stop()
330+
lightningService.resetNetworkGraph(walletIndex)
331+
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) }
332+
shouldRestartForGraphReset = true
333+
return@withLock Result.success(Unit)
334+
}
335+
324336
// Post-startup tasks (non-blocking)
325337
connectToTrustedPeers().onFailure {
326338
Logger.error("Failed to connect to trusted peers", it, context = TAG)
@@ -360,6 +372,21 @@ class LightningRepo @Inject constructor(
360372
customServerUrl = customServerUrl,
361373
customRgsServerUrl = customRgsServerUrl,
362374
channelMigration = channelMigration,
375+
shouldValidateGraph = shouldValidateGraph,
376+
)
377+
}
378+
379+
// Restart after graph reset OUTSIDE the mutex to avoid deadlock
380+
if (shouldRestartForGraphReset) {
381+
return@withContext start(
382+
walletIndex = walletIndex,
383+
timeout = timeout,
384+
shouldRetry = shouldRetry,
385+
customServerUrl = customServerUrl,
386+
customRgsServerUrl = customRgsServerUrl,
387+
eventHandler = eventHandler,
388+
channelMigration = channelMigration,
389+
shouldValidateGraph = false, // Prevent infinite loop
363390
)
364391
}
365392

app/src/main/java/to/bitkit/services/LightningService.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,58 @@ class LightningService @Inject constructor(
262262
Logger.info("LDK storage wiped", context = TAG)
263263
}
264264

265+
/**
266+
* Resets the network graph cache, forcing a full RGS sync on next startup.
267+
* This is useful when the cached graph is stale or missing nodes.
268+
* Note: Node must be stopped before calling this.
269+
*/
270+
fun resetNetworkGraph(walletIndex: Int) {
271+
if (node != null) throw ServiceError.NodeStillRunning()
272+
Logger.warn("Resetting network graph cache…", context = TAG)
273+
val ldkPath = Path(Env.ldkStoragePath(walletIndex)).toFile()
274+
val graphFile = ldkPath.resolve("network_graph")
275+
if (graphFile.exists()) {
276+
graphFile.delete()
277+
Logger.info("Network graph cache deleted", context = TAG)
278+
} else {
279+
Logger.info("No network graph cache found", context = TAG)
280+
}
281+
}
282+
283+
/**
284+
* Validates that all trusted peers are present in the network graph.
285+
* Returns false if all trusted peers are missing, indicating the graph cache is stale.
286+
*/
287+
fun validateNetworkGraph(): Boolean {
288+
val node = this.node ?: return true
289+
val graph = node.networkGraph()
290+
val graphNodes = graph.listNodes().toSet()
291+
if (graphNodes.isEmpty()) {
292+
Logger.debug("Network graph is empty, skipping validation", context = TAG)
293+
return true
294+
}
295+
val missingPeers = trustedPeers.filter { it.nodeId !in graphNodes }
296+
if (missingPeers.size == trustedPeers.size) {
297+
Logger.warn(
298+
"Network graph missing all ${trustedPeers.size} trusted peers",
299+
context = TAG,
300+
)
301+
return false
302+
}
303+
if (missingPeers.isNotEmpty()) {
304+
Logger.debug(
305+
"Network graph missing ${missingPeers.size}/${trustedPeers.size} trusted peers",
306+
context = TAG,
307+
)
308+
}
309+
val presentCount = trustedPeers.size - missingPeers.size
310+
Logger.debug(
311+
"Network graph validated: $presentCount/${trustedPeers.size} trusted peers present",
312+
context = TAG,
313+
)
314+
return true
315+
}
316+
265317
suspend fun sync() {
266318
val node = this.node ?: throw ServiceError.NodeNotSetup()
267319

app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class LightningNodeServiceTest : BaseUnitTest() {
101101
anyOrNull(),
102102
anyOrNull(),
103103
anyOrNull(),
104+
any(),
104105
)
105106
} doAnswer {
106107
capturedHandler = it.getArgument(5) as? NodeEventHandler

app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class LightningRepoTest : BaseUnitTest() {
9191
whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit)
9292
whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit)
9393
whenever(lightningService.sync()).thenReturn(Unit)
94+
whenever(lightningService.validateNetworkGraph()).thenReturn(true)
9495
whenever(settingsStore.data).thenReturn(flowOf(SettingsData()))
9596
val blocktank = mock<BlocktankService>()
9697
whenever(coreService.blocktank).thenReturn(blocktank)
@@ -107,6 +108,7 @@ class LightningRepoTest : BaseUnitTest() {
107108
whenever(lightningService.node).thenReturn(mock())
108109
whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit)
109110
whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit)
111+
whenever(lightningService.validateNetworkGraph()).thenReturn(true)
110112
val blocktank = mock<BlocktankService>()
111113
whenever(coreService.blocktank).thenReturn(blocktank)
112114
whenever(blocktank.info(any())).thenReturn(null)
@@ -388,6 +390,7 @@ class LightningRepoTest : BaseUnitTest() {
388390
whenever(lightningService.node).thenReturn(mock())
389391
whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit)
390392
whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit)
393+
whenever(lightningService.validateNetworkGraph()).thenReturn(true)
391394
whenever(lightningService.sync()).thenThrow(RuntimeException("Sync failed"))
392395
whenever(settingsStore.data).thenReturn(flowOf(SettingsData()))
393396
val blocktank = mock<BlocktankService>()
@@ -621,6 +624,7 @@ class LightningRepoTest : BaseUnitTest() {
621624
whenever(lightningService.node).thenReturn(null)
622625
whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit)
623626
whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit)
627+
whenever(lightningService.validateNetworkGraph()).thenReturn(true)
624628
whenever(settingsStore.data).thenReturn(flowOf(SettingsData()))
625629

626630
val blocktank = mock<BlocktankService>()
@@ -665,6 +669,7 @@ class LightningRepoTest : BaseUnitTest() {
665669
whenever(lightningService.node).thenReturn(null)
666670
whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit)
667671
whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit)
672+
whenever(lightningService.validateNetworkGraph()).thenReturn(true)
668673
whenever(settingsStore.data).thenReturn(flowOf(SettingsData()))
669674

670675
val blocktank = mock<BlocktankService>()
@@ -690,6 +695,7 @@ class LightningRepoTest : BaseUnitTest() {
690695

691696
// lightningService.start() succeeds (state becomes Running at line 241)
692697
whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit)
698+
whenever(lightningService.validateNetworkGraph()).thenReturn(true)
693699
// lightningService.nodeId throws during syncState() (called at line 244, AFTER state = Running)
694700
whenever(lightningService.nodeId).thenThrow(RuntimeException("error during syncState"))
695701

app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,18 @@ class WalletViewModelTest : BaseUnitTest() {
241241
whenever(testWalletRepo.walletExists()).thenReturn(true)
242242
whenever(testLightningRepo.lightningState).thenReturn(lightningState)
243243
whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode)
244-
whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()))
245-
.thenReturn(Result.success(Unit))
244+
whenever(
245+
testLightningRepo.start(
246+
any(),
247+
anyOrNull(),
248+
any(),
249+
anyOrNull(),
250+
anyOrNull(),
251+
anyOrNull(),
252+
anyOrNull(),
253+
any(),
254+
),
255+
).thenReturn(Result.success(Unit))
246256

247257
val testSut = WalletViewModel(
248258
context = context,
@@ -262,7 +272,16 @@ class WalletViewModelTest : BaseUnitTest() {
262272
testSut.start()
263273
advanceUntilIdle()
264274

265-
verify(testLightningRepo).start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
275+
verify(testLightningRepo).start(
276+
any(),
277+
anyOrNull(),
278+
any(),
279+
anyOrNull(),
280+
anyOrNull(),
281+
anyOrNull(),
282+
anyOrNull(),
283+
any(),
284+
)
266285
verify(testWalletRepo).refreshBip21()
267286
}
268287

@@ -282,8 +301,18 @@ class WalletViewModelTest : BaseUnitTest() {
282301
whenever(testWalletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit))
283302
whenever(testLightningRepo.lightningState).thenReturn(lightningState)
284303
whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode)
285-
whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()))
286-
.thenReturn(Result.success(Unit))
304+
whenever(
305+
testLightningRepo.start(
306+
any(),
307+
anyOrNull(),
308+
any(),
309+
anyOrNull(),
310+
anyOrNull(),
311+
anyOrNull(),
312+
anyOrNull(),
313+
any(),
314+
),
315+
).thenReturn(Result.success(Unit))
287316

288317
val testSut = WalletViewModel(
289318
context = context,

0 commit comments

Comments
 (0)