Skip to content

Commit 11ac987

Browse files
authored
Merge pull request #462 from synonymdev/fix/reimport-channel-monitor
fix: recover orphaned channel monitors from RN migration
2 parents 5c6b2ce + f9dc87c commit 11ac987

4 files changed

Lines changed: 114 additions & 3 deletions

File tree

Bitkit/Services/CoreService.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,11 @@ class ActivityService {
10051005
}
10061006
}
10071007

1008+
/// Checks if an on-chain activity exists for a given txid (e.g., a sweep tx has been synced)
1009+
func hasOnchainActivityForTxid(txid: String) async -> Bool {
1010+
await (try? getOnchainActivityByTxId(txid: txid)) != nil
1011+
}
1012+
10081013
/// Checks if an on-chain activity exists for a given channel (e.g., close tx has been synced)
10091014
func hasOnchainActivityForChannel(channelId: String) async -> Bool {
10101015
guard let activities = try? await get(filter: .onchain, limit: 50, sortDirection: .desc) else {

Bitkit/Services/MigrationsService.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ class MigrationsService: ObservableObject {
336336
private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration"
337337
private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds"
338338
private static let rnDidAttemptPeerRecoveryKey = "rnDidAttemptMigrationPeerRecovery"
339+
private static let rnChannelRecoveryCheckedKey = "rnChannelRecoveryChecked"
339340
private static let didCleanupInvalidTransfersKey = "didCleanupInvalidMigrationTransfers"
340341

341342
@Published var isShowingMigrationLoading = false {
@@ -434,6 +435,12 @@ class MigrationsService: ObservableObject {
434435
set { UserDefaults.standard.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) }
435436
}
436437

438+
/// True after we've checked for orphaned channel monitors (so we don't retry every node start if all succeeded).
439+
var isChannelRecoveryChecked: Bool {
440+
get { UserDefaults.standard.bool(forKey: Self.rnChannelRecoveryCheckedKey) }
441+
set { UserDefaults.standard.set(newValue, forKey: Self.rnChannelRecoveryCheckedKey) }
442+
}
443+
437444
/// True if the user completed RN migration (local or remote).
438445
var rnMigrationCompleted: Bool {
439446
UserDefaults.standard.bool(forKey: Self.rnMigrationCompletedKey)
@@ -1998,13 +2005,16 @@ extension MigrationsService {
19982005
return nil
19992006
}
20002007

2001-
private func fetchRNRemoteLdkData() async {
2008+
/// Fetches channel manager and monitors from RN remote backup.
2009+
/// Returns `true` if all monitors were successfully retrieved (or none exist), `false` if some failed.
2010+
@discardableResult
2011+
func fetchRNRemoteLdkData() async -> Bool {
20022012
do {
20032013
let files = try await RNBackupClient.shared.listFiles(fileGroup: "ldk")
20042014

20052015
guard let managerData = try? await RNBackupClient.shared.retrieve(label: "channel_manager", fileGroup: "ldk") else {
20062016
Logger.debug("No channel_manager found in remote LDK backup", context: "Migration")
2007-
return
2017+
return true
20082018
}
20092019

20102020
let expectedCount = files.channel_monitors.count
@@ -2046,8 +2056,11 @@ extension MigrationsService {
20462056
)
20472057
Logger.info("Prepared \(monitors.count)/\(expectedCount) channel monitors for migration", context: "Migration")
20482058
}
2059+
2060+
return failedMonitors.isEmpty
20492061
} catch {
20502062
Logger.error("Failed to fetch remote LDK data: \(error)", context: "Migration")
2063+
return false
20512064
}
20522065
}
20532066

Bitkit/Services/TransferService.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,48 @@ class TransferService {
172172
try await markSettled(id: transfer.id)
173173
Logger.debug("Force close sweep detected, settled transfer: \(transfer.id)", context: "TransferService")
174174
} else {
175-
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
175+
// When LDK batches sweeps from multiple channels into one transaction,
176+
// the onchain activity may only be linked to one channel. Fall back to
177+
// checking if there are no remaining pending sweep balances for this channel.
178+
var sweepSpendingTxid: String?
179+
let hasPendingSweep = balances?.pendingBalancesFromChannelClosures.contains(where: { sweep in
180+
switch sweep {
181+
case let .pendingBroadcast(sweepChannelId, _):
182+
return sweepChannelId == channelId
183+
case let .broadcastAwaitingConfirmation(sweepChannelId, _, latestSpendingTxid, _):
184+
if sweepChannelId == channelId {
185+
sweepSpendingTxid = latestSpendingTxid.description
186+
return true
187+
}
188+
return false
189+
case let .awaitingThresholdConfirmations(sweepChannelId, latestSpendingTxid, _, _, _):
190+
if sweepChannelId == channelId {
191+
sweepSpendingTxid = latestSpendingTxid.description
192+
return true
193+
}
194+
return false
195+
}
196+
}) ?? false
197+
198+
if !hasPendingSweep {
199+
try await markSettled(id: transfer.id)
200+
Logger.debug(
201+
"Force close sweep completed (no pending sweeps), settled transfer: \(transfer.id)",
202+
context: "TransferService"
203+
)
204+
} else if let sweepTxid = sweepSpendingTxid,
205+
await coreService.activity.hasOnchainActivityForTxid(txid: sweepTxid)
206+
{
207+
// The sweep tx was already synced as an onchain activity (linked to another
208+
// channel in the same batched sweep). Safe to settle this transfer.
209+
try await markSettled(id: transfer.id)
210+
Logger.debug(
211+
"Force close batched sweep detected via txid \(sweepTxid), settled transfer: \(transfer.id)",
212+
context: "TransferService"
213+
)
214+
} else {
215+
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
216+
}
176217
}
177218
} else {
178219
// For coop closes and other types, settle immediately when balance is gone

Bitkit/ViewModels/WalletViewModel.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,21 @@ class WalletViewModel: ObservableObject {
136136
MigrationsService.shared.pendingChannelMigration = nil
137137
}
138138

139+
// If no local migration data, try fetching from RN remote backup (one-time)
140+
if channelMigration == nil {
141+
let (remoteMigration, allRetrieved) = await fetchOrphanedChannelMonitorsIfNeeded(walletIndex: walletIndex)
142+
if let remoteMigration {
143+
channelMigration = ChannelDataMigration(
144+
channelManager: [UInt8](remoteMigration.channelManager),
145+
channelMonitors: remoteMigration.channelMonitors.map { [UInt8]($0) }
146+
)
147+
MigrationsService.shared.pendingChannelMigration = nil
148+
}
149+
if allRetrieved {
150+
MigrationsService.shared.isChannelRecoveryChecked = true
151+
}
152+
}
153+
139154
await runLegacyNetworkGraphCleanupIfNeeded()
140155

141156
try await lightningService.setup(
@@ -236,6 +251,43 @@ class WalletViewModel: ObservableObject {
236251
}
237252
}
238253

254+
/// Fetches orphaned channel monitors from RN remote backup before LDK setup.
255+
/// Returns (migration data if found, whether all monitors were successfully retrieved).
256+
private func fetchOrphanedChannelMonitorsIfNeeded(walletIndex: Int) async -> (PendingChannelMigration?, Bool) {
257+
let migrations = MigrationsService.shared
258+
guard !migrations.isChannelRecoveryChecked else { return (nil, true) }
259+
260+
Logger.info("Running pre-startup channel monitor recovery check", context: "WalletViewModel")
261+
262+
do {
263+
guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else {
264+
Logger.debug("Channel recovery: no mnemonic, skipping", context: "WalletViewModel")
265+
migrations.isChannelRecoveryChecked = true
266+
return (nil, true)
267+
}
268+
let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: walletIndex))
269+
270+
RNBackupClient.shared.reset()
271+
try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: passphrase)
272+
273+
let allRetrieved = await migrations.fetchRNRemoteLdkData()
274+
275+
if let migration = migrations.pendingChannelMigration {
276+
Logger.info(
277+
"Found \(migration.channelMonitors.count) monitors on RN backup for pre-startup recovery",
278+
context: "WalletViewModel"
279+
)
280+
return (migration, allRetrieved)
281+
} else {
282+
Logger.info("No channel monitors found on RN backup", context: "WalletViewModel")
283+
return (nil, allRetrieved)
284+
}
285+
} catch {
286+
Logger.error("Pre-startup channel monitor fetch failed: \(error)", context: "WalletViewModel")
287+
return (nil, false)
288+
}
289+
}
290+
239291
private func fetchTrustedPeersFromBlocktank() async -> [LnPeer]? {
240292
switch Self.peerSimulation {
241293
case .apiFailure:

0 commit comments

Comments
 (0)