Skip to content
Merged
60 changes: 60 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,29 @@ struct AppScene: View {
.onChange(of: scenePhase, perform: handleScenePhaseChange)
.onChange(of: migrations.isShowingMigrationLoading) { isLoading in
if !isLoading {
SettingsViewModel.shared.updatePinEnabledState()
widgets.loadSavedWidgets()
suggestionsManager.reloadDismissed()
tagManager.reloadLastUsedTags()
if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled {
isPinVerified = false
}
SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets)

if migrations.needsPostMigrationSync {
app.toast(
type: .warning,
title: t("migration__network_required_title"),
description: t("migration__network_required_msg"),
visibilityTime: 8.0
)
}
}
}
.onChange(of: network.isConnected) { isConnected in
// Retry starting wallet when network comes back online
if isConnected {
handleNetworkRestored()
}
}
.environmentObject(app)
Expand Down Expand Up @@ -297,6 +313,18 @@ struct AppScene: View {
}

private func startWallet() async {
// Check network before attempting to start - LDK hangs when VSS is unreachable
guard network.isConnected else {
Logger.warn("Network offline, skipping wallet start", context: "AppScene")
if MigrationsService.shared.isShowingMigrationLoading {
await MainActor.run {
MigrationsService.shared.isShowingMigrationLoading = false
SettingsViewModel.shared.updatePinEnabledState()
}
}
return
}

do {
try await wallet.start()
try await activity.syncLdkNodePayments()
Expand All @@ -309,6 +337,13 @@ struct AppScene: View {
} catch {
Logger.error(error, context: "Failed to start wallet")
Haptics.notify(.error)

if MigrationsService.shared.isShowingMigrationLoading {
await MainActor.run {
MigrationsService.shared.isShowingMigrationLoading = false
SettingsViewModel.shared.updatePinEnabledState()
}
}
}
}

Expand Down Expand Up @@ -467,6 +502,31 @@ struct AppScene: View {
}
}

private func handleNetworkRestored() {
// Refresh currency rates when network is restored - critical for UI
// to display balances (MoneyText returns "0" if rates are nil)
Task {
await currency.refresh()
}

guard wallet.walletExists == true,
scenePhase == .active
else {
return
}

// If node is stopped/failed, restart it
switch wallet.nodeLifecycleState {
case .stopped, .errorStarting:
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
Task {
await startWallet()
}
default:
break
}
}

