From cccba9571d66c575f4c29a927345b0002a8663e1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 16:22:20 -0500 Subject: [PATCH 1/2] fix: keep wallet backups consistent during paykit activity --- Bitkit/Services/BackupService.swift | 386 +++++++++--------- .../PrivatePaykitService+Backup.swift | 12 +- Bitkit/Services/PubkyService.swift | 34 +- BitkitTests/BackupServiceTests.swift | 249 +++++++++++ BitkitTests/PaykitSdkOperationLockTests.swift | 57 +++ changelog.d/next/950.fixed.md | 1 + .../wallet-snapshot-consistency.md | 32 ++ 7 files changed, 582 insertions(+), 189 deletions(-) create mode 100644 changelog.d/next/950.fixed.md create mode 100644 journeys/backup-restore/wallet-snapshot-consistency.md diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 78fccefba..f15dad1a5 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -92,12 +92,23 @@ struct WalletBackupRestoreGate { class BackupService { static let shared = BackupService() + enum BackupOutcome { + case completed + case snapshotChanged + case busy + case failed + + var canRetryPendingWalletBackup: Bool { + self == .completed || self == .snapshotChanged || self == .busy + } + } + private let vssBackupClient = VssBackupClient.shared private let walletRestoreGate = WalletBackupRestoreGate.keychain private let walletBackupWriter = WalletBackupWriter() @MainActor weak var usdtWallet: UsdtWalletManager? private var backupJobs: [BackupCategory: Task] = [:] - private var runningBackupTasks: [BackupCategory: Task] = [:] + private var runningBackupTasks: [BackupCategory: Task] = [:] private var cancellables = Set() private var periodicCheckTask: Task? private var isObserving = false @@ -108,7 +119,9 @@ class BackupService { private let defaults: UserDefaults private let backupData: ((BackupCategory) async throws -> Data)? private let uploadBackup: (String, Data) async throws -> Void + private let waitForBackupDelay: () async throws -> Void private let backupStatusesKey = "backupStatuses" + private var backupVersions: [BackupCategory: UInt64] = [:] private let statusUpdateQueue = DispatchQueue(label: "backup-service-status-update", qos: .userInitiated) private let stateQueue = DispatchQueue(label: "backup-service-state", qos: .userInitiated) @@ -134,11 +147,15 @@ class BackupService { backupData: ((BackupCategory) async throws -> Data)? = nil, uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in _ = try await VssBackupClient.shared.putObject(key: key, data: data) + }, + waitForBackupDelay: @escaping () async throws -> Void = { + try await Task.sleep(nanoseconds: 5_000_000_000) } ) { self.defaults = defaults self.backupData = backupData self.uploadBackup = uploadBackup + self.waitForBackupDelay = waitForBackupDelay let statuses = getAllBackupStatuses() var clearedStatuses = statuses for category in BackupCategory.allCases { @@ -159,7 +176,6 @@ class BackupService { // MARK: - Constants - private static let backupDebounce: TimeInterval = 5.0 // 5 seconds private static let backupFailureCheckInterval: TimeInterval = 60.0 // 1 minute private static let failedBackupCheckTime: UInt64 = 30 * 60 // 30 minutes in seconds private static let failedBackupNotificationInterval: UInt64 = 10 * 60 // 10 minutes in seconds @@ -194,7 +210,7 @@ class BackupService { func stopObservingBackups() async { let shouldStop = try? await ServiceQueue.background(.backup) { - guard self.isObserving else { return false } + let wasObserving = self.isObserving self.isObserving = false self.backupJobs.values.forEach { $0.cancel() } @@ -203,7 +219,7 @@ class BackupService { self.runningBackupTasks.removeAll() self.periodicCheckTask?.cancel() self.periodicCheckTask = nil - return true + return wasObserving } await walletBackupWriter.waitUntilIdle() @@ -212,69 +228,73 @@ class BackupService { Logger.debug("Stopped observing backup statuses and data store changes", context: "BackupService") } - func triggerBackup(category: BackupCategory) async { - let existingTask = try? await ServiceQueue.background(.backup) { self.runningBackupTasks[category] } - if let existingTask, !existingTask.isCancelled { - return + @discardableResult + func triggerBackup(category: BackupCategory) async -> BackupOutcome { + let backupTask = try? await ServiceQueue.background(.backup) { [self] () -> Task? in + guard runningBackupTasks[category] == nil else { return nil } + let backupTask = Task { await self.runBackup(category: category) } + runningBackupTasks[category] = backupTask + return backupTask } - let backupTask = Task { - Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService") + guard let backupTask else { return .busy } + return await withTaskCancellationHandler { + await backupTask.value + } onCancel: { + backupTask.cancel() + } + } + private func runBackup(category: BackupCategory) async -> BackupOutcome { + Logger.debug("Backup starting for: '\(category.rawValue)'", context: "BackupService") + var outcome = BackupOutcome.failed + var requiredAtStart: UInt64 = 0 + var versionAtStart: UInt64 = 0 + updateBackupStatus(category: category) { status in + requiredAtStart = max(status.required, status.synced + 1, UInt64(Date().timeIntervalSince1970)) + versionAtStart = self.backupVersions[category, default: 0] + return BackupItemStatus(synced: status.synced, required: requiredAtStart, running: true) + } + + do { + if category == .wallet { + try await PaykitPaymentActivity.shared.waitUntilIdle() + try await persistWalletBackup() + } else { + let data = try await getBackupDataBytes(category: category) + try await uploadBackup(category.rawValue, data) + } + updateBackupStatus(category: category) { status in + let changed = self.backupVersions[category, default: 0] != versionAtStart + return BackupItemStatus( + synced: changed ? min(requiredAtStart, status.required - 1) : requiredAtStart, + required: status.required, + running: false + ) + } + outcome = .completed + Logger.info("Backup succeeded for: '\(category.rawValue)'", context: "BackupService") + } catch is CancellationError { updateBackupStatus(category: category) { status in BackupItemStatus( synced: status.synced, - required: UInt64(Date().timeIntervalSince1970), - running: true + required: category == .wallet ? status.required : status.synced, + running: false ) } - - do { - if category == .wallet { - try await PaykitPaymentActivity.shared.waitUntilIdle() - try await persistWalletBackup() - } else { - let data = try await getBackupDataBytes(category: category) - try await uploadBackup(category.rawValue, data) - } - - updateBackupStatus(category: category) { status in - BackupItemStatus( - synced: UInt64(Date().timeIntervalSince1970), - required: status.required, - running: false - ) - } - - Logger.info("Backup succeeded for: '\(category.rawValue)'", context: "BackupService") - } catch is CancellationError { - updateBackupStatus(category: category) { status in - BackupItemStatus( - synced: status.synced, - required: category == .wallet ? status.required : status.synced, - running: false - ) - } - } catch { - updateBackupStatus(category: category) { status in - BackupItemStatus( - synced: status.synced, - required: status.required, - running: false - ) - } + } catch { + updateBackupStatus(category: category) { status in + BackupItemStatus(synced: status.synced, required: status.required, running: false) + } + if error is WalletBackupSnapshotError { + outcome = .snapshotChanged + Logger.warn("Wallet backup deferred after three changing snapshots", context: "BackupService") + } else { Logger.error("Backup failed for: '\(category.rawValue)': \(error)", context: "BackupService") } - - _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) } - } - - try? await ServiceQueue.background(.backup) { self.runningBackupTasks[category] = backupTask } - await withTaskCancellationHandler { - await backupTask.value - } onCancel: { - backupTask.cancel() } + _ = try? await ServiceQueue.background(.backup) { self.runningBackupTasks.removeValue(forKey: category) } + return outcome } func persistWalletBackup(usdt: String? = nil) async throws { @@ -282,10 +302,12 @@ class BackupService { try await walletBackupWriter.write { guard !self.shouldSkipBackup() else { throw UsdtError.BackupUnavailable } let data = try await self.getBackupDataBytes(category: .wallet, usdt: usdt) + try Task.checkCancellation() + guard !self.shouldSkipBackup() else { throw UsdtError.BackupUnavailable } try await self.uploadBackup(BackupCategory.wallet.rawValue, data) } } catch { - markBackupRequired(category: .wallet) + markBackupRequired(category: .wallet, schedule: !(error is CancellationError)) throw error } } @@ -514,55 +536,9 @@ class BackupService { .store(in: &self.cancellables) } - // TRANSFERS - TransferStorage.shared.transfersChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .wallet) - } - .store(in: &cancellables) - - // PRIVATE PAYKIT WALLET DATA - PrivatePaykitAddressReservationStore.walletBackupDataChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .wallet) - } - .store(in: &cancellables) - - PrivatePaykitService.walletBackupDataChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .wallet) - } - .store(in: &cancellables) - - PaykitSdkService.walletBackupDataChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .wallet) - } - .store(in: &cancellables) - - PaykitSubscriptionStateStore.walletBackupDataChangedPublisher - .merge(with: PaykitPaymentRequestIdStore.walletBackupDataChangedPublisher) - .merge(with: PaykitPaymentProofService.proofStateChangedPublisher) - .merge(with: OnchainSendAttemptStore.walletBackupDataChangedPublisher) - .merge(with: UsdtWalletManager.walletBackupDataChangedPublisher) - .merge(with: PaykitUsdtPaymentService.walletBackupDataChangedPublisher) - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - guard let self, !self.shouldSkipBackup() else { return } - markBackupRequired(category: .wallet) - } - .store(in: &cancellables) - - WatchOnlyAccountStore.walletBackupDataChangedPublisher - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + // Record requirements synchronously; the scheduled upload already has a debounce. + WalletBackupChangeTracker.localChanges + .merge(with: PaykitSdkService.walletBackupDataChangedPublisher) .sink { [weak self] _ in guard let self, !self.shouldSkipBackup() else { return } markBackupRequired(category: .wallet) @@ -659,15 +635,17 @@ class BackupService { } } - private func markBackupRequired(category: BackupCategory) { + func markBackupRequired(category: BackupCategory, schedule: Bool = true) { updateBackupStatus(category: category) { status in - BackupItemStatus( + self.backupVersions[category, default: 0] &+= 1 + return BackupItemStatus( synced: status.synced, - required: UInt64(Date().timeIntervalSince1970), - running: true + required: max(status.required, status.synced + 1, UInt64(Date().timeIntervalSince1970)), + running: schedule || status.running ) } + guard schedule else { return } Task { let status = getBackupStatus(category: category) if status.isRequired && !shouldSkipBackup() { @@ -685,19 +663,8 @@ class BackupService { } private func scheduleBackup(category: BackupCategory) async { - let hasExistingTask = try? await ServiceQueue.background(.backup) { - if let existingTask = self.backupJobs[category], !existingTask.isCancelled { - return true - } - return false - } - - if hasExistingTask == true { - return - } - - let currentStatus = getBackupStatus(category: category) - if !currentStatus.running { + try? await ServiceQueue.background(.backup) { [self] in + guard backupJobs[category] == nil else { return } updateBackupStatus(category: category) { status in BackupItemStatus( synced: status.synced, @@ -705,39 +672,31 @@ class BackupService { running: true ) } + backupJobs[category] = Task { await self.runScheduledBackup(category: category) } } + } - let backupTask = Task { - defer { - Task { - try? await ServiceQueue.background(.backup) { - self.backupJobs.removeValue(forKey: category) - } - } - } - - try? await Task.sleep(nanoseconds: UInt64(Self.backupDebounce * 1_000_000_000)) - - guard !Task.isCancelled else { - updateBackupStatus(category: category) { status in - BackupItemStatus(synced: status.synced, required: status.required, running: false) - } - return - } - - let status = getBackupStatus(category: category) - if status.isRequired && !shouldSkipBackup() { - await triggerBackup(category: category) - } else { - updateBackupStatus(category: category) { status in - BackupItemStatus(synced: status.synced, required: status.required, running: false) - } + private func runScheduledBackup(category: BackupCategory) async { + var outcome = BackupOutcome.failed + while !Task.isCancelled { + do { + try await waitForBackupDelay() + try Task.checkCancellation() + } catch { + break } + guard getBackupStatus(category: category).isRequired && !shouldSkipBackup() else { break } + outcome = await triggerBackup(category: category) + if category != .wallet || !outcome.canRetryPendingWalletBackup || !getBackupStatus(category: category).isRequired { break } } - - try? await ServiceQueue.background(.backup) { - self.backupJobs[category]?.cancel() - self.backupJobs[category] = backupTask + _ = try? await ServiceQueue.background(.backup) { self.backupJobs.removeValue(forKey: category) } + updateBackupStatus(category: category) { status in + BackupItemStatus(synced: status.synced, required: status.required, running: false) + } + if category == .wallet, outcome.canRetryPendingWalletBackup, !Task.isCancelled, + getBackupStatus(category: category).isRequired, !shouldSkipBackup() + { + await scheduleBackup(category: category) } } @@ -747,6 +706,7 @@ class BackupService { let hasFailedBackups = BackupCategory.allCases.contains { category in let status = getBackupStatus(category: category) return status.isRequired && + currentTime > status.required && (currentTime - status.required) > Self.failedBackupCheckTime } @@ -911,36 +871,44 @@ class BackupService { return encoded case .wallet: - try walletRestoreGate.requireReplacementBackupAllowed() - let transfers = try TransferStorage.shared.getAll() - let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot() - let paykitSdkBackupState = try await PrivatePaykitService.shared.backupSnapshot() - let watchOnlyAccountSnapshot = try WatchOnlyAccountStore.backupSnapshot() - let usdtSnapshot: String? - if let usdt { - usdtSnapshot = usdt - } else if let wallet = await usdtWallet { - usdtSnapshot = try await wallet.backupSnapshot() - } else { - guard !Env.isUsdtEnabled else { throw UsdtError.BackupUnavailable } - usdtSnapshot = nil + let changes = WalletBackupChangeTracker() + let identity = try await PaykitSdkService.shared.backupIdentity() + return try await changes.capture { + try walletRestoreGate.requireReplacementBackupAllowed() + let paykitBackup = try await PrivatePaykitService.shared.backupSnapshot() + let transfers = try TransferStorage.shared.getAll() + let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot() + let watchOnlyAccountSnapshot = try WatchOnlyAccountStore.backupSnapshot() + let usdtSnapshot: String? + if let usdt { + usdtSnapshot = usdt + } else if let wallet = await usdtWallet { + usdtSnapshot = try await wallet.backupSnapshot() + } else { + guard !Env.isUsdtEnabled else { throw UsdtError.BackupUnavailable } + usdtSnapshot = nil + } + var paymentState = try await PaykitPaymentProofService.shared.backupSnapshot( + wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) + ) + paymentState.usdt = try await PaykitUsdtPaymentService.shared.backupSnapshot() + let payload = WalletBackupV1( + version: 1, + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), + transfers: transfers, + privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType, + paykitSdkBackupState: paykitBackup.state, + watchOnlyAccounts: watchOnlyAccountSnapshot.accounts, + watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState, + usdtWallet: usdtSnapshot, + paykitPaymentState: paymentState + ) + let data = try JSONEncoder().encode(payload) + // Proof/attempt locks have been released before reacquiring the SDK lock. + try await PaykitSdkService.shared.validateBackupSnapshot(version: paykitBackup.sdkVersion, identity: identity) + try walletRestoreGate.requireReplacementBackupAllowed() + return data } - var paymentState = try await PaykitPaymentProofService.shared.backupSnapshot( - wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0) - ) - paymentState.usdt = try await PaykitUsdtPaymentService.shared.backupSnapshot() - let payload = WalletBackupV1( - version: 1, - createdAt: UInt64(Date().timeIntervalSince1970 * 1000), - transfers: transfers, - privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType, - paykitSdkBackupState: paykitSdkBackupState, - watchOnlyAccounts: watchOnlyAccountSnapshot.accounts, - watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState, - usdtWallet: usdtSnapshot, - paykitPaymentState: paymentState - ) - return try JSONEncoder().encode(payload) case .metadata: let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) @@ -1081,6 +1049,56 @@ class BackupService { } } +enum WalletBackupSnapshotError: Error { + case changed +} + +final class WalletBackupChangeTracker { + static var localChanges: AnyPublisher { + Publishers.MergeMany([ + TransferStorage.shared.transfersChangedPublisher.map { _ in () }.eraseToAnyPublisher(), + PrivatePaykitAddressReservationStore.walletBackupDataChangedPublisher, + PrivatePaykitService.walletBackupDataChangedPublisher, + PaykitSubscriptionStateStore.walletBackupDataChangedPublisher, + PaykitPaymentRequestIdStore.walletBackupDataChangedPublisher, + PaykitPaymentProofService.proofStateChangedPublisher, + OnchainSendAttemptStore.walletBackupDataChangedPublisher, + UsdtWalletManager.walletBackupDataChangedPublisher, + PaykitUsdtPaymentService.walletBackupDataChangedPublisher, + WatchOnlyAccountStore.walletBackupDataChangedPublisher, + ]).eraseToAnyPublisher() + } + + private let lock = NSLock() + private var version: UInt64 = 0 + private var subscription: AnyCancellable? + + init(changes: AnyPublisher = localChanges) { + subscription = changes.sink { [weak self] _ in + guard let self else { return } + lock.withLock { self.version &+= 1 } + } + } + + func capture(_ snapshot: () async throws -> T) async throws -> T { + for attempt in 1 ... 3 { + try Task.checkCancellation() + let initialVersion = lock.withLock { version } + do { + let result = try await snapshot() + try Task.checkCancellation() + guard lock.withLock({ version == initialVersion }) else { throw WalletBackupSnapshotError.changed } + return result + } catch WalletBackupSnapshotError.changed { + try Task.checkCancellation() + Logger.debug("Wallet snapshot changed during capture (attempt \(attempt)/3)", context: "BackupService") + if attempt == 3 { throw WalletBackupSnapshotError.changed } + } + } + throw WalletBackupSnapshotError.changed + } +} + actor WalletBackupWriter { private var previous: Task? @@ -1096,6 +1114,10 @@ actor WalletBackupWriter { try await operation() } previous = task - try await task.value + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } } } diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index da15fb70d..589b4c5cf 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -3,13 +3,15 @@ import Foundation // MARK: - Backup extension PrivatePaykitService { - func backupSnapshot() async throws -> String? { + func backupSnapshot() async throws -> (state: String?, sdkVersion: UInt64) { + let initialSdkVersion = await PaykitSdkService.shared.backupStateVersion guard try await PaykitSdkService.shared.currentPublicKey() != nil else { - return nil + return (nil, initialSdkVersion) } // Wallet backups may be required before broadcast; they cannot wait for payment to finish. - let backup = try await Backup( - sdkState: PaykitSdkService.shared.exportBackupState(priority: .interactive), + let exported = try await PaykitSdkService.shared.exportBackupSnapshot(priority: .interactive) + let backup = Backup( + sdkState: exported.state, consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in contactState.consumedPrivatePaymentListVersion } @@ -18,7 +20,7 @@ extension PrivatePaykitService { guard let encoded = String(data: data, encoding: .utf8) else { throw CocoaError(.fileWriteInapplicableStringEncoding) } - return encoded + return (encoded, exported.version) } func restoreBackup(_ backup: String?) async throws { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index f31ced339..95de17122 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -422,6 +422,16 @@ actor PaykitSdkService { let backupRevision: String } + struct BackupExport { + let state: String + let version: UInt64 + } + + struct BackupIdentity: Equatable { + let runtimeGeneration: UInt64 + let walletGeneration: Int + } + static let shared = PaykitSdkService() /// Maximum plaintext size accepted by Paykit's pubky-noise transport. static let maximumMessageBytes = 1000 @@ -449,6 +459,8 @@ actor PaykitSdkService { private var sdk: PaykitSdk? private var cachedPaykitKey: (publicKey: String, generation: UInt64)? private var cachedBackupState: BackupStateSnapshot? + private(set) var backupStateVersion: UInt64 = 0 + private var runtimeGeneration: UInt64 = 0 init( sdkFactory: (() throws -> PaykitSdk)? = nil, @@ -1248,17 +1260,33 @@ actor PaykitSdkService { } func exportBackupState(priority: PaykitSdkOperationLock.Priority = .background) async throws -> String { + try await exportBackupSnapshot(priority: priority).state + } + + func backupIdentity() throws -> BackupIdentity { + try BackupIdentity(runtimeGeneration: runtimeGeneration, walletGeneration: operationLock.walletGeneration()) + } + + func validateBackupSnapshot(version: UInt64, identity: BackupIdentity) async throws { + try await operationLock.withLock(priority: .interactive) { + guard try backupIdentity() == identity else { throw PubkyServiceError.identityChanged } + guard backupStateVersion == version else { throw WalletBackupSnapshotError.changed } + } + } + + func exportBackupSnapshot(priority: PaykitSdkOperationLock.Priority = .background) async throws -> BackupExport { let generation = try operationLock.walletGeneration() let instance = try handle() while true { if priority == .background { try await PaykitPaymentActivity.shared.waitUntilIdle() } - let exported: String? = try await withSdk(priority: priority) { sdk in + let exported: BackupExport? = try await withSdk(priority: priority) { sdk in guard sdk === instance, try operationLock.walletGeneration() == generation else { throw PubkyServiceError.identityChanged } // Payment may have started while this export waited for the SDK lock. if priority == .background, await PaykitPaymentActivity.shared.isActive { return nil } - return try await sdk.exportBackupString() + let state = try await sdk.exportBackupString() + return BackupExport(state: state, version: backupStateVersion) } if let exported { return exported } } @@ -1477,10 +1505,12 @@ actor PaykitSdkService { } private func markWalletBackupDataChanged() { + backupStateVersion &+= 1 Self.walletBackupDataChangedSubject.send() } private func resetRuntime() { + runtimeGeneration &+= 1 sdk = nil cachedPaykitKey = nil cachedBackupState = nil diff --git a/BitkitTests/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift index aa6fb9b16..f8fcd2de7 100644 --- a/BitkitTests/BackupServiceTests.swift +++ b/BitkitTests/BackupServiceTests.swift @@ -5,6 +5,255 @@ import XCTest @MainActor final class BackupServiceTests: XCTestCase { + func testChangesDuringCaptureAndUploadRemainRequired() async throws { + for changeDuringCapture in [true, false] { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var service: BackupService! + var shouldChange = true + service = BackupService( + defaults: defaults, + backupData: { _ in + if changeDuringCapture, shouldChange { service.markBackupRequired(category: .wallet) } + return Data("snapshot".utf8) + }, + uploadBackup: { _, _ in + if !changeDuringCapture, shouldChange { service.markBackupRequired(category: .wallet) } + } + ) + + await service.triggerBackup(category: .wallet) + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + shouldChange = false + await service.triggerBackup(category: .wallet) + XCTAssertFalse(service.getBackupStatus(category: .wallet).isRequired) + service.setWiping(true) + await service.stopObservingBackups() + } + } + + func testWalletCaptureDiscardsMixedStateAndRecapturesLatestValues() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let tracker = WalletBackupChangeTracker() + var state = 0 + var attempts = 0 + let snapshot = try await tracker.capture { + attempts += 1 + let earlyRead = state + if attempts == 1 { + state = 1 + try Bitkit.WatchOnlyAccountStore.save([], defaults: defaults) + } + await Task.yield() + return [earlyRead, state] + } + XCTAssertEqual(snapshot, [1, 1]) + XCTAssertEqual(attempts, 2) + } + + func testAutomaticBackupRetriesContentionAndChangesDuringUpload() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let changes = PassthroughSubject() + let tracker = WalletBackupChangeTracker(changes: changes.eraseToAnyPublisher()) + let completed = expectation(description: "All wallet changes backed up") + var captures = 0 + var uploads: [Data] = [] + var service: BackupService! + service = BackupService(defaults: defaults, backupData: { _ in + try await tracker.capture { + captures += 1 + if captures <= 3 { changes.send() } + return Data("snapshot-\(captures)".utf8) + } + }, uploadBackup: { _, data in + uploads.append(data) + if uploads.count == 1 { service.markBackupRequired(category: .wallet) } + }) + let subscription = service.backupStatusesPublisher + .filter { $0[.wallet].map { !$0.isRequired && !$0.running } == true } + .first() + .sink { _ in completed.fulfill() } + defer { subscription.cancel() } + + await service.triggerBackup(category: .wallet) + XCTAssertEqual(captures, 3) + XCTAssertTrue(uploads.isEmpty) + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + await fulfillment(of: [completed], timeout: 15) + XCTAssertEqual(uploads, [Data("snapshot-4".utf8), Data("snapshot-5".utf8)]) + service.setWiping(true) + await service.stopObservingBackups() + } + + func testWalletCaptureBoundsContentionAndDoesNotRetryOtherFailures() async { + let changes = PassthroughSubject() + let tracker = WalletBackupChangeTracker(changes: changes.eraseToAnyPublisher()) + var attempts = 0 + do { + try await tracker.capture { + attempts += 1 + changes.send() + } + XCTFail("Expected unstable snapshot to be rejected") + } catch WalletBackupSnapshotError.changed { + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(attempts, 3) + + for identityChanged in [true, false] { + attempts = 0 + do { + try await tracker.capture { + attempts += 1 + changes.send() + if identityChanged { throw PubkyServiceError.identityChanged } + throw OnchainSendAttemptError.unresolved + } + XCTFail("Expected capture failure") + } catch PubkyServiceError.identityChanged { + XCTAssertTrue(identityChanged) + } catch OnchainSendAttemptError.unresolved { + XCTAssertFalse(identityChanged) + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(attempts, 1) + } + } + + func testAutomaticBackupDoesNotRepeatUnrelatedFailures() async throws { + let firstAttempts = expectation(description: "Scheduled backups attempted") + firstAttempts.expectedFulfillmentCount = 3 + let repeatedAttempt = expectation(description: "Unrelated failure retried") + repeatedAttempt.isInverted = true + var services: [BackupService] = [] + var suites: [(UserDefaults, String)] = [] + defer { suites.forEach { $0.0.removePersistentDomain(forName: $0.1) } } + for failure in 0 ..< 3 { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + suites.append((defaults, suite)) + var attempts = 0 + let service = BackupService(defaults: defaults, backupData: { _ in + attempts += 1 + if attempts == 1 { firstAttempts.fulfill() } else { repeatedAttempt.fulfill() } + if failure == 0 { throw PubkyServiceError.identityChanged } + if failure == 1 { throw OnchainSendAttemptError.unresolved } + return Data("snapshot".utf8) + }, uploadBackup: { _, _ in throw URLError(.notConnectedToInternet) }) + services.append(service) + service.markBackupRequired(category: .wallet) + } + + await fulfillment(of: [firstAttempts], timeout: 8) + await fulfillment(of: [repeatedAttempt], timeout: 6) + for service in services { + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .wallet).running) + service.setWiping(true) + await service.stopObservingBackups() + } + } + + func testScheduledBackupRetainsFollowupWhileManualUploadIsBusy() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let manualUploadStarted = expectation(description: "Manual upload held") + let firstDelayStarted = expectation(description: "Scheduled backup held") + let secondDelayStarted = expectation(description: "Busy scheduled backup retained") + let followupUploaded = expectation(description: "Newer state uploaded") + let (manualUpload, releaseManualUpload) = AsyncStream.makeStream() + let (firstDelay, releaseFirstDelay) = AsyncStream.makeStream() + let (secondDelay, releaseSecondDelay) = AsyncStream.makeStream() + defer { + releaseManualUpload.finish() + releaseFirstDelay.finish() + releaseSecondDelay.finish() + } + var uploads = 0 + var delays = 0 + let service = BackupService(defaults: defaults, backupData: { _ in Data("snapshot".utf8) }, uploadBackup: { _, _ in + uploads += 1 + if uploads == 1 { + manualUploadStarted.fulfill() + for await _ in manualUpload {} + } else { + followupUploaded.fulfill() + } + }, waitForBackupDelay: { + delays += 1 + if delays == 1 { + firstDelayStarted.fulfill() + for await _ in firstDelay {} + } else { + secondDelayStarted.fulfill() + for await _ in secondDelay {} + } + }) + let manual = Task { await service.triggerBackup(category: .wallet) } + await fulfillment(of: [manualUploadStarted], timeout: 2) + service.markBackupRequired(category: .wallet) + await fulfillment(of: [firstDelayStarted], timeout: 2) + releaseFirstDelay.finish() + await fulfillment(of: [secondDelayStarted], timeout: 2) + releaseManualUpload.finish() + _ = await manual.value + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + releaseSecondDelay.finish() + await fulfillment(of: [followupUploaded], timeout: 2) + XCTAssertEqual(uploads, 2) + service.setWiping(true) + await service.stopObservingBackups() + } + + func testCancellationWinsOverFinalSnapshotContention() async { + let tracker = WalletBackupChangeTracker(changes: Empty().eraseToAnyPublisher()) + let capture = Task { () in + var attempts = 0 + try await tracker.capture { + attempts += 1 + if attempts == 3 { withUnsafeCurrentTask { $0?.cancel() } } + throw WalletBackupSnapshotError.changed + } + } + do { + try await capture.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testCancelledWalletCaptureDoesNotUploadOrRetry() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let started = expectation(description: "Wallet capture started") + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let service = BackupService(defaults: defaults, backupData: { _ in + started.fulfill() + for await _ in gate {} + return Data("cancelled".utf8) + }, uploadBackup: { _, _ in XCTFail("Cancelled capture must not upload") }) + let backup = Task { await service.triggerBackup(category: .wallet) } + await fulfillment(of: [started], timeout: 2) + backup.cancel() + _ = await backup.value + XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) + XCTAssertFalse(service.getBackupStatus(category: .wallet).running) + service.setWiping(true) + await service.stopObservingBackups() + } + func testCancelledDeferredWalletBackupRemainsRequiredAndRetryCompletes() async throws { let suite = "BackupServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index b82afbef3..10beb4593 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -3,6 +3,63 @@ import Paykit import XCTest final class PaykitSdkOperationLockTests: XCTestCase { + func testBackupExportPairsVersionAfterQueuedMutationAndRejectsLaterChanges() async throws { + let sdk = PublicReadSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let identity = try await service.backupIdentity() + let initialVersion = await service.backupStateVersion + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "SDK mutation started") + sdk.paymentMutation = { _ in + started.fulfill() + for await _ in gate {} + } + let mutation = Task { try await service.claimPaymentRequestForExecution(counterparty: "peer", paymentRequestId: "request") } + await fulfillment(of: [started], timeout: 2) + let export = Task { try await service.exportBackupSnapshot(priority: .interactive) } + try await Task.sleep(for: .milliseconds(50)) + release.finish() + do { + _ = try await mutation.value + XCTFail("Expected partial mutation failure") + } catch PaymentMutationReached.reached {} + + let snapshot = try await export.value + XCTAssertEqual(snapshot.state, "backup") + XCTAssertGreaterThan(snapshot.version, initialVersion) + try await service.validateBackupSnapshot(version: snapshot.version, identity: identity) + + sdk.paymentMutation = { _ in } + do { + _ = try await service.claimPaymentRequestForExecution(counterparty: "peer", paymentRequestId: "request") + } catch PaymentMutationReached.reached {} + do { + try await service.validateBackupSnapshot(version: snapshot.version, identity: identity) + XCTFail("Expected later SDK change to invalidate the snapshot") + } catch WalletBackupSnapshotError.changed {} + } + + func testBackupIdentityFenceSurvivesRecaptureAfterRuntimeReplacement() async throws { + let sdk = PublicReadSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let identity = try await service.backupIdentity() + var captures = 0 + do { + try await WalletBackupChangeTracker().capture { + captures += 1 + if captures == 1 { + await service.clearState() + throw WalletBackupSnapshotError.changed + } + let snapshot = try await service.exportBackupSnapshot(priority: .interactive) + try await service.validateBackupSnapshot(version: snapshot.version, identity: identity) + } + XCTFail("A fresh content baseline must not acknowledge a different identity") + } catch PubkyServiceError.identityChanged {} + XCTAssertEqual(captures, 2) + } + private actor Recorder { private(set) var events: [String] = [] diff --git a/changelog.d/next/950.fixed.md b/changelog.d/next/950.fixed.md new file mode 100644 index 000000000..7a02f8f41 --- /dev/null +++ b/changelog.d/next/950.fixed.md @@ -0,0 +1 @@ +Keep wallet backups consistent during Paykit activity and preserve pending changes until they are backed up. diff --git a/journeys/backup-restore/wallet-snapshot-consistency.md b/journeys/backup-restore/wallet-snapshot-consistency.md new file mode 100644 index 000000000..1d8bf45b0 --- /dev/null +++ b/journeys/backup-restore/wallet-snapshot-consistency.md @@ -0,0 +1,32 @@ +# Wallet snapshot consistency + +Use an isolated test wallet and backup destination. Controlled SDK/capture/upload +gates and inspection of stored backup envelopes are not journey Capabilities; +these checks require a dedicated fault-injection fixture. Do not inject into a +funded personal wallet or shorten payment safety waits. + +1. Queue a wallet backup behind a Paykit mutation, then release the mutation. + Verify the exported SDK state includes it and a consistent snapshot uploads. +2. Hold snapshot capture after the SDK export, change private payment state, and + release capture. Verify the discarded snapshot never uploads and a fresh, + consistent capture succeeds when activity settles. +3. Keep changing state through each capture attempt. Verify no mixed snapshot + uploads, the wallet remains pending backup, and automatic backup succeeds + after the fixture becomes quiet without requiring another state change. +4. Change state during a held upload. Verify completion does not mark the newer + state backed up, and the subsequent upload contains that state. +5. Cancel capture or replace the identity while capture/retry is held. Verify no + mixed-identity snapshot uploads. Verify SDK/export/upload failures and unsigned + payment attempts still fail without being reported as successful backups. +6. Restore a successful captured envelope into an isolated fixture. Verify its + SDK state, private consumption state, reservations, proofs and signed active + payment attempt agree. Do not broadcast a payment as part of this check. + +On iOS, place fixture gates at `BackupService` wallet capture/upload and +`PaykitSdkService` export. Include the `WalletBackupRestoreGate` pending-restore +check and `PaykitPaymentProofService` active-attempt guard. Use a dedicated +simulator through XcodeBuildMCP; do not reset or reuse another test's simulator. + +Record the tested commit, gate locations, attempts and upload counts, and the +restored state. Unit coverage does not establish a device pass or a maximum +backup delay during sustained activity. From 0387da32cf239b7dd14eca265af6c6f1d8918e87 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 15 Oct 2026 17:39:17 -0500 Subject: [PATCH 2/2] fix: retain pending backups across all categories --- Bitkit/Services/BackupService.swift | 6 +- BitkitTests/BackupServiceTests.swift | 166 +++++++++++------- .../wallet-snapshot-consistency.md | 4 + 3 files changed, 108 insertions(+), 68 deletions(-) diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index f15dad1a5..0d2189b02 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -98,7 +98,7 @@ class BackupService { case busy case failed - var canRetryPendingWalletBackup: Bool { + var canRetryPendingBackup: Bool { self == .completed || self == .snapshotChanged || self == .busy } } @@ -687,13 +687,13 @@ class BackupService { } guard getBackupStatus(category: category).isRequired && !shouldSkipBackup() else { break } outcome = await triggerBackup(category: category) - if category != .wallet || !outcome.canRetryPendingWalletBackup || !getBackupStatus(category: category).isRequired { break } + if !outcome.canRetryPendingBackup || !getBackupStatus(category: category).isRequired { break } } _ = try? await ServiceQueue.background(.backup) { self.backupJobs.removeValue(forKey: category) } updateBackupStatus(category: category) { status in BackupItemStatus(synced: status.synced, required: status.required, running: false) } - if category == .wallet, outcome.canRetryPendingWalletBackup, !Task.isCancelled, + if outcome.canRetryPendingBackup, !Task.isCancelled, getBackupStatus(category: category).isRequired, !shouldSkipBackup() { await scheduleBackup(category: category) diff --git a/BitkitTests/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift index f8fcd2de7..bd4a6e762 100644 --- a/BitkitTests/BackupServiceTests.swift +++ b/BitkitTests/BackupServiceTests.swift @@ -54,6 +54,38 @@ final class BackupServiceTests: XCTestCase { XCTAssertEqual(attempts, 2) } + func testScheduledBackupUploadsChangesFromEveryCategory() async throws { + for category in BackupCategory.allCases where category != .lightningConnections { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let completed = expectation(description: "Latest \(category.rawValue) state backed up") + var state = Data("initial".utf8) + var uploads: [Data] = [] + var service: BackupService! + service = BackupService(defaults: defaults, backupData: { _ in state }, uploadBackup: { key, data in + XCTAssertEqual(key, category.rawValue) + uploads.append(data) + if uploads.count == 1 { + state = Data("updated".utf8) + service.markBackupRequired(category: category, schedule: false) + } + }, waitForBackupDelay: { await Task.yield() }) + let subscription = service.backupStatusesPublisher + .filter { $0[category].map { !$0.isRequired && !$0.running } == true } + .first() + .sink { _ in completed.fulfill() } + defer { subscription.cancel() } + + service.markBackupRequired(category: category) + await fulfillment(of: [completed], timeout: 2) + XCTAssertEqual(uploads, [Data("initial".utf8), Data("updated".utf8)], category.rawValue) + XCTAssertFalse(service.getBackupStatus(category: category).isRequired, category.rawValue) + service.setWiping(true) + await service.stopObservingBackups() + } + } + func testAutomaticBackupRetriesContentionAndChangesDuringUpload() async throws { let suite = "BackupServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) @@ -129,88 +161,92 @@ final class BackupServiceTests: XCTestCase { func testAutomaticBackupDoesNotRepeatUnrelatedFailures() async throws { let firstAttempts = expectation(description: "Scheduled backups attempted") - firstAttempts.expectedFulfillmentCount = 3 + firstAttempts.expectedFulfillmentCount = 6 let repeatedAttempt = expectation(description: "Unrelated failure retried") repeatedAttempt.isInverted = true - var services: [BackupService] = [] + var services: [(BackupService, BackupCategory)] = [] var suites: [(UserDefaults, String)] = [] defer { suites.forEach { $0.0.removePersistentDomain(forName: $0.1) } } - for failure in 0 ..< 3 { - let suite = "BackupServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - suites.append((defaults, suite)) - var attempts = 0 - let service = BackupService(defaults: defaults, backupData: { _ in - attempts += 1 - if attempts == 1 { firstAttempts.fulfill() } else { repeatedAttempt.fulfill() } - if failure == 0 { throw PubkyServiceError.identityChanged } - if failure == 1 { throw OnchainSendAttemptError.unresolved } - return Data("snapshot".utf8) - }, uploadBackup: { _, _ in throw URLError(.notConnectedToInternet) }) - services.append(service) - service.markBackupRequired(category: .wallet) + for category in [BackupCategory.wallet, .settings] { + for failure in 0 ..< 3 { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + suites.append((defaults, suite)) + var attempts = 0 + let service = BackupService(defaults: defaults, backupData: { _ in + attempts += 1 + if attempts == 1 { firstAttempts.fulfill() } else { repeatedAttempt.fulfill() } + if failure == 0 { throw PubkyServiceError.identityChanged } + if failure == 1 { throw OnchainSendAttemptError.unresolved } + return Data("snapshot".utf8) + }, uploadBackup: { _, _ in throw URLError(.notConnectedToInternet) }) + services.append((service, category)) + service.markBackupRequired(category: category) + } } await fulfillment(of: [firstAttempts], timeout: 8) await fulfillment(of: [repeatedAttempt], timeout: 6) - for service in services { - XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) - XCTAssertFalse(service.getBackupStatus(category: .wallet).running) + for (service, category) in services { + XCTAssertTrue(service.getBackupStatus(category: category).isRequired) + XCTAssertFalse(service.getBackupStatus(category: category).running) service.setWiping(true) await service.stopObservingBackups() } } func testScheduledBackupRetainsFollowupWhileManualUploadIsBusy() async throws { - let suite = "BackupServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let manualUploadStarted = expectation(description: "Manual upload held") - let firstDelayStarted = expectation(description: "Scheduled backup held") - let secondDelayStarted = expectation(description: "Busy scheduled backup retained") - let followupUploaded = expectation(description: "Newer state uploaded") - let (manualUpload, releaseManualUpload) = AsyncStream.makeStream() - let (firstDelay, releaseFirstDelay) = AsyncStream.makeStream() - let (secondDelay, releaseSecondDelay) = AsyncStream.makeStream() - defer { - releaseManualUpload.finish() + for category in [BackupCategory.wallet, .settings] { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let manualUploadStarted = expectation(description: "Manual upload held") + let firstDelayStarted = expectation(description: "Scheduled backup held") + let secondDelayStarted = expectation(description: "Busy scheduled backup retained") + let followupUploaded = expectation(description: "Newer state uploaded") + let (manualUpload, releaseManualUpload) = AsyncStream.makeStream() + let (firstDelay, releaseFirstDelay) = AsyncStream.makeStream() + let (secondDelay, releaseSecondDelay) = AsyncStream.makeStream() + defer { + releaseManualUpload.finish() + releaseFirstDelay.finish() + releaseSecondDelay.finish() + } + var uploads = 0 + var delays = 0 + let service = BackupService(defaults: defaults, backupData: { _ in Data("snapshot".utf8) }, uploadBackup: { _, _ in + uploads += 1 + if uploads == 1 { + manualUploadStarted.fulfill() + for await _ in manualUpload {} + } else { + followupUploaded.fulfill() + } + }, waitForBackupDelay: { + delays += 1 + if delays == 1 { + firstDelayStarted.fulfill() + for await _ in firstDelay {} + } else { + secondDelayStarted.fulfill() + for await _ in secondDelay {} + } + }) + let manual = Task { await service.triggerBackup(category: category) } + await fulfillment(of: [manualUploadStarted], timeout: 2) + service.markBackupRequired(category: category) + await fulfillment(of: [firstDelayStarted], timeout: 2) releaseFirstDelay.finish() + await fulfillment(of: [secondDelayStarted], timeout: 2) + releaseManualUpload.finish() + _ = await manual.value + XCTAssertTrue(service.getBackupStatus(category: category).isRequired) releaseSecondDelay.finish() + await fulfillment(of: [followupUploaded], timeout: 2) + XCTAssertEqual(uploads, 2) + service.setWiping(true) + await service.stopObservingBackups() } - var uploads = 0 - var delays = 0 - let service = BackupService(defaults: defaults, backupData: { _ in Data("snapshot".utf8) }, uploadBackup: { _, _ in - uploads += 1 - if uploads == 1 { - manualUploadStarted.fulfill() - for await _ in manualUpload {} - } else { - followupUploaded.fulfill() - } - }, waitForBackupDelay: { - delays += 1 - if delays == 1 { - firstDelayStarted.fulfill() - for await _ in firstDelay {} - } else { - secondDelayStarted.fulfill() - for await _ in secondDelay {} - } - }) - let manual = Task { await service.triggerBackup(category: .wallet) } - await fulfillment(of: [manualUploadStarted], timeout: 2) - service.markBackupRequired(category: .wallet) - await fulfillment(of: [firstDelayStarted], timeout: 2) - releaseFirstDelay.finish() - await fulfillment(of: [secondDelayStarted], timeout: 2) - releaseManualUpload.finish() - _ = await manual.value - XCTAssertTrue(service.getBackupStatus(category: .wallet).isRequired) - releaseSecondDelay.finish() - await fulfillment(of: [followupUploaded], timeout: 2) - XCTAssertEqual(uploads, 2) - service.setWiping(true) - await service.stopObservingBackups() } func testCancellationWinsOverFinalSnapshotContention() async { diff --git a/journeys/backup-restore/wallet-snapshot-consistency.md b/journeys/backup-restore/wallet-snapshot-consistency.md index 1d8bf45b0..0c5e7acfb 100644 --- a/journeys/backup-restore/wallet-snapshot-consistency.md +++ b/journeys/backup-restore/wallet-snapshot-consistency.md @@ -26,6 +26,10 @@ On iOS, place fixture gates at `BackupService` wallet capture/upload and `PaykitSdkService` export. Include the `WalletBackupRestoreGate` pending-restore check and `PaykitPaymentProofService` active-attempt guard. Use a dedicated simulator through XcodeBuildMCP; do not reset or reuse another test's simulator. +Also repeat the held-upload check for settings, widgets, activity, metadata and +Blocktank backups, including a scheduled backup overlapping a manual upload. +Each category must upload its pending changes after release without another +mutation. A genuine capture/upload failure must not enter an automatic retry loop. Record the tested commit, gate locations, attempts and upload counts, and the restored state. Unit coverage does not establish a device pass or a maximum