diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index a437a4d6..43a14305 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1005,6 +1005,11 @@ class ActivityService { } } + /// Checks if an on-chain activity exists for a given txid (e.g., a sweep tx has been synced) + func hasOnchainActivityForTxid(txid: String) async -> Bool { + await (try? getOnchainActivityByTxId(txid: txid)) != nil + } + /// Checks if an on-chain activity exists for a given channel (e.g., close tx has been synced) func hasOnchainActivityForChannel(channelId: String) async -> Bool { guard let activities = try? await get(filter: .onchain, limit: 50, sortDirection: .desc) else { diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 01ceeba1..83201e0d 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -336,6 +336,7 @@ class MigrationsService: ObservableObject { private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration" private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds" private static let rnDidAttemptPeerRecoveryKey = "rnDidAttemptMigrationPeerRecovery" + private static let rnChannelRecoveryCheckedKey = "rnChannelRecoveryChecked" @Published var isShowingMigrationLoading = false { didSet { @@ -433,6 +434,12 @@ class MigrationsService: ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) } } + /// True after we've checked for orphaned channel monitors (so we don't retry every node start if all succeeded). + var isChannelRecoveryChecked: Bool { + get { UserDefaults.standard.bool(forKey: Self.rnChannelRecoveryCheckedKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.rnChannelRecoveryCheckedKey) } + } + /// True if the user completed RN migration (local or remote). var rnMigrationCompleted: Bool { UserDefaults.standard.bool(forKey: Self.rnMigrationCompletedKey) @@ -1956,13 +1963,16 @@ extension MigrationsService { return nil } - private func fetchRNRemoteLdkData() async { + /// Fetches channel manager and monitors from RN remote backup. + /// Returns `true` if all monitors were successfully retrieved (or none exist), `false` if some failed. + @discardableResult + func fetchRNRemoteLdkData() async -> Bool { do { let files = try await RNBackupClient.shared.listFiles(fileGroup: "ldk") guard let managerData = try? await RNBackupClient.shared.retrieve(label: "channel_manager", fileGroup: "ldk") else { Logger.debug("No channel_manager found in remote LDK backup", context: "Migration") - return + return true } let expectedCount = files.channel_monitors.count @@ -2004,8 +2014,11 @@ extension MigrationsService { ) Logger.info("Prepared \(monitors.count)/\(expectedCount) channel monitors for migration", context: "Migration") } + + return failedMonitors.isEmpty } catch { Logger.error("Failed to fetch remote LDK data: \(error)", context: "Migration") + return false } } diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index fdea2e45..0e9c8436 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -162,7 +162,48 @@ class TransferService { try await markSettled(id: transfer.id) Logger.debug("Force close sweep detected, settled transfer: \(transfer.id)", context: "TransferService") } else { - Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService") + // When LDK batches sweeps from multiple channels into one transaction, + // the onchain activity may only be linked to one channel. Fall back to + // checking if there are no remaining pending sweep balances for this channel. + var sweepSpendingTxid: String? + let hasPendingSweep = balances?.pendingBalancesFromChannelClosures.contains(where: { sweep in + switch sweep { + case let .pendingBroadcast(sweepChannelId, _): + return sweepChannelId == channelId + case let .broadcastAwaitingConfirmation(sweepChannelId, _, latestSpendingTxid, _): + if sweepChannelId == channelId { + sweepSpendingTxid = latestSpendingTxid.description + return true + } + return false + case let .awaitingThresholdConfirmations(sweepChannelId, latestSpendingTxid, _, _, _): + if sweepChannelId == channelId { + sweepSpendingTxid = latestSpendingTxid.description + return true + } + return false + } + }) ?? false + + if !hasPendingSweep { + try await markSettled(id: transfer.id) + Logger.debug( + "Force close sweep completed (no pending sweeps), settled transfer: \(transfer.id)", + context: "TransferService" + ) + } else if let sweepTxid = sweepSpendingTxid, + await coreService.activity.hasOnchainActivityForTxid(txid: sweepTxid) + { + // The sweep tx was already synced as an onchain activity (linked to another + // channel in the same batched sweep). Safe to settle this transfer. + try await markSettled(id: transfer.id) + Logger.debug( + "Force close batched sweep detected via txid \(sweepTxid), settled transfer: \(transfer.id)", + context: "TransferService" + ) + } else { + Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService") + } } } else { // For coop closes and other types, settle immediately when balance is gone diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 95c9033f..297c9ba1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -59,6 +59,8 @@ class WalletViewModel: ObservableObject { static var peerSimulation: BlocktankPeerSimulation = .none + private static let channelRecoveryRestartDelayMs: UInt64 = 500 + @Published var isRestoringWallet = false @Published var balanceInTransferToSavings: Int = 0 @Published var balanceInTransferToSpending: Int = 0 @@ -234,6 +236,78 @@ class WalletViewModel: ObservableObject { Task { @MainActor in try await sync() } + + // One-time check for orphaned channel monitors from RN migration + Task { + await checkForOrphanedChannelMonitorRecovery() + } + } + + private func checkForOrphanedChannelMonitorRecovery() async { + let migrations = MigrationsService.shared + guard !migrations.isChannelRecoveryChecked else { return } + + Logger.info("Running one-time channel monitor recovery check", context: "WalletViewModel") + + var allMonitorsRetrieved = false + do { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { + Logger.debug("Channel recovery: no mnemonic, skipping", context: "WalletViewModel") + migrations.isChannelRecoveryChecked = true + return + } + let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: 0)) + + RNBackupClient.shared.reset() + try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: passphrase) + + let retrieved = await migrations.fetchRNRemoteLdkData() + + if let migration = migrations.pendingChannelMigration { + let monitorCount = migration.channelMonitors.count + Logger.info( + "Found \(monitorCount) monitors on RN backup, attempting recovery", + context: "WalletViewModel" + ) + + let channelMigration = ChannelDataMigration( + channelManager: [UInt8](migration.channelManager), + channelMonitors: migration.channelMonitors.map { [UInt8]($0) } + ) + migrations.pendingChannelMigration = nil + + // Stop and restart the lightning service directly (not via self.start()) + // to avoid the nodeLifecycleState guard racing with concurrent sync Tasks + try await lightningService.stop() + try await Task.sleep(nanoseconds: Self.channelRecoveryRestartDelayMs * 1_000_000) + + let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl + let rgsServerUrl = rgsConfigService.getCurrentServerUrl() + try await lightningService.setup( + walletIndex: 0, + electrumServerUrl: electrumServerUrl, + rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl, + channelMigration: channelMigration + ) + try await lightningService.start() + + nodeLifecycleState = .running + syncState() + Logger.info("Channel monitor recovery complete", context: "WalletViewModel") + } else { + Logger.info("No channel monitors found on RN backup", context: "WalletViewModel") + } + + allMonitorsRetrieved = retrieved + } catch { + Logger.error("Channel monitor recovery check failed: \(error)", context: "WalletViewModel") + } + + if allMonitorsRetrieved { + migrations.isChannelRecoveryChecked = true + } else { + Logger.warn("Some monitors failed to download, will retry on next startup", context: "WalletViewModel") + } } private func fetchTrustedPeersFromBlocktank() async -> [LnPeer]? { @@ -251,7 +325,6 @@ class WalletViewModel: ObservableObject { break } - var info: IBtInfo? do { info = try await coreService.blocktank.info(refresh: true)