Skip to content

Commit 64e5cee

Browse files
authored
Merge pull request #381 from synonymdev/fix/migration-offline
fix: migration when user has connection issues
2 parents bfdabd1 + 7271d40 commit 64e5cee

File tree

5 files changed

+247
-27
lines changed

5 files changed

+247
-27
lines changed

Bitkit/AppScene.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,29 @@ struct AppScene: View {
8080
.onChange(of: scenePhase, perform: handleScenePhaseChange)
8181
.onChange(of: migrations.isShowingMigrationLoading) { isLoading in
8282
if !isLoading {
83+
SettingsViewModel.shared.updatePinEnabledState()
8384
widgets.loadSavedWidgets()
8485
suggestionsManager.reloadDismissed()
8586
tagManager.reloadLastUsedTags()
8687
if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled {
8788
isPinVerified = false
8889
}
8990
SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets)
91+
92+
if migrations.needsPostMigrationSync {
93+
app.toast(
94+
type: .warning,
95+
title: t("migration__network_required_title"),
96+
description: t("migration__network_required_msg"),
97+
visibilityTime: 8.0
98+
)
99+
}
100+
}
101+
}
102+
.onChange(of: network.isConnected) { isConnected in
103+
// Retry starting wallet when network comes back online
104+
if isConnected {
105+
handleNetworkRestored()
90106
}
91107
}
92108
.environmentObject(app)
@@ -297,6 +313,18 @@ struct AppScene: View {
297313
}
298314

299315
private func startWallet() async {
316+
// Check network before attempting to start - LDK hangs when VSS is unreachable
317+
guard network.isConnected else {
318+
Logger.warn("Network offline, skipping wallet start", context: "AppScene")
319+
if MigrationsService.shared.isShowingMigrationLoading {
320+
await MainActor.run {
321+
MigrationsService.shared.isShowingMigrationLoading = false
322+
SettingsViewModel.shared.updatePinEnabledState()
323+
}
324+
}
325+
return
326+
}
327+
300328
do {
301329
try await wallet.start()
302330
try await activity.syncLdkNodePayments()
@@ -309,6 +337,13 @@ struct AppScene: View {
309337
} catch {
310338
Logger.error(error, context: "Failed to start wallet")
311339
Haptics.notify(.error)
340+
341+
if MigrationsService.shared.isShowingMigrationLoading {
342+
await MainActor.run {
343+
MigrationsService.shared.isShowingMigrationLoading = false
344+
SettingsViewModel.shared.updatePinEnabledState()
345+
}
346+
}
312347
}
313348
}
314349

@@ -467,6 +502,31 @@ struct AppScene: View {
467502
}
468503
}
469504

