@@ -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
0 commit comments