private func handleQuickAction(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let shortcutType = userInfo["shortcutType"] as? String
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,8 @@
"migration__title" = "Wallet Migration";
"migration__headline" = "MIGRATING\n<accent>WALLET</accent>";
"migration__description" = "Please wait while your old wallet data migrates to this new Bitkit version...";
"migration__network_required_title" = "Network Issues Detected";
"migration__network_required_msg" = "Please ensure you have a stable internet connection. Data may show incorrectly while trying to connect.";
"settings__adv__suggestions_reset" = "Reset Suggestions";
"settings__adv__reset_title" = "Reset Suggestions?";
"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.";
Expand Down
181 changes: 165 additions & 16 deletions Bitkit/Services/MigrationsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ enum RNKeychainKey {

// MARK: - Channel Migration Data

struct PendingChannelMigration {
struct PendingChannelMigration: Codable {
let channelManager: Data
let channelMonitors: [Data]
}
Expand All @@ -320,29 +320,122 @@ class MigrationsService: ObservableObject {

private static let rnMigrationCompletedKey = "rnMigrationCompleted"
private static let rnMigrationCheckedKey = "rnMigrationChecked"
private static let rnNeedsPostMigrationSyncKey = "rnNeedsPostMigrationSync"
private static let rnPendingRemoteActivityDataKey = "rnPendingRemoteActivityData"
private static let rnPendingRemoteTransfersKey = "rnPendingRemoteTransfers"
private static let rnPendingRemoteBoostsKey = "rnPendingRemoteBoosts"
private static let rnPendingMetadataKey = "rnPendingMetadata"
private static let rnPendingRemotePaidOrdersKey = "rnPendingRemotePaidOrders"
private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration"
private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds"

@Published var isShowingMigrationLoading = false {
didSet {
if isShowingMigrationLoading {
startMigrationTimeout()
} else {
cancelMigrationTimeout()
}
}
}

@Published var isShowingMigrationLoading = false
var isRestoringFromRNRemoteBackup = false

var pendingChannelMigration: PendingChannelMigration?
/// Timeout for migration loading screen (120 seconds)
private var migrationTimeoutTask: Task<Void, Never>?
private let migrationTimeoutSeconds: UInt64 = 120

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

/// Stored transfer info from RN wallet backup for marking on-chain txs as transfers
private var pendingRemoteTransfers: [String: String]? // txId -> channelId
migrationTimeoutTask = Task { @MainActor [weak self] in
do {
let timeoutSeconds = self?.migrationTimeoutSeconds ?? 120
try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000)

guard let self, !Task.isCancelled else { return }

/// Stored boost info from RN wallet backup for applying boostTxIds to activities
private var pendingRemoteBoosts: [String: String]? // oldTxId -> newTxId
if isShowingMigrationLoading {
Logger.warn("Migration loading timeout reached (\(timeoutSeconds)s), dismissing screen", context: "Migration")
isShowingMigrationLoading = false
}
} catch {
// Task was cancelled, which is expected when migration completes normally
}
}
}

private func cancelMigrationTimeout() {
migrationTimeoutTask?.cancel()
migrationTimeoutTask = nil
}

/// Tracks whether post-migration sync work is still pending (persists across app restarts)
var needsPostMigrationSync: Bool {
get { UserDefaults.standard.bool(forKey: Self.rnNeedsPostMigrationSyncKey) }
set { UserDefaults.standard.set(newValue, forKey: Self.rnNeedsPostMigrationSyncKey) }
}

/// Stored LDK channel data for migration (persisted)
var pendingChannelMigration: PendingChannelMigration? {
get { getCodable(forKey: Self.rnPendingChannelMigrationKey) }
set { setCodable(newValue, forKey: Self.rnPendingChannelMigrationKey) }
}

/// Stored activity data from RN remote backup for reapplying metadata after sync (persisted)
var pendingRemoteActivityData: [RNActivityItem]? {
get { getCodable(forKey: Self.rnPendingRemoteActivityDataKey) }
set { setCodable(newValue, forKey: Self.rnPendingRemoteActivityDataKey) }
}

/// Stored transfer info from RN wallet backup for marking on-chain txs as transfers (persisted)
var pendingRemoteTransfers: [String: String]? {
get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemoteTransfersKey) as? [String: String] }
set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemoteTransfersKey) }
}

/// Stored boost info from RN wallet backup for applying boostTxIds to activities (persisted)
var pendingRemoteBoosts: [String: String]? {
get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemoteBoostsKey) as? [String: String] }
set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemoteBoostsKey) }
}

/// Stored metadata for reapplying after on-chain activities are synced (persisted)
var pendingMetadata: RNMetadata? {
get { getCodable(forKey: Self.rnPendingMetadataKey) }
set { setCodable(newValue, forKey: Self.rnPendingMetadataKey) }
}

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

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

private init() {}

// MARK: - UserDefaults Helpers

private func getCodable<T: Codable>(forKey key: String) -> T? {
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}

private func setCodable(_ value: (some Codable)?, forKey key: String) {
if let value, let data = try? JSONEncoder().encode(value) {
UserDefaults.standard.set(data, forKey: key)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
}

private var rnNetworkString: String {
switch Env.network {
case .bitcoin:
Expand Down Expand Up @@ -492,8 +585,8 @@ extension MigrationsService {
UserDefaults.standard.set(true, forKey: Self.rnMigrationCompletedKey)
UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey)

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

Logger.info("RN migration completed", context: "Migration")
}
Expand Down Expand Up @@ -633,8 +726,30 @@ extension MigrationsService {
func cleanupAfterMigration() {
cleanupRNKeychain()
cleanupRNFiles()
clearPendingMigrationData()
Logger.info("RN cleanup completed", context: "Migration")
}

/// Returns true if all pending migration data has been processed and cleanup can proceed
var canCleanupAfterMigration: Bool {
// Don't cleanup if there's still pending Blocktank data that needs retry
if pendingBlocktankOrderIds != nil || pendingRemotePaidOrders != nil {
Logger.debug("Cannot cleanup: pending Blocktank data exists", context: "Migration")
return false
}
return true
}

/// Clears all persisted pending migration data from UserDefaults
private func clearPendingMigrationData() {
pendingChannelMigration = nil
pendingRemoteActivityData = nil
pendingRemoteTransfers = nil
pendingRemoteBoosts = nil
pendingMetadata = nil
pendingRemotePaidOrders = nil
pendingBlocktankOrderIds = nil
}
}

// MARK: - MMKV Data Migration
Expand Down Expand Up @@ -1212,6 +1327,12 @@ extension MigrationsService {
}
} catch {
Logger.warn("Failed to fetch and upsert Blocktank orders: \(error)", context: "Migration")
// Store order IDs for retry after sync completes (when online)
pendingBlocktankOrderIds = allOrderIds
if !paidOrders.isEmpty {
pendingRemotePaidOrders = paidOrders
}
Logger.info("Stored \(allOrderIds.count) Blocktank order IDs for retry", context: "Migration")
}
}

