From cc57da02899c1d0c03e4e86ed8c98fa57f20664d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 10:12:40 -0300 Subject: [PATCH 1/7] chore: add recovery key and property to orphaned channel check --- Bitkit/Services/MigrationsService.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 01ceeba1..5f545ce0 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) From 0ef0e8afe6f37ffa0c48c1b2ad5973a40efa2067 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 10:14:07 -0300 Subject: [PATCH 2/7] chore: make fetchRNRemoteLdkData public and return boolean for success --- Bitkit/Services/MigrationsService.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 5f545ce0..83201e0d 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1963,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 @@ -2011,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 } } From d90d7d2e568abf8d967d471e7ec89a3a280a7285 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 10:20:20 -0300 Subject: [PATCH 3/7] fix: implement the recover flow for orphaned channels --- Bitkit/ViewModels/WalletViewModel.swift | 55 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 95c9033f..11c1f367 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,58 @@ class WalletViewModel: ObservableObject { Task { @MainActor in try await sync() } + + // One-time check for orphaned channel monitors from RN migration + Task { @MainActor in + 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 { + Logger.info( + "Found \(migration.channelMonitors.count) monitors on RN backup, attempting recovery", + context: "WalletViewModel" + ) + + try await stopLightningNode() + try await Task.sleep(nanoseconds: Self.channelRecoveryRestartDelayMs * 1_000_000) + try await start() + + 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 +305,6 @@ class WalletViewModel: ObservableObject { break } - var info: IBtInfo? do { info = try await coreService.blocktank.info(refresh: true) From 86924278ca5d5334980b7c3c0911da02422c43e8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 10:47:00 -0300 Subject: [PATCH 4/7] chore: remove redundant MainActor annotaion --- Bitkit/ViewModels/WalletViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 11c1f367..c31091f6 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -238,7 +238,7 @@ class WalletViewModel: ObservableObject { } // One-time check for orphaned channel monitors from RN migration - Task { @MainActor in + Task { await checkForOrphanedChannelMonitorRecovery() } } From bb7a28a5ae07f6dbcc40fff4f0476b9650bfb281 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 11:46:31 -0300 Subject: [PATCH 5/7] fix: call lightningService.stop() directly to bypass the lifecycle guard that was racing with concurrent syncNodeStatus calls --- Bitkit/ViewModels/WalletViewModel.swift | 26 ++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index c31091f6..297c9ba1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -264,15 +264,35 @@ class WalletViewModel: ObservableObject { let retrieved = await migrations.fetchRNRemoteLdkData() if let migration = migrations.pendingChannelMigration { + let monitorCount = migration.channelMonitors.count Logger.info( - "Found \(migration.channelMonitors.count) monitors on RN backup, attempting recovery", + "Found \(monitorCount) monitors on RN backup, attempting recovery", context: "WalletViewModel" ) - try await stopLightningNode() + 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) - try await start() + 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") From f69b7ba051e5beb7bbe33f7f36d1bf36c4a99048 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 12:01:52 -0300 Subject: [PATCH 6/7] fix: handle multiple force close swep --- Bitkit/Services/TransferService.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index fdea2e45..25bd9ad5 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -162,7 +162,27 @@ 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. + let hasPendingSweep = balances?.pendingBalancesFromChannelClosures.contains(where: { sweep in + switch sweep { + case let .pendingBroadcast(sweepChannelId, _), + let .broadcastAwaitingConfirmation(sweepChannelId, _, _, _), + let .awaitingThresholdConfirmations(sweepChannelId, _, _, _, _): + return sweepChannelId == channelId + } + }) ?? 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 { + 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 From 3240b56eb50c225260e7fe0778976180c4b3ee11 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 13:42:57 -0300 Subject: [PATCH 7/7] fix: check if the sweep already exists as confirmed onchain linked to another channel in the same batch --- Bitkit/Services/CoreService.swift | 5 +++++ Bitkit/Services/TransferService.swift | 27 ++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) 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/TransferService.swift b/Bitkit/Services/TransferService.swift index 25bd9ad5..0e9c8436 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -165,12 +165,23 @@ class 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, _), - let .broadcastAwaitingConfirmation(sweepChannelId, _, _, _), - let .awaitingThresholdConfirmations(sweepChannelId, _, _, _, _): + 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 @@ -180,6 +191,16 @@ class TransferService { "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") }