diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 61f86ba7..f31e06fd 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/AppScene.swift b/Bitkit/AppScene.swift index e67c152b..f5d434f6 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -248,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 { @@ -332,6 +334,44 @@ struct AppScene: View { } } + 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 = 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 a821ff29..e0aca0f6 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/CoreService.swift b/Bitkit/Services/CoreService.swift index 24e701fb..63b116b1 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -352,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? = { @@ -412,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, @@ -421,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/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 23cf2646..ff3a6706 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -116,7 +116,6 @@ struct RNSettings: Codable { struct RNMetadata: Codable { var tags: [String: [String]]? var lastUsedTags: [String]? - var comments: [String: String]? } struct RNActivityState: Codable { @@ -290,9 +289,22 @@ class MigrationsService: ObservableObject { 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 { @@ -436,6 +448,10 @@ extension MigrationsService { throw AppError(message: "Invalid mnemonic: \(words.count) words", debugMessage: nil) } + guard BitkitCore.validateMnemonic(mnemonic) else { + throw AppError(message: "Invalid BIP39 mnemonic", debugMessage: nil) + } + try Keychain.saveString(key: .bip39Mnemonic(index: walletIndex), str: mnemonic) } @@ -578,8 +594,7 @@ extension MigrationsService { 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") + Logger.debug("Extracted RN metadata: \(tagCount) tagged txs", context: "Migration") return metadata } catch { Logger.error("Failed to decode RN metadata: \(error)", context: "Migration") @@ -759,42 +774,6 @@ extension MigrationsService { 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 @@ -947,7 +926,7 @@ extension MigrationsService { if let metadata = extractRNMetadata(from: mmkvData) { Logger.info("Migrating metadata", context: "Migration") - await applyRNMetadata(metadata) + await applyAllMetadata(metadata) } else { Logger.debug("No metadata found in MMKV", context: "Migration") } @@ -965,20 +944,132 @@ extension MigrationsService { } func reapplyMetadataAfterSync() async { - guard hasRNMmkvData(), let mmkvData = loadRNMmkvData() else { - return + // 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) + } } - if let metadata = extractRNMetadata(from: mmkvData) { - Logger.info("Re-applying metadata after sync", context: "Migration") - await applyRNMetadata(metadata) + // 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 } - if let activities = extractRNActivities(from: mmkvData) { - await applyOnchainMetadata(activities) + // 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 { @@ -1108,3 +1199,338 @@ extension MigrationsService { 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/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 047403a3..3cf92020 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -559,6 +559,12 @@ extension AppViewModel { 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