Expand Down Expand Up @@ -1334,8 +1455,34 @@ extension MigrationsService {
pendingMetadata = nil
}

// Handle pending Blocktank orders that couldn't be fetched during migration (offline)
var blocktankFetchFailed = false
if let orderIds = pendingBlocktankOrderIds, !orderIds.isEmpty {
Logger.info("Retrying \(orderIds.count) pending Blocktank orders", context: "Migration")
do {
let fetchedOrders = try await CoreService.shared.blocktank.orders(orderIds: orderIds, filter: nil, refresh: true)
if !fetchedOrders.isEmpty {
try await CoreService.shared.blocktank.upsertOrdersList(fetchedOrders)
Logger.info("Upserted \(fetchedOrders.count) Blocktank orders after retry", context: "Migration")

// Also create transfers for paid orders using the fetched orders
if let paidOrders = pendingRemotePaidOrders, !paidOrders.isEmpty {
Logger.info("Creating transfers for \(paidOrders.count) paid orders", context: "Migration")
await createTransfersForPaidOrders(paidOrdersMap: paidOrders, orders: fetchedOrders)
pendingRemotePaidOrders = nil
}
}
pendingBlocktankOrderIds = nil
} catch {
Logger.warn("Still unable to fetch Blocktank orders: \(error)", context: "Migration")
blocktankFetchFailed = true
}
}

// Handle remote backup paid orders (create transfers for pending channel orders)
if let paidOrders = pendingRemotePaidOrders {
// This also handles paid orders from RN remote backup restore
// Skip if Blocktank fetch failed - paid orders depend on Blocktank order data
if !blocktankFetchFailed, let paidOrders = pendingRemotePaidOrders {
Logger.info("Applying \(paidOrders.count) remote paid orders", context: "Migration")
await applyRemotePaidOrders(paidOrders)
pendingRemotePaidOrders = nil
Expand Down Expand Up @@ -1763,6 +1910,8 @@ extension MigrationsService {
Logger.warn("Failed to retrieve bitkit_blocktank_orders from remote backup", context: "Migration")
}

needsPostMigrationSync = true

Logger.info("RN remote backup restore completed", context: "Migration")
}

Expand Down
27 changes: 16 additions & 11 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -844,23 +844,28 @@ extension AppViewModel {
case let .syncCompleted(syncType, syncedBlockHeight):
Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)")

if MigrationsService.shared.isShowingMigrationLoading {
if MigrationsService.shared.needsPostMigrationSync {
Task { @MainActor in
try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? [])
await CoreService.shared.activity.markAllUnseenActivitiesAsSeen()
await MigrationsService.shared.reapplyMetadataAfterSync()
try? await LightningService.shared.restart()

SettingsViewModel.shared.updatePinEnabledState()
if MigrationsService.shared.canCleanupAfterMigration {
if MigrationsService.shared.isShowingMigrationLoading {
try? await LightningService.shared.restart()
}

MigrationsService.shared.isShowingMigrationLoading = false
self.toast(type: .success, title: "Migration Complete", description: "Your wallet has been successfully migrated")
}
} else if MigrationsService.shared.isRestoringFromRNRemoteBackup {
Task {
try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? [])
await MigrationsService.shared.reapplyMetadataAfterSync()
MigrationsService.shared.isRestoringFromRNRemoteBackup = false
SettingsViewModel.shared.updatePinEnabledState()
MigrationsService.shared.cleanupAfterMigration()
MigrationsService.shared.needsPostMigrationSync = false
MigrationsService.shared.isRestoringFromRNRemoteBackup = false
} else {
Logger.info("Post-migration sync incomplete, will retry on next sync", context: "AppViewModel")
}

if MigrationsService.shared.isShowingMigrationLoading {
MigrationsService.shared.isShowingMigrationLoading = false
}
}
}

Expand Down
Loading
Loading