diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 718c7bd83..13186d23f 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -92,6 +92,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 RN_DID_ATTEMPT_PEER_RECOVERY_KEY = "rnDidAttemptMigrationPeerRecovery" private const val OPENING_CURLY_BRACE = "{" private const val MMKV_ROOT = "persist:root" @@ -351,6 +352,16 @@ class MigrationService @Inject constructor( rnMigrationStore.edit { it[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 hasRNWalletData(): Boolean { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) if (mnemonic?.isNotEmpty() == true) return true @@ -1320,13 +1331,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 + 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 { @@ -1363,9 +1374,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 4e38e5ddd..ba72b96e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -62,6 +62,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 @@ -297,6 +298,7 @@ class WalletViewModel @Inject constructor( if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() } + checkForOrphanedChannelMonitorRecovery() } .onFailure { Logger.error("Node startup error", it, context = TAG) @@ -318,6 +320,50 @@ class WalletViewModel @Inject constructor( } } + private suspend fun checkForOrphanedChannelMonitorRecovery() { + if (migrationService.isChannelRecoveryChecked()) return + + Logger.info("Running one-time channel monitor recovery check", context = TAG) + + val allMonitorsRetrieved = runCatching { + val allRetrieved = migrationService.fetchRNRemoteLdkData() + val channelMigration = buildChannelMigrationIfAvailable() + + if (channelMigration == null) { + Logger.info("No channel monitors found on RN backup", context = TAG) + return@runCatching allRetrieved + } + + 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) + } + + allRetrieved + }.getOrDefault(false) + + if (allMonitorsRetrieved) { + migrationService.markChannelRecoveryChecked() + } else { + Logger.warn("Some monitors failed to download, will retry on next startup", context = TAG) + } + } + fun stop() { if (!walletExists) return diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 817350c9c..01f0af3cc 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(migrationService.tryFetchMigrationPeersFromBackup()).thenReturn(emptyList()) whenever(connectivityRepo.isOnline).thenReturn(isOnline)