diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e6b82941..5a953ed8 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -25,7 +25,7 @@ "location" : "https://github.com/synonymdev/ldk-node", "state" : { "branch" : "main", - "revision" : "47bfd947e5dee1be7117179c2693d6c8bd1020bb" + "revision" : "612ad053cf086bb6d0d09720c0039102434177f8" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index add6f520..e67c152b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -23,6 +23,7 @@ struct AppScene: View { @StateObject private var tagManager = TagManager() @StateObject private var transferTracking: TransferTrackingManager @StateObject private var channelDetails = ChannelDetailsViewModel.shared + @StateObject private var migrations = MigrationsService.shared @State private var hideSplash = false @State private var removeSplash = false @@ -72,6 +73,9 @@ struct AppScene: View { .onChange(of: wallet.walletExists, perform: handleWalletExistsChange) .onChange(of: wallet.nodeLifecycleState, perform: handleNodeLifecycleChange) .onChange(of: scenePhase, perform: handleScenePhaseChange) + .onChange(of: migrations.isShowingMigrationLoading) { isLoading in + if !isLoading { widgets.loadSavedWidgets() } + } .environmentObject(app) .environmentObject(navigation) .environmentObject(network) @@ -111,7 +115,9 @@ struct AppScene: View { @ViewBuilder private var mainContent: some View { ZStack { - if showRecoveryScreen { + if migrations.isShowingMigrationLoading { + migrationLoadingContent + } else if showRecoveryScreen { RecoveryRouter() .accentColor(.white) } else if hasCriticalUpdate { @@ -127,6 +133,32 @@ struct AppScene: View { } } + @ViewBuilder + private var migrationLoadingContent: some View { + VStack(spacing: 24) { + Spacer() + + ProgressView() + .scaleEffect(1.5) + .tint(.white) + + VStack(spacing: 8) { + Text("Updating Wallet") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + + Text("Please wait while we update the app...") + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } + @ViewBuilder private var walletContent: some View { if wallet.walletExists == true { @@ -247,6 +279,7 @@ struct AppScene: View { @Sendable private func setupTask() async { do { + await checkAndPerformRNMigration() try wallet.setWalletExistsState() // Setup TimedSheetManager with all timed sheets @@ -262,6 +295,43 @@ struct AppScene: View { } } + private func checkAndPerformRNMigration() async { + let migrations = MigrationsService.shared + + guard !migrations.isMigrationChecked else { + Logger.debug("RN migration already checked, skipping", context: "AppScene") + return + } + + guard !migrations.hasNativeWalletData() else { + Logger.info("Native wallet data exists, skipping RN migration", context: "AppScene") + migrations.markMigrationChecked() + return + } + + guard migrations.hasRNWalletData() else { + Logger.info("No RN wallet data found, skipping migration", context: "AppScene") + migrations.markMigrationChecked() + return + } + + await MainActor.run { migrations.isShowingMigrationLoading = true } + Logger.info("RN wallet data found, starting migration...", context: "AppScene") + + do { + try await migrations.migrateFromReactNative() + } catch { + Logger.error("RN migration failed: \(error)", context: "AppScene") + migrations.markMigrationChecked() + await MainActor.run { migrations.isShowingMigrationLoading = false } + app.toast( + type: .error, + title: "Migration Failed", + description: "Please restore your wallet manually using your recovery phrase" + ) + } + } + private func handleNodeLifecycleChange(_ state: NodeLifecycleState) { if state == .initializing { walletIsInitializing = true diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index ce9c627f..24e701fb 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -154,6 +154,42 @@ class ActivityService { } } + func markAllUnseenActivitiesAsSeen() async { + let timestamp = UInt64(Date().timeIntervalSince1970) + + do { + let activities = try await get() + var didMarkAny = false + + for activity in activities { + let id: String + let isSeen: Bool + + switch activity { + case let .onchain(onchain): + id = onchain.id + isSeen = onchain.seenAt != nil + case let .lightning(lightning): + id = lightning.id + isSeen = lightning.seenAt != nil + } + + if !isSeen { + try await ServiceQueue.background(.core) { + try BitkitCore.markActivityAsSeen(activityId: id, seenAt: timestamp) + } + didMarkAny = true + } + } + + if didMarkAny { + activitiesChangedSubject.send() + } + } catch { + Logger.error("Failed to mark all activities as seen: \(error)", context: "ActivityService") + } + } + // MARK: - Transaction Status Checks func wasTransactionReplaced(txid: String) async -> Bool { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index bf9fdd93..99279c94 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -22,7 +22,12 @@ class LightningService { private init() {} - func setup(walletIndex: Int, electrumServerUrl: String? = nil, rgsServerUrl: String? = nil) async throws { + func setup( + walletIndex: Int, + electrumServerUrl: String? = nil, + rgsServerUrl: String? = nil, + channelMigration: ChannelDataMigration? = nil + ) async throws { Logger.debug("Checking lightning process lock...") try StateLocker.lock(.lightning, wait: 30) // Wait 30 seconds to lock because maybe extension is still running @@ -80,7 +85,11 @@ class LightningService { Logger.debug("Building ldk-node with vssUrl: '\(vssUrl)'") Logger.debug("Building ldk-node with lnurlAuthServerUrl: '\(lnurlAuthServerUrl)'") - // Set entropy from mnemonic on builder + if let channelMigration { + builder.setChannelDataMigration(migration: channelMigration) + Logger.info("Applied channel migration: \(channelMigration.channelMonitors.count) monitors", context: "Migration") + } + builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase) try await ServiceQueue.background(.ldk) { diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 1030e5da..23cf2646 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1,101 +1,1110 @@ +import BitkitCore import Foundation -import LightningDevKit // TODO: remove this when we no longer need it to read funding_tx and index from monitors -import SQLite +import Security -typealias Expression = SQLite.Expression +// MARK: - MMKV Parser -class MigrationsService { +/// Lightweight parser for MMKV binary format (react-native-mmkv) +/// MMKV stores data as: [4-byte size (little-endian)][4-byte marker][key-value pairs...] +/// Each pair: [varint key_length][key_bytes][varint value_length][value_bytes] +struct MMKVParser { + private let data: Data + + init(data: Data) { + self.data = data + } + + func parse() -> [String: String] { + guard data.count > 8 else { return [:] } + + let contentSize = Int(data[0]) | + Int(data[1]) << 8 | + Int(data[2]) << 16 | + Int(data[3]) << 24 + let endOffset = min(8 + contentSize, data.count) + + var result: [String: String] = [:] + var offset = 8 + + while offset < endOffset { + guard let (keyLength, keyLengthBytes) = readVarint(at: offset, endOffset: endOffset) else { break } + offset += keyLengthBytes + + guard offset + keyLength <= endOffset else { break } + let keyData = data.subdata(in: offset ..< offset + keyLength) + guard let key = String(data: keyData, encoding: .utf8) else { break } + offset += keyLength + + guard let (valueLength, valueLengthBytes) = readVarint(at: offset, endOffset: endOffset) else { break } + offset += valueLengthBytes + + guard offset + valueLength <= endOffset else { break } + let valueData = data.subdata(in: offset ..< offset + valueLength) + + if let value = String(data: valueData, encoding: .utf8) { + result[key] = value + } else if let value = String(data: valueData, encoding: .isoLatin1) { + result[key] = value + } + offset += valueLength + } + + return result + } + + private func readVarint(at offset: Int, endOffset: Int) -> (Int, Int)? { + var result = 0 + var shift = 0 + var bytesRead = 0 + var currentOffset = offset + + while currentOffset < endOffset { + let byte = data[currentOffset] + result |= Int(byte & 0x7F) << shift + + bytesRead += 1 + currentOffset += 1 + + if byte & 0x80 == 0 { + return (result, bytesRead) + } + + shift += 7 + if shift >= 64 { return nil } + } + + return nil + } +} + +// MARK: - RN Redux State Types + +struct RNSettings: Codable { + var enableAutoReadClipboard: Bool? + var enableSendAmountWarning: Bool? + var enableSwipeToHideBalance: Bool? + var pin: Bool? + var pinOnLaunch: Bool? + var pinOnIdle: Bool? + var pinForPayments: Bool? + var biometrics: Bool? + var rbf: Bool? + var theme: String? + var unit: String? + var denomination: String? + var selectedCurrency: String? + var selectedLanguage: String? + var coinSelectAuto: Bool? + var coinSelectPreference: String? + var enableDevOptions: Bool? + var enableOfflinePayments: Bool? + var enableQuickpay: Bool? + var quickpayAmount: Int? + var showWidgets: Bool? + var showWidgetTitles: Bool? + var transactionSpeed: String? + var customFeeRate: Int? + var hideBalance: Bool? + var hideBalanceOnOpen: Bool? + var quickpayIntroSeen: Bool? + var shopIntroSeen: Bool? + var transferIntroSeen: Bool? + var spendingIntroSeen: Bool? + var savingsIntroSeen: Bool? +} + +struct RNMetadata: Codable { + var tags: [String: [String]]? + var lastUsedTags: [String]? + var comments: [String: String]? +} + +struct RNActivityState: Codable { + var items: [RNActivityItem]? +} + +struct RNActivityItem: Codable { + var id: String + var activityType: String + var txType: String + var txId: String? + var value: Int64 + var fee: Int64? + var feeRate: Int64? + var address: String? + var confirmed: Bool? + var timestamp: Int64 + var isBoosted: Bool? + var isTransfer: Bool? + var exists: Bool? + var confirmTimestamp: Int64? + var channelId: String? + var transferTxId: String? + var status: String? + var message: String? + var preimage: String? +} + +struct RNLightningState: Codable { + var nodes: [String: RNLightningNode]? +} + +struct RNLightningNode: Codable { + var channels: [String: [String: RNChannel]]? +} + +struct RNChannel: Codable { + var channel_id: String + var status: String? + var createdAt: Int64? + var counterparty_node_id: String? + var funding_txid: String? + var channel_value_satoshis: UInt64? + var balance_sat: UInt64? + var claimable_balances: [RNClaimableBalance]? + var outbound_capacity_sat: UInt64? + var inbound_capacity_sat: UInt64? + var is_usable: Bool? + var is_channel_ready: Bool? + var confirmations: UInt32? + var confirmations_required: UInt32? + var short_channel_id: String? + var closureReason: String? + var unspendable_punishment_reserve: UInt64? + var counterparty_unspendable_punishment_reserve: UInt64? +} + +struct RNClaimableBalance: Codable { + var amount_satoshis: UInt64? + var type: String? +} + +struct RNWidgets: Codable { + var onboardedWidgets: Bool? + var sortOrder: [String]? +} + +struct RNWidgetsWithOptions { + var widgets: RNWidgets + var widgetOptions: [String: Data] // widget name -> JSON options data +} + +// MARK: - Widget Types for Migration + +enum MigrationWidgetType: String, Codable { + case price + case news + case blocks + case facts + case calculator + case weather +} + +struct MigrationSavedWidget: Codable { + let type: MigrationWidgetType + let optionsData: Data? + + init(type: MigrationWidgetType, optionsData: Data? = nil) { + self.type = type + self.optionsData = optionsData + } +} + +private enum MigrationGraphPeriod: String, Codable { + case oneDay = "1D" + case oneWeek = "1W" + case oneMonth = "1M" + case oneYear = "1Y" +} + +private struct MigrationPriceWidgetOptions: Codable { + var selectedPairs: [String] + var selectedPeriod: MigrationGraphPeriod + var showSource: Bool +} + +private struct MigrationWeatherWidgetOptions: Codable { + var showStatus: Bool + var showText: Bool + var showMedian: Bool + var showNextBlockFee: Bool +} + +private struct MigrationNewsWidgetOptions: Codable { + var showDate: Bool + var showTitle: Bool + var showSource: Bool +} + +private struct MigrationBlocksWidgetOptions: Codable { + var height: Bool + var time: Bool + var date: Bool + var transactionCount: Bool + var size: Bool + var weight: Bool + var difficulty: Bool + var hash: Bool + var merkleRoot: Bool + var showSource: Bool +} + +private struct MigrationFactsWidgetOptions: Codable { + var showSource: Bool +} + +// MARK: - RN Migration Keys + +enum RNKeychainKey { + case mnemonic(walletName: String) + case passphrase(walletName: String) + case pin + + var service: String { + switch self { + case let .mnemonic(walletName): + return walletName + case let .passphrase(walletName): + return "\(walletName)passphrase" + case .pin: + return "pin" + } + } +} + +// MARK: - Channel Migration Data + +struct PendingChannelMigration { + let channelManager: Data + let channelMonitors: [Data] +} + +// MARK: - MigrationsService + +class MigrationsService: ObservableObject { static var shared = MigrationsService() + private let fileManager = FileManager.default + + private static let rnMigrationCompletedKey = "rnMigrationCompleted" + private static let rnMigrationCheckedKey = "rnMigrationChecked" + + @Published var isShowingMigrationLoading = false + + var pendingChannelMigration: PendingChannelMigration? + private init() {} + + private var rnNetworkString: String { + switch Env.network { + case .bitcoin: + return "bitcoin" + case .regtest: + return "bitcoinRegtest" + case .testnet: + return "bitcoinTestnet" + case .signet: + return "signet" + } + } + + private let rnWalletName = "wallet0" } -// MARK: Migrations for RN Bitkit to Swift Bitkit +// MARK: - RN Keychain Access extension MigrationsService { - func ldkToLdkNode(walletIndex: Int, seed: Data, manager: Data, monitors: [Data]) throws { - Logger.info("Migrating LDK to LDKNode") - let ldkStorage = Env.ldkStorage(walletIndex: walletIndex) - let sqlFilePath = ldkStorage.appendingPathComponent("ldk_node_data.sqlite").path + func loadFromRNKeychain(key: RNKeychainKey) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: key.service, + kSecAttrAccount as String: key.service, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var dataTypeRef: AnyObject? + var status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + // RN keychain library may store items without kSecAttrAccount in some versions + if status == errSecItemNotFound { + let queryWithoutAccount: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: key.service, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + status = SecItemCopyMatching(queryWithoutAccount as CFDictionary, &dataTypeRef) + } + + if status == errSecItemNotFound { + Logger.debug("RN keychain key '\(key.service)' not found", context: "Migration") + return nil + } - // Create path if doesn't exist - let fileManager = FileManager.default - var isDir: ObjCBool = true - if !fileManager.fileExists(atPath: ldkStorage.path, isDirectory: &isDir) { - try fileManager.createDirectory(atPath: ldkStorage.path, withIntermediateDirectories: true, attributes: nil) - Logger.debug("Directory created at path: \(ldkStorage.path)") + if status != noErr { + Logger.error("Failed to load RN keychain key '\(key.service)': \(status)", context: "Migration") + throw KeychainError.failedToLoad } - Logger.debug(sqlFilePath, context: "SQLIte file path") + Logger.debug("RN keychain key '\(key.service)' loaded successfully", context: "Migration") + return dataTypeRef as? Data + } - // Can't migrate if data currently exists - guard !fileManager.fileExists(atPath: sqlFilePath) else { - throw AppError(serviceError: .ldkNodeSqliteAlreadyExists) + func loadStringFromRNKeychain(key: RNKeychainKey) throws -> String? { + guard let data = try loadFromRNKeychain(key: key) else { + return nil } + return String(data: data, encoding: .utf8) + } +} - let db = try Connection(sqlFilePath) +// MARK: - RN Migration Detection & Execution + +extension MigrationsService { + var isMigrationChecked: Bool { + UserDefaults.standard.bool(forKey: Self.rnMigrationCheckedKey) + } - let table = Table("ldk_node_data") + func hasRNWalletData() -> Bool { + do { + let mnemonic = try loadStringFromRNKeychain(key: .mnemonic(walletName: rnWalletName)) + return mnemonic?.isEmpty == false + } catch { + Logger.error("Error checking for RN wallet data: \(error)", context: "Migration") + return false + } + } - let pnCol = Expression("primary_namespace") - let snCol = Expression("secondary_namespace") - let keyCol = Expression("key") - let valueCol = Expression("value") + func hasNativeWalletData() -> Bool { + do { + return try Keychain.exists(key: .bip39Mnemonic(index: 0)) + } catch { + return false + } + } - try db.run( - table.create { t in - t.column(pnCol, primaryKey: true) - t.column(snCol) - t.column(keyCol) - t.column(valueCol) + private var rnLdkBasePath: URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0].appendingPathComponent("ldk") + } + + private var rnLdkAccountPath: URL { + let accountName = "\(rnWalletName)\(rnNetworkString)ldkaccountv3" + return rnLdkBasePath.appendingPathComponent(accountName) + } + + func hasRNLdkData() -> Bool { + let channelManagerPath = rnLdkAccountPath.appendingPathComponent("channel_manager.bin") + let exists = fileManager.fileExists(atPath: channelManagerPath.path) + Logger.debug("RN LDK path: \(rnLdkAccountPath.path), channel_manager exists: \(exists)", context: "Migration") + return exists + } + + func migrateFromReactNative(walletIndex: Int = 0) async throws { + Logger.info("Starting RN migration", context: "Migration") + + try migrateMnemonic(walletIndex: walletIndex) + try migratePassphrase(walletIndex: walletIndex) + try migratePin() + + if hasRNLdkData() { + try await migrateLdkData() + } + + if hasRNMmkvData() { + Logger.info("Found MMKV data, starting migration", context: "Migration") + await migrateMMKVData() + } else { + Logger.warn("No MMKV data found, skipping settings/activities migration", context: "Migration") + } + + UserDefaults.standard.set(true, forKey: Self.rnMigrationCompletedKey) + UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey) + Logger.info("RN migration completed", context: "Migration") + } + + private func migrateMnemonic(walletIndex: Int) throws { + guard let mnemonic = try loadStringFromRNKeychain(key: .mnemonic(walletName: rnWalletName)) else { + throw AppError(message: "No RN mnemonic found", debugMessage: nil) + } + + let words = mnemonic.split(separator: " ") + guard words.count == 12 || words.count == 24 else { + throw AppError(message: "Invalid mnemonic: \(words.count) words", debugMessage: nil) + } + + try Keychain.saveString(key: .bip39Mnemonic(index: walletIndex), str: mnemonic) + } + + private func migratePassphrase(walletIndex: Int) throws { + guard let passphrase = try loadStringFromRNKeychain(key: .passphrase(walletName: rnWalletName)), + !passphrase.isEmpty + else { + return + } + try Keychain.saveString(key: .bip39Passphrase(index: walletIndex), str: passphrase) + } + + private func migratePin() throws { + guard let pin = try loadStringFromRNKeychain(key: .pin), + !pin.isEmpty + else { + return + } + + try Keychain.saveString(key: .securityPin, str: pin) + } + + private func migrateLdkData() async throws { + let accountPath = rnLdkAccountPath + let managerPath = accountPath.appendingPathComponent("channel_manager.bin") + + guard fileManager.fileExists(atPath: managerPath.path) else { + return + } + + let managerData = try Data(contentsOf: managerPath) + var monitors: [Data] = [] + + let channelsPath = accountPath.appendingPathComponent("channels") + let monitorsPath = accountPath.appendingPathComponent("monitors") + let monitorDir = fileManager.fileExists(atPath: channelsPath.path) ? channelsPath : monitorsPath + + if fileManager.fileExists(atPath: monitorDir.path) { + let monitorFiles = try fileManager.contentsOfDirectory(atPath: monitorDir.path) + for file in monitorFiles where file.hasSuffix(".bin") { + let monitorData = try Data(contentsOf: monitorDir.appendingPathComponent(file)) + monitors.append(monitorData) } - ) + } - // TODO: use create statement directly from LDK-node instead - // CREATE TABLE IF NOT EXISTS {} ( - // primary_namespace TEXT NOT NULL, - // secondary_namespace TEXT DEFAULT \"\" NOT NULL, - // key TEXT NOT NULL CHECK (key <> ''), - // value BLOB, PRIMARY KEY ( primary_namespace, secondary_namespace, key ) - // ); - - let insert = table.insert(pnCol <- "", snCol <- "", keyCol <- "manager", valueCol <- manager) - let rowid = try db.run(insert) - Logger.debug(rowid, context: "Inserted manager") - - let seconds = UInt64(NSDate().timeIntervalSince1970) - let nanoSeconds = UInt32(truncating: NSNumber(value: seconds * 1000 * 1000)) - let keysManager = KeysManager( - seed: [UInt8](seed), - startingTimeSecs: seconds, - startingTimeNanos: nanoSeconds + pendingChannelMigration = PendingChannelMigration( + channelManager: managerData, + channelMonitors: monitors ) + Logger.info("Prepared \(monitors.count) channel monitors for migration", context: "Migration") + } + + func markMigrationChecked() { + UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey) + } +} - for monitor in monitors { - // MARK: get funding_tx and index using plain LDK +// MARK: - MMKV Data Migration - // https://github.com/lightning/bolts/blob/master/02-peer-protocol.md#definition-of-channel_id - guard let channelMonitor = Bindings.readThirtyTwoBytesChannelMonitor( - ser: [UInt8](monitor), argA: keysManager.asEntropySource(), argB: keysManager.asSignerProvider() - ).getValue()?.1 +extension MigrationsService { + private var rnMmkvPath: URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0].appendingPathComponent("mmkv/mmkv.default") + } + + func hasRNMmkvData() -> Bool { + fileManager.fileExists(atPath: rnMmkvPath.path) + } + + func loadRNMmkvData() -> [String: String]? { + guard hasRNMmkvData() else { + Logger.debug("No MMKV data found at \(rnMmkvPath.path)", context: "Migration") + return nil + } + + do { + let data = try Data(contentsOf: rnMmkvPath) + let parser = MMKVParser(data: data) + let parsed = parser.parse() + Logger.debug("Parsed \(parsed.count) keys from MMKV", context: "Migration") + return parsed.isEmpty ? nil : parsed + } catch { + Logger.error("Failed to read MMKV data: \(error)", context: "Migration") + return nil + } + } + + func extractRNSettings(from mmkvData: [String: String]) -> RNSettings? { + guard let rootJson = mmkvData["persist:root"] else { + Logger.debug("persist:root not found in MMKV. Available keys: \(Array(mmkvData.keys))", context: "Migration") + return nil + } + + var jsonString = rootJson + if let jsonStart = rootJson.firstIndex(of: "{") { + jsonString = String(rootJson[jsonStart...]) + } + + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + Logger.debug("Failed to parse persist:root as JSON", context: "Migration") + return nil + } + + guard let settingsJson = root["settings"] as? String, + let settingsData = settingsJson.data(using: .utf8) + else { + Logger.debug("Failed to extract settings from persist:root", context: "Migration") + return nil + } + + do { + let settings = try JSONDecoder().decode(RNSettings.self, from: settingsData) + Logger.debug( + "Extracted RN settings: currency=\(settings.selectedCurrency ?? "nil"), language=\(settings.selectedLanguage ?? "nil")", + context: "Migration" + ) + return settings + } catch { + Logger.error("Failed to decode RN settings: \(error)", context: "Migration") + return nil + } + } + + func extractRNMetadata(from mmkvData: [String: String]) -> RNMetadata? { + guard let rootJson = mmkvData["persist:root"], + let jsonStart = rootJson.firstIndex(of: "{") + else { return nil } + + let jsonString = String(rootJson[jsonStart...]) + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let metadataJson = root["metadata"] as? String, + let metadataData = metadataJson.data(using: .utf8) + else { + return nil + } + + do { + let metadata = try JSONDecoder().decode(RNMetadata.self, from: metadataData) + let tagCount = metadata.tags?.count ?? 0 + let commentCount = metadata.comments?.count ?? 0 + Logger.debug("Extracted RN metadata: \(tagCount) tagged txs, \(commentCount) comments", context: "Migration") + return metadata + } catch { + Logger.error("Failed to decode RN metadata: \(error)", context: "Migration") + return nil + } + } + + func extractRNWidgets(from mmkvData: [String: String]) -> RNWidgetsWithOptions? { + guard let rootJson = mmkvData["persist:root"], + let jsonStart = rootJson.firstIndex(of: "{") + else { return nil } + + let jsonString = String(rootJson[jsonStart...]) + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let widgetsJson = root["widgets"] as? String, + let widgetsData = widgetsJson.data(using: .utf8) + else { + return nil + } + + do { + let widgets = try JSONDecoder().decode(RNWidgets.self, from: widgetsData) + Logger.debug("Extracted RN widgets: sortOrder=\(widgets.sortOrder ?? [])", context: "Migration") + + var widgetOptions: [String: Data] = [:] + if let widgetsDict = try? JSONSerialization.jsonObject(with: widgetsData) as? [String: Any] { + widgetOptions = convertRNWidgetPreferences(widgetsDict) + + if widgetOptions.isEmpty, let nestedDict = widgetsDict["widgets"] as? [String: Any] { + widgetOptions = convertRNWidgetPreferences(nestedDict) + } + } + + return RNWidgetsWithOptions(widgets: widgets, widgetOptions: widgetOptions) + } catch { + Logger.error("Failed to decode RN widgets: \(error)", context: "Migration") + return nil + } + } + + func extractRNActivities(from mmkvData: [String: String]) -> [RNActivityItem]? { + guard let rootJson = mmkvData["persist:root"], + let jsonStart = rootJson.firstIndex(of: "{") + else { return nil } + + let jsonString = String(rootJson[jsonStart...]) + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let activityJson = root["activity"] as? String, + let activityData = activityJson.data(using: .utf8) + else { + return nil + } + + do { + let activityState = try JSONDecoder().decode(RNActivityState.self, from: activityData) + let items = activityState.items ?? [] + Logger.debug("Extracted \(items.count) RN activities", context: "Migration") + return items + } catch { + Logger.error("Failed to decode RN activities: \(error)", context: "Migration") + return nil + } + } + + func extractRNClosedChannels(from mmkvData: [String: String]) -> [RNChannel]? { + guard let rootJson = mmkvData["persist:root"], + let jsonStart = rootJson.firstIndex(of: "{") + else { return nil } + + let jsonString = String(rootJson[jsonStart...]) + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let lightningJson = root["lightning"] as? String, + let lightningData = lightningJson.data(using: .utf8) + else { + return nil + } + + do { + let lightningState = try JSONDecoder().decode(RNLightningState.self, from: lightningData) + var closedChannels: [RNChannel] = [] + for (_, node) in lightningState.nodes ?? [:] { + for (_, channels) in node.channels ?? [:] { + for (_, channel) in channels { + if channel.status == "closed" { + closedChannels.append(channel) + } + } + } + } + + Logger.debug("Extracted \(closedChannels.count) RN closed channels", context: "Migration") + return closedChannels.isEmpty ? nil : closedChannels + } catch { + Logger.error("Failed to decode RN lightning state: \(error)", context: "Migration") + return nil + } + } + + func applyRNSettings(_ settings: RNSettings) { + let defaults = UserDefaults.standard + + if let currency = settings.selectedCurrency { + defaults.set(currency, forKey: "selectedCurrency") + } + if let language = settings.selectedLanguage { + defaults.set(language, forKey: "selectedLanguageCode") + } + if let unit = settings.unit { + let nativeValue = unit == "BTC" ? "Bitcoin" : "Fiat" + defaults.set(nativeValue, forKey: "primaryDisplay") + } + if let denomination = settings.denomination { + defaults.set(denomination, forKey: "bitcoinDisplayUnit") + } + if let hideBalance = settings.hideBalance { + defaults.set(hideBalance, forKey: "hideBalance") + } + if let hideBalanceOnOpen = settings.hideBalanceOnOpen { + defaults.set(hideBalanceOnOpen, forKey: "hideBalanceOnOpen") + } + if let swipeToHide = settings.enableSwipeToHideBalance { + defaults.set(swipeToHide, forKey: "swipeBalanceToHide") + } + if let enableQuickpay = settings.enableQuickpay { + defaults.set(enableQuickpay, forKey: "enableQuickpay") + } + if let quickpayAmount = settings.quickpayAmount { + defaults.set(Double(quickpayAmount), forKey: "quickpayAmount") + } + if let readClipboard = settings.enableAutoReadClipboard { + defaults.set(readClipboard, forKey: "readClipboard") + } + if let warnWhenSending = settings.enableSendAmountWarning { + defaults.set(warnWhenSending, forKey: "warnWhenSendingOver100") + } + if let showWidgets = settings.showWidgets { + defaults.set(showWidgets, forKey: "showWidgets") + } + if let showWidgetTitles = settings.showWidgetTitles { + defaults.set(showWidgetTitles, forKey: "showWidgetTitles") + } + if let speed = settings.transactionSpeed { + defaults.set(speed, forKey: "defaultTransactionSpeed") + } + if let coinSelectAuto = settings.coinSelectAuto { + let method = coinSelectAuto ? "autopilot" : "manual" + defaults.set(method, forKey: "coinSelectionMethod") + } + if let coinSelectPreference = settings.coinSelectPreference { + defaults.set(coinSelectPreference, forKey: "coinSelectionAlgorithm") + } + if let requirePinForPayments = settings.pinForPayments { + defaults.set(requirePinForPayments, forKey: "requirePinForPayments") + } + if let useBiometrics = settings.biometrics { + defaults.set(useBiometrics, forKey: "useBiometrics") + } + if let seen = settings.quickpayIntroSeen { + defaults.set(seen, forKey: "hasSeenQuickpayIntro") + } + if let seen = settings.shopIntroSeen { + defaults.set(seen, forKey: "hasSeenShopIntro") + } + if let seen = settings.transferIntroSeen { + defaults.set(seen, forKey: "hasSeenTransferIntro") + } + if let seen = settings.spendingIntroSeen { + defaults.set(seen, forKey: "hasSeenTransferToSpendingIntro") + } + if let seen = settings.savingsIntroSeen { + defaults.set(seen, forKey: "hasSeenTransferToSavingsIntro") + } + + Logger.info("Applied RN settings to UserDefaults", context: "Migration") + } + + func applyRNMetadata(_ metadata: RNMetadata) async { + if let tags = metadata.tags { + for (txId, tagList) in tags { + do { + var activityId = txId + if let onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txId) { + activityId = onchain.id + } + try await CoreService.shared.activity.upsertTags([ + ActivityTags(activityId: activityId, tags: tagList), + ]) + } catch { + Logger.error("Failed to migrate tags for \(txId): \(error)", context: "Migration") + } + } + Logger.info("Migrated \(tags.count) activity tags", context: "Migration") + } + + if let lastUsedTags = metadata.lastUsedTags { + UserDefaults.standard.set(lastUsedTags, forKey: "lastUsedTags") + } + + if let comments = metadata.comments, !comments.isEmpty { + var existingComments = UserDefaults.standard.dictionary(forKey: "activityComments") as? [String: String] ?? [:] + for (txId, comment) in comments { + var activityId = txId + if let onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txId) { + activityId = onchain.id + } + existingComments[activityId] = comment + } + UserDefaults.standard.set(existingComments, forKey: "activityComments") + Logger.info("Migrated \(comments.count) activity comments", context: "Migration") + } + } + + func applyRNWidgets(_ widgetsWithOptions: RNWidgetsWithOptions) { + let widgets = widgetsWithOptions.widgets + let widgetOptions = widgetsWithOptions.widgetOptions + + if let sortOrder = widgets.sortOrder { + let widgetTypeMap: [String: MigrationWidgetType] = [ + "price": .price, + "news": .news, + "blocks": .blocks, + "weather": .weather, + "facts": .facts, + ] + + var savedWidgets: [MigrationSavedWidget] = [] + for widgetName in sortOrder { + if let widgetType = widgetTypeMap[widgetName] { + let optionsData = widgetOptions[widgetName] + savedWidgets.append(MigrationSavedWidget(type: widgetType, optionsData: optionsData)) + } + } + + if !savedWidgets.isEmpty { + do { + let encodedData = try JSONEncoder().encode(savedWidgets) + UserDefaults.standard.set(encodedData, forKey: "savedWidgets") + UserDefaults.standard.synchronize() + let withOptions = savedWidgets.filter { $0.optionsData != nil }.count + Logger.info("Migrated \(savedWidgets.count) widgets (\(withOptions) with options)", context: "Migration") + } catch { + Logger.error("Failed to encode widgets: \(error)", context: "Migration") + } + } + } + + if let onboarded = widgets.onboardedWidgets { + UserDefaults.standard.set(onboarded, forKey: "hasSeenWidgetsIntro") + } + } + + func applyRNActivities(_ items: [RNActivityItem]) async { + var activities: [Activity] = [] + let now = UInt64(Date().timeIntervalSince1970) + + for item in items { + guard item.activityType == "lightning" else { continue } + + let txType: BitkitCore.PaymentType = item.txType == "sent" ? .sent : .received + let status: BitkitCore.PaymentState = switch item.status { + case "successful", "succeeded": .succeeded + case "failed": .failed + default: .pending + } + + let timestampSecs = UInt64(item.timestamp / 1000) + let invoice = (item.address?.isEmpty == false) ? item.address! : "migrated:\(item.id)" + + let lightning = BitkitCore.LightningActivity( + id: item.id, + txType: txType, + status: status, + value: UInt64(item.value), + fee: item.fee.map { UInt64($0) }, + invoice: invoice, + message: item.message ?? "", + timestamp: timestampSecs, + preimage: item.preimage, + createdAt: timestampSecs, + updatedAt: timestampSecs, + seenAt: now + ) + activities.append(.lightning(lightning)) + } + + if !activities.isEmpty { + do { + try await CoreService.shared.activity.upsertList(activities) + Logger.info("Migrated \(activities.count) lightning activities", context: "Migration") + } catch { + Logger.error("Failed to migrate activities: \(error)", context: "Migration") + } + } + } + + func applyRNClosedChannels(_ channels: [RNChannel]) async { + let now = UInt64(Date().timeIntervalSince1970) + + let closedChannels: [ClosedChannelDetails] = channels.compactMap { channel -> ClosedChannelDetails? in + guard let fundingTxid = channel.funding_txid else { return nil } + + let closedAtSecs = channel.createdAt.map { UInt64($0 / 1000) } ?? now + + let outboundMsat = (channel.outbound_capacity_sat ?? 0) * 1000 + let inboundMsat = (channel.inbound_capacity_sat ?? 0) * 1000 + + return ClosedChannelDetails( + channelId: channel.channel_id, + counterpartyNodeId: channel.counterparty_node_id ?? "", + fundingTxoTxid: fundingTxid, + fundingTxoIndex: 0, + channelValueSats: channel.channel_value_satoshis ?? 0, + closedAt: closedAtSecs, + outboundCapacityMsat: outboundMsat, + inboundCapacityMsat: inboundMsat, + counterpartyUnspendablePunishmentReserve: channel.counterparty_unspendable_punishment_reserve ?? 0, + unspendablePunishmentReserve: channel.unspendable_punishment_reserve ?? 0, + forwardingFeeProportionalMillionths: 0, + forwardingFeeBaseMsat: 0, + channelName: "", + channelClosureReason: channel.closureReason ?? "unknown" + ) + } + + if !closedChannels.isEmpty { + do { + try await CoreService.shared.activity.upsertClosedChannelList(closedChannels) + Logger.info("Migrated \(closedChannels.count) closed channels", context: "Migration") + } catch { + Logger.error("Failed to migrate closed channels: \(error)", context: "Migration") + } + } + } + + func migrateMMKVData() async { + guard let mmkvData = loadRNMmkvData() else { + Logger.debug("No MMKV data to migrate", context: "Migration") + return + } + + if let activities = extractRNActivities(from: mmkvData) { + let lightningCount = activities.filter { $0.activityType == "lightning" }.count + Logger.info("Found \(activities.count) activities (\(lightningCount) lightning to migrate)", context: "Migration") + await applyRNActivities(activities) + } else { + Logger.debug("No activities found in MMKV", context: "Migration") + } + + if let closedChannels = extractRNClosedChannels(from: mmkvData) { + Logger.info("Found \(closedChannels.count) closed channels to migrate", context: "Migration") + await applyRNClosedChannels(closedChannels) + } else { + Logger.debug("No closed channels found in MMKV", context: "Migration") + } + + if let settings = extractRNSettings(from: mmkvData) { + Logger.info("Migrating settings", context: "Migration") + applyRNSettings(settings) + } else { + Logger.warn("Failed to extract settings from MMKV", context: "Migration") + } + + if let metadata = extractRNMetadata(from: mmkvData) { + Logger.info("Migrating metadata", context: "Migration") + await applyRNMetadata(metadata) + } else { + Logger.debug("No metadata found in MMKV", context: "Migration") + } + + if let widgets = extractRNWidgets(from: mmkvData) { + Logger.info("Migrating widgets", context: "Migration") + applyRNWidgets(widgets) + } else { + Logger.debug("No widgets found in MMKV", context: "Migration") + } + + UserDefaults.standard.set("", forKey: "onchainAddress") + + Logger.info("MMKV data migration completed", context: "Migration") + } + + func reapplyMetadataAfterSync() async { + guard hasRNMmkvData(), let mmkvData = loadRNMmkvData() else { + return + } + + if let metadata = extractRNMetadata(from: mmkvData) { + Logger.info("Re-applying metadata after sync", context: "Migration") + await applyRNMetadata(metadata) + } + + if let activities = extractRNActivities(from: mmkvData) { + await applyOnchainMetadata(activities) + } + } + + private func applyOnchainMetadata(_ items: [RNActivityItem]) async { + let onchainItems = items.filter { $0.activityType == "onchain" } + for item in onchainItems { + guard let txId = item.txId ?? (item.id.isEmpty ? nil : item.id), + var onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txId) else { - Logger.error("Could not read channel monitor using readThirtyTwoBytesChannelMonitor") - throw AppError(serviceError: .ldkToLdkNodeMigration) + continue + } + + if item.timestamp > 0 { + onchain.timestamp = UInt64(item.timestamp / 1000) + } + if let confirmTs = item.confirmTimestamp, confirmTs > 0 { + onchain.confirmTimestamp = UInt64(confirmTs / 1000) + } + if item.isTransfer == true { + onchain.isTransfer = true + onchain.channelId = item.channelId + onchain.transferTxId = item.transferTxId + } + + do { + try await CoreService.shared.activity.update(id: onchain.id, activity: .onchain(onchain)) + } catch { + Logger.error("Failed to update onchain metadata for \(txId): \(error)", context: "Migration") + } + } + + if !onchainItems.isEmpty { + Logger.info("Applied metadata to \(onchainItems.count) onchain activities", context: "Migration") + } + } + + private func convertRNWidgetPreferences(_ widgetsDict: [String: Any]) -> [String: Data] { + var result: [String: Data] = [:] + + func getBool(from dict: [String: Any], key: String, fallbackKey: String? = nil, defaultValue: Bool) -> Bool { + let keys = fallbackKey != nil ? [key, fallbackKey!] : [key] + for k in keys { + if let val = dict[k] as? Bool { return val } + if let val = dict[k] as? Int { return val != 0 } + if let val = dict[k] as? NSNumber { return val.boolValue } + } + return defaultValue + } + let pricePrefs = (widgetsDict["pricePreferences"] as? [String: Any]) + ?? (widgetsDict["price"] as? [String: Any]) + if let prefs = pricePrefs { + var selectedPairs = ["BTC/USD"] + if let pairsArray = (prefs["pairs"] as? [String]) ?? (prefs["enabledPairs"] as? [String]) { + selectedPairs = pairsArray.map { $0.replacingOccurrences(of: "_", with: "/") } + if selectedPairs.isEmpty { selectedPairs = ["BTC/USD"] } + } + let rnPeriod = prefs["period"] as? String ?? "1D" + let periodMap = ["ONE_DAY": "1D", "ONE_WEEK": "1W", "ONE_MONTH": "1M", "ONE_YEAR": "1Y"] + let iosPeriodRaw = periodMap[rnPeriod] ?? rnPeriod + let period = MigrationGraphPeriod(rawValue: iosPeriodRaw) ?? .oneDay + let options = MigrationPriceWidgetOptions( + selectedPairs: selectedPairs, + selectedPeriod: period, + showSource: getBool(from: prefs, key: "showSource", defaultValue: false) + ) + if let data = try? JSONEncoder().encode(options) { + result["price"] = data } + } - let fundingTx = Data(channelMonitor.getFundingTxo().0.getTxid()!.reversed()).hex - let index = channelMonitor.getFundingTxo().0.getIndex() + let weatherPrefs = (widgetsDict["weatherPreferences"] as? [String: Any]) + ?? (widgetsDict["weather"] as? [String: Any]) + if let prefs = weatherPrefs { + let options = MigrationWeatherWidgetOptions( + showStatus: getBool(from: prefs, key: "showTitle", fallbackKey: "showStatus", defaultValue: true), + showText: getBool(from: prefs, key: "showDescription", fallbackKey: "showText", defaultValue: false), + showMedian: getBool(from: prefs, key: "showCurrentFee", fallbackKey: "showMedian", defaultValue: false), + showNextBlockFee: getBool(from: prefs, key: "showNextBlockFee", defaultValue: false) + ) + if let data = try? JSONEncoder().encode(options) { + result["weather"] = data + } + } - let key = "\(fundingTx)_\(index)" - let insert = table.insert( - pnCol <- "monitors", - snCol <- "", - keyCol <- key, - valueCol <- monitor + let newsPrefs = (widgetsDict["headlinePreferences"] as? [String: Any]) + ?? (widgetsDict["headline"] as? [String: Any]) + ?? (widgetsDict["news"] as? [String: Any]) + if let prefs = newsPrefs { + let options = MigrationNewsWidgetOptions( + showDate: getBool(from: prefs, key: "showDate", fallbackKey: "showTime", defaultValue: true), + showTitle: getBool(from: prefs, key: "showTitle", defaultValue: true), + showSource: getBool(from: prefs, key: "showSource", defaultValue: true) ) + if let data = try? JSONEncoder().encode(options) { + result["news"] = data + } + } - try db.run(insert) - Logger.debug(key, context: "Inserted monitor") + let blocksPrefs = (widgetsDict["blocksPreferences"] as? [String: Any]) + ?? (widgetsDict["blocks"] as? [String: Any]) + if let prefs = blocksPrefs { + let options = MigrationBlocksWidgetOptions( + height: getBool(from: prefs, key: "height", fallbackKey: "showBlock", defaultValue: true), + time: getBool(from: prefs, key: "time", fallbackKey: "showTime", defaultValue: true), + date: getBool(from: prefs, key: "date", fallbackKey: "showDate", defaultValue: true), + transactionCount: getBool(from: prefs, key: "transactionCount", fallbackKey: "showTransactions", defaultValue: false), + size: getBool(from: prefs, key: "size", fallbackKey: "showSize", defaultValue: false), + weight: getBool(from: prefs, key: "weight", defaultValue: false), + difficulty: getBool(from: prefs, key: "difficulty", defaultValue: false), + hash: getBool(from: prefs, key: "hash", defaultValue: false), + merkleRoot: getBool(from: prefs, key: "merkleRoot", defaultValue: false), + showSource: getBool(from: prefs, key: "showSource", defaultValue: false) + ) + if let data = try? JSONEncoder().encode(options) { + result["blocks"] = data + } } + + let factsPrefs = (widgetsDict["factsPreferences"] as? [String: Any]) + ?? (widgetsDict["facts"] as? [String: Any]) + if let prefs = factsPrefs { + let options = MigrationFactsWidgetOptions( + showSource: getBool(from: prefs, key: "showSource", defaultValue: false) + ) + if let data = try? JSONEncoder().encode(options) { + result["facts"] = data + } + } + + return result } } diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 9d129707..6fd90cb6 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -7,8 +7,6 @@ enum KeychainEntryType { case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload case securityPin - // TODO: allow for reading keychain entries from RN wallet and then migrate them if needed - var storageKey: String { switch self { case let .bip39Mnemonic(index): diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 29428c61..047403a3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -549,6 +549,18 @@ extension AppViewModel { case let .syncCompleted(syncType, syncedBlockHeight): Logger.info("Sync completed: \(syncType) at height \(syncedBlockHeight)") + if MigrationsService.shared.isShowingMigrationLoading { + 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() + + MigrationsService.shared.isShowingMigrationLoading = false + self.toast(type: .success, title: "Migration Complete", description: "Your wallet has been successfully migrated") + } + } + // MARK: Balance Events case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, oldTotalOnchain, newTotalOnchain, oldLightning, newLightning): diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 83b620dc..cfb7bfc4 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -102,10 +102,21 @@ class WalletViewModel: ObservableObject { do { let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl let rgsServerUrl = rgsConfigService.getCurrentServerUrl() + + var channelMigration: ChannelDataMigration? + if let migration = MigrationsService.shared.pendingChannelMigration { + channelMigration = ChannelDataMigration( + channelManager: [UInt8](migration.channelManager), + channelMonitors: migration.channelMonitors.map { [UInt8]($0) } + ) + MigrationsService.shared.pendingChannelMigration = nil + } + try await lightningService.setup( walletIndex: walletIndex, electrumServerUrl: electrumServerUrl, - rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl + rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl, + channelMigration: channelMigration ) try await lightningService.start(onEvent: { event in Task { @MainActor in @@ -230,6 +241,10 @@ class WalletViewModel: ObservableObject { return } + if MigrationsService.shared.isShowingMigrationLoading { + return + } + isSyncingWallet = true syncState()