505+
private func handleNetworkRestored() {
506+
// Refresh currency rates when network is restored - critical for UI
507+
// to display balances (MoneyText returns "0" if rates are nil)
508+
Task {
509+
await currency.refresh()
510+
}
511+
512+
guard wallet.walletExists == true,
513+
scenePhase == .active
514+
else {
515+
return
516+
}
517+
518+
// If node is stopped/failed, restart it
519+
switch wallet.nodeLifecycleState {
520+
case .stopped, .errorStarting:
521+
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
522+
Task {
523+
await startWallet()
524+
}
525+
default:
526+
break
527+
}
528+
}
529+
470530
private func handleQuickAction(_ notification: Notification) {
471531
guard let userInfo = notification.userInfo,
472532
let shortcutType = userInfo["shortcutType"] as? String

Bitkit/Resources/Localization/en.lproj/Localizable.strings

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,8 @@
764764
"migration__title" = "Wallet Migration";
765765
"migration__headline" = "MIGRATING\n<accent>WALLET</accent>";
766766
"migration__description" = "Please wait while your old wallet data migrates to this new Bitkit version...";
767+
"migration__network_required_title" = "Network Issues Detected";
768+
"migration__network_required_msg" = "Please ensure you have a stable internet connection. Data may show incorrectly while trying to connect.";
767769
"settings__adv__suggestions_reset" = "Reset Suggestions";
768770
"settings__adv__reset_title" = "Reset Suggestions?";
769771
"settings__adv__reset_desc" = "Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview.";

Bitkit/Services/MigrationsService.swift

Lines changed: 165 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ enum RNKeychainKey {
306306

307307
// MARK: - Channel Migration Data
308308

309-
struct PendingChannelMigration {
309+
struct PendingChannelMigration: Codable {
310310
let channelManager: Data
311311
let channelMonitors: [Data]
312312
}
@@ -320,29 +320,122 @@ class MigrationsService: ObservableObject {
320320

321321
private static let rnMigrationCompletedKey = "rnMigrationCompleted"
322322
private static let rnMigrationCheckedKey = "rnMigrationChecked"
323+
private static let rnNeedsPostMigrationSyncKey = "rnNeedsPostMigrationSync"
324+
private static let rnPendingRemoteActivityDataKey = "rnPendingRemoteActivityData"
325+
private static let rnPendingRemoteTransfersKey = "rnPendingRemoteTransfers"
326+
private static let rnPendingRemoteBoostsKey = "rnPendingRemoteBoosts"
327+
private static let rnPendingMetadataKey = "rnPendingMetadata"
328+
private static let rnPendingRemotePaidOrdersKey = "rnPendingRemotePaidOrders"
329+
private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration"
330+
private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds"
331+
332+
@Published var isShowingMigrationLoading = false {
333+
didSet {
334+
if isShowingMigrationLoading {
335+
startMigrationTimeout()
336+
} else {
337+
cancelMigrationTimeout()
338+
}
339+
}
340+
}
323341

324-
@Published var isShowingMigrationLoading = false
325342
var isRestoringFromRNRemoteBackup = false
326343

327-
var pendingChannelMigration: PendingChannelMigration?
344+
/// Timeout for migration loading screen (120 seconds)
345+
private var migrationTimeoutTask: Task<Void, Never>?
346+
private let migrationTimeoutSeconds: UInt64 = 120
328347

329-
/// Stored activity data from RN remote backup for reapplying metadata after sync
330-
private var pendingRemoteActivityData: [RNActivityItem]?
348+
private func startMigrationTimeout() {
349+
// Cancel any existing timeout
350+
migrationTimeoutTask?.cancel()
331351

332-
/// Stored transfer info from RN wallet backup for marking on-chain txs as transfers
333-
private var pendingRemoteTransfers: [String: String]? // txId -> channelId
352+
migrationTimeoutTask = Task { @MainActor [weak self] in
353+
do {
354+
let timeoutSeconds = self?.migrationTimeoutSeconds ?? 120
355+
try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000)
356+
357+
guard let self, !Task.isCancelled else { return }
334358

335-
/// Stored boost info from RN wallet backup for applying boostTxIds to activities
336-
private var pendingRemoteBoosts: [String: String]? // oldTxId -> newTxId
359+
if isShowingMigrationLoading {
360+
Logger.warn("Migration loading timeout reached (\(timeoutSeconds)s), dismissing screen", context: "Migration")
361+
isShowingMigrationLoading = false
362+
}
363+
} catch {
364+
// Task was cancelled, which is expected when migration completes normally
365+
}
366+
}
367+
}
368+
369+
private func cancelMigrationTimeout() {
370+
migrationTimeoutTask?.cancel()
371+
migrationTimeoutTask = nil
372+
}
373+
374+
/// Tracks whether post-migration sync work is still pending (persists across app restarts)
375+
var needsPostMigrationSync: Bool {
376+
get { UserDefaults.standard.bool(forKey: Self.rnNeedsPostMigrationSyncKey) }
377+
set { UserDefaults.standard.set(newValue, forKey: Self.rnNeedsPostMigrationSyncKey) }
378+
}
379+
380+
/// Stored LDK channel data for migration (persisted)
381+
var pendingChannelMigration: PendingChannelMigration? {
382+
get { getCodable(forKey: Self.rnPendingChannelMigrationKey) }
383+
set { setCodable(newValue, forKey: Self.rnPendingChannelMigrationKey) }
384+
}
385+
386+
/// Stored activity data from RN remote backup for reapplying metadata after sync (persisted)
387+
var pendingRemoteActivityData: [RNActivityItem]? {
388+
get { getCodable(forKey: Self.rnPendingRemoteActivityDataKey) }
389+
set { setCodable(newValue, forKey: Self.rnPendingRemoteActivityDataKey) }
390+
}
391+
392+
/// Stored transfer info from RN wallet backup for marking on-chain txs as transfers (persisted)
393+
var pendingRemoteTransfers: [String: String]? {
394+
get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemoteTransfersKey) as? [String: String] }
395+
set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemoteTransfersKey) }
396+
}
397+
398+
/// Stored boost info from RN wallet backup for applying boostTxIds to activities (persisted)
399+
var pendingRemoteBoosts: [String: String]? {
400+
get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemoteBoostsKey) as? [String: String] }
401+
set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemoteBoostsKey) }
402+
}
403+
404+
/// Stored metadata for reapplying after on-chain activities are synced (persisted)
405+
var pendingMetadata: RNMetadata? {
406+
get { getCodable(forKey: Self.rnPendingMetadataKey) }
407+
set { setCodable(newValue, forKey: Self.rnPendingMetadataKey) }
408+
}
337409

