diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index b8188c6c6..45d6cbe98 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -120,6 +120,13 @@ struct RNMetadata: Codable { var lastUsedTags: [String]? } +func rnMetadataRetainingUnappliedTags(_ metadata: RNMetadata, unappliedActivityIds: Set) -> RNMetadata? { + guard let tags = metadata.tags else { return nil } + let remaining = tags.filter { unappliedActivityIds.contains($0.key) } + guard !remaining.isEmpty else { return nil } + return RNMetadata(tags: remaining, lastUsedTags: nil) +} + struct RNActivityState: Codable { var items: [RNActivityItem]? } @@ -305,11 +312,38 @@ struct BackupPeerEntry: Codable { let port: UInt16 } +struct RNMigrationRetrySchedule: Codable { + let attemptedAt: Date + let delay: TimeInterval + + init(previous: Self?, attemptedAt: Date) { + self.attemptedAt = attemptedAt + delay = min(max((previous?.delay ?? 0) * 2, 60), 3600) + } + + func isDue(at now: Date) -> Bool { + now < attemptedAt || now.timeIntervalSince(attemptedAt) >= delay + } +} + // MARK: - MigrationsService class MigrationsService: ObservableObject { static var shared = MigrationsService() + @MainActor private(set) var retryGeneration = UUID() + + /// A reset invalidates snapshots held across activity lookups and queued writes. + @MainActor + func invalidatePendingRetries() { + retryGeneration = UUID() + } + + @MainActor + func isRetryCurrent(_ generation: UUID) -> Bool { + retryGeneration == generation && !Task.isCancelled + } + private let fileManager = FileManager.default private static let appGroupSuiteName = "group.bitkit" @@ -342,6 +376,7 @@ class MigrationsService: ObservableObject { private static let rnPendingRemoteTransfersKey = "rnPendingRemoteTransfers" private static let rnPendingRemoteBoostsKey = "rnPendingRemoteBoosts" private static let rnPendingMetadataKey = "rnPendingMetadata" + private static let rnRetryScheduleKey = "rnMigrationRetrySchedule" private static let rnPendingRemotePaidOrdersKey = "rnPendingRemotePaidOrders" private static let rnPendingChannelMigrationKey = "rnPendingChannelMigration" private static let rnPendingBlocktankOrderIdsKey = "rnPendingBlocktankOrderIds" @@ -393,8 +428,8 @@ class MigrationsService: ObservableObject { /// Tracks whether post-migration sync work is still pending (persists across app restarts) var needsPostMigrationSync: Bool { - get { UserDefaults.standard.bool(forKey: Self.rnNeedsPostMigrationSyncKey) } - set { UserDefaults.standard.set(newValue, forKey: Self.rnNeedsPostMigrationSyncKey) } + get { userDefaults.bool(forKey: Self.rnNeedsPostMigrationSyncKey) } + set { userDefaults.set(newValue, forKey: Self.rnNeedsPostMigrationSyncKey) } } /// Stored LDK channel data for migration (persisted) @@ -422,14 +457,14 @@ class MigrationsService: ObservableObject { /// Stored transfer info from RN wallet backup for marking on-chain txs as transfers (persisted) var pendingRemoteTransfers: [String: String]? { - get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemoteTransfersKey) as? [String: String] } - set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemoteTransfersKey) } + get { userDefaults.dictionary(forKey: Self.rnPendingRemoteTransfersKey) as? [String: String] } + set { userDefaults.set(newValue, forKey: Self.rnPendingRemoteTransfersKey) } } /// Stored boost info from RN wallet backup for applying boostTxIds to activities (persisted) var pendingRemoteBoosts: [String: String]? { - get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemoteBoostsKey) as? [String: String] } - set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemoteBoostsKey) } + get { userDefaults.dictionary(forKey: Self.rnPendingRemoteBoostsKey) as? [String: String] } + set { userDefaults.set(newValue, forKey: Self.rnPendingRemoteBoostsKey) } } /// Stored metadata for reapplying after on-chain activities are synced (persisted) @@ -440,47 +475,53 @@ class MigrationsService: ObservableObject { /// Stored paid orders from RN backup for creating transfers after wallet starts (persisted) var pendingRemotePaidOrders: [String: String]? { - get { UserDefaults.standard.dictionary(forKey: Self.rnPendingRemotePaidOrdersKey) as? [String: String] } - set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingRemotePaidOrdersKey) } + get { userDefaults.dictionary(forKey: Self.rnPendingRemotePaidOrdersKey) as? [String: String] } + set { userDefaults.set(newValue, forKey: Self.rnPendingRemotePaidOrdersKey) } } /// Stored Blocktank order IDs that couldn't be fetched during migration (offline) (persisted) var pendingBlocktankOrderIds: [String]? { - get { UserDefaults.standard.stringArray(forKey: Self.rnPendingBlocktankOrderIdsKey) } - set { UserDefaults.standard.set(newValue, forKey: Self.rnPendingBlocktankOrderIdsKey) } + get { userDefaults.stringArray(forKey: Self.rnPendingBlocktankOrderIdsKey) } + set { userDefaults.set(newValue, forKey: Self.rnPendingBlocktankOrderIdsKey) } } /// True after we've attempted once to fetch peers from remote backup (so we don't retry every node start). var didAttemptPeerRecovery: Bool { - get { UserDefaults.standard.bool(forKey: Self.rnDidAttemptPeerRecoveryKey) } - set { UserDefaults.standard.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) } + get { userDefaults.bool(forKey: Self.rnDidAttemptPeerRecoveryKey) } + set { userDefaults.set(newValue, forKey: Self.rnDidAttemptPeerRecoveryKey) } } /// True after we've checked for orphaned channel monitors (so we don't retry every node start if all succeeded). var isChannelRecoveryChecked: Bool { - get { UserDefaults.standard.bool(forKey: Self.rnChannelRecoveryCheckedKey) } - set { UserDefaults.standard.set(newValue, forKey: Self.rnChannelRecoveryCheckedKey) } + get { userDefaults.bool(forKey: Self.rnChannelRecoveryCheckedKey) } + set { userDefaults.set(newValue, forKey: Self.rnChannelRecoveryCheckedKey) } } /// True if the user completed RN migration (local or remote). var rnMigrationCompleted: Bool { - UserDefaults.standard.bool(forKey: Self.rnMigrationCompletedKey) + userDefaults.bool(forKey: Self.rnMigrationCompletedKey) } - private init() {} + private let userDefaults: UserDefaults + private let now: () -> Date + + init(userDefaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { + self.userDefaults = userDefaults + self.now = now + } // MARK: - UserDefaults Helpers private func getCodable(forKey key: String) -> T? { - guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + guard let data = userDefaults.data(forKey: key) else { return nil } return try? JSONDecoder().decode(T.self, from: data) } private func setCodable(_ value: (some Codable)?, forKey key: String) { if let value, let data = try? JSONEncoder().encode(value) { - UserDefaults.standard.set(data, forKey: key) + userDefaults.set(data, forKey: key) } else { - UserDefaults.standard.removeObject(forKey: key) + userDefaults.removeObject(forKey: key) } } @@ -554,7 +595,7 @@ extension MigrationsService { extension MigrationsService { var isMigrationChecked: Bool { - UserDefaults.standard.bool(forKey: Self.rnMigrationCheckedKey) + userDefaults.bool(forKey: Self.rnMigrationCheckedKey) } func hasRNWalletData() -> Bool { @@ -630,8 +671,8 @@ extension MigrationsService { 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) + userDefaults.set(true, forKey: Self.rnMigrationCompletedKey) + userDefaults.set(true, forKey: Self.rnMigrationCheckedKey) // Mark that post-migration sync work is needed (will run when node syncs) needsPostMigrationSync = true @@ -680,12 +721,12 @@ extension MigrationsService { private func clearPinSettings() { try? Keychain.delete(key: .securityPin) - UserDefaults.standard.removeObject(forKey: "requirePinForPayments") - UserDefaults.standard.removeObject(forKey: "useBiometrics") - UserDefaults.standard.removeObject(forKey: "pinFailedAttempts") - UserDefaults.standard.removeObject(forKey: "pinOnLaunch") - UserDefaults.standard.removeObject(forKey: "pinOnIdle") - UserDefaults.standard.removeObject(forKey: "pin") + userDefaults.removeObject(forKey: "requirePinForPayments") + userDefaults.removeObject(forKey: "useBiometrics") + userDefaults.removeObject(forKey: "pinFailedAttempts") + userDefaults.removeObject(forKey: "pinOnLaunch") + userDefaults.removeObject(forKey: "pinOnIdle") + userDefaults.removeObject(forKey: "pin") } private func migrateLdkData() async throws { @@ -719,7 +760,7 @@ extension MigrationsService { } func markMigrationChecked() { - UserDefaults.standard.set(true, forKey: Self.rnMigrationCheckedKey) + userDefaults.set(true, forKey: Self.rnMigrationCheckedKey) } // MARK: - RN Data Cleanup @@ -778,6 +819,26 @@ extension MigrationsService { Logger.info("RN cleanup completed", context: "Migration") } + var hasPendingMigrationRetries: Bool { + pendingMetadata != nil || pendingRemoteActivityData != nil || pendingRemoteTransfers != nil || + pendingRemoteBoosts != nil || pendingBlocktankOrderIds != nil || pendingRemotePaidOrders != nil + } + + private var retrySchedule: RNMigrationRetrySchedule? { + get { getCodable(forKey: Self.rnRetryScheduleKey) } + set { setCodable(newValue, forKey: Self.rnRetryScheduleKey) } + } + + var shouldRetryPendingMigrationData: Bool { + hasPendingMigrationRetries && (retrySchedule?.isDue(at: now()) ?? true) + } + + func recordPendingMigrationRetry() { + retrySchedule = hasPendingMigrationRetries + ? RNMigrationRetrySchedule(previous: retrySchedule, attemptedAt: now()) + : nil + } + /// Returns true if all pending migration data has been processed and cleanup can proceed var canCleanupAfterMigration: Bool { // Don't cleanup if there's still pending Blocktank data that needs retry @@ -785,20 +846,27 @@ extension MigrationsService { Logger.debug("Cannot cleanup: pending Blocktank data exists", context: "Migration") return false } + if pendingRemoteTransfers != nil || pendingRemoteBoosts != nil { + return false + } + if let tags = pendingMetadata?.tags, !tags.isEmpty { + Logger.debug("Cannot cleanup: pending metadata tags exist", context: "Migration") + return false + } return true } /// One-time cleanup for transfers created from unpaid/expired Blocktank orders during migration. /// The RN backup's paidOrders map could contain orders that were never actually paid. func cleanupInvalidMigrationTransfers() async { - guard !UserDefaults.standard.bool(forKey: Self.didCleanupInvalidTransfersKey) else { return } + guard !userDefaults.bool(forKey: Self.didCleanupInvalidTransfersKey) else { return } guard rnMigrationCompleted else { return } guard let transfers = try? TransferStorage.shared.getActiveTransfers() else { return } let orderTransfers = transfers.filter { $0.type.isToSpending() && $0.lspOrderId != nil } guard !orderTransfers.isEmpty else { - UserDefaults.standard.set(true, forKey: Self.didCleanupInvalidTransfersKey) + userDefaults.set(true, forKey: Self.didCleanupInvalidTransfersKey) return } @@ -825,12 +893,13 @@ extension MigrationsService { } } - UserDefaults.standard.set(true, forKey: Self.didCleanupInvalidTransfersKey) + userDefaults.set(true, forKey: Self.didCleanupInvalidTransfersKey) Logger.info("Migration transfer cleanup completed", context: "Migration") } /// Clears all persisted pending migration data from UserDefaults private func clearPendingMigrationData() { + retrySchedule = nil pendingChannelMigration = nil pendingRemoteActivityData = nil pendingRemoteTransfers = nil @@ -1219,7 +1288,7 @@ extension MigrationsService { } func applyRNSettings(_ settings: RNSettings) { - let defaults = UserDefaults.standard + let defaults = userDefaults if let currency = settings.selectedCurrency { defaults.set(currency, forKey: "selectedCurrency") @@ -1302,7 +1371,7 @@ extension MigrationsService { } func applyRNAddressTypeSettings(selectedAddressType: String?, addressTypesToMonitor: [String]?) { - let defaults = UserDefaults.standard + let defaults = userDefaults if let selected = selectedAddressType { defaults.set(selected, forKey: "selectedAddressType") @@ -1349,8 +1418,8 @@ extension MigrationsService { if !savedWidgets.isEmpty { do { let encodedData = try JSONEncoder().encode(savedWidgets) - UserDefaults.standard.set(encodedData, forKey: "savedWidgets") - UserDefaults.standard.synchronize() + userDefaults.set(encodedData, forKey: "savedWidgets") + userDefaults.synchronize() let withOptions = savedWidgets.filter { $0.optionsData != nil }.count Logger.info("Migrated \(savedWidgets.count) widgets (\(withOptions) with options)", context: "Migration") } catch { @@ -1360,7 +1429,7 @@ extension MigrationsService { } if let onboarded = widgets.onboardedWidgets { - UserDefaults.standard.set(onboarded, forKey: "hasSeenWidgetsIntro") + userDefaults.set(onboarded, forKey: "hasSeenWidgetsIntro") } } @@ -1388,9 +1457,9 @@ extension MigrationsService { } if !dismissedIds.isEmpty { - let existing = UserDefaults.standard.stringArray(forKey: "dismissedSuggestions") ?? [] + let existing = userDefaults.stringArray(forKey: "dismissedSuggestions") ?? [] let merged = Array(Set(existing + dismissedIds)) - UserDefaults.standard.set(merged, forKey: "dismissedSuggestions") + userDefaults.set(merged, forKey: "dismissedSuggestions") Logger.info("Migrated \(dismissedIds.count) dismissed suggestions", context: "Migration") } } @@ -1550,7 +1619,7 @@ extension MigrationsService { pendingMetadata = metadata // Apply lastUsedTags immediately (doesn't require activities) if let lastUsedTags = metadata.lastUsedTags { - UserDefaults.standard.set(lastUsedTags, forKey: "lastUsedTags") + userDefaults.set(lastUsedTags, forKey: "lastUsedTags") } } else { Logger.debug("No metadata found in MMKV", context: "Migration") @@ -1577,63 +1646,74 @@ extension MigrationsService { Logger.debug("No blocktank data found in MMKV", context: "Migration") } - UserDefaults.standard.set("", forKey: "onchainAddress") + userDefaults.set("", forKey: "onchainAddress") Logger.info("MMKV data migration completed", context: "Migration") } - func reapplyMetadataAfterSync() async { + @MainActor + func reapplyMetadataAfterSync( + includeLocalMetadata: Bool = true, + applyTags: (([String: [String]]) async -> Set)? = nil, + applyTransfers: (([String: String]) async -> [String: String])? = nil, + applyBoosts: (([String: String]) async -> [String: String])? = nil + ) async { + let generation = retryGeneration + guard isRetryCurrent(generation) else { return } // Handle MMKV (local) migration data - if hasRNMmkvData(), let mmkvData = loadRNMmkvData() { + if includeLocalMetadata, hasRNMmkvData(), let mmkvData = loadRNMmkvData() { if let activities = extractRNActivities(from: mmkvData) { await applyOnchainMetadata(activities) + guard isRetryCurrent(generation) else { return } } // Extract and apply wallet backup data (transfers and boosts) if let walletBackup = extractRNWalletBackup(from: mmkvData) { if !walletBackup.transfers.isEmpty { Logger.info("Applying \(walletBackup.transfers.count) local transfer markers", context: "Migration") - await applyRemoteTransfers(walletBackup.transfers) + pendingRemoteTransfers = (pendingRemoteTransfers ?? [:]).merging(walletBackup.transfers) { _, local in local } } if !walletBackup.boosts.isEmpty { Logger.info("Applying \(walletBackup.boosts.count) local boost markers", context: "Migration") - await applyBoostTransactions(walletBackup.boosts) + pendingRemoteBoosts = (pendingRemoteBoosts ?? [:]).merging(walletBackup.boosts) { _, local in local } } } - - if let metadata = extractRNMetadata(from: mmkvData) { - Logger.info("Re-applying MMKV metadata after sync", context: "Migration") - await applyAllMetadata(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) + guard isRetryCurrent(generation) else { return } 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 + let remaining = if let applyTransfers { + await applyTransfers(transfers) + } else { + await applyRemoteTransfers(transfers) + } + guard isRetryCurrent(generation) else { return } + pendingRemoteTransfers = remaining.isEmpty ? nil : remaining } // Handle remote backup boosts (apply boostTxIds to activities) if let boosts = pendingRemoteBoosts { Logger.info("Applying \(boosts.count) remote boost markers", context: "Migration") - await applyBoostTransactions(boosts) - pendingRemoteBoosts = nil + let remaining = if let applyBoosts { + await applyBoosts(boosts) + } else { + await applyBoostTransactions(boosts) + } + guard isRetryCurrent(generation) else { return } + pendingRemoteBoosts = remaining.isEmpty ? nil : remaining } - // Apply stored metadata (all tags after activities are imported) - if let metadata = pendingMetadata { - Logger.info("Applying stored metadata after sync", context: "Migration") - await applyAllMetadata(metadata) - pendingMetadata = nil - } + await retryPendingMetadata(applyTags: applyTags) + guard isRetryCurrent(generation) else { return } // Handle pending Blocktank orders that couldn't be fetched during migration (offline) var blocktankFetchFailed = false @@ -1641,14 +1721,17 @@ extension MigrationsService { Logger.info("Retrying \(orderIds.count) pending Blocktank orders", context: "Migration") do { let fetchedOrders = try await CoreService.shared.blocktank.orders(orderIds: orderIds, filter: nil, refresh: true) + guard isRetryCurrent(generation) else { return } if !fetchedOrders.isEmpty { try await CoreService.shared.blocktank.upsertOrdersList(fetchedOrders) + guard isRetryCurrent(generation) else { return } Logger.info("Upserted \(fetchedOrders.count) Blocktank orders after retry", context: "Migration") // Also create transfers for paid orders using the fetched orders if let paidOrders = pendingRemotePaidOrders, !paidOrders.isEmpty { Logger.info("Creating transfers for \(paidOrders.count) paid orders", context: "Migration") await createTransfersForPaidOrders(paidOrdersMap: paidOrders, orders: fetchedOrders) + guard isRetryCurrent(generation) else { return } pendingRemotePaidOrders = nil } } @@ -1665,23 +1748,36 @@ extension MigrationsService { if !blocktankFetchFailed, let paidOrders = pendingRemotePaidOrders { Logger.info("Applying \(paidOrders.count) remote paid orders", context: "Migration") await applyRemotePaidOrders(paidOrders) + guard isRetryCurrent(generation) else { return } pendingRemotePaidOrders = nil } } - private func applyRemoteTransfers(_ transfers: [String: String]) async { + @MainActor + func applyRemoteTransfers( + _ transfers: [String: String], + getActivity: (String) async -> OnchainActivity? = { try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: $0) }, + updateActivity: (OnchainActivity) async throws -> Void = { + try await CoreService.shared.activity.update(id: $0.id, activity: .onchain($0)) + } + ) async -> [String: String] { + let generation = retryGeneration + var remaining = transfers var applied = 0 for (txId, channelId) in transfers { - guard var onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: txId) else { - continue - } + guard isRetryCurrent(generation) else { return remaining } + let activity = await getActivity(txId) + guard isRetryCurrent(generation) else { return remaining } + guard var onchain = activity else { continue } onchain.isTransfer = true onchain.channelId = channelId do { - try await CoreService.shared.activity.update(id: onchain.id, activity: .onchain(onchain)) + try await updateActivity(onchain) + guard isRetryCurrent(generation) else { return remaining } + remaining.removeValue(forKey: txId) applied += 1 } catch { Logger.error("Failed to mark tx \(txId) as transfer: \(error)", context: "Migration") @@ -1689,6 +1785,7 @@ extension MigrationsService { } Logger.info("Applied \(applied)/\(transfers.count) transfer markers", context: "Migration") + return remaining } private func applyRemotePaidOrders(_ paidOrders: [String: String]) async { @@ -1707,12 +1804,24 @@ extension MigrationsService { } } - private func applyBoostTransactions(_ boosts: [String: String]) async { + @MainActor + func applyBoostTransactions( + _ boosts: [String: String], + getActivity: (String) async -> OnchainActivity? = { try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: $0) }, + updateActivity: (OnchainActivity) async throws -> Void = { + try await CoreService.shared.activity.update(id: $0.id, activity: .onchain($0)) + } + ) async -> [String: String] { + let generation = retryGeneration + var remaining = boosts var applied = 0 for (oldTxId, newTxId) in boosts { - let oldOnchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: oldTxId) - let newOnchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: newTxId) + guard isRetryCurrent(generation) else { return remaining } + let oldOnchain = await getActivity(oldTxId) + guard isRetryCurrent(generation) else { return remaining } + let newOnchain = await getActivity(newTxId) + guard isRetryCurrent(generation) else { return remaining } if let oldOnchain, var newOnchain { var parentOnchain = oldOnchain @@ -1725,8 +1834,11 @@ extension MigrationsService { newOnchain.boostTxIds.removeAll { $0 == oldTxId } do { - try await CoreService.shared.activity.update(id: parentOnchain.id, activity: .onchain(parentOnchain)) - try await CoreService.shared.activity.update(id: newOnchain.id, activity: .onchain(newOnchain)) + try await updateActivity(parentOnchain) + guard isRetryCurrent(generation) else { return remaining } + try await updateActivity(newOnchain) + guard isRetryCurrent(generation) else { return remaining } + remaining.removeValue(forKey: oldTxId) applied += 1 } catch { Logger.error("Failed to apply CPFP boost for parent \(oldTxId) / child \(newTxId): \(error)", context: "Migration") @@ -1738,7 +1850,9 @@ extension MigrationsService { newOnchain.isBoosted = true do { - try await CoreService.shared.activity.update(id: newOnchain.id, activity: .onchain(newOnchain)) + try await updateActivity(newOnchain) + guard isRetryCurrent(generation) else { return remaining } + remaining.removeValue(forKey: oldTxId) applied += 1 } catch { Logger.error("Failed to apply RBF boost for tx \(newTxId): \(error)", context: "Migration") @@ -1747,50 +1861,62 @@ extension MigrationsService { } Logger.info("Applied \(applied)/\(boosts.count) boost markers", context: "Migration") + return remaining } - private func applyAllMetadata(_ metadata: RNMetadata) async { - if let tags = metadata.tags, !tags.isEmpty { - await applyPendingTags(tags) + @MainActor + func retryPendingMetadata(applyTags: (([String: [String]]) async -> Set)? = nil) async { + let generation = retryGeneration + guard isRetryCurrent(generation), let metadata = pendingMetadata else { return } + let unapplied: Set = if let applyTags { + await applyTags(metadata.tags ?? [:]) + } else { + await applyPendingTags(metadata.tags ?? [:]) } - + guard isRetryCurrent(generation) else { return } if let lastUsedTags = metadata.lastUsedTags { - UserDefaults.standard.set(lastUsedTags, forKey: "lastUsedTags") + userDefaults.set(lastUsedTags, forKey: "lastUsedTags") } + pendingMetadata = rnMetadataRetainingUnappliedTags(metadata, unappliedActivityIds: unapplied) } - private func applyPendingTags(_ tags: [String: [String]]) async { - var applied = 0 + @MainActor + func applyPendingTags( + _ tags: [String: [String]], + resolveActivityId: (String) async throws -> String? = { activityId in + if let onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: activityId) { + return onchain.id + } + guard let activity = try? await CoreService.shared.activity.getActivity(id: activityId) else { return nil } + switch activity { + case .lightning: return activityId + case let .onchain(onchain): return onchain.id + } + }, + upsertTags: ([ActivityTags]) async throws -> Void = { tags in + try await CoreService.shared.activity.upsertTags(tags) + } + ) async -> Set { + let generation = retryGeneration + var unapplied = Set() for (activityId, tagList) in tags { + guard isRetryCurrent(generation) else { return Set(tags.keys) } do { - // Try to find on-chain activity by txId first - if let onchain = try? await CoreService.shared.activity.getOnchainActivityByTxId(txid: activityId) { - try await CoreService.shared.activity.upsertTags([ - ActivityTags(walletId: WalletScope.default, activityId: onchain.id, tags: tagList), - ]) - applied += 1 - } else if let activity = try? await CoreService.shared.activity.getActivity(id: activityId) { - // Found activity by ID - handle both lightning and on-chain - switch activity { - case .lightning: - try await CoreService.shared.activity.upsertTags([ - ActivityTags(walletId: WalletScope.default, activityId: activityId, tags: tagList), - ]) - applied += 1 - case let .onchain(onchain): - try await CoreService.shared.activity.upsertTags([ - ActivityTags(walletId: WalletScope.default, activityId: onchain.id, tags: tagList), - ]) - applied += 1 - } - } else { - Logger.warn("Activity not found for tags: id=\(activityId)", context: "Migration") + let resolved = try await resolveActivityId(activityId) + guard isRetryCurrent(generation) else { return Set(tags.keys) } + guard let resolvedId = resolved else { + unapplied.insert(activityId) + continue } + try await upsertTags([ + ActivityTags(walletId: WalletScope.default, activityId: resolvedId, tags: tagList), + ]) } catch { Logger.error("Failed to apply pending tag for \(activityId): \(error)", context: "Migration") + unapplied.insert(activityId) } } - Logger.info("Applied \(applied)/\(tags.count) pending tags", context: "Migration") + return unapplied } private func applyOnchainMetadata(_ items: [RNActivityItem]) async { diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 0752ccb87..d95b9037c 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -15,7 +15,9 @@ enum AppReset { toastType: Toast.ToastType = .success ) async throws { try await PaykitSdkService.shared.withWalletWipe { - try await wipeLocal(app: app, wallet: wallet, session: session, toastType: toastType) + try await app.withMigrationSyncPausedForWipe { + try await wipeLocal(app: app, wallet: wallet, session: session, toastType: toastType) + } } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1a54209b8..15953addb 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -85,8 +85,28 @@ struct ScanPaymentOperations { } } +@MainActor +struct PostMigrationSyncOperations { + var syncPayments: () async -> Void + var markActivitiesSeen: () async -> Void + var reapplyMetadata: (Bool) async -> Void + + static func live(migrations: MigrationsService) -> Self { + Self( + syncPayments: { try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? []) }, + markActivitiesSeen: { _ = await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() }, + reapplyMetadata: { await migrations.reapplyMetadataAfterSync(includeLocalMetadata: $0) } + ) + } +} + @MainActor class AppViewModel: ObservableObject { + private var isSyncingMigration = false + private var isResettingMigration = false + private(set) var postMigrationSyncTask: Task? + private let migrations: MigrationsService + private let postMigrationSyncOperations: PostMigrationSyncOperations // Send flow @Published var scannedLightningInvoice: LightningInvoice? @Published var scannedOnchainInvoice: OnChainInvoice? @@ -239,8 +259,12 @@ class AppViewModel: ObservableObject { coreService: CoreService = .shared, sheetViewModel: SheetViewModel, navigationViewModel: NavigationViewModel, - scanPaymentOperations: ScanPaymentOperations? = nil + scanPaymentOperations: ScanPaymentOperations? = nil, + migrations: MigrationsService = .shared, + postMigrationSyncOperations: PostMigrationSyncOperations? = nil ) { + self.migrations = migrations + self.postMigrationSyncOperations = postMigrationSyncOperations ?? .live(migrations: migrations) self.lightningService = lightningService self.coreService = coreService self.sheetViewModel = sheetViewModel @@ -1367,6 +1391,64 @@ extension AppViewModel { } } + /// Invalidates captured retries and drains queued activity writes before databases/defaults are wiped. + func cancelMigrationSyncForWipe() async { + isResettingMigration = true + migrations.invalidatePendingRetries() + postMigrationSyncTask?.cancel() + await postMigrationSyncTask?.value + } + + /// Keep the retiring app paused on success, but resume retained metadata if the wipe aborts. + func withMigrationSyncPausedForWipe(_ wipe: () async throws -> Void) async rethrows { + await cancelMigrationSyncForWipe() + do { + try await wipe() + } catch { + isResettingMigration = false + throw error + } + } + + private func beginPostMigrationSync() { + guard !isResettingMigration, !isSyncingMigration, + migrations.needsPostMigrationSync || migrations.shouldRetryPendingMigrationData + else { return } + let completingMigration = migrations.needsPostMigrationSync + let generation = migrations.retryGeneration + isSyncingMigration = true + postMigrationSyncTask = Task { @MainActor in + defer { + self.isSyncingMigration = false + self.postMigrationSyncTask = nil + } + guard migrations.isRetryCurrent(generation) else { return } + if completingMigration { + await postMigrationSyncOperations.syncPayments() + guard migrations.isRetryCurrent(generation) else { return } + await postMigrationSyncOperations.markActivitiesSeen() + guard migrations.isRetryCurrent(generation) else { return } + } + await postMigrationSyncOperations.reapplyMetadata(completingMigration) + guard migrations.isRetryCurrent(generation) else { return } + migrations.needsPostMigrationSync = false + migrations.recordPendingMigrationRetry() + + if migrations.canCleanupAfterMigration { + if migrations.isShowingMigrationLoading { + try? await LightningService.shared.restart() + guard migrations.isRetryCurrent(generation) else { return } + } + SettingsViewModel.shared.updatePinEnabledState() + migrations.cleanupAfterMigration() + } else { + Logger.info("Post-migration sync incomplete, pending data will retry with backoff", context: "AppViewModel") + } + migrations.isRestoringFromRNRemoteBackup = false + migrations.isShowingMigrationLoading = false + } + } + func handleLdkNodeEvent(_ event: Event) { switch event { case let .paymentReceived(paymentId, _, amountMsat, _): @@ -1609,30 +1691,7 @@ extension AppViewModel { } } - if MigrationsService.shared.needsPostMigrationSync { - Task { @MainActor in - try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? []) - await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() - await MigrationsService.shared.reapplyMetadataAfterSync() - - if MigrationsService.shared.canCleanupAfterMigration { - if MigrationsService.shared.isShowingMigrationLoading { - try? await LightningService.shared.restart() - } - - SettingsViewModel.shared.updatePinEnabledState() - MigrationsService.shared.cleanupAfterMigration() - MigrationsService.shared.needsPostMigrationSync = false - MigrationsService.shared.isRestoringFromRNRemoteBackup = false - } else { - Logger.info("Post-migration sync incomplete, will retry on next sync", context: "AppViewModel") - } - - if MigrationsService.shared.isShowingMigrationLoading { - MigrationsService.shared.isShowingMigrationLoading = false - } - } - } + beginPostMigrationSync() // MARK: Balance Events diff --git a/BitkitTests/RNMigrationRetryScheduleTests.swift b/BitkitTests/RNMigrationRetryScheduleTests.swift new file mode 100644 index 000000000..7057c0ccb --- /dev/null +++ b/BitkitTests/RNMigrationRetryScheduleTests.swift @@ -0,0 +1,75 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class RNMigrationRetryScheduleTests: XCTestCase { + func testBackoffSurvivesReloadAndStopsSyncFrequencyRetries() throws { + let suite = "RNMigrationBackoff.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var now = Date(timeIntervalSince1970: 1000) + let service = Bitkit.MigrationsService(userDefaults: defaults, now: { now }) + service.pendingMetadata = Bitkit.RNMetadata(tags: ["missing": ["retained"]]) + XCTAssertTrue(service.shouldRetryPendingMigrationData) + service.recordPendingMigrationRetry() + + for _ in 0 ..< 5 { + now.addTimeInterval(10) + XCTAssertFalse(service.shouldRetryPendingMigrationData) + } + let reloaded = Bitkit.MigrationsService(userDefaults: defaults, now: { now }) + XCTAssertFalse(reloaded.shouldRetryPendingMigrationData) + XCTAssertEqual(reloaded.pendingMetadata?.tags, ["missing": ["retained"]]) + now.addTimeInterval(10) + XCTAssertTrue(reloaded.shouldRetryPendingMigrationData) + reloaded.recordPendingMigrationRetry() + now.addTimeInterval(119) + XCTAssertFalse(service.shouldRetryPendingMigrationData) + now.addTimeInterval(1) + XCTAssertTrue(service.shouldRetryPendingMigrationData) + } + + func testRetriesReachHourlyCapWithoutDroppingMarkersAndRecoverLateTags() async throws { + let suite = "RNMigrationBackoffRecovery.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var now = Date(timeIntervalSince1970: 1000) + let service = Bitkit.MigrationsService(userDefaults: defaults, now: { now }) + service.pendingMetadata = Bitkit.RNMetadata(tags: ["late": ["retained"]]) + for delay in [60.0, 120, 240, 480, 960, 1920, 3600, 3600] { + XCTAssertTrue(service.shouldRetryPendingMigrationData) + service.recordPendingMigrationRetry() + now.addTimeInterval(delay - 1) + XCTAssertFalse(service.shouldRetryPendingMigrationData) + now.addTimeInterval(1) + XCTAssertTrue(service.shouldRetryPendingMigrationData) + XCTAssertEqual(service.pendingMetadata?.tags, ["late": ["retained"]]) + } + var applied: [String: [String]] = [:] + await service.retryPendingMetadata { tags in + applied = tags + return [] + } + service.recordPendingMigrationRetry() + XCTAssertEqual(applied, ["late": ["retained"]]) + XCTAssertFalse(service.hasPendingMigrationRetries) + XCTAssertNil(defaults.data(forKey: "rnMigrationRetrySchedule")) + service.pendingRemoteTransfers = ["new-transfer": "channel"] + XCTAssertTrue(service.shouldRetryPendingMigrationData, "a completed schedule must not delay newly pending data") + } + + func testClockRollbackDoesNotStarveRetries() throws { + let suite = "RNMigrationBackoffClock.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var now = Date(timeIntervalSince1970: 1000) + let service = Bitkit.MigrationsService(userDefaults: defaults, now: { now }) + service.pendingRemoteTransfers = ["transfer": "channel"] + service.recordPendingMigrationRetry() + XCTAssertFalse(service.shouldRetryPendingMigrationData) + now.addTimeInterval(-1) + XCTAssertTrue(service.shouldRetryPendingMigrationData) + service.recordPendingMigrationRetry() + XCTAssertFalse(service.shouldRetryPendingMigrationData) + } +} diff --git a/BitkitTests/RNMigrationSyncLifecycleTests.swift b/BitkitTests/RNMigrationSyncLifecycleTests.swift new file mode 100644 index 000000000..95ea4b862 --- /dev/null +++ b/BitkitTests/RNMigrationSyncLifecycleTests.swift @@ -0,0 +1,287 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +@MainActor +final class RNMigrationSyncLifecycleTests: XCTestCase { + override func setUp() { + super.setUp() + // Keep the event caller from scheduling the host wallet's unrelated restore sweep/prune. + snapshotAppDefaults("pendingRestoreActivitySeenSince", "pendingRestoreAddressTypePrune") + SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 + SettingsViewModel.shared.pendingRestoreAddressTypePrune = false + } + + private final class Gate { + let entered = XCTestExpectation(description: "migration operation suspended") + private var continuation: CheckedContinuation? + + func suspend() async { + await withCheckedContinuation { + continuation = $0 + entered.fulfill() + } + } + + func resume() { + continuation?.resume() + continuation = nil + } + } + + func testWipeDuringTagLookupDoesNotWriteOrResurrectOldMetadata() async throws { + let suite = "RNMigrationWipe.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let migrations = Bitkit.MigrationsService(userDefaults: defaults) + migrations.pendingMetadata = Bitkit.RNMetadata(tags: ["old": ["old-tag"]], lastUsedTags: ["old-tag"]) + let gate = Gate() + var writes = 0 + let task = Task { + await migrations.retryPendingMetadata { tags in + await migrations.applyPendingTags(tags, resolveActivityId: { id in + await gate.suspend() + return id + }, upsertTags: { _ in writes += 1 }) + } + } + await fulfillment(of: [gate.entered], timeout: 2) + migrations.invalidatePendingRetries() + defaults.removePersistentDomain(forName: suite) + migrations.pendingMetadata = Bitkit.RNMetadata(tags: ["new-wallet": ["new-tag"]]) + gate.resume() + await task.value + + XCTAssertEqual(writes, 0) + XCTAssertNil(defaults.array(forKey: "lastUsedTags")) + XCTAssertEqual(migrations.pendingMetadata?.tags, ["new-wallet": ["new-tag"]]) + XCTAssertEqual(Bitkit.MigrationsService(userDefaults: defaults).pendingMetadata?.tags, ["new-wallet": ["new-tag"]]) + } + + func testWipeDuringTransferLookupDoesNotWriteOrResurrectMarkers() async throws { + try await assertMarkerLookupInvalidated(isBoost: false) + } + + func testWipeDuringBoostLookupDoesNotWriteOrResurrectMarkers() async throws { + try await assertMarkerLookupInvalidated(isBoost: true) + } + + private func assertMarkerLookupInvalidated(isBoost: Bool) async throws { + let suite = "RNMigrationMarkerWipe.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let migrations = Bitkit.MigrationsService(userDefaults: defaults) + if isBoost { + migrations.pendingRemoteBoosts = ["parent": "child"] + } else { + migrations.pendingRemoteTransfers = ["transfer": "channel"] + } + let gate = Gate() + var writes = 0 + let get: (String) async -> OnchainActivity? = { id in + await gate.suspend() + return self.onchain(id: id) + } + let update: (OnchainActivity) async throws -> Void = { _ in writes += 1 } + let task = Task { + await migrations.reapplyMetadataAfterSync( + includeLocalMetadata: false, + applyTransfers: { await migrations.applyRemoteTransfers($0, getActivity: get, updateActivity: update) }, + applyBoosts: { await migrations.applyBoostTransactions($0, getActivity: get, updateActivity: update) } + ) + } + await fulfillment(of: [gate.entered], timeout: 2) + migrations.invalidatePendingRetries() + defaults.removePersistentDomain(forName: suite) + migrations.pendingRemoteTransfers = ["new-transfer": "new-channel"] + migrations.pendingRemoteBoosts = ["new-parent": "new-child"] + gate.resume() + await task.value + + XCTAssertEqual(writes, 0) + let reloaded = Bitkit.MigrationsService(userDefaults: defaults) + XCTAssertEqual(reloaded.pendingRemoteTransfers, ["new-transfer": "new-channel"]) + XCTAssertEqual(reloaded.pendingRemoteBoosts, ["new-parent": "new-child"]) + } + + func testOverlappingSyncEventsCompleteOnceAndBackgroundRetryLeavesNewReceiveUnseen() async throws { + var now = Date(timeIntervalSince1970: 1000) + let suite = "RNMigrationCompletion.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let migrations = Bitkit.MigrationsService(userDefaults: defaults, now: { now }) + migrations.needsPostMigrationSync = true + migrations.isShowingMigrationLoading = true + migrations.isRestoringFromRNRemoteBackup = true + migrations.pendingMetadata = Bitkit.RNMetadata(tags: ["missing": ["retained"]]) + migrations.recordPendingMigrationRetry() + XCTAssertFalse(migrations.shouldRetryPendingMigrationData) + let gate = Gate() + var syncCount = 0 + var sweepCount = 0 + var localPasses: [Bool] = [] + var seen = ["history": false] + let operations = PostMigrationSyncOperations( + syncPayments: { + syncCount += 1 + if syncCount == 1 { await gate.suspend() } + }, + markActivitiesSeen: { + sweepCount += 1 + for id in seen.keys { + seen[id] = true + } + }, + reapplyMetadata: { includeLocal in + localPasses.append(includeLocal) + // Exercise retained tags only; never read the host simulator's RN files or activity DB. + await migrations.retryPendingMetadata { Set($0.keys) } + } + ) + let app = AppViewModel( + sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel(), + migrations: migrations, postMigrationSyncOperations: operations + ) + let event = Event.syncCompleted(syncType: .onchainWallet, syncedBlockHeight: 1) + app.handleLdkNodeEvent(event) + let completion = try XCTUnwrap(app.postMigrationSyncTask) + app.handleLdkNodeEvent(event) + await fulfillment(of: [gate.entered], timeout: 2) + app.handleLdkNodeEvent(event) + XCTAssertEqual(syncCount, 1) + XCTAssertTrue(migrations.needsPostMigrationSync) + gate.resume() + await completion.value + + XCTAssertEqual(sweepCount, 1) + XCTAssertEqual(seen["history"], true) + XCTAssertFalse(migrations.needsPostMigrationSync) + XCTAssertFalse(migrations.isShowingMigrationLoading) + XCTAssertFalse(migrations.isRestoringFromRNRemoteBackup) + XCTAssertTrue(migrations.hasPendingMigrationRetries) + XCTAssertTrue(AppViewModel.shouldPresentConfirmedOnlyReceive( + confirmationTime: UInt64(Date().timeIntervalSince1970), blockHeight: 2, + isMigrating: migrations.isShowingMigrationLoading || migrations.needsPostMigrationSync, + restoreSyncedBlockHeight: 0 + )) + + seen["new-receive"] = false + app.handleLdkNodeEvent(event) + XCTAssertNil(app.postMigrationSyncTask, "sync events during backoff must not start a pass") + now.addTimeInterval(120) + app.handleLdkNodeEvent(event) + let retry = try XCTUnwrap(app.postMigrationSyncTask) + await retry.value + XCTAssertEqual(syncCount, 1, "metadata retries must not rebuild payment history or trigger its backup") + XCTAssertEqual(sweepCount, 1) + XCTAssertEqual(localPasses, [true, false]) + XCTAssertEqual(seen["new-receive"], false) + } + + func testResetDrainsAnInFlightWriteAndBlocksFurtherSyncEvents() async throws { + let suite = "RNMigrationDrain.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let migrations = Bitkit.MigrationsService(userDefaults: defaults) + migrations.needsPostMigrationSync = true + let gate = Gate() + var finishedWrite = false + var resetReturned = false + var reapplyCount = 0 + let app = AppViewModel( + sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel(), + migrations: migrations, postMigrationSyncOperations: PostMigrationSyncOperations( + syncPayments: {}, markActivitiesSeen: {}, reapplyMetadata: { _ in + reapplyCount += 1 + await gate.suspend() + finishedWrite = true + } + ) + ) + let event = Event.syncCompleted(syncType: .onchainWallet, syncedBlockHeight: 1) + app.handleLdkNodeEvent(event) + await fulfillment(of: [gate.entered], timeout: 2) + let reset = Task { + await app.cancelMigrationSyncForWipe() + XCTAssertTrue(finishedWrite, "reset must drain existing writes before wiping storage") + resetReturned = true + } + while app.postMigrationSyncTask?.isCancelled == false { + await Task.yield() + } + XCTAssertFalse(resetReturned) + app.handleLdkNodeEvent(event) + gate.resume() + await reset.value + XCTAssertTrue(resetReturned) + XCTAssertTrue(migrations.needsPostMigrationSync, "the cancelled pass must not alter completion state") + XCTAssertEqual(reapplyCount, 1) + } + + func testFailedWipeDrainsCancelledWorkThenResumesPendingRetries() async throws { + let suite = "RNMigrationFailedWipe.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let migrations = Bitkit.MigrationsService(userDefaults: defaults) + migrations.needsPostMigrationSync = true + migrations.pendingMetadata = Bitkit.RNMetadata(tags: ["missing": ["retained"]]) + let gate = Gate() + var syncCount = 0 + var retryCount = 0 + let app = AppViewModel( + sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel(), + migrations: migrations, postMigrationSyncOperations: PostMigrationSyncOperations( + syncPayments: { + syncCount += 1 + if syncCount == 1 { await gate.suspend() } + }, + markActivitiesSeen: {}, + reapplyMetadata: { _ in + retryCount += 1 + await migrations.retryPendingMetadata { Set($0.keys) } + } + ) + ) + let event = Event.syncCompleted(syncType: .onchainWallet, syncedBlockHeight: 1) + app.handleLdkNodeEvent(event) + await fulfillment(of: [gate.entered], timeout: 2) + let wipeEntered = expectation(description: "wipe started after cancelled task drained") + let reset = Task { + do { + try await app.withMigrationSyncPausedForWipe { + XCTAssertNil(app.postMigrationSyncTask) + wipeEntered.fulfill() + throw NSError(domain: "wipe failed", code: 1) + } + XCTFail("wipe failure must propagate") + } catch {} + } + while app.postMigrationSyncTask?.isCancelled == false { + await Task.yield() + } + app.handleLdkNodeEvent(event) + XCTAssertEqual(syncCount, 1) + gate.resume() + await reset.value + await fulfillment(of: [wipeEntered], timeout: 2) + XCTAssertEqual(retryCount, 0) + XCTAssertEqual(migrations.pendingMetadata?.tags, ["missing": ["retained"]]) + app.handleLdkNodeEvent(event) + let retry = try XCTUnwrap(app.postMigrationSyncTask) + await retry.value + XCTAssertEqual(syncCount, 2) + XCTAssertEqual(retryCount, 1) + XCTAssertEqual(migrations.pendingMetadata?.tags, ["missing": ["retained"]]) + } + + private func onchain(id: String) -> OnchainActivity { + OnchainActivity( + walletId: "wallet0", id: id, txType: .received, txId: id, + value: 1000, fee: 0, feeRate: 1, address: "", confirmed: true, + timestamp: 1, isBoosted: false, boostTxIds: [], isTransfer: false, + doesExist: true, confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil + ) + } +} diff --git a/BitkitTests/RNMigrationTagRetentionTests.swift b/BitkitTests/RNMigrationTagRetentionTests.swift new file mode 100644 index 000000000..cca59b143 --- /dev/null +++ b/BitkitTests/RNMigrationTagRetentionTests.swift @@ -0,0 +1,134 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +final class RNMigrationTagRetentionTests: XCTestCase { + func testTransferAndBoostMarkersSurviveMissingActivitiesAndReload() async throws { + let suite = "RNMigrationMarkers.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let service = MigrationsService(userDefaults: defaults) + service.pendingRemoteTransfers = ["transfer": "channel"] + service.pendingRemoteBoosts = ["parent": "child"] + var activities: [String: OnchainActivity] = [:] + let get: (String) async -> OnchainActivity? = { activities[$0] } + let update: (OnchainActivity) async throws -> Void = { activities[$0.txId] = $0 } + let transfers: ([String: String]) async -> [String: String] = { + await service.applyRemoteTransfers($0, getActivity: get, updateActivity: update) + } + let boosts: ([String: String]) async -> [String: String] = { + await service.applyBoostTransactions($0, getActivity: get, updateActivity: update) + } + + await service.reapplyMetadataAfterSync(includeLocalMetadata: false, applyTransfers: transfers, applyBoosts: boosts) + XCTAssertEqual(service.pendingRemoteTransfers, ["transfer": "channel"]) + XCTAssertEqual(service.pendingRemoteBoosts, ["parent": "child"]) + XCTAssertFalse(service.canCleanupAfterMigration) + XCTAssertFalse(service.needsPostMigrationSync) + + let reloaded = MigrationsService(userDefaults: defaults) + for id in ["transfer", "parent", "child"] { + activities[id] = onchain(id: id) + } + await reloaded.reapplyMetadataAfterSync(includeLocalMetadata: false, applyTransfers: transfers, applyBoosts: boosts) + XCTAssertNil(reloaded.pendingRemoteTransfers) + XCTAssertNil(reloaded.pendingRemoteBoosts) + XCTAssertTrue(reloaded.canCleanupAfterMigration) + XCTAssertEqual(activities["transfer"]?.channelId, "channel") + XCTAssertEqual(activities["transfer"]?.isTransfer, true) + XCTAssertEqual(activities["parent"]?.boostTxIds, ["child"]) + } + + func testFailedMarkerWritesRemainPending() async { + let service = MigrationsService() + let update: (OnchainActivity) async throws -> Void = { _ in throw NSError(domain: "test", code: 1) } + let transfer = ["transfer": "channel"] + let boost = ["parent": "child"] + let pendingTransfers = await service.applyRemoteTransfers(transfer, getActivity: { self.onchain(id: $0) }, updateActivity: update) + let pendingBoosts = await service.applyBoostTransactions(boost, getActivity: { self.onchain(id: $0) }, updateActivity: update) + XCTAssertEqual(pendingTransfers, transfer) + XCTAssertEqual(pendingBoosts, boost) + } + + private func onchain(id: String) -> OnchainActivity { + OnchainActivity( + walletId: "wallet0", id: id, txType: .received, txId: id, + value: 1000, fee: 0, feeRate: 1, address: "", confirmed: true, + timestamp: 1, isBoosted: false, boostTxIds: [], isTransfer: false, + doesExist: true, confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil + ) + } + + func testTwoSyncsRetainMissingTagsWithoutReplayingAppliedTags() async throws { + let suite = "RNMigrationTagRetentionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let service = MigrationsService(userDefaults: defaults) + service.pendingMetadata = RNMetadata(tags: ["known": ["sent"], "missing": ["received"]]) + var activities = Set(["known"]) + var writes: [String] = [] + let apply: ([String: [String]]) async -> Set = { tags in + await service.applyPendingTags(tags, resolveActivityId: { id in + activities.contains(id) ? id : nil + }, upsertTags: { tags in + writes.append(contentsOf: tags.map(\.activityId)) + }) + } + + await service.reapplyMetadataAfterSync(includeLocalMetadata: false, applyTags: apply) + XCTAssertEqual(service.pendingMetadata?.tags, ["missing": ["received"]]) + XCTAssertFalse(service.canCleanupAfterMigration) + XCTAssertTrue(service.hasPendingMigrationRetries) + XCTAssertFalse(service.needsPostMigrationSync) + XCTAssertEqual(writes, ["known"]) + + let reloaded = MigrationsService(userDefaults: defaults) + XCTAssertEqual(reloaded.pendingMetadata?.tags, ["missing": ["received"]]) + activities.insert("missing") + await reloaded.reapplyMetadataAfterSync(includeLocalMetadata: false, applyTags: apply) + XCTAssertNil(reloaded.pendingMetadata) + XCTAssertTrue(reloaded.canCleanupAfterMigration) + XCTAssertFalse(reloaded.hasPendingMigrationRetries) + XCTAssertEqual(writes, ["known", "missing"]) + } + + func testFailedTagWriteStaysPending() async throws { + let suite = "RNMigrationTagRetentionTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let service = MigrationsService(userDefaults: defaults) + service.pendingMetadata = RNMetadata(tags: ["known": ["sent"]]) + await service.retryPendingMetadata { tags in + await service.applyPendingTags(tags, resolveActivityId: { $0 }, upsertTags: { _ in + throw NSError(domain: "test", code: 1) + }) + } + XCTAssertEqual(service.pendingMetadata?.tags, ["known": ["sent"]]) + XCTAssertFalse(service.canCleanupAfterMigration) + } + + func testKeepsOnlyTagsWhoseActivityIsMissing() { + let metadata = RNMetadata( + tags: ["known": ["sent"], "missing": ["received"]], + lastUsedTags: ["sent"] + ) + + let remaining = rnMetadataRetainingUnappliedTags(metadata, unappliedActivityIds: ["missing"]) + + XCTAssertEqual(remaining?.tags, ["missing": ["received"]]) + XCTAssertNil(remaining?.lastUsedTags) + } + + func testDropsMetadataWhenEveryTagApplied() { + let metadata = RNMetadata(tags: ["known": ["sent"]], lastUsedTags: ["sent"]) + + XCTAssertNil(rnMetadataRetainingUnappliedTags(metadata, unappliedActivityIds: [])) + } + + func testDropsMetadataWhenThereAreNoTags() { + let metadata = RNMetadata(tags: nil, lastUsedTags: ["sent"]) + + XCTAssertNil(rnMetadataRetainingUnappliedTags(metadata, unappliedActivityIds: ["missing"])) + } +} diff --git a/changelog.d/next/852.fixed.md b/changelog.d/next/852.fixed.md new file mode 100644 index 000000000..4ffa80859 --- /dev/null +++ b/changelog.d/next/852.fixed.md @@ -0,0 +1 @@ +Activity metadata restored from a React Native wallet stays saved for later recovery without repeatedly rebuilding payment history or uploading unchanged backups.