From 7e09b8ffd1766676c63f91db3cde28b6de9445b6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 24 Feb 2026 07:17:50 -0300 Subject: [PATCH 1/6] fix: check for orphaned channel monitor recovery --- .../to/bitkit/services/MigrationService.kt | 20 +++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 57 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 7456c8686..4eceb31d9 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -90,6 +90,7 @@ class MigrationService @Inject constructor( private const val RN_PENDING_METADATA_KEY = "rnPendingMetadata" private const val RN_PENDING_TRANSFERS_KEY = "rnPendingTransfers" private const val RN_PENDING_BOOSTS_KEY = "rnPendingBoosts" + private const val RN_CHANNEL_RECOVERY_CHECKED_KEY = "rnChannelRecoveryChecked" private const val OPENING_CURLY_BRACE = "{" private const val MMKV_ROOT = "persist:root" private const val RN_WALLET_NAME = "wallet0" @@ -348,6 +349,25 @@ class MigrationService @Inject constructor( rnMigrationStore.edit { it[key] = "true" } } + suspend fun isMigrationCompleted(): Boolean { + val key = stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY) + return rnMigrationStore.data.first()[key] == "true" + } + + suspend fun isChannelRecoveryChecked(): Boolean { + val key = stringPreferencesKey(RN_CHANNEL_RECOVERY_CHECKED_KEY) + return rnMigrationStore.data.first()[key] == "true" + } + + suspend fun markChannelRecoveryChecked() { + val key = stringPreferencesKey(RN_CHANNEL_RECOVERY_CHECKED_KEY) + rnMigrationStore.edit { it[key] = "true" } + } + + suspend fun fetchChannelRecoveryData() { + fetchRNRemoteLdkData() + } + suspend fun hasRNWalletData(): Boolean { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) if (mnemonic?.isNotEmpty() == true) return true diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0d371c8c..a6ca76fe1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -205,6 +205,9 @@ class AppViewModel @Inject constructor( registerSheet(highBalanceSheet) } private var isCompletingMigration = false + + @Volatile + private var isPerformingChannelRecovery = false private var addressValidationJob: Job? = null fun setShowForgotPin(value: Boolean) { @@ -370,7 +373,10 @@ class AppViewModel @Inject constructor( when { (isShowingLoading || needsPostMigrationSync) && !isCompletingMigration -> completeMigration() isRestoringRemote -> completeRNRemoteBackupRestore() - !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() + !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> { + walletRepo.debounceSyncByEvent() + checkForOrphanedChannelMonitorRecovery() + } else -> Unit } } @@ -503,6 +509,55 @@ class AppViewModel @Inject constructor( } } + private fun checkForOrphanedChannelMonitorRecovery() { + if (isPerformingChannelRecovery) return + viewModelScope.launch(bgDispatcher) { + if (migrationService.isChannelRecoveryChecked()) return@launch + if (!migrationService.isMigrationCompleted()) { + migrationService.markChannelRecoveryChecked() + return@launch + } + + isPerformingChannelRecovery = true + Logger.info("Running one-time channel monitor recovery check", context = TAG) + + runCatching { + migrationService.fetchChannelRecoveryData() + val channelMigration = buildChannelMigrationIfAvailable() + + if (channelMigration == null) { + Logger.info("No channel monitors found on RN backup", context = TAG) + return@runCatching + } + + Logger.info( + "Found ${channelMigration.channelMonitors.size} monitors on RN backup, attempting recovery", + context = TAG, + ) + + lightningRepo.stop().onFailure { + Logger.error("Failed to stop node for channel recovery", it, context = TAG) + } + delay(REMOTE_RESTORE_NODE_RESTART_DELAY_MS) + lightningRepo.start(channelMigration = channelMigration, shouldRetry = false) + .onSuccess { + migrationService.consumePendingChannelMigration() + walletRepo.syncNodeAndWallet() + walletRepo.syncBalances() + Logger.info("Channel monitor recovery complete", context = TAG) + } + .onFailure { + Logger.error("Failed to restart node after channel recovery", it, context = TAG) + } + }.onFailure { + Logger.error("Channel monitor recovery check failed", it, context = TAG) + } + + migrationService.markChannelRecoveryChecked() + isPerformingChannelRecovery = false + } + } + private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) } From 9cd5bc1e87a092d87f56605eb34e4acb0be34614 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 24 Feb 2026 07:37:33 -0300 Subject: [PATCH 2/6] refactor: move logic to wallet start --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 57 +------------------ .../to/bitkit/viewmodels/WalletViewModel.kt | 46 +++++++++++++++ 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a6ca76fe1..f0d371c8c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -205,9 +205,6 @@ class AppViewModel @Inject constructor( registerSheet(highBalanceSheet) } private var isCompletingMigration = false - - @Volatile - private var isPerformingChannelRecovery = false private var addressValidationJob: Job? = null fun setShowForgotPin(value: Boolean) { @@ -373,10 +370,7 @@ class AppViewModel @Inject constructor( when { (isShowingLoading || needsPostMigrationSync) && !isCompletingMigration -> completeMigration() isRestoringRemote -> completeRNRemoteBackupRestore() - !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> { - walletRepo.debounceSyncByEvent() - checkForOrphanedChannelMonitorRecovery() - } + !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() else -> Unit } } @@ -509,55 +503,6 @@ class AppViewModel @Inject constructor( } } - private fun checkForOrphanedChannelMonitorRecovery() { - if (isPerformingChannelRecovery) return - viewModelScope.launch(bgDispatcher) { - if (migrationService.isChannelRecoveryChecked()) return@launch - if (!migrationService.isMigrationCompleted()) { - migrationService.markChannelRecoveryChecked() - return@launch - } - - isPerformingChannelRecovery = true - Logger.info("Running one-time channel monitor recovery check", context = TAG) - - runCatching { - migrationService.fetchChannelRecoveryData() - val channelMigration = buildChannelMigrationIfAvailable() - - if (channelMigration == null) { - Logger.info("No channel monitors found on RN backup", context = TAG) - return@runCatching - } - - Logger.info( - "Found ${channelMigration.channelMonitors.size} monitors on RN backup, attempting recovery", - context = TAG, - ) - - lightningRepo.stop().onFailure { - Logger.error("Failed to stop node for channel recovery", it, context = TAG) - } - delay(REMOTE_RESTORE_NODE_RESTART_DELAY_MS) - lightningRepo.start(channelMigration = channelMigration, shouldRetry = false) - .onSuccess { - migrationService.consumePendingChannelMigration() - walletRepo.syncNodeAndWallet() - walletRepo.syncBalances() - Logger.info("Channel monitor recovery complete", context = TAG) - } - .onFailure { - Logger.error("Failed to restart node after channel recovery", it, context = TAG) - } - }.onFailure { - Logger.error("Channel monitor recovery check failed", it, context = TAG) - } - - migrationService.markChannelRecoveryChecked() - isPerformingChannelRecovery = false - } - } - private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index cd2b5ee43..d180d622b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -61,6 +61,7 @@ class WalletViewModel @Inject constructor( companion object { private const val TAG = "WalletViewModel" private val TIMEOUT_RESTORE_WAIT = 30.seconds + private const val CHANNEL_RECOVERY_RESTART_DELAY_MS = 500L } val lightningState = lightningRepo.lightningState @@ -295,6 +296,7 @@ class WalletViewModel @Inject constructor( if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() } + checkForOrphanedChannelMonitorRecovery() } .onFailure { Logger.error("Node startup error", it, context = TAG) @@ -304,6 +306,50 @@ class WalletViewModel @Inject constructor( } } + private suspend fun checkForOrphanedChannelMonitorRecovery() { + if (migrationService.isChannelRecoveryChecked()) return + if (!migrationService.isMigrationCompleted()) { + migrationService.markChannelRecoveryChecked() + return + } + + Logger.info("Running one-time channel monitor recovery check", context = TAG) + + runCatching { + migrationService.fetchChannelRecoveryData() + val channelMigration = buildChannelMigrationIfAvailable() + + if (channelMigration == null) { + Logger.info("No channel monitors found on RN backup", context = TAG) + return@runCatching + } + + Logger.info( + "Found ${channelMigration.channelMonitors.size} monitors on RN backup, attempting recovery", + context = TAG, + ) + + lightningRepo.stop().onFailure { + Logger.error("Failed to stop node for channel recovery", it, context = TAG) + } + delay(CHANNEL_RECOVERY_RESTART_DELAY_MS) + lightningRepo.start(channelMigration = channelMigration, shouldRetry = false) + .onSuccess { + migrationService.consumePendingChannelMigration() + walletRepo.syncNodeAndWallet() + walletRepo.syncBalances() + Logger.info("Channel monitor recovery complete", context = TAG) + } + .onFailure { + Logger.error("Failed to restart node after channel recovery", it, context = TAG) + } + }.onFailure { + Logger.error("Channel monitor recovery check failed", it, context = TAG) + } + + migrationService.markChannelRecoveryChecked() + } + fun stop() { if (!walletExists) return From fde8a137004eff1c83516d8758261f58c9deac4b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 24 Feb 2026 09:47:23 -0300 Subject: [PATCH 3/6] fix: add retry flow if some migration fail --- .../to/bitkit/services/MigrationService.kt | 18 +++++++++--------- .../to/bitkit/viewmodels/WalletViewModel.kt | 18 +++++++++++------- .../java/to/bitkit/ui/WalletViewModelTest.kt | 1 + 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index f0ce7470a..a0d252536 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -366,9 +366,7 @@ class MigrationService @Inject constructor( rnMigrationStore.edit { it[key] = "true" } } - suspend fun fetchChannelRecoveryData() { - fetchRNRemoteLdkData() - } + suspend fun fetchChannelRecoveryData(): Boolean = fetchRNRemoteLdkData() suspend fun hasRNWalletData(): Boolean { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) @@ -1301,13 +1299,13 @@ class MigrationService @Inject constructor( return null } - private suspend fun fetchRNRemoteLdkData() { - runCatching { - val files = rnBackupClient.listFiles(fileGroup = "ldk") ?: return@runCatching - if (!files.list.any { it.removeSuffix(".bin") == "channel_manager" }) return@runCatching + private suspend fun fetchRNRemoteLdkData(): Boolean { + return runCatching { + val files = rnBackupClient.listFiles(fileGroup = "ldk") ?: return@runCatching true + if (!files.list.any { it.removeSuffix(".bin") == "channel_manager" }) return@runCatching true val managerData = rnBackupClient.retrieve("channel_manager", fileGroup = "ldk") - ?: return@runCatching + ?: return@runCatching true val expectedCount = files.channelMonitors.size val monitorResults = coroutineScope { @@ -1344,9 +1342,11 @@ class MigrationService @Inject constructor( channelMonitors = monitors, ) } + + failedMonitors.isEmpty() }.onFailure { e -> Logger.error("Failed to fetch remote LDK data", e, context = TAG) - } + }.getOrDefault(false) } private suspend fun applyRNRemoteSettings(data: ByteArray) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d180d622b..62186b6d0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -315,13 +315,13 @@ class WalletViewModel @Inject constructor( Logger.info("Running one-time channel monitor recovery check", context = TAG) - runCatching { - migrationService.fetchChannelRecoveryData() + val allMonitorsRetrieved = runCatching { + val allRetrieved = migrationService.fetchChannelRecoveryData() val channelMigration = buildChannelMigrationIfAvailable() if (channelMigration == null) { Logger.info("No channel monitors found on RN backup", context = TAG) - return@runCatching + return@runCatching true } Logger.info( @@ -343,11 +343,15 @@ class WalletViewModel @Inject constructor( .onFailure { Logger.error("Failed to restart node after channel recovery", it, context = TAG) } - }.onFailure { - Logger.error("Channel monitor recovery check failed", it, context = TAG) - } - migrationService.markChannelRecoveryChecked() + allRetrieved + }.getOrDefault(false) + + if (allMonitorsRetrieved) { + migrationService.markChannelRecoveryChecked() + } else { + Logger.warn("Some monitors failed to download, will retry on next startup", context = TAG) + } } fun stop() { diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index fdfbacb49..7a0de29f0 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -57,6 +57,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(migrationService.isMigrationChecked()).thenReturn(true) + whenever(migrationService.isChannelRecoveryChecked()).thenReturn(true) whenever(connectivityRepo.isOnline).thenReturn(isOnline) sut = WalletViewModel( From e3435ed034492be253ae07a704022d73ed081b68 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 24 Feb 2026 11:35:22 -0300 Subject: [PATCH 4/6] fix: remove isMigrationCompleted guard --- app/src/main/java/to/bitkit/services/MigrationService.kt | 5 ----- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 4 ---- 2 files changed, 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index a0d252536..e0c905d26 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -351,11 +351,6 @@ class MigrationService @Inject constructor( rnMigrationStore.edit { it[key] = "true" } } - suspend fun isMigrationCompleted(): Boolean { - val key = stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY) - return rnMigrationStore.data.first()[key] == "true" - } - suspend fun isChannelRecoveryChecked(): Boolean { val key = stringPreferencesKey(RN_CHANNEL_RECOVERY_CHECKED_KEY) return rnMigrationStore.data.first()[key] == "true" diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 62186b6d0..a07c63f51 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -308,10 +308,6 @@ class WalletViewModel @Inject constructor( private suspend fun checkForOrphanedChannelMonitorRecovery() { if (migrationService.isChannelRecoveryChecked()) return - if (!migrationService.isMigrationCompleted()) { - migrationService.markChannelRecoveryChecked() - return - } Logger.info("Running one-time channel monitor recovery check", context = TAG) From c89db0bae40dd9203706585f7b311faf3c645902 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 24 Feb 2026 14:58:40 -0300 Subject: [PATCH 5/6] fix: handle the case were RN has monitors but fail to download --- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index a07c63f51..48468ac51 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -317,7 +317,7 @@ class WalletViewModel @Inject constructor( if (channelMigration == null) { Logger.info("No channel monitors found on RN backup", context = TAG) - return@runCatching true + return@runCatching allRetrieved } Logger.info( From 7bd3a79169073a2db841a7280cca1e424d9f29a7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 09:54:47 -0300 Subject: [PATCH 6/6] chore: remove redundant method --- app/src/main/java/to/bitkit/services/MigrationService.kt | 4 +--- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index e0c905d26..3b01c898c 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -361,8 +361,6 @@ class MigrationService @Inject constructor( rnMigrationStore.edit { it[key] = "true" } } - suspend fun fetchChannelRecoveryData(): Boolean = fetchRNRemoteLdkData() - suspend fun hasRNWalletData(): Boolean { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) if (mnemonic?.isNotEmpty() == true) return true @@ -1294,7 +1292,7 @@ class MigrationService @Inject constructor( return null } - private suspend fun fetchRNRemoteLdkData(): Boolean { + suspend fun fetchRNRemoteLdkData(): Boolean { return runCatching { val files = rnBackupClient.listFiles(fileGroup = "ldk") ?: return@runCatching true if (!files.list.any { it.removeSuffix(".bin") == "channel_manager" }) return@runCatching true diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 48468ac51..3d2f8c4e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -312,7 +312,7 @@ class WalletViewModel @Inject constructor( Logger.info("Running one-time channel monitor recovery check", context = TAG) val allMonitorsRetrieved = runCatching { - val allRetrieved = migrationService.fetchChannelRecoveryData() + val allRetrieved = migrationService.fetchRNRemoteLdkData() val channelMigration = buildChannelMigrationIfAvailable() if (channelMigration == null) {