338-
/// Stored metadata for reapplying after on-chain activities are synced
339-
private var pendingMetadata: RNMetadata?
410+
/// Stored paid orders from RN backup for creating transfers after wallet starts (persisted)
411+
var pendingRemotePaidOrders: [String: String]? {
412+
get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemotePaidOrdersKey) as? [String: String] }
413+
set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemotePaidOrdersKey) }
414+
}
340415

341-
/// Stored paid orders from RN backup for creating transfers after wallet starts
342-
private var pendingRemotePaidOrders: [String: String]? // orderId -> txId
416+
/// Stored Blocktank order IDs that couldn't be fetched during migration (offline) (persisted)
417+
var pendingBlocktankOrderIds: [String]? {
418+
get { UserDefaults.standard.stringArray(forKey: Self.rnPendingBlocktankOrderIdsKey) }
419+
set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingBlocktankOrderIdsKey) }
420+
}
343421

344422
private init() {}
345423

424+
// MARK: - UserDefaults Helpers
425+
426+
private func getCodable<T: Codable>(forKey key: String) -> T? {
427+
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
428+
return try? JSONDecoder().decode(T.self, from: data)
429+
}
430+
431+
private func setCodable(_ value: (some Codable)?, forKey key: String) {
432+
if let value, let data = try? JSONEncoder().encode(value) {
433+
UserDefaults.standard.set(data, forKey: key)
434+
} else {
435+
UserDefaults.standard.removeObject(forKey: key)
436+
}
437+
}
438+
346439
private var rnNetworkString: String {
347440
switch Env.network {
348441
case .bitcoin:
@@ -492,8 +585,8 @@ extension MigrationsService {
492585
UserDefaults.standard.set(true, forKey: Self.rnMigrationCompletedKey)
493586
UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey)
494587

495-
// Clean up RN data after successful migration
496-
cleanupAfterMigration()
588+
// Mark that post-migration sync work is needed (will run when node syncs)
589+
needsPostMigrationSync = true
497590

498591
Logger.info("RN migration completed", context: "Migration")
499592
}
@@ -633,8 +726,30 @@ extension MigrationsService {
633726
func cleanupAfterMigration() {
634727
cleanupRNKeychain()
635728
cleanupRNFiles()
729+
clearPendingMigrationData()
636730
Logger.info("RN cleanup completed", context: "Migration")
637731
}
732+
733+
/// Returns true if all pending migration data has been processed and cleanup can proceed
734+
var canCleanupAfterMigration: Bool {
735+
// Don't cleanup if there's still pending Blocktank data that needs retry
736+
if pendingBlocktankOrderIds != nil || pendingRemotePaidOrders != nil {
737+
Logger.debug("Cannot cleanup: pending Blocktank data exists", context: "Migration")
738+
return false
739+
}
740+
return true
741+
}
742+
743+
/// Clears all persisted pending migration data from UserDefaults
744+
private func clearPendingMigrationData() {
745+
pendingChannelMigration = nil
746+
pendingRemoteActivityData = nil
747+
pendingRemoteTransfers = nil
748+
pendingRemoteBoosts = nil
749+
pendingMetadata = nil
750+
pendingRemotePaidOrders = nil
751+
pendingBlocktankOrderIds = nil
752+
}
638753
}
639754

640755
// MARK: - MMKV Data Migration
@@ -1212,6 +1327,12 @@ extension MigrationsService {
12121327
}
12131328
} catch {
12141329
Logger.warn("Failed to fetch and upsert Blocktank orders: \(error)", context: "Migration")
1330+
// Store order IDs for retry after sync completes (when online)
1331+
pendingBlocktankOrderIds = allOrderIds
1332+
if !paidOrders.isEmpty {
1333+
pendingRemotePaidOrders = paidOrders
1334+
}
1335+
Logger.info("Stored \(allOrderIds.count) Blocktank order IDs for retry", context: "Migration")
12151336
}
12161337
}
12171338

