diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index cdc9cdef..b0f369d0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ Services/GeoService.swift, Services/LightningService.swift, Services/MigrationsService.swift, + Services/RNBackupClient.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Utilities/Crypto.swift, 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..92ee9c60 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 { @@ -216,16 +248,18 @@ struct AppScene: View { if wallet.isRestoringWallet { Task { - await BackupService.shared.performFullRestoreFromLatestBackup() + await restoreFromMostRecentBackup() await MainActor.run { widgets.loadSavedWidgets() widgets.objectWillChange.send() } + + await startWallet() } + } else { + Task { await startWallet() } } - - Task { await startWallet() } } private func startWallet() async { @@ -247,6 +281,7 @@ struct AppScene: View { @Sendable private func setupTask() async { do { + await checkAndPerformRNMigration() try wallet.setWalletExistsState() // Setup TimedSheetManager with all timed sheets @@ -262,6 +297,81 @@ 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 restoreFromMostRecentBackup() async { + guard let mnemonicData = try? Keychain.load(key: .bip39Mnemonic(index: 0)), + let mnemonic = String(data: mnemonicData, encoding: .utf8) + else { return } + + let passphrase: String? = { + guard let data = try? Keychain.load(key: .bip39Passphrase(index: 0)) else { return nil } + return String(data: data, encoding: .utf8) + }() + + // Check for RN backup and get its timestamp + let hasRNBackup = await MigrationsService.shared.hasRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase) + let rnTimestamp: UInt64? = await hasRNBackup ? (try? RNBackupClient.shared.getLatestBackupTimestamp()) : nil + + // Get VSS backup timestamp + let vssTimestamp = await BackupService.shared.getLatestBackupTime() + + // Determine which backup is more recent + let shouldRestoreRN: Bool = { + guard hasRNBackup else { return false } + guard let vss = vssTimestamp, vss > 0 else { return true } // No VSS, use RN + guard let rn = rnTimestamp else { return false } // No RN timestamp, use VSS + return rn >= vss // RN is same or newer + }() + + if shouldRestoreRN { + do { + try await MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase) + } catch { + Logger.error("RN remote backup restore failed: \(error)", context: "AppScene") + // Fall back to VSS + await BackupService.shared.performFullRestoreFromLatestBackup() + } + } else { + await BackupService.shared.performFullRestoreFromLatestBackup() + } + } + private func handleNodeLifecycleChange(_ state: NodeLifecycleState) { if state == .initializing { walletIsInitializing = true diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 1510f6d4..0e3901ce 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -204,6 +204,20 @@ enum Env { } } + static var rnBackupServerHost: String { + switch network { + case .bitcoin: "https://blocktank.synonym.to/backups-ldk" + default: "https://bitkit.stag0.blocktank.to/backups-ldk" + } + } + + static var rnBackupServerPubKey: String { + switch network { + case .bitcoin: "0236efd76e37f96cf2dced9d52ff84c97e5b3d4a75e7d494807291971783f38377" + default: "02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d" + } + } + static var blockExplorerUrl: String { switch network { case .bitcoin: "https://mempool.space" diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index a229b8ba..db893e44 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -499,14 +499,53 @@ class BackupService { return statuses[category] ?? BackupItemStatus() } - func getLatestBackupTime() -> UInt64? { - let statuses = getAllBackupStatuses() - let syncedTimestamps = BackupCategory.allCases.compactMap { category -> UInt64? in - let status = statuses[category] ?? BackupItemStatus() - return status.synced > 0 ? status.synced : nil + func getLatestBackupTime() async -> UInt64? { + do { + try await vssBackupClient.setup() + + let timestamps = await withTaskGroup(of: UInt64?.self) { group in + for category in BackupCategory.allCases where category != .lightningConnections { + group.addTask { + await self.getRemoteBackupTimestamp(category: category) + } + } + + var results: [UInt64] = [] + for await timestamp in group { + if let ts = timestamp, ts > 0 { + results.append(ts) + } + } + return results + } + + return timestamps.max() + } catch { + Logger.warn("Failed to get VSS backup timestamp: \(error)", context: "BackupService") + return nil } + } + + private func getRemoteBackupTimestamp(category: BackupCategory) async -> UInt64? { + do { + guard let item = try await vssBackupClient.getObject(key: category.rawValue) else { + return nil + } - return syncedTimestamps.max() + struct BackupWithCreatedAt: Codable { + let createdAt: UInt64? + } + + let backup = try JSONDecoder().decode(BackupWithCreatedAt.self, from: item.value) + guard let createdAtMillis = backup.createdAt, createdAtMillis > 0 else { + return nil + } + // Convert from milliseconds to seconds (matching Android behavior) + return createdAtMillis / 1000 + } catch { + Logger.debug("Failed to get remote backup timestamp for \(category.rawValue): \(error)", context: "BackupService") + return nil + } } func scheduleFullBackup() async { @@ -576,7 +615,7 @@ class BackupService { let settingsDict = await SettingsViewModel.shared.getSettingsDictionary() let payload = SettingsBackupV1( version: 1, - createdAt: UInt64(Date().timeIntervalSince1970), + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), settings: settingsDict ) return try payload.encode() @@ -592,7 +631,7 @@ class BackupService { let payload = WidgetsBackupV1( version: 1, - createdAt: UInt64(Date().timeIntervalSince1970), + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), widgets: androidWidgetsDict ) let encoded = try payload.encode() @@ -603,13 +642,13 @@ class BackupService { let transfers = try TransferStorage.shared.getAll() let payload = WalletBackupV1( version: 1, - createdAt: UInt64(Date().timeIntervalSince1970), + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), transfers: transfers ) return try JSONEncoder().encode(payload) case .metadata: - let currentTime = UInt64(Date().timeIntervalSince1970) + let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) let cache = await SettingsViewModel.shared.getAppCacheData() let preActivityMetadata = try await CoreService.shared.activity.getAllPreActivityMetadata() @@ -629,7 +668,7 @@ class BackupService { let payload = BlocktankBackupV1( version: 1, - createdAt: UInt64(Date().timeIntervalSince1970), + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), orders: orders, cjitEntries: cjitEntries, info: info @@ -644,7 +683,7 @@ class BackupService { let payload = ActivityBackupV1( version: 1, - createdAt: UInt64(Date().timeIntervalSince1970), + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), activities: activities, activityTags: activityTags, closedChannels: closedChannels diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index ce9c627f..63b116b1 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 { @@ -316,15 +352,16 @@ class ActivityService { let value = payment.amountSats ?? 0 // Determine confirmation status from payment's txStatus - // Ensure confirmTimestamp is at least equal to paymentTimestamp when confirmed - // This handles cases where payment.latestUpdateTimestamp is more recent than blockTimestamp - let (isConfirmed, confirmedTimestamp): (Bool, UInt64?) = - if case let .onchain(_, txStatus) = payment.kind, - case let .confirmed(_, _, blockTimestamp) = txStatus { - (true, max(blockTimestamp, paymentTimestamp)) - } else { - (false, nil) - } + var blockTimestamp: UInt64? + let isConfirmed: Bool + if case let .onchain(_, txStatus) = payment.kind, + case let .confirmed(_, _, bts) = txStatus + { + isConfirmed = true + blockTimestamp = bts + } else { + isConfirmed = false + } // Extract existing activity data let existingOnchain: OnchainActivity? = { @@ -376,6 +413,12 @@ class ActivityService { // Build and save the activity let finalDoesExist = isConfirmed ? true : doesExist + let activityTimestamp: UInt64 = if existingActivity == nil, let bts = blockTimestamp, bts < paymentTimestamp { + bts + } else { + existingOnchain?.timestamp ?? paymentTimestamp + } + let onchain = OnchainActivity( id: payment.id, txType: payment.direction == .outbound ? .sent : .received, @@ -385,12 +428,12 @@ class ActivityService { feeRate: feeRate, address: address, confirmed: isConfirmed, - timestamp: paymentTimestamp, + timestamp: activityTimestamp, isBoosted: isBoosted, boostTxIds: boostTxIds, isTransfer: isTransfer, doesExist: finalDoesExist, - confirmTimestamp: confirmedTimestamp, + confirmTimestamp: blockTimestamp, channelId: channelId, transferTxId: transferTxId, createdAt: UInt64(payment.creationTime.timeIntervalSince1970), 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..02650938 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1,101 +1,1538 @@ +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]? +} + +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 isRestoringFromRNRemoteBackup = false + + var pendingChannelMigration: PendingChannelMigration? + + /// Stored activity data from RN remote backup for reapplying metadata after sync + private var pendingRemoteActivityData: [RNActivityItem]? + + /// Stored transfer info from RN wallet backup for marking on-chain txs as transfers + private var pendingRemoteTransfers: [String: String]? // txId -> channelId + + /// Stored boost info from RN wallet backup for applying boostTxIds to activities + private var pendingRemoteBoosts: [String: String]? // oldTxId -> newTxId + + /// Stored metadata from RN backup for reapplying after on-chain activities are synced + private var pendingRemoteMetadata: RNMetadata? + 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 + } + + func loadStringFromRNKeychain(key: RNKeychainKey) throws -> String? { + guard let data = try loadFromRNKeychain(key: key) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} + +// MARK: - RN Migration Detection & Execution - // Can't migrate if data currently exists - guard !fileManager.fileExists(atPath: sqlFilePath) else { - throw AppError(serviceError: .ldkNodeSqliteAlreadyExists) +extension MigrationsService { + var isMigrationChecked: Bool { + UserDefaults.standard.bool(forKey: Self.rnMigrationCheckedKey) + } + + 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 } + } + + func hasNativeWalletData() -> Bool { + do { + return try Keychain.exists(key: .bip39Mnemonic(index: 0)) + } catch { + return false + } + } - let db = try Connection(sqlFilePath) + 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) + } - let table = Table("ldk_node_data") + 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 + } - let pnCol = Expression("primary_namespace") - let snCol = Expression("secondary_namespace") - let keyCol = Expression("key") - let valueCol = Expression("value") + 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() + } - try db.run( - table.create { t in - t.column(pnCol, primaryKey: true) - t.column(snCol) - t.column(keyCol) - t.column(valueCol) + 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) + } + + do { + try validateMnemonic(mnemonicPhrase: mnemonic) + } catch { + throw AppError(message: "Invalid BIP39 mnemonic", 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) + } +} + +// MARK: - MMKV Data Migration + +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 + Logger.debug("Extracted RN metadata: \(tagCount) tagged txs", 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 applyRNWidgets(_ widgetsWithOptions: RNWidgetsWithOptions) { + let widgets = widgetsWithOptions.widgets + let widgetOptions = widgetsWithOptions.widgetOptions - for monitor in monitors { - // MARK: get funding_tx and index using plain LDK + if let sortOrder = widgets.sortOrder { + let widgetTypeMap: [String: MigrationWidgetType] = [ + "price": .price, + "news": .news, + "blocks": .blocks, + "weather": .weather, + "facts": .facts, + ] - // 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 + 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 applyAllMetadata(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 { + // Handle MMKV (local) migration data + if hasRNMmkvData(), let mmkvData = loadRNMmkvData() { + if let metadata = extractRNMetadata(from: mmkvData) { + Logger.info("Re-applying MMKV metadata after sync", context: "Migration") + await applyAllMetadata(metadata) + } + + if let activities = extractRNActivities(from: mmkvData) { + await applyOnchainMetadata(activities) + } + } + + // Handle remote backup data (for on-chain timestamps from RN backup) + if let remoteActivities = pendingRemoteActivityData { + Logger.info("Re-applying remote backup metadata after sync", context: "Migration") + await applyOnchainMetadata(remoteActivities) + pendingRemoteActivityData = nil + } + + // Handle remote backup transfers (mark on-chain txs as transfers) + if let transfers = pendingRemoteTransfers { + Logger.info("Applying \(transfers.count) remote transfer markers", context: "Migration") + await applyRemoteTransfers(transfers) + pendingRemoteTransfers = nil + } + + // Handle remote backup boosts (apply boostTxIds to activities) + if let boosts = pendingRemoteBoosts { + Logger.info("Applying \(boosts.count) remote boost markers", context: "Migration") + await applyRemoteBoosts(boosts) + pendingRemoteBoosts = nil + } + + // Apply stored metadata (all tags after sync when activities exist) + if let metadata = pendingRemoteMetadata { + Logger.info("Applying stored metadata after sync", context: "Migration") + await applyAllMetadata(metadata) + pendingRemoteMetadata = nil + } + } + + private func applyRemoteTransfers(_ transfers: [String: String]) async { + var applied = 0 + + for (txId, channelId) in transfers { + guard var onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txId) else { + continue + } + + onchain.isTransfer = true + onchain.channelId = channelId + + do { + try await CoreService.shared.activity.update(id: onchain.id, activity: .onchain(onchain)) + applied += 1 + } catch { + Logger.error("Failed to mark tx \(txId) as transfer: \(error)", context: "Migration") + } + } + + Logger.info("Applied \(applied)/\(transfers.count) transfer markers", context: "Migration") + } + + private func applyRemoteBoosts(_ boosts: [String: String]) async { + var applied = 0 + + for (oldTxId, newTxId) in boosts { + guard var onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: newTxId) else { + continue + } + + if !onchain.boostTxIds.contains(oldTxId) { + onchain.boostTxIds.append(oldTxId) + } + onchain.isBoosted = true + + do { + try await CoreService.shared.activity.update(id: onchain.id, activity: .onchain(onchain)) + applied += 1 + } catch { + Logger.error("Failed to apply boost for tx \(newTxId): \(error)", context: "Migration") + } + } + + Logger.info("Applied \(applied)/\(boosts.count) boost markers", context: "Migration") + } + + private func applyAllMetadata(_ metadata: RNMetadata) async { + if let tags = metadata.tags, !tags.isEmpty { + await applyPendingTags(tags) + } + + if let lastUsedTags = metadata.lastUsedTags { + UserDefaults.standard.set(lastUsedTags, forKey: "lastUsedTags") + } + } + + private func applyPendingTags(_ tags: [String: [String]]) async { + var applied = 0 + for (activityId, tagList) in tags { + do { + // Try on-chain first + if let onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: activityId) { + try await CoreService.shared.activity.upsertTags([ + ActivityTags(activityId: onchain.id, tags: tagList), + ]) + applied += 1 + } + // Then try lightning + else if let activity = try? await CoreService.shared.activity.getActivity(id: activityId), + case .lightning = activity + { + try await CoreService.shared.activity.upsertTags([ + ActivityTags(activityId: activityId, tags: tagList), + ]) + applied += 1 + } else { + Logger.warn("Activity \(activityId) still not found after sync", context: "Migration") + } + } catch { + Logger.error("Failed to apply pending tag for \(activityId): \(error)", context: "Migration") + } + } + Logger.info("Applied \(applied)/\(tags.count) pending tags", context: "Migration") + } + + 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 + } +} + +// MARK: - RN Remote Backup Restore + +extension MigrationsService { + private func normalizePassphrase(_ passphrase: String?) -> String? { + passphrase?.isEmpty == true ? nil : passphrase + } + + func hasRNRemoteBackup(mnemonic: String, passphrase: String?) async -> Bool { + do { + let effectivePassphrase = normalizePassphrase(passphrase) + RNBackupClient.shared.reset() + try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: effectivePassphrase) + return try await RNBackupClient.shared.hasBackup() + } catch { + Logger.error("Failed to check RN remote backup: \(error)", context: "Migration") + return false + } + } + + func restoreFromRNRemoteBackup(mnemonic: String, passphrase: String?) async throws { + let effectivePassphrase = normalizePassphrase(passphrase) + try await RNBackupClient.shared.setup(mnemonic: mnemonic, passphrase: effectivePassphrase) + + isRestoringFromRNRemoteBackup = true + Logger.info("Starting RN remote backup restore", context: "Migration") + + // Fetch LDK data (channel_manager and channel_monitors) + await fetchRNRemoteLdkData() + + async let settingsData = RNBackupClient.shared.retrieve(label: "bitkit_settings", fileGroup: "bitkit") + async let widgetsData = RNBackupClient.shared.retrieve(label: "bitkit_widgets", fileGroup: "bitkit") + async let activityData = RNBackupClient.shared.retrieve(label: "bitkit_lightning_activity", fileGroup: "bitkit") + async let metadataData = RNBackupClient.shared.retrieve(label: "bitkit_metadata", fileGroup: "bitkit") + async let walletData = RNBackupClient.shared.retrieve(label: "bitkit_wallet", fileGroup: "bitkit") + async let blocktankData = RNBackupClient.shared.retrieve(label: "bitkit_blocktank_orders", fileGroup: "bitkit") + + if let settings = try? await settingsData { + try await applyRNRemoteSettings(settings) + } else { + Logger.warn("Failed to retrieve bitkit_settings from remote backup", context: "Migration") + } + + if let widgets = try? await widgetsData { + try await applyRNRemoteWidgets(widgets) + } else { + Logger.warn("Failed to retrieve bitkit_widgets from remote backup", context: "Migration") + } + + if let activity = try? await activityData { + try await applyRNRemoteActivity(activity) + } else { + Logger.warn("Failed to retrieve bitkit_lightning_activity from remote backup", context: "Migration") + } + + if let metadata = try? await metadataData { + try await applyRNRemoteMetadata(metadata) + } else { + Logger.warn("Failed to retrieve bitkit_metadata from remote backup", context: "Migration") + } + + if let wallet = try? await walletData { + try await applyRNRemoteWallet(wallet) + } else { + Logger.warn("Failed to retrieve bitkit_wallet from remote backup", context: "Migration") + } + + if let blocktank = try? await blocktankData { + try await applyRNRemoteBlocktank(blocktank) + } else { + Logger.warn("Failed to retrieve bitkit_blocktank_orders from remote backup", context: "Migration") + } + + Logger.info("RN remote backup restore completed", context: "Migration") + } + + private func fetchRNRemoteLdkData() async { + do { + let files = try await RNBackupClient.shared.listFiles(fileGroup: "ldk") + + guard let managerData = try? await RNBackupClient.shared.retrieve(label: "channel_manager", fileGroup: "ldk") else { + Logger.debug("No channel_manager found in remote LDK backup", context: "Migration") + return + } + + let monitors = await withTaskGroup(of: Data?.self) { group in + var results: [Data] = [] + for monitorFile in files.channel_monitors { + group.addTask { + let channelId = monitorFile.replacingOccurrences(of: ".bin", with: "") + return try? await RNBackupClient.shared.retrieveChannelMonitor(channelId: channelId) + } + } + for await monitor in group { + if let monitor { + results.append(monitor) + } + } + return results + } + + if !monitors.isEmpty { + pendingChannelMigration = PendingChannelMigration( + channelManager: managerData, + channelMonitors: monitors + ) + Logger.info("Prepared \(monitors.count) channel monitors for migration", context: "Migration") + } + } catch { + Logger.error("Failed to fetch remote LDK data: \(error)", context: "Migration") + } + } + + private func applyRNRemoteSettings(_ data: Data) async throws { + struct BackupEnvelope: Codable { + let data: RNSettings + } + + guard let json = try? JSONDecoder().decode(BackupEnvelope.self, from: data) else { + Logger.warn("Failed to decode RN remote settings backup", context: "Migration") + return + } + + applyRNSettings(json.data) + } + + private func applyRNRemoteWidgets(_ data: Data) async throws { + struct BackupEnvelope: Codable { + let data: RNWidgets + } + + guard let json = try? JSONDecoder().decode(BackupEnvelope.self, from: data) else { + Logger.warn("Failed to decode RN remote widgets backup", context: "Migration") + return + } + + var widgetOptions: [String: Data] = [:] + if let rawDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = rawDict["data"] as? [String: Any] + { + widgetOptions = convertRNWidgetPreferences(dataDict) + + if widgetOptions.isEmpty, let nestedDict = dataDict["widgets"] as? [String: Any] { + widgetOptions = convertRNWidgetPreferences(nestedDict) + } + } + + let widgetsWithOptions = RNWidgetsWithOptions(widgets: json.data, widgetOptions: widgetOptions) + applyRNWidgets(widgetsWithOptions) + } + + private func applyRNRemoteMetadata(_ data: Data) async throws { + struct BackupEnvelope: Codable { + let data: RNMetadata + } + + guard let json = try? JSONDecoder().decode(BackupEnvelope.self, from: data) else { + Logger.warn("Failed to decode RN remote metadata backup", context: "Migration") + return + } + + // Store metadata for application after sync (on-chain activities don't exist yet) + pendingRemoteMetadata = json.data + } + + private func applyRNRemoteActivity(_ data: Data) async throws { + struct ActivityItem: 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 BackupEnvelope: Codable { + let data: [ActivityItem] + } + + guard let json = try? JSONDecoder().decode(BackupEnvelope.self, from: data) else { + Logger.warn("Failed to decode RN remote activity backup", context: "Migration") + return + } + + let items: [RNActivityItem] = json.data.map { item in + RNActivityItem( + id: item.id, + activityType: item.activityType, + txType: item.txType, + txId: item.txId, + value: item.value, + fee: item.fee, + feeRate: item.feeRate, + address: item.address, + confirmed: item.confirmed, + timestamp: item.timestamp, + isBoosted: item.isBoosted, + isTransfer: item.isTransfer, + exists: item.exists, + confirmTimestamp: item.confirmTimestamp, + channelId: item.channelId, + transferTxId: item.transferTxId, + status: item.status, + message: item.message, + preimage: item.preimage + ) + } + + // Store for later reapplication after sync (for on-chain timestamps) + pendingRemoteActivityData = items + + await applyRNActivities(items) + } + + private func applyRNRemoteWallet(_ data: Data) async throws { + struct Transfer: Codable { + var txId: String? + var type: String? + } + + struct BoostedTransaction: Codable { + var oldTxId: String? + var newTxId: String? + } + + struct WalletBackup: Codable { + var transfers: [String: [Transfer]]? + var boostedTransactions: [String: [String: BoostedTransaction]]? + } + + struct BackupEnvelope: Codable { + let data: WalletBackup + } + + guard let json = try? JSONDecoder().decode(BackupEnvelope.self, from: data) else { + Logger.warn("Failed to decode RN remote wallet backup", context: "Migration") + return + } + + // Store transfers for later application (to mark on-chain txs as transfers) + if let transfers = json.data.transfers { + var transferMap: [String: String] = [:] + var totalTransfersFound = 0 + for (_, networkTransfers) in transfers { + totalTransfersFound += networkTransfers.count + for transfer in networkTransfers { + if let txId = transfer.txId, let type = transfer.type { + // type contains the channelId for transfer identification + transferMap[txId] = type + } + } + } + Logger.info("Found \(totalTransfersFound) transfers in backup, \(transferMap.count) with valid txId/type", context: "Migration") + if !transferMap.isEmpty { + pendingRemoteTransfers = transferMap + } + } else { + Logger.debug("No transfers found in RN remote wallet backup", context: "Migration") + } + + if let boostedTxs = json.data.boostedTransactions { + var boostMap: [String: String] = [:] + for (_, networkBoosts) in boostedTxs { + for (oldTxId, boost) in networkBoosts { + if let newTxId = boost.newTxId { + boostMap[oldTxId] = newTxId + } + } + } + if !boostMap.isEmpty { + pendingRemoteBoosts = boostMap + } + } + } + + private func applyRNRemoteBlocktank(_ data: Data) async throws { + struct BlocktankOrder: Codable { + var id: String + var state: String? + var lspBalanceSat: UInt64? + var clientBalanceSat: UInt64? + var channelExpiryWeeks: Int? + var createdAt: String? + } + + struct BlocktankBackup: Codable { + var orders: [BlocktankOrder]? + var paidOrders: [String]? + } + + struct BackupEnvelope: Codable { + let data: BlocktankBackup + } + + guard let json = try? JSONDecoder().decode(BackupEnvelope.self, from: data) else { + Logger.warn("Failed to decode RN remote blocktank backup", context: "Migration") + return + } + + var orderIds: [String] = [] + + if let orders = json.data.orders { + orderIds.append(contentsOf: orders.map(\.id)) + } + + if let paidOrderIds = json.data.paidOrders { + orderIds.append(contentsOf: paidOrderIds) + } + + if !orderIds.isEmpty { + 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", context: "Migration") + } + } catch { + Logger.warn("Failed to fetch and upsert Blocktank orders: \(error)", context: "Migration") + } } } } diff --git a/Bitkit/Services/RNBackupClient.swift b/Bitkit/Services/RNBackupClient.swift new file mode 100644 index 00000000..b22fb03d --- /dev/null +++ b/Bitkit/Services/RNBackupClient.swift @@ -0,0 +1,373 @@ +import CommonCrypto +import CryptoKit +import Foundation +import LightningDevKit + +enum RNBackupError: Error, LocalizedError { + case notSetup + case missingResponse + case invalidServerResponse(String) + case decryptFailed(String) + case authFailed + case noBackupFound + + var errorDescription: String? { + switch self { + case .notSetup: + return "RN Backup client requires setup" + case .missingResponse: + return "Missing response from backup server" + case let .invalidServerResponse(msg): + return "Invalid backup server response: \(msg)" + case let .decryptFailed(msg): + return "Failed to decrypt backup: \(msg)" + case .authFailed: + return "Authentication with backup server failed" + case .noBackupFound: + return "No backup found on server" + } + } +} + +struct RNBackupListResponse: Codable { + let list: [String] + let channel_monitors: [String] +} + +private struct AuthChallengeResponse: Codable { + let challenge: String +} + +private struct AuthBearerResponse: Codable { + let bearer: String + let expires: Int +} + +class RNBackupClient { + static let shared = RNBackupClient() + + private static let version = "v1" + private static let signedMessagePrefix = "react-native-ldk backup server auth:" + + private var secretKey: Data? + private var publicKey: Data? + private var network: String? + private var serverHost: String? + private var cachedBearer: AuthBearerResponse? + + private var encryptionKey: SymmetricKey? { + guard let secretKey else { return nil } + return SymmetricKey(data: secretKey) + } + + private init() {} + + func setup(mnemonic: String, passphrase: String?) async throws { + let seed = try deriveSeed(mnemonic: mnemonic, passphrase: passphrase) + secretKey = seed + publicKey = try Crypto.getPublicKey(privateKey: seed) + serverHost = Env.rnBackupServerHost + network = networkString() + cachedBearer = nil + } + + func reset() { + secretKey = nil + publicKey = nil + network = nil + cachedBearer = nil + } + + private func networkString() -> String { + switch Env.network { + case .bitcoin: "bitcoin" + case .testnet: "testnet" + case .regtest: "regtest" + case .signet: "signet" + } + } + + private func deriveSeed(mnemonic: String, passphrase: String?) throws -> Data { + let mnemonicData = Data(mnemonic.utf8) + let salt = "mnemonic" + (passphrase ?? "") + let saltData = Data(salt.utf8) + + var bip39Seed = [UInt8](repeating: 0, count: 64) + let pbkdfResult = bip39Seed.withUnsafeMutableBytes { seedPtr in + mnemonicData.withUnsafeBytes { mnemonicPtr in + saltData.withUnsafeBytes { saltPtr in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + mnemonicPtr.bindMemory(to: Int8.self).baseAddress!, + mnemonicData.count, + saltPtr.bindMemory(to: UInt8.self).baseAddress!, + saltData.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512), + 2048, + seedPtr.bindMemory(to: UInt8.self).baseAddress!, + 64 + ) + } + } + } + + guard pbkdfResult == kCCSuccess else { + throw RNBackupError.authFailed + } + + let hmacKey = Data("Bitcoin seed".utf8) + let seedData = Data(bip39Seed) + + var hmacOutput = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + hmacKey.withUnsafeBytes { keyPtr in + seedData.withUnsafeBytes { seedPtr in + CCHmac( + CCHmacAlgorithm(kCCHmacAlgSHA512), + keyPtr.baseAddress!, + hmacKey.count, + seedPtr.baseAddress!, + seedData.count, + &hmacOutput + ) + } + } + + let bip32Seed = [UInt8](hmacOutput.prefix(32)) + + let currentTime = Date() + let seconds = UInt64(currentTime.timeIntervalSince1970) + let nanoSeconds = UInt32((currentTime.timeIntervalSince1970.truncatingRemainder(dividingBy: 1)) * 1_000_000_000) + + let keysManager = KeysManager( + seed: bip32Seed, + startingTimeSecs: seconds, + startingTimeNanos: nanoSeconds + ) + + return Data(keysManager.getNodeSecretKey()) + } + + // MARK: - Public API + + func listFiles(fileGroup: String? = "ldk") async throws -> RNBackupListResponse { + let bearer = try await getAuthToken() + let url = try buildUrl(method: "list", fileGroup: fileGroup) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(bearer, forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: data, encoding: .utf8) ?? "" + Logger.error("RN Backup listFiles failed: status=\(statusCode) body=\(body)", context: "RNBackup") + throw RNBackupError.invalidServerResponse("List files failed") + } + + return try JSONDecoder().decode(RNBackupListResponse.self, from: data) + } + + func retrieve(label: String, fileGroup: String? = nil) async throws -> Data { + let bearer = try await getAuthToken() + let url = try buildUrl(method: "retrieve", label: label, fileGroup: fileGroup) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(bearer, forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw RNBackupError.invalidServerResponse("Retrieve \(label) failed") + } + + return try decrypt(data) + } + + func hasBackup() async throws -> Bool { + let ldkFiles = try await listFiles(fileGroup: "ldk") + let bitkitFiles = try await listFiles(fileGroup: "bitkit") + return !ldkFiles.list.isEmpty || !ldkFiles.channel_monitors.isEmpty || !bitkitFiles.list.isEmpty + } + + func getLatestBackupTimestamp() async throws -> UInt64? { + struct BackupWithMetadata: Codable { + struct Metadata: Codable { + let timestamp: Int64? + } + + let metadata: Metadata? + } + + let labels = [ + "bitkit_settings", + "bitkit_metadata", + "bitkit_widgets", + "bitkit_lightning_activity", + "bitkit_wallet", + "bitkit_blocktank_orders", + ] + var latestTimestamp: UInt64? + + for label in labels { + guard let data = try? await retrieve(label: label, fileGroup: "bitkit") else { continue } + guard let backup = try? JSONDecoder().decode(BackupWithMetadata.self, from: data), + let timestamp = backup.metadata?.timestamp + else { continue } + + let ts = UInt64(timestamp / 1000) // Convert ms to seconds + if let latest = latestTimestamp { + if ts > latest { + latestTimestamp = ts + } + } else { + latestTimestamp = ts + } + } + + return latestTimestamp + } + + func retrieveChannelMonitor(channelId: String) async throws -> Data { + let bearer = try await getAuthToken() + let url = try buildUrl(method: "retrieve", label: "channel_monitor", fileGroup: "ldk", channelId: channelId) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(bearer, forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw RNBackupError.invalidServerResponse("Retrieve channel_monitor \(channelId) failed") + } + + return try decrypt(data) + } + + // MARK: - Private Helpers + + private func buildUrl(method: String, label: String? = nil, fileGroup: String? = nil, channelId: String? = nil) throws -> URL { + guard let serverHost, let network else { + throw RNBackupError.notSetup + } + + var urlString = "\(serverHost)/\(Self.version)/\(method)?network=\(network)" + + if let label { + urlString += "&label=\(label)" + } + if let fileGroup { + urlString += "&fileGroup=\(fileGroup)" + } + if let channelId { + urlString += "&channelId=\(channelId)" + } + + guard let url = URL(string: urlString) else { + throw RNBackupError.invalidServerResponse("Invalid URL") + } + + return url + } + + private func getAuthToken() async throws -> String { + // Return cached token if still valid + if let cached = cachedBearer { + let expiresAt = Double(cached.expires) / 1000.0 + if expiresAt > Date().timeIntervalSince1970 { + return cached.bearer + } + } + + guard let publicKey, secretKey != nil else { + throw RNBackupError.notSetup + } + + let pubKeyHex = publicKey.hex + let timestamp = String(Date().timeIntervalSince1970) + let signedTimestamp = try sign(message: timestamp) + + let challengeUrl = try buildUrl(method: "auth/challenge") + + var challengeRequest = URLRequest(url: challengeUrl) + challengeRequest.httpMethod = "POST" + challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + challengeRequest.setValue(pubKeyHex, forHTTPHeaderField: "Public-Key") + challengeRequest.httpBody = try JSONSerialization.data(withJSONObject: [ + "timestamp": timestamp, + "signature": signedTimestamp, + ]) + + let (challengeData, challengeResponse) = try await URLSession.shared.data(for: challengeRequest) + + guard let httpResponse = challengeResponse as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (challengeResponse as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: challengeData, encoding: .utf8) ?? "" + Logger.error("Auth challenge failed: status=\(statusCode) body=\(body)", context: "RNBackup") + throw RNBackupError.authFailed + } + + let challengeResult = try JSONDecoder().decode(AuthChallengeResponse.self, from: challengeData) + let signedChallenge = try sign(message: challengeResult.challenge) + + let authUrl = try buildUrl(method: "auth/response") + var authRequest = URLRequest(url: authUrl) + authRequest.httpMethod = "POST" + authRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + authRequest.setValue(pubKeyHex, forHTTPHeaderField: "Public-Key") + authRequest.httpBody = try JSONSerialization.data(withJSONObject: [ + "signature": signedChallenge, + ]) + + let (authData, authResponse) = try await URLSession.shared.data(for: authRequest) + + guard let httpAuthResponse = authResponse as? HTTPURLResponse, httpAuthResponse.statusCode == 200 else { + throw RNBackupError.authFailed + } + + let bearerResult = try JSONDecoder().decode(AuthBearerResponse.self, from: authData) + cachedBearer = bearerResult + + return bearerResult.bearer + } + + private func sign(message: String) throws -> String { + guard let secretKey else { + throw RNBackupError.notSetup + } + + let fullMessage = "\(Self.signedMessagePrefix)\(message)" + return try Crypto.sign(message: fullMessage, privateKey: secretKey) + } + + private func decrypt(_ blob: Data) throws -> Data { + guard let encryptionKey else { + throw RNBackupError.notSetup + } + + guard blob.count > 28 else { // 12 nonce + 16 tag minimum + throw RNBackupError.decryptFailed("Data too short") + } + + let nonce = blob.prefix(12) + let tag = blob.suffix(16) + let ciphertext = blob.dropFirst(12).dropLast(16) + + do { + let sealedBox = try AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: nonce), + ciphertext: ciphertext, + tag: tag + ) + return try AES.GCM.open(sealedBox, using: encryptionKey) + } catch { + throw RNBackupError.decryptFailed(error.localizedDescription) + } + } +} diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 021122a9..d76fe6ed 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -34,6 +34,9 @@ enum AppReset { UserDefaults.standard.removePersistentDomain(forName: bundleID) } + // Prevent RN migration from triggering after wipe + MigrationsService.shared.markMigrationChecked() + // Wipe logs if Env.network == .regtest { try wipeLogs() diff --git a/Bitkit/Utilities/Crypto.swift b/Bitkit/Utilities/Crypto.swift index 8462acde..d9b55468 100644 --- a/Bitkit/Utilities/Crypto.swift +++ b/Bitkit/Utilities/Crypto.swift @@ -102,6 +102,75 @@ class Crypto { return SHA256.hash(data: Data(sha256)).bytes } + /// Sign using Lightning Network message signing format + static func sign(message: String, privateKey: Data) throws -> String { + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { + throw CryptoError.contextCreationFailed + } + defer { secp256k1_context_destroy(context) } + + let lightningPrefix = "Lightning Signed Message:" + let prefixedMessage = lightningPrefix + message + let hash1 = SHA256.hash(data: Data(prefixedMessage.utf8)) + let messageHash = SHA256.hash(data: Data(hash1)) + + var signature = secp256k1_ecdsa_recoverable_signature() + + let result = messageHash.withUnsafeBytes { hashPtr in + privateKey.withUnsafeBytes { keyPtr in + secp256k1_ecdsa_sign_recoverable( + context, + &signature, + hashPtr.bindMemory(to: UInt8.self).baseAddress!, + keyPtr.bindMemory(to: UInt8.self).baseAddress!, + nil, + nil + ) + } + } + + guard result == 1 else { + throw CryptoError.signingFailed + } + + var output = [UInt8](repeating: 0, count: 64) + var recId: Int32 = 0 + secp256k1_ecdsa_recoverable_signature_serialize_compact(context, &output, &recId, &signature) + + let recIdByte = UInt8(recId + 31) + var fullSig = [recIdByte] + fullSig.append(contentsOf: output) + return Data(fullSig).hex + } + + static func getPublicKey(privateKey: Data) throws -> Data { + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { + throw CryptoError.contextCreationFailed + } + defer { secp256k1_context_destroy(context) } + + var publicKey = secp256k1_pubkey() + let createResult = privateKey.withUnsafeBytes { keyPtr in + secp256k1_ec_pubkey_create( + context, + &publicKey, + keyPtr.bindMemory(to: UInt8.self).baseAddress! + ) + } + + guard createResult == 1 else { + throw CryptoError.publicKeyCreationFailed + } + + var serializedPubKey = [UInt8](repeating: 0, count: 33) + var outputLen = 33 + guard secp256k1_ec_pubkey_serialize(context, &serializedPubKey, &outputLen, &publicKey, UInt32(SECP256K1_EC_COMPRESSED)) == 1 else { + throw CryptoError.publicKeySerializationFailed + } + + return Data(serializedPubKey) + } + enum CryptoError: Error { case sharedSecretGenerationFailed case invalidDerivationName @@ -112,5 +181,6 @@ class Crypto { case publicKeySerializationFailed case decryptionFailed case invalidInputData + case signingFailed } } 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..3cf92020 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -549,6 +549,24 @@ 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") + } + } else if MigrationsService.shared.isRestoringFromRNRemoteBackup { + Task { + try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? []) + await MigrationsService.shared.reapplyMetadataAfterSync() + MigrationsService.shared.isRestoringFromRNRemoteBackup = false + } + } + // 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() diff --git a/Bitkit/Views/Backup/BackupMetadata.swift b/Bitkit/Views/Backup/BackupMetadata.swift index a19b2b8a..8b3b1882 100644 --- a/Bitkit/Views/Backup/BackupMetadata.swift +++ b/Bitkit/Views/Backup/BackupMetadata.swift @@ -46,7 +46,7 @@ struct BackupMetadata: View { } private func loadLastBackupTime() async { - if let timestamp = BackupService.shared.getLatestBackupTime() { + if let timestamp = await BackupService.shared.getLatestBackupTime() { let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) let formatter = DateFormatter() formatter.dateStyle = .medium diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index f72c6cc2..c62234fb 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -6,6 +6,8 @@ struct AppStatusView: View { @EnvironmentObject private var wallet: WalletViewModel @EnvironmentObject private var app: AppViewModel + @State private var backupTimestamp: UInt64? + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__status__title")) @@ -30,10 +32,14 @@ struct AppStatusView: View { .onAppear { wallet.syncState() } + .task { + backupTimestamp = await BackupService.shared.getLatestBackupTime() + } } private func refreshAppStatus() async { wallet.syncState() + backupTimestamp = await BackupService.shared.getLatestBackupTime() if wallet.nodeLifecycleState == .running { do { @@ -133,11 +139,10 @@ struct AppStatusView: View { } private var backupStatusRow: some View { - let timestamp = BackupService.shared.getLatestBackupTime() let description: String let status: HealthStatus - if let timestamp { + if let timestamp = backupTimestamp { let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) let formatter = DateFormatter() formatter.dateStyle = .medium