@@ -1334,8 +1455,34 @@ extension MigrationsService {
13341455
pendingMetadata = nil
13351456
}
13361457

1458+
// Handle pending Blocktank orders that couldn't be fetched during migration (offline)
1459+
var blocktankFetchFailed = false
1460+
if let orderIds = pendingBlocktankOrderIds, !orderIds.isEmpty {
1461+
Logger.info("Retrying \(orderIds.count) pending Blocktank orders", context: "Migration")
1462+
do {
1463+
let fetchedOrders = try await CoreService.shared.blocktank.orders(orderIds: orderIds, filter: nil, refresh: true)
1464+
if !fetchedOrders.isEmpty {
1465+
try await CoreService.shared.blocktank.upsertOrdersList(fetchedOrders)
1466+
Logger.info("Upserted \(fetchedOrders.count) Blocktank orders after retry", context: "Migration")
1467+
1468+
// Also create transfers for paid orders using the fetched orders
1469+
if let paidOrders = pendingRemotePaidOrders, !paidOrders.isEmpty {
1470+
Logger.info("Creating transfers for \(paidOrders.count) paid orders", context: "Migration")
1471+
await createTransfersForPaidOrders(paidOrdersMap: paidOrders, orders: fetchedOrders)
1472+
pendingRemotePaidOrders = nil
1473+
}
1474+
}
1475+
pendingBlocktankOrderIds = nil
1476+
} catch {
1477+
Logger.warn("Still unable to fetch Blocktank orders: \(error)", context: "Migration")
1478+
blocktankFetchFailed = true
1479+
}
1480+
}
1481+
13371482
// Handle remote backup paid orders (create transfers for pending channel orders)
1338-
if let paidOrders = pendingRemotePaidOrders {
1483+
// This also handles paid orders from RN remote backup restore
1484+
// Skip if Blocktank fetch failed - paid orders depend on Blocktank order data
1485+
if !blocktankFetchFailed, let paidOrders = pendingRemotePaidOrders {
13391486
Logger.info("Applying \(paidOrders.count) remote paid orders", context: "Migration")
13401487
await applyRemotePaidOrders(paidOrders)
13411488
pendingRemotePaidOrders = nil
@@ -1763,6 +1910,8 @@ extension MigrationsService {
17631910
Logger.warn("Failed to retrieve bitkit_blocktank_orders from remote backup", context: "Migration")
17641911
}
17651912

1913+
needsPostMigrationSync = true
1914+
17661915
Logger.info("RN remote backup restore completed", context: "Migration")
17671916
}
17681917

Bitkit/ViewModels/AppViewModel.swift

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -844,23 +844,28 @@ extension AppViewModel {
844844
case let .syncCompleted(syncType, syncedBlockHeight):
845845
Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)")
846846

847-
if MigrationsService.shared.isShowingMigrationLoading {
847+
if MigrationsService.shared.needsPostMigrationSync {
848848
Task { @MainActor in
849849
try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? [])
850850
await CoreService.shared.activity.markAllUnseenActivitiesAsSeen()
851851
await MigrationsService.shared.reapplyMetadataAfterSync()
852-
try? await LightningService.shared.restart()
853852

854-
SettingsViewModel.shared.updatePinEnabledState()
853+
if MigrationsService.shared.canCleanupAfterMigration {
854+
if MigrationsService.shared.isShowingMigrationLoading {
855+
try? await LightningService.shared.restart()
856+
}
855857

856-
MigrationsService.shared.isShowingMigrationLoading = false
857-
self.toast(type: .success, title: "Migration Complete", description: "Your wallet has been successfully migrated")
858-
}
859-
} else if MigrationsService.shared.isRestoringFromRNRemoteBackup {
860-
Task {
861-
try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? [])
862-
await MigrationsService.shared.reapplyMetadataAfterSync()
863-
MigrationsService.shared.isRestoringFromRNRemoteBackup = false
858+
SettingsViewModel.shared.updatePinEnabledState()
859+
MigrationsService.shared.cleanupAfterMigration()
860+
MigrationsService.shared.needsPostMigrationSync = false
861+
MigrationsService.shared.isRestoringFromRNRemoteBackup = false
862+
} else {
863+
Logger.info("Post-migration sync incomplete, will retry on next sync", context: "AppViewModel")
864+
}
865+
866+
if MigrationsService.shared.isShowingMigrationLoading {
867+
MigrationsService.shared.isShowingMigrationLoading = false
868+
}
864869
}
865870
}
866871

0 commit comments

Comments
 (0)